unified table cell logic between frontend and backend; unified autoplay; implemented autoplay on frontend

main
Inga 🏳‍🌈 5 days ago
parent 0c2ef25b17
commit 6b707f9441
  1. 28
      src/backend/components/boardgame.tsx
  2. 23
      src/backend/main/boardgame-handler.ts
  3. 44
      src/frontend/components/board-game.ts
  4. 15
      src/frontend/components/progressive-form.ts
  5. 8
      src/frontend/lib/navigation-utils.ts
  6. 8
      src/frontend/lib/query-tracking-utils.ts
  7. 11
      src/frontend/lib/url-utils.ts
  8. 28
      src/shared/boardgame.ts
  9. 34
      src/shared/display.ts

@ -1,13 +1,6 @@
import { sequence } from "../../shared/array-utils.ts"; import { sequence } from "../../shared/array-utils.ts";
import { import { BoardgameStateType, CurrentOutcome, GameRules, Player } from "../../shared/datatypes.ts";
BoardgameStateType, import { ClassNames, getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
CurrentOutcome,
GameRules,
Player,
SquareState,
formatSquareState,
} from "../../shared/datatypes.ts";
import { ClassNames, getDisplayStates } from "../../shared/display.ts";
const getClassAndDisplayAttributes = ( const getClassAndDisplayAttributes = (
className: ClassNames, className: ClassNames,
@ -37,22 +30,11 @@ const getCellHtml = ({
row: number; row: number;
column: number; column: number;
}) => { }) => {
if (!gameState.board) { const { isDisabled, nextGameState, text } = getCellDisplayData({ gameState, currentOutcome, row, column });
return (
<button {...getSubmitAttributes(key, gameState)} disabled>
{" "}
</button>
);
}
const squareState = gameState.board.get(row, column);
const nextGameState =
squareState === SquareState.Unoccupied && currentOutcome === CurrentOutcome.Undecided
? gameState.withMove(row, column)
: gameState;
return ( return (
<button {...getSubmitAttributes(key, nextGameState)} disabled={nextGameState === gameState}> <button {...getSubmitAttributes(key, nextGameState)} disabled={isDisabled}>
{formatSquareState(squareState)} {text}
</button> </button>
); );
}; };

@ -3,9 +3,7 @@ import { rewriteQueryParamsWith, safeGetQueryValue } from "../utils.ts";
import { BoardgameState } from "../../shared/boardgame-state.ts"; import { BoardgameState } from "../../shared/boardgame-state.ts";
import { getBoardgameHtml } from "../components/boardgame.tsx"; import { getBoardgameHtml } from "../components/boardgame.tsx";
import { gamesRules } from "../../shared/rules.ts"; import { gamesRules } from "../../shared/rules.ts";
import { getAllSolutions } from "../../shared/solver-cache.ts"; import { getTargetGameState } from "../../shared/boardgame.ts";
import { CurrentOutcome } from "../../shared/datatypes.ts";
import { createOpponent } from "../../shared/opponent.ts";
// Returns nothing if query parameter is uninitialized and a redirect was made, // Returns nothing if query parameter is uninitialized and a redirect was made,
// or component if query parameter is initialized. // 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 rules = gamesRules.tictactoe;
const serializedState = safeGetQueryValue(req, key); const serializedState = safeGetQueryValue(req, key);
const state = BoardgameState.fromSerialized(serializedState); const inputState = BoardgameState.fromSerialized(serializedState);
if (!state) { const state = inputState ? getTargetGameState(inputState, rules) : BoardgameState.createWithoutBoard(3, 3);
const newState = BoardgameState.createWithoutBoard(3, 3); if (state !== inputState) {
rewriteQueryParamsWith(req, res, { [key]: newState.serialize() }); rewriteQueryParamsWith(req, res, { [key]: state.serialize() });
return; 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); return getBoardgameHtml(key, state, rules);
}; };

