├── .dockerignore ├── .editorconfig ├── .env ├── .env.production ├── .eslintrc.cjs ├── .github └── workflows │ ├── continous-integration.yml │ └── publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── deploy ├── ingress.yml ├── namespace.yml └── web.yml ├── index.html ├── nginx └── default.conf ├── package.json ├── package.json.note ├── public ├── og-image.png └── site.webmanifest ├── src ├── App.vue ├── assets │ ├── style.css │ └── tile-icons │ │ ├── sprite.2x.png │ │ ├── sprite.css │ │ └── sprite.png ├── components │ ├── AdvancedCalculator.vue │ ├── AdvancedCalculatorScoreModal.vue │ ├── ConfigurationModal.vue │ ├── DoraCounter.vue │ ├── MahjongCombination.vue │ ├── MahjongTile.vue │ ├── ModalBase.vue │ ├── SimpleCalculator.vue │ ├── SimpleCalculatorScoreModal.vue │ └── TileSelectionModal.vue ├── core │ ├── combination-classes.js │ ├── ema │ │ ├── ema-fu-calculator.js │ │ ├── ema-han-calculator.js │ │ └── ema-point-calculator.js │ ├── fu-calculation │ │ ├── fu-calculation-context.js │ │ ├── fu-calculation-result.js │ │ ├── fu-calculator.js │ │ ├── fu-info.js │ │ ├── fu-rule.js │ │ ├── index.js │ │ └── rules │ │ │ ├── chiitoitsu-fu-rule.js │ │ │ ├── closed-ron-fu-rule.js │ │ │ ├── combinations-fu-rule.js │ │ │ ├── open-pinfu-fu-rule.js │ │ │ ├── pair-fu-rule.js │ │ │ ├── tsumo-fu-rule.js │ │ │ ├── wait-fu-rule.js │ │ │ └── winning-fu-rule.js │ ├── han-calculation │ │ ├── han-calculation-result.js │ │ ├── han-calculator.js │ │ ├── han-info.js │ │ ├── index.js │ │ ├── yaku.js │ │ └── yakus │ │ │ ├── chankan-yaku.js │ │ │ ├── chanta-yaku.js │ │ │ ├── chiihou-yaku.js │ │ │ ├── chiitoitsu-yaku.js │ │ │ ├── chinitsu-yaku.js │ │ │ ├── chinroutou-yaku.js │ │ │ ├── chuuren-poutou-yaku.js │ │ │ ├── daisangen-yaku.js │ │ │ ├── daisuushii-yaku.js │ │ │ ├── double-riichi-yaku.js │ │ │ ├── haitei-raoyue-yaku.js │ │ │ ├── honitsu-yaku.js │ │ │ ├── honroutou-yaku.js │ │ │ ├── houtei-raoyui-yaku.js │ │ │ ├── iipeikou-yaku.js │ │ │ ├── ippatsu-yaku.js │ │ │ ├── ittsuu-yaku.js │ │ │ ├── junchan-yaku.js │ │ │ ├── kokushi-musou-yaku.js │ │ │ ├── menzen-tsumo-yaku.js │ │ │ ├── open-riichi-yaku.js │ │ │ ├── pinfu-yaku.js │ │ │ ├── renhou-yaku.js │ │ │ ├── riichi-yaku.js │ │ │ ├── rinshan-kaihou-yaku.js │ │ │ ├── ryanpeikou-yaku.js │ │ │ ├── ryuuiisou-yaku.js │ │ │ ├── sanankou-yaku.js │ │ │ ├── sankantsu-yaku.js │ │ │ ├── sanshoku-doujun-yaku.js │ │ │ ├── sanshoku-doukou-yaku.js │ │ │ ├── shousangen-yaku.js │ │ │ ├── shousuushii-yaku.js │ │ │ ├── suuankou-yaku.js │ │ │ ├── suukantsu-yaku.js │ │ │ ├── tanyao-yaku.js │ │ │ ├── tenhou-yaku.js │ │ │ ├── toitoi-yaku.js │ │ │ ├── tsuuiisou-yaku.js │ │ │ └── yakuhai-yaku.js │ ├── hand.js │ ├── point-calculation │ │ └── point-calculator.js │ ├── tile-classes.js │ ├── waits │ │ ├── is-kanchan-wait.js │ │ ├── is-penchan-wait.js │ │ └── is-tanki-wait.js │ └── wrc │ │ ├── index.js │ │ ├── wrc-fu-calculator.js │ │ ├── wrc-han-calculator.js │ │ └── wrc-point-calculator.js ├── event-bus.js ├── filters │ ├── format-number.js │ └── title-case.js ├── i18n.js ├── locales │ ├── en.json │ └── jp-romanized.json ├── main.js ├── ruleset.js └── rulesets │ ├── custom-ruleset.js │ ├── ema-ruleset.js │ ├── helpers │ └── four-player-game-tiles.js │ └── wrc-ruleset.js ├── tests ├── .eslintrc.cjs ├── fixtures │ ├── dealer-score-fixture.json │ └── non-dealer-score-fixture.json ├── integration │ ├── ema │ │ ├── ema-fu-calculator.spec.js │ │ ├── ema-han-calculator.spec.js │ │ └── ema-point-calculator.spec.js │ └── wrc │ │ ├── wrc-fu-calculator.spec.js │ │ ├── wrc-han-calculator.spec.js │ │ └── wrc-point-calculator.spec.js └── unit │ ├── combination-classes.spec.js │ ├── fu-calculation │ ├── fu-calculator.spec.js │ └── rules │ │ ├── chiitoitsu-fu-rule.spec.js │ │ ├── closed-ron-fu-rule.spec.js │ │ ├── combinations-fu-rule.spec.js │ │ ├── open-pinfu-fu-rule.spec.js │ │ ├── pair-fu-rule.spec.js │ │ ├── tsumo-fu-rule.spec.js │ │ ├── wait-fu-rule.spec.js │ │ └── winning-fu-rule.spec.js │ ├── han-calculation │ ├── han-calculator.spec.js │ └── yakus │ │ ├── chankan-yaku.spec.js │ │ ├── chanta-yaku.spec.js │ │ ├── chiihou-yaku.spec.js │ │ ├── chiitoitsu-yaku.spec.js │ │ ├── chinitsu-yaku.spec.js │ │ ├── chinroutou-yaku.spec.js │ │ ├── chuuren-poutou-yaku.spec.js │ │ ├── daisangen-yaku.spec.js │ │ ├── daisuushii-yaku.spec.js │ │ ├── double-riichi-yaku.spec.js │ │ ├── haitei-raoyue-yaku.spec.js │ │ ├── honitsu-yaku.spec.js │ │ ├── honroutou-yaku.spec.js │ │ ├── houtei-raoyui-yaku.spec.js │ │ ├── iipeikou-yaku.spec.js │ │ ├── ippatsu-yaku.spec.js │ │ ├── ittsuu-yaku.spec.js │ │ ├── junchan-yaku.spec.js │ │ ├── kokushi-musou-yaku.spec.js │ │ ├── menzen-tsumo-yaku.spec.js │ │ ├── open-riichi-yaku.spec.js │ │ ├── pinfu-yaku.spec.js │ │ ├── renhou-yaku.spec.js │ │ ├── riichi-yaku.spec.js │ │ ├── rinshan-kaihou-yaku.spec.js │ │ ├── ryanpeikou-yaku.spec.js │ │ ├── ryuuiisou-yaku.spec.js │ │ ├── sanankou-yaku.spec.js │ │ ├── sankantsu-yaku.spec.js │ │ ├── sanshoku-doujun-yaku.spec.js │ │ ├── sanshoku-doukou-yaku.spec.js │ │ ├── shousangen-yaku.spec.js │ │ ├── shousuushii-yaku.spec.js │ │ ├── suuankou-yaku.spec.js │ │ ├── suukantsu-yaku.spec.js │ │ ├── tanyao-yaku.spec.js │ │ ├── tenhou-yaku.spec.js │ │ ├── toitoi-yaku.spec.js │ │ ├── tsuuiisou-yaku.spec.js │ │ └── yakuhai-yaku.spec.js │ ├── hand.spec.js │ ├── point-calculation │ └── point-calculator.spec.js │ ├── tiles.spec.js │ └── waits │ ├── is-kanchan-wait.spec.js │ ├── is-penchan-wait.spec.js │ └── is-tanki-wait.spec.js ├── vite.config.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false 9 | 10 | [*.js] 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=/ 2 | VITE_BASE_URL_FULL=http://localhost:3000/ -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=/riichi/pointer/ 2 | VITE_BASE_URL_FULL=https://tools.phil.moe/riichi/pointer/ -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | 'vue/setup-compiler-macros': true 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'standard' 9 | ], 10 | ignorePatterns: ['tests/*'] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/continous-integration.yml: -------------------------------------------------------------------------------- 1 | name: Continous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: Install Yarn 22 | run: | 23 | npm i -g yarn 24 | 25 | - name: Install dependencies 26 | run: | 27 | yarn --frozen-lockfile --production=false 28 | 29 | - name: Run linter 30 | run: | 31 | yarn lint 32 | 33 | - name: Run unit tests 34 | run: | 35 | yarn test:unit 36 | 37 | - name: Test application build 38 | run: | 39 | yarn build 40 | 41 | - name: Test Docker build 42 | run: | 43 | docker build -t ci:local . -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | 17 | - name: Install Yarn 18 | run: | 19 | npm i -g yarn 20 | 21 | - name: Install dependencies 22 | run: | 23 | yarn --frozen-lockfile --production=false 24 | 25 | - name: Build application 26 | run: | 27 | yarn build 28 | 29 | - name: Build docker image 30 | run: | 31 | docker build -t emeraldcoder/riichi-pointer-web:${{ github.event.release.tag_name }} . 32 | 33 | - name: Publish docker image 34 | run: | 35 | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }} 36 | docker push emeraldcoder/riichi-pointer-web:${{ github.event.release.tag_name }} 37 | 38 | - name: Update K8S cluster 39 | run: | 40 | cat <<< "${{ secrets.DO_K8S_CONFIG }}" > $GITHUB_WORKSPACE/.kubeconfig 41 | IMAGE_TAG=${{ github.event.release.tag_name }} envsubst < $GITHUB_WORKSPACE/deploy/web.yml > $GITHUB_WORKSPACE/deploy/web.processed.yml 42 | kubectl --kubeconfig=$GITHUB_WORKSPACE/.kubeconfig apply -f $GITHUB_WORKSPACE/deploy/namespace.yml 43 | kubectl --kubeconfig=$GITHUB_WORKSPACE/.kubeconfig apply -f $GITHUB_WORKSPACE/deploy/web.processed.yml 44 | kubectl --kubeconfig=$GITHUB_WORKSPACE/.kubeconfig apply -f $GITHUB_WORKSPACE/deploy/ingress.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | ADD ./dist /usr/share/nginx/html 3 | ADD ./nginx /etc/nginx/conf.d -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 EmeraldCoder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /deploy/ingress.yml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: riichi-pointer 5 | namespace: riichi 6 | annotations: 7 | kubernetes.io/ingress.class: "nginx" 8 | nginx.ingress.kubernetes.io/rewrite-target: /$1 9 | nginx.ingress.kubernetes.io/service-upstream: "true" 10 | spec: 11 | rules: 12 | - host: tools.phil.moe 13 | http: 14 | paths: 15 | - path: /riichi/pointer/?(.*) 16 | pathType: Prefix 17 | backend: 18 | service: 19 | name: riichi-pointer-web 20 | port: 21 | number: 80 -------------------------------------------------------------------------------- /deploy/namespace.yml: -------------------------------------------------------------------------------- 1 | kind: Namespace 2 | apiVersion: v1 3 | metadata: 4 | name: riichi 5 | labels: 6 | name: riichi -------------------------------------------------------------------------------- /deploy/web.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: riichi-pointer-web 5 | namespace: riichi 6 | labels: 7 | app: riichi-pointer-web 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: riichi-pointer-web 13 | template: 14 | metadata: 15 | labels: 16 | app: riichi-pointer-web 17 | spec: 18 | containers: 19 | - name: riichi-pointer-web 20 | image: emeraldcoder/riichi-pointer-web:${IMAGE_TAG} 21 | ports: 22 | - containerPort: 80 23 | imagePullSecrets: 24 | - name: regcred 25 | 26 | --- 27 | 28 | apiVersion: v1 29 | kind: Service 30 | metadata: 31 | name: riichi-pointer-web 32 | namespace: riichi 33 | spec: 34 | selector: 35 | app: riichi-pointer-web 36 | ports: 37 | - name: tcp 38 | port: 80 -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Riichi Mahjong Pointer (beta version) 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | 9 | # Default caching strategy based on the etag feature (for stuff like index.html and site.webmanifest that don't have a content hash in the name) 10 | etag on; 11 | add_header Cache-Control "no-cache"; 12 | 13 | # Caching strategy for static assets (Vue build those assets with a content hash in the name, so etag is not usefull and the cache will be busted automatically if the content of the file change) 14 | location /assets { 15 | etag off; 16 | add_header Cache-Control "public,max-age=31536000,immutable"; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "riichi-pointer-js", 3 | "version": "0.7.4", 4 | "private": true, 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview --port 8080", 11 | "test:unit": "vitest run", 12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore", 13 | "watch:test:unit": "vitest" 14 | }, 15 | "dependencies": { 16 | "@fortawesome/fontawesome-svg-core": "^6.0.0", 17 | "@fortawesome/free-solid-svg-icons": "^6.0.0", 18 | "@fortawesome/vue-fontawesome": "^3.0.0", 19 | "dot-prop": "^9.0.0", 20 | "tiny-emitter": "^2.1.0", 21 | "vue": "^3.2.31" 22 | }, 23 | "devDependencies": { 24 | "@vitejs/plugin-vue": "^5.0.0", 25 | "eslint": "^8.0.0", 26 | "eslint-config-standard": "^17.0.0", 27 | "eslint-plugin-import": "^2.0.0", 28 | "eslint-plugin-n": "^16.0.0 ", 29 | "eslint-plugin-promise": "^6.0.0", 30 | "eslint-plugin-vue": "^9.0.0", 31 | "vite": "^6.0.0", 32 | "vitest": "^2.0.0", 33 | "vite-plugin-html": "^3.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json.note: -------------------------------------------------------------------------------- 1 | Package Current Wanted Latest Package Type URL 2 | eslint 8.57.1 8.57.1 9.11.1 devDependencies https://eslint.org 3 | eslint-plugin-n 16.6.2 16.6.2 17.10.3 devDependencies https://github.com/eslint-community/eslint-plugin-n#readme 4 | eslint-plugin-promise 6.6.0 6.6.0 7.1.0 devDependencies https://github.com/eslint-community/eslint-plugin-promise 5 | 6 | Can't update eslint to v9 for now because eslint-config-standard have a peer dependency on v8. 7 | Can't update eslint-plugin-n to v17 for now because eslint-config-standard have a peer dependency on v15 or v16. 8 | Can't update eslint-plugin-promise to v7 for now because eslint-config-standard have a peer dependency on v6. 9 | 10 | ---------------------- -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmeraldCoder/riichi-pointer-js/2272d287df9377cf7fae585934875d9801671065/public/og-image.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Riichi Mahjong Pointer", 3 | "short_name": "Riichi Mahjong Pointer", 4 | "background_color": "#43a047", 5 | "theme_color": "#1b5e20" 6 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 122 | 123 | 135 | -------------------------------------------------------------------------------- /src/assets/tile-icons/sprite.2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmeraldCoder/riichi-pointer-js/2272d287df9377cf7fae585934875d9801671065/src/assets/tile-icons/sprite.2x.png -------------------------------------------------------------------------------- /src/assets/tile-icons/sprite.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Generated and lightly adapted from https://jcthepants.github.io/Retina-CSS-Sprite-Generator/ 3 | */ 4 | 5 | .tile-icon { 6 | background: url('sprite.png') no-repeat top left; 7 | width: 58px; 8 | height: 78px; 9 | display: inline-block; 10 | } 11 | .tile-icon.man-1 { 12 | background-position: 0 0; 13 | } 14 | .tile-icon.man-2 { 15 | background-position: 0 -79px; 16 | } 17 | .tile-icon.man-3 { 18 | background-position: 0 -158px; 19 | } 20 | .tile-icon.man-4 { 21 | background-position: 0 -237px; 22 | } 23 | .tile-icon.man-5 { 24 | background-position: 0 -316px; 25 | } 26 | .tile-icon.man-6 { 27 | background-position: 0 -395px; 28 | } 29 | .tile-icon.man-7 { 30 | background-position: 0 -474px; 31 | } 32 | .tile-icon.man-8 { 33 | background-position: 0 -553px; 34 | } 35 | .tile-icon.man-9 { 36 | background-position: 0 -632px; 37 | } 38 | .tile-icon.pin-1 { 39 | background-position: 0 -711px; 40 | } 41 | .tile-icon.pin-2 { 42 | background-position: 0 -790px; 43 | } 44 | .tile-icon.pin-3 { 45 | background-position: 0 -869px; 46 | } 47 | .tile-icon.pin-4 { 48 | background-position: 0 -948px; 49 | } 50 | .tile-icon.pin-5 { 51 | background-position: 0 -1027px; 52 | } 53 | .tile-icon.pin-6 { 54 | background-position: 0 -1106px; 55 | } 56 | .tile-icon.pin-7 { 57 | background-position: 0 -1185px; 58 | } 59 | .tile-icon.pin-8 { 60 | background-position: 0 -1264px; 61 | } 62 | .tile-icon.pin-9 { 63 | background-position: 0 -1343px; 64 | } 65 | .tile-icon.sou-1 { 66 | background-position: 0 -1422px; 67 | } 68 | .tile-icon.sou-2 { 69 | background-position: 0 -1501px; 70 | } 71 | .tile-icon.sou-3 { 72 | background-position: 0 -1580px; 73 | } 74 | .tile-icon.sou-4 { 75 | background-position: 0 -1659px; 76 | } 77 | .tile-icon.sou-5 { 78 | background-position: 0 -1738px; 79 | } 80 | .tile-icon.sou-6 { 81 | background-position: 0 -1817px; 82 | } 83 | .tile-icon.sou-7 { 84 | background-position: 0 -1896px; 85 | } 86 | .tile-icon.sou-8 { 87 | background-position: 0 -1975px; 88 | } 89 | .tile-icon.sou-9 { 90 | background-position: 0 -2054px; 91 | } 92 | .tile-icon.hatsu { 93 | background-position: 0 -2133px; 94 | } 95 | .tile-icon.chun { 96 | background-position: 0 -2212px; 97 | } 98 | .tile-icon.haku { 99 | background: none; 100 | } 101 | .tile-icon.ton { 102 | background-position: 0 -2291px; 103 | } 104 | .tile-icon.nan { 105 | background-position: 0 -2370px; 106 | } 107 | .tile-icon.shaa { 108 | background-position: 0 -2449px; 109 | } 110 | .tile-icon.pei { 111 | background-position: 0 -2528px; 112 | } 113 | 114 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { 115 | .tile-icon { 116 | background: url('sprite.2x.png') no-repeat top left; 117 | background-size: 58px 2607px; 118 | } 119 | } -------------------------------------------------------------------------------- /src/assets/tile-icons/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmeraldCoder/riichi-pointer-js/2272d287df9377cf7fae585934875d9801671065/src/assets/tile-icons/sprite.png -------------------------------------------------------------------------------- /src/components/DoraCounter.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 47 | 48 | 60 | -------------------------------------------------------------------------------- /src/components/MahjongTile.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 53 | 54 | 87 | -------------------------------------------------------------------------------- /src/components/SimpleCalculatorScoreModal.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 126 | 127 | 134 | -------------------------------------------------------------------------------- /src/components/TileSelectionModal.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 78 | 79 | 89 | -------------------------------------------------------------------------------- /src/core/combination-classes.js: -------------------------------------------------------------------------------- 1 | import { TileFactory } from './tile-classes' 2 | 3 | /** 4 | * Combination factory class 5 | * Factory design pattern to help create hand combination classes 6 | * 7 | * ex. : CombinationFactory.create('triplet', new DotTile(1)) 8 | */ 9 | export const CombinationFactory = { 10 | create (combinationType, firstCombinationTile) { 11 | switch (combinationType) { 12 | case 'pair': 13 | return new Pair(firstCombinationTile) 14 | case 'triplet': 15 | return new Triplet(firstCombinationTile) 16 | case 'quad': 17 | return new Quad(firstCombinationTile) 18 | case 'sequence': { 19 | const secondCombinationTile = TileFactory.create(firstCombinationTile.suit, firstCombinationTile.value + 1) 20 | const thirdCombinationTile = TileFactory.create(firstCombinationTile.suit, firstCombinationTile.value + 2) 21 | return new Sequence(firstCombinationTile, secondCombinationTile, thirdCombinationTile) 22 | } 23 | case 'orphan': 24 | return new Orphan(firstCombinationTile) 25 | default: 26 | throw new Error(`Hand Combination Factory Error: "${combinationType}" is not a supported combination type`) 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Combination base class (pair, triplet, quad, sequence) 33 | */ 34 | export class Combination { 35 | constructor (tiles) { 36 | this.tiles = tiles // property that contain the tiles of the combination 37 | } 38 | } 39 | 40 | /** 41 | * Pair combination class 42 | * two identical tiles (ex. : 3,3 of bamboo tiles) 43 | * 44 | * param Tile tile 45 | */ 46 | export class Pair extends Combination { 47 | constructor (tile) { 48 | super([ 49 | TileFactory.create(tile.suit, tile.value), 50 | TileFactory.create(tile.suit, tile.value) 51 | ]) 52 | } 53 | } 54 | 55 | /** 56 | * Triplet combination class 57 | * three identical tiles (ex. : 3,3,3 of bamboo tiles) 58 | * 59 | * param Tile tile 60 | */ 61 | export class Triplet extends Combination { 62 | constructor (tile) { 63 | super([ 64 | TileFactory.create(tile.suit, tile.value), 65 | TileFactory.create(tile.suit, tile.value), 66 | TileFactory.create(tile.suit, tile.value) 67 | ]) 68 | } 69 | } 70 | 71 | /** 72 | * Quad combination class 73 | * four identical tiles (ex. : 3,3,3,3 of bamboo tiles) 74 | * 75 | * param Tile tile 76 | */ 77 | export class Quad extends Combination { 78 | constructor (tile) { 79 | super([ 80 | TileFactory.create(tile.suit, tile.value), 81 | TileFactory.create(tile.suit, tile.value), 82 | TileFactory.create(tile.suit, tile.value), 83 | TileFactory.create(tile.suit, tile.value) 84 | ]) 85 | } 86 | } 87 | 88 | /** 89 | * Sequence combination class 90 | * three following numbered tile of the same suit (ex. : 2,3,4 of bamboo tiles) 91 | * 92 | * param Tile tile 93 | */ 94 | export class Sequence extends Combination { 95 | constructor (tile1, tile2, tile3) { 96 | super([tile1, tile2, tile3]) 97 | } 98 | } 99 | 100 | /** 101 | * Orphan combination class 102 | * one tile (only used for thirteen orphans yakuman) 103 | * 104 | * param Tile tile 105 | */ 106 | export class Orphan extends Combination { 107 | constructor (tile) { 108 | super([tile]) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/core/ema/ema-fu-calculator.js: -------------------------------------------------------------------------------- 1 | import FuCalculator from '../fu-calculation/fu-calculator' 2 | 3 | import WinRule from '../fu-calculation/rules/winning-fu-rule' 4 | import WaitRule from '../fu-calculation/rules/wait-fu-rule' 5 | import TsumoRule from '../fu-calculation/rules/tsumo-fu-rule' 6 | import PairRule from '../fu-calculation/rules/pair-fu-rule' 7 | import OpenPinfuRule from '../fu-calculation/rules/open-pinfu-fu-rule' 8 | import CombinationsRule from '../fu-calculation/rules/combinations-fu-rule' 9 | import ClosedRonRule from '../fu-calculation/rules/closed-ron-fu-rule' 10 | import ChiitoitsuRule from '../fu-calculation/rules/chiitoitsu-fu-rule' 11 | 12 | import ChiitoitsuYaku from '../han-calculation/yakus/chiitoitsu-yaku' 13 | import PinfuYaku from '../han-calculation/yakus/pinfu-yaku' 14 | 15 | class EmaFuCalculator extends FuCalculator { 16 | constructor () { 17 | super([ 18 | // put the chiitoitsu rule at the top because it's a fixed amount of fu 19 | new ChiitoitsuRule({ chiitoitsuYakuPattern: new ChiitoitsuYaku(), fuValue: 25 }), 20 | new WinRule(), 21 | new CombinationsRule(), 22 | new PairRule({ stackable: true }), 23 | new WaitRule(), 24 | new OpenPinfuRule(), 25 | new ClosedRonRule(), 26 | new TsumoRule({ excludedYakuPatterns: [new PinfuYaku()] }) 27 | ]) 28 | } 29 | } 30 | 31 | export default EmaFuCalculator 32 | -------------------------------------------------------------------------------- /src/core/ema/ema-point-calculator.js: -------------------------------------------------------------------------------- 1 | import PointCalculator from '../point-calculation/point-calculator' 2 | 3 | class EmaPointCalculator extends PointCalculator { 4 | constructor () { 5 | super({ 6 | kazoeYakumanAsSanbaiman: true, 7 | kiriageMangan: false 8 | }) 9 | } 10 | } 11 | 12 | export default EmaPointCalculator 13 | -------------------------------------------------------------------------------- /src/core/fu-calculation/fu-calculation-context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu calculation context information. 3 | * 4 | * @typedef {Object} FuCalculationContext 5 | * @property {boolean} stop=false - Flag indicating if the calculator can continue to process other rules 6 | * @property {boolean} rounding=true - Flag indicating if the calculator can round up to the tens the total 7 | * @memberof FuCalculation 8 | */ 9 | -------------------------------------------------------------------------------- /src/core/fu-calculation/fu-calculation-result.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} FuCalculationResult 3 | * @property {FuCalculation.FuInfo[]} details - Details explaining what are the fu sum in the total 4 | * @property {number} total - Total fu value of the hand. Usually round up to the tens (except in a few case like Chiitoitsu). 5 | * @memberof FuCalculation 6 | */ 7 | -------------------------------------------------------------------------------- /src/core/fu-calculation/fu-calculator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for fu calculator implementations.
3 | * This class is intended to be used internally or to create custom fu calculator.

