From d26e0427dcf3d5eb900c09095faf6d40d00a92ab Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Wed, 1 Nov 2023 16:56:15 +0100 Subject: [PATCH 01/12] chore: update-browserlist --- package-lock.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07d4adc..eb76420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3064,9 +3064,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001412", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", - "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", + "version": "1.0.30001559", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001559.tgz", + "integrity": "sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==", "dev": true, "funding": [ { @@ -3076,6 +3076,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -16325,9 +16329,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001412", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", - "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", + "version": "1.0.30001559", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001559.tgz", + "integrity": "sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==", "dev": true }, "cardinal": { From 1ac6b9a404f58e19f1bef819f28632d32ce81f1d Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Wed, 1 Nov 2023 17:40:35 +0100 Subject: [PATCH 02/12] style: add bg color depending on current player to left column --- src/app.scss | 16 ++++++++ .../current-phase-controller.tsx | 18 +++++---- .../game/current-phase/current-phase.scss | 32 ++++++++------- .../game/current-phase/current-phase.tsx | 39 ++++++++++++------- 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/src/app.scss b/src/app.scss index 9f464d2..e890134 100644 --- a/src/app.scss +++ b/src/app.scss @@ -43,3 +43,19 @@ .ignore-clicks { pointer-events: none; } + +.player { + background-color: #fffaaf; +} + +.enemy1 { + background-color: #ffafaf; +} + +.enemy2 { + background-color: #afafff; +} + +.enemy3 { + background-color: #afffaf; +} diff --git a/src/components/game/current-phase/current-phase-controller.tsx b/src/components/game/current-phase/current-phase-controller.tsx index c3295f5..56b374c 100644 --- a/src/components/game/current-phase/current-phase-controller.tsx +++ b/src/components/game/current-phase/current-phase-controller.tsx @@ -1,22 +1,24 @@ import { useGameContext } from "contexts"; import { CurrentPhase } from "./current-phase"; import { mustSkip } from "./must-skip"; +import { warnInconsistentState } from "utils/console"; -/** - * Renders depends on: - * - `useGameContext()` - * - * Defines visual current phase from GameContext - * - */ export function CurrentPhaseController(): JSX.Element { const gameContext = useGameContext(); + if (!gameContext.phase || !gameContext.activePlayer) { + warnInconsistentState(`: no phase or active player found`, { + gameContext, + }); + return <>ERROR; + } + return ( { gameContext.skip(); }} diff --git a/src/components/game/current-phase/current-phase.scss b/src/components/game/current-phase/current-phase.scss index 2e66f1e..f6760de 100644 --- a/src/components/game/current-phase/current-phase.scss +++ b/src/components/game/current-phase/current-phase.scss @@ -1,27 +1,33 @@ .current-phase { - width: 100%; - + height: 100vh; display: flex; flex-direction: column; justify-content: flex-start; align-items: center; - - background-color: rgb(243, 220, 220); + overflow: hidden; &__heading { - height: 100px; + display: flex; + flex: 1; + max-height: 10%; + align-items: center; // center Y + + border-bottom: 1px solid #ccc; } - &__planning { - height: 100%; + &__content { + display: flex; + flex-direction: column; + flex: 1; + max-height: 90%; + justify-content: center; - .card-silhouette { - margin: 10px; // FIXME - } - } + &__planning {} - &__action { - height: 100%; + &__action {} } +} +.card-silhouette { + margin: 10px; // FIXME } diff --git a/src/components/game/current-phase/current-phase.tsx b/src/components/game/current-phase/current-phase.tsx index 5174f1d..2388205 100644 --- a/src/components/game/current-phase/current-phase.tsx +++ b/src/components/game/current-phase/current-phase.tsx @@ -2,18 +2,21 @@ import { Button } from "components/common"; import IconCard from "components/game/cards/icon-card"; // TODO: deprecated import { logRender } from "utils/console"; import { CardSilhouette } from "components/game/cards"; +import c from "classnames"; import "./current-phase.scss"; -interface Props { - phase?: PhaseType; +type Props = { + phase: PhaseType; + activePlayer: PlayerType; activeCard?: Card; mustSkip?: boolean; onSkip: () => void; -} +}; export function CurrentPhase({ - phase = 'setup', + phase, + activePlayer, activeCard = undefined, mustSkip = false, onSkip, @@ -21,22 +24,28 @@ export function CurrentPhase({ logRender("CurrentPhase"); return ( -
+

{phase}

- {phase === "action" ? ( - - ) : ( - - )} +
+ {phase === "action" ? ( + + ) : ( + + )} +
); } -function ActionPhase({ activeCard, mustSkip, onSkip }: Omit) { +function ActionPhase({ + activeCard, + mustSkip, + onSkip, +}: Omit) { return (
From 6958c3f01c8bf091081ed4aa3612e668fea805cd Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Fri, 3 Nov 2023 15:40:43 +0100 Subject: [PATCH 03/12] feat(planning): including 'commit' or 'pending' actions --- src/@types/storming.d.ts | 33 +++--- .../game/board/infer-visual-board.test.ts | 12 +- .../__snapshots__/current-phase.test.tsx.snap | 58 +++++----- .../game/current-phase/current-phase.test.tsx | 8 +- .../game/current-phase/must-skip.test.ts | 39 +++---- .../player-hand/infer-player-hands.test.ts | 104 ++++++++---------- .../game/player-hand/infer-player-hands.ts | 20 ++-- .../player-hand/player-hand-controller.tsx | 10 +- .../game/player-hand/player-hand.test.tsx | 42 +++---- .../game/player-hand/player-hand.tsx | 12 +- .../game/timeline/timeline.test.tsx | 40 +++---- src/components/game/timeline/timeline.tsx | 8 +- .../game-context/use-game-context.tsx | 11 +- .../game-context/use-timeline.test.tsx | 34 +++--- src/contexts/game-context/use-timeline.tsx | 13 ++- src/game-logic/available-tiles.test.ts | 55 +++------ src/models/card.ts | 17 ++- src/models/index.ts | 2 - 18 files changed, 239 insertions(+), 279 deletions(-) delete mode 100644 src/models/index.ts diff --git a/src/@types/storming.d.ts b/src/@types/storming.d.ts index 919be94..c3ce8b1 100644 --- a/src/@types/storming.d.ts +++ b/src/@types/storming.d.ts @@ -63,34 +63,41 @@ interface TileWithStatus extends Tile { // CARDS // -------------- +type CardId = `${PlayerType}_${ActionCardType | EventCardType}_${number}`; + type ActionCardType = "build" | "diplo" | "move" | "recruit"; type Card = ActionCard | EventCard; -interface ActionCard { +type ActionCard = { cardType: "actionCard"; action: ActionCardType; owner: PlayerType; - cardId: string; -} + cardId: CardId; +}; -type EventCardType = "even1" | "event2" | "event3"; +type EventCardType = "event1" | "event2" | "event3"; -interface EventCard { +type EventCard = { cardType: "eventCard"; event: EventCardType; playedBy: PlayerType; - cardId: string; -} + cardId: CardId; +}; // -------------- // TIMELINE // -------------- +type TimelineCard = { + card: Card; + commited: boolean; +}; + type Timeline = { current: Card | undefined; - next: Card[]; - future: Card[]; + next: TimelineCard[]; + future: TimelineCard[]; }; // -------------- @@ -142,7 +149,7 @@ interface PlayerStatus { greatestEmpirePoint: boolean; } -interface GameContext { +type GameContext = { phase: PhaseType; board: Board; timeline: Timeline; @@ -157,8 +164,8 @@ interface GameContext { firstPlayer(player: PlayerType): void; skip(): void; - loadSavegame(gameContext: GameContext): void -} + loadSavegame(gameContext: GameContext): void; +}; // ---- @@ -166,4 +173,4 @@ type Savegame = { createdAt: string; // ms from Epoch playerEmpireSize: number; gameContext: GameContext; -} +}; diff --git a/src/components/game/board/infer-visual-board.test.ts b/src/components/game/board/infer-visual-board.test.ts index 909fe1a..cae9a54 100644 --- a/src/components/game/board/infer-visual-board.test.ts +++ b/src/components/game/board/infer-visual-board.test.ts @@ -1,17 +1,11 @@ import { initialBoard } from "contexts/game-context/initial-board"; import { inferVisualBoardFromGameContext } from "./infer-visual-board"; +import { Card } from "models/card"; -test("board/inferVisualBoardFromGameContext()", () => { - const activeCard: Card = { - action: "build", - owner: "player", - cardType: "actionCard", - cardId: "player_build_A", - }; - +test("inferVisualBoardFromGameContext()", () => { const board = inferVisualBoardFromGameContext({ board: initialBoard, - activeCard, + activeCard: Card("build", "player"), }); /* only settlement of player at "-4,0" */ diff --git a/src/components/game/current-phase/__snapshots__/current-phase.test.tsx.snap b/src/components/game/current-phase/__snapshots__/current-phase.test.tsx.snap index 662f283..a770ad0 100644 --- a/src/components/game/current-phase/__snapshots__/current-phase.test.tsx.snap +++ b/src/components/game/current-phase/__snapshots__/current-phase.test.tsx.snap @@ -3,50 +3,54 @@ exports[` render: match snapshot 1`] = `

- setup + planification

- next +
+ next +
+
+ Drag here the card you wish to use next round +
- Drag here the card you wish to use next round +
+ future +
+
+ Drag here the card you wish to use in two rounds +
-
-
-
- future -
-
- Drag here the card you wish to use in two rounds -
+ GO +
-
diff --git a/src/components/game/current-phase/current-phase.test.tsx b/src/components/game/current-phase/current-phase.test.tsx index f914a3b..18b5e3d 100644 --- a/src/components/game/current-phase/current-phase.test.tsx +++ b/src/components/game/current-phase/current-phase.test.tsx @@ -3,7 +3,13 @@ import { CurrentPhase } from "./current-phase"; describe("", () => { test("render: match snapshot", () => { - const { asFragment } = render(); + const { asFragment } = render( + + ); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/src/components/game/current-phase/must-skip.test.ts b/src/components/game/current-phase/must-skip.test.ts index 714b5c2..0d1863f 100644 --- a/src/components/game/current-phase/must-skip.test.ts +++ b/src/components/game/current-phase/must-skip.test.ts @@ -1,29 +1,22 @@ import { mustSkip } from "./must-skip"; import { emptyBoard } from "contexts/game-context/empty-board"; import { initialBoard } from "contexts/game-context/initial-board"; +import { Card } from "models/card"; -test("mustSkip() returns true if no available tiles for action card", () => { - const gameContext = { - activeCard: { - cardType: "actionCard", - action: "build", - owner: "player", - cardId: "player_build_A", - }, - board: emptyBoard, - } as GameContext; - expect(mustSkip(gameContext)).toBe(true); -}); +describe("mustSkip()", () => { + test("returns true if no available tiles for action card", () => { + const gameContext = { + activeCard: Card("build", "player"), + board: emptyBoard, + } as GameContext; + expect(mustSkip(gameContext)).toBe(true); + }); -test("mustSkip() returns false if there are available tiles for action card", () => { - const gameContext = { - activeCard: { - cardType: "actionCard", - action: "build", - owner: "player", - cardId: "player_build_A", - }, - board: initialBoard, - } as GameContext; - expect(mustSkip(gameContext)).toBe(false); + test("returns false if there are available tiles for action card", () => { + const gameContext = { + activeCard: Card("build", "player"), + board: initialBoard, + } as GameContext; + expect(mustSkip(gameContext)).toBe(false); + }); }); diff --git a/src/components/game/player-hand/infer-player-hands.test.ts b/src/components/game/player-hand/infer-player-hands.test.ts index cae964d..e91a0bd 100644 --- a/src/components/game/player-hand/infer-player-hands.test.ts +++ b/src/components/game/player-hand/infer-player-hands.test.ts @@ -1,63 +1,53 @@ import { inferPlayerHandsFromGameContext } from "./infer-player-hands"; +import { Card, _resetCardId } from "models/card"; -const timeline: Timeline = { - current: undefined, - next: [ - { - cardType: "actionCard", - action: "build", - owner: "player", - cardId: "player_build_1", - }, - { - cardType: "actionCard", - action: "move", - owner: "enemy1", - cardId: "enemy1_move_1", - }, - { - cardType: "actionCard", - action: "recruit", - owner: "enemy2", - cardId: "enemy2_recruit_1", - }, - { - cardType: "actionCard", - action: "move", - owner: "enemy3", - cardId: "enemy3_move_2", - }, - ], - future: [ - { - cardType: "actionCard", - action: "move", - owner: "player", - cardId: "player_move_1", - }, - { - cardType: "actionCard", - action: "move", - owner: "enemy1", - cardId: "enemy1_move_2", - }, - { - cardType: "actionCard", - action: "diplo", - owner: "enemy2", - cardId: "enemy2_diplo_1", - }, - { - cardType: "actionCard", - action: "build", - owner: "enemy3", - cardId: "enemy3_build_1", - }, - ], -}; +function mockTimeline(): Timeline { + return { + current: undefined, + next: [ + { + card: Card("build", "player"), + commited: true, + }, + { + card: Card("move", "enemy1"), + commited: true, + }, + { + card: Card("recruit", "enemy2"), + commited: true, + }, + { + card: Card("move", "enemy3"), + commited: true, + }, + ], + future: [ + { + card: Card("move", "player"), + commited: true, + }, + { + card: Card("move", "enemy1"), + commited: true, + }, + { + card: Card("diplo", "enemy2"), + commited: true, + }, + { + card: Card("build", "enemy3"), + commited: true, + }, + ], + }; +} describe("inferPlayerHandsFromGameContext()", () => { + beforeEach(_resetCardId); + it("returns card status grouped by player", () => { + const timeline = mockTimeline(); const got = inferPlayerHandsFromGameContext(timeline); const player = [ @@ -221,7 +211,7 @@ describe("inferPlayerHandsFromGameContext()", () => { owner: "enemy3", cardId: "enemy3_move_1", }, - status: "available", + status: "played", }, { card: { @@ -230,7 +220,7 @@ describe("inferPlayerHandsFromGameContext()", () => { owner: "enemy3", cardId: "enemy3_move_2", }, - status: "played", + status: "available", }, { card: { diff --git a/src/components/game/player-hand/infer-player-hands.ts b/src/components/game/player-hand/infer-player-hands.ts index 8826650..0667906 100644 --- a/src/components/game/player-hand/infer-player-hands.ts +++ b/src/components/game/player-hand/infer-player-hands.ts @@ -1,13 +1,20 @@ -import { PLAYER_CARDS } from "models"; +import { PLAYER_CARDS } from "models/player-cards"; +import { isActionCard } from "models/card"; function getPlayedActionCards(timeline: Timeline): ActionCard[] { const playedCards = [...timeline.next, ...timeline.future]; if (timeline.current) { - playedCards.unshift(timeline.current); // insert timeline.current >> [X...] + if (isActionCard(timeline.current)) { + // insert timeline.current >> [X...] + playedCards.unshift({ + card: timeline.current, + commited: true, + }); + } } - return playedCards.filter( - (playedCard) => playedCard.cardType === "actionCard" - ) as ActionCard[]; + return playedCards + .filter((playedCard) => isActionCard(playedCard.card)) + .map((playedCard) => playedCard.card as ActionCard); } function defineCardStatus({ @@ -16,7 +23,7 @@ function defineCardStatus({ playedActionCards, }: { playedActionCards: ActionCard[]; - cardId: string; + cardId: CardId; selectedCard?: ActionCard; }): PlayerHandCardStatus { if (selectedCard && selectedCard.cardId === cardId) { @@ -41,7 +48,6 @@ export function inferPlayerHandsFromGameContext( selectedCard?: ActionCard ) { const playedActionCards = getPlayedActionCards(timeline); - const players = Object.keys(PLAYER_CARDS) as PlayerType[]; const playerHands = players.reduce((acc, player) => { const playerHand = PLAYER_CARDS[player].map((card) => { diff --git a/src/components/game/player-hand/player-hand-controller.tsx b/src/components/game/player-hand/player-hand-controller.tsx index ad2d03f..eeb23f4 100644 --- a/src/components/game/player-hand/player-hand-controller.tsx +++ b/src/components/game/player-hand/player-hand-controller.tsx @@ -2,7 +2,7 @@ import { useGameContext } from "contexts"; import { useEffect, useState } from "react"; import { warnInconsistentState } from "utils/console"; import { inferPlayerHandsFromGameContext } from "./infer-player-hands"; -import PlayerHand from "./player-hand"; +import { PlayerHand } from "./player-hand"; /** * Renders depends on: @@ -13,7 +13,7 @@ import PlayerHand from "./player-hand"; */ function PlayerHandController(): JSX.Element { const gameContext = useGameContext(); - const [nextCard, setNextCard] = useState(undefined); + const [nextCard, setNextCard] = useState(); // FIXME can I change the approach: new player -> no selected card useEffect(() => { @@ -29,7 +29,7 @@ function PlayerHandController(): JSX.Element { ? playerCards[gameContext.activePlayer] : []; - const handlePlayerCardClick = (cardId: string) => { + const handlePlayerCardClick = (cardId: CardId) => { if (!gameContext.activePlayer) { return warnInconsistentState( `trying to handle click on ${cardId} while no activePlayer` @@ -43,8 +43,8 @@ function PlayerHandController(): JSX.Element { ); } - if (playerCard.status !== 'available') { - return + if (playerCard.status !== "available") { + return; } if (nextCard) { diff --git a/src/components/game/player-hand/player-hand.test.tsx b/src/components/game/player-hand/player-hand.test.tsx index 3335d01..594eb08 100644 --- a/src/components/game/player-hand/player-hand.test.tsx +++ b/src/components/game/player-hand/player-hand.test.tsx @@ -1,41 +1,27 @@ import { render } from "@testing-library/react"; -import PlayerHand from "./player-hand"; - -describe("", () => { - test("render: match snapshot", () => { - const { asFragment } = render( - - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); +import { Card } from "models/card"; +import { PlayerHand } from "./player-hand"; const playerHand: PlayerHand = [ { - card: { - cardType: "actionCard", - action: "move", - owner: "player", - cardId: "player_move_A", - }, + card: Card("move", "player"), status: "available", }, { - card: { - cardType: "actionCard", - action: "move", - owner: "player", - cardId: "player_move_B", - }, + card: Card("move", "player"), status: "available", }, { - card: { - cardType: "actionCard", - action: "build", - owner: "player", - cardId: "player_build_A", - }, + card: Card("build", "player"), status: "available", }, ]; + +describe("", () => { + test("render: match snapshot", () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/game/player-hand/player-hand.tsx b/src/components/game/player-hand/player-hand.tsx index 29e09d3..a12ac86 100644 --- a/src/components/game/player-hand/player-hand.tsx +++ b/src/components/game/player-hand/player-hand.tsx @@ -3,14 +3,14 @@ import { logRender } from "utils/console"; import "./player-hand.scss"; -interface Props { +type Props = { cards: PlayerHand; isActive?: boolean; player?: PlayerType; - onClick: (cardId: string) => void; -} + onClick: (cardId: CardId) => void; +}; -function PlayerHand({ +export function PlayerHand({ cards, isActive = false, player = undefined, @@ -18,7 +18,7 @@ function PlayerHand({ }: Props): JSX.Element { logRender("PlayerHand"); - const considerOnClickIfActive = (cardId: string) => { + const considerOnClickIfActive = (cardId: CardId) => { isActive && onClick(cardId); }; @@ -38,5 +38,3 @@ function PlayerHand({
); } - -export default PlayerHand; diff --git a/src/components/game/timeline/timeline.test.tsx b/src/components/game/timeline/timeline.test.tsx index ceffeff..42c1647 100644 --- a/src/components/game/timeline/timeline.test.tsx +++ b/src/components/game/timeline/timeline.test.tsx @@ -1,39 +1,29 @@ import { render } from "@testing-library/react"; +import { Card } from "models/card"; import { Timeline } from "./timeline"; -describe("", () => { - test("render: match snapshot", () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); -}); - const timeline: Timeline = { - current: { - cardType: "actionCard", - action: "move", - owner: "player", - cardId: "player_move_A", - }, + current: Card("move", "player"), next: [ { - cardType: "actionCard", - action: "move", - owner: "enemy1", - cardId: "enemy1_move_A", + card: Card("move", "enemy1"), + commited: true, }, { - cardType: "actionCard", - action: "move", - owner: "enemy2", - cardId: "enemy2_move_A", + card: Card("move", "enemy2"), + commited: true, }, { - cardType: "actionCard", - action: "move", - owner: "enemy3", - cardId: "enemy3_move_A", + card: Card("move", "enemy3"), + commited: true, }, ], future: [], }; + +describe("", () => { + test("render: match snapshot", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/game/timeline/timeline.tsx b/src/components/game/timeline/timeline.tsx index 7d0bce3..954b3be 100644 --- a/src/components/game/timeline/timeline.tsx +++ b/src/components/game/timeline/timeline.tsx @@ -3,15 +3,15 @@ import { ActionLineItem } from "./line-item"; import "./timeline.scss"; -interface Props { +type Props = { timeline: Timeline; -} +}; export function Timeline({ timeline }: Props): JSX.Element { logRender("Timeline"); - const renderLineItems = (section: Card[]) => { - return section.map((card) => { + const renderLineItems = (section: TimelineCard[]) => { + return section.map(({ card }) => { if (card.cardType === "actionCard") return ; }); diff --git a/src/contexts/game-context/use-game-context.tsx b/src/contexts/game-context/use-game-context.tsx index 925bd36..58081d2 100644 --- a/src/contexts/game-context/use-game-context.tsx +++ b/src/contexts/game-context/use-game-context.tsx @@ -87,25 +87,25 @@ export function GameContextProvider({ children }: Props): JSX.Element { break; case "planification": nextPhase = "action"; - nextCard(); // XXX break; case "action": nextPhase = "planification"; break; } - console.info(`GameContext.changePhase() (${phase} -> ${nextPhase})`); + console.info(`changePhase() (${phase} -> ${nextPhase})`); if (nextPhase === "planification") { newTurn(); } + if (nextPhase === "action") { + nextCard(); + } setPhase(nextPhase); }; const resolveActionCard = () => { nextCard(); if (timeline.next.length === 0) { - console.info( - "GameContext.resolveActionCard(): no more cards to resolve this turn" - ); + console.info("resolveActionCard(): no more cards to resolve this turn"); changePhase(); } }; @@ -152,6 +152,7 @@ export function GameContextProvider({ children }: Props): JSX.Element { plan(action: PlanAction) { const { next } = planification(action); if (next.length % 4 === 0) { + console.info("plan(): all players have planned, change phase"); changePhase(); } }, diff --git a/src/contexts/game-context/use-timeline.test.tsx b/src/contexts/game-context/use-timeline.test.tsx index f3a1e10..268aaee 100644 --- a/src/contexts/game-context/use-timeline.test.tsx +++ b/src/contexts/game-context/use-timeline.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { useTimeline } from "./use-timeline"; +import { Card } from "models/card"; function TestingComponent() { const { timeline, planification, nextCard, newTurn } = useTimeline(); @@ -11,12 +12,13 @@ function TestingComponent() {
- {timeline.next[0]?.cardType === "actionCard" && timeline.next[0].action} + {timeline.next[0]?.card.cardType === "actionCard" && + timeline.next[0].card.action}
- {timeline.future[0]?.cardType === "actionCard" && - timeline.future[0].action} + {timeline.future[0]?.card.cardType === "actionCard" && + timeline.future[0].card.action}
+ + ); +} + +function PlanningPhase({ + nextAction, + futureAction, + onSubmitPlan, +}: PlanningPhaseProps) { + return ( +
+ {nextAction ? : } + {futureAction ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/game/current-phase/must-skip.test.ts b/src/components/current-phase/must-skip.test.ts similarity index 65% rename from src/components/game/current-phase/must-skip.test.ts rename to src/components/current-phase/must-skip.test.ts index 0d1863f..70c7945 100644 --- a/src/components/game/current-phase/must-skip.test.ts +++ b/src/components/current-phase/must-skip.test.ts @@ -1,12 +1,12 @@ +import { emptyBoard } from "game-context/empty-board"; +import { initialBoard } from "game-context/initial-board"; +import { NewCard } from "models/new-card"; import { mustSkip } from "./must-skip"; -import { emptyBoard } from "contexts/game-context/empty-board"; -import { initialBoard } from "contexts/game-context/initial-board"; -import { Card } from "models/card"; describe("mustSkip()", () => { test("returns true if no available tiles for action card", () => { const gameContext = { - activeCard: Card("build", "player"), + activeCard: NewCard("build", "player"), board: emptyBoard, } as GameContext; expect(mustSkip(gameContext)).toBe(true); @@ -14,7 +14,7 @@ describe("mustSkip()", () => { test("returns false if there are available tiles for action card", () => { const gameContext = { - activeCard: Card("build", "player"), + activeCard: NewCard("build", "player"), board: initialBoard, } as GameContext; expect(mustSkip(gameContext)).toBe(false); diff --git a/src/components/current-phase/must-skip.ts b/src/components/current-phase/must-skip.ts new file mode 100644 index 0000000..aeaa68a --- /dev/null +++ b/src/components/current-phase/must-skip.ts @@ -0,0 +1,17 @@ +import { getAvailableTilesForActionCard } from "game-logic/available-tiles"; + +export function mustSkip({ + activeCard, + board, +}: { + activeCard: Card | undefined; + board: Board; +}) { + if (activeCard?.cardType === "actionCard") { + return ( + activeCard.action !== "diplo" && + getAvailableTilesForActionCard({ board, activeCard }).length === 0 + ); + } + return false; +} diff --git a/src/components/game/current-phase/current-phase-controller.tsx b/src/components/game/current-phase/current-phase-controller.tsx deleted file mode 100644 index 56b374c..0000000 --- a/src/components/game/current-phase/current-phase-controller.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useGameContext } from "contexts"; -import { CurrentPhase } from "./current-phase"; -import { mustSkip } from "./must-skip"; -import { warnInconsistentState } from "utils/console"; - -export function CurrentPhaseController(): JSX.Element { - const gameContext = useGameContext(); - - if (!gameContext.phase || !gameContext.activePlayer) { - warnInconsistentState(`: no phase or active player found`, { - gameContext, - }); - return <>ERROR; - } - - return ( - { - gameContext.skip(); - }} - /> - ); -} diff --git a/src/components/game/current-phase/current-phase.tsx b/src/components/game/current-phase/current-phase.tsx deleted file mode 100644 index 2388205..0000000 --- a/src/components/game/current-phase/current-phase.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Button } from "components/common"; -import IconCard from "components/game/cards/icon-card"; // TODO: deprecated -import { logRender } from "utils/console"; -import { CardSilhouette } from "components/game/cards"; -import c from "classnames"; - -import "./current-phase.scss"; - -type Props = { - phase: PhaseType; - activePlayer: PlayerType; - activeCard?: Card; - mustSkip?: boolean; - onSkip: () => void; -}; - -export function CurrentPhase({ - phase, - activePlayer, - activeCard = undefined, - mustSkip = false, - onSkip, -}: Props): JSX.Element { - logRender("CurrentPhase"); - - return ( -
-

{phase}

-
- {phase === "action" ? ( - - ) : ( - - )} -
-
- ); -} - -function ActionPhase({ - activeCard, - mustSkip, - onSkip, -}: Omit) { - return ( -
- - -
- ); -} - -function PlanningPhase() { - return ( -
- - - -
- ); -} diff --git a/src/components/game/current-phase/must-skip.ts b/src/components/game/current-phase/must-skip.ts deleted file mode 100644 index 4b4d2fe..0000000 --- a/src/components/game/current-phase/must-skip.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getAvailableTilesForActionCard } from "game-logic/available-tiles"; - -export function mustSkip(gameContext: GameContext) { - if (gameContext.activeCard?.cardType === "actionCard") { - return ( - gameContext.activeCard.action !== "diplo" && - getAvailableTilesForActionCard({ - board: gameContext.board, - activeCard: gameContext.activeCard, - }).length === 0 - ); - } - return false; -} diff --git a/src/components/game/log-panel/log-panel.scss b/src/components/game/log-panel/log-panel.scss deleted file mode 100644 index 1f61a0b..0000000 --- a/src/components/game/log-panel/log-panel.scss +++ /dev/null @@ -1,27 +0,0 @@ -@use '/src/styles/colors.scss'; - - -.log-panel { - display: flex; - flex-direction: column-reverse; - - &__entry { - - &--player { - outline: 2px solid colors.$player-primary; - } - - &--enemy1 { - outline: 2px solid colors.$enemy1-primary; - } - - &--enemy2 { - outline: 2px solid colors.$enemy2-primary; - } - - &--enemy3 { - outline: 2px solid colors.$enemy3-primary; - } - } - -} diff --git a/src/components/game/log-panel/log-panel.tsx b/src/components/game/log-panel/log-panel.tsx deleted file mode 100644 index c2cd9eb..0000000 --- a/src/components/game/log-panel/log-panel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useGameLog } from "contexts"; -import c from "classnames"; - -import "./log-panel.scss"; - -function LogPanel(): JSX.Element { - const { actions } = useGameLog(); - return ( -
- {actions.map((action, i) => ( - - ))} -
- ); -} - -interface LogEntryProps { - action: ActionLog; -} - -function LogEntry({ action }: LogEntryProps): JSX.Element { - return ( -
- {action.msg} -
- ); -} - -export default LogPanel; diff --git a/src/components/game/timeline/timeline-controller.tsx b/src/components/game/timeline/timeline-controller.tsx deleted file mode 100644 index 4d00600..0000000 --- a/src/components/game/timeline/timeline-controller.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useGameContext } from "contexts"; -import { Timeline } from "./timeline"; - -/** - * Renders depends on: - * - useGameContext() - */ -export function TimelineController(): JSX.Element { - const gameContext = useGameContext(); - - return ; -} diff --git a/src/components/index.ts b/src/components/index.ts index 9f93599..db013e1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,8 +1,7 @@ -export { Menu } from "./common/menu/menu"; -export { default as Board } from "./game/board/board-controller"; -export { default as LogPanel } from "./game/log-panel/log-panel"; -export { default as Marketplace } from "./game/marketplace/marketplace"; -export { default as PlayerHand } from "./game/player-hand/player-hand-controller"; -export { default as RoundSummary } from "./game/round-summary/round-summary"; -export { TimelineController as TimeLine } from "./game/timeline/timeline-controller"; -export { CurrentPhaseController as CurrentPhase } from "./game/current-phase/current-phase-controller"; +export { default as Board } from "./board/board-controller"; +export { CurrentPhaseController as CurrentPhase } from "./current-phase/current-phase-controller"; +export { default as Marketplace } from "./marketplace/marketplace"; +export { Menu } from "./menu/menu"; +export { PlayerHandController as PlayerHand } from "./player-hand/player-hand-controller"; +export { default as RoundSummary } from "./round-summary/round-summary"; +export { TimelineController as TimeLine } from "./timeline/timeline-controller"; diff --git a/src/components/game/marketplace/marketplace.scss b/src/components/marketplace/marketplace.scss similarity index 100% rename from src/components/game/marketplace/marketplace.scss rename to src/components/marketplace/marketplace.scss diff --git a/src/components/game/marketplace/marketplace.tsx b/src/components/marketplace/marketplace.tsx similarity index 100% rename from src/components/game/marketplace/marketplace.tsx rename to src/components/marketplace/marketplace.tsx diff --git a/src/components/common/menu/menu-icon.svg b/src/components/menu/menu-icon.svg similarity index 100% rename from src/components/common/menu/menu-icon.svg rename to src/components/menu/menu-icon.svg diff --git a/src/components/common/menu/menu.scss b/src/components/menu/menu.scss similarity index 100% rename from src/components/common/menu/menu.scss rename to src/components/menu/menu.scss diff --git a/src/components/common/menu/menu.tsx b/src/components/menu/menu.tsx similarity index 95% rename from src/components/common/menu/menu.tsx rename to src/components/menu/menu.tsx index a007705..b68f1bf 100644 --- a/src/components/common/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -1,5 +1,5 @@ -import { Button, Dialog } from "components/common"; -import { useGameContext } from "contexts"; +import { Button, Dialog } from "elements"; +import { useGameContext } from "game-context"; import { useState } from "react"; import { listSavegames, loadSavegame, savegame } from "services/db"; import { logRender } from "utils/console"; diff --git a/src/components/game/player-hand/__snapshots__/player-hand.test.tsx.snap b/src/components/player-hand/__snapshots__/player-hand.test.tsx.snap similarity index 100% rename from src/components/game/player-hand/__snapshots__/player-hand.test.tsx.snap rename to src/components/player-hand/__snapshots__/player-hand.test.tsx.snap diff --git a/src/components/game/player-hand/infer-player-hands.test.ts b/src/components/player-hand/infer-player-hands.test.ts similarity index 92% rename from src/components/game/player-hand/infer-player-hands.test.ts rename to src/components/player-hand/infer-player-hands.test.ts index e91a0bd..c8085fa 100644 --- a/src/components/game/player-hand/infer-player-hands.test.ts +++ b/src/components/player-hand/infer-player-hands.test.ts @@ -1,42 +1,42 @@ +import { NewCard, _resetCardId } from "models/new-card"; import { inferPlayerHandsFromGameContext } from "./infer-player-hands"; -import { Card, _resetCardId } from "models/card"; function mockTimeline(): Timeline { return { current: undefined, next: [ { - card: Card("build", "player"), + card: NewCard("build", "player"), commited: true, }, { - card: Card("move", "enemy1"), + card: NewCard("move", "enemy1"), commited: true, }, { - card: Card("recruit", "enemy2"), + card: NewCard("recruit", "enemy2"), commited: true, }, { - card: Card("move", "enemy3"), + card: NewCard("move", "enemy3"), commited: true, }, ], future: [ { - card: Card("move", "player"), + card: NewCard("move", "player"), commited: true, }, { - card: Card("move", "enemy1"), + card: NewCard("move", "enemy1"), commited: true, }, { - card: Card("diplo", "enemy2"), + card: NewCard("diplo", "enemy2"), commited: true, }, { - card: Card("build", "enemy3"), + card: NewCard("build", "enemy3"), commited: true, }, ], diff --git a/src/components/game/player-hand/infer-player-hands.ts b/src/components/player-hand/infer-player-hands.ts similarity index 62% rename from src/components/game/player-hand/infer-player-hands.ts rename to src/components/player-hand/infer-player-hands.ts index 0667906..00a1911 100644 --- a/src/components/game/player-hand/infer-player-hands.ts +++ b/src/components/player-hand/infer-player-hands.ts @@ -1,16 +1,19 @@ +import { isActionCard } from "models/new-card"; import { PLAYER_CARDS } from "models/player-cards"; -import { isActionCard } from "models/card"; -function getPlayedActionCards(timeline: Timeline): ActionCard[] { - const playedCards = [...timeline.next, ...timeline.future]; - if (timeline.current) { - if (isActionCard(timeline.current)) { - // insert timeline.current >> [X...] - playedCards.unshift({ - card: timeline.current, - commited: true, - }); - } +function getPlayedActionCards({ + activeCard, + next, + future, +}: { + activeCard: Card | undefined; + next: TimelineCard[]; + future: TimelineCard[]; +}): ActionCard[] { + const playedCards = [...next, ...future]; + if (activeCard && isActionCard(activeCard)) { + // insert timeline.current >> [X...] + playedCards.unshift({ card: activeCard, commited: true }); } return playedCards .filter((playedCard) => isActionCard(playedCard.card)) @@ -19,14 +22,14 @@ function getPlayedActionCards(timeline: Timeline): ActionCard[] { function defineCardStatus({ cardId, - selectedCard, + nextCard, playedActionCards, }: { - playedActionCards: ActionCard[]; cardId: CardId; - selectedCard?: ActionCard; + nextCard?: ActionCard; + playedActionCards: ActionCard[]; }): PlayerHandCardStatus { - if (selectedCard && selectedCard.cardId === cardId) { + if (nextCard && nextCard.cardId === cardId) { return "selected"; } const hasBeenPlayed = playedActionCards.some( @@ -44,16 +47,20 @@ function defineCardStatus({ * } */ export function inferPlayerHandsFromGameContext( - timeline: Timeline, - selectedCard?: ActionCard + playedCards: { + activeCard: Card | undefined; + next: TimelineCard[]; + future: TimelineCard[]; + }, + nextCard?: ActionCard ) { - const playedActionCards = getPlayedActionCards(timeline); + const playedActionCards = getPlayedActionCards(playedCards); const players = Object.keys(PLAYER_CARDS) as PlayerType[]; const playerHands = players.reduce((acc, player) => { const playerHand = PLAYER_CARDS[player].map((card) => { const status = defineCardStatus({ + nextCard, cardId: card.cardId, - selectedCard, playedActionCards, }); return { card, status }; diff --git a/src/components/game/player-hand/player-hand-controller.tsx b/src/components/player-hand/player-hand-controller.tsx similarity index 51% rename from src/components/game/player-hand/player-hand-controller.tsx rename to src/components/player-hand/player-hand-controller.tsx index eeb23f4..c9211ad 100644 --- a/src/components/game/player-hand/player-hand-controller.tsx +++ b/src/components/player-hand/player-hand-controller.tsx @@ -1,33 +1,27 @@ -import { useGameContext } from "contexts"; -import { useEffect, useState } from "react"; -import { warnInconsistentState } from "utils/console"; +import { useGameContext } from "game-context"; +import { isActionCard } from "models/new-card"; +import { logRender, warnInconsistentState } from "utils/console"; import { inferPlayerHandsFromGameContext } from "./infer-player-hands"; import { PlayerHand } from "./player-hand"; -/** - * Renders depends on: - * - `useGameContext()` - * - * Defines visual player hand from GameContext - * - */ -function PlayerHandController(): JSX.Element { +export function PlayerHandController() { + logRender("PlayerHandController"); const gameContext = useGameContext(); - const [nextCard, setNextCard] = useState(); - // FIXME can I change the approach: new player -> no selected card - useEffect(() => { - setNextCard(undefined); - }, [gameContext.activePlayer]); + if (!gameContext.activePlayer) { + warnInconsistentState( + "trying to render while no activePlayer", + { gameContext } + ); + return <>ERROR; + } /* infered state */ - const playerCards = inferPlayerHandsFromGameContext( - gameContext.timeline, - nextCard - ); - const activePlayerHand = gameContext.activePlayer - ? playerCards[gameContext.activePlayer] - : []; + const nextCard = gameContext.next.find( + (timelineCard) => !timelineCard.commited && isActionCard(timelineCard.card) + )?.card as ActionCard; + const playerCards = inferPlayerHandsFromGameContext(gameContext, nextCard); + const activePlayerHand = playerCards[gameContext.activePlayer]; const handlePlayerCardClick = (cardId: CardId) => { if (!gameContext.activePlayer) { @@ -44,17 +38,22 @@ function PlayerHandController(): JSX.Element { } if (playerCard.status !== "available") { + return; // ignore click + } + + if (!isActionCard(playerCard.card)) { + // TODO; event cards return; } if (nextCard) { gameContext.plan({ - nextCard, - futureCard: playerCard.card as ActionCard, // casting? XXX - player: gameContext.activePlayer, + futureActionCard: playerCard.card, }); } else { - setNextCard(playerCard.card as ActionCard); // casting? XXX + gameContext.plan({ + nextActionCard: playerCard.card, + }); } }; @@ -67,5 +66,3 @@ function PlayerHandController(): JSX.Element { /> ); } - -export default PlayerHandController; diff --git a/src/components/game/player-hand/player-hand.scss b/src/components/player-hand/player-hand.scss similarity index 100% rename from src/components/game/player-hand/player-hand.scss rename to src/components/player-hand/player-hand.scss diff --git a/src/components/game/player-hand/player-hand.test.tsx b/src/components/player-hand/player-hand.test.tsx similarity index 75% rename from src/components/game/player-hand/player-hand.test.tsx rename to src/components/player-hand/player-hand.test.tsx index 594eb08..39aa686 100644 --- a/src/components/game/player-hand/player-hand.test.tsx +++ b/src/components/player-hand/player-hand.test.tsx @@ -1,18 +1,18 @@ import { render } from "@testing-library/react"; -import { Card } from "models/card"; +import { NewCard } from "models/new-card"; import { PlayerHand } from "./player-hand"; const playerHand: PlayerHand = [ { - card: Card("move", "player"), + card: NewCard("move", "player"), status: "available", }, { - card: Card("move", "player"), + card: NewCard("move", "player"), status: "available", }, { - card: Card("build", "player"), + card: NewCard("build", "player"), status: "available", }, ]; diff --git a/src/components/game/player-hand/player-hand.tsx b/src/components/player-hand/player-hand.tsx similarity index 93% rename from src/components/game/player-hand/player-hand.tsx rename to src/components/player-hand/player-hand.tsx index a12ac86..30e361d 100644 --- a/src/components/game/player-hand/player-hand.tsx +++ b/src/components/player-hand/player-hand.tsx @@ -1,4 +1,4 @@ -import { Card } from "components/game/cards/card"; +import { Card } from "components/cards/card"; import { logRender } from "utils/console"; import "./player-hand.scss"; diff --git a/src/components/game/round-summary/assets/enemy-1.png b/src/components/round-summary/assets/enemy-1.png similarity index 100% rename from src/components/game/round-summary/assets/enemy-1.png rename to src/components/round-summary/assets/enemy-1.png diff --git a/src/components/game/round-summary/assets/enemy-2.png b/src/components/round-summary/assets/enemy-2.png similarity index 100% rename from src/components/game/round-summary/assets/enemy-2.png rename to src/components/round-summary/assets/enemy-2.png diff --git a/src/components/game/round-summary/assets/enemy-3.png b/src/components/round-summary/assets/enemy-3.png similarity index 100% rename from src/components/game/round-summary/assets/enemy-3.png rename to src/components/round-summary/assets/enemy-3.png diff --git a/src/components/game/round-summary/assets/player.png b/src/components/round-summary/assets/player.png similarity index 100% rename from src/components/game/round-summary/assets/player.png rename to src/components/round-summary/assets/player.png diff --git a/src/components/game/round-summary/assets/star.png b/src/components/round-summary/assets/star.png similarity index 100% rename from src/components/game/round-summary/assets/star.png rename to src/components/round-summary/assets/star.png diff --git a/src/components/game/round-summary/player-summary.scss b/src/components/round-summary/player-summary.scss similarity index 100% rename from src/components/game/round-summary/player-summary.scss rename to src/components/round-summary/player-summary.scss diff --git a/src/components/game/round-summary/player-summary.tsx b/src/components/round-summary/player-summary.tsx similarity index 100% rename from src/components/game/round-summary/player-summary.tsx rename to src/components/round-summary/player-summary.tsx diff --git a/src/components/game/round-summary/round-summary.scss b/src/components/round-summary/round-summary.scss similarity index 100% rename from src/components/game/round-summary/round-summary.scss rename to src/components/round-summary/round-summary.scss diff --git a/src/components/game/round-summary/round-summary.tsx b/src/components/round-summary/round-summary.tsx similarity index 91% rename from src/components/game/round-summary/round-summary.tsx rename to src/components/round-summary/round-summary.tsx index 0026ea1..72cf0cf 100644 --- a/src/components/game/round-summary/round-summary.tsx +++ b/src/components/round-summary/round-summary.tsx @@ -1,14 +1,10 @@ -import { useGameContext } from "contexts"; +import { useGameContext } from "game-context"; import { logRender } from "utils/console"; import PlayerSummary from "./player-summary"; import "./round-summary.scss"; -/** - * Renders depends on: - * - useGameContext() - */ function RoundSummary(): JSX.Element { logRender("RoundSummary"); diff --git a/src/components/game/timeline/__snapshots__/timeline.test.tsx.snap b/src/components/timeline/__snapshots__/timeline.test.tsx.snap similarity index 100% rename from src/components/game/timeline/__snapshots__/timeline.test.tsx.snap rename to src/components/timeline/__snapshots__/timeline.test.tsx.snap diff --git a/src/components/game/timeline/line-item.scss b/src/components/timeline/line-item.scss similarity index 93% rename from src/components/game/timeline/line-item.scss rename to src/components/timeline/line-item.scss index 326f266..8d90118 100644 --- a/src/components/game/timeline/line-item.scss +++ b/src/components/timeline/line-item.scss @@ -17,7 +17,9 @@ $content-size: 20px; border: 2px solid black; background-color: colors.$base-grey; - + &--pending { + border: 3px dotted colors.$darkest-grey; + } &__content { width: $content-size; diff --git a/src/components/game/timeline/line-item.tsx b/src/components/timeline/line-item.tsx similarity index 62% rename from src/components/game/timeline/line-item.tsx rename to src/components/timeline/line-item.tsx index e1178bc..333c9d3 100644 --- a/src/components/game/timeline/line-item.tsx +++ b/src/components/timeline/line-item.tsx @@ -2,13 +2,14 @@ import c from "classnames"; import "./line-item.scss"; -interface ActionLineItem { +type Props = { card: ActionCard; -} + commited: boolean; +}; -export function ActionLineItem({ card }: ActionLineItem): JSX.Element { +export function ActionLineItem({ card, commited }: Props): JSX.Element { return ( -
+
diff --git a/src/components/timeline/timeline-controller.tsx b/src/components/timeline/timeline-controller.tsx new file mode 100644 index 0000000..7a3a947 --- /dev/null +++ b/src/components/timeline/timeline-controller.tsx @@ -0,0 +1,8 @@ +import { useGameContext } from "game-context"; +import { Timeline } from "./timeline"; + +export function TimelineController(): JSX.Element { + const gameContext = useGameContext(); + + return ; +} diff --git a/src/components/game/timeline/timeline.scss b/src/components/timeline/timeline.scss similarity index 100% rename from src/components/game/timeline/timeline.scss rename to src/components/timeline/timeline.scss diff --git a/src/components/game/timeline/timeline.test.tsx b/src/components/timeline/timeline.test.tsx similarity index 69% rename from src/components/game/timeline/timeline.test.tsx rename to src/components/timeline/timeline.test.tsx index 42c1647..2b95219 100644 --- a/src/components/game/timeline/timeline.test.tsx +++ b/src/components/timeline/timeline.test.tsx @@ -1,20 +1,20 @@ import { render } from "@testing-library/react"; -import { Card } from "models/card"; +import { NewCard } from "models/new-card"; import { Timeline } from "./timeline"; const timeline: Timeline = { - current: Card("move", "player"), + current: NewCard("move", "player"), next: [ { - card: Card("move", "enemy1"), + card: NewCard("move", "enemy1"), commited: true, }, { - card: Card("move", "enemy2"), + card: NewCard("move", "enemy2"), commited: true, }, { - card: Card("move", "enemy3"), + card: NewCard("move", "enemy3"), commited: true, }, ], diff --git a/src/components/game/timeline/timeline.tsx b/src/components/timeline/timeline.tsx similarity index 68% rename from src/components/game/timeline/timeline.tsx rename to src/components/timeline/timeline.tsx index 954b3be..9fe488f 100644 --- a/src/components/game/timeline/timeline.tsx +++ b/src/components/timeline/timeline.tsx @@ -4,16 +4,19 @@ import { ActionLineItem } from "./line-item"; import "./timeline.scss"; type Props = { - timeline: Timeline; + next: TimelineCard[]; + future: TimelineCard[]; }; -export function Timeline({ timeline }: Props): JSX.Element { +export function Timeline({ next, future }: Props): JSX.Element { logRender("Timeline"); const renderLineItems = (section: TimelineCard[]) => { - return section.map(({ card }) => { + return section.map(({ card, commited }) => { if (card.cardType === "actionCard") - return ; + return ( + + ); }); }; @@ -22,13 +25,13 @@ export function Timeline({ timeline }: Props): JSX.Element {
NEXT
- {renderLineItems(timeline.next)} + {renderLineItems(next)}
FUTURE
- {renderLineItems(timeline.future)} + {renderLineItems(future)}
diff --git a/src/contexts/game-context/use-game-context.tsx b/src/contexts/game-context/use-game-context.tsx deleted file mode 100644 index 58081d2..0000000 --- a/src/contexts/game-context/use-game-context.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { empireSize } from "game-logic/empire-size"; -import { isConquering, isCreatingGreatesEmpire } from "game-logic/score-check"; -import { createContext, useContext, useState } from "react"; -import { logRender } from "utils/console"; -import { emptyBoard } from "./empty-board"; -import { useBoard } from "./use-board"; -import usePlayers from "./use-players"; -import { emptyTimeline, useTimeline } from "./use-timeline"; - -const GameContext = createContext({ - phase: "setup", - board: emptyBoard, - timeline: emptyTimeline, - activeCard: undefined, - activePlayer: undefined, - players: [], - build: () => {}, - move: () => {}, - recruit: () => {}, - plan: () => {}, - firstPlayer: () => {}, - skip: () => {}, - loadSavegame: () => {}, -}); - -interface Props { - children: React.ReactNode; -} - -function defineActivePlayer( - phase: PhaseType, - timeline: Timeline, - players: PlayerStatus[] -) { - if (phase === "planification") { - // [ . . . . ] playerOrder[0] 0, 4 - // [ . ] playerOrder[1] 1, 5 - // [ . . ] playerOrder[2] 2, 6 - return players[timeline.next.length % 4].player; - } - if (phase === "action") { - return timeline.current?.cardType === "actionCard" - ? timeline.current.owner - : undefined; - } - return undefined; -} - -export function GameContextProvider({ children }: Props): JSX.Element { - logRender("GameContextProvider"); - - const [phase, setPhase] = useState("planification"); // will be setup - const { board, buildOnTile, movePiece, recruitOnTile, _overrideBoard } = - useBoard(); - const { timeline, nextCard, planification, newTurn, _overrideTimeline } = - useTimeline(); - const { - players, - firstPlayer, - scorePoint, - declareGreatestEmpire, - _overridePlayers, - } = usePlayers(); - - /* derived state */ - const activeCard = timeline.current; - const activePlayer = defineActivePlayer(phase, timeline, players); - - /* API */ - const overrideGameContext = ({ - phase, - board, - timeline, - players, - }: GameContext) => { - setPhase(phase); - _overrideBoard(board); - _overrideTimeline(timeline); - _overridePlayers(players); - }; - - const changePhase = () => { - let nextPhase: PhaseType; - switch (phase) { - case "setup": - nextPhase = "planification"; - break; - case "planification": - nextPhase = "action"; - break; - case "action": - nextPhase = "planification"; - break; - } - console.info(`changePhase() (${phase} -> ${nextPhase})`); - if (nextPhase === "planification") { - newTurn(); - } - if (nextPhase === "action") { - nextCard(); - } - setPhase(nextPhase); - }; - - const resolveActionCard = () => { - nextCard(); - if (timeline.next.length === 0) { - console.info("resolveActionCard(): no more cards to resolve this turn"); - changePhase(); - } - }; - - return ( - - {children} - - ); -} - -export function useGameContext() { - return useContext(GameContext); -} diff --git a/src/contexts/game-context/use-timeline.tsx b/src/contexts/game-context/use-timeline.tsx deleted file mode 100644 index b821abd..0000000 --- a/src/contexts/game-context/use-timeline.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; - -export function useTimeline() { - const [timeline, setTimeline] = useState(emptyTimeline); - - const nextCard = () => { - console.info("nextCard()"); - setTimeline((currentTimeline) => { - const current = currentTimeline.next[0]?.card; - const next = currentTimeline.next.slice(1); - return { - current, - next, - future: currentTimeline.future, - }; - }); - }; - - const planification = ({ player, nextCard, futureCard }: PlanAction) => { - console.info( - `planification({ player: ${player}, next: ${nextCard.action}, future: ${futureCard.action} })` - ); - const newTimeline = { - current: undefined, - next: timeline.next.concat({ card: nextCard, commited: true }), - future: timeline.future.concat({ card: futureCard, commited: true }), - }; - setTimeline(newTimeline); - return newTimeline; - }; - - /** action -> planification */ - const newTurn = () => { - console.info("newTurn()"); - const newTimeline = { - current: undefined, - next: timeline.next.concat(timeline.future), - future: [], - }; - setTimeline(newTimeline); - return newTimeline; - }; - - return { - timeline, - nextCard, - planification, - newTurn, - _overrideTimeline: setTimeline, - }; -} - -export const emptyTimeline: Timeline = { - current: undefined, - next: [], - future: [], -}; diff --git a/src/contexts/game-log.tsx b/src/contexts/game-log.tsx deleted file mode 100644 index 4e1bae2..0000000 --- a/src/contexts/game-log.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createContext, useContext, useState } from "react"; - -const GameLogContext = createContext({ - actions: [], - log: () => {}, -}); - -interface Props { - children: React.ReactNode; -} - -export function GameLogProvider({ children }: Props): JSX.Element { - const [actions, setActions] = useState([]); - - const log = (action: ActionLog) => { - setActions([action, ...actions]); - }; - - return ( - - {children} - - ); -} - -export function useGameLog() { - return useContext(GameLogContext); -} diff --git a/src/contexts/index.ts b/src/contexts/index.ts deleted file mode 100644 index a311d2b..0000000 --- a/src/contexts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GameContextProvider, useGameContext } from "./game-context/use-game-context"; -export { GameLogProvider, useGameLog } from "./game-log"; diff --git a/src/components/common/button.scss b/src/elements/button.scss similarity index 100% rename from src/components/common/button.scss rename to src/elements/button.scss diff --git a/src/components/common/button.tsx b/src/elements/button.tsx similarity index 100% rename from src/components/common/button.tsx rename to src/elements/button.tsx diff --git a/src/components/common/dialog.scss b/src/elements/dialog.scss similarity index 100% rename from src/components/common/dialog.scss rename to src/elements/dialog.scss diff --git a/src/components/common/dialog.tsx b/src/elements/dialog.tsx similarity index 100% rename from src/components/common/dialog.tsx rename to src/elements/dialog.tsx diff --git a/src/components/common/index.ts b/src/elements/index.ts similarity index 100% rename from src/components/common/index.ts rename to src/elements/index.ts diff --git a/src/contexts/game-context/empty-board.ts b/src/game-context/empty-board.ts similarity index 100% rename from src/contexts/game-context/empty-board.ts rename to src/game-context/empty-board.ts diff --git a/src/game-context/index.ts b/src/game-context/index.ts new file mode 100644 index 0000000..d5b17a3 --- /dev/null +++ b/src/game-context/index.ts @@ -0,0 +1 @@ +export { useGameContext, GameContextProvider } from "./use-game-context"; diff --git a/src/contexts/game-context/initial-board.ts b/src/game-context/initial-board.ts similarity index 100% rename from src/contexts/game-context/initial-board.ts rename to src/game-context/initial-board.ts diff --git a/src/contexts/game-context/use-board.test.tsx b/src/game-context/use-board.test.tsx similarity index 89% rename from src/contexts/game-context/use-board.test.tsx rename to src/game-context/use-board.test.tsx index 392d55d..74cba57 100644 --- a/src/contexts/game-context/use-board.test.tsx +++ b/src/game-context/use-board.test.tsx @@ -56,32 +56,32 @@ function TestingComponent() { const getTileInfo = (testId: string) => screen.getByTestId(testId).textContent; -describe("game-context", () => { - test("useBoard() returns board{}", () => { +describe("useBoard()", () => { + it("returns board{}", () => { render(); expect(getTileInfo("tile-0,2")).toEqual("soldier (enemy3)"); expect(getTileInfo("tile-1,2")).toEqual(""); expect(getTileInfo("tile-0,3")).toEqual("village (enemy3)"); }); - test("useBoard() returns buildOnTile()", () => { + it("returns buildOnTile()", () => { render(); fireEvent.click(screen.getByTestId("build-on-tile-12")); expect(getTileInfo("tile-1,2")).toEqual("village (enemy3)"); }); - test("useBoard() returns movePiece()", () => { + it("returns movePiece()", () => { render(); fireEvent.click(screen.getByTestId("move-piece-from-02-to12")); expect(getTileInfo("tile-0,2")).toEqual(""); expect(getTileInfo("tile-1,2")).toEqual("soldier (enemy3)"); }); - test("useBoard() returns recruitOnTile()", () => { + it("returns recruitOnTile()", () => { render(); fireEvent.click(screen.getByTestId("recruit-on-tile-03")); expect(getTileInfo("tile-0,3")).toEqual("village (enemy3)soldier (enemy3)"); }); - test.todo("useBoard() returns isConquering()"); + it.todo("returns isConquering()"); }); diff --git a/src/contexts/game-context/use-board.ts b/src/game-context/use-board.ts similarity index 68% rename from src/contexts/game-context/use-board.ts rename to src/game-context/use-board.ts index a61be88..a8458c6 100644 --- a/src/contexts/game-context/use-board.ts +++ b/src/game-context/use-board.ts @@ -4,15 +4,20 @@ import { initialBoard } from "./initial-board"; /** * plain react state + named update methods * + * **direct update on the board; no validity check** * **no game logic here** */ export function useBoard() { const [board, setBoard] = useState(initialBoard); - const buildOnTile = ({ tile, building }: BuildAction) => { - console.info( - `GameContext.buildOnTile({ tile: <${tile}>, building: ${building.type}(${building.owner}) })` - ); + const buildOnTile = ({ + tile, + building, + }: { + tile: TileID; + building: Building; + }) => { + console.info(`buildOnTile({ tile: <${tile}>, building: ${building} })`); setBoard((currentBoard) => { const newTile: Tile = { ...currentBoard[tile], @@ -25,10 +30,17 @@ export function useBoard() { }); }; - /** direct update on the board; no validity check */ - const movePiece = ({ piece, from, to }: MoveAction) => { + const movePiece = ({ + piece, + from, + to, + }: { + piece: Piece; + from: TileID; + to: TileID; + }) => { console.info( - `GameContext.movePiece({ from: <${from}>, to: <${to}>, piece: ${piece.type}(${piece.owner}) })` + `movePiece({ from: <${from}>, to: <${to}>, piece: ${piece} })` ); setBoard((currentBoard) => { const originTile: Tile = { @@ -53,10 +65,8 @@ export function useBoard() { }); }; - const recruitOnTile = ({ tile, piece }: RecruitAction) => { - console.info( - `GameContext.recruitOnTile({ tile: <${tile}>, piece: ${piece.type}(${piece.owner}) })` - ); + const recruitOnTile = ({ tile, piece }: { tile: TileID; piece: Piece }) => { + console.info(`recruitOnTile({ tile: <${tile}>, piece: ${piece}) })`); setBoard((currentBoard) => { const newTile: Tile = { ...currentBoard[tile], diff --git a/src/game-context/use-game-context.tsx b/src/game-context/use-game-context.tsx new file mode 100644 index 0000000..c349ccd --- /dev/null +++ b/src/game-context/use-game-context.tsx @@ -0,0 +1,232 @@ +import { empireSize } from "game-logic/empire-size"; +import { isConquering, isCreatingGreatesEmpire } from "game-logic/score-check"; +import { createContext, useContext } from "react"; +import { logRender, warnInconsistentState } from "utils/console"; +import { emptyBoard } from "./empty-board"; +import { useBoard } from "./use-board"; +import { usePlayers } from "./use-players"; +import { useTimeline } from "./use-timeline"; + +const GameContext = createContext({ + phase: "setup", + activeCard: undefined, + activePlayer: undefined, + board: emptyBoard, + next: [], + future: [], + players: [], + build: () => {}, + move: () => {}, + recruit: () => {}, + skip: () => {}, + plan: () => {}, + submitPlanification: () => {}, + firstPlayer: () => {}, + loadSavegame: () => {}, +}); + +type Props = { children: React.ReactNode }; + +/** + * GameController + * + * game state + * + * ``` + * { useTimeline(), useBoard(), usePlayers() } + * ``` + */ +export function GameContextProvider({ children }: Props) { + logRender("GameContextProvider"); + + const { board, buildOnTile, movePiece, recruitOnTile, _overrideBoard } = + useBoard(); + + const timeline = useTimeline(); + + const { + players, + nextFirstPlayer, + scorePoint, + declareGreatestEmpire, + _overridePlayers, + } = usePlayers(); + + /* derived state */ + const activePlayer = defineActivePlayer(timeline, { players }); + + /* API */ + + const loadSavegame = ({ + phase, + activeCard, + next, + future, + board, + players, + }: { + phase: PhaseType; + activeCard: Card | undefined; + next: TimelineCard[]; + future: TimelineCard[]; + board: Board; + players: PlayerStatus[]; + }) => { + _overrideBoard(board); + timeline._overrideTimeline({ phase, activeCard, next, future }); + _overridePlayers(players); + }; + + const _resolveActionCard = () => { + if (timeline.next.length === 0) { + console.info("resolveActionCard(): no more cards, changing phase"); + timeline.startPlanningPhase(); + } else { + console.info("resolveActionCard(): next card"); + timeline.nextActiveCard(); + } + }; + + const build = (action: { tile: TileID; building: Building }) => { + if (isCreatingGreatesEmpire({ ...action, empires: empireSize(board) })) { + declareGreatestEmpire(action.building.owner); + } + buildOnTile(action); + _resolveActionCard(); + }; + + const move = (action: { piece: Piece; from: TileID; to: TileID }) => { + const player = action.piece.owner; + if (isConquering({ player, targetTile: board[action.to] })) { + scorePoint(player); + } + movePiece(action); + _resolveActionCard(); + }; + + const recruit = (action: { tile: TileID; piece: Piece }) => { + recruitOnTile(action); + _resolveActionCard(); + }; + + const firstPlayer = () => { + nextFirstPlayer(); + _resolveActionCard(); + }; + + const plan = (action: { + nextActionCard: ActionCard | undefined; + futureActionCard: ActionCard | undefined; + }) => { + console.info( + `plan({ next: ${action.nextActionCard}, future: ${action.futureActionCard} })` + ); + if (timeline.phase !== "planification") { + return warnInconsistentState( + `trying to plan action but phase is not "planification"`, + { phase: timeline.phase, action } + ); + } + timeline.planAction(action); + }; + + const submitPlanification = () => { + const { phase, activeCard, next, future } = timeline; + if (phase !== "planification") { + return warnInconsistentState( + `trying to submit planification but phase is not "planification"`, + { phase, activeCard, next, future } + ); + } + console.info("submitPlanification()"); + + const pendingNextActions = next.filter((card) => !card.commited); + if (pendingNextActions.length !== 1) { + return warnInconsistentState( + `trying to submit planification but there are ${pendingNextActions.length} pending actions`, + { phase, activeCard, next, future } + ); + } + + const pendingFutureActions = future.filter((card) => !card.commited); + if (pendingFutureActions.length !== 1) { + return warnInconsistentState( + `trying to submit planification but there are ${pendingFutureActions.length} pending actions`, + { phase, activeCard, next, future } + ); + } + + timeline.submitPlanification(); + if (next[0] && next.length % 4 === 0) { + timeline.startActionPhase(); + } + }; + + const skip = () => { + const { phase, activeCard } = timeline; + if ( + activeCard?.cardType !== "actionCard" || + activeCard?.owner !== activePlayer + ) { + return warnInconsistentState( + `trying to skip action but active card is not an action card`, + { phase, activeCard } + ); + } + _resolveActionCard(); + }; + + return ( + + {children} + + ); +} + +export function useGameContext() { + return useContext(GameContext); +} + +// TODO move to state +function defineActivePlayer( + { + activeCard, + phase, + next, + }: { activeCard: Card | undefined; phase: PhaseType; next: TimelineCard[] }, + { players }: { players: PlayerStatus[] } +) { + if (phase === "planification") { + // [ . . . . ] playerOrder[0] 0, 4 + // [ . ] playerOrder[1] 1, 5 + // [ . . ] playerOrder[2] 2, 6 + const committedActions = next.filter( + (timelineCard) => timelineCard.commited + ); + return players[committedActions.length % 4].player; + } + if (phase === "action") { + if (activeCard && activeCard.cardType === "actionCard") { + return activeCard.owner; + } + } + return undefined; +} diff --git a/src/contexts/game-context/use-players.test.tsx b/src/game-context/use-players.test.tsx similarity index 88% rename from src/contexts/game-context/use-players.test.tsx rename to src/game-context/use-players.test.tsx index a2a616f..488403e 100644 --- a/src/contexts/game-context/use-players.test.tsx +++ b/src/game-context/use-players.test.tsx @@ -1,8 +1,8 @@ import { render, screen, fireEvent } from "@testing-library/react"; -import usePlayers from "./use-players"; +import { usePlayers } from "./use-players"; function TestingComponent() { - const { players, firstPlayer, scorePoint, declareGreatestEmpire } = + const { players, nextFirstPlayer, scorePoint, declareGreatestEmpire } = usePlayers(); return ( <> @@ -18,12 +18,8 @@ function TestingComponent() { onClick={() => scorePoint("enemy1")} /> +
+
+ + +`; + +exports[` render: match snapshot - planification 1`] = `
render: match snapshot 1`] = ` class="current-phase__content" >
render: match snapshot 1`] = `
+
+
+ +
+`; + +exports[` render: match snapshot - planification with cards 1`] = ` + +
+

+ planification +

+
+
+
+
+
+ move +
+
+ Move up to two different Units +
+
+
+
+
+
+ recruit +
+
+ Recruit units at up to two different Settlements +
+
+
+ diff --git a/src/components/current-phase/current-phase-controller.test.tsx b/src/components/current-phase/current-phase-controller.test.tsx index 6f833e6..c0e29f2 100644 --- a/src/components/current-phase/current-phase-controller.test.tsx +++ b/src/components/current-phase/current-phase-controller.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; +import { GameContextProvider } from "game-context"; import { CurrentPhaseController } from "./current-phase-controller"; -import { GameContextProvider } from "contexts"; describe("", () => { test("reads GameContext.phase", async () => { diff --git a/src/components/current-phase/current-phase.test.tsx b/src/components/current-phase/current-phase.test.tsx index 18b5e3d..d6da384 100644 --- a/src/components/current-phase/current-phase.test.tsx +++ b/src/components/current-phase/current-phase.test.tsx @@ -1,15 +1,42 @@ import { render } from "@testing-library/react"; import { CurrentPhase } from "./current-phase"; +import { NewCard } from "models/new-card"; describe("", () => { - test("render: match snapshot", () => { + test("render: match snapshot - action", () => { const { asFragment } = render( ); expect(asFragment()).toMatchSnapshot(); }); + + test("render: match snapshot - planification", () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test("render: match snapshot - planification with cards", () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); }); diff --git a/src/components/current-phase/current-phase.tsx b/src/components/current-phase/current-phase.tsx index 2f1c78f..a0d8227 100644 --- a/src/components/current-phase/current-phase.tsx +++ b/src/components/current-phase/current-phase.tsx @@ -23,9 +23,7 @@ type PlanningPhaseProps = { onSubmitPlan: () => void; }; -export function CurrentPhase( - props: ActionPhaseProps | PlanningPhaseProps -): JSX.Element { +export function CurrentPhase(props: ActionPhaseProps | PlanningPhaseProps) { logRender("CurrentPhase"); return ( diff --git a/src/components/player-hand/infer-player-hands.test.ts b/src/components/player-hand/infer-player-hands.test.ts index c8085fa..74749fc 100644 --- a/src/components/player-hand/infer-player-hands.test.ts +++ b/src/components/player-hand/infer-player-hands.test.ts @@ -1,9 +1,9 @@ import { NewCard, _resetCardId } from "models/new-card"; import { inferPlayerHandsFromGameContext } from "./infer-player-hands"; -function mockTimeline(): Timeline { +function mockTimeline() { return { - current: undefined, + activeCard: undefined, next: [ { card: NewCard("build", "player"), diff --git a/src/components/timeline/timeline.test.tsx b/src/components/timeline/timeline.test.tsx index 2b95219..25c09c3 100644 --- a/src/components/timeline/timeline.test.tsx +++ b/src/components/timeline/timeline.test.tsx @@ -2,28 +2,25 @@ import { render } from "@testing-library/react"; import { NewCard } from "models/new-card"; import { Timeline } from "./timeline"; -const timeline: Timeline = { - current: NewCard("move", "player"), - next: [ - { - card: NewCard("move", "enemy1"), - commited: true, - }, - { - card: NewCard("move", "enemy2"), - commited: true, - }, - { - card: NewCard("move", "enemy3"), - commited: true, - }, - ], - future: [], -}; +const next: TimelineCard[] = [ + { + card: NewCard("move", "enemy1"), + commited: true, + }, + { + card: NewCard("move", "enemy2"), + commited: true, + }, + { + card: NewCard("move", "enemy3"), + commited: true, + }, +]; +const future: TimelineCard[] = []; describe("", () => { test("render: match snapshot", () => { - const { asFragment } = render(); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/src/game-context/use-players.test.tsx b/src/game-context/use-players.test.tsx index 488403e..fda3d09 100644 --- a/src/game-context/use-players.test.tsx +++ b/src/game-context/use-players.test.tsx @@ -1,6 +1,8 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { usePlayers } from "./use-players"; +// TODO change to renderHook(() => usePlayers()); + function TestingComponent() { const { players, nextFirstPlayer, scorePoint, declareGreatestEmpire } = usePlayers(); @@ -66,22 +68,16 @@ describe("usePlayers()", () => { ]); }); - test("returns firstPlayer()", () => { + test("returns nextFirstPlayer()", () => { render(); - fireEvent.click(screen.getByTestId("enemy2-first-player")); + fireEvent.click(screen.getByTestId("next-first-player")); expect(getPlayerList()).toEqual([ - "enemy2 - 0 points", - "player - 0 points", "enemy1 - 0 points", - "enemy3 - 0 points", - ]); - fireEvent.click(screen.getByTestId("player-first-player")); - expect(getPlayerList()).toEqual([ - "player - 0 points", "enemy2 - 0 points", - "enemy1 - 0 points", "enemy3 - 0 points", + "player - 0 points", ]); + }); test("returns declareGreatesEmpire()", () => { diff --git a/src/game-context/use-players.ts b/src/game-context/use-players.ts index 6706c14..8a926da 100644 --- a/src/game-context/use-players.ts +++ b/src/game-context/use-players.ts @@ -23,13 +23,11 @@ export function usePlayers() { * ``` */ const nextFirstPlayer = () => { - console.info(`nextFirstPlayer()`); const newPlayerOrder = [...players.slice(1), players[0]]; setPlayers(newPlayerOrder); }; const scorePoint = (player: PlayerType) => { - console.info(`scorePoint({ player: ${player} })`); setPlayers((currentPlayers) => currentPlayers.map((p) => p.player === player ? { ...p, points: p.points + 1 } : p @@ -38,7 +36,6 @@ export function usePlayers() { }; const declareGreatestEmpire = (player: PlayerType) => { - console.info(`declareGreatestEmpire({ player: ${player} })`); setPlayers((currentPlayers) => currentPlayers.map((p) => p.player === player diff --git a/src/game-context/use-timeline.test.tsx b/src/game-context/use-timeline.test.tsx index 4ad3d41..2c8e848 100644 --- a/src/game-context/use-timeline.test.tsx +++ b/src/game-context/use-timeline.test.tsx @@ -1,94 +1,107 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { useTimeline } from "./use-timeline"; import { NewCard } from "models/new-card"; -function TestingComponent() { - const { timeline, planification, nextCard, newTurn } = useTimeline(); - - return ( - <> -
- {timeline.current?.cardType === "actionCard" && timeline.current.action} -
- -
- {timeline.next[0]?.card.cardType === "actionCard" && - timeline.next[0].card.action} -
- -
- {timeline.future[0]?.card.cardType === "actionCard" && - timeline.future[0].card.action} -
- - +
); } diff --git a/src/elements/button.scss b/src/elements/button.scss index 46c0145..b154e5a 100644 --- a/src/elements/button.scss +++ b/src/elements/button.scss @@ -1,19 +1,43 @@ +@use '/src/styles/colors.scss'; + +@mixin box-shadow($color) { + box-shadow: inset -1px -3px 0px $color; + background: #FFFFFF; // override +} + .button { - width: 133px; - height: 34px; - - /* Auto layout */ - display: flex; - flex-direction: column; - align-items: center; - padding: 8px 0px; - flex: none; - order: 0; - flex-grow: 0; - - /* visual */ - border: 2px solid #000000; - border-radius: 16px; - background: #FFFFFF; - box-shadow: inset 0px -4px 0px #FFCA3A; + width: 12vh; + height: 4vh; + + font-weight: bold; + + border: 2px solid colors.$darkest-grey; + border-radius: 12px; + + &--disabled { + border: 2px solid colors.$base-grey; + box-shadow: none !important; + } + + &:hover { + cursor: pointer; + } + + transition: all 0.2s ease-in-out; +} + +.button.player { + @include box-shadow(colors.$player-primary); +} + +.button.enemy1 { + @include box-shadow(colors.$enemy1-primary); +} + +.button.enemy2 { + @include box-shadow(colors.$enemy2-primary); +} + +.button.enemy3 { + @include box-shadow(colors.$enemy3-primary); } diff --git a/src/elements/button.tsx b/src/elements/button.tsx index c9489c1..0cad097 100644 --- a/src/elements/button.tsx +++ b/src/elements/button.tsx @@ -1,15 +1,12 @@ -import type { ButtonHTMLAttributes } from "react"; - +import c from "classnames"; import "./button.scss"; -interface ButtonProps extends ButtonHTMLAttributes { - children: React.ReactNode; -} +type Props = { children: React.ReactNode } & React.ComponentProps<"button">; -export function Button(props: ButtonProps): JSX.Element { +export function Button({ className, children, ...props }: Props) { return ( - ); } From 2dbe9d2f3f17af8542907e5ce9d3a97f7907176b Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Fri, 17 Nov 2023 14:01:29 +0100 Subject: [PATCH 09/12] style: cleaning up around tiles --- src/components/board/board.scss | 56 ++++++++++------------------- src/components/board/tile-size.scss | 1 + src/components/board/tile.scss | 52 +++++++++------------------ 3 files changed, 36 insertions(+), 73 deletions(-) create mode 100644 src/components/board/tile-size.scss diff --git a/src/components/board/board.scss b/src/components/board/board.scss index 40e3f7f..ab38cd6 100644 --- a/src/components/board/board.scss +++ b/src/components/board/board.scss @@ -1,12 +1,15 @@ -// FIXME: this is absolutly manual, there should be a better way of solving this. -$collapse-rows: -26px; -$row-left-space: 60px; +@use 'tile-size.scss' as *; + +$collapse-rows: calc($TILE_SIZE / 5); // 24px aprox +$row-left-space: calc($TILE_SIZE / 2); .board { + grid-area: board; + position: relative; width: -moz-fit-content; width: fit-content; - height: 688px; // FIXME why doesn't fit-content work? + height: 100%; &__row { display: flex; @@ -15,44 +18,21 @@ $row-left-space: 60px; width: -moz-fit-content; width: fit-content; - // collapse rows - &:nth-child(2) { - top: $collapse-rows; - } - - &:nth-child(3) { - top: $collapse-rows * 2; - } - - &:nth-child(4) { - top: $collapse-rows * 3; - } - - &:nth-child(5) { - top: $collapse-rows * 4; - } - - &:nth-child(6) { - top: $collapse-rows * 5; - } - - &:nth-child(7) { - top: $collapse-rows * 6; - } - - // move rows to the right - &--1-to-equator { - left: $row-left-space; - } - - &--2-to-equator { - left: $row-left-space * 2; + // Loop for collapsing rows + @for $i from 2 through 7 { + &:nth-child(#{$i}) { + top: $collapse-rows * (-1) * ($i - 1); + } } - &--3-to-equator { - left: $row-left-space * 3; + // Loop for moving rows to the right + @for $i from 1 through 3 { + &--#{$i}-to-equator { + left: $row-left-space * $i; + } } } + // debug border: 1px solid red; } diff --git a/src/components/board/tile-size.scss b/src/components/board/tile-size.scss new file mode 100644 index 0000000..bb9cc74 --- /dev/null +++ b/src/components/board/tile-size.scss @@ -0,0 +1 @@ +$TILE_SIZE: 120px; diff --git a/src/components/board/tile.scss b/src/components/board/tile.scss index 06c0f03..691d84e 100644 --- a/src/components/board/tile.scss +++ b/src/components/board/tile.scss @@ -1,16 +1,19 @@ @use '/src/styles/colors.scss'; +@use 'tile-size.scss' as *; $forbidden-tile: rgba(190, 88, 88, 0.5); -$horizontal-padding: 5px; +$horizontal-padding: calc($TILE_SIZE/ 30); + $tile-stroke: 3px; $default-opacity: 0.7; -@mixin setStrokeColor($element, $color, $fill-color) { - &__#{$element} { +@mixin setStroke($player, $color) { + + // tile__available--player + &__#{$player} { path { stroke: $color; - fill: $fill-color; } } } @@ -26,8 +29,8 @@ $default-opacity: 0.7; .tile { position: relative; - width: 120px; - height: 120px; + width: $TILE_SIZE; + height: $TILE_SIZE; margin: 0; padding: 0 $horizontal-padding; @@ -44,10 +47,10 @@ $default-opacity: 0.7; &--available { opacity: 1; - @include setStrokeColor(player, colors.$player-primary, colors.$player-secondary); - @include setStrokeColor(enemy1, colors.$enemy1-primary, colors.$enemy1-secondary); - @include setStrokeColor(enemy2, colors.$enemy2-primary, colors.$enemy2-secondary); - @include setStrokeColor(enemy3, colors.$enemy3-primary, colors.$enemy3-secondary); + @include setStroke(player, colors.$player-primary, ); + @include setStroke(enemy1, colors.$enemy1-primary, ); + @include setStroke(enemy2, colors.$enemy2-primary, ); + @include setStroke(enemy3, colors.$enemy3-primary, ); } /* debug: turn on at tile.tsx */ @@ -60,33 +63,13 @@ $default-opacity: 0.7; &__content {} - &__terrain { - position: absolute; - stroke: #5f5858; - stroke-width: 3px; - - &--forest { - top: 15%; - left: 15%; - - width: 80px; - height: 80px - } - - &--lake { - top: 5%; - left: 5%; - width: 110px; - height: 110px - } - } - &__building { - width: 90px; - height: 90px; + // make it relative to $TILE_SIZE + width: calc(0.75 * #{$TILE_SIZE}); + height: calc(0.75 * #{$TILE_SIZE}); position: absolute; - bottom: 5px; // building in the bottom + bottom: 1vh; // building in the bottom @include setBackgroundImage('player', 'castle--player.png'); @include setBackgroundImage('enemy1', 'castle--enemy1.png'); @@ -122,5 +105,4 @@ $default-opacity: 0.7; // --- } } - } From fa15b2f1a7a1c054f080b91a28e09bedfda79d2f Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Fri, 17 Nov 2023 14:12:50 +0100 Subject: [PATCH 10/12] style: board is relative to TILE_SIZE --- src/components/board/board.scss | 5 ++--- src/components/board/piece.scss | 3 ++- src/components/board/tile-size.scss | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/board/board.scss b/src/components/board/board.scss index ab38cd6..b5e0cd0 100644 --- a/src/components/board/board.scss +++ b/src/components/board/board.scss @@ -1,6 +1,6 @@ @use 'tile-size.scss' as *; -$collapse-rows: calc($TILE_SIZE / 5); // 24px aprox +$collapse-rows-y: calc($TILE_SIZE / 5); // 22px aprox $row-left-space: calc($TILE_SIZE / 2); .board { @@ -9,7 +9,6 @@ $row-left-space: calc($TILE_SIZE / 2); position: relative; width: -moz-fit-content; width: fit-content; - height: 100%; &__row { display: flex; @@ -21,7 +20,7 @@ $row-left-space: calc($TILE_SIZE / 2); // Loop for collapsing rows @for $i from 2 through 7 { &:nth-child(#{$i}) { - top: $collapse-rows * (-1) * ($i - 1); + margin-top: $collapse-rows-y * (-1) ; } } diff --git a/src/components/board/piece.scss b/src/components/board/piece.scss index 73c5ab3..f013b02 100644 --- a/src/components/board/piece.scss +++ b/src/components/board/piece.scss @@ -1,6 +1,7 @@ @use '/src/styles/colors.scss'; +@use './tile-size.scss' as *; -$piece-size: 60px; +$piece-size: calc($TILE_SIZE * 0.6); @mixin piece-icon($piece, $owner) { background-image: url('./assets/' + $piece + '-' + $owner + '.svg'); diff --git a/src/components/board/tile-size.scss b/src/components/board/tile-size.scss index bb9cc74..ba26da9 100644 --- a/src/components/board/tile-size.scss +++ b/src/components/board/tile-size.scss @@ -1 +1 @@ -$TILE_SIZE: 120px; +$TILE_SIZE: 12vh; From bbe45c4a824382e8671600a174e5b4a7cad9e83a Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Mon, 20 Nov 2023 16:24:54 +0100 Subject: [PATCH 11/12] fix: solve 2 issues when starting planning phase - add new inconsistent state guards at iuseGameContext() - useTimeline(): solve 2 issues startPlanningPhase(): setActiveCard to undefined nextActiveCard(): re-set commited: true to timeline cards - extra: move some console.logs from use-board to use-game-context - review guard at inferVisualBoardFromGameContext() test: update snapshots --- src/@types/storming.d.ts | 2 +- src/components/board/board-controller.tsx | 11 ++--- src/components/board/infer-visual-board.ts | 15 +++++- .../__snapshots__/current-phase.test.tsx.snap | 49 ++++++++++++------- src/components/index.ts | 2 +- src/game-context/use-board.ts | 29 +++++------ src/game-context/use-game-context.tsx | 27 ++++++++++ src/game-context/use-timeline.tsx | 6 ++- 8 files changed, 95 insertions(+), 46 deletions(-) diff --git a/src/@types/storming.d.ts b/src/@types/storming.d.ts index d58f92b..1d62347 100644 --- a/src/@types/storming.d.ts +++ b/src/@types/storming.d.ts @@ -8,7 +8,7 @@ type PlayerType = | "enemy2" /* blue */ | "enemy3" /* green */; -type PlayerHandCardStatus = "available" | "played"; +type PlayerHandCardStatus = "selected" | "available" | "played"; /** * ```ts diff --git a/src/components/board/board-controller.tsx b/src/components/board/board-controller.tsx index ab6e7aa..ffd0539 100644 --- a/src/components/board/board-controller.tsx +++ b/src/components/board/board-controller.tsx @@ -14,7 +14,7 @@ import { NewBuilding } from "models/new-building"; * * => state validation before changing GameContext <= */ -function BoardController() { +export function BoardController() { const gameContext = useGameContext(); const [selectedTile, setSelectedTile] = useState(); const [buildingTile, setBuildingTile] = useState(); @@ -32,6 +32,8 @@ function BoardController() { ); } setSelectedTile(undefined); + setBuildingTile(undefined); + setRecruitingTile(undefined); gameContext.build({ tile, building: NewBuilding({ owner: piece.owner }), @@ -93,6 +95,8 @@ function BoardController() { } if (piece.owner === gameContext.activePlayer) { setSelectedTile(undefined); + setBuildingTile(undefined); + setRecruitingTile(undefined); return gameContext.move({ piece, from: selectedTile, @@ -111,7 +115,6 @@ function BoardController() { { tile: board[tile] } ); } - setSelectedTile(undefined); setBuildingTile(undefined); setRecruitingTile(undefined); @@ -165,8 +168,6 @@ function BoardController() { return moveFromTile(tile); case "recruit": return resolveRecruitOnTile(tile); - default: - break; } }; @@ -209,5 +210,3 @@ function BoardController() { ); } - -export default BoardController; diff --git a/src/components/board/infer-visual-board.ts b/src/components/board/infer-visual-board.ts index 08c36ca..e74319c 100644 --- a/src/components/board/infer-visual-board.ts +++ b/src/components/board/infer-visual-board.ts @@ -15,8 +15,21 @@ export function inferVisualBoardFromGameContext( { board, activeCard }: { board: Board; activeCard: Card | undefined }, selectedTile?: TileID ): VisualBoard { + if (!activeCard) { + return Object.entries(board).reduce( + (acc, [tileId, tile]) => ({ + ...acc, + [tileId]: { + ...tile, + status: "idle", + }, + }), + {} as VisualBoard + ); + } + const availableTiles = - activeCard?.cardType === "actionCard" + activeCard.cardType === "actionCard" ? getAvailableTilesForActionCard({ board, activeCard, diff --git a/src/components/current-phase/__snapshots__/current-phase.test.tsx.snap b/src/components/current-phase/__snapshots__/current-phase.test.tsx.snap index 4ed6f21..1c9da09 100644 --- a/src/components/current-phase/__snapshots__/current-phase.test.tsx.snap +++ b/src/components/current-phase/__snapshots__/current-phase.test.tsx.snap @@ -3,13 +3,15 @@ exports[` render: match snapshot - action 1`] = `
-

- action -

+

+ action +

+
@@ -43,13 +45,15 @@ exports[` render: match snapshot - action 1`] = ` exports[` render: match snapshot - planification 1`] = `
-

- planification -

+

+ planification +

+
@@ -57,7 +61,8 @@ exports[` render: match snapshot - planification 1`] = ` class="current-phase__planification" >
render: match snapshot - planification 1`] = `
render: match snapshot - planification 1`] = `
@@ -98,13 +105,15 @@ exports[` render: match snapshot - planification 1`] = ` exports[` render: match snapshot - planification with cards 1`] = `
-

- planification -

+

+ planification +

+
@@ -112,6 +121,7 @@ exports[` render: match snapshot - planification with cards 1`] class="current-phase__planification" >
render: match snapshot - planification with cards 1`] />
render: match snapshot - planification with cards 1`] />
diff --git a/src/components/index.ts b/src/components/index.ts index db013e1..6e7b6f0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,4 @@ -export { default as Board } from "./board/board-controller"; +export { BoardController as Board } from "./board/board-controller"; export { CurrentPhaseController as CurrentPhase } from "./current-phase/current-phase-controller"; export { default as Marketplace } from "./marketplace/marketplace"; export { Menu } from "./menu/menu"; diff --git a/src/game-context/use-board.ts b/src/game-context/use-board.ts index a8458c6..350b278 100644 --- a/src/game-context/use-board.ts +++ b/src/game-context/use-board.ts @@ -4,8 +4,10 @@ import { initialBoard } from "./initial-board"; /** * plain react state + named update methods * - * **direct update on the board; no validity check** - * **no game logic here** + * - **direct update on the board; no validity check** + * - **no game logic** + * - **no console.log** + * - **no inconsistent state** */ export function useBoard() { const [board, setBoard] = useState(initialBoard); @@ -17,15 +19,13 @@ export function useBoard() { tile: TileID; building: Building; }) => { - console.info(`buildOnTile({ tile: <${tile}>, building: ${building} })`); setBoard((currentBoard) => { - const newTile: Tile = { - ...currentBoard[tile], - building, - }; return { ...currentBoard, - [tile]: newTile, + [tile]: { + ...currentBoard[tile], + building, + }, }; }); }; @@ -39,14 +39,7 @@ export function useBoard() { from: TileID; to: TileID; }) => { - console.info( - `movePiece({ from: <${from}>, to: <${to}>, piece: ${piece} })` - ); setBoard((currentBoard) => { - const originTile: Tile = { - ...currentBoard[from], - piece: undefined, - }; const targetTile: Tile = { ...currentBoard[to], piece, @@ -59,14 +52,16 @@ export function useBoard() { } return { ...currentBoard, - [from]: originTile, + [from]: { + ...currentBoard[from], + piece: undefined, + }, [to]: targetTile, }; }); }; const recruitOnTile = ({ tile, piece }: { tile: TileID; piece: Piece }) => { - console.info(`recruitOnTile({ tile: <${tile}>, piece: ${piece}) })`); setBoard((currentBoard) => { const newTile: Tile = { ...currentBoard[tile], diff --git a/src/game-context/use-game-context.tsx b/src/game-context/use-game-context.tsx index c349ccd..c54574e 100644 --- a/src/game-context/use-game-context.tsx +++ b/src/game-context/use-game-context.tsx @@ -88,6 +88,15 @@ export function GameContextProvider({ children }: Props) { }; const build = (action: { tile: TileID; building: Building }) => { + if (timeline.phase !== "action") { + return warnInconsistentState( + `trying to build but not in "action" phase`, + { phase: timeline.phase, action } + ); + } + console.info( + `buildOnTile({ tile: <${action.tile}>, building: ${action.building} })` + ); if (isCreatingGreatesEmpire({ ...action, empires: empireSize(board) })) { declareGreatestEmpire(action.building.owner); } @@ -96,6 +105,15 @@ export function GameContextProvider({ children }: Props) { }; const move = (action: { piece: Piece; from: TileID; to: TileID }) => { + if (timeline.phase !== "action") { + return warnInconsistentState(`trying to move but not in "action" phase`, { + phase: timeline.phase, + action, + }); + } + console.info( + `movePiece({ from: <${action.from}>, to: <${action.to}>, piece: ${action.piece} })` + ); const player = action.piece.owner; if (isConquering({ player, targetTile: board[action.to] })) { scorePoint(player); @@ -105,6 +123,15 @@ export function GameContextProvider({ children }: Props) { }; const recruit = (action: { tile: TileID; piece: Piece }) => { + if (timeline.phase !== "action") { + return warnInconsistentState( + `trying to recruit but not in "action" phase`, + { phase: timeline.phase, action } + ); + } + console.info( + `recruitOnTile({ tile: <${action.tile}>, piece: ${action.piece} })` + ); recruitOnTile(action); _resolveActionCard(); }; diff --git a/src/game-context/use-timeline.tsx b/src/game-context/use-timeline.tsx index 93f36be..ec68308 100644 --- a/src/game-context/use-timeline.tsx +++ b/src/game-context/use-timeline.tsx @@ -15,6 +15,7 @@ export function useTimeline() { const startPlanningPhase = () => { setPhase("planification"); + setActiveCard(undefined); setNext(next.concat(future)); setFuture([]); }; @@ -26,7 +27,10 @@ export function useTimeline() { const nextActiveCard = () => { setActiveCard(next[0].card); - setNext(next.slice(1)); + // issue: we were loosing the commited state of the card + setNext( + next.slice(1).map((timelineCard) => ({ ...timelineCard, commited: true })) + ); }; const planAction = ({ From c3644d8088acf05ec2de3874279c0e0691218adc Mon Sep 17 00:00:00 2001 From: Manu Artero Anguita Date: Fri, 17 Nov 2023 10:43:51 +0100 Subject: [PATCH 12/12] chore: 3x pngs --- rule-book/build-icn.png | Bin 0 -> 4016 bytes rule-book/move-icn.png | Bin 0 -> 10330 bytes rule-book/recruit-icn.png | Bin 0 -> 22861 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 rule-book/build-icn.png create mode 100644 rule-book/move-icn.png create mode 100644 rule-book/recruit-icn.png diff --git a/rule-book/build-icn.png b/rule-book/build-icn.png new file mode 100644 index 0000000000000000000000000000000000000000..0da2f5239de02c88426d7f932c19a03bcb32c277 GIT binary patch literal 4016 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 literal 0 HcmV?d00001