+ )
+})
+export const HandCard = memo(function HandCard({
+ card, candidates}: {card: number, candidates: any[]}) {
+ if (card === 0) return
+ return (
+
+
+
+ );
+})
\ 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
+
+
+ >)) : (<>
+ Play
+
+ 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"
+ />
+
+
+ with a friend
+
+
+
+ >)}
+ >
+ )
+}
\ 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 (
+ <>
+
+ Restart
+
+ {i18n[lang]['error']}
+ {i18n[lang]['error.report']}
+
+
+ Report Bugs
+
+
+ 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}
+
+ {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 (
+
+ )
+})
+
+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 (
+
+
+
+ ok
+
+
+
+ )
+}
+
+const Raise = memo(function Raise({max, handler}: {max: number, handler: any}) {
+ const [tip, setTip] = useState(1)
+ return (
+
+
+ handler({bet: Bet.RAISE, tip})}>RAISE
+
+
+ setTip(parseInt(e.target.value, 10))}
+ disabled={max === 1} />
+ 1 air
+ {max} air
+
+
)
+})
+
+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 ? (
+
+ ) : (
+ onSubmit({bet})}>{bet}
+ )) : (
+ {bet}
+ )
+ }
+ }
+ 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)})
+ }}
+ >
+
+
+ )}
+
+
{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: {
+ "~": "/",
+ },
+ },
+});