4 | * 5 | * Contains the logic to process a list of rules to aggregate a fu total 6 | * rounded up to the tens (except in a few case like Chiitoitsu).

7 | * 8 | * Real implementation, like WrcFuCalculator, would be better if you just want to get the fu value of a hand. 9 | * 10 | * @memberof FuCalculation 11 | */ 12 | class FuCalculator { 13 | /** 14 | * @param {FuCalculation.FuRule[]} rules - Set of rules to check during the fu calculation process 15 | */ 16 | constructor (rules) { 17 | if (rules == null) throw new Error('rules parameter is required') 18 | 19 | this.rules = rules 20 | } 21 | 22 | /** 23 | * Calculate the fu value of a hand. 24 | * 25 | * @param {Hand} hand 26 | * @returns {FuCalculation.FuCalculationResult} 27 | */ 28 | calculate (hand) { 29 | const context = { stop: false, rounding: true } 30 | const details = [] 31 | 32 | for (const rule of this.rules) { 33 | const result = rule.check(hand, context) 34 | if (result) details.push(...normalizeDetails(result)) 35 | if (context.stop) break 36 | } 37 | 38 | let total = details.reduce((agg, x) => agg + (x.quantity * x.fuValue), 0) 39 | 40 | if (context.rounding) total = Math.ceil(total / 10) * 10 41 | 42 | return { details, total } 43 | } 44 | } 45 | 46 | function normalizeDetails (result) { 47 | return result instanceof Array ? result : [result] 48 | } 49 | 50 | export default FuCalculator 51 | -------------------------------------------------------------------------------- /src/core/fu-calculation/fu-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Information returned by fu calculation rule about why and how much fu the calculator need to add. 3 | * 4 | * @typedef {Object} FuInfo 5 | * @property {string} key - Key to identify why the fu are added 6 | * @property {number} fuValue - Amount of fu added 7 | * @property {number} quantity - Number of time those fu need to be added 8 | * @memberof FuCalculation 9 | */ 10 | -------------------------------------------------------------------------------- /src/core/fu-calculation/fu-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu rule interface 3 | * 4 | * @interface FuRule 5 | * @memberof FuCalculation 6 | */ 7 | 8 | /** 9 | * Function called by the fu calculator to know what fu are allowed according to this rule. 10 | * 11 | * @function 12 | * @name FuCalculation.FuRule#check 13 | * @param {Hand} hand - Hand to apply the rule onto 14 | * @param {FuCalculation.FuCalculationContext} context - Context of the fu calculation process 15 | * @returns {(undefined|FuCalculation.FuInfo|FuCalculation.FuInfo[])} 16 | */ 17 | -------------------------------------------------------------------------------- /src/core/fu-calculation/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace FuCalculation 3 | */ 4 | 5 | export { default as ChiitoitsuFuRule } from './rules/chiitoitsu-fu-rule' 6 | export { default as ClosedRonFuRule } from './rules/closed-ron-fu-rule' 7 | export { default as CombinationsFuRule } from './rules/combinations-fu-rule' 8 | export { default as OpenPinfuFuRule } from './rules/open-pinfu-fu-rule' 9 | export { default as PairFuRule } from './rules/pair-fu-rule' 10 | export { default as TsumoFuRule } from './rules/tsumo-fu-rule' 11 | export { default as WaitFuRule } from './rules/wait-fu-rule' 12 | export { default as WinningFuRule } from './rules/winning-fu-rule' 13 | 14 | export { default as FuCalculator } from './fu-calculator' 15 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/chiitoitsu-fu-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu calculation rule that will attribute a fixed amount of fu (usually 25) for hands with chiitoitsu (seven pairs).

3 | * 4 | * As an exception, the fu value of the hand is not rounded up to the tens because it is worth of fixed amount.

5 | * 6 | * Some rules say seven pairs has 50 fu and one han, especially in the Kansai region.
7 | * https://en.wikipedia.org/wiki/Japanese_Mahjong_scoring_rules#Steps_of_calculation 8 | * 9 | * @implements FuCalculation.FuRule 10 | * @memberof FuCalculation 11 | */ 12 | class ChiitoitsuFuRule { 13 | /** 14 | * @param {Object} options - Configuration options for the rule 15 | * @param {YakuPattern} options.chiitoitsuYakuPattern - Yaku pattern to validate the hand shape 16 | * @param {number} [options.fuValue=25] - Amount of fu to add if the rule is valid 17 | */ 18 | constructor (options) { 19 | if (options?.chiitoitsuYakuPattern == null) throw new Error('chiitoitsuYakuPattern is required') 20 | 21 | this.chiitoitsuYakuPattern = options.chiitoitsuYakuPattern 22 | this.fuValue = options?.fuValue ?? 25 23 | } 24 | 25 | /** @override */ 26 | check (hand, context) { 27 | if (this.chiitoitsuYakuPattern.check(hand)) { 28 | // tell the calculator to stop processing the other rules 29 | // and to not round up the total because this hand is always worth a fixed amount of fu. 30 | context.stop = true 31 | context.rounding = false 32 | return { key: 'chiitoitsu', fuValue: this.fuValue, quantity: 1 } 33 | } 34 | } 35 | } 36 | 37 | export default ChiitoitsuFuRule 38 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/closed-ron-fu-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu calculation rule that will attribute 10 fu for concealed hands won by ron. 3 | * 4 | * @implements FuCalculation.FuRule 5 | * @memberof FuCalculation 6 | */ 7 | class ClosedRonFuRule { 8 | /** @override */ 9 | check ({ isOpen, winningType }) { 10 | if (!isOpen && winningType === 'ron') { 11 | return { key: 'closed ron', fuValue: 10, quantity: 1 } 12 | } 13 | } 14 | } 15 | 16 | export default ClosedRonFuRule 17 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/combinations-fu-rule.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | import { Triplet, Quad } from './../../combination-classes' 3 | 4 | /** 5 | * Fu calculation rule that will attribute fu according to the hand's combinations.

6 | * 7 | * 8 | * 9 | * 10 | * 11 | * 12 | * 13 | *
CombinationSimplesTerminal / Honor
Minkou (Open Triplet)24
Ankou (Concealed Triplet)48
Minkan (Open Quad)816
Ankan (Concealed Quad)1632
14 | * 15 | * @implements FuCalculation.FuRule 16 | * @memberof FuCalculation 17 | */ 18 | class CombinationsFuRule { 19 | /** @override */ 20 | check (hand) { 21 | const result = [] 22 | 23 | const counts = countCombinationType(hand) 24 | 25 | if (counts.minkouSimple > 0) result.push({ key: 'minkou simple', fuValue: 2, quantity: counts.minkouSimple }) 26 | if (counts.minkouNonSimple > 0) result.push({ key: 'minkou non simple', fuValue: 4, quantity: counts.minkouNonSimple }) 27 | if (counts.minkanSimple > 0) result.push({ key: 'minkan simple', fuValue: 8, quantity: counts.minkanSimple }) 28 | if (counts.minkanNonSimple > 0) result.push({ key: 'minkan non simple', fuValue: 16, quantity: counts.minkanNonSimple }) 29 | if (counts.ankouSimple > 0) result.push({ key: 'ankou simple', fuValue: 4, quantity: counts.ankouSimple }) 30 | if (counts.ankouNonSimple > 0) result.push({ key: 'ankou non simple', fuValue: 8, quantity: counts.ankouNonSimple }) 31 | if (counts.ankanSimple > 0) result.push({ key: 'ankan simple', fuValue: 16, quantity: counts.ankanSimple }) 32 | if (counts.ankanNonSimple > 0) result.push({ key: 'ankan non simple', fuValue: 32, quantity: counts.ankanNonSimple }) 33 | 34 | if (result.length > 0) return result 35 | } 36 | } 37 | 38 | function isSimpleTile (tile) { 39 | return !(tile instanceof HonorTile) && !tile.isTerminal() 40 | } 41 | 42 | function countCombinationType (hand) { 43 | let counts = { 44 | minkouSimple: 0, 45 | minkouNonSimple: 0, 46 | ankouSimple: 0, 47 | ankouNonSimple: 0, 48 | minkanSimple: 0, 49 | minkanNonSimple: 0, 50 | ankanSimple: 0, 51 | ankanNonSimple: 0 52 | } 53 | 54 | counts = hand.concealedCombinations.reduce((agg, combination, combinationIndex) => { 55 | if (combination instanceof Triplet) { 56 | if (hand.winningType === 'ron' && hand.winningCombinationIndex === combinationIndex) { 57 | if (isSimpleTile(combination.tiles[0])) { 58 | agg.minkouSimple++ 59 | } else { 60 | agg.minkouNonSimple++ 61 | } 62 | } else { 63 | if (isSimpleTile(combination.tiles[0])) { 64 | agg.ankouSimple++ 65 | } else { 66 | agg.ankouNonSimple++ 67 | } 68 | } 69 | } else if (combination instanceof Quad) { 70 | if (isSimpleTile(combination.tiles[0])) { 71 | agg.ankanSimple++ 72 | } else { 73 | agg.ankanNonSimple++ 74 | } 75 | } 76 | 77 | return agg 78 | }, counts) 79 | 80 | return hand.openCombinations.reduce((agg, combination) => { 81 | if (combination instanceof Triplet) { 82 | if (isSimpleTile(combination.tiles[0])) { 83 | agg.minkouSimple++ 84 | } else { 85 | agg.minkouNonSimple++ 86 | } 87 | } else if (combination instanceof Quad) { 88 | if (isSimpleTile(combination.tiles[0])) { 89 | agg.minkanSimple++ 90 | } else { 91 | agg.minkanNonSimple++ 92 | } 93 | } 94 | 95 | return agg 96 | }, counts) 97 | } 98 | 99 | export default CombinationsFuRule 100 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/open-pinfu-fu-rule.js: -------------------------------------------------------------------------------- 1 | import { Pair, Sequence } from './../../combination-classes' 2 | import { DragonTile } from './../../tile-classes' 3 | import isTankiWait from './../../waits/is-tanki-wait' 4 | import isKanchanWait from './../../waits/is-kanchan-wait' 5 | import isPenchanWait from './../../waits/is-penchan-wait' 6 | 7 | /** 8 | * Fu calculation rule that will attribute fu if the hand was open and won by ron without any fu for the pair, the combinations and the wait. 9 | * 10 | * @implements FuCalculation.FuRule 11 | * @memberof FuCalculation 12 | */ 13 | class OpenPinfuFuRule { 14 | /** @override */ 15 | check (hand) { 16 | if (hand.winningType === 'ron' && hand.isOpen) { 17 | const chiis = hand.combinations.filter(x => x instanceof Sequence) 18 | const pairs = hand.concealedCombinations.filter(x => x instanceof Pair) 19 | 20 | if ( 21 | chiis.length === 4 && 22 | pairs.length === 1 && 23 | 24 | !(pairs[0].tiles[0] instanceof DragonTile) && 25 | pairs[0].tiles[0].value !== hand.roundWind && 26 | pairs[0].tiles[0].value !== hand.seatWind && 27 | 28 | !isTankiWait(hand) && 29 | !isKanchanWait(hand) && 30 | !isPenchanWait(hand) 31 | ) { 32 | return { key: 'open pinfu', fuValue: 10, quantity: 1 } 33 | } 34 | } 35 | } 36 | } 37 | 38 | export default OpenPinfuFuRule 39 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/pair-fu-rule.js: -------------------------------------------------------------------------------- 1 | import { Pair } from './../../combination-classes' 2 | import { DragonTile } from './../../tile-classes' 3 | 4 | /** 5 | * Fu calculation rule that will attribute fu according to the hand's pair.

6 | * 7 | * If the hand's pair is of tiles that would score yakuhai in a koutsu, then this scores 2 fu. 8 | * If the pair doubles up as both the round wind and the seat wind, it may score 2 fu, or 4 fu. 9 | * That is dependent on which scoring rule is used for this case.
10 | * http://arcturus.su/wiki/Fu 11 | * 12 | * @implements FuCalculation.FuRule 13 | * @memberof FuCalculation 14 | */ 15 | class PairFuRule { 16 | /** 17 | * @param {Object} options - Configuration options for the rule 18 | * @param {boolean} [options.stackable=true] - Flag indicating if we can stack the fu for the round wind and seat wind 19 | */ 20 | constructor (options) { 21 | this.stackable = options?.stackable ?? true 22 | } 23 | 24 | /** @override */ 25 | check ({ concealedCombinations, roundWind, seatWind }) { 26 | const pairs = concealedCombinations.filter(x => x instanceof Pair) 27 | 28 | if (pairs.length !== 1) return 29 | 30 | const tile = pairs[0].tiles[0] 31 | 32 | if ( 33 | tile instanceof DragonTile || 34 | tile.value === roundWind || 35 | tile.value === seatWind 36 | ) { 37 | let quantity = 1 38 | 39 | if (this.stackable && tile.value === roundWind && tile.value === seatWind) { 40 | quantity++ 41 | } 42 | 43 | return { key: 'pair', fuValue: 2, quantity } 44 | } 45 | } 46 | } 47 | 48 | export default PairFuRule 49 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/tsumo-fu-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu calculation rule that will attribute 2 fu if the hand was won by tsumo.

3 | * 4 | * Depending of the ruleset, some hands with certain yaku are not eligible. In most ruleset, this is the case for hands with a pinfu yaku.

5 | * 6 | * There is also some ruleset that exclude hands won with Rinshan Kaihou. 7 | * 8 | * @implements FuCalculation.FuRule 9 | * @memberof FuCalculation 10 | */ 11 | class TsumoFuRule { 12 | /** 13 | * @param {Object} options - Configuration options for the rule 14 | * @param {YakuPattern[]} [options.excludedYakuPatterns=[]] - Yaku patterns excluded from getting the fu from this rule. 15 | * (usually Pinfu, but can also be Rinshan Kaihou) 16 | */ 17 | constructor (options) { 18 | this.excludedYakuPatterns = options?.excludedYakuPatterns ?? [] 19 | } 20 | 21 | /** @override */ 22 | check (hand) { 23 | let excluded = false 24 | 25 | for (const yakuPattern of this.excludedYakuPatterns) { 26 | if (yakuPattern.check(hand)) { 27 | excluded = true 28 | break 29 | } 30 | } 31 | 32 | if (!excluded && hand.winningType === 'tsumo') { 33 | return { key: 'tsumo', fuValue: 2, quantity: 1 } 34 | } 35 | } 36 | } 37 | 38 | export default TsumoFuRule 39 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/wait-fu-rule.js: -------------------------------------------------------------------------------- 1 | import isTankiWait from './../../waits/is-tanki-wait' 2 | import isKanchanWait from './../../waits/is-kanchan-wait' 3 | import isPenchanWait from './../../waits/is-penchan-wait' 4 | 5 | /** 6 | * Fu calculation rule that will attribute fu according to the hand's wait.

7 | * 8 | * If the hand was waiting on only one tile, single wait (tanki), closed wait (kanchan) or edge wait (penchan), it is worth 2 fu.
9 | * 10 | * Other waits are worth nothing. 11 | * 12 | * @implements FuCalculation.FuRule 13 | * @memberof FuCalculation 14 | */ 15 | class WaitFuRule { 16 | /** @override */ 17 | check (hand) { 18 | if (isTankiWait(hand) || isKanchanWait(hand) || isPenchanWait(hand)) { 19 | return { key: 'wait', fuValue: 2, quantity: 1 } 20 | } 21 | } 22 | } 23 | 24 | export default WaitFuRule 25 | -------------------------------------------------------------------------------- /src/core/fu-calculation/rules/winning-fu-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fu calculation rule that will attribute a default value of 20 fu for winning a hand. 3 | * 4 | * @implements FuCalculation.FuRule 5 | * @memberof FuCalculation 6 | */ 7 | class WinningFuRule { 8 | /** @override */ 9 | check () { 10 | return { key: 'win', fuValue: 20, quantity: 1 } 11 | } 12 | } 13 | 14 | export default WinningFuRule 15 | -------------------------------------------------------------------------------- /src/core/han-calculation/han-calculation-result.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} HanCalculationResult 3 | * @property {HanCalculation.HanInfo[]} details - Details explaining what are the han eligible for the hand 4 | * @property {number} han - Total han value of the hand. (null if it's a yakuman hand) 5 | * @property {number} yakuman - Total yakuman value of the hand. (null if it's not a yakuman hand) 6 | * @memberof HanCalculation 7 | */ 8 | -------------------------------------------------------------------------------- /src/core/han-calculation/han-calculator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for han calculator implementations.
3 | * This class is intended to be used internally or to create custom han calculator.

4 | * 5 | * Contains the logic to process a list of yaku to aggregate a han or yakuman total

6 | * 7 | * Real implementation, like WrcHanCalculator, would be better if you just want to get the han or yakuman value of a hand. 8 | * 9 | * @memberof HanCalculation 10 | */ 11 | class HanCalculator { 12 | /** 13 | * @param {HanCalculation.Yaku[]} yakus - Set of yaku to check during the han calculation process 14 | */ 15 | constructor (yakus, options) { 16 | if (yakus == null) throw new Error('yakus parameter is required') 17 | 18 | this.yakus = yakus 19 | this.stackableYakuman = options?.stackableYakuman ?? false 20 | } 21 | 22 | /** 23 | * Calculate the han value of a hand. 24 | * 25 | * @param {Hand} hand 26 | * @returns {HanCalculation.HanCalculationResult} 27 | */ 28 | calculate (hand) { 29 | const details = [] 30 | 31 | // process the hand for each yaku 32 | 33 | for (const yaku of this.yakus) { 34 | const result = yaku.check(hand) 35 | if (result) details.push(...normalizeDetails(result)) 36 | } 37 | 38 | // sum the yakuman value of the hand and return early if it's higher than 0 39 | // because we don't need the han value if it's a yakuman hand 40 | 41 | const yakumanDetails = details.filter(x => x.yakumanValue != null && x.yakumanValue > 0) 42 | 43 | if (yakumanDetails.length > 0) { 44 | let yakuman = 0 45 | 46 | if (this.stackableYakuman) { 47 | yakuman = sum(yakumanDetails.map(x => x.yakumanValue)) 48 | } else { 49 | // take the value of the yakuman with the highest value 50 | yakuman = Math.max(...yakumanDetails.map(x => x.yakumanValue)) 51 | } 52 | 53 | return { details: details.filter(x => x.yakumanValue != null && x.yakumanValue > 0), han: null, yakuman } 54 | } 55 | 56 | // if it's not a yakuman hand 57 | // sum the han value of the hand 58 | 59 | let han = sum(details.map(x => x.hanValue)) 60 | 61 | // add the number of dora to the han value of the hand only if the hand have 62 | // already some valid yaku 63 | 64 | if (han > 0 && hand.nbDora > 0) { 65 | han += hand.nbDora 66 | details.push({ key: 'dora', hanValue: hand.nbDora, yakumanValue: 0 }) 67 | } 68 | 69 | return { details, han: han > 0 ? han : null, yakuman: null } 70 | } 71 | } 72 | 73 | function sum (array) { 74 | return array.reduce((agg, x) => { 75 | if (x != null) agg += x 76 | return agg 77 | }, 0) 78 | } 79 | 80 | function normalizeDetails (result) { 81 | return result instanceof Array ? result : [result] 82 | } 83 | 84 | export default HanCalculator 85 | -------------------------------------------------------------------------------- /src/core/han-calculation/han-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Information returned by yaku about why and how much han or yakuman the calculator need to add. 3 | * 4 | * @typedef {Object} HanInfo 5 | * @property {string} key - Key to identify why the han are added 6 | * @property {number} hanValue - Amount of han added 7 | * @property {number} yakumanValue - Amount of yakuman added 8 | * @memberof HanCalculation 9 | */ 10 | -------------------------------------------------------------------------------- /src/core/han-calculation/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace HanCalculation 3 | */ 4 | 5 | export { default as ChankanYaku } from './yakus/chankan-yaku' 6 | export { default as ChantaYaku } from './yakus/chanta-yaku' 7 | export { default as ChiihouYaku } from './yakus/chiihou-yaku' 8 | export { default as ChiitoitsuYaku } from './yakus/chiitoitsu-yaku' 9 | export { default as ChinitsuYaku } from './yakus/chinitsu-yaku' 10 | export { default as ChinroutouYaku } from './yakus/chinroutou-yaku' 11 | export { default as ChuurenPoutouYaku } from './yakus/chuuren-poutou-yaku' 12 | export { default as DaisangenYaku } from './yakus/daisangen-yaku' 13 | export { default as DaisuushiiYaku } from './yakus/daisuushii-yaku' 14 | export { default as DoubleRiichiYaku } from './yakus/double-riichi-yaku' 15 | export { default as HaiteiRaoyueYaku } from './yakus/haitei-raoyue-yaku' 16 | export { default as HonitsuYaku } from './yakus/honitsu-yaku' 17 | export { default as HonroutouYaku } from './yakus/honroutou-yaku' 18 | export { default as HouteiRaoyuiYaku } from './yakus/houtei-raoyui-yaku' 19 | export { default as IipeikouYaku } from './yakus/iipeikou-yaku' 20 | export { default as IppatsuYaku } from './yakus/ippatsu-yaku' 21 | export { default as IttsuuYaku } from './yakus/ittsuu-yaku' 22 | export { default as JunchanYaku } from './yakus/junchan-yaku' 23 | export { default as KokushiMusouYaku } from './yakus/kokushi-musou-yaku' 24 | export { default as MenzenTsumoYaku } from './yakus/menzen-tsumo-yaku' 25 | export { default as OpenRiichiYaku } from './yakus/open-riichi-yaku' 26 | export { default as PinfuYaku } from './yakus/pinfu-yaku' 27 | export { default as RenhouYaku } from './yakus/renhou-yaku' 28 | export { default as RiichiYaku } from './yakus/riichi-yaku' 29 | export { default as RinshanKaihouYaku } from './yakus/rinshan-kaihou-yaku' 30 | export { default as RyanpeikouYaku } from './yakus/ryanpeikou-yaku' 31 | export { default as RyuuiisouYaku } from './yakus/ryuuiisou-yaku' 32 | export { default as SanankouYaku } from './yakus/sanankou-yaku' 33 | export { default as SankantsuYaku } from './yakus/sankantsu-yaku' 34 | export { default as SanshokuDoujunYaku } from './yakus/sanshoku-doujun-yaku' 35 | export { default as SanshokuDoukouYaku } from './yakus/sanshoku-doukou-yaku' 36 | export { default as ShousangenYaku } from './yakus/shousangen-yaku' 37 | export { default as ShousuushiiYaku } from './yakus/shousuushii-yaku' 38 | export { default as SuuankouYaku } from './yakus/suuankou-yaku' 39 | export { default as SuukantsuYaku } from './yakus/suukantsu-yaku' 40 | export { default as TanyaoYaku } from './yakus/tanyao-yaku' 41 | export { default as TenhouYaku } from './yakus/tenhou-yaku' 42 | export { default as ToitoiYaku } from './yakus/toitoi-yaku' 43 | export { default as TsuuiisouYaku } from './yakus/tsuuiisou-yaku' 44 | export { default as YakuhaiYaku } from './yakus/yakuhai-yaku' 45 | 46 | export { default as HanCalculator } from './han-calculator' 47 | -------------------------------------------------------------------------------- /src/core/han-calculation/yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Yaku interface 3 | * 4 | * @interface Yaku 5 | * @memberof HanCalculation 6 | */ 7 | 8 | /** 9 | * Function called by the han calculator to know what han are allowed according to this yaku. 10 | * 11 | * @function 12 | * @name HanCalculation.Yaku#check 13 | * @param {Hand} hand - Hand to check the yaku onto 14 | * @returns {(undefined|HanCalculation.HanInfo|HanCalculation.HanInfo[])} 15 | */ 16 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chankan-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Chankan (Robbing the kan) yaku pattern

3 | * 4 | * Winning on off a tile used to extend a kong.

5 | * 6 | * Must be concealed: no
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class ChankanYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('chankan')) return { key: 'chankan', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default ChankanYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chanta-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | import { HonorTile } from './../../tile-classes' 3 | 4 | /** 5 | * Chanta (outside hand) yaku pattern

6 | * 7 | * A hand where all sets contain a terminal or honor tile, and at least one of the sets is a sequence.

8 | * 9 | * Must be concealed: no
10 | * Han: 2 (concealed) / 1 (open) 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class ChantaYaku { 16 | /** @override */ 17 | check ({ combinations, isOpen }) { 18 | let nbChii = 0 19 | let nbHonorTile = 0 20 | 21 | for (const combination of combinations) { 22 | if (combination instanceof Sequence) nbChii++ 23 | 24 | const nbTerminalOrHonor = combination.tiles.reduce((agg, tile) => { 25 | if (tile instanceof HonorTile) { 26 | agg++ 27 | nbHonorTile++ 28 | } else if (tile.isTerminal()) { 29 | agg++ 30 | } 31 | return agg 32 | }, 0) 33 | 34 | if (nbTerminalOrHonor === 0) return 35 | } 36 | 37 | // without sequence it would be considered a honroutou or chinroutou 38 | // without honor tile it would be considered a junchan 39 | 40 | if (nbChii && nbHonorTile) { 41 | return { key: 'chanta', hanValue: isOpen ? 1 : 2, yakumanValue: 0 } 42 | } 43 | } 44 | } 45 | 46 | export default ChantaYaku 47 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chiihou-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Chiihou (Hand of earth)

3 | * 4 | * A hand won by a non-dealer on his first draw when no open-meld declaration has been made

5 | * 6 | * Must be concealed: yes
7 | * Yakuman: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class ChiihouYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('chiihou')) return { key: 'chiihou', hanValue: 0, yakumanValue: 1 } 16 | } 17 | } 18 | 19 | export default ChiihouYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chiitoitsu-yaku.js: -------------------------------------------------------------------------------- 1 | import { Pair } from './../../combination-classes' 2 | 3 | /** 4 | * Chiitoitsu (seven pairs) yaku pattern

5 | * 6 | * A hand consisting of seven pairs

7 | * 8 | * Must be concealed: yes
9 | * Han: 2 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class ChiitoitsuYaku { 15 | /** @override */ 16 | check ({ concealedCombinations }) { 17 | const nbPair = concealedCombinations.reduce((agg, combination) => { 18 | if (combination instanceof Pair) agg++ 19 | return agg 20 | }, 0) 21 | 22 | if (nbPair === 7) return { key: 'chiitoitsu', hanValue: 2, yakumanValue: 0 } 23 | } 24 | } 25 | 26 | export default ChiitoitsuYaku 27 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chinitsu-yaku.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | 3 | /** 4 | * Chinitsu (full flush) yaku pattern

5 | * 6 | * A hand with tiles from only one suit

7 | * 8 | * Must be concealed: no
9 | * Han: 6 (concealed) / 5 (open) 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class ChinitsuYaku { 15 | /** @override */ 16 | check ({ combinations, isOpen }) { 17 | let suit = null 18 | 19 | for (const combination of combinations) { 20 | for (const tile of combination.tiles) { 21 | if (tile instanceof HonorTile) return 22 | 23 | if (suit == null) { 24 | suit = tile.suit 25 | } else if (suit !== tile.suit) { 26 | return 27 | } 28 | } 29 | } 30 | 31 | return { key: 'chinitsu', hanValue: isOpen ? 5 : 6, yakumanValue: 0 } 32 | } 33 | } 34 | 35 | export default ChinitsuYaku 36 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chinroutou-yaku.js: -------------------------------------------------------------------------------- 1 | import { NumberedTile } from './../../tile-classes' 2 | 3 | /** 4 | * Chin Routou (All Terminals)

5 | * 6 | * A hand with only terminal tiles (1 and 9)

7 | * 8 | * Must be concealed: no
9 | * Yakuman: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class ChinroutouYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | for (const combination of combinations) { 18 | for (const tile of combination.tiles) { 19 | if (!(tile instanceof NumberedTile && tile.isTerminal())) { 20 | return 21 | } 22 | } 23 | } 24 | 25 | return { key: 'chinroutou', hanValue: 0, yakumanValue: 1 } 26 | } 27 | } 28 | 29 | export default ChinroutouYaku 30 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/chuuren-poutou-yaku.js: -------------------------------------------------------------------------------- 1 | import { NumberedTile } from './../../tile-classes' 2 | 3 | /** 4 | * Chuuren Poutou (Nine Gates)

5 | * 6 | * A hand with (1-1-1-2-3-4-5-6-7-8-9-9-9) of the same suit and an other tile of the same suit
7 | * Worth two yakuman if the hand was waiting on nine different tiles (Junsei Chuuren Poutou)

8 | * 9 | * Must be concealed: yes
10 | * Yakuman: 1 / 2 (Waiting on nine different tiles) 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class ChuurenPoutouYaku { 16 | constructor (options) { 17 | this.allowDoubleYakuman = options?.allowDoubleYakuman ?? false 18 | } 19 | 20 | /** @override */ 21 | check ({ concealedCombinations, winningCombinationIndex, winningTileIndex }) { 22 | let suit = null 23 | 24 | const numbers = concealedCombinations.reduce((numbers, combination) => { 25 | combination.tiles.forEach(tile => { 26 | if (suit == null) suit = tile.suit 27 | 28 | if (tile.suit === suit && tile instanceof NumberedTile) { 29 | numbers[tile.number]++ 30 | } 31 | }) 32 | return numbers 33 | }, { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0 }) 34 | 35 | if (!containsAllChuurenPoutouNumbers(numbers)) return 36 | 37 | numbers[concealedCombinations[winningCombinationIndex].tiles[winningTileIndex].number]-- 38 | 39 | const yakumanValue = containsAllChuurenPoutouNumbers(numbers) && this.allowDoubleYakuman ? 2 : 1 40 | 41 | return { key: 'chuuren poutou', hanValue: 0, yakumanValue } 42 | } 43 | } 44 | 45 | function containsAllChuurenPoutouNumbers (numbers) { 46 | return numbers[1] > 2 && numbers[9] > 2 && numbers[2] && numbers[3] && numbers[4] && numbers[5] && numbers[6] && numbers[7] && numbers[8] 47 | } 48 | 49 | export default ChuurenPoutouYaku 50 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/daisangen-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | import { DragonTile } from './../../tile-classes' 3 | 4 | /** 5 | * Dai Sangen (Big Three Dragons)

6 | * 7 | * A hand with a triplet or quad of each type of dragon tile

8 | * 9 | * Must be concealed: no
10 | * Yakuman: 1 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class DaisangenYaku { 16 | /** @override */ 17 | check ({ combinations }) { 18 | const nbOfDragonPonOrKan = combinations.filter(x => (x instanceof Triplet || x instanceof Quad) && x.tiles[0] instanceof DragonTile).length 19 | 20 | if (nbOfDragonPonOrKan === 3) return { key: 'daisangen', hanValue: 0, yakumanValue: 1 } 21 | } 22 | } 23 | 24 | export default DaisangenYaku 25 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/daisuushii-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | import { WindTile } from './../../tile-classes' 3 | 4 | /** 5 | * Dai Suushii (Big Four Winds)

6 | * 7 | * A hand with four triplet/quad of winds

8 | * 9 | * Must be concealed: no
10 | * Yakuman: 2 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class DaisuushiiYaku { 16 | constructor (options) { 17 | this.allowDoubleYakuman = options?.allowDoubleYakuman ?? false 18 | } 19 | 20 | /** @override */ 21 | check ({ combinations }) { 22 | const nbOfWindPonOrKan = combinations.filter(x => (x instanceof Triplet || x instanceof Quad) && x.tiles[0] instanceof WindTile).length 23 | 24 | if (nbOfWindPonOrKan === 4) return { key: 'daisuushii', hanValue: 0, yakumanValue: this.allowDoubleYakuman ? 2 : 1 } 25 | } 26 | } 27 | 28 | export default DaisuushiiYaku 29 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/double-riichi-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Double Riichi yaku pattern