@ -1,19 +1,29 @@
import { BoardgameState } from "../../shared/boardgame-state.ts"; import { BoardgameState } from "../../shared/boardgame-state.ts";
import { CurrentOutcome, SquareState, formatSquareState } from "../../shared/datatypes.ts"; import { getTargetGameState } from "../../shared/boardgame.ts";
import { getDisplayStates } from "../../shared/display.ts"; import { CurrentOutcome } from "../../shared/datatypes.ts";
import { getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
import { gamesRules } from "../../shared/rules.ts"; import { gamesRules } from "../../shared/rules.ts";
import { replaceLocation } from "../lib/navigation-utils.ts";
import { TrackingTools } from "../lib/query-tracking-utils.ts"; import { TrackingTools } from "../lib/query-tracking-utils.ts";
import { updateWithQueryParams } from "../lib/url-utils.ts";
export class BoardGameComponent extends HTMLElement { export class BoardGameComponent extends HTMLElement {
private readonly trackingTools = new TrackingTools(this); private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) { handleTrackedValueUpdate(newValue: string | null, key: string) {
const rules = gamesRules.tictactoe; const rules = gamesRules.tictactoe;
const gameState = BoardgameState.fromSerialized(newValue); const inputGameState = BoardgameState.fromSerialized(newValue);
if (!gameState) { if (!inputGameState) {
throw new Error("Empty game state"); 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 currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided;
const displayStates = getDisplayStates(gameState, currentOutcome); const displayStates = getDisplayStates(gameState, currentOutcome);
@ -37,21 +47,19 @@ export class BoardGameComponent extends HTMLElement {
continue; continue;
} }
const { isDisabled, nextGameState, text } = getCellDisplayData({
gameState,
currentOutcome,
row: rowNumber,
column: columnNumber,
});
for (const button of cell.querySelectorAll("button")) { 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.value = nextGameState.serialize();
button.disabled = nextGameState === gameState; if (button.disabled !== isDisabled) {
button.innerText = formatSquareState(squareState); button.disabled = isDisabled;
}
if (button.innerText !== text) {
button.innerText = text;
} }
} }
} }

@ -1,15 +1,8 @@
const submitListener = function (this: HTMLFormElement, e: SubmitEvent) { import { goToLocation } from "../lib/navigation-utils.ts";
const targetUrl = new URL(this.action); import { updateWithQueryParams } from "../lib/url-utils.ts";
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");
}
targetUrl.searchParams.set(key, value); const submitListener = function (this: HTMLFormElement, e: SubmitEvent) {
} goToLocation(updateWithQueryParams(new URL(this.action), new FormData(this, e.submitter)));
window.history.pushState({}, "", targetUrl.toString());
e.preventDefault(); e.preventDefault();
}; };

@ -29,3 +29,11 @@ export const onLocationChange = (observer: () => void) => {
export const onLocationChangeUnsubscribe = (observer: () => void) => { export const onLocationChangeUnsubscribe = (observer: () => void) => {
observers.delete(observer); observers.delete(observer);
}; };
export const goToLocation = (targetUrl: URL) => {
window.history.pushState({}, "", targetUrl.toString());
};
export const replaceLocation = (targetUrl: URL) => {
window.history.replaceState({}, "", targetUrl.toString());
};

@ -1,12 +1,12 @@
import { onLocationChange, onLocationChangeUnsubscribe } from "./navigation-utils.ts"; 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); let currentValue = new URLSearchParams(window.location.search).get(key);
const observer = () => { const observer = () => {
const newValue = new URLSearchParams(window.location.search).get(key); const newValue = new URLSearchParams(window.location.search).get(key);
if (newValue !== currentValue) { if (newValue !== currentValue) {
currentValue = newValue; currentValue = newValue;
onChange(newValue); onChange(newValue, key);
} }
}; };
@ -17,7 +17,9 @@ const createQueryStringTracker = (key: string, onChange: (newValue: string | nul
}; };
export class TrackingTools< 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 currentTrackKey: string | null = null;
private clearTracker: (() => void) | null = null; private clearTracker: (() => void) | null = null;

@ -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;
};

@ -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;
};

@ -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) => ({ export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({
"outcome-winx": currentOutcome === CurrentOutcome.WinX, "outcome-winx": currentOutcome === CurrentOutcome.WinX,
@ -13,3 +13,35 @@ export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome:
}); });
export type ClassNames = keyof ReturnType<typeof getDisplayStates>; export type ClassNames = keyof ReturnType<typeof getDisplayStates>;
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),
};
};

Loading…
Cancel
Save