diff --git a/.prettierrc b/.prettierrc index 8b137891..9e26dfee 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1 +1 @@ - +{} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 36a5875b..68eb7208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "pg": "^8.5.1", "react": "17.0.1", "react-dom": "17.0.1", + "react-textfit": "^1.1.1", "short-uuid": "^4.1.0", "sqlite3": "^5.0.0" }, @@ -30,6 +31,7 @@ "@types/puppeteer": "^5.4.2", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-textfit": "^1.1.0", "@types/sqlite3": "^3.1.6", "@typescript-eslint/eslint-plugin": "^4.11.0", "@typescript-eslint/parser": "^4.11.0", @@ -39,7 +41,7 @@ "typescript": "^4.1.3" }, "engines": { - "node": "12.x" + "node": ">=12.x" } }, "node_modules/@ampproject/toolbox-core": { @@ -629,6 +631,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-textfit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/react-textfit/-/react-textfit-1.1.0.tgz", + "integrity": "sha512-iF49wuf4TMUKxcQjKyZcaJRN8rKFrXUBIAo0KQ0O/orHLQpwCyxpiD8NlPRqmcRN3FOAxSiRAOENbEQF1bhqiQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", @@ -8228,6 +8239,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-textfit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-textfit/-/react-textfit-1.1.1.tgz", + "integrity": "sha512-UDSQRo5yBEGueLTE5SgNV9fSmr5CWJkE0E0R0YbcbCO69iuJGfcT6wspKhX2sIwdsDyT9qXOwMC80cnRolir7Q==", + "dependencies": { + "process": "^0.11.10", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0", + "react-dom": "^15.0.0 || ^16.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -11771,6 +11795,15 @@ "@types/react": "*" } }, + "@types/react-textfit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/react-textfit/-/react-textfit-1.1.0.tgz", + "integrity": "sha512-iF49wuf4TMUKxcQjKyZcaJRN8rKFrXUBIAo0KQ0O/orHLQpwCyxpiD8NlPRqmcRN3FOAxSiRAOENbEQF1bhqiQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/sinonjs__fake-timers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", @@ -17825,6 +17858,15 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", "integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==" }, + "react-textfit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-textfit/-/react-textfit-1.1.1.tgz", + "integrity": "sha512-UDSQRo5yBEGueLTE5SgNV9fSmr5CWJkE0E0R0YbcbCO69iuJGfcT6wspKhX2sIwdsDyT9qXOwMC80cnRolir7Q==", + "requires": { + "process": "^0.11.10", + "prop-types": "^15.7.2" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", diff --git a/package.json b/package.json index 4a3490c9..42671bf2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "everdell", "version": "0.1.0", "engines": { - "node": "12.x" + "node": ">=12.x" }, "private": true, "scripts": { @@ -32,6 +32,7 @@ "pg": "^8.5.1", "react": "17.0.1", "react-dom": "17.0.1", + "react-textfit": "^1.1.1", "short-uuid": "^4.1.0", "sqlite3": "^5.0.0" }, @@ -44,6 +45,7 @@ "@types/puppeteer": "^5.4.2", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-textfit": "^1.1.0", "@types/sqlite3": "^3.1.6", "@typescript-eslint/eslint-plugin": "^4.11.0", "@typescript-eslint/parser": "^4.11.0", diff --git a/public/images/legendary_card.png b/public/images/legendary_card.png new file mode 100644 index 00000000..17e1b850 Binary files /dev/null and b/public/images/legendary_card.png differ diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 50e0ac7f..628bedc0 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,7 +1,13 @@ import * as React from "react"; +import { Textfit } from "react-textfit"; import { Card as CardModel } from "../model/card"; import styles from "../styles/card.module.css"; -import { ResourceType, CardName, PlayedCardInfo } from "../model/types"; +import { + ResourceType, + CardName, + PlayedCardInfo, + ExpansionType, +} from "../model/types"; import { Player } from "../model/player"; import { resourceMapToGameText, toGameText } from "../model/gameText"; import { @@ -19,6 +25,7 @@ const colorClassMap = { PRODUCTION: styles.color_production, DESTINATION: styles.color_destination, TRAVELER: styles.color_traveler, + LEGEND: styles.color_legend, }; function romanize(num: number): string { @@ -65,7 +72,12 @@ function romanize(num: number): string { // determine rarity label, which is unique vs. common and // critter vs. construction const getRarityLabel = (card: CardModel) => { - const rarity = card.isUnique ? "Unique" : "Common"; + const rarity = + card.expansion === ExpansionType.LEGENDS + ? "Legendary" + : card.isUnique + ? "Unique" + : "Common"; const category = card.isCritter ? "Critter" : "Construction"; return rarity + " " + category; }; @@ -75,14 +87,12 @@ const getAssociatedCard = (card: CardModel) => { if (card.associatedCard) { return card.associatedCard; } else { - if (card.name == CardName.FARM) { + if (card.name == CardName.FARM || card.name == CardName.MCGREGORS_MARKET) { return "Husband / Wife"; } else if (card.name == CardName.EVERTREE) { return "Any"; } else { - throw new Error( - "Associated card is null and card is not Farm or Evertree" - ); + <> ; } } }; @@ -156,6 +166,18 @@ const AssociatedCard = ({ ); }; +const UpgradeableCard = ({ card }: { card: CardModel }) => { + if (!card.upgradeableCard) { + return <> ; + } + + return ( +
+ {card.upgradeableCard} +
+ ); +}; + const Card: React.FC<{ name: CardName; usedForCritter?: boolean }> = ({ name, usedForCritter = false, @@ -166,25 +188,29 @@ const Card: React.FC<{ name: CardName; usedForCritter?: boolean }> = ({ return ( <>
-
-
+
+
-
- {card.baseVP} -
-
- {name} +
+ + + {name} + + {card.expansion && ( {card.expansion} )}
+
+ {card.baseVP} +
+
+
+ {rarityLabel} +  · {romanize(card.numInDeck)}
@@ -221,10 +247,7 @@ const Card: React.FC<{ name: CardName; usedForCritter?: boolean }> = ({
-
- {rarityLabel} -  · {romanize(card.numInDeck)} -
+
diff --git a/src/components/CardPayment.tsx b/src/components/CardPayment.tsx index 95f32d52..79eaa633 100644 --- a/src/components/CardPayment.tsx +++ b/src/components/CardPayment.tsx @@ -3,7 +3,12 @@ import { Field, useField } from "formik"; import { Card as CardModel } from "../model/card"; import { Player } from "../model/player"; -import { ResourceType, CardName, GameInputPlayCard } from "../model/types"; +import { + ResourceType, + CardName, + GameInputPlayCard, + ExpansionType, +} from "../model/types"; import { ResourceTypeIcon, Description } from "./common"; import styles from "../styles/CardPayment.module.css"; @@ -95,6 +100,7 @@ const OptionToUseAssociatedCard: React.FC<{ const [_field, meta, helpers] = useField(name); const card = CardModel.fromName(cardName); const hasUnusedAssociatedCard = + card.expansion !== ExpansionType.LEGENDS && card.associatedCard && viewingPlayer.hasUnusedByCritterConstruction(card.associatedCard); const hasUnusedEvertree = viewingPlayer.hasUnusedByCritterConstruction( @@ -102,8 +108,9 @@ const OptionToUseAssociatedCard: React.FC<{ ); const canUseAssociatedCard = card.isCritter && (hasUnusedAssociatedCard || hasUnusedEvertree); + if (!canUseAssociatedCard) { - return <>; + return null; } const isChecked = !!meta.value; @@ -231,6 +238,32 @@ const CardToDungeonForm: React.FC<{ ) : null; }; +const CardToUpgradeForm: React.FC<{ + name: string; + cardName: CardName; + viewingPlayer: Player; +}> = ({ name, cardName }) => { + const card = CardModel.fromName(cardName); + if (card.expansion !== ExpansionType.LEGENDS || !card.upgradeableCard) { + return null; + } + + return ( +
+ +
+ ); +}; + const CardPayment: React.FC<{ name: string; resetPaymentOptions: (state: "DEFAULT" | "COST" | "ZERO") => void; @@ -247,7 +280,9 @@ const CardPayment: React.FC<{ resetPaymentOptions={resetPaymentOptions} /> )} - + {clientOptions.paymentOptions.resources && ( + + )} {clientOptions.card && ( + {clientOptions.card && ( + + )}
); }; diff --git a/src/components/GameBuilder.tsx b/src/components/GameBuilder.tsx index bd65912c..0f2e172d 100644 --- a/src/components/GameBuilder.tsx +++ b/src/components/GameBuilder.tsx @@ -110,6 +110,10 @@ const GameBuilder: React.FC = () => { {"Pearlbrook"} +

Settings

diff --git a/src/components/GameInputPlayCard.tsx b/src/components/GameInputPlayCard.tsx index c6cd7ca1..29863eba 100644 --- a/src/components/GameInputPlayCard.tsx +++ b/src/components/GameInputPlayCard.tsx @@ -3,7 +3,7 @@ import { useField } from "formik"; import styles from "../styles/gameBoard.module.css"; -import { CardName, ResourceType } from "../model/types"; +import { CardName, ExpansionType, ResourceType } from "../model/types"; import { Player } from "../model/player"; import { Card as CardModel } from "../model/card"; @@ -22,6 +22,9 @@ const GameInputPlayCard: React.FC<{ overrides: any = {} ) => { const card = CardModel.fromName(cardName); + const canUpgradeCard = + card.expansion == ExpansionType.LEGENDS && + viewingPlayer.hasCardInCity(card.upgradeableCard!); const canUseAssociatedCard = card.isCritter && card.associatedCard && @@ -34,16 +37,20 @@ const GameInputPlayCard: React.FC<{ paymentOptions: { cardToUse: null, cardToDungeon: null, + cardToUpgrade: canUpgradeCard ? card.upgradeableCard : null, useAssociatedCard: state === "DEFAULT" ? canUseAssociatedCard : false, - resources: { - [ResourceType.BERRY]: 0, - [ResourceType.TWIG]: 0, - [ResourceType.RESIN]: 0, - [ResourceType.PEBBLE]: 0, - ...((state === "DEFAULT" && !canUseAssociatedCard) || state === "COST" - ? card.baseCost - : {}), - }, + resources: canUpgradeCard + ? null + : { + [ResourceType.BERRY]: 0, + [ResourceType.TWIG]: 0, + [ResourceType.RESIN]: 0, + [ResourceType.PEBBLE]: 0, + ...((state === "DEFAULT" && !canUseAssociatedCard) || + state === "COST" + ? card.baseCost + : {}), + }, }, }); }; diff --git a/src/components/Players.tsx b/src/components/Players.tsx index a294e298..6e47b6d5 100644 --- a/src/components/Players.tsx +++ b/src/components/Players.tsx @@ -11,7 +11,7 @@ import { import { PlayerJSON, GameStateJSON } from "../model/jsonTypes"; import { Player } from "../model/player"; import { GameState } from "../model/gameState"; -import { GameBlock } from "./common"; +import { GameBlock, LegendaryCardIcon } from "./common"; import { PlayerCity } from "./gameBoard"; import { InfoIconSvg, @@ -237,6 +237,19 @@ const PlayerStatus: React.FC<{
)} + {gameStateJSON.gameOptions.legends && ( +
+
+ +
+
+ {player.numLegendsInHand} +
+
+ )}
{"WORKERS"} diff --git a/src/components/ViewerUI.tsx b/src/components/ViewerUI.tsx index b5337fd3..1bd724ba 100644 --- a/src/components/ViewerUI.tsx +++ b/src/components/ViewerUI.tsx @@ -22,6 +22,11 @@ const ViewerUI: React.FC<{ {player.adornmentsInHand.map((name, idx) => ( ))} + {player.legendsInHand.map((name, idx) => ( + + + + ))}
diff --git a/src/components/common.tsx b/src/components/common.tsx index bc48b5fd..7766eda8 100644 --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -86,6 +86,16 @@ export const AdornmentCardIcon = () => { ); }; +export const LegendaryCardIcon = () => { + return ( + Legendary Card Icon + ); +}; + export const WorkerSpotIcon = ({ locked = false }: { locked?: boolean }) => { if (locked) { return ( @@ -290,7 +300,6 @@ export const Description = ({ textParts }: { textParts: GameText }) => { ); } assertUnreachable(part, `Unexpected part: ${JSON.stringify(part)}`); - break; default: assertUnreachable(part, `Unexpected part: ${JSON.stringify(part)}`); } diff --git a/src/model/card.ts b/src/model/card.ts index 3f480ac2..3fe980cd 100644 --- a/src/model/card.ts +++ b/src/model/card.ts @@ -44,6 +44,19 @@ import { } from "./gameText"; import { assertUnreachable } from "../utils"; +import { + amillaGlistendew, + bridgeOfTheSky, + cirrusWindfall, + foresight, + fynnNobletail, + mcgregorsMarket, + oleandersOperaHouse, + poe, + silverScaleSpring, + theGreenAcorn, +} from "./cards/legends"; + type NumWorkersInnerFn = (cardOwner: Player) => number; type ProductionInnerFn = ( gameState: GameState, @@ -83,6 +96,7 @@ export class Card readonly expansion: ExpansionType | null; readonly isConstruction: boolean; readonly associatedCard: CardName | null; + readonly upgradeableCard: CardName | null; readonly isOpenDestination: boolean; readonly productionInner: ProductionInnerFn | undefined; @@ -102,6 +116,7 @@ export class Card isUnique, isConstruction, associatedCard, + upgradeableCard, resourcesToGain, productionInner, productionWillActivateInner, @@ -122,7 +137,8 @@ export class Card isUnique: boolean; numInDeck: number; isConstruction: boolean; - associatedCard: CardName | null; + associatedCard?: CardName | null; + upgradeableCard?: CardName | null; isOpenDestination?: boolean; expansion?: ExpansionType | null; playInner?: GameStatePlayFn; @@ -157,7 +173,8 @@ export class Card this.isUnique = isUnique; this.isCritter = !isConstruction; this.isConstruction = isConstruction; - this.associatedCard = associatedCard; + this.associatedCard = associatedCard || null; + this.upgradeableCard = upgradeableCard || null; this.isOpenDestination = isOpenDestination; this.playInner = playInner; this.canPlayCheckInner = canPlayCheckInner; @@ -254,7 +271,8 @@ export class Card } if ( !gameInput.clientOptions.fromMeadow && - player.cardsInHand.indexOf(this.name) === -1 + player.cardsInHand.indexOf(this.name) === -1 && + player.legendsInHand.indexOf(this.name) === -1 ) { return `Card ${ this.name @@ -601,7 +619,11 @@ const CARD_REGISTRY: Record = { " in your city.", ]), // 1 point per common construction - pointsInner: getPointsPerRarityLabel({ isCritter: false, isUnique: false }), + pointsInner: getPointsPerRarityLabel({ + isCritter: false, + isUnique: false, + pointsEach: 1, + }), }), [CardName.CEMETARY]: new Card({ name: CardName.CEMETARY, @@ -2016,7 +2038,11 @@ const CARD_REGISTRY: Record = { " in your city", ]), // 1 point per unique construction - pointsInner: getPointsPerRarityLabel({ isCritter: false, isUnique: true }), + pointsInner: getPointsPerRarityLabel({ + isCritter: false, + isUnique: true, + pointsEach: 1, + }), }), [CardName.PEDDLER]: new Card({ name: CardName.PEDDLER, @@ -2708,7 +2734,11 @@ const CARD_REGISTRY: Record = { " in your city.", ]), // 1 point per common critter - pointsInner: getPointsPerRarityLabel({ isCritter: true, isUnique: false }), + pointsInner: getPointsPerRarityLabel({ + isCritter: true, + isUnique: false, + pointsEach: 1, + }), }), [CardName.SHEPHERD]: new Card({ name: CardName.SHEPHERD, @@ -3128,7 +3158,11 @@ const CARD_REGISTRY: Record = { " in your city.", ]), // 1 point per unique critter - pointsInner: getPointsPerRarityLabel({ isCritter: true, isUnique: true }), + pointsInner: getPointsPerRarityLabel({ + isCritter: true, + isUnique: true, + pointsEach: 1, + }), }), [CardName.TWIG_BARGE]: new Card({ name: CardName.TWIG_BARGE, @@ -3952,25 +3986,41 @@ const CARD_REGISTRY: Record = { } }, }), + + /** + * WIP: Legends Cards + */ + [CardName.AMILLA_GLISTENDEW]: new Card(amillaGlistendew), + [CardName.BRIDGE_OF_THE_SKY]: new Card(bridgeOfTheSky), + [CardName.CIRRUS_WINDFALL]: new Card(cirrusWindfall), + [CardName.FORESIGHT]: new Card(foresight), + [CardName.FYNN_NOBLETAIL]: new Card(fynnNobletail), + [CardName.MCGREGORS_MARKET]: new Card(mcgregorsMarket), + [CardName.OLEANDERS_OPERA_HOUSE]: new Card(oleandersOperaHouse), + [CardName.POE]: new Card(poe), + [CardName.SILVER_SCALE_SPRING]: new Card(silverScaleSpring), + [CardName.THE_GREEN_ACORN]: new Card(theGreenAcorn), }; -function getPointsPerRarityLabel({ +export function getPointsPerRarityLabel({ isCritter, isUnique, + pointsEach, }: { isCritter: boolean; isUnique: boolean; + pointsEach: number; }): GameStateCountPointsFn { return (gameState: GameState, playerId: string) => { const player = gameState.getPlayer(playerId); - let numCardsToCount = 0; + let numPoints = 0; player.forEachPlayedCard(({ cardName }) => { const card = Card.fromName(cardName as CardName); if (card.isCritter === isCritter && card.isUnique === isUnique) { - numCardsToCount++; + numPoints += pointsEach; } }); - return numCardsToCount; + return numPoints; }; } diff --git a/src/model/cards/legends/amillaGlistendew.ts b/src/model/cards/legends/amillaGlistendew.ts new file mode 100644 index 00000000..cde835c1 --- /dev/null +++ b/src/model/cards/legends/amillaGlistendew.ts @@ -0,0 +1,54 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + GameInputType, + ResourceType, +} from "../../types"; + +export const amillaGlistendew: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.AMILLA_GLISTENDEW, + upgradeableCard: CardName.QUEEN, + cardType: CardType.DESTINATION, + cardDescription: toGameText( + "Achieve an Event, even if you don't meet the listed requirements." + ), + isConstruction: false, + isUnique: false, + baseVP: 5, + numInDeck: 1, + resourcesToGain: {}, + baseCost: { + [ResourceType.BERRY]: 6, + }, + canPlayCheckInner: (gameState: GameState, gameInput: GameInput) => { + if (gameInput.inputType === GameInputType.VISIT_DESTINATION_CARD) { + if ( + Object.entries(gameState.eventsMap).every( + ([eventName, playerId]) => playerId !== null + ) + ) { + return "No playable events"; + } + } + return null; + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + if (gameInput.inputType === GameInputType.VISIT_DESTINATION_CARD) { + gameState.pendingGameInputs.push({ + inputType: GameInputType.CLAIM_EVENT, + prevInputType: gameInput.inputType, + label: "Select an EVENT to play for free", + cardContext: CardName.AMILLA_GLISTENDEW, + clientOptions: { + event: null, + }, + }); + } + }, +}; diff --git a/src/model/cards/legends/bridgeOfTheSky.ts b/src/model/cards/legends/bridgeOfTheSky.ts new file mode 100644 index 00000000..cedaba88 --- /dev/null +++ b/src/model/cards/legends/bridgeOfTheSky.ts @@ -0,0 +1,40 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + ResourceType, +} from "../../types"; + +export const bridgeOfTheSky: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.BRIDGE_OF_THE_SKY, + associatedCard: CardName.ARCHITECT, + upgradeableCard: CardName.CRANE, + cardType: CardType.GOVERNANCE, + cardDescription: toGameText([ + "You may play 1 ", + { type: "em", text: "Construction " }, + "for -3 ANY, then place it on top of this card. ", + { type: "HR" }, + "PLUS is equal to the ", + { type: "points", value: 0 }, + " value of the ", + { type: "em", text: "Construction" }, + " on top of this card.", + ]), + isConstruction: true, + isUnique: false, + baseVP: 0, + numInDeck: 1, + resourcesToGain: {}, + baseCost: { + [ResourceType.PEBBLE]: 2, + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + }, +}; diff --git a/src/model/cards/legends/cirrusWindfall.ts b/src/model/cards/legends/cirrusWindfall.ts new file mode 100644 index 00000000..a2c5e712 --- /dev/null +++ b/src/model/cards/legends/cirrusWindfall.ts @@ -0,0 +1,37 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + ResourceType, +} from "../../types"; + +export const cirrusWindfall: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.CIRRUS_WINDFALL, + upgradeableCard: CardName.POSTAL_PIGEON, + cardType: CardType.TRAVELER, + cardDescription: toGameText([ + "You may play 1 CARD worth up to ", + { type: "points", value: 3 }, + " for free.", + ]), + isConstruction: false, + isUnique: false, + baseVP: 4, + numInDeck: 1, + resourcesToGain: {}, + baseCost: { + [ResourceType.BERRY]: 4, + }, + canPlayCheckInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + return null; + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + }, +}; diff --git a/src/model/cards/legends/foresight.test.ts b/src/model/cards/legends/foresight.test.ts new file mode 100644 index 00000000..8f3d73d7 --- /dev/null +++ b/src/model/cards/legends/foresight.test.ts @@ -0,0 +1,76 @@ +import expect from "expect.js"; +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { Player } from "../../player"; +import { + multiStepGameInputTest, + playCardInput, + testInitialGameState, +} from "../../testHelpers"; +import { CardName, GameInputType, ResourceType } from "../../types"; + +describe(CardName.FORESIGHT, () => { + let gameState: GameState; + let player: Player; + + beforeEach(() => { + gameState = testInitialGameState(); + player = gameState.getActivePlayer(); + }); + + it("should be worth 4 VP", () => { + expect(Card.fromName(CardName.FORESIGHT).baseVP).to.equal(4); + }); + + it("should draw 2 cards if player plays a critter", () => { + player.addToCity(gameState, CardName.FORESIGHT); + + const cardToPlay = Card.fromName(CardName.MINER_MOLE); + player.cardsInHand.push(cardToPlay.name); + player.gainResources(gameState, cardToPlay.baseCost); + + gameState.deck.addToStack(CardName.QUEEN); + gameState.deck.addToStack(CardName.KING); + + [player, gameState] = multiStepGameInputTest(gameState, [ + playCardInput(cardToPlay.name), + ]); + + expect(player.cardsInHand).to.eql([CardName.KING, CardName.QUEEN]); + }); + + it("should gain a resource if player plays a construction", () => { + player.addToCity(gameState, CardName.FORESIGHT); + + const cardToPlay = Card.fromName(CardName.MINE); + player.cardsInHand = [cardToPlay.name]; + player.gainResources(gameState, cardToPlay.baseCost); + + [player, gameState] = multiStepGameInputTest(gameState, [ + playCardInput(cardToPlay.name), + { + inputType: GameInputType.SELECT_OPTION_GENERIC, + prevInputType: GameInputType.PLAY_CARD, + cardContext: CardName.FORESIGHT, + options: ["TWIG", "RESIN", "PEBBLE", "BERRY"], + clientOptions: { + selectedOption: "BERRY", + }, + }, + ]); + + expect(player.getNumResourcesByType(ResourceType.PEBBLE)).to.be(1); + expect(player.getNumResourcesByType(ResourceType.BERRY)).to.be(1); + }); + + it("should not draw cards when the player plays the foresight", () => { + const cardToPlay = Card.fromName(CardName.FORESIGHT); + player.cardsInHand = [cardToPlay.name]; + player.gainResources(gameState, cardToPlay.baseCost); + + [player, gameState] = multiStepGameInputTest(gameState, [ + playCardInput(cardToPlay.name), + ]); + expect(player.cardsInHand).to.eql([]); + }); +}); diff --git a/src/model/cards/legends/foresight.ts b/src/model/cards/legends/foresight.ts new file mode 100644 index 00000000..3db0512e --- /dev/null +++ b/src/model/cards/legends/foresight.ts @@ -0,0 +1,76 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + GameInputType, + ResourceType, +} from "../../types"; + +export const foresight: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.FORESIGHT, + upgradeableCard: CardName.HISTORIAN, + cardType: CardType.GOVERNANCE, + cardDescription: toGameText([ + "Draw 2 CARD after you play a ", + { type: "em", text: "Critter" }, + ". Gain 1 ANY after you play a ", + { type: "em", text: "Construction" }, + ]), + isConstruction: false, + isUnique: false, + baseVP: 4, + numInDeck: 1, + resourcesToGain: {}, + baseCost: { + [ResourceType.BERRY]: 4, + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + const player = gameState.getActivePlayer(); + if ( + gameInput.inputType === GameInputType.PLAY_CARD && + gameInput.clientOptions.card !== CardName.FORESIGHT && + gameInput.clientOptions.card + ) { + const card = Card.fromName(gameInput.clientOptions.card); + if (card.isCritter) { + player.drawCards(gameState, 2); + gameState.addGameLogFromCard(CardName.HISTORIAN, [ + player, + ` drew 2 CARDS.`, + ]); + } else { + gameState.pendingGameInputs.push({ + inputType: GameInputType.SELECT_OPTION_GENERIC, + label: "Select TWIG / RESIN / PEBBLE / BERRY", + prevInputType: gameInput.inputType, + cardContext: CardName.FORESIGHT, + options: ["TWIG", "RESIN", "PEBBLE", "BERRY"], + clientOptions: { + selectedOption: null, + }, + }); + } + } else if ( + gameInput.inputType === GameInputType.SELECT_OPTION_GENERIC && + gameInput.cardContext === CardName.FORESIGHT + ) { + const selectedOption = gameInput.clientOptions?.selectedOption || ""; + if (["TWIG", "RESIN", "PEBBLE", "BERRY"].indexOf(selectedOption) === -1) { + throw new Error("Invalid input"); + } + + player.gainResources(gameState, { + [selectedOption]: 1, + }); + gameState.addGameLogFromCard(CardName.FORESIGHT, [ + player, + ` gained ${selectedOption} for playing a Construction.`, + ]); + } + }, +}; diff --git a/src/model/cards/legends/fynnNobletail.test.ts b/src/model/cards/legends/fynnNobletail.test.ts new file mode 100644 index 00000000..a2a1f20b --- /dev/null +++ b/src/model/cards/legends/fynnNobletail.test.ts @@ -0,0 +1,30 @@ +import expect from "expect.js"; +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { Player } from "../../player"; +import { testInitialGameState } from "../../testHelpers"; +import { CardName, EventName } from "../../types"; + +describe(CardName.FYNN_NOBLETAIL, () => { + let gameState: GameState; + let player: Player; + + beforeEach(() => { + gameState = testInitialGameState(); + player = gameState.getActivePlayer(); + }); + + it("should be worth 4 VP", () => { + expect(Card.fromName(CardName.FYNN_NOBLETAIL).baseVP).to.equal(5); + }); + + it("should be worth 2 extra VP per basic event critter", () => { + const card = Card.fromName(CardName.FYNN_NOBLETAIL); + + player.placeWorkerOnEvent(EventName.BASIC_FOUR_PRODUCTION); + expect(card.getPoints(gameState, player.playerId)).to.be(5 + 2); + + player.placeWorkerOnEvent(EventName.SPECIAL_GRADUATION_OF_SCHOLARS); + expect(card.getPoints(gameState, player.playerId)).to.be(5 + 2 + 3); + }); +}); diff --git a/src/model/cards/legends/fynnNobletail.ts b/src/model/cards/legends/fynnNobletail.ts new file mode 100644 index 00000000..bbe38639 --- /dev/null +++ b/src/model/cards/legends/fynnNobletail.ts @@ -0,0 +1,47 @@ +import { Card } from "../../card"; +import { Event } from "../../event"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + EventName, + EventType, + ExpansionType, + ResourceType, +} from "../../types"; + +export const fynnNobletail: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.FYNN_NOBLETAIL, + upgradeableCard: CardName.KING, + cardType: CardType.PROSPERITY, + cardDescription: toGameText([ + { type: "points", value: 2 }, + " for each basic Event you achieved.", + { type: "BR" }, + { type: "points", value: 3 }, + " for each special Event you achieved.", + ]), + isConstruction: false, + isUnique: false, + baseVP: 5, + numInDeck: 1, + resourcesToGain: {}, + baseCost: { + [ResourceType.BERRY]: 7, + }, + pointsInner: (gameState: GameState, playerId: string) => { + let numPoints = 0; + const player = gameState.getPlayer(playerId); + Object.keys(player.claimedEvents).forEach((eventName) => { + const event = Event.fromName(eventName as EventName); + if (event.type === EventType.BASIC) { + numPoints += 2; + } else if (event.type === EventType.SPECIAL) { + numPoints += 3; + } + }); + return numPoints; + }, +}; diff --git a/src/model/cards/legends/index.ts b/src/model/cards/legends/index.ts new file mode 100644 index 00000000..db1f32fc --- /dev/null +++ b/src/model/cards/legends/index.ts @@ -0,0 +1,10 @@ +export { amillaGlistendew } from "./amillaGlistendew"; +export { bridgeOfTheSky } from "./bridgeOfTheSky"; +export { cirrusWindfall } from "./cirrusWindfall"; +export { foresight } from "./foresight"; +export { fynnNobletail } from "./fynnNobletail"; +export { mcgregorsMarket } from "./mcgregorsMarket"; +export { oleandersOperaHouse } from "./oleandersOperaHouse"; +export { poe } from "./poe"; +export { silverScaleSpring } from "./silverScaleSpring"; +export { theGreenAcorn } from "./theGreenAcorn"; diff --git a/src/model/cards/legends/mcgregorsMarket.ts b/src/model/cards/legends/mcgregorsMarket.ts new file mode 100644 index 00000000..ff108700 --- /dev/null +++ b/src/model/cards/legends/mcgregorsMarket.ts @@ -0,0 +1,37 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { GainMoreThan1AnyResource } from "../../gameStatePlayHelpers"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + ResourceType, +} from "../../types"; + +export const mcgregorsMarket: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.MCGREGORS_MARKET, + associatedCard: null, + upgradeableCard: CardName.FARM, + cardType: CardType.PRODUCTION, + isConstruction: true, + isUnique: false, + baseVP: 5, + numInDeck: 1, + cardDescription: toGameText("Gain 2 ANY."), + baseCost: { + [ResourceType.TWIG]: 2, + [ResourceType.RESIN]: 2, + [ResourceType.PEBBLE]: 1, + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + const gainAnyHelper = new GainMoreThan1AnyResource({ + cardContext: CardName.MCGREGORS_MARKET, + }); + if (gainAnyHelper.matchesGameInput(gameInput)) { + gainAnyHelper.play(gameState, gameInput); + } + }, +}; diff --git a/src/model/cards/legends/oleandersOperaHouse.test.ts b/src/model/cards/legends/oleandersOperaHouse.test.ts new file mode 100644 index 00000000..a3bd4f9b --- /dev/null +++ b/src/model/cards/legends/oleandersOperaHouse.test.ts @@ -0,0 +1,43 @@ +import expect from "expect.js"; +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { Player } from "../../player"; +import { testInitialGameState } from "../../testHelpers"; +import { CardName } from "../../types"; + +describe(CardName.OLEANDERS_OPERA_HOUSE, () => { + let gameState: GameState; + let player: Player; + + beforeEach(() => { + gameState = testInitialGameState(); + player = gameState.getActivePlayer(); + }); + + it("should be worth 4 VP", () => { + expect(Card.fromName(CardName.OLEANDERS_OPERA_HOUSE).baseVP).to.equal(4); + }); + + it("should be worth 2 extra VP per unique critter", () => { + const card = Card.fromName(CardName.OLEANDERS_OPERA_HOUSE); + player.addToCity(gameState, card.name); + const playerId = player.playerId; + + expect(card.getPoints(gameState, playerId)).to.be(4 + 0); + + player.addToCity(gameState, CardName.DUNGEON); + expect(card.getPoints(gameState, playerId)).to.be(4 + 0); + + player.addToCity(gameState, CardName.RANGER); + expect(card.getPoints(gameState, playerId)).to.be(4 + 2); + + player.addToCity(gameState, CardName.HUSBAND); + expect(card.getPoints(gameState, playerId)).to.be(4 + 2); + + player.addToCity(gameState, CardName.WANDERER); + expect(card.getPoints(gameState, playerId)).to.be(4 + 2); + + player.addToCity(gameState, CardName.BARD); + expect(card.getPoints(gameState, playerId)).to.be(4 + 4); + }); +}); diff --git a/src/model/cards/legends/oleandersOperaHouse.ts b/src/model/cards/legends/oleandersOperaHouse.ts new file mode 100644 index 00000000..b96a4828 --- /dev/null +++ b/src/model/cards/legends/oleandersOperaHouse.ts @@ -0,0 +1,31 @@ +import { Card, getPointsPerRarityLabel } from "../../card"; +import { toGameText } from "../../gameText"; +import { CardName, CardType, ExpansionType, ResourceType } from "../../types"; + +export const oleandersOperaHouse: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.OLEANDERS_OPERA_HOUSE, + associatedCard: CardName.BARD, + upgradeableCard: CardName.THEATRE, + cardType: CardType.PROSPERITY, + isConstruction: true, + isUnique: false, + baseVP: 4, + numInDeck: 1, + cardDescription: toGameText([ + { type: "points", value: 2 }, + " for each ", + { type: "em", text: "Unique Critter" }, + " in your city.", + ]), + baseCost: { + [ResourceType.TWIG]: 3, + [ResourceType.RESIN]: 2, + [ResourceType.PEBBLE]: 2, + }, + pointsInner: getPointsPerRarityLabel({ + isCritter: true, + isUnique: true, + pointsEach: 2, + }), +}; diff --git a/src/model/cards/legends/poe.ts b/src/model/cards/legends/poe.ts new file mode 100644 index 00000000..fdebb9af --- /dev/null +++ b/src/model/cards/legends/poe.ts @@ -0,0 +1,34 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + ResourceType, +} from "../../types"; + +export const poe: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.POE, + upgradeableCard: CardName.TEACHER, + cardType: CardType.PRODUCTION, + cardDescription: toGameText( + "Discard any number of CARD, then draw up to your hand limit." + ), + isConstruction: false, + isUnique: false, + baseVP: 4, + numInDeck: 1, + baseCost: { + [ResourceType.BERRY]: 4, + }, + canPlayCheckInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + return null; + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + }, +}; diff --git a/src/model/cards/legends/silverScaleSpring.ts b/src/model/cards/legends/silverScaleSpring.ts new file mode 100644 index 00000000..f166736a --- /dev/null +++ b/src/model/cards/legends/silverScaleSpring.ts @@ -0,0 +1,40 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { toGameText } from "../../gameText"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + ResourceType, +} from "../../types"; + +export const silverScaleSpring: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.SILVER_SCALE_SPRING, + associatedCard: CardName.PEDDLER, + upgradeableCard: CardName.RUINS, + cardType: CardType.TRAVELER, + isConstruction: true, + isUnique: false, + baseVP: 2, + numInDeck: 1, + cardDescription: toGameText([ + "Play this card under a ", + { type: "em", text: "Construction " }, + "in your city. ", + "Gain that ", + { type: "em", text: "Construction" }, + "'s resources and draw 2 CARD", + ]), + baseCost: { + [ResourceType.PEBBLE]: 1, + }, + canPlayCheckInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + return null; + }, + playInner: (gameState: GameState, gameInput: GameInput) => { + // TODO: Implement this + }, +}; diff --git a/src/model/cards/legends/theGreenAcorn.ts b/src/model/cards/legends/theGreenAcorn.ts new file mode 100644 index 00000000..5532782f --- /dev/null +++ b/src/model/cards/legends/theGreenAcorn.ts @@ -0,0 +1,160 @@ +import { Card } from "../../card"; +import { GameState } from "../../gameState"; +import { sumResources } from "../../gameStatePlayHelpers"; +import { toGameText } from "../../gameText"; +import { Player } from "../../player"; +import { + CardName, + CardType, + ExpansionType, + GameInput, + GameInputType, + ResourceType, +} from "../../types"; + +function availableCardsToPlay( + gameState: GameState, + player: Player +): CardName[] { + const resources = player.getResources(); + const availableCards = gameState.meadowCards.concat(player.cardsInHand); + return availableCards.filter((cardName) => { + const card = Card.fromName(cardName); + return ( + card.canPlayIgnoreCostAndSource(gameState) && + player.isPaidResourcesValid(resources, card.baseCost, "ANY 4", false) + ); + }); +} + +export const theGreenAcorn: ConstructorParameters[0] = { + expansion: ExpansionType.LEGENDS, + name: CardName.THE_GREEN_ACORN, + associatedCard: CardName.INNKEEPER, + upgradeableCard: CardName.INN, + cardType: CardType.DESTINATION, + isOpenDestination: true, + isConstruction: true, + isUnique: false, + baseVP: 4, + numInDeck: 1, + cardDescription: toGameText([ + "Play a ", + { type: "em", text: "Critter " }, + "or ", + { type: "em", text: "Construction " }, + "for 4 fewer ANY", + ]), + baseCost: { + [ResourceType.TWIG]: 3, + [ResourceType.RESIN]: 3, + }, + canPlayCheckInner: (gameState: GameState, gameInput: GameInput) => { + const player = gameState.getActivePlayer(); + if (gameInput.inputType === GameInputType.VISIT_DESTINATION_CARD) { + const canPlayCards = availableCardsToPlay(gameState, player).length > 0; + if (!canPlayCards) { + return `You don't have any cards you can play`; + } + } + return null; + }, + // Play any card for 4 less resources + playInner: (gameState: GameState, gameInput: GameInput) => { + const player = gameState.getActivePlayer(); + if (gameInput.inputType === GameInputType.VISIT_DESTINATION_CARD) { + // add pending input to select a card to play + gameState.pendingGameInputs.push({ + inputType: GameInputType.SELECT_CARDS, + prevInputType: gameInput.inputType, + label: "Select 1 CARD from to play for 4 fewer ANY", + cardOptions: availableCardsToPlay(gameState, player), + maxToSelect: 1, + minToSelect: 1, + cardContext: CardName.THE_GREEN_ACORN, + clientOptions: { + selectedCards: [], + }, + }); + } else if ( + gameInput.inputType === GameInputType.SELECT_CARDS && + gameInput.cardContext === CardName.THE_GREEN_ACORN + ) { + const selectedCards = gameInput.clientOptions.selectedCards; + if (!selectedCards) { + throw new Error("Must select card to play."); + } + if (selectedCards.length !== 1) { + throw new Error("Can only play 1 card."); + } + const selectedCardName = selectedCards[0]; + if ( + gameState.meadowCards.indexOf(selectedCardName) < 0 || + player.cardsInHand.indexOf(selectedCardName) < 0 + ) { + throw new Error("Cannot find selected card."); + } + const selectedCard = Card.fromName(selectedCardName); + if (!selectedCard.canPlayIgnoreCostAndSource(gameState)) { + throw new Error(`Unable to play ${selectedCardName}`); + } + + gameState.addGameLogFromCard(CardName.THE_GREEN_ACORN, [ + player, + " selected ", + selectedCard, + " to play.", + ]); + + if (sumResources(selectedCard.baseCost) <= 4) { + gameState.removeCardFromMeadow(selectedCard.name); + selectedCard.addToCityAndPlay(gameState, gameInput); + gameState.addGameLogFromCard(CardName.THE_GREEN_ACORN, [ + player, + " played ", + selectedCard, + ]); + } else { + gameState.pendingGameInputs.push({ + inputType: GameInputType.SELECT_PAYMENT_FOR_CARD, + prevInputType: gameInput.inputType, + cardContext: CardName.THE_GREEN_ACORN, + card: selectedCardName, + clientOptions: { + card: selectedCardName, + paymentOptions: { resources: {} }, + }, + }); + } + } else if ( + gameInput.inputType === GameInputType.SELECT_PAYMENT_FOR_CARD && + gameInput.cardContext === CardName.INN + ) { + if (!gameInput.clientOptions?.paymentOptions?.resources) { + throw new Error( + "Invalid input: clientOptions.paymentOptions.resources missing" + ); + } + const card = Card.fromName(gameInput.card); + const paymentError = player.validatePaidResources( + gameInput.clientOptions.paymentOptions.resources, + card.baseCost, + "ANY 3" + ); + if (paymentError) { + throw new Error(paymentError); + } + player.payForCard(gameState, gameInput); + card.addToCityAndPlay(gameState, gameInput); + + gameState.removeCardFromMeadow(card.name); + + gameState.addGameLogFromCard(CardName.INN, [ + player, + " played ", + card, + " from the Meadow.", + ]); + } + }, +}; diff --git a/src/model/deck.ts b/src/model/deck.ts index 92a1496f..dbcf7f27 100644 --- a/src/model/deck.ts +++ b/src/model/deck.ts @@ -87,3 +87,29 @@ export const initialDeck = ({ return cardStack; }; + +export const legendaryCritters = (): CardStack => { + return new CardStack({ + name: "Legendary Critters", + cards: [ + CardName.AMILLA_GLISTENDEW, + CardName.CIRRUS_WINDFALL, + CardName.FORESIGHT, + CardName.FYNN_NOBLETAIL, + CardName.POE, + ], + }); +}; + +export const legendaryConstructions = (): CardStack => { + return new CardStack({ + name: "Legendary Constructions", + cards: [ + CardName.BRIDGE_OF_THE_SKY, + CardName.MCGREGORS_MARKET, + CardName.OLEANDERS_OPERA_HOUSE, + CardName.SILVER_SCALE_SPRING, + CardName.THE_GREEN_ACORN, + ], + }); +}; diff --git a/src/model/gameState.ts b/src/model/gameState.ts index b3a93c83..425aca36 100644 --- a/src/model/gameState.ts +++ b/src/model/gameState.ts @@ -44,7 +44,7 @@ import { RiverDestinationSpot, } from "./riverDestination"; import { Event, initialEventMap } from "./event"; -import { initialDeck } from "./deck"; +import { initialDeck, legendaryCritters, legendaryConstructions } from "./deck"; import { assertUnreachable } from "../utils"; import { toGameText, @@ -109,7 +109,6 @@ export const gameTextToDebugStr = (gameText: GameText): string => { ); } assertUnreachable(part, `Unexpected part: ${JSON.stringify(part)}`); - break; default: assertUnreachable(part, `Unexpected part: ${JSON.stringify(part)}`); } @@ -121,6 +120,7 @@ const defaultGameOptions = (gameOptions: Partial): GameOptions => { return { realtimePoints: false, pearlbrook: false, + legends: false, ...gameOptions, }; }; @@ -151,6 +151,9 @@ export class GameState { readonly adornmentsPile: CardStack | null; readonly riverDestinationMap: RiverDestinationMap | null; + readonly legendaryCritters: CardStack | null; + readonly legendaryConstructions: CardStack | null; + constructor({ gameStateId, activePlayerId, @@ -166,6 +169,8 @@ export class GameState { playedGameInputs = [], adornmentsPile = null, riverDestinationMap = null, + legendaryCritters = null, + legendaryConstructions = null, }: { gameStateId: number; activePlayerId?: Player["playerId"]; @@ -177,6 +182,8 @@ export class GameState { eventsMap: EventNameToPlayerId; adornmentsPile?: CardStack | null; riverDestinationMap?: RiverDestinationMap | null; + legendaryCritters?: CardStack | null; + legendaryConstructions?: CardStack | null; pendingGameInputs: GameInputMultiStep[]; playedGameInputs?: GameInput[]; gameLog: GameLogEntry[]; @@ -193,8 +200,15 @@ export class GameState { this.pendingGameInputs = pendingGameInputs; this.playedGameInputs = playedGameInputs; this.gameLog = gameLog; + + // Pearlbrook this.adornmentsPile = adornmentsPile; this.riverDestinationMap = riverDestinationMap; + + // Legends + this.legendaryCritters = legendaryCritters; + this.legendaryConstructions = legendaryConstructions; + this.gameOptions = defaultGameOptions(gameOptions); } @@ -308,6 +322,12 @@ export class GameState { adornmentsPile: this.adornmentsPile ? this.adornmentsPile.toJSON(includePrivate) : null, + legendaryCritters: this.legendaryCritters + ? this.legendaryCritters.toJSON(includePrivate) + : null, + legendaryConstructions: this.legendaryConstructions + ? this.legendaryConstructions.toJSON(includePrivate) + : null, }, ...(includePrivate ? { @@ -1279,6 +1299,12 @@ export class GameState { riverDestinationMap: gameStateJSON.riverDestinationMap ? RiverDestinationMap.fromJSON(gameStateJSON.riverDestinationMap) : null, + legendaryCritters: gameStateJSON.legendaryCritters + ? CardStack.fromJSON(gameStateJSON.legendaryCritters) + : null, + legendaryConstructions: gameStateJSON.legendaryConstructions + ? CardStack.fromJSON(gameStateJSON.legendaryConstructions) + : null, }); } @@ -1297,6 +1323,7 @@ export class GameState { const gameOptionsWithDefaults = defaultGameOptions(gameOptions); const withPearlbrook = gameOptionsWithDefaults.pearlbrook; + const withLegends = gameOptionsWithDefaults.legends; const gameState = new GameState({ gameStateId: 1, players, @@ -1309,6 +1336,8 @@ export class GameState { ), adornmentsPile: withPearlbrook ? allAdornments() : null, riverDestinationMap: withPearlbrook ? initialRiverDestinationMap() : null, + legendaryCritters: withLegends ? legendaryCritters() : null, + legendaryConstructions: withLegends ? legendaryConstructions() : null, eventsMap: initialEventMap(gameOptionsWithDefaults), gameOptions: gameOptionsWithDefaults, gameLog: [], @@ -1323,11 +1352,22 @@ export class GameState { ` expansion.`, ]); } + if (withLegends) { + gameState.addGameLog([ + `Playing with the `, + { type: "em", text: "Legends" }, + ` expansion.`, + ]); + } if (shuffleDeck) { if (withPearlbrook) { gameState.adornmentsPile!.shuffle(); } + if (withLegends) { + gameState.legendaryCritters!.shuffle(); + gameState.legendaryConstructions!.shuffle(); + } gameState.deck.shuffle(); } @@ -1340,6 +1380,12 @@ export class GameState { gameState.adornmentsPile!.drawInner() ); } + if (withLegends) { + p.legendsInHand.push( + gameState.legendaryCritters!.drawInner(), + gameState.legendaryConstructions!.drawInner() + ); + } p.drawCards(gameState, STARTING_PLAYER_HAND_SIZE + idx); }); @@ -1349,6 +1395,11 @@ export class GameState { if (withPearlbrook) { gameState.addGameLog(`Dealing 2 adornment cards to each player.`); } + if (withLegends) { + gameState.addGameLog( + `Dealing 1 legendary critter and 1 legendary construction to each player.` + ); + } return gameState; } @@ -1496,6 +1547,15 @@ export class GameState { ret.push({ card: cardName, fromMeadow: false }); } }); + player.legendsInHand.forEach((cardName) => { + const card = Card.fromName(cardName); + if ( + player.canAffordCard(card.name, false) && + card.canPlayIgnoreCostAndSource(this, false /* strict */) + ) { + ret.push({ card: cardName, fromMeadow: false }); + } + }); return ret; } diff --git a/src/model/jsonTypes.ts b/src/model/jsonTypes.ts index 53af4eef..d20a6b7e 100644 --- a/src/model/jsonTypes.ts +++ b/src/model/jsonTypes.ts @@ -38,6 +38,8 @@ export type GameStateJSON = { gameLog: GameLogEntry[]; riverDestinationMap: RiverDestinationMapJSON | null; adornmentsPile: CardStackJSON | null; + legendaryCritters: CardStackJSON | null; + legendaryConstructions: CardStackJSON | null; }; export type CardStackJSON = { @@ -63,6 +65,8 @@ export type PlayerJSON = { adornmentsInHand: AdornmentName[]; playedAdornments: AdornmentName[]; numAmbassadors: number; + legendsInHand: CardName[]; + numLegendsInHand: number; }; export type RiverDestinationMapJSON = { diff --git a/src/model/player.ts b/src/model/player.ts index 9da851bf..0275037c 100644 --- a/src/model/player.ts +++ b/src/model/player.ts @@ -21,6 +21,7 @@ import { LocationType, PlayerStatus, IGameTextEntity, + ExpansionType, } from "./types"; import { PlayerJSON } from "./jsonTypes"; import { GameState } from "./gameState"; @@ -58,6 +59,8 @@ export class Player implements IGameTextEntity { readonly adornmentsInHand: AdornmentName[]; readonly playedAdornments: AdornmentName[]; + readonly legendsInHand: CardName[]; + constructor({ name, playerSecret = uuid(), @@ -81,6 +84,7 @@ export class Player implements IGameTextEntity { playerStatus = PlayerStatus.DURING_SEASON, adornmentsInHand = [], playedAdornments = [], + legendsInHand = [], }: { name: string; playerSecret?: string; @@ -97,6 +101,7 @@ export class Player implements IGameTextEntity { playerStatus?: PlayerStatus; adornmentsInHand?: AdornmentName[]; playedAdornments?: AdornmentName[]; + legendsInHand?: CardName[]; }) { this.playerId = playerId; this.playerSecret = playerSecret; @@ -115,6 +120,9 @@ export class Player implements IGameTextEntity { this.adornmentsInHand = adornmentsInHand; this.playedAdornments = playedAdornments; + // legends only + this.legendsInHand = legendsInHand; + this._numCardsInHand = numCardsInHand; } @@ -332,26 +340,29 @@ export class Player implements IGameTextEntity { while (pendingPlayCardGameInput.length !== 0) { const gameInput = pendingPlayCardGameInput.pop() as GameInputPlayCard; pendingCardNames.pop(); - [CardName.HISTORIAN, CardName.SHOPKEEPER, CardName.COURTHOUSE].forEach( - (cardName) => { - // Don't trigger if we just played this card and we haven't gotten to it yet. - // Eg. We played POSTAL_PIGEON -> SHOPKEEPER. We shouldn't activate SHOPKEEPER - // on the POSTAL_PIGEON. - if (pendingCardNames.indexOf(cardName) !== -1) { - return; - } + [ + CardName.HISTORIAN, + CardName.SHOPKEEPER, + CardName.COURTHOUSE, + CardName.FORESIGHT, + ].forEach((cardName) => { + // Don't trigger if we just played this card and we haven't gotten to it yet. + // Eg. We played POSTAL_PIGEON -> SHOPKEEPER. We shouldn't activate SHOPKEEPER + // on the POSTAL_PIGEON. + if (pendingCardNames.indexOf(cardName) !== -1) { + return; + } - if (this.hasCardInCity(cardName)) { - const card = Card.fromName(cardName); - card.activateCard( - gameState, - gameInput, - this, - this.getFirstPlayedCard(cardName) - ); - } + if (this.hasCardInCity(cardName)) { + const card = Card.fromName(cardName); + card.activateCard( + gameState, + gameInput, + this, + this.getFirstPlayedCard(cardName) + ); } - ); + }); } } @@ -996,6 +1007,13 @@ export class Player implements IGameTextEntity { } } + // Check if you have the associated card if card is legendary + if (card.expansion === ExpansionType.LEGENDS) { + if (this.hasCardInCity(card.upgradeableCard!)) { + return true; + } + } + // Queen (below 3 vp free) if ( card.baseVP <= 3 && @@ -1037,7 +1055,7 @@ export class Player implements IGameTextEntity { paidResources: CardCost, cardCost: CardCost, // Discounts are exclusive so we use a single argument to represent them - discount: ResourceType.BERRY | "ANY 3" | "ANY 1" | null = null, + discount: ResourceType.BERRY | "ANY 4" | "ANY 3" | "ANY 1" | null = null, errorIfOverpay = true ): string | null { const needToPay = { @@ -1086,6 +1104,17 @@ export class Player implements IGameTextEntity { const payingWithRemainerSum = sumResources(payingWith); // With wild discount, should have outstandingOwedSum left + if (discount === "ANY 4" && outstandingOwedSum <= 4) { + if ( + errorIfOverpay && + payingWithSum !== 0 && + payingWithSum + 4 > needToPaySum + ) { + return "Cannot overpay for cards"; + } + return null; + } + if (discount === "ANY 3" && outstandingOwedSum <= 3) { if ( errorIfOverpay && @@ -1138,7 +1167,7 @@ export class Player implements IGameTextEntity { paidResources: CardCost, cardCost: CardCost, // Discounts are exclusive so we use a single argument to represent them - discount: ResourceType.BERRY | "ANY 3" | "ANY 1" | null = null, + discount: ResourceType.BERRY | "ANY 4" | "ANY 3" | "ANY 1" | null = null, errorIfOverpay = true ): boolean { return !this.validatePaidResources( @@ -1231,6 +1260,8 @@ export class Player implements IGameTextEntity { `Unexpected card: ${paymentOptions.cardToUse}` ); } + } else if (paymentOptions.cardToUpgrade) { + // Do something? } } @@ -1654,12 +1685,15 @@ export class Player implements IGameTextEntity { numAdornmentsInHand: this.adornmentsInHand.length, cardsInHand: null, adornmentsInHand: [], + legendsInHand: [], playedAdornments: this.playedAdornments, + numLegendsInHand: this.legendsInHand.length, ...(includePrivate ? { playerSecret: this.playerSecret, cardsInHand: this.cardsInHand, adornmentsInHand: this.adornmentsInHand, + legendsInHand: this.legendsInHand, } : {}), }); diff --git a/src/model/types.ts b/src/model/types.ts index cb42cb10..f1640738 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -74,6 +74,7 @@ export type GameInputPlayCard = { export type GameInputClaimEvent = { inputType: GameInputType.CLAIM_EVENT; + prevInputType?: GameInputType; clientOptions: { event: EventName | null; }; @@ -262,6 +263,7 @@ export type GameInputMultiStep = ( | GameInputSelectOptionGeneric | GameInputSelectPlayedAdornment | GameInputSelectRiverDestination + | GameInputClaimEvent ) & GameInputMultiStepContext & { prevInput?: GameInput; @@ -515,6 +517,18 @@ export enum CardName { PIRATE = "Pirate", PIRATE_SHIP = "Pirate Ship", SHIPWRIGHT = "Shipwright", + + // Legends + AMILLA_GLISTENDEW = "Amilla Glistendew", + BRIDGE_OF_THE_SKY = "Bridge of the Sky", + CIRRUS_WINDFALL = "Cirrus Windfall", + FORESIGHT = "Foresight", + FYNN_NOBLETAIL = "Fynn Nobletail", + MCGREGORS_MARKET = "McGregor's Market", + OLEANDERS_OPERA_HOUSE = "Oleander's Opera House", + POE = "Poe", + SILVER_SCALE_SPRING = "Silver Scale Spring", + THE_GREEN_ACORN = "The Green Acorn", } export enum ResourceType { @@ -572,6 +586,19 @@ export type CardPaymentOptions = { | CardName.INN | null; + // Eg. farm, ruins, queen + cardToUpgrade?: + | CardName.QUEEN + | CardName.CRANE + | CardName.POSTAL_PIGEON + | CardName.HISTORIAN + | CardName.KING + | CardName.FARM + | CardName.THEATRE + | CardName.TEACHER + | CardName.RUINS + | CardName.INN; + resources: { [ResourceType.TWIG]?: number; [ResourceType.BERRY]?: number; @@ -641,6 +668,7 @@ export interface IGameTextEntity { export type GameOptions = { realtimePoints: boolean; pearlbrook: boolean; + legends: boolean; }; export enum RiverDestinationType { @@ -667,6 +695,7 @@ export enum RiverDestinationName { export enum ExpansionType { PEARLBROOK = "PEARLBROOK", + LEGENDS = "LEGENDS", } export enum AdornmentName { diff --git a/src/pages/api/create-game-from-state.ts b/src/pages/api/create-game-from-state.ts new file mode 100644 index 00000000..b854050a --- /dev/null +++ b/src/pages/api/create-game-from-state.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { createGameFromGameState } from "../../model/game"; +import { GameState } from "../../model/gameState"; + +export default async ( + req: NextApiRequest, + res: NextApiResponse +): Promise => { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + res.status(405).end(`Method ${req.method} Not Allowed`); + return; + } + + const game = await createGameFromGameState(GameState.fromJSON(req.body)); + res.json({ + success: "ok", + gameUrl: `/game/${game.gameId}?gameSecret=${game.gameSecretUNSAFE}`, + }); +}; diff --git a/src/pages/api/create-game.ts b/src/pages/api/create-game.ts index 3738a61e..3e7f3491 100644 --- a/src/pages/api/create-game.ts +++ b/src/pages/api/create-game.ts @@ -33,9 +33,10 @@ export default async ( : [...body.players]; const realtimePoints = !!body.realtimePoints; const pearlbrook = !!body.pearlbrook; + const legends = !!body.legends; const game = await createGame( players.map((p: any) => p.name), - { realtimePoints, pearlbrook } + { realtimePoints, pearlbrook, legends } ); res.json({ success: "ok", diff --git a/src/pages/test/inputs.tsx b/src/pages/test/inputs.tsx index 957f0f58..59153f47 100644 --- a/src/pages/test/inputs.tsx +++ b/src/pages/test/inputs.tsx @@ -73,6 +73,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { player.addToCity(gameState, CardName.LOOKOUT); player.addToCity(gameState, CardName.CHIP_SWEEP); player.addToCity(gameState, CardName.MINER_MOLE); + player.addToCity(gameState, CardName.AMILLA_GLISTENDEW); player.placeWorkerOnCard( gameState, diff --git a/src/pages/test/legends.tsx b/src/pages/test/legends.tsx new file mode 100644 index 00000000..f41c9601 --- /dev/null +++ b/src/pages/test/legends.tsx @@ -0,0 +1,76 @@ +import { GetServerSideProps, NextPage } from "next"; +import GameInputBox from "../../components/GameInputBox"; +import { testInitialGameState } from "../../model/testHelpers"; +import { CardName, EventName, GameInputType } from "../../model/types"; +import { Game as GameModel } from "../../model/game"; +import { GameJSON } from "../../model/jsonTypes"; +import { GameState } from "../../model/gameState"; +import { FunctionComponent, useEffect } from "react"; +import Game from "../../components/Game"; +import { NextRouter, useRouter } from "next/router"; + +export const getServerSideProps: GetServerSideProps = async (context) => { + const playerNames = ["Michael", "Elynn", "Chris", "Vanessa"]; + const numPlayers = playerNames.length; + const gameState = testInitialGameState({ + numPlayers, + playerNames, + specialEvents: [ + EventName.SPECIAL_GRADUATION_OF_SCHOLARS, + EventName.SPECIAL_A_BRILLIANT_MARKETING_PLAN, + EventName.SPECIAL_PERFORMER_IN_RESIDENCE, + EventName.SPECIAL_CAPTURE_OF_THE_ACORN_THIEVES, + ], + shuffleDeck: true, + gameOptions: { + legends: true, + }, + }); + + gameState.players.forEach((player, idx) => { + player.nextSeason(); + + player.drawCards(gameState, idx); + }); + + const game = new GameModel({ + gameId: "testGameId", + gameSecret: "testGameSecret", + gameState, + }); + + return { + props: { + game: game.toJSON(true /* includePrivate */), + }, + }; +}; + +async function createGame(router: NextRouter, game: GameJSON) { + const response = await fetch("/api/create-game-from-state", { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(game.gameState), + }); + const json = await response.json(); + if (json.success && json.gameUrl) { + router.push(json.gameUrl); + } else { + alert(json.error); + } +} + +const AmillaGlistendew: FunctionComponent<{ game: GameJSON }> = (props) => { + const router = useRouter(); + + useEffect(() => { + createGame(router, props.game); + }, []); + + return null; +}; + +export default AmillaGlistendew; diff --git a/src/pages/test/ui.tsx b/src/pages/test/ui.tsx index 3b420f41..a3946c5c 100644 --- a/src/pages/test/ui.tsx +++ b/src/pages/test/ui.tsx @@ -64,6 +64,7 @@ export default function TestUIPage() { ) as LocationName[]; const [showPearlbrookOnly, setShowPearlbrookOnly] = useState(false); + const [showLegendsOnly, setShowLegendsOnly] = useState(false); const [showCards, setShowCards] = useState(true); const [showAdornments, setShowAdornments] = useState(true); const [showRiver, setShowRiver] = useState(true); @@ -111,6 +112,16 @@ export default function TestUIPage() { /> Pearlbrook only +
{allCards @@ -127,6 +138,11 @@ export default function TestUIPage() { return false; } } + if (showLegendsOnly) { + if (CardModel.fromName(x).expansion !== ExpansionType.LEGENDS) { + return false; + } + } return true; }) .map((card) => { diff --git a/src/styles/card.module.css b/src/styles/card.module.css index b94f8da1..366648eb 100644 --- a/src/styles/card.module.css +++ b/src/styles/card.module.css @@ -1,5 +1,5 @@ .card { - min-height: 180px; + min-height: 200px; width: 200px; border-radius: 5px; position: relative; @@ -12,6 +12,14 @@ justify-content: space-between; } +.card_header_row { + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + width: 100%; + overflow: hidden; +} + .card_header { height: 35px; font-weight: var(--font-bold); @@ -22,6 +30,8 @@ display: flex; flex-direction: column; justify-content: center; + flex: 1 1 auto; + overflow: hidden; } .expansion { @@ -38,7 +48,11 @@ display: flex; color: var(--text-color); text-transform: uppercase; - text-align: right; +} + +.upgradeable_card { + display: flex; + text-transform: uppercase; } .associated_card_used { @@ -46,7 +60,8 @@ opacity: 0.3; } -.associated_card > span { +.associated_card > span, +.upgradeable_card > span { border-radius: 5px 0 0 0; font-weight: var(--font-bold); color: var(--text-color-inv); @@ -55,6 +70,11 @@ align-self: flex-end; } +.upgradeable_card > span { + background: var(--color-legend); + border-radius: 0 5px 0 0; +} + .info_row { display: flex; flex-direction: column; @@ -90,6 +110,7 @@ font-size: 10px; font-weight: var(--font-bold); color: var(--text-color-light); + text-align: center; } .circle { @@ -97,16 +118,10 @@ width: 24px; height: 24px; margin: 6px; - text-align: center; font-size: 27px; -} - -.card_header_vp { - position: absolute; - top: 0; - right: 0; - z-index: 10; + position: relative; + flex: 0 0 auto; } .card_header_vp_number { @@ -116,12 +131,7 @@ margin-top: -14px; vertical-align: middle; color: var(--color-vp-text); -} - -.card_header_symbol { - position: absolute; - top: 0; - z-index: 10; + flex: 0 0 auto; } .color_victory_point { diff --git a/src/styles/globals.css b/src/styles/globals.css index 562be452..e90070d4 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -24,12 +24,13 @@ --color-vp: #e3b117; --color-vp-text: #3c1346; - --color-dest: #943032; + --color-dest: #79191d; --color-prod: #88a55e; --color-pros: #534279; --color-trav: #796a53; --color-gov: #476f80; --color-loc: #13463c; + --color-legend: #b94147; --color-loc-basic: #88a55e; --color-loc-forest: #4e5627; diff --git a/src/styles/location.module.css b/src/styles/location.module.css index 7f4fd205..3df160e6 100644 --- a/src/styles/location.module.css +++ b/src/styles/location.module.css @@ -1,5 +1,5 @@ .location { - height: 100px; + height: 120px; width: 200px; border-radius: 5px; color: var(--color-loc);