diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..18dc1d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[**/*.{js,jsx,html}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 0000000..07e067c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,49 @@ +name: 🐛 Bug report (バグ報告) +description: Create a report to help me improve +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thank you for reporting an issue. + バグ報告・ご意見、ありがとうございます! + + This issue tracker is for bugs and issues found within this product. + Please fill in as much of the following form as you're able. + 以下の項目を埋めていただき、ご報告いただければ幸いです。 + - type: dropdown + id: os + attributes: + label: OS + options: + - Windows 10 + - Windows 11 + - OSX + - iOS + - Android + - Other + default: 0 + validations: + required: true + - type: input + attributes: + label: Browser (UserAgent) + description: | + ex, `Chrome 122.0.6261.129` + - type: input + attributes: + label: Bug Time (バグの発生日) + description: | + ex, `2024-04-01 JST` + - type: textarea + attributes: + label: What bugs? (バグ内容) + description: text in detail, screenshots and so on. (画像も利用できます) + validations: + required: true + - type: textarea + attributes: + label: What steps will reproduce the bug? (バグの再現手順) + - type: textarea + attributes: + label: Error message on screen. (画面に表示されたエラーメッセージ) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 0000000..ec0ed2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,22 @@ +name: 🚀 Feature request (ご意見・機能要望) +description: Suggest an idea for this project +labels: [feature request] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting an idea to make this product better. + ご意見ご要望、ありがとうございます! + + Please fill in as much of the following form as you're able. + 以下の項目を埋めていただき、ご報告いただければ幸いです。 + - type: textarea + attributes: + label: What is the problem this feature will solve? (解決したい課題は?) + validations: + required: true + - type: textarea + attributes: + label: What is the feature you are proposing to solve the problem? (解決方法は?) + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..49bfbd3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: X Contact with this product owner + url: https://twitter.com/darai_0512 + about: Please speak English or Japanese. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0b03967 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11839e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: test + +on: + pull_request: + push: + branches: + - main + +jobs: + testing: + runs-on: ubuntu-latest + strategy: + matrix: + node: ['20'] + steps: + - uses: actions/checkout@v2 + - name: Setup node + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + - run: yarn --frozen-lockfile + - run: yarn lint + - run: yarn test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..588948b --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +package-lock.json +yarn.lock +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f2ad5c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Air Poker (inspired by 嘘喰い) + +[![Build Status](https://github.com/darai0512/air-poker/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/darai0512/air-poker/actions) + +## Rule + +- 原作になるべく沿いつつ、ゲーム性を第一に改変 +- プレイヤーは上側と下側を一人で同時にこなす +- ベットフェーズにおいては、上側のルールを優先する + - ペアを作るまで相手の数字を見れない + - 下を優先してベット時にカードオープンしてしまうと、(ネタバレ済のため)相手の役がわかり、ベットが単調になる + +### 原作との差異 + +- カード消費がないケース(v42p183) + - 役作りで使用済みカードを使うミスした時 + - 制限時間オーバー + - 天災 + +## Reference + +- https://phmpk.hatenablog.com/entry/2016/06/11/073000 +- card animation + - https://codepen.io/agdales/pres/qbrRvp +- Trump design: ["白魔空間"](http://shiroma.client.jp/download/material/trump_23x32/) + +# for Developper +## Local dev + +``` +$docker compose exec mongo /bin/sh +sh-4.4# mongosh mongodb://root:root@localhost:27778/?directConnection=true +> use test +> show collections +> db.matching.find({}) +> db.matching.drop() +``` \ No newline at end of file diff --git a/app/@rule/main.tsx b/app/@rule/main.tsx new file mode 100644 index 0000000..8fb7fbb --- /dev/null +++ b/app/@rule/main.tsx @@ -0,0 +1,320 @@ +'use client' +import { Table, + TableBody, + TableCell, + TableHead, + TableHeadCell, + TableRow, + Timeline, + TimelineBody, + TimelineContent, + TimelineItem, + TimelinePoint, + TimelineTitle, + List, + ListItem, +} from "flowbite-react" +import Link from 'next/link' +import {ExclamationCircleIcon} from "@heroicons/react/16/solid" +import {I18nStr} from '~/app/components/const' + +const rule = { + ja: (
+
+エア・ポーカーは「嘘喰い(著: 迫稔雄)」の作中内ゲーム(40~43巻)です。
+以下のルール説明はネタバレにあたるためご注意ください。 +
+
+
+あなたと相手は水没した部屋でair(空気入りチップ)を賭け対戦します。
+25 air を与えられますが、毎ラウンド始まりに、呼吸のため 1 air を消費します...
+
+ + + + + 1. 手札1枚を裏向きに場へ出す + + 5枚の手札から、ラウンド毎に1枚場に出します(ドラッグ&ドロップ)。
+ ※ 制限時間(60秒)を超えると自動選択されます。
+
+ 手札の数字は(スート任意の)トランプ5枚分の数字の和を表します。
+ 互いに場へ出したら(2-2.)、数字を満たすポーカーの役を作り合います。
+ トランプは1デッキを共有し、使用されたカードは使えません。
+
+ + 数字をクリック: 現在作成可能な役の一覧を表示 + Cardsボタン: 残りのトランプを確認 + +
+
+
+ + + + 2-1. airを賭ける + + 場代としてRound数と同じairが自動的にBetされます。
+ 2-2.と並行して、30秒制限のBet(以下)を繰り返します。
+ ※ 制限時間を超えるとCHECKかFOLDが自動選択されます。
+
+ + + Betボタン + 説明 + + + + + CHECK + + + パスします(一度もRAISEされていない場合のみ可能)。
+ 互いにCHECKなら、互いに役を確定後、判定に移ります。 +
+
+ + + RAISE + + + 相手のRAISE分(CHECKなら0) + 上乗せ分、を賭けます。
+ 上限:「互いの残air」or「場のBet総額の半分」の小さい方
+

+  スライダーで上乗せ分を調整できます

+
+
+ + + CALL + + + 相手のRAISEに対し、同じairを賭けます。
+ 互いに役を確定後、判定に移ります。 +
+
+ + + FOLD + + + このラウンドが敗北扱いとなります。
+ 役が未確定のプレイヤーがいても、判定に移ります。
+
+
+
+
+
+ ※ Bet順は、1ラウンド目は場の数字が小さいプレイヤーから
+ (同じ場合はランダム)。次ラウンド以降は交互。 +
+
+
+ + + + 2-2. 役を作る + + 2-1.と並行して、100秒以内に役を作ってください。
+ 役を確定させると、相手の数字がオープンされます。
+ (あなたの数字が表になる=相手の役が確定した)
+
+ ※(カード消費やミスで)合計数値が一致しない役作りをした場合、
+ 制限時間超えで役を作れなかった場合は、
+ 自動的に無役(最低役、カード消費なし)となります。
+ 例.
+ 47でロイヤルストレートフラッシュ(10, J, Q, K, A)を作ると、次ラウンド
+ 以降は6を出してもフォーカード(A, A, A, A, 2)は作れず、無役となります。
+
+ + タイマーボタン: 役を作るモーダルを開閉 + 確定ボタン: カード5枚を選ぶと押下可能。押下で即確定 + 「待機中...」点滅クリック: 確定させた役を確認 + +
+
+
+ + + + 3. ラウンド勝敗判定 + + FOLDされていない場合は、ポーカーの役の強さを比較します
+ (同役は構成カードの強さを比較。スートの序列は無し)。
+ ※ ドローの場合はそのラウンドで先にBetしたプレイヤーが勝利
+
+ 互いに同じカード(同数字・同スート)を消費した場合「天災」が発生し、
+ 敗者は追加でBetしたairと同数のairを水中に放出します。
+
+ + プレイヤー名をクリック: 過去ラウンドの履歴を確認 + +
+
+
+ + + + ゲーム勝敗判定 + + 1,2を繰り返し、airが尽きるか、
+ 5ラウンド終了後に相手よりairが少ないプレイヤーが敗者です。 +
+
+
+
+
), + en: (
+
+ Air Poker is a game made by Japanese comic "Usogui" (by Toshio Sako, vol.40-43).
+ Please be aware that the following rule explanations may contain spoilers. +
+
+
+ You have 25 "air" (as the poker tips) in a submerged room...
+ At the beginning of each round, 1 air is consumed for breathing. +
+ + + + + 1. Select 1 card face down on the field + + The numbers in your hand (×5) represent the sum of
+ the numbers on 5 playing cards without suits.
+ Drag & drop 1 card from your hand on the field each round.
+ ※ If over 60 seconds, automatically selected at random.
+
+ After both players placed their cards, form a poker hand.(2-2.)
+ Available cards share one deck with each other and can only be used once.
+
+ + Click hand number: Display a list of currently possible hands + Cards button: Check the remaining playing cards + +
+
+
+ + + + 2-1. Bet your air + + At first, the same amount of air as the round number is bet as entry fee.
+ Select the following clickable action within 30 seconds, and
+ concurrently play 2-2.
+ * If the time limit is exceeded, CHECK or FOLD is automatically selected.
+
+ + + Bet + Description + + + + + CHECK + + + You pass, if nobody choices RAISE.
+ If both players CHECK, move 3. judgement after they play 2-2. +
+
+ + + RAISE + + + You bet the opponent's RAISE (or 0 if CHECK) air + extra air.
+ The limit is the smaller of "each player's remaining air" or
+ "half the total betting air on the table". +

+  Use the slider to adjust the amount of extra air.

+
+
+ + + CALL + + + Against an opponent's RAISE, you bet the same amount of air.
+ Move 3. judgement after they play 2-2. +
+
+ + + FOLD + + + You will lose this round immediately.
+ (even if there is a player whose has not yet played 2-2.) +
+
+
+
+ ※ At the 1st round, bet starts with the player with the lowest number (or
+ randomly if the same).
+ From next round, bet alternates.
+
+
+
+ + + + 2-2. Making a Hand + + Simultaneously with 2-1., please make a hand within 100 seconds.
+ When you fix a poker hand, the opponent's number is opened.(vice versa)
+
+ If you failed to make a poker hand (= the sum of 5 cards not match
+ your number), or if over 100 seconds, you become no-rank. (= the
+ Lowest rank of all poker hands, no card consumption)
+ ex,
+ If you make 47 Royal Straight Flush (10, J, Q, K, A), your number of 6
+ do not become Four of a Kind (A, A, A, A, 2). It will be a no-rank.
+
+ + Timer button: open modal to make your poker hand + Make button: fix your hand (※ you cannot undo) after select 5 cards. + Click "Waiting...": check your fixed hand + +
+
+
+ + + + 3. Judgement + + If nobody FOLD, compare the poker rank from your poker hand.
+ (The same role compares the strength of the component cards.
+ There is no order of suit.)
+ ※ In the case of a draw, the first bet player wins.
+
+ If both players use the same number & suit, Disaster occurs.
+ The loser releases the same amount of air as bet air into the water.
+
+ + Click near player name: view logs up to previous rounds + +
+
+
+ + + + Win Condition + + Repeat the above, and the loser is the player who
+ running out of air or fewer air than the opponent after 5th round. +
+
+
+
+
) +} + +export default function Rule({locale}: {locale: I18nStr}) { + return rule[locale] +} \ No newline at end of file diff --git a/app/@rule/page.tsx b/app/@rule/page.tsx new file mode 100644 index 0000000..dd0b148 --- /dev/null +++ b/app/@rule/page.tsx @@ -0,0 +1,14 @@ +import {getLocale} from '~/app/server/locale' +import i18n from '~/app/components/const' +import Rule from './main' +import Provider from '~/app/components/provider' + +export default async function Home({searchParams}: {searchParams: Record}) { + const intl = await getLocale(searchParams.lang) + + return ( + + + + ) +} \ No newline at end of file diff --git a/app/components/Bubble.tsx b/app/components/Bubble.tsx new file mode 100644 index 0000000..efd00f9 --- /dev/null +++ b/app/components/Bubble.tsx @@ -0,0 +1,37 @@ +'use client' +import React, {memo} from 'react' + +const initNum = 20 +const shakeKind = 10 + +// todo move, position, scale +// const bubbleMap = [{m: 4, p: 8, s: 9},] => i,j消す + +const Bubble = memo(function Bubble({i}: {i: number}) { + let j + switch (i % 5) { + case 0: + j = (i + 5) % shakeKind + case 1: + j = (i + 15) % shakeKind + case 2: + j = (i + 10) % shakeKind + case 3: + j = (i + 20) % shakeKind + default: // = 4 + j = i % shakeKind + } + return ( +
+
+
+
+
+ ) +}) + +export default function Bubbles({num = initNum}: {num?: number}) { // todo num not working + const nodes = [] + for (let i=0;i < num;i++) nodes.push() + return nodes +} \ No newline at end of file diff --git a/app/components/Cards.tsx b/app/components/Cards.tsx new file mode 100644 index 0000000..ed5b891 --- /dev/null +++ b/app/components/Cards.tsx @@ -0,0 +1,118 @@ +'use client' +import {useState, memo, useRef} from "react"; +import {useIntl} from "react-intl"; +import {NUMBERS, SUITS} from "~/src/AirPoker"; +import { + buttonClass, + cardMark, + suitMap, + toolTipTheme, + trumpClass, + trumpMiniClass, + trumpSizeClass +} from "~/app/components/const" +import Modal from "~/app/components/modal"; +import Timer from "~/app/components/Timer"; +import Loading from "~/app/components/Loading"; +import {Tooltip} from "flowbite-react" +import type {AirPokerResponse, Card} from "~/src"; + +export const Trump = memo(function Trump({ + suit, selected = -1, onClick, c, mini = false}: {suit: SUITS, c: number, selected?: number, onClick?:any, mini?: boolean}) { + const {className, symbol} = suitMap[suit] + return ( +
-1 ? ' bg-indigo-400 ' : ' bg-white ') + className} + onClick={onClick} + > + {symbol} + {cardMark[c] ? cardMark[c] : c} +
+ ) +}) + +export const Trumps = memo(function Trumps({pairs}: {pairs: Card[]}) { + const {formatMessage} = useIntl() + if (pairs.length === 0) return formatMessage({id: 'no_rank'}) + return pairs.map((v, idx) => ( + + )) +}) + +export function Cards({ + data, onSubmit}: {data: AirPokerResponse, onSubmit?:any}) { + const [isOpen, setIsOpen] = useState(false) + const [pairs, setPairs] = useState([]) + const pairsRef = useRef(pairs) + const {formatMessage} = useIntl() + if (Array.isArray(data.you.pairs)) { + return ( +
+
} + trigger="click" theme={toolTipTheme}> + {node && (node as HTMLSpanElement).click()}} /> + +
+ ) + } + const cardElms = [] + const timer = data.you.selected && data.opponent.selected ? + () : null + const id = timer ? 'make_rank' : 'remaining' + for (const c of NUMBERS) { + for (const [suit, _] of Object.entries(SUITS)) { + if (!data.cards[c].includes(suit as SUITS)) { + cardElms.push(
) + continue + } + let selected = -1 + let onClick + if (timer) { + selected = pairs.findIndex(v => v.number === c && v.suit === suit) + onClick = (e: any) => { + if (selected > -1) setPairs(pairs.filter((_, i) => i !== selected)) + // @ts-ignore + else if (pairs.length < 5) setPairs([...pairs, {number: c, suit}]) + } + } + cardElms.push( + + ) + } + } + const onClose = () => setIsOpen(false) + return (
+ + +
+ {cardElms} +
+
+ {timer && ( + + )} + +
+
+
) +} \ No newline at end of file diff --git a/app/components/Hand.tsx b/app/components/Hand.tsx new file mode 100644 index 0000000..83e602a --- /dev/null +++ b/app/components/Hand.tsx @@ -0,0 +1,70 @@ +import React, {memo} from 'react' +import {RANK} from "~/src/AirPoker" +import { Tooltip } from 'flowbite-react' +import {toolTipTheme, handSizeClass, handClass, handEmptyClass} from '~/app/components/const' +import {useIntl} from "react-intl"; +import {Trump} from "~/app/components/Cards"; +import {Card} from '~/src/index' + +export const OpCard = memo(function OpCard({waiting}: {waiting: boolean}) { + return ( +
+
+ {waiting && +
+ Opponent Waiting... +
+ } +
+ ) +}) + +export const HandTooltip = memo(function HandTooltip({ + candidates, children}: {candidates: any[], children: any}) { + const {formatMessage} = useIntl() + let elm + if (candidates.length === 0) { + elm =
{formatMessage({id: 'no_pairs'})}
+ } else { + elm = ( +
+ {candidates.map((c, i) => ( +
+ {RANK[c.rank]} {c.cards.map((c: Card, i: number) => ( + + ))} +
+ ))} +
+ ) + } + // todo arrow of bottom pattern + return ( + + {children} + + ); +}) + +export const StaticCard = memo(function HandCard({ + card}: {card: number}) { + if (card === 0) return
+ return ( +
+
+
{card}
+
+ ) +}) +export const HandCard = memo(function HandCard({ + card, candidates}: {card: number, candidates: any[]}) { + if (card === 0) return
+ return ( + +
+
+
{card}
+
+
+ ); +}) \ No newline at end of file diff --git a/app/components/Loading.tsx b/app/components/Loading.tsx new file mode 100644 index 0000000..573c837 --- /dev/null +++ b/app/components/Loading.tsx @@ -0,0 +1,17 @@ +'use client' +import {forwardRef, memo} from "react"; +import {useIntl} from "react-intl"; + +const Loading = forwardRef(function Loading({ + id = 'loading', h = 60}: {id?: string, h?: number}, ref?: any) { + const {formatMessage} = useIntl() + return ( +
+
+ {formatMessage({id})} +
+
+ ) +}) + +export default Loading \ No newline at end of file diff --git a/app/components/Timer.tsx b/app/components/Timer.tsx new file mode 100644 index 0000000..06189db --- /dev/null +++ b/app/components/Timer.tsx @@ -0,0 +1,36 @@ +'use client' +import React, {useState, useEffect} from "react"; +import {ClockIcon} from "@heroicons/react/16/solid" + +const interval = 1 * 1000 +const redLine = 10 + +function useTimer(deadlineTs: number) { + const [ts, setTs] = useState(deadlineTs - Date.now()) + + useEffect(() => { + const intervalId = setInterval(() => { + // setTs((_timespan) => _timespan - interval) + setTs(new Date(deadlineTs).getTime() - Date.now()) + }, interval) + return () => { + clearInterval(intervalId); + } + }, [deadlineTs]) + const sec = Math.floor(ts / 1000) + return sec < 0 ? 0 : sec +} + +export default function Timer({deadline, handler}: + {deadline: number, handler?: (params: any) => void}) { + const ts = useTimer(deadline) + useEffect(() => { + if (ts === 0 && handler) handler({timeout: true}) // deadline = 0があり得る場合は注意 + }, [ts]) + return ( +
+ + {ts} +
+ ) +} \ No newline at end of file diff --git a/app/components/_Bubble.scss b/app/components/_Bubble.scss new file mode 100644 index 0000000..f777922 --- /dev/null +++ b/app/components/_Bubble.scss @@ -0,0 +1,74 @@ +.item { + width: 40px; + height: 40px; + border-radius: 100%; + box-shadow: 0px 0px 15px 0px rgba(255, 255, 255, 0.6) inset; + -webkit-box-shadow: 0px 0px 15px 0px rgba(255, 255, 255, 0.6) inset; +} +.item:after { + content: ""; + display: block; + width: 20%; + height: 20%; + border-radius: 100%; + background: rgba(255, 255, 255, 0.8); + position: absolute; + right: 15%; + top: 15%; + filter: blur(2px); + -webkit-filter: blur(2px); + transform: rotateZ(45deg) scaleY(0.8); + -webkit-transform: rotateZ(45deg) scaleY(0.8); +} +@keyframes shake { 0% { transform: translateX(10px); } + 50% { transform: translateX(-10px); } + 100% { transform: translateX(10px); } +} +@-webkit-keyframes shake { + 0% { -webkit-transform: translateX(10px); } + 50% { -webkit-transform: translateX(-10px); } + 100% { -webkit-transform: translateX(10px); } +} + +@keyframes move { + 0% { transform: translateY(5px); opacity: 1;} + 100% { transform: translateY(-95vh); opacity: 0; } +} +@-webkit-keyframes move { + 0% { -webkit-transform: translateY(5px); opacity: 1; } + 100% { -webkit-transform: translateY(-95vh); opacity: 0; } +} + +.bubble { + position: absolute; + bottom: 5px; + opacity: 0; +} + +$posKind: 20; +@for $i from 0 through $posKind { + $time: $i*0.2; + + .move#{$i * 1} { + animation: move ($time+5)+s ease-out $time+s infinite normal; + -webkit-animation: move ($time+5)+s ease-out $time+s infinite normal; + } +} + +$shakeKind: 10; +@for $i from 0 through $shakeKind { + $time: $i*0.2; + $scale: $i*0.1; + + .pos#{$i * 1} { + left: percentage(calc($i / $shakeKind)); + } + .shake#{$i * 1} { + animation: shake ($time+2)+s ease 0s infinite normal; + -webkit-animation: shake ($time+2)+s ease 0s infinite normal; + } + .scale#{$i * 1} { + transform: scale($scale); + -webkit-transform: scale($scale); + } +} diff --git a/app/components/const.test.tsx b/app/components/const.test.tsx new file mode 100644 index 0000000..b0bcc2d --- /dev/null +++ b/app/components/const.test.tsx @@ -0,0 +1,13 @@ +import { expect, test } from "vitest"; +import {PEER_ID_PATTERN} from "./const"; + +test.each` + id | expected + ${undefined} | ${false} + ${'09269e8a-6afe-4e89-a987-91de03d48eab'} | ${true} + ${'09269e8a-6afe-4e89_a987 91de03d48eaB'} | ${true} + ${'09269e8a-6afe-4e89_a987 91de03d48ea'} | ${false} + ${'09269e8a-6afe-4e89_a987 91de03d48eaBB'} | ${false} +`('id=$id match peer id: $expected', ({id, expected}) => { + expect(new RegExp(PEER_ID_PATTERN).test(id)).toBe(expected) +}); \ No newline at end of file diff --git a/app/components/const.ts b/app/components/const.ts new file mode 100644 index 0000000..ea7b8a0 --- /dev/null +++ b/app/components/const.ts @@ -0,0 +1,179 @@ +import type {CustomFlowbiteTheme} from "flowbite-react"; +import {SUITS} from "~/src/AirPoker"; + +export const nameMaxLength = 10 +export const PEER_ID_LENGTH = 36 +export const PEER_ID_PATTERN = `^[0-9a-zA-Z_\\- ]{${PEER_ID_LENGTH}}$` +export const buttonClass = "w-[70px] sm:w-[100px] flex justify-center rounded-md border" + + " border-transparent bg-indigo-600 py-3 text-base font-medium text-white hover:bg-indigo-700" + + " focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:bg-gray-500" +export const handSizeClass = "w-[75px] sm:w-[100px] h-[110px] sm:h-[147px]" +export const handClass = "flex justify-center items-center font-['Luminari'] text-[60px]/[60px] rounded front" +export const handEmptyClass = handSizeClass + " border-4 rounded cursor-default" +export const trumpSizeClass = " sm:w-[35px] sm:h-[45px] w-[31px] h-[41px]" +export const trumpMiniClass = " w-[20px] h-[20px]" +export const trumpClass = "rounded border flex justify-between" // + size +const toolTipArrow = "absolute z-10 h-2 w-2 tri" +export const toolTipTheme: CustomFlowbiteTheme['tooltip'] = { + arrow: { + base: toolTipArrow + " rotate-[135deg]", + }, + style: { + dark: "bg-gray-700/50 [text-shadow:initial] text-white", + light: "bg-gray-700/50 [text-shadow:initial] text-white", + auto: "bg-gray-700/50 [text-shadow:initial] text-white", + } +} +export const toolTipBottomTheme: CustomFlowbiteTheme['tooltip'] = { + ...toolTipTheme, + arrow: { + base: toolTipArrow + " rotate-[-45deg]", + }, +} +export const suitMap: Record> = { + [SUITS.HEART]: { + symbol: '♥️', + className: 'text-[color:red]', + }, + [SUITS.SPADE]: { + symbol: '♠️️', + className: 'text-[color:black]', + }, + [SUITS.CLUB]: { + symbol: '♣️', + className: 'text-[color:black]', + }, + [SUITS.DIAMOND]: { + symbol: '♦️', + className: 'text-[color:red]', + }, +} +export const cardMark: Record = { + 1: 'A', + 11: 'J', + 12: 'Q', + 13: 'K' +} + +const peerJsErrors: Record> = { + 'browser-incompatible': { + ja: 'FATAL: ブラウザが対応していません', + en: "FATAL: The client's browser does not support some or all WebRTC features that you are trying to use.", + }, + disconnected: { + ja: 'このIDは既に接続不可状態です。リロードしてやり直して下さい。', + en: "You've already disconnected this peer from the server and can no longer make any new connections on it.", + }, + 'invalid-id': { + ja: 'FATAL: IDが存在しません', + en: 'FATAL: The ID passed into the Peer constructor contains illegal characters.', + }, + 'invalid-key': { + ja: 'FATAL: APIキーが不正です', + en: 'FATAL: The API key passed into the Peer constructor contains illegal characters or is not in the system (cloud server only).', + }, + network: { + ja: 'オンライン用サーバーが混み合っています。{br}時間を置いてお試し下さい。', + en: 'Cannot establish a connection to the online(signalling) server.{br}Wait & retry for a while.' + }, + 'peer-unavailable': { + ja: 'このIDは既に他のhostに所属済みです', + en: "The peer you're trying to connect to does not exist.(ex, The ID connected already another host.)" + }, + 'ssl-unavailable': { + ja: 'FATAL: セキュアな通信ができません。他のブラウザでお試し下さい。', + en: 'PeerJS is being used securely, but the cloud server does not support SSL. Use a custom PeerServer.', + }, + 'server-error': { + ja: 'FATAL: WebRTCサーバーと接続できません', + en: 'FATAL: Unable to reach the server.', + }, + 'socket-error': { + ja: 'FATAL: ソケット上でエラーが発生し、切断しました', + en: 'FATAL: An error from the underlying socket.', + }, + 'socket-closed': { + ja: 'FATAL: ソケットがクローズ状態となり、切断しました', + en: 'FATAL: The underlying socket closed unexpectedly.', + }, + 'unavailable-id': { + ja: 'あなたのIDは現在使用できません。リロードしてやり直してください。', + en: 'The ID passed into the Peer constructor is already taken.', + }, + webrtc: { + ja: 'WebRTC固有のエラーが発生しました。', + en: 'Native WebRTC errors.' + }, +} + +export const title = 'AirPoker' +interface I18n { + ja: string + en: string +} +export const defaultLng: I18nStr = 'en' +export type I18nStr = keyof I18n +const i18n: Record> = { + en: { + "toCourtTheKing": "To Court the King", + "invalid_data": "Invalid data selected", + "invalid_connection": "Failed to connect opponent.", + "invalid_key": "Data not found. Please reload to restart game.", + "non_host_connection_close": "Disconnected with your opponent. Reload & restart", + "host_connection_close": "This ID is already connected with you or another host", + "peer_id_title": "ID is within {len} (letters including '-','_' & ' ')", + "online_name": "Name is within 10 letters", + "order": "Play from top to bottom on Round 1.(You can move them by dragging and dropping)", + "online": "・This is beta.{br}・No auto matching. Please share your ID to host by yourself.{br}・Not crack down on Glitch/Delay.{br}・If disconnected, may become offline mode.", + "entry": "Entry fee", + "peer_id_registered": "Your ID was already in the waiting list.", + "make_rank": "Make the rank by 5 cards so that the total is {sum}.", + "remaining": "remaining cards", + "status": "Status page of Online mode (WebRTC server)", + "error": "Something went wrong!", + "error.report": "Please report it(only 10 seconds)", + "betting": "opponent betting...", + "waiting": "Waiting...", + "make_pairs": "Make pairs within deadline", + "no_rank": "no-rank", + "no_pairs": "No pairs...", + "by_yourself": "To play by yourself, check here & use multi browsers.", + "make_button_value": "Make", + + "win": "You win! You're alive", + "lose": "Game Over, you're drowned...", + ...Object.keys(peerJsErrors).reduce((a, v) => ({...a, [v]: peerJsErrors[v].en}), {}), + }, + ja: { + "toCourtTheKing": "王への請願", + "invalid_data": "不正なデータ選択です", + "invalid_connection": "相手との接続が切断されました。リロードして初めからやり直してください。", + "invalid_key": "データが見つかりません。リロードして初めからやり直してください。", + "non_host_connection_close": "対戦相手との接続が切断されました。リロードしてください。", + "host_connection_close": "既に(他の)ホストと通信済みのIDです", + "peer_id_title": "IDは{len}文字の英数字およびハイフン、アンダースコア、スペースからなる文字列です", + "online_name": "1~10文字で命名ください", + "order": "Round 1は上から順に行います。ドラッグ&ドロップで移動できます。", + "online": "・オンライン機能は改善中です{br}・マッチング機能は現在ありません。発行されたIDを自身でホストにシェアください{br}・不正・遅延の対策機能はありません{br}・接続切れ端末は一人回しモードに移行することがあります", + "entry": "場代", + "peer_id_registered": "既に登録済のIDです", + "make_rank": "合計が{sum}になるよう5枚を選び役を作れ!", + "remaining": "残りカード一覧", + "status": "オンライン通信(WebRTC)サーバーのステータス", + "error": "問題が発生しました", + "error.report": "以下'Error message'をコピペいただき、レポートお願いします(10秒程度)", + "betting": "対戦相手がBet中...", + "waiting": "待機中...", + "make_pairs": "制限時間までに役を作れ", + "no_rank": "無役", + "no_pairs": "作れる役がありません", + "by_yourself": "一人回しもできます(要複数ブラウザ)", // todo 現状複数タブでも可能だがrecoveryをcookieで行う場合は別ブラウザ + "make_button_value": "確定", + + "win": "You win! You're alive", + "lose": "Game Over, you're drowned...", + ...Object.keys(peerJsErrors).reduce((a, v) => ({...a, [v]: peerJsErrors[v].ja}), {}), + }, +} + +export default i18n \ No newline at end of file diff --git a/app/components/lobby.tsx b/app/components/lobby.tsx new file mode 100644 index 0000000..0953ccd --- /dev/null +++ b/app/components/lobby.tsx @@ -0,0 +1,180 @@ +'use client' +import React, {useState, useEffect, ChangeEvent, MouseEventHandler} from 'react' +import {UsersIcon, CloudIcon, QuestionMarkCircleIcon} from "@heroicons/react/24/outline" +import { + buttonClass, + PEER_ID_LENGTH, + PEER_ID_PATTERN, + nameMaxLength, + toolTipBottomTheme +} from "./const" +import {useIntl} from "react-intl" +import {Tooltip} from "flowbite-react" +import {matching} from '../server/db' +import Loading from './Loading' +import type {Peer, DataConnection, PeerError} from "peerjs" + +export interface RTCData { + action: 'ping' | 'hello' + [key: string]: any +} + +const inputClass = "block flex-1 border-0 border-white bg-transparent py-1.5 pl-1 " + + "placeholder-gray-400 text-gray-900 focus:ring-0 sm:text-sm sm:leading-6" + +function isRTCData(data: unknown): data is RTCData { + return typeof data === 'object' && typeof (data as RTCData).action === 'string' +} + +export function Lobby({setError, peer, setPeer, onSubmit, lobbyRef}: {setError: any, peer: any, + setPeer: any, onSubmit: any, lobbyRef: {current: {you: string, opponent: string, conn: DataConnection}}}) { + const [online, setOnline] = useState({ + name: '', fixed: false, friend: false, + }) + const {formatMessage, locale} = useIntl() + + function sender(_peer: any, id: string) { + const conn = _peer.connect(id, {metadata: online.name}) + conn.on('open', () => { + _peer.disconnect() + conn.on('data', (data: RTCData) => { + console.log('conn.data', data, conn.label) + if (!isRTCData(data)) return + if (data.action === 'hello') { + lobbyRef.current = {you: online.name, opponent: data.name, conn} + return onSubmit({hello: true}) + } else return onSubmit(null) + }) + conn.on('error', (err: { type: string }) => { + console.error('conn.error', err) + setError(formatMessage({id: err.type})) + }) + }) + conn.on('close', () => { + console.error('close', conn) + setError(formatMessage({id: 'host_connection_close'})) + }) + } + function matched(res: {success: boolean, error?: string, id?: any}) { + if (!res.success) return setError(formatMessage({id: res.error})) + else if (res.id) sender(peer, res.id) + } + + useEffect(() => { // 初回レンダリング後必ず呼ばれる + if (!peer.id) return + // 受け手の設定 + peer.on('connection', (conn: DataConnection) => { + conn.on('open', () => { + console.log('receiver conn.open', conn.label, conn, peer) + peer.disconnect() + conn.send({action: 'hello', name: online.name}) + lobbyRef.current = {you: online.name, opponent: conn.metadata, conn} + conn.on('data', (data) => { + console.log('receiver conn.data', data, conn.label, conn, peer) + if (isRTCData(data) && data.action === 'ping') return onSubmit(null) + }) + conn.on('error', (err) => { + console.error('conn.error', err, conn, peer) + setError(formatMessage({id: err.type})) + }) + }) + conn.on('close', () => { // hostがリロードした時 + setError(formatMessage({id: 'non_host_connection_close'})) + }) + }) + if (!online.friend) matching({id: peer.id}).then(matched).catch((e) => window.location.reload()) + return () => {} + }, [peer]) + + const handleInput = (e: ChangeEvent) => { + if (!e.target.checkValidity()) return e.target.reportValidity() + sender(peer, e.target.value) + } + const handleChange = (e: ChangeEvent) => { + setOnline({...online, friend: !online.friend}) + matching({id: peer.id}).then(matched).catch((e) => window.location.reload()) + } + const onClick: MouseEventHandler = (e) => { + const name = online.name + if (name.length > nameMaxLength || name.length < 1) return setError(formatMessage({id: 'online_name'})) + setOnline({...online, fixed: true}) + // @ts-ignore + const _peer = new Peer() + _peer.on('open', (id) => { + setPeer(_peer) + }) + _peer.on('error', (err: PeerError) => { + console.error('peer.error', err, _peer, err.type) + setError(formatMessage({id: err.type}, {br:
})) + if (err.type === 'peer-unavailable') { + matching({id: _peer.id}).then(matched).catch((e) => {console.error(e);window.location.reload()}) + } + }) + } + return ( + <> +

+ Air Poker +

+ {online.fixed ? (!peer.id || !online.friend ? (<> + +
document.getElementById('rule')?.click()} + >Ruleを確認する
+ ) : (<> +

Your ID: {peer.id}

+
+ +
+
+ +
+ Change free matching +
+
+ )) : (<> + +
+ setOnline({...online, name: e.target.value})} + /> +
+
+ setOnline({...online, friend: !online.friend})} + type="checkbox" + className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" + /> + + + +
+ )} + + ) +} \ No newline at end of file diff --git a/app/components/modal.tsx b/app/components/modal.tsx new file mode 100644 index 0000000..11c081d --- /dev/null +++ b/app/components/modal.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Fragment} from "react" +import { Dialog, Transition } from '@headlessui/react' + +export default function Modal({isOpen, onClose, title, opacity, children}: {isOpen: boolean, onClose: any, title?: string, opacity?: string, children?: any}) { + opacity = opacity || 'bg-opacity-50' + return ( + + + +
+ + +
+ + + + {title} + + {children} + + +
+
+
+ ) +} diff --git a/app/components/provider.tsx b/app/components/provider.tsx new file mode 100644 index 0000000..e69802d --- /dev/null +++ b/app/components/provider.tsx @@ -0,0 +1,10 @@ +'use client' +import { IntlProvider } from 'react-intl' + +export default async function Provider({messages, locale, children}: {messages: Record, locale: string, children: any}) { + return ( + + {children} + + ) +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..0bf4937 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,36 @@ +'use client' + +import {buttonClass} from "~/app/components/const"; +import { useSearchParams } from "next/navigation" +import i18n, {defaultLng, I18nStr} from './components/const' + +const lngs = Object.keys(i18n) as I18nStr[] + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const params = useSearchParams() + const lang = lngs.includes(params.get('lang') as any) ? params.get('lang') as I18nStr : defaultLng + return ( + <> + +

{i18n[lang]['error']}

+
{i18n[lang]['error.report']}
+ + + +
Error message:
+
{(typeof error === 'string' ? error : error.message) || 'none'}
+ + ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..7a3f97c --- /dev/null +++ b/app/globals.css @@ -0,0 +1,131 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 255, 255, 255; + --background-end-rgb: 0, 0, 0; +} + +@layer utilities { + .scrollbar-none { + -ms-overflow-style: none; /* IE, Edge */ + scrollbar-width: none; /* Firefox */ + } + .scrollbar-none::-webkit-scrollbar { + /* Chrome, Safari */ + display: none; + } +} + +body { + color: rgba(var(--foreground-rgb), 1.0); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ); + &:before { + content: ' '; + display: block; + position: absolute; + width: 100%; + height: 100%; + opacity: 0.1; + background-image: url("/images/bg.webp"); + background-position: center; + background-size: cover; + z-index: -20; + } + .tri { + /* tooltip arrow. color:"bg-gray-700" */ + background: linear-gradient(45deg, rgb(0 0 0 / 0) 0%, rgb(0 0 0 / 0) 50%, rgb(55 65 81 / 0.5) 50%, rgb(55 65 81 / 0.5) 100%); + } + .git-label { + position: absolute; + top: 0; + right: 0; + } +} + +.front, .back { + overflow: hidden; + position: absolute; + backface-visibility: hidden; + transition: transform 1.2s; + width: 100%; + height: 100%; +} +.front { + background: linear-gradient(to top left, #F7DE05, #DA8E00, #EDAC06, #F7DE05, #ECB802, #DAAF08, #B67B03); + color: rgba(255, 255, 255, 0.7); + -webkit-text-stroke: 1px rgba(0, 0, 0, 0.5); +} +.back-above { + .front { + transform: rotatey(180deg); + } + .back { + transform: rotatey(0deg); + } + &.reverse-x { + .back { + transform: rotatey(0deg) rotate(180deg); + } + } +} +/* enterTo={reverse ? 'back-above' : ''}> */ +.front-by-hover { + &:hover { + .front { + transform: rotatey(0deg); + } + .back { + transform: rotatey(180deg); + } + } +} +.back { + transform: rotatey(180deg); + background: linear-gradient(to top left, #F7DE05, #DA8E00, #EDAC06, #F7DE05, #ECB802, #DAAF08, #B67B03); + &:before { + content: ' '; + position: absolute; + width: 100%; + height: 100%; + opacity: 0.3; + background-image: url("/images/cardback.png"); + background-size: contain; + } + +} + +@keyframes smooth { + from {opacity: .1;} + to {opacity: 1;} +} + +.crown { + position: relative; + padding: 0.2em 0.1em 0px 2em; + color: #dbb400; +} +.crown::before, +.crown::after { + position: absolute; + left: 0px; + width: 0px; + height: 0px; + content: ""; +} +.crown::before { + top: -1.25em; + border: 1em solid transparent; + border-bottom: 1.5em solid currentColor; +} +.crown::after { + top: 0.25em; + border: 0.5em solid transparent; + border-left: 1em solid currentColor; + border-right: 1em solid currentColor; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ec95445 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,128 @@ +import Link from 'next/link' +import Image from "next/image" +import "./globals.css"; +import "./components/_Bubble.scss"; +import {LanguageIcon} from "@heroicons/react/16/solid"; +import { Dropdown, DropdownItem, Tooltip, CustomFlowbiteTheme } from 'flowbite-react' +import {getLocale} from './server/locale' +import i18n, {title, toolTipBottomTheme} from './components/const' +import { Inter } from 'next/font/google' +import type { Metadata } from "next" + +const inter = Inter({ + subsets: ['latin'], + display: 'swap', +}) +const description = "Online game of AirPoker by Usogui" +export const metadata: Metadata = { + metadataBase: new URL('http://localhost:3000'), + title, + description, + icons: { + icon: '/images/favicon.ico', + }, + openGraph: { + title, + description, + images: `/images/bg.webp`, + siteName: title, + locale: 'ja_JP', + type: 'website', + }, + twitter: { + card: 'summary', + title, + description, + site: '@darai_0512', + creator: '@darai_0512', + }, +} + +const dropdownTheme: CustomFlowbiteTheme['dropdown'] = { + floating: { + style: { + auto: "border border-gray-200 bg-white/50 text-gray-900 dark:border-none dark:bg-gray-700 dark:text-white", + dark: "border border-gray-200 bg-white/50 text-gray-900 dark:border-none dark:bg-gray-700 dark:text-white", + light: "border border-gray-200 bg-white/50 text-gray-900 dark:border-none dark:bg-gray-700 dark:text-white", + }, + item: { + container: "text-gray-400 [text-shadow:initial]", + } + }, + inlineWrapper: "flex items-center [text-shadow:inherit]", + arrowIcon: "ml-1 h-4 w-4 text-gray-400", +} +export default async function RootLayout({ + children, + rule, + params +}: Readonly<{ + children: React.ReactNode + rule: React.ReactNode + params: Object +}>) { + const lang = await getLocale(null) // not include searchParams + return ( + + +
+ +
+
+ {children} +
+
+
+ + © 2024 by daraiii ( + contact) + +
+ + + + ) +} \ No newline at end of file diff --git a/app/main.tsx b/app/main.tsx new file mode 100644 index 0000000..89dab76 --- /dev/null +++ b/app/main.tsx @@ -0,0 +1,300 @@ +'use client' +import AirPoker, {Bet, Step} from '~/src/AirPoker' +import {firstInit, next, secondInit} from '~/app/server/db' +import React, {memo, ReactNode, useRef, useState} from "react" +import {Lobby} from "./components/lobby" +import Loading from "./components/Loading" +import Modal from "./components/modal" +import {useIntl} from "react-intl" +import Bubbles from "~/app/components/Bubble" +import {HandCard, HandTooltip, OpCard, StaticCard} from "~/app/components/Hand" +import {Cards, Trumps} from "~/app/components/Cards" +import {Label, RangeSlider, Tooltip} from "flowbite-react" +import {buttonClass, handEmptyClass, toolTipBottomTheme} from "~/app/components/const" +import Timer from '~/app/components/Timer' +import Image from "next/image"; +import {Transition} from "@headlessui/react"; +import {ArrowDownTrayIcon, FireIcon} from "@heroicons/react/16/solid" +import type {AirPokerResponse, Log, Player} from '~/src' +import type {DataConnection, Peer} from "peerjs" + +const airPoker = new AirPoker() +const infoClassName = "flex flex-wrap font-mono text-sm text-[color:black] bg-transparent sm:p-3 p-1 border-neutral-800 rounded-xl border" + +function Player_({player, name, step, timer, win}: + {player: Player, name: string, step: Step, timer?: ReactNode, win?: boolean}) { + const {formatMessage} = useIntl() + let elm + const prev = player.logs.at(-1) || {} as Log + if (timer) elm = (
{timer}
) + else if (step === Step.bet) { + elm = ( +
{player.bet ? player.bet : formatMessage({id: 'entry'})}: {player.betAir}air
+ ) + } else if (step === Step.end) { + elm = ( +
{win ? 'Win & Survived!' : 'Lose & Drowned...'}
+ ) + } + return ( +
{node && player.logs.length > 0 && + step !== Step.bet && (node as HTMLSpanElement).click()}}> +
{name}
+
+ logo {player.air}air{prev.win ? () : ( + prev.disaster ? () : null)} +
+ {elm} +
+ ) +} + +function Player({player, name, step, timer, win}: + {player: Player, name: string, step: Step, timer?: ReactNode, win?: boolean}) { + if (player.logs.length === 0) return ( +
+ +
+ ) + return ( +
+ + + +
+ ) +} + +const LogsElm = (logs: Log[]) => { + if (logs.length === 0) return null + return (<> + {logs.map((v, i) => (
+ R{i+1}: {v.selected}→ {v.win ? 'Win' : 'Lose'} + {!v.win && v.disaster && } {v.air}air +
+ ))} + ) +} + +function EndInfo({data, names, onSubmit}:{data: AirPokerResponse, names: any, onSubmit: any}) { + const youWin = data.opponent.air < data.you.air + return ( +
+ +
+ +
+
+ +
+ +
+ ) +} + +const Round = memo(function Info({round}: {round: number}) { + return ( +
+
Round {round}
+
+ ) +}) + +const Info = memo(function Info({data, names, onSubmit}: + {data: AirPokerResponse, names: any, onSubmit: any}) { + const step = airPoker.getStep([data.you, data.opponent]) + const youTimer = (data.betCandidates.length > 0 && data.isBet) ? (<> + Bet by  + ) : (step === Step.select && data.you.selected === 0) ? (<> + Select by  + ) : null + const opTimer = (data.betCandidates.length > 0 && !data.isBet) ? (<> + Bet by  + ) : (step === Step.select && data.opponent.selected === 0) ? (<> + Select by  + ) : (step === Step.bet && data.betCandidates.length === 0 && data.opponent.pairs === null) ? + (<>Make by  + ) : null + return ( +
+ + + + +
+ ) +}) + +function Error({message, onClose}: {message: string, onClose: () => void}) { + return ( + +
+ +
+
+ ) +} + +const Raise = memo(function Raise({max, handler}: {max: number, handler: any}) { + const [tip, setTip] = useState(1) + return (
+
) +}) + +export default function Main() { + const [data, _setData] = useState(airPoker.data as AirPokerResponse) + const [error, setError] = useState('') + const [peer, setPeer] = useState({} as Peer) + const {formatMessage} = useIntl() + const lobbyRef = useRef({you: '', opponent: '', conn: {} as DataConnection}) + const dataRef = useRef(data) + const setData = (newData: (typeof data)) => { + dataRef.current = newData + _setData(newData) + } + + const onSubmit = async (params: any) => { + const you = dataRef.current.you + const conn: DataConnection = lobbyRef.current.conn // Object.values(peer.connections)[0][0].label + const key = conn.label + if (!key) return setError(formatMessage({id: 'invalid_connection'})) + let r + if (!you) { + if (params && params.hello) r = await firstInit({key}) + else r = await secondInit({key}) + } else r = await next({...params, key, id: you.id}) + if (r.success && r.data) { + setData(r.data) + if (params !== null) conn.send({action: 'ping'}) + } else setError(formatMessage({id: r.error})) + } + + let elms + if (!data.you) { + elms = ( + ) + } else { + const step = airPoker.getStep([data.you, data.opponent]) + if (step === Step.end) { + document.body.classList.add('before:animate-[smooth_5s_linear_1_normal_forwards]') + const onClick= () => { + if (typeof peer.destroy === 'function') peer.destroy() + return window.location.reload() + } + return (<> + + ) + } + const betButtons = {} as Record + if (step === Step.bet) { + for (const bet of Object.keys(Bet) as (keyof typeof Bet)[]) { + if (!Bet[bet]) continue + if (data.betCandidates.length === 0) continue + else if (!data.isBet) { + betButtons[bet] = () + continue + } + betButtons[bet] = data.betCandidates.includes(Bet[bet]) ? (bet === Bet.RAISE ? ( + + ) : ( + + )) : ( + + ) + } + } + elms = (<> + +
+
{betButtons.CHECK}
+
+ {step === Step.bet && data.you.pairs !== null ? ( + + + + ) : (<>)} +
+
{betButtons.RAISE}
+
{betButtons.CALL}
+
+ {step === Step.bet && data.opponent.pairs !== null ? ( + + + + ) : data.you.selected > 0 ? ( +
+ +
+ ) : ( +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault() + onSubmit({cardIdx: parseInt(e.dataTransfer.getData('cardIdx'), 10)}) + }} + > +
+ Drag & Drop
+
+ )} +
+
{betButtons.FOLD}
+ {data.you.hand.map((v, i) => data.you.selected === 0 && v !== 0 ? ( + +
{ + e.dataTransfer.setData('cardIdx', String(i)); + (e.target as HTMLElement).classList.add('cursor-grabbing'); + }} + onDragEnd={(e: any) => e.target.classList.remove('cursor-grabbing')} + > + +
+
+ ) : ( + + ))} +
+ ) + } + return (<> + {elms} + {error !== '' ? setError('')}/> : null} + + ) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..7093c37 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,14 @@ +import {getLocale} from './server/locale' +import i18n from './components/const' +import Main from './main' +import Provider from './components/provider' + +export default async function Home({searchParams}: {searchParams: Record}) { + const intl = await getLocale(searchParams.lang) + + return ( + +
+ + ) +} \ No newline at end of file diff --git a/app/server/db.ts b/app/server/db.ts new file mode 100644 index 0000000..5c5eaf2 --- /dev/null +++ b/app/server/db.ts @@ -0,0 +1,180 @@ +'use server' +import AirPoker, {FieldError, Step, sumMax, msecLimit, Bet} from '~/src/AirPoker' +import {MongoClient, ServerApiVersion, ObjectId} from 'mongodb' +import crypto from 'crypto' +import {PEER_ID_PATTERN} from "~/app/components/const" +import {setTimeout} from 'node:timers/promises' +import type {AirPokerStore, AirPokerResponse} from '~/src/index.d' +import type {Player, You} from "~/src/index.d"; + +const uri = process.env.MONGO_URI || 'mongodb://root:root@localhost:27778/?directConnection=true' +const DB = process.env.MONGO_DB || 'test' +const airPoker = new AirPoker() +const peerIdReg = new RegExp(PEER_ID_PATTERN) +const DuplicateKeyErrorCode = 11000 // E11000 duplicate key error collection: test.matching index: _id_ dup key + +const client = new MongoClient(uri, { + serverApi: { + version: ServerApiVersion.v1, + strict: true, + deprecationErrors: true, + } +}) +async function run() { + // Connect the client to the server (optional starting in v4.7) + await client.connect() + console.log("connected to MongoDB!") +} +run().catch(console.error) + +function resp(self: AirPokerStore, id: string): AirPokerResponse { + const [a, b] = self.players + let you, opponent + if (a.id === id) { + you = a + opponent = b + } else if (b.id === id) { + you = b + opponent = a + } else throw new FieldError('invalid_data') + return { + cards: self.cards, + isFirst: self.firstBet === id, + isBet: self.betPlayer === id, + betCandidates: self.betPlayer === '' ? [] : airPoker.betCandidates(you, opponent, self.betPlayer === id), + deadline: self.deadline, + betLine: self.betLine, + you, + opponent: { + air: opponent.air, + betAir: opponent.betAir, + bet: opponent.bet, + selected: you.selected && you.pairs ? opponent.selected : (opponent.selected ? sumMax : 0), + pairs: airPoker.getStep(self.players as [You, Player]) === Step.bet && opponent.pairs ? [] : opponent.pairs, + logs: opponent.logs, + } + } +} + +export async function waitCount() { + try { + const c = client.db(DB).collection('matching') + return {success: true, count: await c.estimatedDocumentCount()} + } catch(e: any) { + return {success: false, error: e.code} + } +} + +export async function matching(params: {id: unknown}) { + try { + const c = client.db(DB).collection('matching') + const r = await c.findOneAndDelete({}) + if (r) return {success: true, id: r._id} + if (!peerIdReg.test(params.id as string)) return {success: false, error: 'peer_id_title'} + await c.insertOne({_id: params.id as ObjectId}) + return {success: true, id: null} + } catch(e: any) { + console.error(e, params) + if (e.code === DuplicateKeyErrorCode) return {success: false, error: 'peer_id_registered'} + return {success: false, error: e.code || 'error'} + } +} + +export async function firstInit(params: {key: any}) { + try { + const id = crypto.randomUUID() + const data = airPoker.firstInit({id}) + const deadline = new Date().getTime() + msecLimit.select + const store: AirPokerStore = {...data, + deadline, + betLine: deadline + msecLimit.bet, + lock: null, + } + await client.db(DB).collection('data').insertOne({...store, _id: params.key}) + return {success: true, data: resp(store, id)} + } catch(e: any) { + console.error(e, params) + if (e.code === DuplicateKeyErrorCode) return {success: false, error: 'invalid_data'} + return {success: false, error: e.code || 'error'} + } +} + +async function getData(_id: any, retry = 0): Promise { + const c = client.db(DB).collection('data') + try { + const data: unknown = await c.findOneAndUpdate({_id, lock: null}, { + $set: {lock: new ObjectId()} + }) + if (data === null) throw new FieldError('invalid_key') + return data as AirPokerStore + } catch (e: any) { + if (e.code !== 'invalid_key' || retry > 3) throw new FieldError('invalid_key') + await setTimeout(1000) + return getData(_id, retry + 1) + } +} + +export async function secondInit(params: {key: any}) { + const c = client.db(DB).collection('data') + try { + const data = await getData(params.key) + const id = crypto.randomUUID() + const newData = airPoker.secondInit(data, {id}) + await c.updateOne({_id: params.key}, { + $set: {...newData, lock: null}}, {upsert: false}) + return {success: true, data: resp(newData as AirPokerStore, id)} + } catch(e: any) { + console.error(e, params) + if (e.code !== 'invalid_key') c.updateOne({_id: params.key}, { + $set: {lock: null}}, {upsert: false}) + return {success: false, error: e.code || 'error'} + } +} + +export async function next(params: {key: any, id: string, [key: string]: any}) { + const c = client.db(DB).collection('data') + try { + const data = await getData(params.key) + const now = Date.now() + const step = airPoker.getStep(data.players as any) + console.log('next', params, data.betPlayer, data.players.map(v=>v.pairs)) + + const betLineOver = data.betLine - now < msecLimit.buffer + const betPlayer = data.betPlayer + let r: any = {data, step: null} + if (params.timeout && data.deadline - now < msecLimit.buffer) { + for (const p of data.players) { + console.log('deadline', step) + const ps: any = {id: p.id} + if (step === Step.select && p.selected === 0) ps.cardIdx = p.hand.findIndex(v => v !== 0) + else if (step === Step.bet && p.pairs === null) ps.pairs = [] + if (Object.keys(ps).length > 1) {console.log('deadline2'); r = airPoker.next(r.data, ps)} + } + } + if (params.timeout && betPlayer && betLineOver) { + console.log('betLine', step, betPlayer, betLineOver) + r = airPoker.next(r.data, {bet: Bet.FOLD, id: betPlayer}) + } + if (!params.timeout) r = airPoker.next(data, params) + const newData: AirPokerStore = {...data, ...r.data, lock: null} + if (r.step) { + newData.deadline = r.step === Step.select ? Date.now() + msecLimit.pairs : + (r.step === Step.judge ? Date.now() + msecLimit.select : newData.deadline) + newData.betLine = Date.now() + msecLimit.bet + } + await c.updateOne({_id: params.key}, {$set: newData}, {upsert: false}) + return {success: true, data: resp(newData, params.id)} + } catch(e: any) { + console.error(e, params) + if (e.code !== 'invalid_key') c.updateOne({_id: params.key}, { + $set: {lock: null}}, {upsert: false}) + return {success: false, error: e.code || 'error'} + } +} + +/* todo +if (step === null) { + await c.updateOne({_id: params.key}, + {$set: {'players.$[element]': data.players.find(v=>v.id===params.id)}, lock: null}, + {upsert: false, arrayFilters: [{'element.id': params.id}]}) + */ diff --git a/app/server/locale.ts b/app/server/locale.ts new file mode 100644 index 0000000..1cf4763 --- /dev/null +++ b/app/server/locale.ts @@ -0,0 +1,19 @@ +'use server' +import i18n, {defaultLng, I18nStr} from '../components/const' +import { headers, cookies } from 'next/headers'; + +const lngs = Object.keys(i18n) + +export async function getLocale(lang: string|null): Promise { + if (lngs.includes(lang as any)) return lang as I18nStr + const cLang = cookies().get('NEXT_LOCALE')?.value + if (lngs.includes(cLang as any)) return cLang as I18nStr + const hLang = headers().get('accept-language') // ex, ja,en-US;q=0.9,en;q=0.8 + if (!hLang) return defaultLng + for (const v of hLang.split(',')) { + const [lRegion, _q] = v.split(';') + const [l, _r] = lRegion.split('-') + if (lngs.includes(l)) return l as I18nStr + } + return defaultLng +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99bc0b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '2.4' +services: + mongo: + image: mongodb/atlas:latest + privileged: true + command: | + /bin/bash -c "atlas deployments setup --type local --port 27778 --bindIpAll --username root --password root --force && tail -f /dev/null" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 27778:27778 + environment: + MONGODB_PASSWORD: root diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..df690f5 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,14 @@ +import { NextResponse, NextRequest } from 'next/server' +import {getLocale} from "~/app/server/locale" + +export async function middleware(request: NextRequest) { + const lang = await getLocale(request.nextUrl.searchParams.get('lang')) + const response = NextResponse.next() + response.cookies.set('NEXT_LOCALE', lang) + + return response +} + +export const config = { + matcher: '/', +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..a747a17 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,20 @@ +import TerserPlugin from 'terser-webpack-plugin' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], + webpack: (config, options) => { + config.optimization.minimize = true + config.optimization.minimizer = [ + new TerserPlugin({ + terserOptions: { + compress: { drop_console: true }, + }, + extractComments: 'all', + }), + ] + return config + }, +} + +export default nextConfig \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..365c369 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "AirPoker", + "version": "2.0.0", + "license": "MIT", + "author": "darai0512", + "private": true, + "eslintConfig": { + "extends": "next/core-web-vitals" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest" + }, + "types": "src/index.d.ts", + "dependencies": { + "flowbite-react": "^0.7.5", + "mongodb": "^6.5.0", + "next": "14.1.0", + "react": "^18", + "react-dom": "^18", + "react-intl": "latest" + }, + "devDependencies": { + "@headlessui/react": "^1.7.18", + "@heroicons/react": "^2.1.1", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@testing-library/react": "^14.2.1", + "@types/node": "^20.11.24", + "@types/peerjs": "^1.1.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.1.0", + "jsdom": "^24.0.0", + "postcss": "^8", + "sass": "^1.72.0", + "tailwindcss": "^3.3.0", + "terser-webpack-plugin": "^5.3.10", + "typescript": "^5", + "vitest": "^1.2.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e569373 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/images/bg.webp b/public/images/bg.webp new file mode 100644 index 0000000..c2f5e68 Binary files /dev/null and b/public/images/bg.webp differ diff --git a/public/images/cardback.png b/public/images/cardback.png new file mode 100644 index 0000000..a3bcb27 Binary files /dev/null and b/public/images/cardback.png differ diff --git a/public/images/favicon.ico b/public/images/favicon.ico new file mode 100644 index 0000000..c48b1e0 Binary files /dev/null and b/public/images/favicon.ico differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..d617d6e Binary files /dev/null and b/public/images/logo.png differ diff --git a/src/AirPoker.test.ts b/src/AirPoker.test.ts new file mode 100644 index 0000000..7419f79 --- /dev/null +++ b/src/AirPoker.test.ts @@ -0,0 +1,268 @@ +// @ts-nocheck +import AirPoker, {Bet, RANK, SUITS, sumMax} from './AirPoker'; +import {expect, test, vi} from "vitest"; + +test("new", () => { + const a = new AirPoker() + const data = a.data + expect(data.cards[1]).toEqual(["HEART", "DIAMOND", "SPADE", "CLUB"]) +}) +test("initHands_", () => { + const a = new AirPoker() + const r = a.initHands_() + expect(r[0].length).toEqual(5) + expect(r[1].length).toEqual(5) + for (const b of r[0]) { + expect(b !== null).toBe(true) + expect(b).toBeGreaterThanOrEqual(6) + expect(b).toBeLessThanOrEqual(64) + } + for (const b of r[1]) { + expect(b !== null).toBe(true) + expect(b).toBeGreaterThanOrEqual(6) + expect(b).toBeLessThanOrEqual(64) + } +}) + +const player = {id: 'id'} +const player2 = {id: 'id2'} + +test("init", () => { + const a = new AirPoker() + const data = a.firstInit(player) + expect(data.players[0].id).toEqual('id') + expect(data.players[0].hand.length).toEqual(5) + for (const c of data.players[0].hand) { + expect(c).toBeGreaterThanOrEqual(6) + expect(c).toBeLessThanOrEqual(64) + } + expect(data.players[0].air).toEqual(24) + expect(data.players[0].betAir).toEqual(0) + expect(data.players[0].bet).toEqual(Bet.NONE) + + expect(() => a.firstInit({ + id: '', + })).toThrowError(/invalid_data/) +}) + +test("getRound should return the number of round", () => { + const a = new AirPoker() + let data = a.firstInit(player) + expect(a.getRound(data.players[0])).toEqual(1) +}) + +test("getMaxRaise should return the number of half of total bet", () => { + const a = new AirPoker() + let data = a.firstInit(player) + data = a.secondInit(data, player2) + expect(a.getMaxRaise(...data.players)).toEqual(0) +}) + +test("betCandidates", () => { + const a = new AirPoker() + let data = a.firstInit(player) + data = a.secondInit(data, player2) + data.players[0].betAir = 3 + data.players[0].bet = Bet.RAISE + data.players[1].betAir = 3 + data.players[1].bet = Bet.CALL + expect(a.betCandidates(...data.players, true).length).toEqual(0) +}) + +test("bet_ CHECK -> CHECK", () => { + const a = new AirPoker() + const spy = vi.spyOn(a, 'initHands_') + spy.mockImplementation(() => [[6,6,6,6,6], [7,7,7,7,7]]) + + let data = a.firstInit(player) + expect(data.players[0].id).toEqual(player.id) + data = a.secondInit(data, player2) + expect(data.players[1].id).toEqual(player2.id) + data = a.setCard_(data, {cardIdx: 0, id: player.id}) + expect(data.players[0].selected).toEqual(6) + data = a.setCard_(data, {cardIdx: 0, id: player2.id}) + expect(data.players[1].selected).toEqual(7) + data = a.initBet_(data) + expect(data.betPlayer).toEqual(data.players[0].id) + expect(a.betCandidates(data.players[0], data.players[1], true)).toEqual([Bet.CHECK, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], false)).toEqual([Bet.CHECK, Bet.RAISE]) + data = a.bet_(data, {bet: Bet.CHECK}) + expect(data.players[0].bet).toEqual(Bet.CHECK) + expect(data.players[0].betAir).toEqual(1) + expect(data.players[0].air).toEqual(23) + expect(data.betPlayer).toEqual(player2.id) + + expect(a.betCandidates(data.players[0], data.players[1], false)).toEqual([Bet.CHECK, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], true)).toEqual([Bet.CHECK, Bet.RAISE]) + data = a.bet_(data, {bet: Bet.CHECK}) + expect(data.players[1].bet).toEqual(Bet.CHECK) + + expect(data.betPlayer).toEqual('') + expect(a.betCandidates(data.players[0], data.players[1], true)).toEqual([]) + expect(a.betCandidates(data.players[1], data.players[0], false)).toEqual([]) +}) + +test("bet_ CHECK -> RAISE -> CALL", () => { + const a = new AirPoker() + const spy = vi.spyOn(a, 'initHands_') + spy.mockImplementation(() => [[6,6,6,6,6], [7,7,7,7,7]]) + + let data = a.firstInit(player) + data = a.secondInit(data, player2) + data = a.setCard_(data, {cardIdx: 0, id: player.id}) + data = a.setCard_(data, {cardIdx: 0, id: player2.id}) + data = a.initBet_(data) + data = a.bet_(data, {bet: Bet.CHECK}) + data = a.bet_(data, {bet: Bet.RAISE, tip: 1}) + expect(data.players[1].bet).toEqual(Bet.RAISE) + expect(data.players[1].betAir).toEqual(2) + expect(data.players[1].air).toEqual(22) + expect(data.betPlayer).toEqual(player.id) + expect(a.betCandidates(data.players[0], data.players[1], true)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], false)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + + data = a.bet_(data, {bet: Bet.CALL}) + expect(data.players[0].bet).toEqual(Bet.CALL) + expect(data.players[0].betAir).toEqual(2) + expect(data.players[0].air).toEqual(22) + + expect(data.betPlayer).toEqual('') + expect(a.betCandidates(data.players[0], data.players[1], false)).toEqual([]) + expect(a.betCandidates(data.players[1], data.players[0], true)).toEqual([]) +}) + +test("bet_ RAISE -> RAISE -> RAISE -> FOLD", () => { + const a = new AirPoker() + const spy = vi.spyOn(a, 'initHands_') + spy.mockImplementation(() => [[6,6,6,6,6], [7,7,7,7,7]]) + + let data = a.firstInit(player) + data = a.secondInit(data, player2) + data = a.setCard_(data, {cardIdx: 0, id: player.id}) + data = a.setCard_(data, {cardIdx: 0, id: player2.id}) + data = a.initBet_(data) + data = a.bet_(data, {bet: Bet.RAISE, tip: 1}) + expect(data.players[0].bet).toEqual(Bet.RAISE) + expect(data.players[0].betAir).toEqual(2) + expect(data.players[0].air).toEqual(22) + expect(data.betPlayer).toEqual(player2.id) + expect(a.betCandidates(data.players[0], data.players[1], false)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], true)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + + data = a.bet_(data, {bet: Bet.RAISE, tip: 1}) + expect(data.players[1].bet).toEqual(Bet.RAISE) + expect(data.players[1].betAir).toEqual(3) + expect(data.players[1].air).toEqual(21) + expect(data.betPlayer).toEqual(player.id) + expect(a.betCandidates(data.players[0], data.players[1], true)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], false)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + + data = a.bet_(data, {bet: Bet.RAISE, tip: 2}) + expect(data.players[0].bet).toEqual(Bet.RAISE) + expect(data.players[0].betAir).toEqual(5) + expect(data.players[0].air).toEqual(19) + expect(data.betPlayer).toEqual(player2.id) + expect(a.betCandidates(data.players[0], data.players[1], false)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + expect(a.betCandidates(data.players[1], data.players[0], true)).toEqual([Bet.FOLD, Bet.CALL, Bet.RAISE]) + + data = a.bet_(data, {bet: Bet.FOLD}) + expect(data.players[1].bet).toEqual(Bet.FOLD) + expect(data.players[1].betAir).toEqual(3) + expect(data.players[1].air).toEqual(21) + + expect(data.betPlayer).toEqual('') + expect(a.betCandidates(data.players[0], data.players[1], true)).toEqual([]) + expect(a.betCandidates(data.players[1], data.players[0], false)).toEqual([]) +}) + +test("should return an available suit from remaining cards / null due to lack of available suits", () => { + const a = new AirPoker() + const testNumbers = [1, 2, 3, 4, 5] + expect(a.getFlushSuit_(a.data.cards, testNumbers)).toEqual(SUITS.HEART) + a.data.cards[5] = []; + expect(a.getFlushSuit_(a.data.cards, testNumbers)).toBe(null) +}) + +test("should judge", () => { + const a = new AirPoker() + let data = a.firstInit(player) + data = a.secondInit(data, player2) + + const [b, c] = data.players + b.pairs = [ + {number: 1, suit: SUITS.HEART}, + {number: 1, suit: SUITS.DIAMOND}, + {number: 1, suit: SUITS.SPADE}, + {number: 1, suit: SUITS.CLUB}, + {number: 2, suit: SUITS.DIAMOND}, + ] + c.pairs = JSON.parse(JSON.stringify(b.pairs)) + expect(a.judge_(b, c, true)).toEqual(b) + c.pairs![4] = {number: 3, suit: SUITS.HEART} + expect(a.judge_(b, c, true)).toEqual(c) +}) + +test.each([ + [[12, 12, 12, 12, 1], {point: 0xce, rank: RANK.FourOfAKind}], + [[2, 2, 2, 3, 3], {point: 0x23, rank: RANK.FullHouse}], + [[10, 11, 12, 13, 1], {point: 0xedcba, rank: RANK.RoyalStraight}], + [[1, 2, 3, 4, 5], {point: 0xe5432, rank: RANK.Straight}], + [[2, 3, 4, 5, 6], {point: 0x65432, rank: RANK.Straight}], + [[2, 3, 3, 3, 4], {point: 0x342, rank: RANK.ThreeOfAKind}], + [[2, 2, 2, 3, 4], {point: 0x243, rank: RANK.ThreeOfAKind}], + [[2, 2, 3, 4, 4], {point: 0x423, rank: RANK.TwoPair}], + [[2, 2, 3, 3, 4], {point: 0x324, rank: RANK.TwoPair}], + [[5, 2, 3, 4, 4], {point: 0x4532, rank: RANK.OnePair}], + [[5, 2, 2, 3, 4], {point: 0x2543, rank: RANK.OnePair}], + [[1, 2, 3, 4, 6], {point: 0xe6432, rank: RANK.HighCard}], + [[8, 10, 11, 12, 13], {point: 0xdcba8, rank: RANK.HighCard}], +])('rank of %s is %s', (cards, expected) => { + const a = new AirPoker() + expect(a.rankWithoutFlush_(cards)).toEqual(expected) +}) + +test.each([ + [6, [[1, 1, 1, 1, 2]]], + [7, [[1, 1, 1, 1, 3], [1, 1, 1, 2, 2]]], + [64, [[12, 13, 13, 13, 13]]], +])('sum=%s is %s', (sum, expected) => { + const a = new AirPoker() + expect(a.getCombinations_(a.data.cards, sum)).toEqual(expected) +}) + +test.each([ + [6, { + [RANK.FourOfAKind]: [ + {number: 1, suit: SUITS.HEART}, + {number: 1, suit: SUITS.DIAMOND}, + {number: 1, suit: SUITS.SPADE}, + {number: 1, suit: SUITS.CLUB}, + {number: 2, suit: SUITS.HEART} + ], + }], + [35, { + [RANK.Straight]: [ + {number: 5, suit: SUITS.HEART}, + {number: 6, suit: SUITS.HEART}, + {number: 7, suit: SUITS.HEART}, + {number: 8, suit: SUITS.HEART}, + {number: 9, suit: SUITS.HEART} + ], + }], +])('sum=%s includes %s', (sum, expected: Record) => { + const a = new AirPoker() + for (const c of a.getCandidates(a.data.cards, sum)) { + if (expected[c.rank]) expect(expected[c.rank]).toEqual(c.cards) + } +}) + +test("getCandidates: no pairs", () => { + const a = new AirPoker() + expect(a.getCandidates(a.data.cards, 5).length).toBe(0) + expect(a.getCandidates(a.data.cards, sumMax).length).toBe(0) + const empty = {} + for (let i=1;i<14;i++) { + empty[i] = [] + } + expect(a.getCandidates(empty, 6).length).toBe(0) +}) diff --git a/src/AirPoker.ts b/src/AirPoker.ts new file mode 100644 index 0000000..09ff821 --- /dev/null +++ b/src/AirPoker.ts @@ -0,0 +1,463 @@ +import type {AirPokerData, AirPokerBase, Player, Card, You, BET, Log} from './index.d' +export const sumMax = 65 +export const INIT_AIR = 24 // = 25 - 1 +export const NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] +const buffer = 1 * 1000 +export const msecLimit = { + buffer, + select: 60 * 1000 + buffer, + bet: 30 * 1000 + buffer, + pairs: 100 * 1000 + buffer, +} +export enum Step { + end, + select, + bet, + judge, +} +export enum SUITS { + HEART = 'HEART', + DIAMOND = 'DIAMOND', + SPADE = 'SPADE', + CLUB = 'CLUB', +} +export const Bet = { + NONE: '', + CHECK: 'CHECK', + RAISE: 'RAISE', + CALL: 'CALL', + FOLD: 'FOLD', +} as const +export enum RANK { + HighCard, + OnePair, + TwoPair, + ThreeOfAKind, + Straight, + RoyalStraight, + Flush, + FullHouse, + FourOfAKind, + StraightFlush, + RoyalStraightFlush, +} + + +const asc = (a: number, b: number) => a - b +export class FieldError extends Error { + code: string + constructor(e: string) { + super(e) + this.code = e + } +} + +export default class AirPoker { + data: AirPokerBase + + constructor() { + this.data = {} as any + this.data.cards = {} + for (const num of NUMBERS) { + this.data.cards[num] = [SUITS.HEART, SUITS.DIAMOND, SUITS.SPADE, SUITS.CLUB] + } + } + + getStep([a, b]: [You, Player]) { + const round = this.getRound(a) + if (round > 5 || a.air < 0 || b.air < 0) return Step.end + if (a.selected === 0 || b.selected === 0) return Step.select + if (a.bet === Bet.FOLD || b.bet === Bet.FOLD || + (Array.isArray(a.pairs) && Array.isArray(b.pairs) && ( + a.bet === Bet.CALL || b.bet === Bet.CALL || + (a.bet === Bet.CHECK && b.bet === Bet.CHECK))) + ) return Step.judge + return Step.bet + } + + getRound(p: You) { + let count = p.hand.reduce((p, v) => v === 0 ? p : p+1, 0) + count = p.selected > 0 ? count + 1 : count + return 5 - count + 1 + } + + firstInit({id}: {id: string}) { + if (id.length === 0) throw new FieldError('invalid_data') + const hands = this.initHands_() + const self: AirPokerData = {...this.data, + players: [], + betPlayer: '', + firstBet: '', + } + for (let i=0;i<2;i++) { + self.players.push({ + id: i === 0 ? id : '', + hand: hands[i], + air: INIT_AIR, + betAir: 0, + bet: Bet.NONE, + selected: 0, + pairs: null, + logs: [], + }) + } + return self + } + + secondInit(self: AirPokerData, {id}: {id: string}) { + if (id.length === 0) throw new FieldError('invalid_id') + const secondIdx = self.players.findIndex(v=>v.id==='') + if (secondIdx < 0 || + self.players[(secondIdx+1)%2].id === id) throw new FieldError('invalid_data') + self.players[secondIdx].id = id + return self + } + + // fieldデータに変更がある場合に直前のstepを添えて返す + next(self: AirPokerData, params: any) { + let step = null + if (this.getStep(self.players as [You, Player]) === Step.select) { + if (typeof params.cardIdx === 'number') self = this.setCard_(self, params) + if (this.getStep(self.players as [You, Player]) === Step.bet) { + self = this.initBet_(self) + return {step: Step.select, data: self} + } + return {step, data: self} + } + if (self.betPlayer === params.id && Bet[params.bet]) { + self = this.bet_(self, params) + step = Step.bet + } else if (Array.isArray(params.pairs)) self = this.makePairs_(self, params) + if (this.getStep(self.players as [You, Player]) === Step.judge) { + return {step: Step.judge, data: this.nextRound_(self)} + } + return {step, data: self} + } + + /* + * initHands_ + * Sums up five card numbers. + */ + private initHands_(): number[][] { + const hands = [] + const cardMap: Record = {} + for (const n of NUMBERS) cardMap[n] = 0 + for (let i = 0; i < 2; i++) { + const hand = [] + for (let j = 0; j < 5; j++) { + let sumup = 0 + let count = 0 + while (1) { + const nums = Object.keys(cardMap) + const i = Math.floor(Math.random() * nums.length) + const n = nums[i] + if (++cardMap[n] === 5) { + delete cardMap[n] + continue + } + sumup += +n + if (++count === 5) break + } + hand.push(sumup) + } + hands.push(hand) + } + return hands + } + + private setCard_(self: AirPokerData, {cardIdx, id}: {cardIdx: number, id: string}) { + const you = self.players.find(v=>v.id===id) + if (!you || you.selected > 0) throw new FieldError('invalid_id') + you.selected = you.hand[cardIdx] + if (!you.selected) throw new FieldError('invalid_data') + you.hand[cardIdx] = 0 + return self + } + + /* + * Pays entry fee according to every round + */ + private initBet_(self: AirPokerData) { + const round = this.getRound(self.players[0]) + for (const p of self.players) { + p.air -= round + if (p.air < 0) throw new FieldError('invalid_data') + p.betAir += round + } + const [p, p2] = self.players + if (round === 1) { + if (p.selected === p2.selected) self.firstBet = p.id < p2.id ? p.id : p2.id // ランダム + else self.firstBet = p.selected < p2.selected ? p.id : p2.id + } + self.betPlayer = self.firstBet + return self + } + + betCandidates(a: Player, b: Player, aBetNow: boolean): BET[] { + const canRaise = this.getMaxRaise(a, b) > 0 + let prev = a + let now = b + if (aBetNow) { + now = a + prev = b + } + if (now.bet === Bet.NONE && + (prev.bet === Bet.NONE || prev.bet === Bet.CHECK)) + return canRaise ? [Bet.CHECK, Bet.RAISE] : [Bet.CHECK] + else if (prev.bet === Bet.RAISE) + return canRaise ? [Bet.FOLD, Bet.CALL, Bet.RAISE] : [Bet.FOLD, Bet.CALL] + return [] + } + + getMaxRaise(a: Player, b: Player) { + let totalTips = a.betAir + b.betAir + return Math.min( + a.air, + b.air, + Math.floor(totalTips / 2) + ) + } + + private bet_(self: AirPokerData, {bet, tip = 0}: {bet: BET, tip?: number}) { + const youIdx = self.players.findIndex(v=>v.id===self.betPlayer) + if (youIdx < 0) throw new FieldError('invalid_id') + const opponent = self.players[(youIdx+1)%2] + const you = self.players[youIdx] + const candidates = this.betCandidates(you, opponent, true) + if (!candidates.includes(bet)) bet = candidates[0] // XXX for time-up + you.bet = bet + self.betPlayer = this.betCandidates(opponent, you, true).length === 0 ? '' : opponent.id + if (bet === Bet.RAISE) { + if (!(tip > 0) || tip > this.getMaxRaise(you, opponent)) throw new FieldError('invalid_data') + you.air -= (opponent.betAir - you.betAir) + tip + you.betAir = opponent.betAir + tip + } else if (bet === Bet.CALL) { + you.air -= opponent.betAir - you.betAir + you.betAir = opponent.betAir + } + return self + } + + private makePairs_(self: AirPokerData, {pairs, id}: {pairs: Card[], id: string}) { + const you = self.players.find(v=>v.id===id) + if (!you || Array.isArray(you.pairs) || !Array.isArray(pairs) || + (pairs.length !== 5 && pairs.length !== 0)) throw new FieldError('invalid_pair') + let sum = 0 + const isSameCard: any = {} + for (const c of pairs) { + if (!NUMBERS.includes(c.number) || !self.cards[c.number].includes(c.suit) || + isSameCard[c.number] === c.suit) { + sum = -1 + break + } + isSameCard[c.number] = c.suit + sum += c.number + } + you.pairs = sum === you.selected ? pairs : [] + return self + } + + private updateCards_(self: AirPokerData) { + let disaster = false + for (const p of self.players) { + if (p.pairs === null) continue + for (const c of p.pairs) { + const index = self.cards[c.number].indexOf(c.suit) + if (index < 0) disaster = true + else self.cards[c.number].splice(index, 1) + } + } + return disaster + } + + /** + * Compares their Poker rank. + * If both of ranks are the same, compare a highest number of the hand. + * Ace(1) is highest. Two(2) is lowest. + **/ + private judge_(a: Player, b: Player, aIsFirst: boolean) { + if (a.bet === Bet.FOLD) return b + else if (b.bet === Bet.FOLD) return a + const ar = this.getRank(a.pairs as Card[]) + const br = this.getRank(b.pairs as Card[]) + // 勝敗判定 (独自:後攻有利なのでdrawなら先攻勝利) + if (ar.rank > br.rank || (ar.rank === br.rank && ar.point > br.point) || + (ar.rank === br.rank && ar.point === br.point && aIsFirst)) return a + return b + } + + private nextRound_(self: AirPokerData) { + const [a, b] = self.players + const winner = this.judge_(a, b, a.id === self.firstBet) + const disaster = this.updateCards_(self) + if (disaster) { + if (a === winner) b.air -= b.betAir + else a.air -= a.betAir + } + winner.air += a.betAir + b.betAir + // logging & minus round air & cleanup + self.firstBet = self.firstBet === a.id ? b.id : a.id + for (const p of self.players) { + p.logs.push({ + selected: p.selected, + pairs: p.pairs === null ? [] : p.pairs, + bet: p.bet, + betAir: p.betAir, + + air: p.air, + win: (winner as You).id === p.id, + disaster, + }) + p.air -= 1 + p.bet = Bet.NONE + p.betAir = 0 + p.selected = 0 + p.pairs = null + } + return self + } + + getRank(cards: Card[]) { + if (cards.length === 0) return {rank: RANK.HighCard, point: 0} + const numbers = [] + let preSuit = null + let flush = false + for (const card of cards) { + numbers.push(card.number) + if (!preSuit) preSuit = card.suit + else flush = preSuit === card.suit + } + let {point, rank} = this.rankWithoutFlush_(numbers) + if (flush) rank = this.flush_(rank) + return {point, rank} + } + + /* + * getCombinations_ + * Returns sorted array of five numbers to be the arguement number by summing them. + */ + private getCombinations_(cards: AirPokerBase['cards'], sum: number) { + if (sum < 6 || sum >= sumMax) return [] + const combinations = [] + for (let a = 1; a < sum / 5; a++) { + if (cards[a].length < 1) continue + const max2 = sum - a + for (let b = a;b <= Math.min(max2/4, 13); b++) { + if (cards[b].length < (a === b ? 2 : 1)) continue + const max3 = max2 - b; + for (let c = b; c <= Math.min(max3 / 3, 13); c++) { + if (cards[c].length < (a === c ? 3 : (b === c ? 2 : 1))) continue + const max4 = max3 - c; + for (let d = c; d <= Math.min(max4 / 2, 13); d++) { + if (cards[d].length < (a === d ? 4 : (b === d ? 3 : (c === d ? 2 : 1)))) continue + const e = max4 - d // eはd以上 + if (e > 13 || a === e || + cards[e].length < (b === e ? 4 : (c === e ? 3 : (d === e ? 2 : 1)))) continue + combinations.push([a, b, c, d, e]) + } + } + } + } + return combinations; + } + + /* + * @param numbers length = 5 + * @return {rank: poker rank, point: high card number} + */ + private rankWithoutFlush_(numbers: number[]) { + if (numbers.length !== 5) throw new FieldError('invalid_data') + const rank = {point: 0, rank: RANK.HighCard} + + const pairs: Record = {} + let count = 1 + const cards = numbers.map((v, i) => { + if (v === 1) v = 14 + if (pairs[v]) { + pairs[v] += 1 + count = count > pairs[v] ? count : pairs[v] + } else pairs[v] = 1 + return v + }) + cards.sort(asc) + if (count === 1) { + if (cards.every((e, i) => i + 1 === cards.length || e + 1 === cards[i + 1] || (e === 5 && cards[i + 1] === 14))) { + if (cards[0] === 10) rank.rank = RANK.RoyalStraight + else rank.rank = RANK.Straight + } + let point = '' + for (const n of cards) point = n.toString(16) + point + rank.point = parseInt(point, 16) + return rank + } else if (count === 2) { + if (Object.keys(pairs).length === 3) { // 2 - 2 - 1 + rank.rank = RANK.TwoPair + const a = cards[0] === cards[1] ? (cards[2] === cards[3] ? cards[4] : cards[2]) : cards[0] + rank.point = parseInt(cards[3].toString(16) + cards[1].toString(16) + a.toString(16), 16) + } else { // 2-1-1-1 + rank.rank = RANK.OnePair + let point = '' + let pairHex = '' + for (const n of cards) { + if (pairs[n] === 2) pairHex = n.toString(16) + else point = n.toString(16) + point + } + rank.point = parseInt(pairHex + point, 16) + } + } else if (count === 3) { + if (Object.keys(pairs).length === 2) { // 3 - 2 + rank.rank = RANK.FullHouse + rank.point = parseInt(cards[2].toString(16) + (cards[0] === cards[2] ? cards[4].toString(16) : cards[0].toString(16)), 16) + } else { // 3 - 1 - 1 + rank.rank = RANK.ThreeOfAKind + const a = [] + for (const c of cards) if (c !== cards[2]) a.push(c) + rank.point = parseInt(cards[2].toString(16) + a[1].toString(16) + a[0].toString(16), 16) + } + } else if (count === 4) { // 4 - 1 + rank.rank = RANK.FourOfAKind + rank.point = parseInt(cards[1].toString(16) + (cards[0] === cards[1] ? cards[4].toString(16) : cards[0].toString(16)), 16) + } + return rank; + } + + /* + * Finds an available suit.(if deckNum is 1) + */ + private getFlushSuit_(cards: AirPokerBase['cards'], numbers: number[]): SUITS | null { + if (numbers.length !== 5) throw new FieldError('invalid_data') + let suits: SUITS[] = cards[numbers[0]] + for (const n of numbers.slice(1)) { + const oks = [] + for (const s of cards[n]) if (suits.includes(s)) oks.push(s) + if (oks.length === 0) return null + suits = oks + } + return suits[0] + } + + flush_(rank: RANK): RANK { + if (rank === RANK.Straight) return RANK.StraightFlush + else if (rank === RANK.HighCard) return RANK.Flush + else if (rank === RANK.RoyalStraight) return RANK.RoyalStraightFlush + return rank + } + + /* + * 可能な役のペアを強い順で最大N個返す(最強と最弱は含める) + */ + getCandidates(c: AirPokerBase['cards'], sum: number): {cards: Card[], rank: RANK}[] { + const candidates = [] + for (const v of this.getCombinations_(c, sum)) { + let {rank, point} = this.rankWithoutFlush_(v) + let suit = null + if (rank !== this.flush_(rank)) suit = this.getFlushSuit_(c, v) + if (suit) rank = this.flush_(rank) + + const c2 = JSON.parse(JSON.stringify(c)) + candidates.push({rank, cards: v.map(number => ({number, suit: suit || c2[number].shift()}))}) + } + candidates.sort((a, b) => b.rank - a.rank) + return candidates + } +} diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..b4cdf0f --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,52 @@ +import {Bet, SUITS} from "~/src/AirPoker" + +type BET = typeof Bet[keyof typeof Bet] + +interface Card { + number: number + suit: SUITS +} +interface Log { + selected: number + pairs: Card[] + bet: BET + betAir: number + // todo 以下はなくても算出できる。整合性重視して無くしたい + air: number + win: boolean + disaster: boolean +} +interface Player { + air: number + betAir: number + bet: BET + selected: number + pairs: Card[] | null + logs: Log[] +} +interface You extends Player { + id: string + hand: number[] +} +interface Timer { + deadline: number + betLine: number +} +interface AirPokerBase { + cards: Record +} +interface AirPokerResponse extends AirPokerBase, Timer { + you: You + opponent: Player + isFirst: boolean + isBet: boolean + betCandidates: BET[] +} +interface AirPokerData extends AirPokerBase { + players: You[] + firstBet: string // player.id + betPlayer: string // player.id +} +interface AirPokerStore extends AirPokerData, Timer { + lock: null | string +} \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..c342f08 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,22 @@ +/** @type {import('tailwindcss').Config} */ +import type { Config } from "tailwindcss"; + +const config: Config = { + theme: { + extend: { + gridTemplateColumns: { + '13': 'repeat(13, minmax(0, 1fr))', + } + } + }, + content: [ + './app/**/*.{js,ts,jsx,tsx}', + 'node_modules/flowbite-react/lib/esm/**/*.js', + ], + plugins: [ + require('@tailwindcss/aspect-ratio'), + require('@tailwindcss/forms'), + require('flowbite/plugin'), + ], +}; +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a84939 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES6", + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "~/*": ["./*"] + } + }, + "files": ["src/index.d.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1c89d31 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + }, + resolve: { + alias: { + "~": "/", + }, + }, +});