Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -7,6 +7,9 @@ const RETRY_INTERVAL_MS = 30_000;
|
|||||||
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
|
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
|
||||||
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable.
|
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable.
|
||||||
export class GameRecorder {
|
export class GameRecorder {
|
||||||
|
// Being saved right now.
|
||||||
|
private readonly saving = new Map<string, GameRecord>();
|
||||||
|
// Failed to save; retried later.
|
||||||
private readonly pending = new Map<string, GameRecord>();
|
private readonly pending = new Map<string, GameRecord>();
|
||||||
private readonly savedListeners: ((record: GameRecord) => void)[] = [];
|
private readonly savedListeners: ((record: GameRecord) => void)[] = [];
|
||||||
private retryTimer?: NodeJS.Timeout;
|
private retryTimer?: NodeJS.Timeout;
|
||||||
@ -19,16 +22,24 @@ export class GameRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save(record: GameRecord): Promise<void> {
|
async save(record: GameRecord): Promise<void> {
|
||||||
|
this.saving.set(record.id, record);
|
||||||
try {
|
try {
|
||||||
await this.repository.save(record);
|
await this.repository.save(record);
|
||||||
|
this.saving.delete(record.id);
|
||||||
this.notifySaved(record);
|
this.notifySaved(record);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message);
|
console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message);
|
||||||
this.pending.set(record.id, record);
|
this.pending.set(record.id, record);
|
||||||
|
this.saving.delete(record.id);
|
||||||
await this.liveGameStore.savePendingRecord(record).catch(() => undefined);
|
await this.liveGameStore.savePendingRecord(record).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finished games that are not in the database yet, so readers can include them.
|
||||||
|
getUnsaved(): GameRecord[] {
|
||||||
|
return [...this.saving.values(), ...this.pending.values()];
|
||||||
|
}
|
||||||
|
|
||||||
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
|
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
|
||||||
async isRecorded(gameId: string): Promise<boolean> {
|
async isRecorded(gameId: string): Promise<boolean> {
|
||||||
return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined;
|
return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined;
|
||||||
|
|||||||
@ -7,23 +7,37 @@ const CACHE_TTL_SECONDS = 10;
|
|||||||
const POPULAR_KEY = "popular-finished";
|
const POPULAR_KEY = "popular-finished";
|
||||||
const recentKey = (userId: string) => `recent:${userId}`;
|
const recentKey = (userId: string) => `recent:${userId}`;
|
||||||
|
|
||||||
// Reads saved games for the API. Lists are cached briefly and come back empty while the database is unreachable,
|
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
|
||||||
// so the home page still loads.
|
export type UnsavedGames = () => GameRecord[];
|
||||||
export class GameHistoryService {
|
|
||||||
constructor(private readonly repository: GameHistoryRepository, private readonly cache: Cache) { }
|
|
||||||
|
|
||||||
get(id: string): Promise<GameRecord | undefined> {
|
const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary;
|
||||||
return this.repository.get(id);
|
|
||||||
|
const byNewest = (a: FinishedGameSummary, b: FinishedGameSummary) => b.endedAt - a.endedAt;
|
||||||
|
const byPopularity = (a: FinishedGameSummary, b: FinishedGameSummary) =>
|
||||||
|
b.peakSpectators - a.peakSpectators || byNewest(a, b);
|
||||||
|
|
||||||
|
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
|
||||||
|
// saved games while the database is unreachable, so the home page still loads.
|
||||||
|
export class GameHistoryService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: GameHistoryRepository,
|
||||||
|
private readonly cache: Cache,
|
||||||
|
private readonly unsaved: UnsavedGames = () => [],
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async get(id: string): Promise<GameRecord | undefined> {
|
||||||
|
return this.unsaved().find((record) => record.id === id) ?? this.repository.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||||
const games = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
const saved = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||||
return games.slice(0, limit);
|
const unsaved = this.unsaved().filter((r) => r.player1.userId === userId || r.player2.userId === userId);
|
||||||
|
return merge(saved, unsaved, byNewest).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
||||||
const games = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT));
|
const saved = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT));
|
||||||
return games.slice(0, limit);
|
return merge(saved, this.unsaved(), byPopularity).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call when a game has been saved, so lists show it right away.
|
// Call when a game has been saved, so lists show it right away.
|
||||||
@ -44,3 +58,8 @@ export class GameHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function merge(saved: FinishedGameSummary[], unsaved: GameRecord[], order: typeof byNewest): FinishedGameSummary[] {
|
||||||
|
const savedIds = new Set(saved.map((game) => game.id));
|
||||||
|
return [...saved, ...unsaved.filter((record) => !savedIds.has(record.id)).map(toSummary)].sort(order);
|
||||||
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ async function main() {
|
|||||||
const liveGameStore = new ValkeyLiveGameStore(valkey);
|
const liveGameStore = new ValkeyLiveGameStore(valkey);
|
||||||
const repository = new PostgresGameHistoryRepository(pool);
|
const repository = new PostgresGameHistoryRepository(pool);
|
||||||
const recorder = new GameRecorder(repository, liveGameStore);
|
const recorder = new GameRecorder(repository, liveGameStore);
|
||||||
const history = new GameHistoryService(repository, new ValkeyCache(valkey));
|
const history = new GameHistoryService(repository, new ValkeyCache(valkey), () => recorder.getUnsaved());
|
||||||
recorder.onSaved((record) => history.onGameSaved(record));
|
recorder.onSaved((record) => history.onGameSaved(record));
|
||||||
|
|
||||||
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
||||||
|
|||||||
@ -48,7 +48,7 @@ describe("HTTP API", () => {
|
|||||||
repository = new InMemoryGameHistoryRepository();
|
repository = new InMemoryGameHistoryRepository();
|
||||||
const liveGameStore = new InMemoryLiveGameStore();
|
const liveGameStore = new InMemoryLiveGameStore();
|
||||||
const recorder = new GameRecorder(repository, liveGameStore);
|
const recorder = new GameRecorder(repository, liveGameStore);
|
||||||
const history = new GameHistoryService(repository, new InMemoryCache());
|
const history = new GameHistoryService(repository, new InMemoryCache(), () => recorder.getUnsaved());
|
||||||
recorder.onSaved((record) => history.onGameSaved(record));
|
recorder.onSaved((record) => history.onGameSaved(record));
|
||||||
gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder });
|
gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder });
|
||||||
liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
||||||
@ -176,6 +176,19 @@ describe("HTTP API", () => {
|
|||||||
repository.failing = true;
|
repository.failing = true;
|
||||||
expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] });
|
expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes a game that ended but is not saved yet", async () => {
|
||||||
|
repository.failing = true;
|
||||||
|
const game = startGame();
|
||||||
|
rtmt.receive(ALICE.id, channel_leave_game);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]);
|
||||||
|
expect((await get("/games/popular")).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]);
|
||||||
|
const { body } = await get(`/game/${game.id}`);
|
||||||
|
expect(body.status).toBe("finished");
|
||||||
|
expect(body.record.moves).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves keep working with the API in place", async () => {
|
it("moves keep working with the API in place", async () => {
|
||||||
|
|||||||
50
apps/frontend/src/components/home/ActiveGameBanner.tsx
Normal file
50
apps/frontend/src/components/home/ActiveGameBanner.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { getColorByBrightness, getPlayerDisplayName, LiveGameSummary } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
|
// Shown at the top of the home page while the user has a game in progress.
|
||||||
|
const ActiveGameBanner: FunctionComponent<{
|
||||||
|
context: Context,
|
||||||
|
game: LiveGameSummary,
|
||||||
|
userId: string,
|
||||||
|
onContinue: () => void,
|
||||||
|
}> = ({ context, game, userId, onContinue }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = context.themeManager.theme;
|
||||||
|
const textColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
|
||||||
|
const opponent = game.player1.userId === userId ? game.player2 : game.player1;
|
||||||
|
const turn = game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn");
|
||||||
|
return (
|
||||||
|
<div className="banner" style={{ background: theme.boardColor, color: textColor }}>
|
||||||
|
<div>
|
||||||
|
<div className="title">{t("GameInProgress")}</div>
|
||||||
|
<div>{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}</div>
|
||||||
|
</div>
|
||||||
|
<Button context={context} color={theme.pitColor} text={t("Continue")} onClick={onContinue} />
|
||||||
|
<style jsx>{`
|
||||||
|
.banner {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 868px;
|
||||||
|
margin: 16px 16px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 0.5s;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActiveGameBanner;
|
||||||
104
apps/frontend/src/components/home/GamePreviewCard.tsx
Normal file
104
apps/frontend/src/components/home/GamePreviewCard.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
BoardViewModelFactory,
|
||||||
|
createGameFromBoard,
|
||||||
|
getColorByBrightness,
|
||||||
|
getPlayerDisplayName,
|
||||||
|
PitViewModelFactory,
|
||||||
|
PlayerSummary,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import BoardView from "../board/BoardView";
|
||||||
|
|
||||||
|
export const PREVIEW_BOARD_WIDTH = 260;
|
||||||
|
|
||||||
|
// A game as a card: the players, the real board at preview size, and a few lines of details.
|
||||||
|
const GamePreviewCard: FunctionComponent<{
|
||||||
|
context: Context,
|
||||||
|
id: string,
|
||||||
|
player1: PlayerSummary,
|
||||||
|
player2: PlayerSummary,
|
||||||
|
board: number[],
|
||||||
|
// Highlighted as the player to move.
|
||||||
|
turnPlayerId?: string,
|
||||||
|
// Show player 2's side at the bottom.
|
||||||
|
revert?: boolean,
|
||||||
|
lines: string[],
|
||||||
|
onClick: () => void,
|
||||||
|
}> = ({ context, id, player1, player2, board, turnPlayerId, revert, lines, onClick }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = context.themeManager.theme;
|
||||||
|
const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
|
||||||
|
|
||||||
|
const game = useMemo(() => createGameFromBoard({
|
||||||
|
id, player1Id: player1.userId, player2Id: player2.userId, turnPlayerId: turnPlayerId ?? player1.userId, board,
|
||||||
|
}), [id, player1.userId, player2.userId, turnPlayerId, board.join(",")]);
|
||||||
|
const boardViewModel = BoardViewModelFactory.create(id, board.map((stoneCount, index) => PitViewModelFactory.create({
|
||||||
|
id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
|
||||||
|
const nameStyle = (player: PlayerSummary): React.CSSProperties =>
|
||||||
|
({ fontWeight: player.userId === turnPlayerId ? "bold" : "normal" });
|
||||||
|
const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" onClick={onClick} role="link" style={{ background: theme.appBarBgColor, color: textColor }}>
|
||||||
|
<div className="players">
|
||||||
|
<span style={nameStyle(topPlayer)}>{name(topPlayer)}</span>
|
||||||
|
<span className="versus">{t("Versus")}</span>
|
||||||
|
<span style={nameStyle(bottomPlayer)}>{name(bottomPlayer)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="board">
|
||||||
|
<BoardView
|
||||||
|
game={game}
|
||||||
|
context={context}
|
||||||
|
boardId={id}
|
||||||
|
boardViewModel={boardViewModel}
|
||||||
|
revert={!!revert}
|
||||||
|
onPitSelect={() => undefined}
|
||||||
|
width={PREVIEW_BOARD_WIDTH} />
|
||||||
|
</div>
|
||||||
|
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
|
||||||
|
<style jsx>{`
|
||||||
|
.card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: ${PREVIEW_BOARD_WIDTH + 24}px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 0.5s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.players {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.versus {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.board {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GamePreviewCard;
|
||||||
45
apps/frontend/src/components/home/HomeSection.tsx
Normal file
45
apps/frontend/src/components/home/HomeSection.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
|
||||||
|
// A titled block of the home page; children are laid out as wrapping cards.
|
||||||
|
const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => {
|
||||||
|
const theme = props.context.themeManager.theme;
|
||||||
|
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
|
||||||
|
const isEmpty = React.Children.count(props.children) === 0;
|
||||||
|
return (
|
||||||
|
<section style={{ color: textColor }}>
|
||||||
|
<h3>{props.title}</h3>
|
||||||
|
{isEmpty && props.emptyText ? <p className="empty">{props.emptyText}</p> : <div className="cards">{props.children}</div>}
|
||||||
|
<style jsx>{`
|
||||||
|
section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 0 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.cards {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeSection;
|
||||||
89
apps/frontend/src/components/home/LiveGamesSection.tsx
Normal file
89
apps/frontend/src/components/home/LiveGamesSection.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
channel_on_live_games,
|
||||||
|
channel_subscribe_live_games,
|
||||||
|
channel_unsubscribe_live_games,
|
||||||
|
LiveGameSummary,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import { PopularGames } from "../../store/GameStore";
|
||||||
|
import { formatDate, getResultText, getScoreText } from "../../util/GameResultText";
|
||||||
|
import GamePreviewCard from "./GamePreviewCard";
|
||||||
|
import HomeSection from "./HomeSection";
|
||||||
|
|
||||||
|
const GAME_COUNT = 5;
|
||||||
|
|
||||||
|
// Live games, updated as they are played; when nothing is live, the most watched finished games.
|
||||||
|
const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [popular, setPopular] = useState<PopularGames | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const loadPopular = () => context.gameStore.getPopularGames(GAME_COUNT)
|
||||||
|
.then((games) => !disposed && setPopular(games))
|
||||||
|
.catch((error) => console.error("(Home) Could not load popular games", error));
|
||||||
|
const onLiveGames = (message: { games: LiveGameSummary[] }) => {
|
||||||
|
if (message.games.length > 0) setPopular({ kind: "live", games: message.games });
|
||||||
|
else loadPopular();
|
||||||
|
};
|
||||||
|
// Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users.
|
||||||
|
const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {});
|
||||||
|
context.rtmt.addMessageListener(channel_on_live_games, onLiveGames);
|
||||||
|
context.rtmt.on("open", subscribe);
|
||||||
|
if (context.rtmt.connectionState === "connected") subscribe();
|
||||||
|
loadPopular();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
context.rtmt.off("open", subscribe);
|
||||||
|
context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames);
|
||||||
|
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!popular) return <></>;
|
||||||
|
const openGame = (id: string) => navigate(`/game/${id}`);
|
||||||
|
|
||||||
|
if (popular.kind === "live") {
|
||||||
|
return (
|
||||||
|
<HomeSection context={context} title={t("LiveGames")}>
|
||||||
|
{popular.games.map((game) => (
|
||||||
|
<GamePreviewCard
|
||||||
|
key={game.id}
|
||||||
|
context={context}
|
||||||
|
id={game.id}
|
||||||
|
player1={game.player1}
|
||||||
|
player2={game.player2}
|
||||||
|
board={game.board}
|
||||||
|
turnPlayerId={game.turnPlayerId}
|
||||||
|
lines={[t("Watching", { count: game.spectatorCount }) + " · " + t("MoveNumber", { number: game.moveCount })]}
|
||||||
|
onClick={() => openGame(game.id)} />
|
||||||
|
))}
|
||||||
|
</HomeSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HomeSection context={context} title={t("PopularGames")} emptyText={t("NoGamesYet")}>
|
||||||
|
{popular.games.map((game) => (
|
||||||
|
<GamePreviewCard
|
||||||
|
key={game.id}
|
||||||
|
context={context}
|
||||||
|
id={game.id}
|
||||||
|
player1={game.player1}
|
||||||
|
player2={game.player2}
|
||||||
|
board={game.board}
|
||||||
|
lines={[
|
||||||
|
`${getResultText(game, undefined, t)} · ${getScoreText(game, undefined)}`,
|
||||||
|
`${formatDate(game.endedAt, i18n.language)} · ${t("Watching", { count: game.peakSpectators })}`,
|
||||||
|
]}
|
||||||
|
onClick={() => openGame(game.id)} />
|
||||||
|
))}
|
||||||
|
</HomeSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LiveGamesSection;
|
||||||
50
apps/frontend/src/components/home/RecentGamesSection.tsx
Normal file
50
apps/frontend/src/components/home/RecentGamesSection.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import { FinishedGameSummary } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
|
||||||
|
import GamePreviewCard from "./GamePreviewCard";
|
||||||
|
import HomeSection from "./HomeSection";
|
||||||
|
|
||||||
|
const GAME_COUNT = 3;
|
||||||
|
|
||||||
|
// The user's last finished games, each shown from their side of the board.
|
||||||
|
const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
context.authService.getAccessToken()
|
||||||
|
.then((token) => context.gameStore.getMyRecentGames(token, GAME_COUNT))
|
||||||
|
.then((recent) => !disposed && setGames(recent))
|
||||||
|
.catch((error) => console.error("(Home) Could not load recent games", error));
|
||||||
|
return () => { disposed = true; };
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
if (!games) return <></>;
|
||||||
|
return (
|
||||||
|
<HomeSection context={context} title={t("MyRecentGames")} emptyText={t("NoGamesYet")}>
|
||||||
|
{games.map((game) => (
|
||||||
|
<GamePreviewCard
|
||||||
|
key={game.id}
|
||||||
|
context={context}
|
||||||
|
id={game.id}
|
||||||
|
player1={game.player1}
|
||||||
|
player2={game.player2}
|
||||||
|
board={game.board}
|
||||||
|
revert={getViewerSeat(game, userId) === "player2"}
|
||||||
|
lines={[
|
||||||
|
`${getResultText(game, userId, t)} · ${getScoreText(game, userId)}`,
|
||||||
|
formatDate(game.endedAt, i18n.language),
|
||||||
|
]}
|
||||||
|
onClick={() => navigate(`/game/${game.id}`)} />
|
||||||
|
))}
|
||||||
|
</HomeSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentGamesSection;
|
||||||
@ -116,9 +116,7 @@ const GamePage: FunctionComponent<{
|
|||||||
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
||||||
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id);
|
|
||||||
return () => {
|
return () => {
|
||||||
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
|
|
||||||
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
@ -126,6 +124,19 @@ const GamePage: FunctionComponent<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Spectators tell the server they are watching once their identity is known and the connection is open,
|
||||||
|
// and again after every reconnect, since the server forgets spectators who go offline.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!game || !userKey || !checkIsSpectator(game)) return;
|
||||||
|
const listen = () => context.rtmt.sendMessage(channel_listen_game_events, game.id);
|
||||||
|
if (context.rtmt.connectionState === "connected") listen();
|
||||||
|
context.rtmt.on("open", listen);
|
||||||
|
return () => {
|
||||||
|
context.rtmt.off("open", listen);
|
||||||
|
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
|
||||||
|
};
|
||||||
|
}, [game?.id, userKey]);
|
||||||
|
|
||||||
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
|
const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
|
||||||
boardViewModel.id = v4();
|
boardViewModel.id = v4();
|
||||||
setBoardId(boardViewModel.id);
|
setBoardId(boardViewModel.id);
|
||||||
@ -207,8 +218,9 @@ const GamePage: FunctionComponent<{
|
|||||||
let pitAnimator: PitAnimator | undefined;
|
let pitAnimator: PitAnimator | undefined;
|
||||||
let unlistenMessages: () => void;
|
let unlistenMessages: () => void;
|
||||||
setLoadingStateGame(LoadingState.Loading())
|
setLoadingStateGame(LoadingState.Loading())
|
||||||
context.gameStore.get(params.gameId!!).then((game) => {
|
context.gameStore.get(params.gameId!!).then((loaded) => {
|
||||||
if (game) {
|
if (loaded?.kind === "live") {
|
||||||
|
const game = loaded.game;
|
||||||
pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel);
|
pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel);
|
||||||
setPitAnimator(pitAnimator);
|
setPitAnimator(pitAnimator);
|
||||||
onGameUpdate(pitAnimator, game);
|
onGameUpdate(pitAnimator, game);
|
||||||
@ -217,6 +229,9 @@ const GamePage: FunctionComponent<{
|
|||||||
} else {
|
} else {
|
||||||
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
|
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") }))
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
unlistenMessages?.();
|
unlistenMessages?.();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useState } from "react";
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness, LiveGameSummary } from "@mancala/core";
|
||||||
import { AuthUser, Theme } from "@mancala/core";
|
import { AuthUser, Theme } from "@mancala/core";
|
||||||
import HeaderBar from "../components/headerbar/HeaderBar";
|
import HeaderBar from "../components/headerbar/HeaderBar";
|
||||||
import PageContainer from "../components/PageContainer";
|
import PageContainer from "../components/PageContainer";
|
||||||
@ -9,6 +9,9 @@ import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
|
|||||||
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
|
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
|
||||||
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
|
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
|
||||||
import AccountMenu from "../components/headerbar/AccountMenu";
|
import AccountMenu from "../components/headerbar/AccountMenu";
|
||||||
|
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
||||||
|
import LiveGamesSection from "../components/home/LiveGamesSection";
|
||||||
|
import RecentGamesSection from "../components/home/RecentGamesSection";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import { Context } from "../context/context";
|
import { Context } from "../context/context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@ -24,6 +27,18 @@ const Home: FunctionComponent<{
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [activeGame, setActiveGame] = useState<LiveGameSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authUser) return;
|
||||||
|
let disposed = false;
|
||||||
|
context.authService.getAccessToken()
|
||||||
|
.then((token) => context.gameStore.getActiveGame(token))
|
||||||
|
.then((game) => !disposed && setActiveGame(game))
|
||||||
|
.catch((error) => console.error("(Home) Could not load the active game", error));
|
||||||
|
return () => { disposed = true; };
|
||||||
|
}, [authUser?.id]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onNewGameClick = () => {
|
const onNewGameClick = () => {
|
||||||
@ -57,6 +72,15 @@ const Home: FunctionComponent<{
|
|||||||
onClick={onNewGameClick} />
|
onClick={onNewGameClick} />
|
||||||
</Row>
|
</Row>
|
||||||
</HeaderBar>
|
</HeaderBar>
|
||||||
|
{activeGame && authUser && (
|
||||||
|
<ActiveGameBanner
|
||||||
|
context={context}
|
||||||
|
game={activeGame}
|
||||||
|
userId={authUser.id}
|
||||||
|
onContinue={() => navigate(`/game/${activeGame.id}`)} />
|
||||||
|
)}
|
||||||
|
<LiveGamesSection context={context} />
|
||||||
|
{authUser && <RecentGamesSection context={context} userId={authUser.id} />}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { CommonMancalaGame, MancalaGame } from 'mancala.js';
|
import { CommonMancalaGame, MancalaGame } from 'mancala.js';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FunctionComponent, useEffect } from 'react';
|
import { FunctionComponent, useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import Center from '../components/Center';
|
import Center from '../components/Center';
|
||||||
import CircularPanel from '../components/CircularPanel';
|
import CircularPanel from '../components/CircularPanel';
|
||||||
@ -10,7 +10,7 @@ import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
|
|||||||
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
|
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
|
||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import Row from '../components/Row';
|
import Row from '../components/Row';
|
||||||
import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core';
|
import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core';
|
||||||
import { Context } from '../context/context';
|
import { Context } from '../context/context';
|
||||||
import { Theme } from '@mancala/core';
|
import { Theme } from '@mancala/core';
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
@ -27,6 +27,11 @@ const LobyPage: FunctionComponent<{
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Set when the server refuses a new game because the user has one in progress.
|
||||||
|
const [activeGameId, setActiveGameId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId);
|
||||||
|
|
||||||
const onGameStart = (message: Object) => {
|
const onGameStart = (message: Object) => {
|
||||||
const newGame: CommonMancalaGame = message as CommonMancalaGame;
|
const newGame: CommonMancalaGame = message as CommonMancalaGame;
|
||||||
navigate(`/game/${newGame.id}`)
|
navigate(`/game/${newGame.id}`)
|
||||||
@ -40,9 +45,11 @@ const LobyPage: FunctionComponent<{
|
|||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
|
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
|
||||||
|
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
|
||||||
context.rtmt.sendMessage("new_game", {});
|
context.rtmt.sendMessage("new_game", {});
|
||||||
return () => {
|
return () => {
|
||||||
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
|
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
|
||||||
|
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -78,8 +85,15 @@ const LobyPage: FunctionComponent<{
|
|||||||
alignContent: "center"
|
alignContent: "center"
|
||||||
}}>
|
}}>
|
||||||
<CircularPanel color={context.themeManager.theme.boardColor}>
|
<CircularPanel color={context.themeManager.theme.boardColor}>
|
||||||
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${t("SearchingOpponent")} ${t("PleaseWait")}...`}</h4>
|
<h4 style={{ margin: "0", color: textColorOnBoard }}>
|
||||||
|
{activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`}
|
||||||
|
</h4>
|
||||||
</CircularPanel>
|
</CircularPanel>
|
||||||
|
{activeGameId && <Button
|
||||||
|
context={context}
|
||||||
|
color={context.themeManager.theme.pitColor}
|
||||||
|
text={t("Continue")}
|
||||||
|
onClick={() => navigate(`/game/${activeGameId}`)} />}
|
||||||
<Button
|
<Button
|
||||||
context={context}
|
context={context}
|
||||||
color={context.themeManager.theme.boardColor}
|
color={context.themeManager.theme.boardColor}
|
||||||
|
|||||||
@ -7,13 +7,12 @@ export class HttpServiceImpl implements HttpService {
|
|||||||
this.serverAdress = serverAdress;
|
this.serverAdress = serverAdress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(route: string): Promise<Response> {
|
// With a token, the request is authenticated as that user.
|
||||||
|
public async get(route: string, token?: string): Promise<Response> {
|
||||||
const url = this.serverAdress + route;
|
const url = this.serverAdress + route;
|
||||||
const requestOptions = {
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
method: 'GET',
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch(url, { method: 'GET', headers });
|
||||||
};
|
|
||||||
const response = await fetch(url, requestOptions);
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,28 +1,55 @@
|
|||||||
import { MancalaGame } from "mancala.js";
|
import { MancalaGame } from "mancala.js";
|
||||||
import { Game } from "@mancala/core";
|
import { FinishedGameResponse, FinishedGameSummary, Game, GameRecord, LiveGameSummary } from "@mancala/core";
|
||||||
import { HttpService } from "../service/HttpService";
|
import { HttpServiceImpl } from "../service/HttpService";
|
||||||
|
|
||||||
|
export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord };
|
||||||
|
|
||||||
|
export type PopularGames = { kind: "live", games: LiveGameSummary[] } | { kind: "finished", games: FinishedGameSummary[] };
|
||||||
|
|
||||||
export interface GameStore {
|
export interface GameStore {
|
||||||
get(id: string): Promise<Game | undefined>;
|
// A live game, or the saved record of a finished one; undefined if there is no such game.
|
||||||
|
get(id: string): Promise<LoadedGame | undefined>;
|
||||||
|
getActiveGame(token: string): Promise<LiveGameSummary | null>;
|
||||||
|
getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]>;
|
||||||
|
getPopularGames(limit: number): Promise<PopularGames>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameStoreImpl implements GameStore {
|
export class GameStoreImpl implements GameStore {
|
||||||
httpService: HttpService;
|
httpService: HttpServiceImpl;
|
||||||
constructor(props: { httpService: HttpService }) {
|
constructor(props: { httpService: HttpServiceImpl }) {
|
||||||
this.httpService = props.httpService;
|
this.httpService = props.httpService;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<Game | undefined> {
|
async get(id: string): Promise<LoadedGame | undefined> {
|
||||||
try {
|
|
||||||
const response = await this.httpService.get(`/game/${id}`);
|
const response = await this.httpService.get(`/game/${id}`);
|
||||||
|
if (response.status === 404) return undefined;
|
||||||
|
if (!response.ok) throw new Error(`Loading game ${id} failed: ${response.status}`);
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const game: Game = json as Game;
|
if ((json as FinishedGameResponse).status === "finished") {
|
||||||
game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame);
|
return { kind: "finished", record: (json as FinishedGameResponse).record };
|
||||||
return game;
|
|
||||||
} catch (error) {
|
|
||||||
// todo check error
|
|
||||||
Promise.resolve(undefined);
|
|
||||||
}
|
}
|
||||||
|
const game = json as Game;
|
||||||
|
game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame);
|
||||||
|
return { kind: "live", game };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActiveGame(token: string): Promise<LiveGameSummary | null> {
|
||||||
|
const json = await this.getJson("/games/me/active", token);
|
||||||
|
return json.game;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||||
|
const json = await this.getJson(`/games/me?limit=${limit}`, token);
|
||||||
|
return json.games;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPopularGames(limit: number): Promise<PopularGames> {
|
||||||
|
return this.getJson(`/games/popular?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson(route: string, token?: string): Promise<any> {
|
||||||
|
const response = await this.httpService.get(route, token);
|
||||||
|
if (!response.ok) throw new Error(`GET ${route} failed: ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
38
apps/frontend/src/util/GameResultText.ts
Normal file
38
apps/frontend/src/util/GameResultText.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { FinishedGameSummary, getPlayerDisplayName } from "@mancala/core";
|
||||||
|
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
const PLAYER1_STORE = 6;
|
||||||
|
const PLAYER2_STORE = 13;
|
||||||
|
|
||||||
|
// The result as the viewer would say it: "You won · Opponent left" for a player, "alice won" for anyone else.
|
||||||
|
export function getResultText(game: FinishedGameSummary, viewerId: string | undefined, t: Translate): string {
|
||||||
|
if (game.result === null) return t("Abandoned");
|
||||||
|
if (game.result === "draw") return t("Draw");
|
||||||
|
const winner = game.result === "player1" ? game.player1 : game.player2;
|
||||||
|
const viewerSeat = getViewerSeat(game, viewerId);
|
||||||
|
if (!viewerSeat) return t("PlayerWon", { name: getPlayerDisplayName(winner, t) ?? t("Anonymous") });
|
||||||
|
|
||||||
|
const viewerWon = winner.userId === viewerId;
|
||||||
|
const outcome = viewerWon ? t("YouWon") : t("YouLost");
|
||||||
|
if (game.endReason === "left") return `${outcome} · ${viewerWon ? t("OpponentLeft") : t("YouLeft")}`;
|
||||||
|
if (game.endReason === "timeout") return `${outcome} · ${viewerWon ? t("OpponentTimedOut") : t("YouTimedOut")}`;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final stones in the stores, the viewer's first when they played.
|
||||||
|
export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string {
|
||||||
|
const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE];
|
||||||
|
return getViewerSeat(game, viewerId) === "player2" ? `${player2} - ${player1}` : `${player1} - ${player2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getViewerSeat(game: FinishedGameSummary, viewerId: string | undefined): "player1" | "player2" | undefined {
|
||||||
|
if (viewerId === undefined) return undefined;
|
||||||
|
if (game.player1.userId === viewerId) return "player1";
|
||||||
|
if (game.player2.userId === viewerId) return "player2";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(epochMillis: number, language: string): string {
|
||||||
|
return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
- [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`,
|
- [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`,
|
||||||
`/game/:id` for finished games, live games channel with throttled push; tests.
|
`/game/:id` for finished games, live games channel with throttled push; tests.
|
||||||
- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
||||||
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
||||||
rejection message.
|
rejection message.
|
||||||
- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
|
- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
|
||||||
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.
|
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.
|
||||||
|
|||||||
@ -85,3 +85,28 @@ export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: nu
|
|||||||
);
|
);
|
||||||
return { ...game, mancalaGame };
|
return { ...game, mancalaGame };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A game showing the given stones, for displaying a board (e.g. a preview) when only the stone counts are known.
|
||||||
|
export function createGameFromBoard(params: {
|
||||||
|
id: string, player1Id: string, player2Id: string, turnPlayerId: string, board: number[],
|
||||||
|
}): Game {
|
||||||
|
const pits = params.board.map((stoneCount, index) =>
|
||||||
|
referenceBoard.checkPitTypeIsBankByIndex(index) ? new Bank(index, stoneCount) : new Pit(index, stoneCount));
|
||||||
|
const mancalaGame = new MancalaGame(
|
||||||
|
params.id,
|
||||||
|
new Board(referenceBoard.playerPitCount, referenceBoard.initialStoneCountInPits, pits),
|
||||||
|
params.player1Id,
|
||||||
|
params.player2Id,
|
||||||
|
params.turnPlayerId,
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
mancalaGame,
|
||||||
|
gameUsersConnectionInfo: {
|
||||||
|
user1ConnectionInfo: { userId: params.player1Id, isOnline: false },
|
||||||
|
user2ConnectionInfo: { userId: params.player2Id, isOnline: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export const en = {
|
|||||||
GameOver: "Game over",
|
GameOver: "Game over",
|
||||||
ViewingMove: "Viewing move {{number}}",
|
ViewingMove: "Viewing move {{number}}",
|
||||||
MoveOf: "Move {{number}} / {{total}}",
|
MoveOf: "Move {{number}} / {{total}}",
|
||||||
|
MoveNumber: "Move {{number}}",
|
||||||
Start: "Start",
|
Start: "Start",
|
||||||
BackToLive: "Back to live",
|
BackToLive: "Back to live",
|
||||||
WatchFromStart: "Watch from start",
|
WatchFromStart: "Watch from start",
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export const tr = {
|
|||||||
GameOver: "Oyun bitti",
|
GameOver: "Oyun bitti",
|
||||||
ViewingMove: "{{number}}. hamleye bakıyorsun",
|
ViewingMove: "{{number}}. hamleye bakıyorsun",
|
||||||
MoveOf: "Hamle {{number}} / {{total}}",
|
MoveOf: "Hamle {{number}} / {{total}}",
|
||||||
|
MoveNumber: "{{number}}. hamle",
|
||||||
Start: "Başlangıç",
|
Start: "Başlangıç",
|
||||||
BackToLive: "Canlıya dön",
|
BackToLive: "Canlıya dön",
|
||||||
WatchFromStart: "Baştan izle",
|
WatchFromStart: "Baştan izle",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user