diff --git a/package-lock.json b/package-lock.json index fe9d904e..0cbb9dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -544,6 +544,26 @@ "@parcel/watcher-win32-x64": "2.5.1" } }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", @@ -563,6 +583,126 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-linux-x64-glibc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", @@ -583,6 +723,86 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", diff --git a/src/ps/commands/games/core.tsx b/src/ps/commands/games/core.tsx index 9fb379ea..388a1cc7 100644 --- a/src/ps/commands/games/core.tsx +++ b/src/ps/commands/games/core.tsx @@ -1,9 +1,11 @@ import { PSGames } from '@/cache'; import { gameCache } from '@/cache/games'; +import { prefix } from '@/config/ps'; import { Games } from '@/ps/games'; import { renderBackups, renderMenu } from '@/ps/games/menus'; import { generateId } from '@/ps/games/utils'; import { ChatError } from '@/utils/chatError'; +import { Button } from '@/utils/components/ps'; import { fromHumanTime, toHumanTime } from '@/utils/humanTime'; import { Timer } from '@/utils/timer'; import { toId } from '@/utils/toId'; @@ -11,10 +13,83 @@ import { toId } from '@/utils/toId'; import type { NoTranslate, TranslationFn } from '@/i18n/types'; import type { CommonGame } from '@/ps/games/game'; import type { BaseModEntry } from '@/ps/games/mods'; +import type { GameHTPData, HTPDropdown, HTPImage } from '@/ps/games/types'; import type { PSCommand, PSCommandChild } from '@/types/chat'; import type { Room } from 'ps-client'; import type { HTMLopts } from 'ps-client/classes/common'; +const guideImageUrl = (path: HTPImage['path']) => (/^https?:\/\//.test(path) ? path : `${process.env.WEB_URL}/static/guides/${path}`); + +const HTPLine = ({ line }: { line: string }) => { + const parts = line.split(/(https?:\/\/[^\s]+)/g); + if (parts.length === 1) return <>{line}; + + return ( + <> + {parts.map((part, idx) => + /^https?:\/\//.test(part) ? ( + + {part} + + ) : ( + part + ) + )} + + ); +}; + +const HTPSection = ({ section }: { section: HTPDropdown }) => { + return ( +
+ {section.title} +
+ {section.lines?.map((line, idx) => ( +
+ +
+ ))} + {section.images?.map((image, idx) => ( + {image.alt + ))} + {section.content} + {section.subsections?.map((sub, idx) => )} +
+
+ ); +}; + +const HowToPlayBox = ({ data }: { data: GameHTPData }) => { + return ( +
+
+ Goal:{' '} + + + +
+ + {data.sections.map((sec, idx) => ( + + ))} +
+ ); +}; + type SearchContext = | { action: 'start'; user: string } | { action: 'join'; user: string } @@ -152,11 +227,35 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): }, help: `Game module for ${Game.meta.name}. See subcommands.`, syntax: 'CMD', - async run({ run, command }) { - return run(`help ${command.join(' ')}`); + async run({ broadcastHTML, message }) { + return broadcastHTML( +
+

+ Hi, to make a game of {gameId}, you  + {Game.meta.players === 'single' ? `can use ,${gameId} create!` : 'need to ask a staff member!'} +

+

+ If you want to learn how to play, check{' '} + {' '} + out! +

+
+ ); }, categories: ['game'], children: { + help: { + name: 'help', + aliases: ['h'], + help: 'Shows this help message.', + syntax: 'CMD', + flags: { allowPMs: true, noDisplay: true }, + async run({ command, run }) { + return run(`help ${command[0]}`); + }, + }, create: { name: 'create', aliases: ['new', 'n'], @@ -223,6 +322,17 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): } }, }, + htp: { + name: 'htp', + aliases: ['howtoplay'], + help: 'Shows the how-to-play for this game.', + flags: { allowPMs: true }, + syntax: 'CMD', + async run({ message }) { + const htpData = Game.meta.htp; + return message.replyHTML(); + }, + }, ...conditionalCommand( Game.meta.autostart === false, { diff --git a/src/ps/games/azul/meta.ts b/src/ps/games/azul/meta.ts index 55369368..905b2f78 100644 --- a/src/ps/games/azul/meta.ts +++ b/src/ps/games/azul/meta.ts @@ -24,6 +24,50 @@ export const meta: Meta = { pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('45 sec'), + htp: { + goal: 'Have the highest score when any player completes a wall row.', + sections: [ + { + title: 'Setup', + lines: [ + '- The game is played over several rounds, each consisting of two phases: drafting tiles and then scoring walls.', + '- Game begins with 4 tiles placed in each factory.', + ], + }, + { + title: 'Drafting Phase', + lines: [''], + subsections: [ + { + title: 'Picking tiles', + lines: [ + '- Choose a tile from a factory to take all tiles of the same type from it.', + '- The remaining tiles from that factory are moved to the waste.', + '- The first to pick from the waste must also take the waste marker, giving them a penalty but also the first turn in the next round.', + ], + }, + { + title: 'Placing tiles', + lines: [ + '- You can only place the tiles in one of the rows on your player board.', + '- You must place all tiles of the same color in a row.', + '- Any overflow of tiles will be moved to the penalty line.', + ], + }, + ], + }, + { + title: 'Scoring', + lines: [ + '- Happens when there are no tiles left in factories/waste.', + '- For each filled row, place one tile on the wall in the corresponding row and type.', + '- Each wall tile scores contiguous horizontal and vertical lines through it.', + '- After the game, each filled row gives 2 points, every column 7, every type 10.', + '- The game ends when a row is filled.', + ], + }, + ], + }, // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/battleship/meta.ts b/src/ps/games/battleship/meta.ts index bb2d1b6e..e78337ae 100644 --- a/src/ps/games/battleship/meta.ts +++ b/src/ps/games/battleship/meta.ts @@ -19,6 +19,29 @@ export const meta: Meta = { pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), + htp: { + goal: "Sink all of your opponent's ships before they sink yours!", + sections: [ + { + title: 'Setup', + lines: [ + '- 5 ships each to be placed on a 10x10 grid.', + '- They are: (C)arrier (5), (B)attleship (4), (D)estroyer (3), (S)ubmarine (3), and (P)atrol (2).', + '- Ships can be placed horizontally or vertically, but not diagonally.', + '- Ships cannot overlap or go out of bounds.', + ], + }, + { + title: 'Gameplay', + lines: [ + "- Players take turns shooting at coordinates on their opponent's board.", + '- If a ship is hit, it is marked as hit.', + '- If all ships on one side are sunk, that player loses, and the game ends.', + ], + }, + ], + }, + // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/chess/meta.ts b/src/ps/games/chess/meta.ts index adfb0b21..05a47dc4 100644 --- a/src/ps/games/chess/meta.ts +++ b/src/ps/games/chess/meta.ts @@ -23,6 +23,10 @@ export const meta: Meta = { themes: CHESS_THEMES, defaultTheme, + htp: { + goal: 'Checkmate the opponent\'s king!', + sections: [{ title: 'Check this guide out for basics!', lines: ['https://www.chess.com/learn-how-to-play-chess'] }] + }, // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/connectfour/meta.ts b/src/ps/games/connectfour/meta.ts index 8e4571e8..5a9e70fd 100644 --- a/src/ps/games/connectfour/meta.ts +++ b/src/ps/games/connectfour/meta.ts @@ -19,6 +19,18 @@ export const meta: Meta = { pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), + htp: { + goal: 'Get four of your colored discs in a row—horizontally, vertically, or diagonally.', + sections: [ + { + title: 'Gameplay', + lines: [ + '- Players take turns dropping one disc into one of the seven columns.', + '- Discs stack on top of each other in the selected column.', + ], + }, + ], + }, // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/lightsout/meta.ts b/src/ps/games/lightsout/meta.ts index ae20f450..09597a7f 100644 --- a/src/ps/games/lightsout/meta.ts +++ b/src/ps/games/lightsout/meta.ts @@ -9,6 +9,20 @@ export const meta: Meta = { abbr: 'lo', players: 'single', + htp: { + goal: 'Turn all the lights off! Bonus points if you can beat PartBot :p', + sections: [ + { + title: 'How to Play', + lines: [ + '- The game is played on a 5×5 grid of lights.', + '- When you click on a light, it toggles (turns on or off), including adjacent lights (up, down, left, right).', + '- A helpful guide: https://www.logicgamesonline.com/lightsout/tutorial.html', + ], + }, + ], + }, + // UGO-CODE ugo: null, }; diff --git a/src/ps/games/mastermind/meta.ts b/src/ps/games/mastermind/meta.ts index 681a6575..4a77a2ad 100644 --- a/src/ps/games/mastermind/meta.ts +++ b/src/ps/games/mastermind/meta.ts @@ -9,6 +9,31 @@ export const meta: Meta = { abbr: 'mm', players: 'single', + htp: { + goal: 'Guess the 4 digit code before your guesses run out!', + sections: [ + { + title: 'How to Play', + lines: [ + '- Numbers go from 0-7, and they can repeat.', + '- Red pin - correct number correct position', + '- White pin - correct number wrong position', + '- No pin - incorrect number', + "- Reds/Whites don't specify that the number in the corresponding position is correct.", + ], + }, + { + title: 'Example', + lines: [ + "Let's assume the code is 0167...", + '- Guess: 1234. Result: White. (1 is the only correct number but in the wrong position.)', + '- Guess: 2567. Result: Red-Red. (6,7 are correct numbers and correct positions.)', + '- Guess: 6723. Result: White-White. (6,7 are correct numbers but in the wrong positions.)', + '- Guess: 2345. Result: Nothing. (All numbers are incorrect.)', + ], + }, + ], + }, // UGO-CODE - ugo: null + ugo: null, }; diff --git a/src/ps/games/othello/meta.ts b/src/ps/games/othello/meta.ts index 49a64bff..0228d8a9 100644 --- a/src/ps/games/othello/meta.ts +++ b/src/ps/games/othello/meta.ts @@ -18,6 +18,35 @@ export const meta: Meta = { pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), + htp: { + goal: 'Have more discs of your color than your opponent when the game ends.', + sections: [ + { + title: 'Initial board setup', + lines: ['- 2 white and 2 black discs start in the center.'], + images: [{ path: 'othello/setup.png', alt: 'Initial Othello board setup', width: 170, height: 178 }], + }, + { + title: 'Gameplay', + lines: [ + '- Black moves first, then white.', + '- A valid move must outflank at least one opponent disc vertically, horizontally, or diagonally.', + '- Outflank: your disc borders opponent row(s) at both ends.', + '- If you have no valid move, your turn is skipped.', + '- Game ends when neither player can move or the board is full.', + ], + }, + { + title: 'Valid move example', + lines: ['- Black outflanks White vertically, horizontally, and diagonally (not all three required).'], + images: [{ path: 'othello/valid-move.gif', alt: 'Valid Othello move example', width: 170 }], + }, + { + title: 'Win conditions', + lines: ['- Most discs wins.'], + }, + ], + }, // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/scrabble/meta.ts b/src/ps/games/scrabble/meta.ts index d939ea61..6021e9c8 100644 --- a/src/ps/games/scrabble/meta.ts +++ b/src/ps/games/scrabble/meta.ts @@ -24,6 +24,53 @@ export const meta: Meta = { pokeTimer: fromHumanTime('1 min'), timer: fromHumanTime('5 min'), + htp: { + goal: 'Get the most points by placing letters on the board to form words.', + sections: [ + { + title: 'Setup', + lines: [ + '- Each player gets 7 letters (A-Z and blanks, which can represent any letter)', + '- The first word must cover the center (star) square', + ], + }, + { + title: 'Gameplay', + lines: [ + '- Players take turn using letters to form words vertically or horizontally', + '- Every new word must connect to at least one letter already on the board', + '- All words formed by your play must be valid Scrabble words', + '- After turn end, draw new tiles to bring your hand back up to 7 tiles', + '- You may also choose to swap letters with the bag instead of making a word, or pass the turn', + ], + }, + { + title: 'Scores', + lines: [ + '- Points are based on letter values + board multipliers', + '- Playing all 7 letters in one turn gives bonus 50 points', + ], + subsections: [ + { + title: 'Board multipliers:', + lines: [ + '- Double letter score (light blue): Double the value of one letter', + '- Triple letter score (dark blue): Triple the value of one letter', + '- Double word score (pink): Double the score of the entire word', + '- Triple word score (red): Triple the score of the entire word', + ], + }, + ], + }, + { + title: 'Win conditions', + lines: [ + '- Game ends when all tiles are drawn and one player empties their rack, or when no player can make a move', + '- Highest score wins', + ], + }, + ], + }, // UGO-CODE ugo: { cap: 6, diff --git a/src/ps/games/snakesladders/meta.ts b/src/ps/games/snakesladders/meta.ts index 4cfdc11a..7a0da47f 100644 --- a/src/ps/games/snakesladders/meta.ts +++ b/src/ps/games/snakesladders/meta.ts @@ -17,6 +17,20 @@ export const meta: Meta = { pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('45 sec'), + htp: { + goal: 'Be the first to reach 100 to win.', + sections: [ + { + title: 'Gameplay', + lines: [ + '- Each player takes turns rolling a six-sided die to move their token along the board.', + '- Players move their token forward by the number of spaces indicated by the die roll.', + '- If a player lands on a snake, they move their token to the tail of the snake.', + '- If a player lands on a ladder, they move their token to the top of the ladder.', + ], + }, + ], + }, // UGO-CODE ugo: { cap: 12, diff --git a/src/ps/games/splendor/htpContent.tsx b/src/ps/games/splendor/htpContent.tsx new file mode 100644 index 00000000..37fb3fe3 --- /dev/null +++ b/src/ps/games/splendor/htpContent.tsx @@ -0,0 +1,9 @@ +import { Small } from '@/ps/games/render'; +import metadata from '@/ps/games/splendor/metadata.json'; +import { PokemonCard } from '@/ps/games/splendor/render'; + +export const swadloonExample = ( + + + +); diff --git a/src/ps/games/splendor/meta.ts b/src/ps/games/splendor/meta.ts index d5c10fbb..8ffe3e2c 100644 --- a/src/ps/games/splendor/meta.ts +++ b/src/ps/games/splendor/meta.ts @@ -1,3 +1,4 @@ +import { swadloonExample } from '@/ps/games/splendor/htpContent'; import { GamesList } from '@/ps/games/types'; import { fromHumanTime } from '@/utils/humanTime'; @@ -15,6 +16,64 @@ export const meta: Meta = { pokeTimer: fromHumanTime('1 min'), timer: fromHumanTime('2 min'), + htp: { + goal: 'Be the first to get 15 points.', + sections: [ + { + title: 'Cards', + lines: [ + 'Each Card consists of 3 components:', + '- Cost (bottom-left): The amount of Gems needed to buy it.', + '- Type (top-right): Each Card acts as a permanent token of that type.', + '- Point (top-left): The value each Card is worth.', + ], + content: swadloonExample, + }, + { + title: 'Gameplay', + lines: ['On your turn choose 1 action:'], + subsections: [ + { + title: 'Pick up Gems', + lines: [ + '- Take 3 different OR 2 of the same type (only if >=4 of that type available).', + '- Max 10 Gems can be held at a time.', + '- If >10, choose which to return.', + ], + }, + { + title: 'Buy a Card', + lines: ['- Pay Gems equal to the cost to make it yours.', '- No game limit on number of Cards buyable.'], + }, + { + title: 'Reserve a Card', + lines: [ + '- Take a card so only you can buy it.', + '- Also gain a Dragon gem (if possible).', + '- Can reserve up to 3 cards only.', + ], + }, + ], + }, + { + title: 'Trainers', + lines: [ + 'Each Trainer card consists of 2 components:', + '- Point (top-right): The value each Trainer is worth', + '- Requirement (bottom-left): Requires having Cards (not Gems) of that specific type.', + '- No payment needed: Trainers are gained automatically when requirements are fulfilled.', + ], + }, + { + title: 'Win conditions', + lines: [ + '- Once a player reaches 15 points, everyone after them in turn order gets one last turn.', + '- If points are tied, the one with fewer Cards wins.', + '- If still tied, Audiino comes and bullies all winners.', + ], + }, + ], + }, // UGO-CODE ugo: { cap: 8, diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts index 08f4b74c..2f3afe25 100644 --- a/src/ps/games/types.ts +++ b/src/ps/games/types.ts @@ -1,6 +1,28 @@ import type { TranslatedText } from '@/i18n/types'; import type { ModData, ModEnum } from '@/ps/games/mods'; import type { Satisfies } from '@/types/common'; +import type { ReactElement } from 'react'; + +export interface HTPImage { + /** Path under `/static/guides/`, e.g. `othello/setup.png`. */ + path: string; + alt?: string; + width?: number; + height?: number; +} + +export interface HTPDropdown { + title: string; + lines?: string[]; + images?: HTPImage[]; + content?: ReactElement; + subsections?: HTPDropdown[]; +} + +export interface GameHTPData { + goal: string; + sections: HTPDropdown[]; +} export type Theme> = Partial>> = { id: string; @@ -33,6 +55,9 @@ export type Meta = Readonly< /** Enables `offerdraw`: pending offer expires after 1 minute or when any player makes a move. */ canOfferDraws?: boolean; + /** How to play instructions. */ + htp: GameHTPData; + // UGO-CODE /** * Metadata for automatic UGO points. diff --git a/src/web/static/guides/othello/setup.png b/src/web/static/guides/othello/setup.png new file mode 100644 index 00000000..fbd51967 Binary files /dev/null and b/src/web/static/guides/othello/setup.png differ diff --git a/src/web/static/guides/othello/valid-move.gif b/src/web/static/guides/othello/valid-move.gif new file mode 100644 index 00000000..e12b2452 Binary files /dev/null and b/src/web/static/guides/othello/valid-move.gif differ