3 | * 4 | * Declaring riichi within the first uninterrupted go around.

5 | * 6 | * Must be concealed: yes
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class DoubleRiichiYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('double riichi')) return { key: 'double riichi', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default DoubleRiichiYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/haitei-raoyue-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Haitei Raoyue (Last Tile Draw) yaku pattern

3 | * 4 | * Winning on the very last tile

5 | * 6 | * Must be concealed: no
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class HaiteiRaoyueYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('haitei raoyue')) return { key: 'haitei raoyue', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default HaiteiRaoyueYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/honitsu-yaku.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | 3 | /** 4 | * Honitsu (half flush) yaku pattern

5 | * 6 | * A hand with tiles from only one suit plus honor tiles

7 | * 8 | * Must be concealed: no
9 | * Han: 3 (concealed) / 2 (open) 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class HonitsuYaku { 15 | /** @override */ 16 | check ({ combinations, isOpen }) { 17 | let nbHonorTile = 0 18 | let suit = null 19 | 20 | for (const combination of combinations) { 21 | for (const tile of combination.tiles) { 22 | if (tile instanceof HonorTile) { 23 | nbHonorTile++ 24 | } else if (suit == null) { 25 | suit = tile.suit 26 | } else if (suit !== tile.suit) { 27 | return 28 | } 29 | } 30 | } 31 | 32 | if (nbHonorTile === 0) return // would be a chinitsu (full flush) instead 33 | 34 | return { key: 'honitsu', hanValue: isOpen ? 2 : 3, yakumanValue: 0 } 35 | } 36 | } 37 | 38 | export default HonitsuYaku 39 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/honroutou-yaku.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | 3 | /** 4 | * Honroutou (all terminals & honors) yaku pattern

5 | * 6 | * A hand consisting of only terminals and honors

7 | * 8 | * Must be concealed: no
9 | * Han: 2 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class HonroutouYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | for (const combination of combinations) { 18 | for (const tile of combination.tiles) { 19 | if (!(tile instanceof HonorTile || tile.isTerminal())) return 20 | } 21 | } 22 | 23 | return { key: 'honroutou', hanValue: 2, yakumanValue: 0 } 24 | } 25 | } 26 | 27 | export default HonroutouYaku 28 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/houtei-raoyui-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Houtei Raoyui (Last Tile Discard) yaku pattern

3 | * 4 | * Winning on the very last discard

5 | * 6 | * Must be concealed: no
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class HouteiRaoyuiYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('houtei raoyui')) return { key: 'houtei raoyui', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default HouteiRaoyuiYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/iipeikou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | 3 | /** 4 | * Iipeikou (pure double sequence) yaku pattern

5 | * 6 | * Two chiis of the same value and suit

7 | * 8 | * Must be concealed: yes
9 | * Han: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class IipeikouYaku { 15 | /** @override */ 16 | check ({ combinations, isOpen }) { 17 | if (isOpen) return 18 | 19 | const storedChiis = [] 20 | let numberOfIdenticalChiis = 0 21 | 22 | for (const combination of combinations) { 23 | if (combination instanceof Sequence) { 24 | const identicalChiis = storedChiis.filter(x => x.tiles[0].suit === combination.tiles[0].suit && x.tiles[0].value === combination.tiles[0].value) 25 | if (identicalChiis.length > 0) numberOfIdenticalChiis++ 26 | storedChiis.push(combination) 27 | } 28 | } 29 | 30 | if (numberOfIdenticalChiis === 1) return { key: 'iipeikou', hanValue: 1, yakumanValue: 0 } 31 | } 32 | } 33 | 34 | export default IipeikouYaku 35 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/ippatsu-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ippatsu (One Shot) yaku pattern

3 | * 4 | * Winning within the first uninterrupted go around after declaring riichi.

