diff --git a/src/backend/components/boardgame.tsx b/src/backend/components/boardgame.tsx index 232159e..76ca84b 100644 --- a/src/backend/components/boardgame.tsx +++ b/src/backend/components/boardgame.tsx @@ -1,13 +1,6 @@ import { sequence } from "../../shared/array-utils.ts"; -import { - BoardgameStateType, - CurrentOutcome, - GameRules, - Player, - SquareState, - formatSquareState, -} from "../../shared/datatypes.ts"; -import { ClassNames, getDisplayStates } from "../../shared/display.ts"; +import { BoardgameStateType, CurrentOutcome, GameRules, Player } from "../../shared/datatypes.ts"; +import { ClassNames, getCellDisplayData, getDisplayStates } from "../../shared/display.ts"; const getClassAndDisplayAttributes = ( className: ClassNames, @@ -37,22 +30,11 @@ const getCellHtml = ({ row: number; column: number; }) => { - if (!gameState.board) { - return ( - - ); - } + const { isDisabled, nextGameState, text } = getCellDisplayData({ gameState, currentOutcome, row, column }); - const squareState = gameState.board.get(row, column); - const nextGameState = - squareState === SquareState.Unoccupied && currentOutcome === CurrentOutcome.Undecided - ? gameState.withMove(row, column) - : gameState; return ( - ); }; diff --git a/src/backend/main/boardgame-handler.ts b/src/backend/main/boardgame-handler.ts index 52792f3..d1dbc29 100644 --- a/src/backend/main/boardgame-handler.ts +++ b/src/backend/main/boardgame-handler.ts @@ -3,9 +3,7 @@ import { rewriteQueryParamsWith, safeGetQueryValue } from "../utils.ts"; import { BoardgameState } from "../../shared/boardgame-state.ts"; import { getBoardgameHtml } from "../components/boardgame.tsx"; import { gamesRules } from "../../shared/rules.ts"; -import { getAllSolutions } from "../../shared/solver-cache.ts"; -import { CurrentOutcome } from "../../shared/datatypes.ts"; -import { createOpponent } from "../../shared/opponent.ts"; +import { getTargetGameState } from "../../shared/boardgame.ts"; // Returns nothing if query parameter is uninitialized and a redirect was made, // or component if query parameter is initialized. @@ -13,24 +11,13 @@ export const handleBoardgame = (req: Request, res: Response, key: string) => { const rules = gamesRules.tictactoe; const serializedState = safeGetQueryValue(req, key); - const state = BoardgameState.fromSerialized(serializedState); + const inputState = BoardgameState.fromSerialized(serializedState); - if (!state) { - const newState = BoardgameState.createWithoutBoard(3, 3); - rewriteQueryParamsWith(req, res, { [key]: newState.serialize() }); + const state = inputState ? getTargetGameState(inputState, rules) : BoardgameState.createWithoutBoard(3, 3); + if (state !== inputState) { + rewriteQueryParamsWith(req, res, { [key]: state.serialize() }); return; } - if (state.board && state.currentPlayer && state.autoPlayers.has(state.currentPlayer)) { - const currentOutcome = rules.getBoardOutcome(state.board); - if (currentOutcome === CurrentOutcome.Undecided) { - const solutions = getAllSolutions(state.rows, state.columns, rules); - const nextMove = createOpponent(solutions).getNextMove(state.board, state.currentPlayer); - const newState = state.withMove(nextMove.row, nextMove.column); - rewriteQueryParamsWith(req, res, { [key]: newState.serialize() }); - return; - } - } - return getBoardgameHtml(key, state, rules); }; diff --git a/src/frontend/components/board-game.ts b/src/frontend/components/board-game.ts index 1962eb0..2d2fc74 100644 --- a/src/frontend/components/board-game.ts +++ b/src/frontend/components/board-game.ts @@ -1,19 +1,29 @@ import { BoardgameState } from "../../shared/boardgame-state.ts"; -import { CurrentOutcome, SquareState, formatSquareState } from "../../shared/datatypes.ts"; -import { getDisplayStates } from "../../shared/display.ts"; +import { getTargetGameState } from "../../shared/boardgame.ts"; +import { CurrentOutcome } from "../../shared/datatypes.ts"; +import { getCellDisplayData, getDisplayStates } from "../../shared/display.ts"; import { gamesRules } from "../../shared/rules.ts"; +import { replaceLocation } from "../lib/navigation-utils.ts"; import { TrackingTools } from "../lib/query-tracking-utils.ts"; +import { updateWithQueryParams } from "../lib/url-utils.ts"; export class BoardGameComponent extends HTMLElement { private readonly trackingTools = new TrackingTools(this); - handleTrackedValueUpdate(newValue: string | null) { + handleTrackedValueUpdate(newValue: string | null, key: string) { const rules = gamesRules.tictactoe; - const gameState = BoardgameState.fromSerialized(newValue); - if (!gameState) { + const inputGameState = BoardgameState.fromSerialized(newValue); + if (!inputGameState) { throw new Error("Empty game state"); } + + const gameState = getTargetGameState(inputGameState, rules); + if (gameState !== inputGameState) { + replaceLocation(updateWithQueryParams(new URL(window.location.href), [[key, gameState.serialize()]])); + return; + } + const currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided; const displayStates = getDisplayStates(gameState, currentOutcome); @@ -37,21 +47,19 @@ export class BoardGameComponent extends HTMLElement { continue; } + const { isDisabled, nextGameState, text } = getCellDisplayData({ + gameState, + currentOutcome, + row: rowNumber, + column: columnNumber, + }); for (const button of cell.querySelectorAll("button")) { - if (!gameState.board) { - button.value = gameState.serialize(); - button.disabled = true; - button.innerText = " "; - } else { - const squareState = gameState.board.get(rowNumber, columnNumber); - const nextGameState = - squareState === SquareState.Unoccupied && currentOutcome === CurrentOutcome.Undecided - ? gameState.withMove(rowNumber, columnNumber) - : gameState; - - button.value = nextGameState.serialize(); - button.disabled = nextGameState === gameState; - button.innerText = formatSquareState(squareState); + button.value = nextGameState.serialize(); + if (button.disabled !== isDisabled) { + button.disabled = isDisabled; + } + if (button.innerText !== text) { + button.innerText = text; } } } diff --git a/src/frontend/components/progressive-form.ts b/src/frontend/components/progressive-form.ts index 5c31000..dce7a65 100644 --- a/src/frontend/components/progressive-form.ts +++ b/src/frontend/components/progressive-form.ts @@ -1,15 +1,8 @@ -const submitListener = function (this: HTMLFormElement, e: SubmitEvent) { - const targetUrl = new URL(this.action); - const formData = new FormData(this, e.submitter); - for (const [key, value] of formData.entries()) { - if (typeof value !== "string") { - throw new Error("File fields are not supported; falling back to regular form submission"); - } +import { goToLocation } from "../lib/navigation-utils.ts"; +import { updateWithQueryParams } from "../lib/url-utils.ts"; - targetUrl.searchParams.set(key, value); - } - - window.history.pushState({}, "", targetUrl.toString()); +const submitListener = function (this: HTMLFormElement, e: SubmitEvent) { + goToLocation(updateWithQueryParams(new URL(this.action), new FormData(this, e.submitter))); e.preventDefault(); }; diff --git a/src/frontend/lib/navigation-utils.ts b/src/frontend/lib/navigation-utils.ts index 49aa538..892e8a7 100644 --- a/src/frontend/lib/navigation-utils.ts +++ b/src/frontend/lib/navigation-utils.ts @@ -29,3 +29,11 @@ export const onLocationChange = (observer: () => void) => { export const onLocationChangeUnsubscribe = (observer: () => void) => { observers.delete(observer); }; + +export const goToLocation = (targetUrl: URL) => { + window.history.pushState({}, "", targetUrl.toString()); +}; + +export const replaceLocation = (targetUrl: URL) => { + window.history.replaceState({}, "", targetUrl.toString()); +}; diff --git a/src/frontend/lib/query-tracking-utils.ts b/src/frontend/lib/query-tracking-utils.ts index eda9e12..6674b50 100644 --- a/src/frontend/lib/query-tracking-utils.ts +++ b/src/frontend/lib/query-tracking-utils.ts @@ -1,12 +1,12 @@ import { onLocationChange, onLocationChangeUnsubscribe } from "./navigation-utils.ts"; -const createQueryStringTracker = (key: string, onChange: (newValue: string | null) => void) => { +const createQueryStringTracker = (key: string, onChange: (newValue: string | null, trackKey: string) => void) => { let currentValue = new URLSearchParams(window.location.search).get(key); const observer = () => { const newValue = new URLSearchParams(window.location.search).get(key); if (newValue !== currentValue) { currentValue = newValue; - onChange(newValue); + onChange(newValue, key); } }; @@ -17,7 +17,9 @@ const createQueryStringTracker = (key: string, onChange: (newValue: string | nul }; export class TrackingTools< - TElement extends HTMLElement & { handleTrackedValueUpdate(this: TElement, newValue: string | null): void }, + TElement extends HTMLElement & { + handleTrackedValueUpdate(this: TElement, newValue: string | null, trackKey: string): void; + }, > { private currentTrackKey: string | null = null; private clearTracker: (() => void) | null = null; diff --git a/src/frontend/lib/url-utils.ts b/src/frontend/lib/url-utils.ts new file mode 100644 index 0000000..ddb463f --- /dev/null +++ b/src/frontend/lib/url-utils.ts @@ -0,0 +1,11 @@ +export const updateWithQueryParams = (targetUrl: URL, data: Iterable<[string, FormDataEntryValue]>) => { + for (const [key, value] of data) { + if (typeof value !== "string") { + throw new Error("File fields are not supported; falling back to regular form submission"); + } + + targetUrl.searchParams.set(key, value); + } + + return targetUrl; +}; diff --git a/src/shared/boardgame.ts b/src/shared/boardgame.ts new file mode 100644 index 0000000..802d1be --- /dev/null +++ b/src/shared/boardgame.ts @@ -0,0 +1,28 @@ +import { BoardgameState } from "./boardgame-state.ts"; +import { CurrentOutcome, GameRules } from "./datatypes.ts"; +import { createOpponent } from "./opponent.ts"; +import { getAllSolutions } from "./solver-cache.ts"; + +export const getTargetGameState = (state: BoardgameState, rules: GameRules) => { + if (!state.board) { + return state; + } + + if (!state.currentPlayer) { + return state; + } + + if (!state.autoPlayers.has(state.currentPlayer)) { + return state; + } + + const currentOutcome = rules.getBoardOutcome(state.board); + if (currentOutcome !== CurrentOutcome.Undecided) { + return state; + } + + const solutions = getAllSolutions(state.rows, state.columns, rules); + const nextMove = createOpponent(solutions).getNextMove(state.board, state.currentPlayer); + const newState = state.withMove(nextMove.row, nextMove.column); + return newState; +}; diff --git a/src/shared/display.ts b/src/shared/display.ts index c8e78a5..3eaa5be 100644 --- a/src/shared/display.ts +++ b/src/shared/display.ts @@ -1,4 +1,4 @@ -import { BoardgameStateType, CurrentOutcome, Player } from "./datatypes.ts"; +import { BoardgameStateType, CurrentOutcome, Player, SquareState, formatSquareState } from "./datatypes.ts"; export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({ "outcome-winx": currentOutcome === CurrentOutcome.WinX, @@ -13,3 +13,35 @@ export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: }); export type ClassNames = keyof ReturnType; + +export const getCellDisplayData = ({ + gameState, + currentOutcome, + row, + column, +}: { + gameState: BoardgameStateType; + currentOutcome: CurrentOutcome; + row: number; + column: number; +}) => { + if (!gameState.board) { + return { + nextGameState: gameState, + isDisabled: true, + text: " ", + }; + } + + const squareState = gameState.board.get(row, column); + const nextGameState = + squareState === SquareState.Unoccupied && currentOutcome === CurrentOutcome.Undecided + ? gameState.withMove(row, column) + : gameState; + + return { + nextGameState: nextGameState, + isDisabled: nextGameState === gameState, + text: formatSquareState(squareState), + }; +};