diff --git a/database.rules.json b/database.rules.json index 6af671d..00dfc2f 100644 --- a/database.rules.json +++ b/database.rules.json @@ -19,7 +19,7 @@ }, "mode": { ".write": "auth != null && auth.uid == data.parent().child('host').val() && newData.exists()", - ".validate": "newData.isString() && newData.val().matches(/^normal|ultraset|setchain$/)" + ".validate": "newData.isString() && newData.val().matches(/^normal|setchain|ultraset|ultra9$/)" }, "enableHint": { ".write": "auth != null && auth.uid == data.parent().child('host').val() && newData.exists()", @@ -33,7 +33,7 @@ "events": { "$eventId": { ".write": "!data.exists() && newData.exists() && auth != null && root.hasChild('games/' + $gameId + '/users/' + auth.uid) && root.child('games/' + $gameId + '/status').val() == 'ingame'", - ".validate": "newData.hasChildren(['user', 'time', 'c1', 'c2', 'c3']) && (newData.hasChild('c4') == (root.child('games/' + $gameId + '/mode').val() == 'ultraset'))", + ".validate": "newData.hasChildren(['user', 'time', 'c1', 'c2', 'c3']) && (newData.hasChild('c4') == (root.child('games/' + $gameId + '/mode').val().matches(/^ultraset|ultra9$/)))", "user": { ".validate": "newData.isString() && newData.val() == auth.uid" }, diff --git a/functions/src/game.ts b/functions/src/game.ts index bd7ef46..63b11eb 100644 --- a/functions/src/game.ts +++ b/functions/src/game.ts @@ -9,7 +9,7 @@ interface GameEvent { c4?: string; } -export type GameMode = "normal" | "setchain" | "ultraset"; +export type GameMode = "normal" | "setchain" | "ultraset" | "ultra9"; /** Generates a random 81-card deck using a Fisher-Yates shuffle. */ export function generateDeck() { @@ -80,7 +80,7 @@ export function findSet(deck: string[], gameMode: GameMode, old?: string[]) { if (old!.includes(c)) { return [c, deck[i], deck[j]]; } - } else if (gameMode === "ultraset") { + } else if (gameMode === "ultraset" || gameMode === "ultra9") { if (c in ultraConjugates) { return [...ultraConjugates[c], deck[i], deck[j]]; } @@ -91,15 +91,19 @@ export function findSet(deck: string[], gameMode: GameMode, old?: string[]) { return null; } -/** Check if cards are valid (all distinct and exist in deck) */ -function isValid(deck: Set, cards: string[]) { +/** Check if all cards are distinct */ +function hasDuplicates(cards: string[]) { for (let i = 0; i < cards.length; i++) { for (let j = i + 1; j < cards.length; j++) { - if (cards[i] === cards[j]) return false; + if (cards[i] === cards[j]) return true; } - if (!deck.has(cards[i])) return false; } - return true; + return false; +} + +/** Check if all cards exist in deck */ +function validCards(deck: Set, cards: string[]) { + return cards.every((c) => deck.has(c)); } /** Delete cards from deck */ @@ -107,42 +111,40 @@ function deleteCards(deck: Set, cards: string[]) { for (const c of cards) deck.delete(c); } -/** Replay game event for normal mode */ -function replayEventNormal(deck: Set, event: GameEvent) { +type ReplayFn = ( + deck: Set, + event: GameEvent, + history: GameEvent[] +) => boolean; + +/** Replay game event for normal and ultra modes */ +function replayEventCommon( + deck: Set, + event: GameEvent, + history: GameEvent[] +) { const cards = [event.c1, event.c2, event.c3]; - if (!isValid(deck, cards)) return false; + if (event.c4) cards.push(event.c4); + if (hasDuplicates(cards) || !validCards(deck, cards)) return false; deleteCards(deck, cards); return true; } /** Replay game event for setchain mode */ function replayEventChain( - history: GameEvent[], deck: Set, - event: GameEvent + event: GameEvent, + history: GameEvent[] ) { const { c1, c2, c3 } = event; - - // Check validity - let ok = c1 !== c2 && c2 !== c3 && c1 !== c3; - ok &&= deck.has(c2) && deck.has(c3); + const allCards = [c1, c2, c3]; + const cards = history.length === 0 ? allCards : allCards.slice(1); + if (hasDuplicates(allCards) || !validCards(deck, cards)) return false; if (history.length) { // One card (c1) should be taken from the previous set - const prevEvent = history[history.length - 1]; - const prev = [prevEvent.c1, prevEvent.c2, prevEvent.c3]; - ok &&= prev.includes(c1); + const prev = history[history.length - 1]; + if (![prev.c1, prev.c2, prev.c3].includes(c1)) return false; } - if (!ok) return; - - const cards = history.length === 0 ? [c1, c2, c3] : [c2, c3]; - deleteCards(deck, cards); - return true; -} - -/** Replay game event for ultraset mode */ -function replayEventUltra(deck: Set, event: GameEvent) { - const cards = [event.c1, event.c2, event.c3, event.c4!]; - if (!isValid(deck, cards)) return false; deleteCards(deck, cards); return true; } @@ -166,15 +168,17 @@ export function replayEvents( const history: GameEvent[] = []; const scores: Record = {}; let finalTime = 0; + const replayFn: ReplayFn | null = + gameMode === "normal" || gameMode === "ultraset" || gameMode === "ultra9" + ? replayEventCommon + : gameMode === "setchain" + ? replayEventChain + : null; + if (!replayFn) { + throw new Error(`invalid gameMode ${gameMode}`); + } for (const event of events) { - let eventValid = false; - if (gameMode === "normal" && replayEventNormal(deck, event)) - eventValid = true; - if (gameMode === "setchain" && replayEventChain(history, deck, event)) - eventValid = true; - if (gameMode === "ultraset" && replayEventUltra(deck, event)) - eventValid = true; - if (eventValid) { + if (replayFn(deck, event, history)) { history.push(event); scores[event.user] = (scores[event.user] ?? 0) + 1; finalTime = event.time; diff --git a/src/components/ChatCards.js b/src/components/ChatCards.js index e5823ec..9541fbe 100644 --- a/src/components/ChatCards.js +++ b/src/components/ChatCards.js @@ -51,7 +51,7 @@ function ChatCards({ item, gameMode, startedAt }) { )} - {gameMode === "ultraset" && ( + {(gameMode === "ultraset" || gameMode === "ultra9") && (
diff --git a/src/components/GameSettings.js b/src/components/GameSettings.js index 4b95479..e1e3add 100644 --- a/src/components/GameSettings.js +++ b/src/components/GameSettings.js @@ -1,15 +1,16 @@ import { makeStyles } from "@material-ui/core/styles"; import FormControlLabel from "@material-ui/core/FormControlLabel"; -import Radio from "@material-ui/core/Radio"; -import RadioGroup from "@material-ui/core/RadioGroup"; import Switch from "@material-ui/core/Switch"; import Tooltip from "@material-ui/core/Tooltip"; +import Typography from "@material-ui/core/Typography"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; import firebase from "../firebase"; import { hasHint, modes } from "../util"; const useStyles = makeStyles(() => ({ - settings: { display: "flex", flexDirection: "column", alignItems: "center" }, + settings: { display: "flex", justifyContent: "space-evenly" }, })); const hintTip = @@ -31,35 +32,28 @@ function GameSettings({ game, gameId, userId }) { return (
- - {["normal", "setchain", "ultraset"].map((mode) => ( - - } - disabled={userId !== game.host} - label={modes[mode].name} - /> - - ))} - - { - - } - label="Enable Hints" - disabled={ - game.access !== "private" || - Object.keys(game.users || {}).length !== 1 - } - /> - - } +
+ Mode: + +
+ + } + label="Enable Hints" + disabled={ + game.access !== "private" || + Object.keys(game.users || {}).length !== 1 + } + /> +
); } diff --git a/src/pages/GamePage.js b/src/pages/GamePage.js index 8a5c3e0..e20772a 100644 --- a/src/pages/GamePage.js +++ b/src/pages/GamePage.js @@ -29,6 +29,7 @@ import { findSet, computeState, hasHint, + cardsInSet, } from "../util"; import foundSfx from "../assets/successfulSetSound.mp3"; import failSfx from "../assets/failedSetSound.mp3"; @@ -141,7 +142,7 @@ function GamePage({ match }) { const gameMode = game.mode || "normal"; const spectating = !game.users || !(user.id in game.users); - const maxNumHints = gameMode === "ultraset" ? 4 : 3; + const maxNumHints = cardsInSet(gameMode); const { current, scores, lastEvents, history, boardSize } = computeState( gameData, @@ -155,10 +156,8 @@ function GamePage({ match }) { ); function handleSet(cards) { - const event = - gameMode === "ultraset" - ? { c1: cards[0], c2: cards[1], c3: cards[2], c4: cards[3] } - : { c1: cards[0], c2: cards[1], c3: cards[2] }; + const event = { c1: cards[0], c2: cards[1], c3: cards[2] }; + if (cards.length === 4) event.c4 = cards[3]; firebase.analytics().logEvent("find_set", event); firebase .database() @@ -227,7 +226,7 @@ function GamePage({ match }) { } else { return vals; } - } else if (gameMode === "ultraset") { + } else if (gameMode === "ultraset" || gameMode === "ultra9") { const vals = [...selected, card]; if (vals.length === 4) { let res = checkSetUltra(...vals); diff --git a/src/util.js b/src/util.js index 7473ba6..a620c89 100644 --- a/src/util.js +++ b/src/util.js @@ -81,6 +81,13 @@ export const modes = { "Find 4 cards such that the first pair and the second pair form a Set with the same additional card.", setType: "UltraSet", }, + ultra9: { + name: "Ultra9", + color: "deepOrange", + description: + "Same as UltraSet, but only 9 cards are dealt at a time, unless they don't contain any sets.", + setType: "UltraSet", + }, }; export const standardLayouts = { @@ -194,7 +201,7 @@ export function findSet(deck, gameMode = "normal", old) { if (old.includes(c)) { return [c, deck[i], deck[j]]; } - } else if (gameMode === "ultraset") { + } else if (gameMode === "ultraset" || gameMode === "ultra9") { if (c in ultraConjugates) { return [...ultraConjugates[c], deck[i], deck[j]]; } @@ -221,28 +228,31 @@ export function generateName() { return "Anonymous " + animals[Math.floor(Math.random() * animals.length)]; } -function hasDuplicates(used, cards) { +function hasDuplicates(cards) { for (let i = 0; i < cards.length; i++) { for (let j = i + 1; j < cards.length; j++) { if (cards[i] === cards[j]) return true; } - if (used[cards[i]]) return true; } return false; } +function hasUsedCards(used, cards) { + return cards.some((c) => used[c]); +} + function removeCards(internalGameState, cards) { - const { current, used } = internalGameState; + const { current, used, minBoardSize } = internalGameState; let canPreserve = true; for (const c of cards) { - if (current.indexOf(c) >= 12) canPreserve = false; + if (current.indexOf(c) >= minBoardSize) canPreserve = false; used[c] = true; } - if (current.length < 12 + cards.length) canPreserve = false; + if (current.length < minBoardSize + cards.length) canPreserve = false; if (canPreserve) { // Try to preserve card locations, if possible - const d = current.splice(12, cards.length); + const d = current.splice(minBoardSize, cards.length); for (let i = 0; i < cards.length; i++) { current[current.indexOf(cards[i])] = d[i]; } @@ -262,49 +272,35 @@ function processValidEvent(internalGameState, event, cards) { removeCards(internalGameState, cards); } -function processEventNormal(internalGameState, event) { - const { current, used } = internalGameState; +function updateBoardSize(internalGameState, cards, old) { + const { current, gameMode, boardSize, minBoardSize } = internalGameState; + const minSize = Math.max(boardSize - cards.length, minBoardSize); + const newBoardSize = splitDeck(current, gameMode, minSize, old)[0].length; + internalGameState.boardSize = newBoardSize; +} + +function processEventCommon(internalGameState, event) { + const { used } = internalGameState; const cards = [event.c1, event.c2, event.c3]; - if (hasDuplicates(used, cards)) return; + if (event.c4) cards.push(event.c4); + if (hasDuplicates(cards) || hasUsedCards(used, cards)) return; processValidEvent(internalGameState, event, cards); - - const minSize = Math.max(internalGameState.boardSize - 3, 12); - const boardSize = splitDeck(current, "normal", minSize)[0].length; - internalGameState.boardSize = boardSize; + updateBoardSize(internalGameState, cards); } function processEventChain(internalGameState, event) { - const { used, history, current } = internalGameState; + const { used, history } = internalGameState; const { c1, c2, c3 } = event; - - let ok = c1 !== c2 && c2 !== c3 && c1 !== c3 && !used[c2] && !used[c3]; + const allCards = [c1, c2, c3]; + const cards = history.length === 0 ? allCards : allCards.slice(1); + if (hasDuplicates(allCards) || hasUsedCards(used, cards)) return; if (history.length) { // One card (c1) should be taken from the previous set - let prev = history[history.length - 1]; - ok &&= [prev.c1, prev.c2, prev.c3].includes(c1); - } else { - ok &&= !used[c1]; + const prev = history[history.length - 1]; + if (![prev.c1, prev.c2, prev.c3].includes(c1)) return; } - if (!ok) return; - - const cards = history.length === 0 ? [c1, c2, c3] : [c2, c3]; processValidEvent(internalGameState, event, cards); - - const minSize = Math.max(internalGameState.boardSize - cards.length, 12); - const old = [c1, c2, c3]; - const boardSize = splitDeck(current, "setchain", minSize, old)[0].length; - internalGameState.boardSize = boardSize; -} - -function processEventUltra(internalGameState, event) { - const { used, current } = internalGameState; - const cards = [event.c1, event.c2, event.c3, event.c4]; - if (hasDuplicates(used, cards)) return; - processValidEvent(internalGameState, event, cards); - - const minSize = Math.max(internalGameState.boardSize - 4, 12); - const boardSize = splitDeck(current, "ultraset", minSize)[0].length; - internalGameState.boardSize = boardSize; + updateBoardSize(internalGameState, cards, allCards); } export function computeState(gameData, gameMode = "normal") { @@ -313,14 +309,17 @@ export function computeState(gameData, gameMode = "normal") { const history = []; // list of valid events in time order const current = gameData.deck.slice(); // remaining cards in the game const lastEvents = {}; // time of the last event for each user + const minBoardSize = gameMode === "ultra9" ? 9 : 12; const internalGameState = { used, current, scores, history, lastEvents, + gameMode, + minBoardSize, // Initial deck split - boardSize: splitDeck(current, gameMode, 12, [])[0].length, + boardSize: splitDeck(current, gameMode, minBoardSize, [])[0].length, }; if (gameData.events) { @@ -329,13 +328,14 @@ export function computeState(gameData, gameMode = "normal") { (e1, e2) => e1.time - e2.time ); const processFn = - gameMode === "normal" - ? processEventNormal + gameMode === "normal" || gameMode === "ultraset" || gameMode === "ultra9" + ? processEventCommon : gameMode === "setchain" ? processEventChain - : gameMode === "ultraset" - ? processEventUltra : null; + if (!processFn) { + throw new Error(`invalid gameMode ${gameMode}`); + } for (const event of events) { processFn(internalGameState, event); } @@ -367,6 +367,10 @@ export function hasHint(game) { ); } +export function cardsInSet(gameMode) { + return gameMode === "ultraset" || gameMode === "ultra9" ? 4 : 3; +} + export function censorText(text) { return censor.applyTo(text, badWords.getAllMatches(text)); }