5 | * 6 | * Must be concealed: yes
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class IppatsuYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('ippatsu')) return { key: 'ippatsu', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default IppatsuYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/ittsuu-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | 3 | /** 4 | * Ittsu or Ikkitsuukan (pure straight) yaku pattern

5 | * 6 | * Three consecutive chiis (1-9) in the same suit

7 | * 8 | * Must be concealed: no
9 | * Han: 2 (concealed) / 1 (open) 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class IttsuuYaku { 15 | /** @override */ 16 | check ({ combinations, isOpen }) { 17 | const chiiCount = {} 18 | 19 | for (const combination of combinations) { 20 | if (combination instanceof Sequence) { 21 | const tile = combination.tiles[0] 22 | 23 | if (chiiCount[tile.suit] == null) { 24 | chiiCount[tile.suit] = { 1: 0, 4: 0, 7: 0 } 25 | } 26 | 27 | const count = chiiCount[tile.suit] 28 | 29 | count[tile.number]++ 30 | 31 | if (count[1] && count[4] && count[7]) { 32 | return { key: 'ittsuu', hanValue: isOpen ? 1 : 2, yakumanValue: 0 } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | export default IttsuuYaku 40 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/junchan-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | import { NumberedTile } from './../../tile-classes' 3 | 4 | /** 5 | * Junchan or Junchan Taiyai or Junchan Tayao (terminals in all sets) yaku pattern

6 | * 7 | * A hand with at least one sequence and where all sets and the pair contains terminals

8 | * 9 | * Must be concealed: no
10 | * Han: 3 (concealed) / 2 (open) 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class JunchanYaku { 16 | /** @override */ 17 | check ({ combinations, isOpen }) { 18 | let nbChii = 0 19 | 20 | for (const combination of combinations) { 21 | if (combination instanceof Sequence) nbChii++ 22 | 23 | const nbTerminal = combination.tiles.reduce((agg, tile) => { 24 | if (tile instanceof NumberedTile && tile.isTerminal()) agg++ 25 | return agg 26 | }, 0) 27 | 28 | if (nbTerminal === 0) return 29 | } 30 | 31 | if (nbChii > 0) return { key: 'junchan', hanValue: isOpen ? 2 : 3, yakumanValue: 0 } 32 | } 33 | } 34 | 35 | export default JunchanYaku 36 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/kokushi-musou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Pair, Orphan } from './../../combination-classes' 2 | import { HonorTile } from './../../tile-classes' 3 | 4 | /** 5 | * Kokushi Musou (Thirteen Orphans)

6 | * 7 | * A hand with one of each dragon, wind and 1 and 9 of each suit, plus an other tile to make a pair
8 | * Worth two yakuman if the hand was waiting on 13 different tiles (Kokushi Musou 13 men machi)

9 | * 10 | * Must be concealed: yes
11 | * Yakuman: 1 / 2 (Waiting on 13 different tiles) 12 | * 13 | * @implements HanCalculation.Yaku 14 | * @memberof HanCalculation 15 | */ 16 | class KokushiMusouYaku { 17 | constructor (options) { 18 | this.allowDoubleYakuman = options?.allowDoubleYakuman ?? false 19 | } 20 | 21 | /** @override */ 22 | check ({ concealedCombinations, winningCombination }) { 23 | const nbOfPair = concealedCombinations.filter(x => x instanceof Pair).length 24 | if (nbOfPair === 0) return 25 | 26 | const nbOfOrphan = concealedCombinations.filter(x => x instanceof Orphan).length 27 | if (nbOfOrphan !== 12) return 28 | 29 | const nbOfValidDistinctTile = concealedCombinations.reduce((tiles, combination) => { 30 | combination.tiles.forEach(tile => tiles.push(tile)) 31 | return tiles 32 | }, []) 33 | .filter(tile => tile instanceof HonorTile || tile.isTerminal()) 34 | .map(tile => `${tile.suit}.${tile.value}`) 35 | .filter((key, index, keys) => keys.indexOf(key) === index) 36 | .length 37 | 38 | if (nbOfValidDistinctTile !== 13) return 39 | 40 | const yakumanValue = winningCombination instanceof Pair && this.allowDoubleYakuman ? 2 : 1 41 | 42 | return { key: 'kokushi musou', hanValue: 0, yakumanValue } 43 | } 44 | } 45 | 46 | export default KokushiMusouYaku 47 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/menzen-tsumo-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Menzen Tsumo (Fully Concealed Hand) yaku pattern

3 | * 4 | * Going out on self-draw with a concealed hand.

5 | * 6 | * Must be concealed: yes
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class MenzenTsumoYaku { 13 | /** @override */ 14 | check ({ winningType, isOpen }) { 15 | if (winningType === 'tsumo' && !isOpen) { 16 | return { key: 'menzen tsumo', hanValue: 1, yakumanValue: 0 } 17 | } 18 | } 19 | } 20 | 21 | export default MenzenTsumoYaku 22 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/open-riichi-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Open Riichi optional yaku pattern

3 | * 4 | * The principle works exactly in the same way as the original riichi. However, there is an added bonus of 1-han attached, which is earned by revealing either the hand or the tile waits.

5 | * 6 | * Must be concealed: yes
7 | * Han: 1 (or yakuman if ron in some ruleset) 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class OpenRiichiYaku { 13 | constructor (options) { 14 | this.ronAsYakuman = options?.ronAsYakuman ?? false 15 | } 16 | 17 | /** @override */ 18 | check ({ yakus, winningType }) { 19 | if (yakus.includes('open riichi')) { 20 | if (winningType === 'ron' && this.ronAsYakuman) return { key: 'open riichi', hanValue: 0, yakumanValue: 1 } 21 | return { key: 'open riichi', hanValue: 1, yakumanValue: 0 } 22 | } 23 | } 24 | } 25 | 26 | export default OpenRiichiYaku 27 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/pinfu-yaku.js: -------------------------------------------------------------------------------- 1 | import { Pair, Sequence } from './../../combination-classes' 2 | import { DragonTile, WindTile } from './../../tile-classes' 3 | import isTankiWait from './../../waits/is-tanki-wait' 4 | import isKanchanWait from './../../waits/is-kanchan-wait' 5 | import isPenchanWait from './../../waits/is-penchan-wait' 6 | 7 | /** 8 | * Pinfu (All sequence / No points) yaku pattern

9 | * 10 | * A hand with no fu except the one for winning
11 | * Just sequence, no pair point (dragon or seat/prevalent wind) and a two-sided wait (only wait that give no fu)

12 | * 13 | * Must be concealed: yes
14 | * Han: 1 15 | * 16 | * @implements HanCalculation.Yaku 17 | * @memberof HanCalculation 18 | */ 19 | class PinfuYaku { 20 | /** @override */ 21 | check (hand) { 22 | if ( 23 | hand.isOpen || 24 | isTankiWait(hand) || 25 | isKanchanWait(hand) || 26 | isPenchanWait(hand) 27 | ) return 28 | 29 | const pairs = hand.concealedCombinations.filter(x => x instanceof Pair) 30 | const chiis = hand.concealedCombinations.filter(x => x instanceof Sequence) 31 | 32 | if (pairs.length !== 1 || chiis.length !== 4) return 33 | 34 | const pairTile = pairs[0].tiles[0] 35 | 36 | if ( 37 | pairTile instanceof DragonTile || 38 | (pairTile instanceof WindTile && (pairTile.value === hand.roundWind || pairTile.value === hand.seatWind)) 39 | ) return 40 | 41 | return { key: 'pinfu', hanValue: 1, yakumanValue: 0 } 42 | } 43 | } 44 | 45 | export default PinfuYaku 46 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/renhou-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Renhou (Hand of man) yaku pattern

3 | * 4 | * A hand won by a non-dealer with a discard tile on his first round without open-meld

5 | * 6 | * Must be concealed: yes
7 | * Han: usually 5 but some rule make it a yakuman 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class RenhouYaku { 13 | /** 14 | * @param {Object} options - Configuration options for the yaku 15 | * @param {number} [options.hanValue=5] - Han value return if the yaku is valid 16 | * @param {number} [options.yakumanValue=0] - Yakuman value return if the yaku is valid 17 | */ 18 | constructor (options) { 19 | this.hanValue = options?.hanValue ?? 5 20 | this.yakumanValue = options?.yakumanValue ?? 0 21 | } 22 | 23 | /** @override */ 24 | check ({ yakus }) { 25 | if (yakus.includes('renhou')) return { key: 'renhou', hanValue: this.hanValue, yakumanValue: this.yakumanValue } 26 | } 27 | } 28 | 29 | export default RenhouYaku 30 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/riichi-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Riichi yaku pattern

3 | * 4 | * Waiting hand with declaration and 1000 point buy in.

5 | * 6 | * Must be concealed: yes
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class RiichiYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('riichi')) return { key: 'riichi', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default RiichiYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/rinshan-kaihou-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rinshan Kaihou (After Kan) yaku pattern

3 | * 4 | * Winning after drawing a replacement tile.
7 | * Han: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class RinshanKaihouYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('rinshan kaihou')) return { key: 'rinshan kaihou', hanValue: 1, yakumanValue: 0 } 16 | } 17 | } 18 | 19 | export default RinshanKaihouYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/ryanpeikou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | 3 | /** 4 | * Ryanpeikou (twice pure double chiis) yaku pattern

5 | * 6 | * Two pair of chiis, where each pair consists of two identical chiis.

7 | * 8 | * Must be concealed: yes (some rules say no)
9 | * Han: 3 (2 if open and the rule accept it) 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class RyanpeikouYaku { 15 | /** 16 | * @param {Object} options - Configuration options for the yaku 17 | * @param {boolean} [options.allowOpen=true] - Flag indicating if the yaku is valid with an open hand (kuitan) 18 | */ 19 | constructor (options) { 20 | this.allowOpen = options?.allowOpen ?? false 21 | } 22 | 23 | /** @override */ 24 | check ({ combinations, isOpen }) { 25 | if (!this.allowOpen && isOpen) return 26 | 27 | const groupsOfIdenticalSequence = combinations.filter(combination => combination instanceof Sequence).reduce((agg, combination) => { 28 | const key = combination.tiles[0].number + combination.tiles[0].suit 29 | const index = agg.findIndex(x => x.key === key) 30 | if (index === -1) { 31 | agg.push({ key, items: [combination] }) 32 | } else { 33 | agg[index].items.push(combination) 34 | } 35 | return agg 36 | }, []) 37 | 38 | if (groupsOfIdenticalSequence.filter(x => x.items.length > 1).length === 2) { 39 | return { key: 'ryanpeikou', hanValue: isOpen ? 2 : 3, yakumanValue: 0 } 40 | } 41 | } 42 | } 43 | 44 | export default RyanpeikouYaku 45 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/ryuuiisou-yaku.js: -------------------------------------------------------------------------------- 1 | import { DragonTile, BambooTile } from './../../tile-classes' 2 | 3 | /** 4 | * Ryuu Iisou (All Green)

5 | * 6 | * A hand with only green tiles (2, 3, 4, 6, 8 of bamboo and green dragons)

7 | * 8 | * Must be concealed: no
9 | * Yakuman: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class RyuuiisouYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | for (const combination of combinations) { 18 | for (const tile of combination.tiles) { 19 | if (!( 20 | (tile instanceof DragonTile && tile.color === 'green') || 21 | (tile instanceof BambooTile && [2, 3, 4, 6, 8].includes(tile.number)) 22 | )) { 23 | return 24 | } 25 | } 26 | } 27 | 28 | return { key: 'ryuuiisou', hanValue: 0, yakumanValue: 1 } 29 | } 30 | } 31 | 32 | export default RyuuiisouYaku 33 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/sanankou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | 3 | /** 4 | * San Ankou (3 concealed pons) yaku pattern

5 | * 6 | * A hand with three concealed pons or kans.

7 | * 8 | * Must be concealed: no
9 | * Han: 2 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class SanankouYaku { 15 | /** @override */ 16 | check ({ concealedCombinations, winningCombinationIndex, winningType }) { 17 | const nbAnkouOrAnkan = concealedCombinations.reduce((agg, combination, combinationIndex) => { 18 | if ( 19 | (combination instanceof Triplet || combination instanceof Quad) && 20 | (combinationIndex !== winningCombinationIndex || winningType === 'tsumo') 21 | ) { 22 | agg++ 23 | } 24 | 25 | return agg 26 | }, 0) 27 | 28 | if (nbAnkouOrAnkan >= 3) return { key: 'sanankou', hanValue: 2, yakumanValue: 0 } 29 | } 30 | } 31 | 32 | export default SanankouYaku 33 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/sankantsu-yaku.js: -------------------------------------------------------------------------------- 1 | import { Quad } from './../../combination-classes' 2 | 3 | /** 4 | * San Kantsu (3 kans) yaku pattern

5 | * 6 | * A hand with three kans.

7 | * 8 | * Must be concealed: no
9 | * Han: 2 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class SankantsuYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | const nbKan = combinations.filter(x => x instanceof Quad).length 18 | if (nbKan === 3) return { key: 'sankantsu', hanValue: 2, yakumanValue: 0 } 19 | } 20 | } 21 | 22 | export default SankantsuYaku 23 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/sanshoku-doujun-yaku.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../../combination-classes' 2 | 3 | /** 4 | * San Shoku Doujun (mixed triple sequence) yaku pattern

5 | * 6 | * Three chiis of the same value, with one in each suit

7 | * 8 | * Must be concealed: no
9 | * Han: 2 (concealed) / 1 (open) 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class SanshokuDoujunYaku { 15 | /** @override */ 16 | check ({ combinations, isOpen }) { 17 | const chiiCount = {} 18 | 19 | for (const combination of combinations) { 20 | if (combination instanceof Sequence) { 21 | const tile = combination.tiles[0] 22 | 23 | if (chiiCount[tile.number] == null) { 24 | chiiCount[tile.number] = { dot: 0, bamboo: 0, character: 0 } 25 | } 26 | 27 | const count = chiiCount[tile.number] 28 | 29 | count[tile.suit]++ 30 | 31 | if (count.dot && count.bamboo && count.character) { 32 | return { key: 'sanshoku doujun', hanValue: isOpen ? 1 : 2, yakumanValue: 0 } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | export default SanshokuDoujunYaku 40 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/sanshoku-doukou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | import { NumberedTile } from './../../tile-classes' 3 | 4 | /** 5 | * San Shoku Doukou (triple triplet) yaku pattern

6 | * 7 | * One triplet or quad in each of the three suits, all having the same number.

8 | * 9 | * Must be concealed: no
10 | * Han: 2 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class SanshokuDokouYaku { 16 | /** @override */ 17 | check (hand) { 18 | const storedPons = {} 19 | 20 | for (const combination of hand.combinations) { 21 | if (combination instanceof Triplet || combination instanceof Quad) { 22 | const tile = combination.tiles[0] 23 | 24 | if (tile instanceof NumberedTile) { 25 | if (storedPons[tile.number] == null) storedPons[tile.number] = 0 26 | 27 | storedPons[tile.number]++ 28 | 29 | if (storedPons[tile.number] === 3) return { key: 'sanshoku doukou', hanValue: 2, yakumanValue: 0 } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | export default SanshokuDokouYaku 37 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/shousangen-yaku.js: -------------------------------------------------------------------------------- 1 | import { Pair, Triplet, Quad } from './../../combination-classes' 2 | import { DragonTile } from './../../tile-classes' 3 | 4 | /** 5 | * Shou Sangen (little three dragons) yaku pattern

6 | * 7 | * Two pons/kans of dragons plus one pair of dragons.

8 | * 9 | * Must be concealed: no
10 | * Han: 2 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class ShousangenYaku { 16 | /** @override */ 17 | check ({ combinations }) { 18 | const dragonCombinationCount = combinations.reduce((agg, combination) => { 19 | const tile = combination.tiles[0] 20 | 21 | if (tile instanceof DragonTile) { 22 | if (combination instanceof Pair) { 23 | agg.pair++ 24 | } else if (combination instanceof Triplet || combination instanceof Quad) { 25 | agg.ponOrKan++ 26 | } 27 | } 28 | 29 | return agg 30 | }, { pair: 0, ponOrKan: 0 }) 31 | 32 | if (dragonCombinationCount.pair === 1 && dragonCombinationCount.ponOrKan === 2) { 33 | return { key: 'shousangen', hanValue: 2, yakumanValue: 0 } 34 | } 35 | } 36 | } 37 | 38 | export default ShousangenYaku 39 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/shousuushii-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad, Pair } from './../../combination-classes' 2 | import { WindTile } from './../../tile-classes' 3 | 4 | /** 5 | * Shou Suushii (Little Four Winds)

6 | * 7 | * A hand with three triplet/quad of winds and a pair of the fourth wind

8 | * 9 | * Must be concealed: no
10 | * Yakuman: 1 11 | * 12 | * @implements HanCalculation.Yaku 13 | * @memberof HanCalculation 14 | */ 15 | class ShousuushiiYaku { 16 | /** @override */ 17 | check ({ combinations }) { 18 | const nbOfWindPonOrKan = combinations.filter(x => (x instanceof Triplet || x instanceof Quad) && x.tiles[0] instanceof WindTile).length 19 | const nbOfWindPair = combinations.filter(x => x instanceof Pair && x.tiles[0] instanceof WindTile).length 20 | 21 | if (nbOfWindPonOrKan === 3 && nbOfWindPair === 1) { 22 | return { key: 'shousuushii', hanValue: 0, yakumanValue: 1 } 23 | } 24 | } 25 | } 26 | 27 | export default ShousuushiiYaku 28 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/suuankou-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | import isTankiWait from './../../waits/is-tanki-wait' 3 | 4 | /** 5 | * Suu Ankou (Four concealed pons)

6 | * 7 | * A hand that has four closed pons/kans
8 | * In the case of a single-tile wait for the pair, the tile can either be self-drawn or won from another player's discard, and it is worth two yakuman

9 | * 10 | * Must be concealed: yes
11 | * Yakuman: 1 (not single wait) / 2 (single wait) 12 | * 13 | * @implements HanCalculation.Yaku 14 | * @memberof HanCalculation 15 | */ 16 | class SuuankouYaku { 17 | constructor (options) { 18 | this.allowDoubleYakuman = options?.allowDoubleYakuman ?? false 19 | } 20 | 21 | /** @override */ 22 | check (hand) { 23 | const nbOfConcealedPonOrKan = hand.concealedCombinations.filter((x, i) => 24 | (x instanceof Triplet || x instanceof Quad) && 25 | (i !== hand.winningCombinationIndex || hand.winningType === 'tsumo') 26 | ).length 27 | 28 | if (nbOfConcealedPonOrKan === 4) { 29 | return { key: 'suuankou', hanValue: 0, yakumanValue: isTankiWait(hand) && this.allowDoubleYakuman ? 2 : 1 } 30 | } 31 | } 32 | } 33 | 34 | export default SuuankouYaku 35 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/suukantsu-yaku.js: -------------------------------------------------------------------------------- 1 | import { Quad } from './../../combination-classes' 2 | 3 | /** 4 | * Suu Kantsu (Four kans)

5 | * 6 | * A hand with four kans, which can be open or concealed

7 | * 8 | * Must be concealed: no
9 | * Yakuman: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class SuukantsuYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | const nbOfKan = combinations.filter(x => x instanceof Quad).length 18 | if (nbOfKan === 4) return { key: 'suukantsu', hanValue: 0, yakumanValue: 1 } 19 | } 20 | } 21 | 22 | export default SuukantsuYaku 23 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/tanyao-yaku.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | 3 | /** 4 | * Tanyao (all simples) yaku pattern

5 | * 6 | * A hand consisting only of suit tiles 2-8 (without terminal or honor tiles)

7 | * 8 | * Must be concealed : no (some rules say yes)
9 | * Han : 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class TanyaoYaku { 15 | /** 16 | * @param {Object} options - Configuration options for the yaku 17 | * @param {boolean} [options.allowOpen=true] - Flag indicating if the yaku is valid with an open hand (kuitan) 18 | */ 19 | constructor (options) { 20 | this.allowOpen = options?.allowOpen ?? true 21 | } 22 | 23 | /** @override */ 24 | check ({ combinations, isOpen }) { 25 | if (!this.allowOpen && isOpen) return 26 | 27 | for (const combination of combinations) { 28 | for (const tile of combination.tiles) { 29 | if (tile instanceof HonorTile || tile.isTerminal()) return 30 | } 31 | } 32 | 33 | return { key: 'tanyao', hanValue: 1, yakumanValue: 0 } 34 | } 35 | } 36 | 37 | export default TanyaoYaku 38 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/tenhou-yaku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tenhou (Heavenly Hand)

3 | * 4 | * A hand won by the dealer on his first draw

5 | * 6 | * Must be concealed: yes
7 | * Yakuman: 1 8 | * 9 | * @implements HanCalculation.Yaku 10 | * @memberof HanCalculation 11 | */ 12 | class TenhouYaku { 13 | /** @override */ 14 | check ({ yakus }) { 15 | if (yakus.includes('tenhou')) return { key: 'tenhou', hanValue: 0, yakumanValue: 1 } 16 | } 17 | } 18 | 19 | export default TenhouYaku 20 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/toitoi-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | 3 | /** 4 | * Toitoi (all pons) yaku pattern

5 | 6 | * A hand with four pons/kans and one pair.

7 | * 8 | * Must be concealed: no
9 | * Han: 2 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class ToitoiYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | const nbPonOrKan = combinations.reduce((agg, combination) => { 18 | if (combination instanceof Triplet || combination instanceof Quad) agg++ 19 | return agg 20 | }, 0) 21 | 22 | if (nbPonOrKan === 4) return { key: 'toitoi', hanValue: 2, yakumanValue: 0 } 23 | } 24 | } 25 | 26 | export default ToitoiYaku 27 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/tsuuiisou-yaku.js: -------------------------------------------------------------------------------- 1 | import { HonorTile } from './../../tile-classes' 2 | 3 | /** 4 | * Tsuu Iisou (All Honors)

5 | * 6 | * A hand with only honor tiles (dragons and winds)

7 | * 8 | * Must be concealed: no
9 | * Yakuman: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class TsuuiisouYaku { 15 | /** @override */ 16 | check ({ combinations }) { 17 | for (const combination of combinations) { 18 | for (const tile of combination.tiles) { 19 | if (!(tile instanceof HonorTile)) return 20 | } 21 | } 22 | 23 | return { key: 'tsuuiisou', hanValue: 0, yakumanValue: 1 } 24 | } 25 | } 26 | 27 | export default TsuuiisouYaku 28 | -------------------------------------------------------------------------------- /src/core/han-calculation/yakus/yakuhai-yaku.js: -------------------------------------------------------------------------------- 1 | import { Triplet, Quad } from './../../combination-classes' 2 | 3 | /** 4 | * Yakuhai yaku pattern

5 | * 6 | * A triplet or quad of dragon, round wind or seat wind

7 | * 8 | * Must be concealed: no
9 | * Han: 1 10 | * 11 | * @implements HanCalculation.Yaku 12 | * @memberof HanCalculation 13 | */ 14 | class YakuhaiYaku { 15 | /** @override */ 16 | check ({ combinations, roundWind, seatWind }) { 17 | const yaku = combinations.reduce((agg, combination) => { 18 | if (combination instanceof Triplet || combination instanceof Quad) { 19 | const tile = combination.tiles[0] 20 | 21 | if (tile.value === 'red') agg.push({ key: 'chun', hanValue: 1, yakumanValue: 0 }) 22 | else if (tile.value === 'green') agg.push({ key: 'hatsu', hanValue: 1, yakumanValue: 0 }) 23 | else if (tile.value === 'white') agg.push({ key: 'haku', hanValue: 1, yakumanValue: 0 }) 24 | else if (tile.value === roundWind || tile.value === seatWind) { 25 | agg.push({ key: mapWindKey(tile.value), hanValue: roundWind === seatWind ? 2 : 1, yakumanValue: 0 }) 26 | } 27 | } 28 | 29 | return agg 30 | }, []) 31 | 32 | if (yaku.length > 0) return yaku 33 | } 34 | } 35 | 36 | function mapWindKey (wind) { 37 | if (wind === 'east') return 'ton' 38 | else if (wind === 'south') return 'nan' 39 | else if (wind === 'west') return 'xia' 40 | else if (wind === 'north') return 'pei' 41 | } 42 | 43 | export default YakuhaiYaku 44 | -------------------------------------------------------------------------------- /src/core/hand.js: -------------------------------------------------------------------------------- 1 | class Hand { 2 | constructor (options) { 3 | this.concealedCombinations = options?.concealedCombinations ?? [] 4 | this.openCombinations = options?.openCombinations ?? [] 5 | 6 | this.winningType = options?.winningType ?? 'tsumo' 7 | this.seatWind = options?.seatWind ?? 'east' 8 | this.roundWind = options?.roundWind ?? 'east' 9 | 10 | this.winningCombinationIndex = options?.winningCombinationIndex ?? null 11 | this.winningTileIndex = options?.winningTileIndex ?? null 12 | 13 | this.nbDora = options?.nbDora ?? 0 14 | 15 | this.yakus = options?.yakus ?? [] 16 | } 17 | 18 | get isOpen () { 19 | return this.openCombinations.length > 0 20 | } 21 | 22 | get combinations () { 23 | return this.concealedCombinations.concat(this.openCombinations) 24 | } 25 | 26 | get winningCombination () { 27 | if (this.winningCombinationIndex != null) { 28 | return this.concealedCombinations[this.winningCombinationIndex] ?? null 29 | } 30 | return null 31 | } 32 | 33 | get winningTile () { 34 | if (this.winningCombination != null && this.winningTileIndex != null) { 35 | return this.winningCombination.tiles[this.winningTileIndex] ?? null 36 | } 37 | return null 38 | } 39 | } 40 | 41 | export default Hand 42 | -------------------------------------------------------------------------------- /src/core/point-calculation/point-calculator.js: -------------------------------------------------------------------------------- 1 | class PointCalculator { 2 | constructor (options) { 3 | this.kazoeYakumanAsSanbaiman = options?.kazoeYakumanAsSanbaiman ?? true 4 | this.kiriageMangan = options?.kiriageMangan ?? false 5 | } 6 | 7 | calculate (hand, fu, han, yakuman) { 8 | if (yakuman > 0) return getPointForYakuman(hand, yakuman) 9 | if (han >= 13 && !this.kazoeYakumanAsSanbaiman) return getPointForYakuman(hand, 1) 10 | if (han >= 13 && this.kazoeYakumanAsSanbaiman) return getPointForSanbaiman(hand) 11 | if (han >= 11) return getPointForSanbaiman(hand) 12 | if (han >= 8) return getPointForBaiman(hand) 13 | if (han >= 6) return getPointForHaneman(hand) 14 | if (han === 5 || (this.kiriageMangan && canBeRoundUpToMangan(fu, han))) return getPointForMangan(hand) 15 | if ((han === 3 && fu > 60) || (han === 4 && fu > 30)) return getPointForMangan(hand) 16 | return getPointFromManualCalculation(hand, fu, han) 17 | } 18 | } 19 | 20 | function canBeRoundUpToMangan (fu, han) { 21 | return (han === 4 && fu === 30) || (han === 3 && fu === 60) 22 | } 23 | 24 | function getPointForYakuman (hand, yakuman) { 25 | return multiplyBy(yakuman, multiplyBy(4, (getPointForMangan(hand)))) 26 | } 27 | 28 | function getPointForSanbaiman (hand) { 29 | return multiplyBy(3, getPointForMangan(hand)) 30 | } 31 | 32 | function getPointForBaiman (hand) { 33 | return multiplyBy(2, getPointForMangan(hand)) 34 | } 35 | 36 | function getPointForHaneman (hand) { 37 | return multiplyBy(1.5, getPointForMangan(hand)) 38 | } 39 | 40 | function getPointForMangan (hand) { 41 | if (hand.seatWind === 'east' && hand.winningType === 'tsumo') return { nonDealer: 4000 } 42 | if (hand.seatWind === 'east' && hand.winningType === 'ron') return { discard: 12000 } 43 | if (hand.seatWind !== 'east' && hand.winningType === 'tsumo') return { dealer: 4000, nonDealer: 2000 } 44 | if (hand.seatWind !== 'east' && hand.winningType === 'ron') return { discard: 8000 } 45 | } 46 | 47 | function getPointFromManualCalculation (hand, fu, han) { 48 | const basicPoints = fu * Math.pow(2, 2 + han) 49 | 50 | if (hand.seatWind === 'east' && hand.winningType === 'tsumo') { 51 | return { nonDealer: roundUpToTheHundreds(basicPoints * 2) } 52 | } 53 | 54 | if (hand.seatWind === 'east' && hand.winningType === 'ron') { 55 | return { discard: roundUpToTheHundreds(basicPoints * 6) } 56 | } 57 | 58 | if (hand.seatWind !== 'east' && hand.winningType === 'tsumo') { 59 | return { dealer: roundUpToTheHundreds(basicPoints * 2), nonDealer: roundUpToTheHundreds(basicPoints) } 60 | } 61 | 62 | if (hand.seatWind !== 'east' && hand.winningType === 'ron') { 63 | return { discard: roundUpToTheHundreds(basicPoints * 4) } 64 | } 65 | } 66 | 67 | function roundUpToTheHundreds (value) { 68 | return Math.ceil(value / 100) * 100 69 | } 70 | 71 | function multiplyBy (factor, source) { 72 | if (source.discard != null) { 73 | return { discard: source.discard * factor } 74 | } 75 | 76 | if (source.nonDealer != null && source.dealer == null) { 77 | return { nonDealer: source.nonDealer * factor } 78 | } 79 | 80 | return { dealer: source.dealer * factor, nonDealer: source.nonDealer * factor } 81 | } 82 | 83 | export default PointCalculator 84 | -------------------------------------------------------------------------------- /src/core/tile-classes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tile factory class 3 | * Factory design pattern to help create tile classes 4 | * 5 | * ex. : TileFactory.create('dragon', 'red') 6 | */ 7 | export const TileFactory = { 8 | create (suit, value) { 9 | switch (suit) { 10 | case 'dragon': 11 | return new DragonTile(value) 12 | case 'wind': 13 | return new WindTile(value) 14 | case 'bamboo': 15 | return new BambooTile(value) 16 | case 'dot': 17 | return new DotTile(value) 18 | case 'character': 19 | return new CharacterTile(value) 20 | default: 21 | throw new Error(`Tile Factory Error : "${suit}" is not a supported suit`) 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Tile base class 28 | * All tile inherit from this class 29 | */ 30 | export class Tile { 31 | constructor (suit, value) { 32 | this.suit = suit 33 | this.value = value 34 | } 35 | } 36 | 37 | /** 38 | * Honor tile base class 39 | * Wind and Dragon tile inherit from this class 40 | */ 41 | export class HonorTile extends Tile {} 42 | 43 | /** 44 | * Numbered tile base class 45 | * Dot, Character and Bamboo tile inherit from this class 46 | */ 47 | export class NumberedTile extends Tile { 48 | constructor (suit, value) { 49 | super(suit, value) 50 | this.number = value 51 | } 52 | 53 | isTerminal () { 54 | return this.number === 1 || this.number === 9 55 | } 56 | } 57 | 58 | /** 59 | * Wind tile class 60 | * East, South, West and North tile 61 | */ 62 | export class WindTile extends HonorTile { 63 | constructor (direction) { 64 | super('wind', direction) // east, south, west, north 65 | this.direction = direction 66 | } 67 | } 68 | 69 | /** 70 | * Dragon tile class 71 | * Red, Green and White dragon tile 72 | */ 73 | export class DragonTile extends HonorTile { 74 | constructor (color) { 75 | super('dragon', color) // red, green, white 76 | this.color = color 77 | } 78 | } 79 | 80 | /** 81 | * Dot tile class 82 | */ 83 | export class DotTile extends NumberedTile { 84 | constructor (number) { 85 | super('dot', number) 86 | } 87 | } 88 | 89 | /** 90 | * Character tile class 91 | */ 92 | export class CharacterTile extends NumberedTile { 93 | constructor (number) { 94 | super('character', number) 95 | } 96 | } 97 | 98 | /** 99 | * Bamboo tile class 100 | */ 101 | export class BambooTile extends NumberedTile { 102 | constructor (number) { 103 | super('bamboo', number) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/core/waits/is-kanchan-wait.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../combination-classes' 2 | 3 | function isKanchanWait ({ concealedCombinations, winningCombinationIndex, winningTileIndex }) { 4 | const winningCombination = concealedCombinations[winningCombinationIndex] 5 | return winningCombination != null && winningCombination instanceof Sequence && winningTileIndex === 1 6 | } 7 | 8 | export default isKanchanWait 9 | -------------------------------------------------------------------------------- /src/core/waits/is-penchan-wait.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from './../combination-classes' 2 | 3 | function isPenchanWait ({ concealedCombinations, winningCombinationIndex, winningTileIndex }) { 4 | const winningCombination = concealedCombinations[winningCombinationIndex] 5 | 6 | if (winningCombination != null && winningCombination instanceof Sequence) { 7 | const winningTile = winningCombination.tiles[winningTileIndex] 8 | 9 | if ( 10 | winningTile != null && 11 | ( 12 | (winningTileIndex === 0 && winningTile.value === 7) || 13 | (winningTileIndex === 2 && winningTile.value === 3) 14 | ) 15 | ) { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | export default isPenchanWait 24 | -------------------------------------------------------------------------------- /src/core/waits/is-tanki-wait.js: -------------------------------------------------------------------------------- 1 | import { Pair, Orphan } from './../combination-classes' 2 | 3 | function isTankiWait ({ concealedCombinations, winningCombinationIndex }) { 4 | const nbOrphans = concealedCombinations.filter(x => x instanceof Orphan).length 5 | const winningCombination = concealedCombinations[winningCombinationIndex] 6 | return winningCombination != null && winningCombination instanceof Pair && nbOrphans === 0 7 | } 8 | 9 | export default isTankiWait 10 | -------------------------------------------------------------------------------- /src/core/wrc/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace Wrc 3 | */ 4 | -------------------------------------------------------------------------------- /src/core/wrc/wrc-fu-calculator.js: -------------------------------------------------------------------------------- 1 | import FuCalculator from './../fu-calculation/fu-calculator' 2 | 3 | import WinRule from './../fu-calculation/rules/winning-fu-rule' 4 | import WaitRule from './../fu-calculation/rules/wait-fu-rule' 5 | import TsumoRule from './../fu-calculation/rules/tsumo-fu-rule' 6 | import PairRule from './../fu-calculation/rules/pair-fu-rule' 7 | import OpenPinfuRule from './../fu-calculation/rules/open-pinfu-fu-rule' 8 | import CombinationsRule from './../fu-calculation/rules/combinations-fu-rule' 9 | import ClosedRonRule from './../fu-calculation/rules/closed-ron-fu-rule' 10 | import ChiitoitsuRule from './../fu-calculation/rules/chiitoitsu-fu-rule' 11 | 12 | import ChiitoitsuYaku from './../han-calculation/yakus/chiitoitsu-yaku' 13 | import PinfuYaku from './../han-calculation/yakus/pinfu-yaku' 14 | 15 | /** 16 | * Fu calculator implementation using WRC ruleset. 17 | * 18 | * @memberof Wrc 19 | */ 20 | class WrcFuCalculator extends FuCalculator { 21 | constructor () { 22 | super([ 23 | // put the chiitoitsu rule at the top because it's a fixed amount of fu 24 | new ChiitoitsuRule({ chiitoitsuYakuPattern: new ChiitoitsuYaku(), fuValue: 25 }), 25 | new WinRule(), 26 | new CombinationsRule(), 27 | new PairRule({ stackable: false }), 28 | new WaitRule(), 29 | new OpenPinfuRule(), 30 | new ClosedRonRule(), 31 | new TsumoRule({ excludedYakuPatterns: [new PinfuYaku()] }) 32 | ]) 33 | } 34 | } 35 | 36 | export default WrcFuCalculator 37 | -------------------------------------------------------------------------------- /src/core/wrc/wrc-point-calculator.js: -------------------------------------------------------------------------------- 1 | import PointCalculator from './../point-calculation/point-calculator' 2 | 3 | class WrcPointCalculator extends PointCalculator { 4 | constructor () { 5 | super({ 6 | kazoeYakumanAsSanbaiman: true, 7 | kiriageMangan: true 8 | }) 9 | } 10 | } 11 | 12 | export default WrcPointCalculator 13 | -------------------------------------------------------------------------------- /src/event-bus.js: -------------------------------------------------------------------------------- 1 | import emitter from 'tiny-emitter/instance' 2 | 3 | export default { 4 | on: (...args) => emitter.on(...args), 5 | once: (...args) => emitter.once(...args), 6 | off: (...args) => emitter.off(...args), 7 | emit: (...args) => emitter.emit(...args) 8 | } 9 | -------------------------------------------------------------------------------- /src/filters/format-number.js: -------------------------------------------------------------------------------- 1 | export default value => { 2 | if (value == null) return value 3 | return Intl.NumberFormat('en-US').format(value) 4 | } 5 | -------------------------------------------------------------------------------- /src/filters/title-case.js: -------------------------------------------------------------------------------- 1 | const smallWords = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|v.?|vs.?|via)$/i 2 | const alphanumericPattern = /([A-Za-z0-9\u00C0-\u00FF])/ 3 | const wordSeparators = /([ :–—-])/ 4 | 5 | export default str => { 6 | if (str == null) return str 7 | 8 | return str.split(wordSeparators) 9 | .map((current, index, array) => { 10 | if ( 11 | /* Check for small words */ 12 | current.search(smallWords) > -1 && 13 | /* Skip first and last word */ 14 | index !== 0 && 15 | index !== array.length - 1 && 16 | /* Ignore title end and subtitle start */ 17 | array[index - 3] !== ':' && 18 | array[index + 1] !== ':' && 19 | /* Ignore small words that start a hyphenated phrase */ 20 | (array[index + 1] !== '-' || 21 | (array[index - 1] === '-' && array[index + 1] === '-')) 22 | ) { 23 | return current.toLowerCase() 24 | } 25 | 26 | /* Ignore intentional capitalization */ 27 | if (current.substr(1).search(/[A-Z]|\../) > -1) { 28 | return current 29 | } 30 | 31 | /* Ignore URLs */ 32 | if (array[index + 1] === ':' && array[index + 2] !== '') { 33 | return current 34 | } 35 | 36 | /* Capitalize the first letter */ 37 | return current.replace(alphanumericPattern, match => match.toUpperCase()) 38 | }) 39 | .join('') 40 | } 41 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import { getProperty } from 'dot-prop' 2 | import en from './locales/en.json' 3 | import jpRomanized from './locales/jp-romanized.json' 4 | 5 | const defaultLocale = 'en' 6 | const messages = { 7 | en, 8 | 'jp-romanized': jpRomanized 9 | } 10 | 11 | export const t = (key, locale) => getProperty(messages[locale ?? defaultLocale], key) ?? key 12 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "chankan": "robbing a kan", 3 | "chanta": "half outside hand", 4 | "chiihou": "blessing of earth", 5 | "chiitoitsu": "seven pairs", 6 | "chinitsu": "full flush", 7 | "chinroutou": "all terminals", 8 | "chun": "red dragon", 9 | "chuuren poutou": "nine gates", 10 | "daisangen": "big three dragons", 11 | "daisuushii": "four big winds", 12 | "double riichi": "double riichi", 13 | "dora": "dora", 14 | "haitei raoyue": "under the sea", 15 | "haku": "white dragon", 16 | "hatsu": "green dragon", 17 | "honitsu": "half flush", 18 | "honroutou": "all terminals and honours", 19 | "houtei raoyui": "under the river", 20 | "iipeikou": "pure double sequence", 21 | "ippatsu": "ippatsu", 22 | "ittsuu": "pure straight", 23 | "junchan": "fully outside hand", 24 | "kokushi musou": "thirteen orphans", 25 | "menzen tsumo": "fully concealed hand", 26 | "nan": "south", 27 | "pei": "north", 28 | "pinfu": "pinfu", 29 | "renhou": "blessing of man", 30 | "riichi": "riichi", 31 | "rinshan kaihou": "after a kan", 32 | "ryanpeikou": "twice pure double sequence", 33 | "ryuuiisou": "all green", 34 | "sanankou": "three concealed triplets", 35 | "sankantsu": "three quads", 36 | "sanshoku doujun": "mixed triple sequence", 37 | "sanshoku doukou": "triple triplets", 38 | "shousangen": "little three dragons", 39 | "shousuushii": "four little winds", 40 | "suuankou": "four concealed triplets", 41 | "suukantsu": "four quads", 42 | "tanyao": "all simples", 43 | "tenhou": "blessing of heaven", 44 | "toitoi": "all triplets", 45 | "ton": "east", 46 | "tsuuiisou": "all honours", 47 | "xia": "west", 48 | 49 | "fuRules": { 50 | "win": "Winning", 51 | "tsumo": "Tsumo", 52 | "closed ron": "concealed ron", 53 | "open pinfu": "open pinfu", 54 | "chiitoitsu": "chiitoitsu (seven pairs)", 55 | "pair": "pair", 56 | "wait": "wait", 57 | "minkou simple": "open triplet (simple)", 58 | "minkou non simple": "open triplet (terminal / honor)", 59 | "minkan simple": "open quad (simple)", 60 | "minkan non simple": "open quad (terminal / honor)", 61 | "ankou simple": "concealed triplet (simple)", 62 | "ankou non simple": "concealed triplet (terminal / honor)", 63 | "ankan simple": "concealed quad (simple)", 64 | "ankan non simple": "concealed quad (terminal / honor)" 65 | } 66 | } -------------------------------------------------------------------------------- /src/locales/jp-romanized.json: -------------------------------------------------------------------------------- 1 | { 2 | "chankan": "chankan", 3 | "chanta": "chanta", 4 | "chiihou": "chiihou", 5 | "chiitoitsu": "chiitoitsu", 6 | "chinitsu": "chinitsu", 7 | "chinroutou": "chinroutou", 8 | "chun": "chun", 9 | "chuuren poutou": "chuuren poutou", 10 | "daisangen": "daisangen", 11 | "daisuushii": "daisuushii", 12 | "double riichi": "double riichi", 13 | "dora": "dora", 14 | "haitei raoyue": "haitei raoyue", 15 | "haku": "haku", 16 | "hatsu": "hatsu", 17 | "honitsu": "honitsu", 18 | "honroutou": "honroutou", 19 | "houtei raoyui": "houtei raoyui", 20 | "iipeikou": "iipeikou", 21 | "ippatsu": "ippatsu", 22 | "ittsuu": "ittsuu", 23 | "junchan": "junchan", 24 | "kokushi musou": "kokushi musou", 25 | "menzen tsumo": "menzen tsumo", 26 | "nan": "nan", 27 | "pei": "pei", 28 | "pinfu": "pinfu", 29 | "renhou": "renhou", 30 | "riichi": "riichi", 31 | "rinshan kaihou": "rinshan kaihou", 32 | "ryanpeikou": "ryanpeikou", 33 | "ryuuiisou": "ryuuiisou", 34 | "sanankou": "sanankou", 35 | "sankantsu": "sankantsu", 36 | "sanshoku doujun": "sanshoku doujun", 37 | "sanshoku doukou": "sanshoku doukou", 38 | "shousangen": "shousangen", 39 | "shousuushii": "shousuushii", 40 | "suuankou": "suuankou", 41 | "suukantsu": "suukantsu", 42 | "tanyao": "tanyao", 43 | "tenhou": "tenhou", 44 | "toitoi": "toitoi", 45 | "ton": "ton", 46 | "tsuuiisou": "tsuuiisou", 47 | "xia": "xia" 48 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import './assets/style.css' 4 | 5 | createApp(App).mount('#App') 6 | -------------------------------------------------------------------------------- /src/ruleset.js: -------------------------------------------------------------------------------- 1 | import * as WrcRuleset from './rulesets/wrc-ruleset' 2 | import * as EmaRuleset from './rulesets/ema-ruleset' 3 | import * as CustomRuleset from './rulesets/custom-ruleset' 4 | 5 | const localStorageKey = 'riichi-pointer-ruleset' 6 | const defaultPreset = WrcRuleset.key 7 | let instance = null 8 | 9 | function setup (key, options) { 10 | if (key === WrcRuleset.key) { 11 | instance = WrcRuleset.create(options) 12 | } else if (key === EmaRuleset.key) { 13 | instance = EmaRuleset.create(options) 14 | } else if (key === CustomRuleset.key) { 15 | instance = CustomRuleset.create(options) 16 | } else { 17 | throw Error(`ruleset key [${key}] not supported`) 18 | } 19 | 20 | if (key === defaultPreset) { 21 | localStorage.removeItem(localStorageKey) 22 | } else if (key === CustomRuleset.key) { 23 | // only need to save the options for the custom ruleset for now because they are readonly for the other presets 24 | localStorage.setItem(localStorageKey, JSON.stringify({ key, options: instance.options })) 25 | } else { 26 | localStorage.setItem(localStorageKey, JSON.stringify({ key })) 27 | } 28 | } 29 | 30 | function initialize () { 31 | // try to find a previous ruleset in the localStorage of the browser to re-apply on the application 32 | try { 33 | const localStorageItem = localStorage.getItem(localStorageKey) 34 | 35 | if (localStorageItem != null) { 36 | const parsedLocalStorageItem = JSON.parse(localStorageItem) 37 | setup(parsedLocalStorageItem.key, parsedLocalStorageItem.options) 38 | } 39 | } catch { } 40 | 41 | // put the default preset if no previous ruleset was found or if an error occured while trying to setup it 42 | if (instance == null) setup(defaultPreset) 43 | } 44 | 45 | initialize() 46 | 47 | export function getRuleset () { 48 | return instance 49 | } 50 | 51 | export function setRuleset (key, options) { 52 | setup(key, options) 53 | } 54 | -------------------------------------------------------------------------------- /src/rulesets/ema-ruleset.js: -------------------------------------------------------------------------------- 1 | import EmaFuCalculator from '../core/ema/ema-fu-calculator' 2 | import EmaHanCalculator from '../core/ema/ema-han-calculator' 3 | import EmaPointCalculator from '../core/ema/ema-point-calculator' 4 | import fourPlayerGameTiles from './helpers/four-player-game-tiles' 5 | 6 | // options are there only to be able to display them as readonly options in the configuration modal 7 | // if you need to change the implementation, please refer to ./../core/ema/* classes 8 | const options = { 9 | kazoeYakumanAsSanbaiman: true, 10 | allowMultipleYakuman: false, 11 | allowDoubleYakuman: false, 12 | allowOpenTanyao: true, 13 | allowDoubleWindFu: true, 14 | renhouValue: 'mangan', 15 | kiriageMangan: false, 16 | 17 | // optional yaku 18 | allowOpenRiichi: false 19 | } 20 | 21 | export const key = 'ema' 22 | 23 | export function create () { 24 | return { 25 | key, 26 | options, 27 | 28 | fuCalculator: new EmaFuCalculator(), 29 | hanCalculator: new EmaHanCalculator(), 30 | pointCalculator: new EmaPointCalculator(), 31 | 32 | tiles: fourPlayerGameTiles 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/rulesets/helpers/four-player-game-tiles.js: -------------------------------------------------------------------------------- 1 | import { TileFactory } from '@/core/tile-classes' 2 | 3 | const tiles = [ 4 | TileFactory.create('dragon', 'green'), 5 | TileFactory.create('dragon', 'red'), 6 | TileFactory.create('dragon', 'white'), 7 | 8 | TileFactory.create('wind', 'east'), 9 | TileFactory.create('wind', 'south'), 10 | TileFactory.create('wind', 'west'), 11 | TileFactory.create('wind', 'north'), 12 | 13 | TileFactory.create('bamboo', 1), 14 | TileFactory.create('bamboo', 2), 15 | TileFactory.create('bamboo', 3), 16 | TileFactory.create('bamboo', 4), 17 | TileFactory.create('bamboo', 5), 18 | TileFactory.create('bamboo', 6), 19 | TileFactory.create('bamboo', 7), 20 | TileFactory.create('bamboo', 8), 21 | TileFactory.create('bamboo', 9), 22 | 23 | TileFactory.create('character', 1), 24 | TileFactory.create('character', 2), 25 | TileFactory.create('character', 3), 26 | TileFactory.create('character', 4), 27 | TileFactory.create('character', 5), 28 | TileFactory.create('character', 6), 29 | TileFactory.create('character', 7), 30 | TileFactory.create('character', 8), 31 | TileFactory.create('character', 9), 32 | 33 | TileFactory.create('dot', 1), 34 | TileFactory.create('dot', 2), 35 | TileFactory.create('dot', 3), 36 | TileFactory.create('dot', 4), 37 | TileFactory.create('dot', 5), 38 | TileFactory.create('dot', 6), 39 | TileFactory.create('dot', 7), 40 | TileFactory.create('dot', 8), 41 | TileFactory.create('dot', 9) 42 | ] 43 | 44 | export default tiles 45 | -------------------------------------------------------------------------------- /src/rulesets/wrc-ruleset.js: -------------------------------------------------------------------------------- 1 | import WrcFuCalculator from './../core/wrc/wrc-fu-calculator' 2 | import WrcHanCalculator from './../core/wrc/wrc-han-calculator' 3 | import WrcPointCalculator from './../core/wrc/wrc-point-calculator' 4 | import fourPlayerGameTiles from './helpers/four-player-game-tiles' 5 | 6 | // options are there only to be able to display them as readonly options in the configuration modal 7 | // if you need to change the implementation, please refer to ./../core/wrc/* classes 8 | const options = { 9 | kazoeYakumanAsSanbaiman: true, 10 | allowMultipleYakuman: true, 11 | allowDoubleYakuman: false, 12 | allowOpenTanyao: true, 13 | allowDoubleWindFu: false, 14 | renhouValue: 'mangan', 15 | kiriageMangan: true, 16 | 17 | // optional yaku 18 | allowOpenRiichi: false 19 | } 20 | 21 | export const key = 'wrc' 22 | 23 | export function create () { 24 | return { 25 | key, 26 | options, 27 | 28 | fuCalculator: new WrcFuCalculator(), 29 | hanCalculator: new WrcHanCalculator(), 30 | pointCalculator: new WrcPointCalculator(), 31 | 32 | tiles: fourPlayerGameTiles 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/dealer-score-fixture.json: -------------------------------------------------------------------------------- 1 | [ 2 | [1, 30, 1500, 500], 3 | [1, 40, 2000, 700], 4 | [1, 50, 2400, 800], 5 | [1, 60, 2900, 1000], 6 | [1, 70, 3400, 1200], 7 | [1, 80, 3900, 1300], 8 | [1, 90, 4400, 1500], 9 | [1, 100, 4800, 1600], 10 | [1, 110, 5300, 1800], 11 | 12 | [2, 20, 2000, 700], 13 | [2, 25, 2400, null], 14 | [2, 30, 2900, 1000], 15 | [2, 40, 3900, 1300], 16 | [2, 50, 4800, 1600], 17 | [2, 60, 5800, 2000], 18 | [2, 70, 6800, 2300], 19 | [2, 80, 7700, 2600], 20 | [2, 90, 8700, 2900], 21 | [2, 100, 9600, 3200], 22 | [2, 110, 10600, 3600], 23 | 24 | [3, 20, 3900, 1300], 25 | [3, 25, 4800, 1600], 26 | [3, 30, 5800, 2000], 27 | [3, 40, 7700, 2600], 28 | [3, 50, 9600, 3200], 29 | [3, 60, 11600, 3900], 30 | [3, 70, 12000, 4000], 31 | [3, 80, 12000, 4000], 32 | [3, 90, 12000, 4000], 33 | [3, 100, 12000, 4000], 34 | [3, 110, 12000, 4000], 35 | 36 | [4, 20, 7700, 2600], 37 | [4, 25, 9600, 3200], 38 | [4, 30, 11600, 3900], 39 | [4, 40, 12000, 4000], 40 | [4, 50, 12000, 4000], 41 | [4, 60, 12000, 4000], 42 | [4, 70, 12000, 4000], 43 | [4, 80, 12000, 4000], 44 | [4, 90, 12000, 4000], 45 | [4, 100, 12000, 4000], 46 | [4, 110, 12000, 4000], 47 | 48 | [5, 20, 12000, 4000], 49 | [5, 25, 12000, 4000], 50 | [5, 30, 12000, 4000], 51 | [5, 40, 12000, 4000], 52 | [5, 50, 12000, 4000], 53 | [5, 60, 12000, 4000], 54 | [5, 70, 12000, 4000], 55 | [5, 80, 12000, 4000], 56 | [5, 90, 12000, 4000], 57 | [5, 100, 12000, 4000], 58 | [5, 110, 12000, 4000], 59 | 60 | [6, 20, 18000, 6000], 61 | [6, 25, 18000, 6000], 62 | [6, 30, 18000, 6000], 63 | [6, 40, 18000, 6000], 64 | [6, 50, 18000, 6000], 65 | [6, 60, 18000, 6000], 66 | [6, 70, 18000, 6000], 67 | [6, 80, 18000, 6000], 68 | [6, 90, 18000, 6000], 69 | [6, 100, 18000, 6000], 70 | [6, 110, 18000, 6000], 71 | 72 | [7, 20, 18000, 6000], 73 | [7, 25, 18000, 6000], 74 | [7, 30, 18000, 6000], 75 | [7, 40, 18000, 6000], 76 | [7, 50, 18000, 6000], 77 | [7, 60, 18000, 6000], 78 | [7, 70, 18000, 6000], 79 | [7, 80, 18000, 6000], 80 | [7, 90, 18000, 6000], 81 | [7, 100, 18000, 6000], 82 | [7, 110, 18000, 6000], 83 | 84 | [8, 20, 24000, 8000], 85 | [8, 25, 24000, 8000], 86 | [8, 30, 24000, 8000], 87 | [8, 40, 24000, 8000], 88 | [8, 50, 24000, 8000], 89 | [8, 60, 24000, 8000], 90 | [8, 70, 24000, 8000], 91 | [8, 80, 24000, 8000], 92 | [8, 90, 24000, 8000], 93 | [8, 100, 24000, 8000], 94 | [8, 110, 24000, 8000], 95 | 96 | [9, 20, 24000, 8000], 97 | [9, 25, 24000, 8000], 98 | [9, 30, 24000, 8000], 99 | [9, 40, 24000, 8000], 100 | [9, 50, 24000, 8000], 101 | [9, 60, 24000, 8000], 102 | [9, 70, 24000, 8000], 103 | [9, 80, 24000, 8000], 104 | [9, 90, 24000, 8000], 105 | [9, 100, 24000, 8000], 106 | [9, 110, 24000, 8000], 107 | 108 | [10, 20, 24000, 8000], 109 | [10, 25, 24000, 8000], 110 | [10, 30, 24000, 8000], 111 | [10, 40, 24000, 8000], 112 | [10, 50, 24000, 8000], 113 | [10, 60, 24000, 8000], 114 | [10, 70, 24000, 8000], 115 | [10, 80, 24000, 8000], 116 | [10, 90, 24000, 8000], 117 | [10, 100, 24000, 8000], 118 | [10, 110, 24000, 8000], 119 | 120 | [11, 20, 36000, 12000], 121 | [11, 25, 36000, 12000], 122 | [11, 30, 36000, 12000], 123 | [11, 40, 36000, 12000], 124 | [11, 50, 36000, 12000], 125 | [11, 60, 36000, 12000], 126 | [11, 70, 36000, 12000], 127 | [11, 80, 36000, 12000], 128 | [11, 90, 36000, 12000], 129 | [11, 100, 36000, 12000], 130 | [11, 110, 36000, 12000], 131 | 132 | [12, 20, 36000, 12000], 133 | [12, 25, 36000, 12000], 134 | [12, 30, 36000, 12000], 135 | [12, 40, 36000, 12000], 136 | [12, 50, 36000, 12000], 137 | [12, 60, 36000, 12000], 138 | [12, 70, 36000, 12000], 139 | [12, 80, 36000, 12000], 140 | [12, 90, 36000, 12000], 141 | [12, 100, 36000, 12000], 142 | [12, 110, 36000, 12000] 143 | ] -------------------------------------------------------------------------------- /tests/integration/ema/ema-point-calculator.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import EmaPointCalculator from '@/core/ema/ema-point-calculator' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new EmaPointCalculator() 6 | 7 | test('kazoe yakuman should count as sanbaiman', () => { 8 | const hand = new Hand({ seatWind: 'east', winningType: 'tsumo' }) 9 | const result = sut.calculate(hand, null, 13) 10 | expect(result).toStrictEqual({ nonDealer: 12000 }) 11 | }) 12 | 13 | test('kiriage mangan should not be active', () => { 14 | const hand = new Hand({ seatWind: 'east', winningType: 'ron' }) 15 | const result = sut.calculate(hand, 30, 4) 16 | expect(result).toStrictEqual({ discard: 11600 }) 17 | }) 18 | -------------------------------------------------------------------------------- /tests/integration/wrc/wrc-point-calculator.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import WrcPointCalculator from '@/core/wrc/wrc-point-calculator' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new WrcPointCalculator() 6 | 7 | test('kazoe yakuman should count as sanbaiman', () => { 8 | const hand = new Hand({ seatWind: 'east', winningType: 'tsumo' }) 9 | const result = sut.calculate(hand, null, 13) 10 | expect(result).toStrictEqual({ nonDealer: 12000 }) 11 | }) 12 | 13 | test('kiriage mangan should be active', () => { 14 | const hand = new Hand({ seatWind: 'east', winningType: 'ron' }) 15 | const result = sut.calculate(hand, 30, 4) 16 | expect(result).toStrictEqual({ discard: 12000 }) 17 | }) 18 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/fu-calculator.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import FuCalculator from '@/core/fu-calculation/fu-calculator' 3 | import Hand from '@/core/hand' 4 | 5 | const hand = new Hand() // just a placeholder because rule checkers will be mocked anyway 6 | 7 | test('trying to instantiate the calculator without rules parameter should throw an error', () => { 8 | /* eslint-disable no-new */ 9 | expect(() => { new FuCalculator() }).toThrow('rules parameter is required') 10 | }) 11 | 12 | test('should support rule returning nothing', () => { 13 | const rules = [{ check () {} }] 14 | const result = new FuCalculator(rules).calculate(hand) 15 | expect(result.details).toStrictEqual([]) 16 | }) 17 | 18 | test('should support rule returning a fu info object', () => { 19 | const rules = [{ check: () => ({ key: 'test', fuValue: 1, quantity: 1 }) }] 20 | const result = new FuCalculator(rules).calculate(hand) 21 | expect(result.details).toStrictEqual([{ key: 'test', fuValue: 1, quantity: 1 }]) 22 | }) 23 | 24 | test('should support rule returning an array of fu info', () => { 25 | const rules = [ 26 | { check: () => ({ key: 'test', fuValue: 1, quantity: 1 }) }, 27 | { check: () => ({ key: 'test 2', fuValue: 2, quantity: 5 }) } 28 | ] 29 | 30 | const result = new FuCalculator(rules).calculate(hand) 31 | 32 | expect(result.details).toStrictEqual([ 33 | { key: 'test', fuValue: 1, quantity: 1 }, 34 | { key: 'test 2', fuValue: 2, quantity: 5 } 35 | ]) 36 | }) 37 | 38 | test('should return the total fu value of the hand', () => { 39 | const rules = [ 40 | { check: () => ({ key: 'test', fuValue: 2, quantity: 1 }) }, 41 | { check: () => ({ key: 'test 2', fuValue: 2, quantity: 4 }) } 42 | ] 43 | 44 | const result = new FuCalculator(rules).calculate(hand) 45 | 46 | expect(result.total).toBe(10) 47 | }) 48 | 49 | test('should round up to the tens the total fu value of the hand', () => { 50 | const rules = [ 51 | { check: () => ({ key: 'test', fuValue: 2, quantity: 1 }) }, 52 | { check: () => ({ key: 'test 2', fuValue: 2, quantity: 5 }) } 53 | ] 54 | 55 | const result = new FuCalculator(rules).calculate(hand) 56 | 57 | expect(result.total).toBe(20) 58 | }) 59 | 60 | describe('given a rule want to stop the process', () => { 61 | const rules = [ 62 | { 63 | check (hand, context) { 64 | context.stop = true 65 | return { key: 'test', fuValue: 1, quantity: 1 } 66 | } 67 | }, 68 | { check: () => ({ key: 'test 2', fuValue: 1, quantity: 1 }) } 69 | ] 70 | 71 | test('should not evaluate the next rules', () => { 72 | const result = new FuCalculator(rules).calculate(hand) 73 | expect(result.details).toStrictEqual([{ key: 'test', fuValue: 1, quantity: 1 }]) 74 | }) 75 | }) 76 | 77 | describe('given a rule want to disable the rounding', () => { 78 | const rules = [ 79 | { 80 | check (hand, context) { 81 | context.rounding = false 82 | return { key: 'test', fuValue: 25, quantity: 1 } 83 | } 84 | } 85 | ] 86 | 87 | test('should not round up to the tens the total fu value of the hand', () => { 88 | const result = new FuCalculator(rules).calculate(hand) 89 | expect(result.total).toBe(25) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/rules/chiitoitsu-fu-rule.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import ChiitoitsuRule from '@/core/fu-calculation/rules/chiitoitsu-fu-rule' 3 | import Hand from '@/core/hand' 4 | 5 | const hand = new Hand() // just a placeholder because the yaku checker will be mocked anyway 6 | 7 | function makeDefaultFuCalculationContext () { 8 | return { stop: false, rounding: true } 9 | } 10 | 11 | test('trying to instantiate the rule without chiitoitsuYakuPattern option should throw an error', () => { 12 | const expectedError = 'chiitoitsuYakuPattern is required' 13 | /* eslint-disable no-new */ 14 | expect(() => { new ChiitoitsuRule() }).toThrow(expectedError) 15 | /* eslint-disable no-new */ 16 | expect(() => { new ChiitoitsuRule({}) }).toThrow(expectedError) 17 | /* eslint-disable no-new */ 18 | expect(() => { new ChiitoitsuRule({ fuValue: 50 }) }).toThrow(expectedError) 19 | }) 20 | 21 | describe('given the hand is not considered a chiitoitsu (seven pairs)', () => { 22 | const mockChiitoitsuYakuPattern = { check: () => false } 23 | 24 | test('should not return any fu info', () => { 25 | const context = makeDefaultFuCalculationContext() 26 | const result = new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 27 | expect(result).toBeUndefined() 28 | }) 29 | 30 | test('should not stop the process', () => { 31 | const context = makeDefaultFuCalculationContext() 32 | new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 33 | expect(context.stop).toBe(false) 34 | }) 35 | 36 | test('should not disable the rounding', () => { 37 | const context = makeDefaultFuCalculationContext() 38 | new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 39 | expect(context.rounding).toBe(true) 40 | }) 41 | }) 42 | 43 | describe('given the hand is considered a chiitoitsu (seven pairs)', () => { 44 | const mockChiitoitsuYakuPattern = { check: () => true } 45 | 46 | test('should return 25 fu by default', () => { 47 | const context = makeDefaultFuCalculationContext() 48 | const result = new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 49 | expect(result).toStrictEqual({ key: 'chiitoitsu', fuValue: 25, quantity: 1 }) 50 | }) 51 | 52 | test('should return the fu value corresponding to the rule option', () => { 53 | const context = makeDefaultFuCalculationContext() 54 | const result = new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern, fuValue: 50 }).check(hand, context) 55 | expect(result).toStrictEqual({ key: 'chiitoitsu', fuValue: 50, quantity: 1 }) 56 | }) 57 | 58 | test('should stop the process', () => { 59 | const context = makeDefaultFuCalculationContext() 60 | new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 61 | expect(context.stop).toBe(true) 62 | }) 63 | 64 | test('should disable the rounding', () => { 65 | const context = makeDefaultFuCalculationContext() 66 | new ChiitoitsuRule({ chiitoitsuYakuPattern: mockChiitoitsuYakuPattern }).check(hand, context) 67 | expect(context.rounding).toBe(false) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/rules/closed-ron-fu-rule.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import ClosedRonRule from '@/core/fu-calculation/rules/closed-ron-fu-rule' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | describe('given the hand is not concealed', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Triplet(new DotTile(1)), 11 | new Triplet(new DotTile(2)), 12 | new Triplet(new DotTile(3)), 13 | new Pair(new DotTile(4)) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new DotTile(5)) 17 | ] 18 | }) 19 | 20 | test('should not return any fu info', () => { 21 | const result = new ClosedRonRule().check(hand) 22 | expect(result).toBeUndefined() 23 | }) 24 | }) 25 | 26 | describe('given the hand was not won by ron (discard)', () => { 27 | const hand = new Hand({ 28 | concealedCombinations: [ 29 | new Triplet(new DotTile(1)), 30 | new Triplet(new DotTile(2)), 31 | new Triplet(new DotTile(3)), 32 | new Triplet(new DotTile(5)), 33 | new Pair(new DotTile(4)) 34 | ], 35 | winningType: 'tsumo' 36 | }) 37 | 38 | test('should not return any fu info', () => { 39 | const result = new ClosedRonRule().check(hand) 40 | expect(result).toBeUndefined() 41 | }) 42 | }) 43 | 44 | describe('given the hand is concealed and was won by ron (discard)', () => { 45 | const hand = new Hand({ 46 | concealedCombinations: [ 47 | new Triplet(new DotTile(1)), 48 | new Triplet(new DotTile(2)), 49 | new Triplet(new DotTile(3)), 50 | new Triplet(new DotTile(5)), 51 | new Pair(new DotTile(4)) 52 | ], 53 | winningType: 'ron' 54 | }) 55 | 56 | test('should return 10 fu', () => { 57 | const result = new ClosedRonRule().check(hand) 58 | expect(result).toStrictEqual({ key: 'closed ron', fuValue: 10, quantity: 1 }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/rules/tsumo-fu-rule.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import TsumoRule from '@/core/fu-calculation/rules/tsumo-fu-rule' 3 | import Hand from '@/core/hand' 4 | import { Pair, Triplet } from '@/core/combination-classes' 5 | import { DotTile, DragonTile, WindTile } from '@/core/tile-classes' 6 | 7 | function makeDefaultHand (winningType) { 8 | return new Hand({ 9 | concealedCombinations: [ 10 | new Triplet(new DragonTile('green')), 11 | new Triplet(new DragonTile('red')), 12 | new Triplet(new DragonTile('white')), 13 | new Triplet(new WindTile('east')), 14 | new Pair(new DotTile(5)) 15 | ], 16 | winningType 17 | }) 18 | } 19 | 20 | describe('given the hand was won by tsumo (self-draw)', () => { 21 | const hand = makeDefaultHand('tsumo') 22 | 23 | test('should return 2 fu', () => { 24 | const result = new TsumoRule().check(hand) 25 | expect(result).toStrictEqual({ key: 'tsumo', fuValue: 2, quantity: 1 }) 26 | }) 27 | }) 28 | 29 | describe('given the hand was won by ron', () => { 30 | const hand = makeDefaultHand('ron') 31 | 32 | test('should not return any fu info', () => { 33 | const result = new TsumoRule().check(hand) 34 | expect(result).toBeUndefined() 35 | }) 36 | }) 37 | 38 | describe('given the hand contains no excluded yaku and was won by tsumo', () => { 39 | const hand = makeDefaultHand('tsumo') 40 | 41 | const mockExcludedYakuPatterns = [ 42 | { check: () => false }, 43 | { check: () => false } 44 | ] 45 | 46 | test('should return 2 fu', () => { 47 | const result = new TsumoRule({ excludedYakuPatterns: mockExcludedYakuPatterns }).check(hand) 48 | expect(result).toStrictEqual({ key: 'tsumo', fuValue: 2, quantity: 1 }) 49 | }) 50 | }) 51 | 52 | describe('given the hand contains one or more excluded yaku', () => { 53 | const hand = makeDefaultHand('tsumo') 54 | 55 | const mockExcludedYakuPatterns = [ 56 | { check: () => false }, 57 | { check: () => true } 58 | ] 59 | 60 | test('should not return any fu info', () => { 61 | const result = new TsumoRule({ excludedYakuPatterns: mockExcludedYakuPatterns }).check(hand) 62 | expect(result).toBeUndefined() 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/rules/wait-fu-rule.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import WaitRule from '@/core/fu-calculation/rules/wait-fu-rule' 3 | import Hand from '@/core/hand' 4 | import { Pair, Triplet, Sequence } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | describe('given the wait is a single wait', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new DotTile(1)), 11 | new Triplet(new DotTile(2)), 12 | new Triplet(new DotTile(3)), 13 | new Triplet(new DotTile(4)), 14 | new Triplet(new DotTile(5)) 15 | ], 16 | winningCombinationIndex: 0, 17 | winningTileIndex: 0 18 | }) 19 | 20 | test('should return 2 fu', () => { 21 | const result = new WaitRule().check(hand) 22 | expect(result).toStrictEqual({ key: 'wait', fuValue: 2, quantity: 1 }) 23 | }) 24 | }) 25 | 26 | describe('given the wait is a edge wait', () => { 27 | const hand = new Hand({ 28 | concealedCombinations: [ 29 | new Pair(new DotTile(1)), 30 | new Sequence(new DotTile(1), new DotTile(2), new DotTile(3)), 31 | new Triplet(new DotTile(3)), 32 | new Triplet(new DotTile(4)), 33 | new Triplet(new DotTile(5)) 34 | ], 35 | winningCombinationIndex: 0, 36 | winningTileIndex: 2 37 | }) 38 | 39 | test('should return 2 fu', () => { 40 | const result = new WaitRule().check(hand) 41 | expect(result).toStrictEqual({ key: 'wait', fuValue: 2, quantity: 1 }) 42 | }) 43 | }) 44 | 45 | describe('given the wait is a closed wait', () => { 46 | const hand = new Hand({ 47 | concealedCombinations: [ 48 | new Pair(new DotTile(1)), 49 | new Sequence(new DotTile(1), new DotTile(2), new DotTile(3)), 50 | new Triplet(new DotTile(3)), 51 | new Triplet(new DotTile(4)), 52 | new Triplet(new DotTile(5)) 53 | ], 54 | winningCombinationIndex: 0, 55 | winningTileIndex: 1 56 | }) 57 | 58 | test('should return 2 fu', () => { 59 | const result = new WaitRule().check(hand) 60 | expect(result).toStrictEqual({ key: 'wait', fuValue: 2, quantity: 1 }) 61 | }) 62 | }) 63 | 64 | describe('given the wait is not a single, edge or closed wait', () => { 65 | const hand = new Hand({ 66 | concealedCombinations: [ 67 | new Pair(new DotTile(1)), 68 | new Triplet(new DotTile(2)), 69 | new Triplet(new DotTile(3)), 70 | new Triplet(new DotTile(4)), 71 | new Triplet(new DotTile(5)) 72 | ], 73 | winningCombinationIndex: 1, 74 | winningTileIndex: 0 75 | }) 76 | 77 | test('should not return any fu info', () => { 78 | const result = new WaitRule().check(hand) 79 | expect(result).toBeUndefined() 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /tests/unit/fu-calculation/rules/winning-fu-rule.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import WinningFuRule from '@/core/fu-calculation/rules/winning-fu-rule' 3 | 4 | test('should return 20 fu', () => { 5 | const result = new WinningFuRule().check() 6 | expect(result).toStrictEqual({ key: 'win', fuValue: 20, quantity: 1 }) 7 | }) 8 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chankan-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ChankanYaku from '@/core/han-calculation/yakus/chankan-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new ChankanYaku() 6 | 7 | test('chankan (Robbing the quad) valid hand', () => { 8 | const hand = new Hand({ yakus: ['chankan'] }) 9 | expect(sut.check(hand)).toStrictEqual({ key: 'chankan', hanValue: 1, yakumanValue: 0 }) 10 | }) 11 | 12 | test('chankan (Robbing the quad) invalid hand', () => { 13 | const hand = new Hand() 14 | expect(sut.check(hand)).toBeUndefined() 15 | }) 16 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chanta-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ChantaYaku from '@/core/han-calculation/yakus/chanta-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile } from '@/core/tile-classes' 6 | 7 | const sut = new ChantaYaku() 8 | 9 | const validConcealedHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 12 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 13 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 14 | new Triplet(new DragonTile('red')), 15 | new Pair(new DotTile(1)) 16 | ] 17 | }) 18 | 19 | test('chanta (outside hand) valid concealed hand', () => { 20 | expect(sut.check(validConcealedHand)).toStrictEqual({ key: 'chanta', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validOpenHand = new Hand({ 24 | concealedCombinations: [ 25 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 26 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 27 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)) 28 | ], 29 | openCombinations: [ 30 | new Triplet(new DragonTile('red')), 31 | new Pair(new DotTile(1)) 32 | ] 33 | }) 34 | 35 | test('chanta (outside hand) valid open hand', () => { 36 | expect(sut.check(validOpenHand)).toStrictEqual({ key: 'chanta', hanValue: 1, yakumanValue: 0 }) 37 | }) 38 | 39 | const invalidHandWithoutChii = new Hand({ 40 | concealedCombinations: [ 41 | new Triplet(new BambooTile(1)), 42 | new Triplet(new BambooTile(9)), 43 | new Triplet(new DotTile(1)), 44 | new Triplet(new DragonTile('red')), 45 | new Pair(new DotTile(9)) 46 | ] 47 | }) 48 | 49 | test('chanta (outside hand) invalid without one sequence', () => { 50 | expect(sut.check(invalidHandWithoutChii)).toBeUndefined() 51 | }) 52 | 53 | // check for terminals 54 | const invalidHandWithSetWithoutTerminalOrHonor = new Hand({ 55 | concealedCombinations: [ 56 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 57 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 58 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 59 | new Triplet(new DragonTile('red')), 60 | new Pair(new DotTile(1)) 61 | ] 62 | }) 63 | 64 | test('chanta (outside hand) invalid hand with set without terminal or honor', () => { 65 | expect(sut.check(invalidHandWithSetWithoutTerminalOrHonor)).toBeUndefined() 66 | }) 67 | 68 | // check of honor tiles 69 | const invalidHandWithSetWithoutTerminalOrHonor2 = new Hand({ 70 | concealedCombinations: [ 71 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 72 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 73 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 74 | new Triplet(new DotTile(9)), 75 | new Pair(new DotTile(1)) 76 | ] 77 | }) 78 | 79 | test('chanta (outside hand) invalid hand with set without terminal or honor', () => { 80 | expect(sut.check(invalidHandWithSetWithoutTerminalOrHonor2)).toBeUndefined() 81 | }) 82 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chiihou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import ChiihouYaku from '@/core/han-calculation/yakus/chiihou-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | describe('given the hand contains the chiihou yaku', () => { 6 | const hand = new Hand({ yakus: ['chiihou'] }) 7 | 8 | test('should be eligible for chiihou', () => { 9 | expect(new ChiihouYaku().check(hand)).toStrictEqual({ key: 'chiihou', hanValue: 0, yakumanValue: 1 }) 10 | }) 11 | }) 12 | 13 | describe('given the hand does not contain the chiihou yaku', () => { 14 | const hand = new Hand() 15 | 16 | test('should not be eligible for chiihou', () => { 17 | expect(new ChiihouYaku().check(hand)).toBeUndefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chiitoitsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ChiiToitsuYaku from '@/core/han-calculation/yakus/chiitoitsu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new ChiiToitsuYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Pair(new BambooTile(1)), 12 | new Pair(new BambooTile(2)), 13 | new Pair(new BambooTile(3)), 14 | new Pair(new BambooTile(4)), 15 | new Pair(new BambooTile(5)), 16 | new Pair(new BambooTile(6)), 17 | new Pair(new BambooTile(7)) 18 | ] 19 | }) 20 | test('chiitoitsu (seven pairs) valid hand', () => { 21 | expect(sut.check(validHand)).toStrictEqual({ key: 'chiitoitsu', hanValue: 2, yakumanValue: 0 }) 22 | }) 23 | 24 | const invalidHandWithoutSevenPairs = new Hand({ 25 | concealedCombinations: [ 26 | new Triplet(new BambooTile(1)), 27 | new Triplet(new BambooTile(2)), 28 | new Triplet(new BambooTile(3)), 29 | new Triplet(new BambooTile(4)), 30 | new Pair(new BambooTile(5)) 31 | ] 32 | }) 33 | test('chiitoitsu (seven pairs) invalid hand without seven pairs', () => { 34 | expect(sut.check(invalidHandWithoutSevenPairs)).toBeUndefined() 35 | }) 36 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chinitsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ChinitsuYaku from '@/core/han-calculation/yakus/chinitsu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile } from '@/core/tile-classes' 6 | 7 | const sut = new ChinitsuYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 12 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 13 | new Triplet(new BambooTile(1)), 14 | new Triplet(new BambooTile(9)), 15 | new Pair(new BambooTile(7)) 16 | ] 17 | }) 18 | 19 | test('chinitsu (full flush) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'chinitsu', hanValue: 6, yakumanValue: 0 }) 21 | }) 22 | 23 | const validHandWithOpenCombination = new Hand({ 24 | concealedCombinations: [ 25 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 26 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 27 | new Pair(new BambooTile(7)) 28 | ], 29 | openCombinations: [ 30 | new Triplet(new BambooTile(1)), 31 | new Triplet(new BambooTile(9)) 32 | ] 33 | }) 34 | 35 | test('chinitsu (full flush) valid hand with open combination', () => { 36 | expect(sut.check(validHandWithOpenCombination)).toStrictEqual({ key: 'chinitsu', hanValue: 5, yakumanValue: 0 }) 37 | }) 38 | 39 | const invalidHandWithHonorTiles = new Hand({ 40 | concealedCombinations: [ 41 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 42 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 43 | new Triplet(new BambooTile(1)), 44 | new Triplet(new BambooTile(9)), 45 | new Pair(new DragonTile('white')) 46 | ] 47 | }) 48 | 49 | test('chinitsu (full flush) invalid hand with honor tile', () => { 50 | expect(sut.check(invalidHandWithHonorTiles)).toBeUndefined() 51 | }) 52 | 53 | const invalidHandWithTwoSuit = new Hand({ 54 | concealedCombinations: [ 55 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 56 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 57 | new Triplet(new BambooTile(1)), 58 | new Triplet(new DotTile(9)), 59 | new Pair(new BambooTile(7)) 60 | ] 61 | }) 62 | 63 | test('chinitsu (full flush) invalid hand with two suit', () => { 64 | expect(sut.check(invalidHandWithTwoSuit)).toBeUndefined() 65 | }) 66 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/chinroutou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ChinroutouYaku from '@/core/han-calculation/yakus/chinroutou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile, WindTile, CharacterTile } from '@/core/tile-classes' 6 | 7 | test('chinroutou (all terminals) valid hand with only teminal tiles', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new DotTile(1)), 11 | new Triplet(new BambooTile(1)), 12 | new Triplet(new BambooTile(9)), 13 | new Triplet(new CharacterTile(1)) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new CharacterTile(9)) 17 | ] 18 | }) 19 | 20 | expect(new ChinroutouYaku().check(hand)).toStrictEqual({ key: 'chinroutou', hanValue: 0, yakumanValue: 1 }) 21 | }) 22 | 23 | test('chinroutou (all terminals) invalid hand because it contains a dragon tile', () => { 24 | const hand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new DragonTile('green')), 27 | new Triplet(new BambooTile(1)), 28 | new Triplet(new BambooTile(9)), 29 | new Triplet(new CharacterTile(1)) 30 | ], 31 | openCombinations: [ 32 | new Triplet(new CharacterTile(9)) 33 | ] 34 | }) 35 | 36 | expect(new ChinroutouYaku().check(hand)).toBeUndefined() 37 | }) 38 | 39 | test('chinroutou (all terminals) invalid hand because it contains a wind tile', () => { 40 | const hand = new Hand({ 41 | concealedCombinations: [ 42 | new Pair(new WindTile('east')), 43 | new Triplet(new BambooTile(1)), 44 | new Triplet(new BambooTile(9)), 45 | new Triplet(new CharacterTile(1)) 46 | ], 47 | openCombinations: [ 48 | new Triplet(new CharacterTile(9)) 49 | ] 50 | }) 51 | 52 | expect(new ChinroutouYaku().check(hand)).toBeUndefined() 53 | }) 54 | 55 | test('chinroutou (all terminals) invalid hand because it contains a non terminal numbered tile', () => { 56 | const hand = new Hand({ 57 | concealedCombinations: [ 58 | new Pair(new DotTile(2)), 59 | new Triplet(new BambooTile(1)), 60 | new Triplet(new BambooTile(9)), 61 | new Triplet(new CharacterTile(1)) 62 | ], 63 | openCombinations: [ 64 | new Triplet(new CharacterTile(9)) 65 | ] 66 | }) 67 | 68 | expect(new ChinroutouYaku().check(hand)).toBeUndefined() 69 | }) 70 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/daisangen-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import DaisangenYaku from '@/core/han-calculation/yakus/daisangen-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { DotTile, DragonTile } from '@/core/tile-classes' 6 | 7 | test('daisangen (big three dragons) valid hand with a triplet of each dragon tile', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new DotTile(1)), 11 | new Triplet(new DragonTile('red')), 12 | new Triplet(new DragonTile('green')), 13 | new Triplet(new DotTile(2)) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new DragonTile('white')) 17 | ] 18 | }) 19 | 20 | expect(new DaisangenYaku().check(hand)).toStrictEqual({ key: 'daisangen', hanValue: 0, yakumanValue: 1 }) 21 | }) 22 | 23 | test('daisangen (big three dragons) valid hand with a quad of each dragon tile', () => { 24 | const hand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new DotTile(1)), 27 | new Quad(new DragonTile('red')), 28 | new Quad(new DragonTile('green')), 29 | new Triplet(new DotTile(2)) 30 | ], 31 | openCombinations: [ 32 | new Quad(new DragonTile('white')) 33 | ] 34 | }) 35 | 36 | expect(new DaisangenYaku().check(hand)).toStrictEqual({ key: 'daisangen', hanValue: 0, yakumanValue: 1 }) 37 | }) 38 | 39 | test('daisangen (big three dragons) valid hand with a mix of triplet and quad of each dragon tile', () => { 40 | const hand = new Hand({ 41 | concealedCombinations: [ 42 | new Pair(new DotTile(1)), 43 | new Quad(new DragonTile('red')), 44 | new Triplet(new DragonTile('green')), 45 | new Triplet(new DotTile(2)) 46 | ], 47 | openCombinations: [ 48 | new Quad(new DragonTile('white')) 49 | ] 50 | }) 51 | 52 | expect(new DaisangenYaku().check(hand)).toStrictEqual({ key: 'daisangen', hanValue: 0, yakumanValue: 1 }) 53 | }) 54 | 55 | test('daisangen (big three dragons) invalid hand because there is no triplet or quad of white dragon', () => { 56 | const hand = new Hand({ 57 | concealedCombinations: [ 58 | new Pair(new DotTile(1)), 59 | new Triplet(new DragonTile('red')), 60 | new Triplet(new DragonTile('green')), 61 | new Triplet(new DotTile(3)) 62 | ], 63 | openCombinations: [ 64 | new Triplet(new DotTile(2)) 65 | ] 66 | }) 67 | 68 | expect(new DaisangenYaku().check(hand)).toBeUndefined() 69 | }) 70 | 71 | test('daisangen (big three dragons) invalid hand because there is no triplet or quad of red dragon', () => { 72 | const hand = new Hand({ 73 | concealedCombinations: [ 74 | new Pair(new DotTile(1)), 75 | new Triplet(new DragonTile('white')), 76 | new Triplet(new DragonTile('green')), 77 | new Triplet(new DotTile(3)) 78 | ], 79 | openCombinations: [ 80 | new Triplet(new DotTile(2)) 81 | ] 82 | }) 83 | 84 | expect(new DaisangenYaku().check(hand)).toBeUndefined() 85 | }) 86 | 87 | test('daisangen (big three dragons) invalid hand because there is no triplet or quad of green dragon', () => { 88 | const hand = new Hand({ 89 | concealedCombinations: [ 90 | new Pair(new DotTile(1)), 91 | new Triplet(new DragonTile('white')), 92 | new Triplet(new DragonTile('red')), 93 | new Triplet(new DotTile(3)) 94 | ], 95 | openCombinations: [ 96 | new Triplet(new DotTile(2)) 97 | ] 98 | }) 99 | 100 | expect(new DaisangenYaku().check(hand)).toBeUndefined() 101 | }) 102 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/daisuushii-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import DaisuushiiYaku from '@/core/han-calculation/yakus/daisuushii-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { DotTile, WindTile } from '@/core/tile-classes' 6 | 7 | test('daisuushii (big four winds) valid hand with a triplet of each wind tile', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new DotTile(1)), 11 | new Triplet(new WindTile('east')), 12 | new Triplet(new WindTile('south')), 13 | new Triplet(new WindTile('west')) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new WindTile('north')) 17 | ] 18 | }) 19 | 20 | expect(new DaisuushiiYaku().check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 1 }) 21 | expect(new DaisuushiiYaku({ allowDoubleYakuman: true }).check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 2 }) 22 | }) 23 | 24 | test('daisuushii (big four winds) valid hand with a quad of each wind tile', () => { 25 | const hand = new Hand({ 26 | concealedCombinations: [ 27 | new Pair(new DotTile(1)), 28 | new Quad(new WindTile('east')), 29 | new Quad(new WindTile('south')), 30 | new Quad(new WindTile('west')) 31 | ], 32 | openCombinations: [ 33 | new Quad(new WindTile('north')) 34 | ] 35 | }) 36 | 37 | expect(new DaisuushiiYaku().check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 1 }) 38 | expect(new DaisuushiiYaku({ allowDoubleYakuman: true }).check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 2 }) 39 | }) 40 | 41 | test('daisuushii (big four winds) valid hand with a mix of four triplet/quad of wind tile', () => { 42 | const hand = new Hand({ 43 | concealedCombinations: [ 44 | new Pair(new DotTile(1)), 45 | new Quad(new WindTile('east')), 46 | new Triplet(new WindTile('south')), 47 | new Quad(new WindTile('west')) 48 | ], 49 | openCombinations: [ 50 | new Triplet(new WindTile('north')) 51 | ] 52 | }) 53 | 54 | expect(new DaisuushiiYaku().check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 1 }) 55 | expect(new DaisuushiiYaku({ allowDoubleYakuman: true }).check(hand)).toStrictEqual({ key: 'daisuushii', hanValue: 0, yakumanValue: 2 }) 56 | }) 57 | 58 | test('daisuushii (big four winds) invalid hand because it does not contains four triplet/quad of wind tile', () => { 59 | const hand = new Hand({ 60 | concealedCombinations: [ 61 | new Pair(new WindTile('east')), 62 | new Quad(new DotTile(1)), 63 | new Triplet(new WindTile('south')), 64 | new Quad(new WindTile('west')) 65 | ], 66 | openCombinations: [ 67 | new Triplet(new WindTile('north')) 68 | ] 69 | }) 70 | 71 | expect(new DaisuushiiYaku().check(hand)).toBeUndefined() 72 | }) 73 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/double-riichi-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import DoubleRiichiYaku from '@/core/han-calculation/yakus/double-riichi-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new DoubleRiichiYaku() 6 | 7 | describe('given the hand contains the double riichi yaku', () => { 8 | const hand = new Hand({ yakus: ['double riichi'] }) 9 | 10 | test('should be eligible for double riichi', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'double riichi', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the double riichi yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for double riichi', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/haitei-raoyue-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import HaiteiRaoyueYaku from '@/core/han-calculation/yakus/haitei-raoyue-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new HaiteiRaoyueYaku() 6 | 7 | describe('given the hand contains the haitei raoyue yaku', () => { 8 | const hand = new Hand({ yakus: ['haitei raoyue'] }) 9 | 10 | test('should be eligible for haitei raoyue', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'haitei raoyue', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the haitei raoyue yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for haitei raoyue', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/honitsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import HonitsuYaku from '@/core/han-calculation/yakus/honitsu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile } from '@/core/tile-classes' 6 | 7 | const sut = new HonitsuYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Pair(new DragonTile('green')), 12 | new Triplet(new BambooTile(1)), 13 | new Triplet(new BambooTile(2)), 14 | new Triplet(new BambooTile(3)), 15 | new Triplet(new BambooTile(4)) 16 | ] 17 | }) 18 | test('honitsu (half flush) valid hand', () => { 19 | expect(sut.check(validHand)).toStrictEqual({ key: 'honitsu', hanValue: 3, yakumanValue: 0 }) 20 | }) 21 | 22 | const validHandWithOpenCombination = new Hand({ 23 | concealedCombinations: [ 24 | new Pair(new DragonTile('green')), 25 | new Triplet(new BambooTile(3)), 26 | new Triplet(new BambooTile(4)) 27 | ], 28 | openCombinations: [ 29 | new Triplet(new BambooTile(1)), 30 | new Triplet(new BambooTile(2)) 31 | ] 32 | }) 33 | test('honitsu (half flush) valid hand with open combination', () => { 34 | expect(sut.check(validHandWithOpenCombination)).toStrictEqual({ key: 'honitsu', hanValue: 2, yakumanValue: 0 }) 35 | }) 36 | 37 | const invalidHandWithoutHonorTile = new Hand({ 38 | concealedCombinations: [ 39 | new Triplet(new BambooTile(9)), 40 | new Triplet(new BambooTile(1)), 41 | new Triplet(new BambooTile(2)), 42 | new Triplet(new BambooTile(3)), 43 | new Pair(new BambooTile(4)) 44 | ] 45 | }) 46 | test('honitsu (half flush) invalid hand without honor tile', () => { 47 | expect(sut.check(invalidHandWithoutHonorTile)).toBeUndefined() 48 | }) 49 | 50 | const invalidHandWithTwoSuit = new Hand({ 51 | concealedCombinations: [ 52 | new Pair(new DragonTile('green')), 53 | new Triplet(new BambooTile(1)), 54 | new Triplet(new BambooTile(2)), 55 | new Triplet(new BambooTile(3)), 56 | new Triplet(new DotTile(4)) 57 | ] 58 | }) 59 | test('honitsu (half flush) invalid hand with two suit', () => { 60 | expect(sut.check(invalidHandWithTwoSuit)).toBeUndefined() 61 | }) 62 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/honroutou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import HonroutouYaku from '@/core/han-calculation/yakus/honroutou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile, WindTile } from '@/core/tile-classes' 6 | 7 | const sut = new HonroutouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Triplet(new DragonTile('green')), 12 | new Triplet(new DragonTile('white')), 13 | new Triplet(new WindTile('east')), 14 | new Triplet(new DotTile(1)), 15 | new Pair(new BambooTile(9)) 16 | ] 17 | }) 18 | 19 | test('honroutou (all terminals & honors) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'honroutou', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const invalidHandWithTileNotHonorOrTerminal = new Hand({ 24 | concealedCombinations: [ 25 | new Triplet(new DragonTile('green')), 26 | new Triplet(new DragonTile('white')), 27 | new Triplet(new WindTile('east')), 28 | new Triplet(new DotTile(1)), 29 | new Pair(new BambooTile(5)) 30 | ] 31 | }) 32 | 33 | test('honroutou (all terminals & honors) invvalid hand with tiles not honor or terminal', () => { 34 | expect(sut.check(invalidHandWithTileNotHonorOrTerminal)).toBeUndefined() 35 | }) 36 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/houtei-raoyui-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import HouteiRaoyuiYaku from '@/core/han-calculation/yakus/houtei-raoyui-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new HouteiRaoyuiYaku() 6 | 7 | describe('given the hand contains the houtei raoyui yaku', () => { 8 | const hand = new Hand({ yakus: ['houtei raoyui'] }) 9 | 10 | test('should be eligible for houtei raoyui', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'houtei raoyui', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the houtei raoyui yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for houtei raoyui', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/iipeikou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import IipeikouYaku from '@/core/han-calculation/yakus/iipeikou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, CharacterTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new IipeikouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 12 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 13 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('iipeikou (pure double sequence) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'iipeikou', hanValue: 1, yakumanValue: 0 }) 21 | }) 22 | 23 | // test with a open hand 24 | const invalidOpenHand = new Hand({ 25 | concealedCombinations: [ 26 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 27 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 28 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)) 29 | ], 30 | openCombinations: [ 31 | new Triplet(new DotTile(3)), 32 | new Pair(new DotTile(7)) 33 | ] 34 | }) 35 | 36 | test('iipeikou (pure double sequence) invalid with a open hand', () => { 37 | expect(sut.check(invalidOpenHand)).toBeUndefined() 38 | }) 39 | 40 | // test with different number 41 | const invalidHandWithoutTwoIdenticalChii1 = new Hand({ 42 | concealedCombinations: [ 43 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 44 | new Sequence(new BambooTile(3), new BambooTile(4), new BambooTile(5)), 45 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 46 | new Triplet(new DotTile(3)), 47 | new Pair(new DotTile(7)) 48 | ] 49 | }) 50 | 51 | test('iipeikou (pure double sequence) invalid hand without two identical sequence', () => { 52 | expect(sut.check(invalidHandWithoutTwoIdenticalChii1)).toBeUndefined() 53 | }) 54 | 55 | // test with different suit 56 | const invalidHandWithoutTwoIdenticalChii2 = new Hand({ 57 | concealedCombinations: [ 58 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 59 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 60 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 61 | new Triplet(new DotTile(3)), 62 | new Pair(new DotTile(7)) 63 | ] 64 | }) 65 | 66 | test('iipeikou (pure double sequence) invalid hand without two identical sequence', () => { 67 | expect(sut.check(invalidHandWithoutTwoIdenticalChii2)).toBeUndefined() 68 | }) 69 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/ippatsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import IppatsuYaku from '@/core/han-calculation/yakus/ippatsu-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new IppatsuYaku() 6 | 7 | describe('given the hand contains the ippatsu yaku', () => { 8 | const hand = new Hand({ yakus: ['ippatsu'] }) 9 | 10 | test('should be eligible for ippatsu', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'ippatsu', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the ippatsu yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for ippatsu', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/ittsuu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import IttsuuYaku from '@/core/han-calculation/yakus/ittsuu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new IttsuuYaku() 8 | 9 | const validConcealedHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 12 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 13 | new Sequence(new BambooTile(4), new BambooTile(5), new BambooTile(6)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('ittsuu (pure straight) valid concealed hand', () => { 20 | expect(sut.check(validConcealedHand)).toStrictEqual({ key: 'ittsuu', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validOpenHand = new Hand({ 24 | concealedCombinations: [ 25 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 26 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 27 | new Sequence(new BambooTile(4), new BambooTile(5), new BambooTile(6)) 28 | ], 29 | openCombinations: [ 30 | new Triplet(new DotTile(3)), 31 | new Pair(new DotTile(7)) 32 | ] 33 | }) 34 | 35 | test('ittsuu (pure straight) valid open hand', () => { 36 | expect(sut.check(validOpenHand)).toStrictEqual({ key: 'ittsuu', hanValue: 1, yakumanValue: 0 }) 37 | }) 38 | 39 | const invalidHandWithoutOneToNineNumber = new Hand({ 40 | concealedCombinations: [ 41 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 42 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 43 | new Sequence(new BambooTile(4), new BambooTile(5), new BambooTile(6)), 44 | new Triplet(new DotTile(3)), 45 | new Pair(new DotTile(7)) 46 | ] 47 | }) 48 | 49 | test('ittsuu (pure straight) invalid hand without one to nine number', () => { 50 | expect(sut.check(invalidHandWithoutOneToNineNumber)).toBeUndefined() 51 | }) 52 | 53 | const invalidHandWithTwoSuit = new Hand({ 54 | concealedCombinations: [ 55 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 56 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 57 | new Sequence(new BambooTile(4), new BambooTile(5), new BambooTile(6)), 58 | new Triplet(new DotTile(3)), 59 | new Pair(new DotTile(7)) 60 | ] 61 | }) 62 | 63 | test('ittsuu (pure straight) valid hand', () => { 64 | expect(sut.check(invalidHandWithTwoSuit)).toBeUndefined() 65 | }) 66 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/junchan-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import JunchanYaku from '@/core/han-calculation/yakus/junchan-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, CharacterTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new JunchanYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 12 | new Triplet(new BambooTile(9)), 13 | new Triplet(new DotTile(1)), 14 | new Triplet(new DotTile(9)), 15 | new Pair(new CharacterTile(1)) 16 | ] 17 | }) 18 | 19 | test('junchan (terminals in all sets) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'junchan', hanValue: 3, yakumanValue: 0 }) 21 | }) 22 | 23 | const invalidHandWithoutChii = new Hand({ 24 | concealedCombinations: [ 25 | new Triplet(new BambooTile(1)), 26 | new Triplet(new BambooTile(9)), 27 | new Triplet(new DotTile(1)), 28 | new Triplet(new DotTile(9)), 29 | new Pair(new CharacterTile(1)) 30 | ] 31 | }) 32 | 33 | test('junchan (terminals in all sets) invalid hand without sequence', () => { 34 | expect(sut.check(invalidHandWithoutChii)).toBeUndefined() 35 | }) 36 | 37 | const invalidHandWithSetWithoutTerminal = new Hand({ 38 | concealedCombinations: [ 39 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 40 | new Triplet(new BambooTile(8)), 41 | new Triplet(new DotTile(1)), 42 | new Triplet(new DotTile(9)), 43 | new Pair(new CharacterTile(1)) 44 | ] 45 | }) 46 | 47 | test('junchan (terminals in all sets) with set without terminal', () => { 48 | expect(sut.check(invalidHandWithSetWithoutTerminal)).toBeUndefined() 49 | }) 50 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/menzen-tsumo-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import MenzenTsumoYaku from '@/core/han-calculation/yakus/menzen-tsumo-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile } from '@/core/tile-classes' 6 | 7 | const sut = new MenzenTsumoYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 12 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 13 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 14 | new Triplet(new DragonTile('red')), 15 | new Pair(new DotTile(1)) 16 | ], 17 | winningType: 'tsumo' 18 | }) 19 | 20 | test('Menzen Tsumo (Fully Concealed Hand) valid hand', () => { 21 | expect(sut.check(validHand)).toStrictEqual({ key: 'menzen tsumo', hanValue: 1, yakumanValue: 0 }) 22 | }) 23 | 24 | const invalidHandWithRon = new Hand({ 25 | concealedCombinations: [ 26 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 27 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 28 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 29 | new Triplet(new DragonTile('red')), 30 | new Pair(new DotTile(1)) 31 | ], 32 | winningType: 'ron' 33 | }) 34 | 35 | test('Menzen Tsumo (Fully Concealed Hand) invalid hand win by ron', () => { 36 | expect(sut.check(invalidHandWithRon)).toBeUndefined() 37 | }) 38 | 39 | const invalidHandNotConcealed = new Hand({ 40 | concealedCombinations: [ 41 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 42 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 43 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 44 | new Pair(new DotTile(1)) 45 | ], 46 | openCombinations: [ 47 | new Triplet(new DragonTile('red')) 48 | ], 49 | winningType: 'tsumo' 50 | }) 51 | 52 | test('Menzen Tsumo (Fully Concealed Hand) invalid hand with open combination', () => { 53 | expect(sut.check(invalidHandNotConcealed)).toBeUndefined() 54 | }) 55 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/open-riichi-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import OpenRiichiYaku from '@/core/han-calculation/yakus/open-riichi-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | describe('given the hand contains the riichi yaku', () => { 6 | test('should be worth 1 han with the hand is won by tsumo (self-draw)', () => { 7 | const hand = new Hand({ yakus: ['riichi', 'open riichi'], winningType: 'tsumo' }) 8 | expect(new OpenRiichiYaku().check(hand)).toStrictEqual({ key: 'open riichi', hanValue: 1, yakumanValue: 0 }) 9 | }) 10 | 11 | test('should be worth 1 han if the hand was won by ron (discard) and the yaku option for ronAsYakuman is false (default)', () => { 12 | const hand = new Hand({ yakus: ['riichi', 'open riichi'], winningType: 'ron' }) 13 | expect(new OpenRiichiYaku().check(hand)).toStrictEqual({ key: 'open riichi', hanValue: 1, yakumanValue: 0 }) 14 | }) 15 | 16 | test('should be worth 1 yakuman if the hand was won by ron (discard) and the yaku option for ronAsYakuman is true', () => { 17 | const hand = new Hand({ yakus: ['riichi', 'open riichi'], winningType: 'ron' }) 18 | expect(new OpenRiichiYaku({ ronAsYakuman: true }).check(hand)).toStrictEqual({ key: 'open riichi', hanValue: 0, yakumanValue: 1 }) 19 | }) 20 | }) 21 | 22 | describe('given the hand does not contain the open riichi yaku', () => { 23 | const hand = new Hand() 24 | 25 | test('should not be eligible for open riichi', () => { 26 | expect(new OpenRiichiYaku().check(hand)).toBeUndefined() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/renhou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import RenhouYaku from '@/core/han-calculation/yakus/renhou-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | describe('given the hand contains the renhou yaku', () => { 6 | const hand = new Hand({ yakus: ['renhou'] }) 7 | 8 | test('should be worth 5 han by default', () => { 9 | expect(new RenhouYaku().check(hand)).toStrictEqual({ key: 'renhou', hanValue: 5, yakumanValue: 0 }) 10 | }) 11 | 12 | test('shoud be worth the amount of han configured with the yaku options', () => { 13 | expect(new RenhouYaku({ hanValue: 4, yakumanValue: 0 }).check(hand)).toStrictEqual({ key: 'renhou', hanValue: 4, yakumanValue: 0 }) 14 | }) 15 | 16 | test('shoud be worth the amount of yakuman configured with the yaku options', () => { 17 | expect(new RenhouYaku({ hanValue: 0, yakumanValue: 1 }).check(hand)).toStrictEqual({ key: 'renhou', hanValue: 0, yakumanValue: 1 }) 18 | }) 19 | }) 20 | 21 | describe('given the hand does not contain the renhou yaku', () => { 22 | const hand = new Hand() 23 | 24 | test('should not be eligible for renhou', () => { 25 | expect(new RenhouYaku().check(hand)).toBeUndefined() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/riichi-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import RiichiYaku from '@/core/han-calculation/yakus/riichi-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new RiichiYaku() 6 | 7 | describe('given the hand contains the riichi yaku', () => { 8 | const hand = new Hand({ yakus: ['riichi'] }) 9 | 10 | test('should be eligible for riichi', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'riichi', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the riichi yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for riichi', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/rinshan-kaihou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import RinshanKaihouYaku from '@/core/han-calculation/yakus/rinshan-kaihou-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new RinshanKaihouYaku() 6 | 7 | describe('given the hand contains the rinshan kaihou yaku', () => { 8 | const hand = new Hand({ yakus: ['rinshan kaihou'] }) 9 | 10 | test('should be eligible for rinshan kaihou', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'rinshan kaihou', hanValue: 1, yakumanValue: 0 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the rinshan kaihou yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for rinshan kaihou', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/ryanpeikou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import RyanpeikouYaku from '@/core/han-calculation/yakus/ryanpeikou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new RyanpeikouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 12 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 13 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 14 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 15 | new Pair(new DotTile(1)) 16 | ] 17 | }) 18 | 19 | test('ryan peikou (twice pure double chiis) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'ryanpeikou', hanValue: 3, yakumanValue: 0 }) 21 | }) 22 | 23 | describe('given the hand have a valid open ryan peikou (twice pure double chiis)', () => { 24 | const validOpenHand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new DotTile(1)), 27 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)) 28 | ], 29 | openCombinations: [ 30 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 31 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 32 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)) 33 | ] 34 | }) 35 | 36 | test('should not return any han by default', () => { 37 | expect(sut.check(validOpenHand)).toBeUndefined() 38 | }) 39 | 40 | test('should return 2 han if the yaku option allow open hand', () => { 41 | expect(new RyanpeikouYaku({ allowOpen: true }).check(validOpenHand)).toStrictEqual({ key: 'ryanpeikou', hanValue: 2, yakumanValue: 0 }) 42 | }) 43 | }) 44 | 45 | const invalidHandWitoutTwoPairOfChii = new Hand({ 46 | concealedCombinations: [ 47 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 48 | new Sequence(new BambooTile(1), new BambooTile(2), new BambooTile(3)), 49 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 50 | new Sequence(new DotTile(1), new DotTile(2), new DotTile(3)), 51 | new Pair(new DotTile(1)) 52 | ] 53 | }) 54 | 55 | test('ryan peikou (twice pure double chiis) invalid hand without two pair of sequence', () => { 56 | expect(sut.check(invalidHandWitoutTwoPairOfChii)).toBeUndefined() 57 | }) 58 | 59 | const invalidHandWithoutChii = new Hand({ 60 | concealedCombinations: [ 61 | new Triplet(new BambooTile(1)), 62 | new Triplet(new BambooTile(2)), 63 | new Triplet(new DotTile(7)), 64 | new Triplet(new DotTile(1)), 65 | new Pair(new DotTile(1)) 66 | ] 67 | }) 68 | 69 | test('ryan peikou (twice pure double chiis) invalid hand without sequence', () => { 70 | expect(sut.check(invalidHandWithoutChii)).toBeUndefined() 71 | }) 72 | 73 | const invalidHandWithThreeIdenticalChii = new Hand({ 74 | concealedCombinations: [ 75 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 76 | new Pair(new BambooTile(4)), 77 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 78 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 79 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)) 80 | ] 81 | }) 82 | 83 | test('ryan peikou (twice pure double chiis) invalid hand with three identical sequences', () => { 84 | expect(sut.check(invalidHandWithThreeIdenticalChii)).toBeUndefined() 85 | }) 86 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/sanankou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import SanankouYaku from '@/core/han-calculation/yakus/sanankou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | const sut = new SanankouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Triplet(new DotTile(1)), 12 | new Triplet(new DotTile(2)), 13 | new Triplet(new DotTile(3)), 14 | new Triplet(new DotTile(4)), 15 | new Pair(new DotTile(5)) 16 | ] 17 | }) 18 | test('Sanankou (3 concealed pons) valid hand', () => { 19 | expect(sut.check(validHand)).toStrictEqual({ key: 'sanankou', hanValue: 2, yakumanValue: 0 }) 20 | }) 21 | 22 | const validOpenHand = new Hand({ 23 | concealedCombinations: [ 24 | new Triplet(new DotTile(1)), 25 | new Triplet(new DotTile(2)), 26 | new Triplet(new DotTile(3)), 27 | new Pair(new DotTile(5)) 28 | ], 29 | openCombinations: [ 30 | new Triplet(new DotTile(4)) 31 | ] 32 | }) 33 | test('Sanankou (3 concealed pons) valid open hand', () => { 34 | expect(sut.check(validOpenHand)).toStrictEqual({ key: 'sanankou', hanValue: 2, yakumanValue: 0 }) 35 | }) 36 | 37 | const validHandWithKan = new Hand({ 38 | concealedCombinations: [ 39 | new Triplet(new DotTile(1)), 40 | new Triplet(new DotTile(2)), 41 | new Quad(new DotTile(3)), 42 | new Triplet(new DotTile(4)), 43 | new Pair(new DotTile(5)) 44 | ] 45 | }) 46 | test('Sanankou (3 concealed pons) valid hand with quad', () => { 47 | expect(sut.check(validHandWithKan)).toStrictEqual({ key: 'sanankou', hanValue: 2, yakumanValue: 0 }) 48 | }) 49 | 50 | const invalidHandWithLessThanThreeConcealedPon = new Hand({ 51 | concealedCombinations: [ 52 | new Triplet(new DotTile(3)), 53 | new Pair(new DotTile(5)) 54 | ], 55 | openCombinations: [ 56 | new Triplet(new DotTile(1)), 57 | new Triplet(new DotTile(2)), 58 | new Triplet(new DotTile(4)) 59 | ] 60 | }) 61 | test('Sanankou (3 concealed pons) invalid hand with less than 3 concealed pons', () => { 62 | expect(sut.check(invalidHandWithLessThanThreeConcealedPon)).toBeUndefined() 63 | }) 64 | 65 | describe('given the third ankou (concealed triplet) is also the winning tile', () => { 66 | const concealedCombinations = [ 67 | new Triplet(new DotTile(1)), 68 | new Triplet(new DotTile(2)), 69 | new Triplet(new DotTile(3)), 70 | new Pair(new DotTile(4)) 71 | ] 72 | const openCombinations = [ 73 | new Triplet(new DotTile(5)) 74 | ] 75 | 76 | test('should be valid for a san ankou yaku if the hand was won by tsumo (self-draw)', () => { 77 | const hand = new Hand({ concealedCombinations, openCombinations, winningType: 'tsumo', winningCombinationIndex: 0, winningTileIndex: 0 }) 78 | expect(sut.check(hand)).toStrictEqual({ key: 'sanankou', hanValue: 2, yakumanValue: 0 }) 79 | }) 80 | 81 | test('should not be eligible for san ankou yaku if the hand was won by ron (discard)', () => { 82 | const hand = new Hand({ concealedCombinations, openCombinations, winningType: 'ron', winningCombinationIndex: 0, winningTileIndex: 0 }) 83 | expect(sut.check(hand)).toBeUndefined() 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/sankantsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import SankantsuYaku from '@/core/han-calculation/yakus/sankantsu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | const sut = new SankantsuYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Quad(new DotTile(1)), 12 | new Quad(new DotTile(2)), 13 | new Quad(new DotTile(3)), 14 | new Triplet(new DotTile(4)), 15 | new Pair(new DotTile(6)) 16 | ] 17 | }) 18 | 19 | test('Sankantsu (3 kans) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'sankantsu', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validHandWithOpenKan = new Hand({ 24 | concealedCombinations: [ 25 | new Quad(new DotTile(3)), 26 | new Triplet(new DotTile(4)), 27 | new Pair(new DotTile(6)) 28 | ], 29 | openCombinations: [ 30 | new Quad(new DotTile(1)), 31 | new Quad(new DotTile(2)) 32 | ] 33 | }) 34 | 35 | test('Sankantsu (3 kans) valid hand with open quad', () => { 36 | expect(sut.check(validHandWithOpenKan)).toStrictEqual({ key: 'sankantsu', hanValue: 2, yakumanValue: 0 }) 37 | }) 38 | 39 | const invalidHandWithLessThanThreeKan = new Hand({ 40 | concealedCombinations: [ 41 | new Quad(new DotTile(1)), 42 | new Quad(new DotTile(2)), 43 | new Triplet(new DotTile(3)), 44 | new Triplet(new DotTile(4)), 45 | new Pair(new DotTile(6)) 46 | ] 47 | }) 48 | 49 | test('Sankantsu (3 kans) invalid hand with less than three quad', () => { 50 | expect(sut.check(invalidHandWithLessThanThreeKan)).toBeUndefined() 51 | }) 52 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/sanshoku-doujun-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import SanshokuDoujunYaku from '@/core/han-calculation/yakus/sanshoku-doujun-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, CharacterTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new SanshokuDoujunYaku() 8 | 9 | const validConcealedHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 12 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 13 | new Sequence(new CharacterTile(2), new CharacterTile(3), new CharacterTile(4)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('sanshoku doujun (mixed triple sequence) valid concealed hand', () => { 20 | expect(sut.check(validConcealedHand)).toStrictEqual({ key: 'sanshoku doujun', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validOpenHand = new Hand({ 24 | concealedCombinations: [ 25 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 26 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 27 | new Sequence(new CharacterTile(2), new CharacterTile(3), new CharacterTile(4)) 28 | ], 29 | openCombinations: [ 30 | new Triplet(new DotTile(3)), 31 | new Pair(new DotTile(7)) 32 | ] 33 | }) 34 | 35 | test('sanshoku doujun (mixed triple sequence) valid open hand', () => { 36 | expect(sut.check(validOpenHand)).toStrictEqual({ key: 'sanshoku doujun', hanValue: 1, yakumanValue: 0 }) 37 | }) 38 | 39 | const invalidHandWithOnlyTwoChiiOfTheSameValue = new Hand({ 40 | concealedCombinations: [ 41 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 42 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 43 | new Triplet(new CharacterTile(2)), 44 | new Triplet(new DotTile(3)), 45 | new Pair(new DotTile(7)) 46 | ] 47 | }) 48 | 49 | test('sanshoku doujun (mixed triple sequence) invalid hand with only two sequence of the same value', () => { 50 | expect(sut.check(invalidHandWithOnlyTwoChiiOfTheSameValue)).toBeUndefined() 51 | }) 52 | 53 | const invalidHandWithoutThreeDifferentSuit = new Hand({ 54 | concealedCombinations: [ 55 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 56 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 57 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 58 | new Triplet(new DotTile(3)), 59 | new Pair(new DotTile(7)) 60 | ] 61 | }) 62 | 63 | test('sanshoku doujun (mixed triple sequence) invalid hand without three different suit', () => { 64 | expect(sut.check(invalidHandWithoutThreeDifferentSuit)).toBeUndefined() 65 | }) 66 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/sanshoku-doukou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import SanshokuDokouYaku from '@/core/han-calculation/yakus/sanshoku-doukou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile, CharacterTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new SanshokuDokouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Triplet(new BambooTile(1)), 12 | new Triplet(new DotTile(1)), 13 | new Triplet(new CharacterTile(1)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('sanshoku dokou (triple triplet) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'sanshoku doukou', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const invalidHandWithoutThreeSamePon = new Hand({ 24 | concealedCombinations: [ 25 | new Triplet(new BambooTile(1)), 26 | new Triplet(new DotTile(1)), 27 | new Triplet(new CharacterTile(2)), 28 | new Triplet(new DotTile(3)), 29 | new Pair(new DotTile(7)) 30 | ] 31 | }) 32 | 33 | test('sanshoku dokou (triple triplet) invalid hand without three same triplet', () => { 34 | expect(sut.check(invalidHandWithoutThreeSamePon)).toBeUndefined() 35 | }) 36 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/shousangen-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ShousangenYaku from '@/core/han-calculation/yakus/shousangen-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { BambooTile, DragonTile, WindTile } from '@/core/tile-classes' 6 | 7 | const sut = new ShousangenYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Triplet(new DragonTile('red')), 12 | new Triplet(new DragonTile('green')), 13 | new Pair(new DragonTile('white')), 14 | new Triplet(new BambooTile(1)), 15 | new Triplet(new BambooTile(2)) 16 | ] 17 | }) 18 | 19 | test('shousangen (little three dragons) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'shousangen', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validHandWithKan = new Hand({ 24 | concealedCombinations: [ 25 | new Triplet(new DragonTile('red')), 26 | new Quad(new DragonTile('green')), 27 | new Pair(new DragonTile('white')), 28 | new Triplet(new BambooTile(1)), 29 | new Triplet(new BambooTile(2)) 30 | ] 31 | }) 32 | 33 | test('shousangen (little three dragons) valid hand with quad', () => { 34 | expect(sut.check(validHandWithKan)).toStrictEqual({ key: 'shousangen', hanValue: 2, yakumanValue: 0 }) 35 | }) 36 | 37 | const invalidHandWithoutDragonPair = new Hand({ 38 | concealedCombinations: [ 39 | new Triplet(new DragonTile('red')), 40 | new Triplet(new DragonTile('green')), 41 | new Pair(new WindTile('east')), 42 | new Triplet(new BambooTile(1)), 43 | new Triplet(new BambooTile(2)) 44 | ] 45 | }) 46 | 47 | test('shousangen (little three dragons) invalid hand without dragon pair', () => { 48 | expect(sut.check(invalidHandWithoutDragonPair)).toBeUndefined() 49 | }) 50 | 51 | const invalidHandWithoutTwoDragonPon = new Hand({ 52 | concealedCombinations: [ 53 | new Triplet(new DragonTile('red')), 54 | new Triplet(new WindTile('north')), 55 | new Pair(new DragonTile('white')), 56 | new Triplet(new BambooTile(1)), 57 | new Triplet(new BambooTile(2)) 58 | ] 59 | }) 60 | 61 | test('shousangen (little three dragons) invalid hand without two dragon triplet', () => { 62 | expect(sut.check(invalidHandWithoutTwoDragonPon)).toBeUndefined() 63 | }) 64 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/shousuushii-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ShousuushiiYaku from '@/core/han-calculation/yakus/shousuushii-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Quad } from '@/core/combination-classes' 5 | import { DotTile, WindTile } from '@/core/tile-classes' 6 | 7 | test('shousuushii (little four winds) valid hand with three triplet of wind tile and a pair of wind tile', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new WindTile('east')), 11 | new Triplet(new WindTile('south')), 12 | new Triplet(new WindTile('west')), 13 | new Triplet(new DotTile(1)) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new WindTile('north')) 17 | ] 18 | }) 19 | 20 | expect(new ShousuushiiYaku().check(hand)).toStrictEqual({ key: 'shousuushii', hanValue: 0, yakumanValue: 1 }) 21 | }) 22 | 23 | test('shousuushii (little four winds) valid hand with three quad of wind tile and a pair of wind tile', () => { 24 | const hand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new WindTile('south')), 27 | new Quad(new WindTile('west')), 28 | new Quad(new WindTile('north')), 29 | new Triplet(new DotTile(1)) 30 | ], 31 | openCombinations: [ 32 | new Quad(new WindTile('east')) 33 | ] 34 | }) 35 | 36 | expect(new ShousuushiiYaku().check(hand)).toStrictEqual({ key: 'shousuushii', hanValue: 0, yakumanValue: 1 }) 37 | }) 38 | 39 | test('shousuushii (little four winds) valid hand with a mix of three triplet/quad of wind tile and a pair of wind tile', () => { 40 | const hand = new Hand({ 41 | concealedCombinations: [ 42 | new Pair(new WindTile('west')), 43 | new Quad(new WindTile('north')), 44 | new Triplet(new WindTile('east')), 45 | new Triplet(new DotTile(1)) 46 | ], 47 | openCombinations: [ 48 | new Quad(new WindTile('south')) 49 | ] 50 | }) 51 | 52 | expect(new ShousuushiiYaku().check(hand)).toStrictEqual({ key: 'shousuushii', hanValue: 0, yakumanValue: 1 }) 53 | }) 54 | 55 | test('shousuushii (little four winds) invalid hand because it contains four triplet/quad of wind tiles (big four winds)', () => { 56 | const hand = new Hand({ 57 | concealedCombinations: [ 58 | new Pair(new DotTile(1)), 59 | new Triplet(new WindTile('north')), 60 | new Triplet(new WindTile('east')), 61 | new Triplet(new WindTile('west')) 62 | ], 63 | openCombinations: [ 64 | new Quad(new WindTile('south')) 65 | ] 66 | }) 67 | 68 | expect(new ShousuushiiYaku().check(hand)).toBeUndefined() 69 | }) 70 | 71 | test('shousuushii (little four winds) invalid hand because it doesn\'t contains a wind pair', () => { 72 | const hand = new Hand({ 73 | concealedCombinations: [ 74 | new Pair(new DotTile(1)), 75 | new Triplet(new WindTile('north')), 76 | new Triplet(new WindTile('east')), 77 | new Triplet(new DotTile(2)) 78 | ], 79 | openCombinations: [ 80 | new Quad(new WindTile('south')) 81 | ] 82 | }) 83 | 84 | expect(new ShousuushiiYaku().check(hand)).toBeUndefined() 85 | }) 86 | 87 | test('shousuushii (little four winds) invalid hand because it doesn\'t contains three triplet/quad of wind tile', () => { 88 | const hand = new Hand({ 89 | concealedCombinations: [ 90 | new Pair(new WindTile('west')), 91 | new Triplet(new WindTile('north')), 92 | new Triplet(new WindTile('east')), 93 | new Triplet(new DotTile(2)) 94 | ], 95 | openCombinations: [ 96 | new Quad(new DotTile(1)) 97 | ] 98 | }) 99 | 100 | expect(new ShousuushiiYaku().check(hand)).toBeUndefined() 101 | }) 102 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/suukantsu-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import SuukantsuYaku from '@/core/han-calculation/yakus/suukantsu-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence, Quad } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | const sut = new SuukantsuYaku() 8 | 9 | test('suukantsu (four kans) invalid hand because there is a triplet', () => { 10 | const hand = new Hand({ 11 | concealedCombinations: [ 12 | new Pair(new DotTile(5)), 13 | new Quad(new DotTile(1)), 14 | new Quad(new DotTile(2)), 15 | new Quad(new DotTile(3)), 16 | new Triplet(new DotTile(4)) 17 | ] 18 | }) 19 | 20 | expect(sut.check(hand)).toBeUndefined() 21 | }) 22 | 23 | test('suukantsu (four kans) invalid hand because there is a sequence', () => { 24 | const hand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new DotTile(5)), 27 | new Quad(new DotTile(1)), 28 | new Quad(new DotTile(2)), 29 | new Quad(new DotTile(3)), 30 | new Sequence(new DotTile(4)) 31 | ] 32 | }) 33 | 34 | expect(sut.check(hand)).toBeUndefined() 35 | }) 36 | 37 | test('suukantsu (four kans) valid hand with four open/concealed kans', () => { 38 | const hand = new Hand({ 39 | concealedCombinations: [ 40 | new Pair(new DotTile(5)), 41 | new Quad(new DotTile(1)), 42 | new Quad(new DotTile(2)), 43 | new Quad(new DotTile(3)) 44 | ], 45 | openCombinations: [ 46 | new Quad(new DotTile(4)) 47 | ] 48 | }) 49 | 50 | expect(sut.check(hand)).toStrictEqual({ key: 'suukantsu', hanValue: 0, yakumanValue: 1 }) 51 | }) 52 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/tanyao-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import TanyaouYaku from '@/core/han-calculation/yakus/tanyao-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence } from '@/core/combination-classes' 5 | import { DotTile, CharacterTile, BambooTile, DragonTile } from '@/core/tile-classes' 6 | 7 | const sut = new TanyaouYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 12 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 13 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('tanyaou (all simples) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'tanyao', hanValue: 1, yakumanValue: 0 }) 21 | }) 22 | 23 | const invalidHandWithHonorTile = new Hand({ 24 | concealedCombinations: [ 25 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 26 | new Sequence(new BambooTile(6), new BambooTile(7), new BambooTile(8)), 27 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 28 | new Triplet(new DragonTile('red')), 29 | new Pair(new DotTile(7)) 30 | ] 31 | }) 32 | 33 | test('tanyaou (all simples) invalid hand with honor tiles', () => { 34 | expect(sut.check(invalidHandWithHonorTile)).toBeUndefined() 35 | }) 36 | 37 | const invalidHandWithTerminalTile = new Hand({ 38 | concealedCombinations: [ 39 | new Sequence(new BambooTile(2), new BambooTile(3), new BambooTile(4)), 40 | new Sequence(new BambooTile(7), new BambooTile(8), new BambooTile(9)), 41 | new Sequence(new CharacterTile(3), new CharacterTile(4), new CharacterTile(5)), 42 | new Triplet(new DotTile(3)), 43 | new Pair(new DotTile(7)) 44 | ] 45 | }) 46 | 47 | test('tanyaou (all simples) invalid hand with terminal tile', () => { 48 | expect(sut.check(invalidHandWithTerminalTile)).toBeUndefined() 49 | }) 50 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/tenhou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import TenhouYaku from '@/core/han-calculation/yakus/tenhou-yaku' 3 | import Hand from '@/core/hand' 4 | 5 | const sut = new TenhouYaku() 6 | 7 | describe('given the hand contains the tenhou yaku', () => { 8 | const hand = new Hand({ yakus: ['tenhou'] }) 9 | 10 | test('should be eligible for tenhou', () => { 11 | expect(sut.check(hand)).toStrictEqual({ key: 'tenhou', hanValue: 0, yakumanValue: 1 }) 12 | }) 13 | }) 14 | 15 | describe('given the hand does not contain the tenhou yaku', () => { 16 | const hand = new Hand() 17 | 18 | test('should not be eligible for tenhou', () => { 19 | expect(sut.check(hand)).toBeUndefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/toitoi-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import ToitoiYaku from '@/core/han-calculation/yakus/toitoi-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair, Sequence, Quad } from '@/core/combination-classes' 5 | import { DotTile, BambooTile } from '@/core/tile-classes' 6 | 7 | const sut = new ToitoiYaku() 8 | 9 | const validHand = new Hand({ 10 | concealedCombinations: [ 11 | new Triplet(new BambooTile(1)), 12 | new Triplet(new BambooTile(7)), 13 | new Triplet(new BambooTile(4)), 14 | new Triplet(new DotTile(3)), 15 | new Pair(new DotTile(7)) 16 | ] 17 | }) 18 | 19 | test('toitoi (all pons) valid hand', () => { 20 | expect(sut.check(validHand)).toStrictEqual({ key: 'toitoi', hanValue: 2, yakumanValue: 0 }) 21 | }) 22 | 23 | const validHandWithKan = new Hand({ 24 | concealedCombinations: [ 25 | new Triplet(new BambooTile(1)), 26 | new Triplet(new BambooTile(7)), 27 | new Triplet(new BambooTile(4)), 28 | new Quad(new DotTile(3)), 29 | new Pair(new DotTile(7)) 30 | ] 31 | }) 32 | 33 | test('toitoi (all pons) valid hand with quad', () => { 34 | expect(sut.check(validHandWithKan)).toStrictEqual({ key: 'toitoi', hanValue: 2, yakumanValue: 0 }) 35 | }) 36 | 37 | const invalidHandWithoutFourPon = new Hand({ 38 | concealedCombinations: [ 39 | new Triplet(new BambooTile(1)), 40 | new Triplet(new BambooTile(7)), 41 | new Triplet(new BambooTile(4)), 42 | new Sequence(new DotTile(3), new DotTile(4), new DotTile(5)), 43 | new Pair(new DotTile(7)) 44 | ] 45 | }) 46 | 47 | test('toitoi (all pons) invalid hand without four triplet', () => { 48 | expect(sut.check(invalidHandWithoutFourPon)).toBeUndefined() 49 | }) 50 | -------------------------------------------------------------------------------- /tests/unit/han-calculation/yakus/tsuuiisou-yaku.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import TsuuiisouYaku from '@/core/han-calculation/yakus/tsuuiisou-yaku' 3 | import Hand from '@/core/hand' 4 | import { Triplet, Pair } from '@/core/combination-classes' 5 | import { DotTile, BambooTile, DragonTile, WindTile, CharacterTile } from '@/core/tile-classes' 6 | 7 | test('tsuuiisou (all honors) valid hand with only honor tiles', () => { 8 | const hand = new Hand({ 9 | concealedCombinations: [ 10 | new Pair(new WindTile('east')), 11 | new Triplet(new WindTile('south')), 12 | new Triplet(new WindTile('north')), 13 | new Triplet(new DragonTile('green')) 14 | ], 15 | openCombinations: [ 16 | new Triplet(new DragonTile('red')) 17 | ] 18 | }) 19 | 20 | expect(new TsuuiisouYaku().check(hand)).toStrictEqual({ key: 'tsuuiisou', hanValue: 0, yakumanValue: 1 }) 21 | }) 22 | 23 | test('tsuuiisou (all honors) invalid hand because it contains dot tiles', () => { 24 | const hand = new Hand({ 25 | concealedCombinations: [ 26 | new Pair(new DotTile(1)), 27 | new Triplet(new WindTile('south')), 28 | new Triplet(new WindTile('north')), 29 | new Triplet(new DragonTile('green')) 30 | ], 31 | openCombinations: [ 32 | new Triplet(new DragonTile('red')) 33 | ] 34 | }) 35 | 36 | expect(new TsuuiisouYaku().check(hand)).toBeUndefined() 37 | }) 38 | 39 | test('tsuuiisou (all honors) invalid hand because it contains bamboo tiles', () => { 40 | const hand = new Hand({ 41 | concealedCombinations: [ 42 | new Pair(new BambooTile(2)), 43 | new Triplet(new WindTile('south')), 44 | new Triplet(new WindTile('north')), 45 | new Triplet(new DragonTile('green')) 46 | ], 47 | openCombinations: [ 48 | new Triplet(new DragonTile('red')) 49 | ] 50 | }) 51 | 52 | expect(new TsuuiisouYaku().check(hand)).toBeUndefined() 53 | }) 54 | 55 | test('tsuuiisou (all honors) invalid hand because it contains character tiles', () => { 56 | const hand = new Hand({ 57 | concealedCombinations: [ 58 | new Pair(new CharacterTile(3)), 59 | new Triplet(new WindTile('south')), 60 | new Triplet(new WindTile('north')), 61 | new Triplet(new DragonTile('green')) 62 | ], 63 | openCombinations: [ 64 | new Triplet(new DragonTile('red')) 65 | ] 66 | }) 67 | 68 | expect(new TsuuiisouYaku().check(hand)).toBeUndefined() 69 | }) 70 | -------------------------------------------------------------------------------- /tests/unit/hand.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { Pair, Triplet, Quad } from '@/core/combination-classes' 3 | import { DotTile } from '@/core/tile-classes' 4 | import Hand from '@/core/hand' 5 | 6 | test('create a hand with all default values', () => { 7 | const hand = new Hand() 8 | 9 | expect(hand).toMatchObject({ 10 | concealedCombinations: [], 11 | openCombinations: [], 12 | roundWind: 'east', 13 | seatWind: 'east', 14 | winningType: 'tsumo', 15 | winningCombinationIndex: null, 16 | winningTileIndex: null, 17 | yakus: [], 18 | nbDora: 0 19 | }) 20 | 21 | // getters don't work in toMatchObject 22 | 23 | expect(hand.isOpen).toBe(false) 24 | expect(hand.combinations).toStrictEqual([]) 25 | expect(hand.winningCombination).toBeNull() 26 | expect(hand.winningTile).toBeNull() 27 | }) 28 | 29 | test('create a hand with options', () => { 30 | const hand = new Hand({ 31 | concealedCombinations: [ 32 | new Pair(new DotTile(1)), 33 | new Triplet(new DotTile(2)) 34 | ], 35 | openCombinations: [ 36 | new Triplet(new DotTile(3)), 37 | new Triplet(new DotTile(4)), 38 | new Quad(new DotTile(5)) 39 | ], 40 | roundWind: 'south', 41 | seatWind: 'west', 42 | winningCombinationIndex: 0, 43 | winningTileIndex: 0, 44 | winningType: 'ron', 45 | nbDora: 1, 46 | yakus: ['rinshan kaihou'] 47 | }) 48 | 49 | expect(hand).toMatchObject({ 50 | concealedCombinations: [ 51 | new Pair(new DotTile(1)), 52 | new Triplet(new DotTile(2)) 53 | ], 54 | openCombinations: [ 55 | new Triplet(new DotTile(3)), 56 | new Triplet(new DotTile(4)), 57 | new Quad(new DotTile(5)) 58 | ], 59 | roundWind: 'south', 60 | seatWind: 'west', 61 | winningType: 'ron', 62 | winningCombinationIndex: 0, 63 | winningTileIndex: 0, 64 | yakus: ['rinshan kaihou'], 65 | nbDora: 1 66 | }) 67 | 68 | // getters don't work in toMatchObject 69 | 70 | expect(hand.isOpen).toBe(true) 71 | expect(hand.combinations).toStrictEqual([ 72 | new Pair(new DotTile(1)), 73 | new Triplet(new DotTile(2)), 74 | new Triplet(new DotTile(3)), 75 | new Triplet(new DotTile(4)), 76 | new Quad(new DotTile(5)) 77 | ]) 78 | expect(hand.winningCombination).toStrictEqual(new Pair(new DotTile(1))) 79 | expect(hand.winningTile).toStrictEqual(new DotTile(1)) 80 | }) 81 | -------------------------------------------------------------------------------- /tests/unit/waits/is-kanchan-wait.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import isKanchanWait from '@/core/waits/is-kanchan-wait' 3 | import Hand from '@/core/hand' 4 | import { Pair, Triplet, Sequence } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | describe('given the hand was won by completing a pair', () => { 8 | test('should be considered kanchan wait', () => { 9 | const hand = new Hand({ 10 | concealedCombinations: [ 11 | new Pair(new DotTile(1)), 12 | new Triplet(new DotTile(2)), 13 | new Triplet(new DotTile(3)), 14 | new Triplet(new DotTile(4)), 15 | new Triplet(new DotTile(5)) 16 | ], 17 | winningCombinationIndex: 0, 18 | winningTileIndex: 0 19 | }) 20 | 21 | expect(isKanchanWait(hand)).toBe(false) 22 | }) 23 | }) 24 | 25 | describe('given the hand was won by completing a triplet', () => { 26 | test('should not be considered kanchan wait', () => { 27 | const hand = new Hand({ 28 | concealedCombinations: [ 29 | new Pair(new DotTile(1)), 30 | new Triplet(new DotTile(2)), 31 | new Triplet(new DotTile(3)), 32 | new Triplet(new DotTile(4)), 33 | new Triplet(new DotTile(5)) 34 | ], 35 | winningCombinationIndex: 1, 36 | winningTileIndex: 0 37 | }) 38 | 39 | expect(isKanchanWait(hand)).toBe(false) 40 | }) 41 | }) 42 | 43 | describe('given the hand was won by completing a sequence', () => { 44 | test('should be considered kanchan wait if the winning tile was the tile in the middle of the combination', () => { 45 | const hand = new Hand({ 46 | concealedCombinations: [ 47 | new Pair(new DotTile(1)), 48 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 49 | new Triplet(new DotTile(3)), 50 | new Triplet(new DotTile(4)), 51 | new Triplet(new DotTile(5)) 52 | ], 53 | winningCombinationIndex: 1, 54 | winningTileIndex: 1 55 | }) 56 | 57 | expect(isKanchanWait(hand)).toBe(true) 58 | }) 59 | 60 | test('should not be considered kanchan wait if the winning tile was not the tile in the middle of the combination', () => { 61 | const hand = new Hand({ 62 | concealedCombinations: [ 63 | new Pair(new DotTile(1)), 64 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 65 | new Triplet(new DotTile(3)), 66 | new Triplet(new DotTile(4)), 67 | new Triplet(new DotTile(5)) 68 | ], 69 | winningCombinationIndex: 1, 70 | winningTileIndex: 0 71 | }) 72 | 73 | expect(isKanchanWait(hand)).toBe(false) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /tests/unit/waits/is-penchan-wait.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import isPenchanWait from '@/core/waits/is-penchan-wait' 3 | import Hand from '@/core/hand' 4 | import { Pair, Triplet, Sequence } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | describe('given the hand was won by completing a pair', () => { 8 | test('should be considered penchan wait', () => { 9 | const hand = new Hand({ 10 | concealedCombinations: [ 11 | new Pair(new DotTile(1)), 12 | new Triplet(new DotTile(2)), 13 | new Triplet(new DotTile(3)), 14 | new Triplet(new DotTile(4)), 15 | new Triplet(new DotTile(5)) 16 | ], 17 | winningCombinationIndex: 0, 18 | winningTileIndex: 0 19 | }) 20 | 21 | expect(isPenchanWait(hand)).toBe(false) 22 | }) 23 | }) 24 | 25 | describe('given the hand was won by completing a triplet', () => { 26 | test('should not be considered penchan wait', () => { 27 | const hand = new Hand({ 28 | concealedCombinations: [ 29 | new Pair(new DotTile(1)), 30 | new Triplet(new DotTile(2)), 31 | new Triplet(new DotTile(3)), 32 | new Triplet(new DotTile(4)), 33 | new Triplet(new DotTile(5)) 34 | ], 35 | winningCombinationIndex: 1, 36 | winningTileIndex: 0 37 | }) 38 | 39 | expect(isPenchanWait(hand)).toBe(false) 40 | }) 41 | }) 42 | 43 | describe('given the hand was won by completing a sequence', () => { 44 | test('should not be considered penchan wait if the winning tile was the tile in the middle of the combination', () => { 45 | const hand = new Hand({ 46 | concealedCombinations: [ 47 | new Pair(new DotTile(1)), 48 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 49 | new Triplet(new DotTile(3)), 50 | new Triplet(new DotTile(4)), 51 | new Triplet(new DotTile(5)) 52 | ], 53 | winningCombinationIndex: 1, 54 | winningTileIndex: 1 55 | }) 56 | 57 | expect(isPenchanWait(hand)).toBe(false) 58 | }) 59 | 60 | test('should be considered penchan wait if the winning tile had the value 3 on a 1-2-3 sequence', () => { 61 | const hand = new Hand({ 62 | concealedCombinations: [ 63 | new Pair(new DotTile(1)), 64 | new Sequence(new DotTile(1), new DotTile(2), new DotTile(3)), 65 | new Triplet(new DotTile(3)), 66 | new Triplet(new DotTile(4)), 67 | new Triplet(new DotTile(5)) 68 | ], 69 | winningCombinationIndex: 1, 70 | winningTileIndex: 2 71 | }) 72 | 73 | expect(isPenchanWait(hand)).toBe(true) 74 | }) 75 | 76 | test('should be considered penchan wait if the winning tile had the value 7 on a 7-8-9 sequence', () => { 77 | const hand = new Hand({ 78 | concealedCombinations: [ 79 | new Pair(new DotTile(1)), 80 | new Sequence(new DotTile(7), new DotTile(8), new DotTile(9)), 81 | new Triplet(new DotTile(3)), 82 | new Triplet(new DotTile(4)), 83 | new Triplet(new DotTile(5)) 84 | ], 85 | winningCombinationIndex: 1, 86 | winningTileIndex: 0 87 | }) 88 | 89 | expect(isPenchanWait(hand)).toBe(true) 90 | }) 91 | 92 | test('should not be considered penchan wait if the winning tile was the first or last tile of the combination (not 1-2-3 and 7-8-9)', () => { 93 | const hand = new Hand({ 94 | concealedCombinations: [ 95 | new Pair(new DotTile(1)), 96 | new Sequence(new DotTile(5), new DotTile(6), new DotTile(7)), 97 | new Triplet(new DotTile(3)), 98 | new Triplet(new DotTile(4)), 99 | new Triplet(new DotTile(5)) 100 | ], 101 | winningCombinationIndex: 1, 102 | winningTileIndex: 0 103 | }) 104 | 105 | expect(isPenchanWait(hand)).toBe(false) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /tests/unit/waits/is-tanki-wait.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import isTankiWait from '@/core/waits/is-tanki-wait' 3 | import Hand from '@/core/hand' 4 | import { Pair, Triplet, Sequence } from '@/core/combination-classes' 5 | import { DotTile } from '@/core/tile-classes' 6 | 7 | describe('given the hand was won by completing a pair', () => { 8 | test('should be considered tanki wait', () => { 9 | const hand = new Hand({ 10 | concealedCombinations: [ 11 | new Pair(new DotTile(1)), 12 | new Triplet(new DotTile(2)), 13 | new Triplet(new DotTile(3)), 14 | new Triplet(new DotTile(4)), 15 | new Triplet(new DotTile(5)) 16 | ], 17 | winningCombinationIndex: 0, 18 | winningTileIndex: 0 19 | }) 20 | 21 | expect(isTankiWait(hand)).toBe(true) 22 | }) 23 | }) 24 | 25 | describe('given the hand was won by completing a triplet', () => { 26 | test('should not be considered tanki wait', () => { 27 | const hand = new Hand({ 28 | concealedCombinations: [ 29 | new Pair(new DotTile(1)), 30 | new Triplet(new DotTile(2)), 31 | new Triplet(new DotTile(3)), 32 | new Triplet(new DotTile(4)), 33 | new Triplet(new DotTile(5)) 34 | ], 35 | winningCombinationIndex: 1, 36 | winningTileIndex: 0 37 | }) 38 | 39 | expect(isTankiWait(hand)).toBe(false) 40 | }) 41 | }) 42 | 43 | describe('given the hand was won by completing a sequence', () => { 44 | test('should not be considered tanki wait', () => { 45 | const hand = new Hand({ 46 | concealedCombinations: [ 47 | new Pair(new DotTile(1)), 48 | new Sequence(new DotTile(2), new DotTile(3), new DotTile(4)), 49 | new Triplet(new DotTile(3)), 50 | new Triplet(new DotTile(4)), 51 | new Triplet(new DotTile(5)) 52 | ], 53 | winningCombinationIndex: 1, 54 | winningTileIndex: 0 55 | }) 56 | 57 | expect(isTankiWait(hand)).toBe(false) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig, loadEnv } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import { createHtmlPlugin } from 'vite-plugin-html' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ mode }) => { 9 | const env = loadEnv(mode, './') // reuse vite's env parser to inject into our index.html 10 | 11 | return { 12 | base: env.VITE_BASE_URL, 13 | 14 | plugins: [ 15 | vue(), 16 | 17 | createHtmlPlugin({ 18 | inject: { 19 | data: env 20 | } 21 | }) 22 | ], 23 | 24 | resolve: { 25 | alias: { 26 | '@': fileURLToPath(new URL('./src', import.meta.url)) 27 | } 28 | } 29 | } 30 | }) 31 | --------------------------------------------------------------------------------