(a: T[], b: U[]): [T, U][] => {
7 | return a
8 | .map(a => b.map(b => [a, b]))
9 | .reduce((acc, cur) => [...acc, ...cur], []) as [T, U][];
10 | };
11 |
12 | export const memoize = (
13 | f: (...params: P) => R,
14 | serialize: (...params: P) => string
15 | ): ((...params: P) => R) => {
16 | const memo: { [key: string]: R } = {};
17 | return (...params: P) => {
18 | const serialized = serialize(...params);
19 | if (typeof memo[serialized] === 'undefined')
20 | memo[serialized] = f(...params);
21 | return memo[serialized] as R;
22 | };
23 | };
24 |
25 | export const uniqueSorted = (
26 | a: T[],
27 | compare: (a: T, b: T) => boolean
28 | ): T[] => {
29 | if (a.length === 0) return [];
30 |
31 | const [first, ...rest] = a;
32 | assertNonNullable(first);
33 | return rest.reduce(
34 | (acc, cur) =>
35 | compare(acc[acc.length - 1] as T, cur) ? acc : [...acc, cur],
36 | [first]
37 | );
38 | };
39 |
40 | export const groupBy = (
41 | a: T[],
42 | f: (e: T, i: number) => U | null
43 | ) =>
44 | a.reduce(
45 | (acc, cur, i) => {
46 | const key = f(cur, i);
47 | if (key === null) return acc;
48 | return { ...acc, [key]: [...(acc[key] ?? []), cur] };
49 | },
50 | {} as { [_ in U]?: T[] }
51 | );
52 |
53 | export const countBy = (a: T[], f: (e: T, i: number) => boolean) =>
54 | a.reduce((acc, cur, i) => acc + (f(cur, i) ? 1 : 0), 0);
55 |
56 | export const countGroupBy = (
57 | a: T[],
58 | f: (e: T, i: number) => U | null
59 | ) =>
60 | a.reduce(
61 | (acc, cur, i) => {
62 | const key = f(cur, i);
63 | if (key === null) return acc;
64 | return { ...acc, [key]: (acc[key] ?? 0) + 1 };
65 | },
66 | {} as { [_ in U]?: number }
67 | );
68 |
69 | export const sumBy = (
70 | a: T[],
71 | f: (e: T, i: number) => number,
72 | initialValue = 0
73 | ) => a.reduce((acc, cur, i) => acc + f(cur, i), initialValue);
74 |
75 | export const minsBy = (a: T[], f: (e: T, i: number) => number) =>
76 | a.reduce(
77 | (acc, cur, i) => {
78 | const c = f(cur, i);
79 | if (c < acc[0]) return [c, [cur]] as [number, T[]];
80 | if (c === acc[0]) return [acc[0], [...acc[1], cur]] as [number, T[]];
81 | return acc;
82 | },
83 | [Number.POSITIVE_INFINITY, []] as [number, T[]]
84 | );
85 |
86 | export const shuffle = (a: T[]) => {
87 | const ret = [...a];
88 | for (let i = ret.length - 1; i > 0; i--) {
89 | const j = Math.floor(Math.random() * (i + 1));
90 | [ret[i], ret[j]] = [ret[j] as T, ret[i] as T];
91 | }
92 | return ret;
93 | };
94 |
95 | export const unreachable = (message?: string) => {
96 | throw new Error(message ?? 'Entered unreachable code');
97 | };
98 |
--------------------------------------------------------------------------------
/src/components/PointDiff.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useStore } from '../contexts/store';
4 | import { sumOfFu } from '../lib/fu';
5 | import type { Hora } from '../lib/hora';
6 | import { calculateBasePoint, ceil100 } from '../lib/score';
7 | import { sumBy } from '../lib/util';
8 |
9 | interface PointDiffProps {
10 | info: Hora;
11 | }
12 |
13 | export const PointDiff: FC = ({ info }) => {
14 | const [
15 | {
16 | currentRule: {
17 | roundedMangan,
18 | accumlatedYakuman,
19 | multipleYakuman,
20 | honbaBonus
21 | },
22 | table
23 | }
24 | ] = useStore();
25 | const { t } = useTranslation();
26 |
27 | if (info.yaku.every(y => y.name === 'dora' || y.name === 'red-dora'))
28 | return null;
29 |
30 | const base =
31 | info.type === 'kokushi' || info.yaku.some(y => y.type === 'yakuman')
32 | ? info.yaku.reduce((acc, cur) => {
33 | const p = cur.type === 'yakuman' ? cur.point : 0;
34 | if (multipleYakuman) return acc + p;
35 | return Math.max(acc, p);
36 | }, 0) * 8000
37 | : info.yaku.every(y => y.name === 'dora' || y.name === 'red-dora')
38 | ? 0
39 | : calculateBasePoint(
40 | info.type === 'mentsu' ? sumOfFu(info.fu) : 25,
41 | sumBy(info.yaku, y => (y.type === 'yaku' ? y.han : 0)),
42 | roundedMangan,
43 | accumlatedYakuman
44 | );
45 | const isDealer = table.seat === 'east';
46 |
47 | return (
48 |
49 |
{t('result.point-diff')}
50 | {info.by === 'ron' && (
51 |
52 | {t('result.point', {
53 | count:
54 | (ceil100(base * (isDealer ? 6 : 4)) +
55 | table.continue * 3 * honbaBonus) *
56 | 2 +
57 | 1000 * table.deposit
58 | })}
59 |
60 | )}
61 | {info.by === 'tsumo' && isDealer && (
62 |
63 | {t('result.point', {
64 | count:
65 | (ceil100(base * 2) + table.continue * honbaBonus) * 4 +
66 | 1000 * table.deposit
67 | })}
68 |
69 | )}
70 | {info.by === 'tsumo' && !isDealer && (
71 | <>
72 |
73 | {t('result.non-dealer-diff', {
74 | count:
75 | ceil100(base) * 3 +
76 | ceil100(base * 2) +
77 | table.continue * honbaBonus * 4 +
78 | 1000 * table.deposit
79 | })}
80 |
81 |
82 | {t('result.dealer-diff', {
83 | count:
84 | ceil100(base) * 2 +
85 | ceil100(base * 2) * 2 +
86 | table.continue * honbaBonus * 4 +
87 | 1000 * table.deposit
88 | })}
89 |
90 | >
91 | )}
92 |
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/wasm/decomposer/src/data.rs:
--------------------------------------------------------------------------------
1 | use crate::counts::{Counts, PackedNumberCounts};
2 | use serde::Serialize;
3 |
4 | #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5 | pub enum BlockType {
6 | #[serde(rename = "kotsu")]
7 | Kotsu,
8 | #[serde(rename = "shuntsu")]
9 | Shuntsu,
10 | #[serde(rename = "ryammen")]
11 | Ryammen,
12 | #[serde(rename = "penchan")]
13 | Penchan,
14 | #[serde(rename = "kanchan")]
15 | Kanchan,
16 | #[serde(rename = "toitsu")]
17 | Toitsu,
18 | }
19 |
20 | #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
21 | pub struct Block {
22 | #[serde(rename = "type")]
23 | pub block_type: BlockType,
24 | pub tile: u8,
25 | }
26 |
27 | #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
28 | pub struct NumberDecomposeResult {
29 | pub rest: PackedNumberCounts,
30 | pub blocks: Vec,
31 | }
32 |
33 | impl NumberDecomposeResult {
34 | pub fn into_decompose_result(self, t: u8) -> DecomposeResult {
35 | DecomposeResult {
36 | rest: {
37 | let mut counts = Counts::new();
38 | match t {
39 | 0 => counts.m = self.rest,
40 | 1 => counts.p = self.rest,
41 | 2 => counts.s = self.rest,
42 | _ => unreachable!(),
43 | }
44 | counts
45 | },
46 | blocks: self
47 | .blocks
48 | .into_iter()
49 | .map(|Block { block_type, tile }| Block {
50 | block_type,
51 | tile: tile + 9 * t,
52 | })
53 | .collect(),
54 | }
55 | }
56 | }
57 |
58 | #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
59 | pub struct DecomposeResult {
60 | pub rest: Counts,
61 | pub blocks: Vec,
62 | }
63 |
64 | impl DecomposeResult {
65 | pub fn shanten(&self, meld: i32) -> i32 {
66 | let mut kotsu = 0;
67 | let mut shuntsu = 0;
68 | let mut ryammen = 0;
69 | let mut penchan = 0;
70 | let mut kanchan = 0;
71 | let mut toitsu = 0;
72 | for b in &self.blocks {
73 | match b.block_type {
74 | BlockType::Kotsu => kotsu += 1,
75 | BlockType::Shuntsu => shuntsu += 1,
76 | BlockType::Ryammen => ryammen += 1,
77 | BlockType::Penchan => penchan += 1,
78 | BlockType::Kanchan => kanchan += 1,
79 | BlockType::Toitsu => toitsu += 1,
80 | }
81 | }
82 |
83 | let mentsu = kotsu + shuntsu + meld;
84 | let tatsu_blocks = ryammen + penchan + kanchan + toitsu;
85 | let tatsu = if mentsu + tatsu_blocks > 4 {
86 | 4 - mentsu
87 | } else {
88 | tatsu_blocks
89 | };
90 | let has_toitsu = mentsu + tatsu_blocks > 4 && toitsu > 0;
91 |
92 | 8 - mentsu * 2 - tatsu - if has_toitsu { 1 } else { 0 }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/lib/tile/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from 'vitest';
2 | import {
3 | type Tile,
4 | type TileCounts,
5 | countsIndexToTile,
6 | tileToCountsIndex,
7 | tilesToCounts
8 | } from '.';
9 |
10 | describe('tileToCountsIndex', () => {
11 | test('1m', () => {
12 | expect(tileToCountsIndex({ type: 'm', n: 1 })).toBe(0);
13 | });
14 | test('2p', () => {
15 | expect(tileToCountsIndex({ type: 'p', n: 2 })).toBe(10);
16 | });
17 | test('3s', () => {
18 | expect(tileToCountsIndex({ type: 's', n: 3 })).toBe(20);
19 | });
20 | test('4z', () => {
21 | expect(tileToCountsIndex({ type: 'z', n: 4 })).toBe(30);
22 | });
23 | });
24 |
25 | describe('countsIndexToTile', () => {
26 | test('0', () => {
27 | expect(countsIndexToTile(0)).toEqual({ type: 'm', n: 1 });
28 | });
29 | test('4', () => {
30 | expect(countsIndexToTile(4)).toEqual({ type: 'm', n: 5, red: false });
31 | });
32 | test('10', () => {
33 | expect(countsIndexToTile(10)).toEqual({ type: 'p', n: 2 });
34 | });
35 | test('20', () => {
36 | expect(countsIndexToTile(20)).toEqual({ type: 's', n: 3 });
37 | });
38 | test('30', () => {
39 | expect(countsIndexToTile(30)).toEqual({ type: 'z', n: 4 });
40 | });
41 | test('33', () => {
42 | expect(countsIndexToTile(33)).toEqual({ type: 'z', n: 7 });
43 | });
44 | });
45 |
46 | describe('tilesToCounts', () => {
47 | test('empty', () => {
48 | expect(tilesToCounts([])).toEqual(
49 | // biome-ignore format:
50 | [
51 | 0, 0, 0, 0, 0, 0, 0, 0, 0,
52 | 0, 0, 0, 0, 0, 0, 0, 0, 0,
53 | 0, 0, 0, 0, 0, 0, 0, 0, 0,
54 | 0, 0, 0, 0, 0, 0, 0
55 | ]
56 | );
57 | });
58 | test('1m0p9s17z', () => {
59 | expect(
60 | tilesToCounts([
61 | { type: 'm', n: 1 },
62 | { type: 'p', n: 5, red: true },
63 | { type: 's', n: 9 },
64 | { type: 'z', n: 1 },
65 | { type: 'z', n: 7 }
66 | ])
67 | ).toEqual(
68 | // biome-ignore format:
69 | [
70 | 1, 0, 0, 0, 0, 0, 0, 0, 0,
71 | 0, 0, 0, 0, 1, 0, 0, 0, 0,
72 | 0, 0, 0, 0, 0, 0, 0, 0, 1,
73 | 1, 0, 0, 0, 0, 0, 1
74 | ]
75 | );
76 | });
77 | test('1111m', () => {
78 | expect(
79 | tilesToCounts([
80 | { type: 'm', n: 1 },
81 | { type: 'm', n: 1 },
82 | { type: 'm', n: 1 },
83 | { type: 'm', n: 1 }
84 | ])
85 | ).toEqual(
86 | // biome-ignore format:
87 | [
88 | 4, 0, 0, 0, 0, 0, 0, 0, 0,
89 | 0, 0, 0, 0, 0, 0, 0, 0, 0,
90 | 0, 0, 0, 0, 0, 0, 0, 0, 0,
91 | 0, 0, 0, 0, 0, 0, 0
92 | ]
93 | );
94 | });
95 | test('11111m', () => {
96 | expect(() =>
97 | tilesToCounts([
98 | { type: 'm', n: 1 },
99 | { type: 'm', n: 1 },
100 | { type: 'm', n: 1 },
101 | { type: 'm', n: 1 },
102 | { type: 'm', n: 1 }
103 | ])
104 | ).toThrow();
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/2z.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/2z.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 | tags:
8 | - 'v*'
9 | pull_request:
10 | branches:
11 | - '**'
12 |
13 | jobs:
14 | build:
15 | name: Build
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | - name: Install Node.js
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: latest
24 | - name: Install Rust
25 | run: |
26 | rustup install stable
27 | - name: Install wasm-pack
28 | uses: taiki-e/install-action@wasm-pack
29 | - name: Prepare cache
30 | id: cache
31 | uses: actions/cache@v4
32 | with:
33 | path: node_modules
34 | key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
35 | - name: Install dependencies
36 | if: steps.cache.outputs.cache-hit != 'true'
37 | run: npm ci
38 | - name: Build wasm
39 | run: npm run build:wasm
40 | - name: Build app
41 | run: npm run build
42 | - name: Test
43 | run: npm test
44 | - name: Lint
45 | run: npm run lint
46 | - name: Archive production artifacts
47 | uses: actions/upload-artifact@v4
48 | with:
49 | name: dist
50 | path: dist
51 | staging:
52 | name: Staging
53 | needs: build
54 | runs-on: ubuntu-latest
55 | environment:
56 | name: ${{ github.event_name == 'pull_request' && 'Pull Request' || 'Staging' }}
57 | url: ${{ steps.deploy.outputs.NETLIFY_URL }}
58 | steps:
59 | - name: Download production artifacts
60 | uses: actions/download-artifact@v4
61 | with:
62 | name: dist
63 | path: dist
64 | - name: Deploy to Netlify
65 | id: deploy
66 | uses: netlify/actions/cli@master
67 | with:
68 | args: deploy --dir=dist
69 | env:
70 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
71 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
72 | deploy:
73 | name: Deploy
74 | if: startsWith(github.ref, 'refs/tags/v')
75 | needs: build
76 | runs-on: ubuntu-latest
77 | permissions:
78 | id-token: write
79 | contents: read
80 | environment:
81 | name: Production
82 | url: https://mahjong-calc.livewing.net/
83 | steps:
84 | - name: Download production artifacts
85 | uses: actions/download-artifact@v4
86 | with:
87 | name: dist
88 | path: dist
89 | - name: Configure AWS Credentials
90 | uses: aws-actions/configure-aws-credentials@v4
91 | with:
92 | role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_DEPLOY_ROLE }}
93 | role-session-name: GitHubActions
94 | aws-region: ap-northeast-1
95 | - name: Deploy to S3
96 | run: aws s3 sync --exact-timestamp --delete dist/ ${{ secrets.AWS_S3_BUCKET_NAME }}
97 | - name: Invalidate CloudFront cache
98 | run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths '/*'
99 |
--------------------------------------------------------------------------------
/src/components/KeyboardHelp.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { formatKeys } from '../lib/os';
4 | import { Tile } from './tile';
5 | import { Button } from './ui/Button';
6 |
7 | interface KeyboardHelpProps {
8 | onClose?: () => void;
9 | }
10 |
11 | export const KeyboardHelp: FC = ({
12 | onClose = () => void 0
13 | }) => {
14 | const { t } = useTranslation();
15 | return (
16 |
17 |
22 |
23 |
24 |
25 | {t('keyboard-shortcuts.title')}
26 |
27 |
28 |
←↓↑→
29 |
{t('keyboard-shortcuts.switch-focus')}
30 |
{formatKeys('Backspace')}
31 |
{t('keyboard-shortcuts.delete-tile')}
32 |
12m34p56s0m
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
1234567z
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
{formatKeys('Shift+P')}
53 |
{t('keyboard-shortcuts.pon')}
54 |
{formatKeys('Shift+C')}
55 |
{t('keyboard-shortcuts.chii')}
56 |
{formatKeys('Shift+M')}
57 |
{t('keyboard-shortcuts.minkan')}
58 |
{formatKeys('Shift+A')}
59 |
{t('keyboard-shortcuts.ankan')}
60 |
{formatKeys('Shift+R')}
61 |
{t('keyboard-shortcuts.toggle-red')}
62 |
63 |
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/src/components/Calculator.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { MdNavigateNext } from 'react-icons/md';
4 | import { useStore } from '../contexts/store';
5 | import { useBoundingClientRect } from '../hooks/dom';
6 | import { generateResult } from '../lib/result';
7 | import { compareRules } from '../lib/rule';
8 | import { HandOptions } from './HandOptions';
9 | import { InputGlance } from './InputGlance';
10 | import { Result } from './Result';
11 | import { ResultGlance } from './ResultGlance';
12 | import { TableSettings } from './TableSettings';
13 | import { Button } from './ui/Button';
14 | import { ConfigItem } from './ui/ConfigItem';
15 | import { TileInput } from './ui/TileInput';
16 |
17 | export const Calculator: FC = () => {
18 | const [ruleRef, ruleRect] = useBoundingClientRect();
19 | const [tableRef, tableRect] = useBoundingClientRect();
20 | const [handOptionsRef, handOptionsRect] =
21 | useBoundingClientRect();
22 | const [{ currentRule, savedRules, table, input, handOptions }, dispatch] =
23 | useStore();
24 | const { t } = useTranslation();
25 |
26 | const ruleName =
27 | Object.entries(savedRules).find(([, r]) =>
28 | compareRules(r, currentRule)
29 | )?.[0] ?? t('settings.untitled-rule');
30 |
31 | const result = generateResult(table, input, handOptions, currentRule);
32 |
33 | return (
34 | <>
35 |
36 |
37 |
38 |
49 |
50 |
54 |
55 |
59 |
60 |
61 |
62 |
63 |
67 |
68 |
69 |
70 |
71 |
76 |
77 | >
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/components/TableSettings.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useStore } from '../contexts/store';
4 | import Stick100 from '../images/point-stick/100.svg?react';
5 | import Stick1000 from '../images/point-stick/1000.svg?react';
6 | import type { Wind } from '../lib/table';
7 | import { ConfigItem } from './ui/ConfigItem';
8 | import { Segment } from './ui/Segment';
9 | import { Stepper } from './ui/Stepper';
10 |
11 | const winds: Wind[] = ['east', 'south', 'west', 'north'];
12 |
13 | export const TableSettings: FC = () => {
14 | const [{ table }, dispatch] = useStore();
15 | const { t } = useTranslation();
16 |
17 | return (
18 |
19 |
20 |
21 |
22 | t(`table-settings.${w}`))}
24 | index={winds.indexOf(table.round)}
25 | onChange={i =>
26 | dispatch({
27 | type: 'set-table',
28 | payload: { ...table, round: winds[i] as Wind }
29 | })
30 | }
31 | />
32 |
33 |
34 |
35 |
36 | t(`table-settings.${w}`))}
38 | index={winds.indexOf(table.seat)}
39 | onChange={i =>
40 | dispatch({
41 | type: 'set-table',
42 | payload: { ...table, seat: winds[i] as Wind }
43 | })
44 | }
45 | />
46 |
47 |
48 |
49 |
50 |
51 |
54 | {t('table-settings.deposit')}
55 |
56 |
57 | }
58 | >
59 |
0}
62 | onChange={value =>
63 | dispatch({
64 | type: 'set-table',
65 | payload: { ...table, deposit: value }
66 | })
67 | }
68 | />
69 |
70 |
71 |
72 |
75 | {t('table-settings.continue')}
76 |
77 |
78 | }
79 | >
80 |
0}
83 | onChange={value =>
84 | dispatch({
85 | type: 'set-table',
86 | payload: { ...table, continue: value }
87 | })
88 | }
89 | />
90 |
91 |
92 |
93 |
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/src/components/AppearanceSettings.tsx:
--------------------------------------------------------------------------------
1 | import { type FC, useState } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { MdCheck } from 'react-icons/md';
4 | import { useStore } from '../contexts/store';
5 | import { getResources } from '../lib/i18n';
6 | import { Checkbox } from './ui/Checkbox';
7 | import { ConfigItem } from './ui/ConfigItem';
8 | import { Dropdown } from './ui/Dropdown';
9 | import { ThemeSwitcher } from './ui/ThemeSwitcher';
10 | import { TileColorSwitcher } from './ui/TileColorSwitcher';
11 |
12 | const languages = Object.keys(getResources());
13 |
14 | export const AppearanceSettings: FC = () => {
15 | const [{ appConfig }, dispatch] = useStore();
16 | const [openLanguageMenu, setOpenLanguageMenu] = useState(false);
17 | const { t, i18n } = useTranslation();
18 |
19 | return (
20 |
21 |
22 |
26 | {t('locale:name')}
27 |
28 | {i18n.resolvedLanguage}
29 |
30 |
31 | }
32 | open={openLanguageMenu}
33 | onSetOpen={setOpenLanguageMenu}
34 | >
35 |
36 | {languages.map(lng => (
37 |
64 | ))}
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
79 | dispatch({
80 | type: 'set-app-config',
81 | payload: { ...appConfig, showBazoro }
82 | })
83 | }
84 | >
85 | {t('settings.show-bazoro')}
86 |
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/src/components/ui/ScoringTableHeader.tsx:
--------------------------------------------------------------------------------
1 | import { type FC, useState } from 'react';
2 | import type React from 'react';
3 | import { useTranslation } from 'react-i18next';
4 | import { useStore } from '../../contexts/store';
5 | import Stick100 from '../../images/point-stick/100.svg?react';
6 | import Stick1000 from '../../images/point-stick/1000.svg?react';
7 | import { SimpleStepper } from './SimpleStepper';
8 |
9 | const HeaderButton: FC<{
10 | children?: React.ReactNode;
11 | active?: boolean;
12 | onClick?: () => void;
13 | }> = ({ children, active = false, onClick }) => (
14 |
25 | );
26 |
27 | export const ScoringTableHeader: FC = () => {
28 | const [{ currentScoringTableTab, table }, dispatch] = useStore();
29 | const [openTableSettings, setOpenTableSettings] = useState(false);
30 | const { t } = useTranslation();
31 |
32 | const isDealer = table.seat === 'east';
33 |
34 | return (
35 |
36 |
38 | dispatch({
39 | type: 'set-table',
40 | payload: {
41 | ...table,
42 | seat: table.seat === 'east' ? 'south' : 'east'
43 | }
44 | })
45 | }
46 | >
47 | {t(isDealer ? 'scoring-table.dealer' : 'scoring-table.non-dealer')}
48 |
49 |
50 |
setOpenTableSettings(o => !o)}
53 | >
54 |
55 | {currentScoringTableTab === 'diff' && (
56 |
57 |
58 |
{table.deposit}
59 |
60 | )}
61 |
62 |
63 |
{table.continue}
64 |
65 |
66 |
67 | {openTableSettings && (
68 |
69 | {currentScoringTableTab === 'diff' && (
70 | 0}
72 | onChange={d =>
73 | dispatch({
74 | type: 'set-table',
75 | payload: { ...table, deposit: table.deposit + d }
76 | })
77 | }
78 | />
79 | )}
80 | 0}
82 | onChange={d =>
83 | dispatch({
84 | type: 'set-table',
85 | payload: { ...table, continue: table.continue + d }
86 | })
87 | }
88 | />
89 |
90 | )}
91 |
92 |
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/2p.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/2p.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { type FC, useEffect, useState } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { MdHelp, MdSettings, MdTableView, MdUpdate } from 'react-icons/md';
4 | import { useStore } from '../contexts/store';
5 |
6 | const buttonClasses = {
7 | default: 'flex items-center gap-1 p-1 hover:bg-blue-500 rounded transition',
8 | active:
9 | 'flex items-center gap-1 p-1 bg-blue-500 hover:bg-blue-400 rounded transition'
10 | } as const;
11 |
12 | export const Header: FC = () => {
13 | const [showUpdateButton, setShowUpdateButton] = useState(false);
14 | const { t } = useTranslation();
15 | const [{ currentScreen }, dispatch] = useStore();
16 | useEffect(() => {
17 | (async () => {
18 | if ('serviceWorker' in navigator) {
19 | const registration = await navigator.serviceWorker.getRegistration();
20 | registration?.addEventListener('updatefound', () => {
21 | setShowUpdateButton(true);
22 | });
23 | }
24 | })();
25 | }, []);
26 | return (
27 |
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/index.ts:
--------------------------------------------------------------------------------
1 | import type { Tile } from '../../../../lib/tile';
2 | import { unreachable } from '../../../../lib/util';
3 | import m0 from './0m.svg?react';
4 | import p0 from './0p.svg?react';
5 | import s0 from './0s.svg?react';
6 | import m1 from './1m.svg?react';
7 | import p1 from './1p.svg?react';
8 | import s1 from './1s.svg?react';
9 | import z1 from './1z.svg?react';
10 | import m2 from './2m.svg?react';
11 | import p2 from './2p.svg?react';
12 | import s2 from './2s.svg?react';
13 | import z2 from './2z.svg?react';
14 | import m3 from './3m.svg?react';
15 | import p3 from './3p.svg?react';
16 | import s3 from './3s.svg?react';
17 | import z3 from './3z.svg?react';
18 | import m4 from './4m.svg?react';
19 | import p4 from './4p.svg?react';
20 | import s4 from './4s.svg?react';
21 | import z4 from './4z.svg?react';
22 | import m5 from './5m.svg?react';
23 | import p5 from './5p.svg?react';
24 | import s5 from './5s.svg?react';
25 | import z5 from './5z.svg?react';
26 | import m6 from './6m.svg?react';
27 | import p6 from './6p.svg?react';
28 | import s6 from './6s.svg?react';
29 | import z6 from './6z.svg?react';
30 | import m7 from './7m.svg?react';
31 | import p7 from './7p.svg?react';
32 | import s7 from './7s.svg?react';
33 | import z7 from './7z.svg?react';
34 | import m8 from './8m.svg?react';
35 | import p8 from './8p.svg?react';
36 | import s8 from './8s.svg?react';
37 | import m9 from './9m.svg?react';
38 | import p9 from './9p.svg?react';
39 | import s9 from './9s.svg?react';
40 |
41 | export const tileImage = (tile: Tile) => {
42 | switch (tile.type) {
43 | case 'm':
44 | switch (tile.n) {
45 | case 1:
46 | return m1;
47 | case 2:
48 | return m2;
49 | case 3:
50 | return m3;
51 | case 4:
52 | return m4;
53 | case 5:
54 | return tile.red ? m0 : m5;
55 | case 6:
56 | return m6;
57 | case 7:
58 | return m7;
59 | case 8:
60 | return m8;
61 | case 9:
62 | return m9;
63 | default:
64 | return unreachable();
65 | }
66 | case 'p':
67 | switch (tile.n) {
68 | case 1:
69 | return p1;
70 | case 2:
71 | return p2;
72 | case 3:
73 | return p3;
74 | case 4:
75 | return p4;
76 | case 5:
77 | return tile.red ? p0 : p5;
78 | case 6:
79 | return p6;
80 | case 7:
81 | return p7;
82 | case 8:
83 | return p8;
84 | case 9:
85 | return p9;
86 | default:
87 | return unreachable();
88 | }
89 | case 's':
90 | switch (tile.n) {
91 | case 1:
92 | return s1;
93 | case 2:
94 | return s2;
95 | case 3:
96 | return s3;
97 | case 4:
98 | return s4;
99 | case 5:
100 | return tile.red ? s0 : s5;
101 | case 6:
102 | return s6;
103 | case 7:
104 | return s7;
105 | case 8:
106 | return s8;
107 | case 9:
108 | return s9;
109 | default:
110 | return unreachable();
111 | }
112 | case 'z':
113 | switch (tile.n) {
114 | case 1:
115 | return z1;
116 | case 2:
117 | return z2;
118 | case 3:
119 | return z3;
120 | case 4:
121 | return z4;
122 | case 5:
123 | return z5;
124 | case 6:
125 | return z6;
126 | case 7:
127 | return z7;
128 | }
129 | }
130 | };
131 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/index.ts:
--------------------------------------------------------------------------------
1 | import type { Tile } from '../../../../lib/tile';
2 | import { unreachable } from '../../../../lib/util';
3 | import m0 from './0m.svg?react';
4 | import p0 from './0p.svg?react';
5 | import s0 from './0s.svg?react';
6 | import m1 from './1m.svg?react';
7 | import p1 from './1p.svg?react';
8 | import s1 from './1s.svg?react';
9 | import z1 from './1z.svg?react';
10 | import m2 from './2m.svg?react';
11 | import p2 from './2p.svg?react';
12 | import s2 from './2s.svg?react';
13 | import z2 from './2z.svg?react';
14 | import m3 from './3m.svg?react';
15 | import p3 from './3p.svg?react';
16 | import s3 from './3s.svg?react';
17 | import z3 from './3z.svg?react';
18 | import m4 from './4m.svg?react';
19 | import p4 from './4p.svg?react';
20 | import s4 from './4s.svg?react';
21 | import z4 from './4z.svg?react';
22 | import m5 from './5m.svg?react';
23 | import p5 from './5p.svg?react';
24 | import s5 from './5s.svg?react';
25 | import z5 from './5z.svg?react';
26 | import m6 from './6m.svg?react';
27 | import p6 from './6p.svg?react';
28 | import s6 from './6s.svg?react';
29 | import z6 from './6z.svg?react';
30 | import m7 from './7m.svg?react';
31 | import p7 from './7p.svg?react';
32 | import s7 from './7s.svg?react';
33 | import z7 from './7z.svg?react';
34 | import m8 from './8m.svg?react';
35 | import p8 from './8p.svg?react';
36 | import s8 from './8s.svg?react';
37 | import m9 from './9m.svg?react';
38 | import p9 from './9p.svg?react';
39 | import s9 from './9s.svg?react';
40 |
41 | export const tileImage = (tile: Tile) => {
42 | switch (tile.type) {
43 | case 'm':
44 | switch (tile.n) {
45 | case 1:
46 | return m1;
47 | case 2:
48 | return m2;
49 | case 3:
50 | return m3;
51 | case 4:
52 | return m4;
53 | case 5:
54 | return tile.red ? m0 : m5;
55 | case 6:
56 | return m6;
57 | case 7:
58 | return m7;
59 | case 8:
60 | return m8;
61 | case 9:
62 | return m9;
63 | default:
64 | return unreachable();
65 | }
66 | case 'p':
67 | switch (tile.n) {
68 | case 1:
69 | return p1;
70 | case 2:
71 | return p2;
72 | case 3:
73 | return p3;
74 | case 4:
75 | return p4;
76 | case 5:
77 | return tile.red ? p0 : p5;
78 | case 6:
79 | return p6;
80 | case 7:
81 | return p7;
82 | case 8:
83 | return p8;
84 | case 9:
85 | return p9;
86 | default:
87 | return unreachable();
88 | }
89 | case 's':
90 | switch (tile.n) {
91 | case 1:
92 | return s1;
93 | case 2:
94 | return s2;
95 | case 3:
96 | return s3;
97 | case 4:
98 | return s4;
99 | case 5:
100 | return tile.red ? s0 : s5;
101 | case 6:
102 | return s6;
103 | case 7:
104 | return s7;
105 | case 8:
106 | return s8;
107 | case 9:
108 | return s9;
109 | default:
110 | return unreachable();
111 | }
112 | case 'z':
113 | switch (tile.n) {
114 | case 1:
115 | return z1;
116 | case 2:
117 | return z2;
118 | case 3:
119 | return z3;
120 | case 4:
121 | return z4;
122 | case 5:
123 | return z5;
124 | case 6:
125 | return z6;
126 | case 7:
127 | return z7;
128 | }
129 | }
130 | };
131 |
--------------------------------------------------------------------------------
/src/lib/store/state.ts:
--------------------------------------------------------------------------------
1 | import type { AppConfig } from '../config';
2 | import type { HandOptions, Input, InputFocus } from '../input';
3 | import type { Rule } from '../rule';
4 | import type { Table } from '../table';
5 |
6 | export interface AppState {
7 | currentScreen: 'main' | 'scoring-table' | 'settings';
8 | currentScoringTableTab: 'score' | 'diff';
9 | currentSettingsTab: 'rule' | 'appearance' | 'about';
10 | appConfig: AppConfig;
11 | savedRules: { [name: string]: Rule };
12 | currentRule: Rule;
13 | table: Table;
14 | input: Input;
15 | inputFocus: InputFocus;
16 | handOptions: HandOptions;
17 | }
18 |
19 | export const initialState: AppState = {
20 | currentScreen: 'main',
21 | currentScoringTableTab: 'score',
22 | currentSettingsTab: 'rule',
23 | appConfig: { theme: 'auto', tileColor: 'light', showBazoro: false },
24 | savedRules: {
25 | 'Mリーグ (M.LEAGUE)': {
26 | red: {
27 | m: 1,
28 | p: 1,
29 | s: 1
30 | },
31 | honbaBonus: 100,
32 | roundedMangan: true,
33 | doubleWindFu: 2,
34 | accumlatedYakuman: false,
35 | multipleYakuman: true,
36 | kokushi13DoubleYakuman: false,
37 | suankoTankiDoubleYakuman: false,
38 | daisushiDoubleYakuman: false,
39 | pureChurenDoubleYakuman: false
40 | },
41 | '天鳳 赤あり (Tenhou with red)': {
42 | red: {
43 | m: 1,
44 | p: 1,
45 | s: 1
46 | },
47 | honbaBonus: 100,
48 | roundedMangan: false,
49 | doubleWindFu: 4,
50 | accumlatedYakuman: true,
51 | multipleYakuman: true,
52 | kokushi13DoubleYakuman: false,
53 | suankoTankiDoubleYakuman: false,
54 | daisushiDoubleYakuman: false,
55 | pureChurenDoubleYakuman: false
56 | },
57 | '天鳳 赤なし (Tenhou without red)': {
58 | red: {
59 | m: 0,
60 | p: 0,
61 | s: 0
62 | },
63 | honbaBonus: 100,
64 | roundedMangan: false,
65 | doubleWindFu: 4,
66 | accumlatedYakuman: true,
67 | multipleYakuman: true,
68 | kokushi13DoubleYakuman: false,
69 | suankoTankiDoubleYakuman: false,
70 | daisushiDoubleYakuman: false,
71 | pureChurenDoubleYakuman: false
72 | },
73 | '雀魂 -じゃんたま- (Mahjong Soul)': {
74 | red: {
75 | m: 1,
76 | p: 1,
77 | s: 1
78 | },
79 | honbaBonus: 100,
80 | roundedMangan: false,
81 | doubleWindFu: 4,
82 | accumlatedYakuman: true,
83 | multipleYakuman: true,
84 | kokushi13DoubleYakuman: true,
85 | suankoTankiDoubleYakuman: true,
86 | daisushiDoubleYakuman: true,
87 | pureChurenDoubleYakuman: true
88 | }
89 | },
90 | currentRule: {
91 | red: {
92 | m: 1,
93 | p: 1,
94 | s: 1
95 | },
96 | honbaBonus: 100,
97 | roundedMangan: true,
98 | doubleWindFu: 2,
99 | accumlatedYakuman: false,
100 | multipleYakuman: true,
101 | kokushi13DoubleYakuman: false,
102 | suankoTankiDoubleYakuman: false,
103 | daisushiDoubleYakuman: false,
104 | pureChurenDoubleYakuman: false
105 | },
106 | table: {
107 | round: 'east',
108 | seat: 'east',
109 | continue: 0,
110 | deposit: 0
111 | },
112 | input: {
113 | dora: [],
114 | hand: [],
115 | melds: []
116 | },
117 | inputFocus: { type: 'hand' },
118 | handOptions: {
119 | riichi: 'none',
120 | ippatsu: false,
121 | rinshan: false,
122 | chankan: false,
123 | haitei: false,
124 | tenho: false
125 | }
126 | };
127 |
128 | export const defaultState = (): AppState => {
129 | const json = localStorage.getItem('store');
130 | if (json === null) return initialState;
131 | const { store } = JSON.parse(json);
132 | return store as AppState;
133 | };
134 |
--------------------------------------------------------------------------------
/src/components/Result.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import type { FC } from 'react';
3 | import { useTranslation } from 'react-i18next';
4 | import type { Result as ResultType } from '../lib/result';
5 | import { compareTiles } from '../lib/tile';
6 | import { sumBy } from '../lib/util';
7 | import { DiscardItem } from './DiscardItem';
8 | import { HoraItem } from './HoraItem';
9 | import { Shanten } from './Shanten';
10 | import { Tempai } from './Tempai';
11 |
12 | const Message: FC<{ children?: React.ReactNode }> = ({ children }) => (
13 |
14 | {children}
15 |
16 | );
17 |
18 | interface ResultProps {
19 | result: ResultType;
20 | }
21 | export const Result: FC = ({ result }) => {
22 | const { t } = useTranslation();
23 |
24 | if (result === null) {
25 | return {t('result.input-message')};
26 | }
27 |
28 | if (result.type === 'just-hora') {
29 | return {t('result.hora')};
30 | }
31 |
32 | if (result.type === 'hora-shanten') {
33 | if (result.info.type === 'hora') {
34 | if (result.info.hora.length === 0) {
35 | return {t('result.no-hora-tiles')};
36 | }
37 | return (
38 |
39 | {result.info.hora.map(hora => (
40 |
44 | ))}
45 |
46 | );
47 | }
48 | return (
49 |
53 | );
54 | }
55 |
56 | if (result.type === 'tempai') {
57 | if (result.tileAvailabilities.every(a => a.count === 0)) {
58 | return {t('result.no-hora-tiles')};
59 | }
60 | return ;
61 | }
62 |
63 | if (result.type === 'discard-shanten') {
64 | const shanten = result.discards.reduce(
65 | (acc, cur) =>
66 | Math.min(
67 | acc,
68 | cur.next.type === 'tempai'
69 | ? 0
70 | : cur.next.info.type === 'hora'
71 | ? 0
72 | : cur.next.info.shanten
73 | ),
74 | Number.POSITIVE_INFINITY
75 | );
76 | const discards = [...result.discards];
77 | if (
78 | discards.every(
79 | d => d.next.type === 'hora-shanten' && d.next.info.type === 'shanten'
80 | )
81 | ) {
82 | discards.sort((a, b) => {
83 | if (
84 | a.next.type !== 'hora-shanten' ||
85 | a.next.info.type !== 'shanten' ||
86 | b.next.type !== 'hora-shanten' ||
87 | b.next.info.type !== 'shanten'
88 | )
89 | throw new Error();
90 |
91 | const ac = sumBy(a.next.info.tileAvailabilities, a => a.count);
92 | const bc = sumBy(b.next.info.tileAvailabilities, a => a.count);
93 |
94 | if (ac === bc) return compareTiles(a.tile, b.tile);
95 | return bc - ac;
96 | });
97 | }
98 |
99 | return (
100 |
101 |
102 | {shanten === 0
103 | ? t('result.tempai')
104 | : t('result.shanten', { count: shanten })}
105 |
106 |
107 | {discards.map((d, i) => (
108 |
109 | ))}
110 |
111 |
112 | );
113 | }
114 |
115 | return (
116 |
117 | {JSON.stringify(result, null, 2)}
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/1m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/1m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/wasm/decomposer/src/counts.rs:
--------------------------------------------------------------------------------
1 | use std::ops::Add;
2 |
3 | use serde::{Serialize, Serializer};
4 |
5 | #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
6 | pub struct PackedCounts(u32);
7 |
8 | impl PackedCounts {
9 | pub fn with(counts: &[u8]) -> Self {
10 | if counts.len() != N {
11 | panic!();
12 | }
13 | let mut d = 0;
14 | (0..N).for_each(|i| {
15 | d |= (counts[i] as u32 & 0b111) << (3 * i);
16 | });
17 | Self(d)
18 | }
19 |
20 | pub fn get(&self, index: usize) -> u8 {
21 | ((self.0 >> (3 * index)) & 0b111) as u8
22 | }
23 |
24 | pub fn set(&mut self, index: usize, value: u8) {
25 | self.0 = (self.0 & !(0b111 << (3 * index))) | ((value as u32 & 0b111) << (3 * index));
26 | }
27 |
28 | pub fn set_by(&mut self, index: usize, f: impl FnOnce(u8) -> u8) {
29 | self.set(index, f(self.get(index)))
30 | }
31 |
32 | pub fn iter(&self) -> PackedCountsIterator {
33 | PackedCountsIterator::new(self)
34 | }
35 | }
36 |
37 | impl Add for PackedCounts {
38 | type Output = Self;
39 |
40 | fn add(self, rhs: Self) -> Self::Output {
41 | let a: Vec<_> = self.iter().zip(rhs.iter()).map(|(a, b)| a + b).collect();
42 | Self::with(&a)
43 | }
44 | }
45 |
46 | impl Serialize for PackedCounts {
47 | fn serialize(&self, serializer: S) -> Result
48 | where
49 | S: Serializer,
50 | {
51 | serializer.collect_seq(self.iter())
52 | }
53 | }
54 |
55 | pub struct PackedCountsIterator<'a, const N: usize> {
56 | source: &'a PackedCounts,
57 | index: usize,
58 | }
59 |
60 | impl<'a, const N: usize> PackedCountsIterator<'a, N> {
61 | fn new(source: &'a PackedCounts) -> Self {
62 | Self { source, index: 0 }
63 | }
64 | }
65 |
66 | impl Iterator for PackedCountsIterator<'_, N> {
67 | type Item = u8;
68 |
69 | fn next(&mut self) -> Option {
70 | if self.index == N {
71 | None
72 | } else {
73 | let value = self.source.get(self.index);
74 | self.index += 1;
75 | Some(value)
76 | }
77 | }
78 | }
79 |
80 | pub type PackedNumberCounts = PackedCounts<9>;
81 | pub type PackedCharacterCounts = PackedCounts<7>;
82 |
83 | #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
84 | pub struct Counts {
85 | pub m: PackedNumberCounts,
86 | pub p: PackedNumberCounts,
87 | pub s: PackedNumberCounts,
88 | pub z: PackedCharacterCounts,
89 | }
90 |
91 | impl Counts {
92 | pub fn new() -> Self {
93 | Default::default()
94 | }
95 |
96 | pub fn with(counts: &[u8]) -> Self {
97 | if counts.len() != 34 {
98 | panic!();
99 | }
100 | Self {
101 | m: PackedNumberCounts::with(&counts[0..9]),
102 | p: PackedNumberCounts::with(&counts[9..18]),
103 | s: PackedNumberCounts::with(&counts[18..27]),
104 | z: PackedCharacterCounts::with(&counts[27..34]),
105 | }
106 | }
107 | }
108 |
109 | impl Add for Counts {
110 | type Output = Self;
111 |
112 | fn add(self, rhs: Self) -> Self::Output {
113 | Self {
114 | m: self.m + rhs.m,
115 | p: self.p + rhs.p,
116 | s: self.s + rhs.s,
117 | z: self.z + rhs.z,
118 | }
119 | }
120 | }
121 |
122 | impl Serialize for Counts {
123 | fn serialize(&self, serializer: S) -> Result
124 | where
125 | S: Serializer,
126 | {
127 | serializer.collect_seq(
128 | self.m
129 | .iter()
130 | .chain(self.p.iter())
131 | .chain(self.s.iter())
132 | .chain(self.z.iter()),
133 | )
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/2m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/2m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/8m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/8m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/3m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/3m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/dark/7m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/tile/images/light/7m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ResultGlance.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useWindowSize } from '../hooks/dom';
4 | import type { Result } from '../lib/result';
5 | import { compareTiles } from '../lib/tile';
6 | import { uniqueSorted } from '../lib/util';
7 | import { Tile } from './tile';
8 |
9 | const scrollMargin = 48;
10 |
11 | interface ResultGlanceProps {
12 | result: Result;
13 | handOptionsPosition?: number | undefined;
14 | }
15 | export const ResultGlance: FC = ({
16 | result,
17 | handOptionsPosition = Number.POSITIVE_INFINITY
18 | }) => {
19 | const { t } = useTranslation();
20 | const { height } = useWindowSize();
21 |
22 | if (handOptionsPosition - height + scrollMargin < 0 || result === null)
23 | return null;
24 |
25 | return (
26 |
27 | {result.type === 'just-hora' &&
{t('result.hora')}
}
28 | {result.type === 'tempai' &&
29 | result.tileAvailabilities.every(a => a.count === 0) && (
30 |
{t('result.no-hora-tiles')}
31 | )}
32 | {result.type === 'tempai' &&
33 | result.tileAvailabilities.some(a => a.count > 0) && (
34 |
35 |
{t('result.tempai')}
36 |
37 | {result.tileAvailabilities
38 | .filter(a => a.count > 0)
39 | .map((a, i) => (
40 |
41 |
42 |
43 | ))}
44 |
45 |
46 | )}
47 | {result.type === 'discard-shanten' && (
48 |
49 |
50 | {(() => {
51 | const shanten = result.discards.reduce(
52 | (acc, cur) =>
53 | Math.min(
54 | acc,
55 | cur.next.type === 'tempai'
56 | ? 0
57 | : cur.next.info.type === 'hora'
58 | ? 0
59 | : cur.next.info.shanten
60 | ),
61 | Number.POSITIVE_INFINITY
62 | );
63 | return shanten === 0
64 | ? t('result.tempai')
65 | : t('result.shanten', { count: shanten });
66 | })()}
67 |
68 |
{t('result.discard')}
69 |
70 | {result.discards.map((d, i) => (
71 |
72 |
73 |
74 | ))}
75 |
76 |
77 | )}
78 | {result.type === 'hora-shanten' && result.info.type === 'shanten' && (
79 |
80 |
81 | {t('result.shanten', { count: result.info.shanten })}
82 |
83 |
{t('result.acceptance')}
84 |
85 | {result.info.tileAvailabilities.map((a, i) => (
86 |
87 |
88 |
89 | ))}
90 |
91 |
92 | )}
93 | {result.type === 'hora-shanten' && result.info.type === 'hora' && (
94 |
95 |
{t('result.tempai')}
96 |
{t('result.waiting')}
97 |
98 | {uniqueSorted(
99 | result.info.hora.map(h => h.horaTile),
100 | (a, b) => compareTiles(a, b) === 0
101 | ).map((t, i) => (
102 |
103 |
104 |
105 | ))}
106 |
107 |
108 | )}
109 |
110 | );
111 | };
112 |
--------------------------------------------------------------------------------
/src/components/HandOptions.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useStore } from '../contexts/store';
4 | import { ConfigItem } from './ui/ConfigItem';
5 | import { Segment } from './ui/Segment';
6 |
7 | const riichiOptions = ['none', 'riichi', 'double-riichi'] as const;
8 |
9 | export const HandOptions: FC = () => {
10 | const [{ table, handOptions }, dispatch] = useStore();
11 | const { t } = useTranslation();
12 |
13 | return (
14 |
15 |
16 |
17 |
18 | t(`hand-options.${o}`))}
20 | index={riichiOptions.indexOf(handOptions.riichi)}
21 | onChange={i =>
22 | dispatch({
23 | type: 'set-hand-options',
24 | payload: {
25 | ...handOptions,
26 | riichi: riichiOptions[i as 0 | 1 | 2]
27 | }
28 | })
29 | }
30 | />
31 |
32 |
33 |
34 |
35 |
39 | dispatch({
40 | type: 'set-hand-options',
41 | payload: { ...handOptions, ippatsu: i === 1 }
42 | })
43 | }
44 | />
45 |
46 |
47 |
48 |
49 |
50 |
51 |
55 | dispatch({
56 | type: 'set-hand-options',
57 | payload: { ...handOptions, rinshan: i === 1 }
58 | })
59 | }
60 | />
61 |
62 |
63 |
64 |
65 |
69 | dispatch({
70 | type: 'set-hand-options',
71 | payload: { ...handOptions, chankan: i === 1 }
72 | })
73 | }
74 | />
75 |
76 |
77 |
78 |
79 |
80 |
81 |
85 | dispatch({
86 | type: 'set-hand-options',
87 | payload: { ...handOptions, haitei: i === 1 }
88 | })
89 | }
90 | />
91 |
92 |
93 |
94 |
101 |
112 | dispatch({
113 | type: 'set-hand-options',
114 | payload: { ...handOptions, tenho: i === 1 }
115 | })
116 | }
117 | />
118 |
119 |
120 |
121 |
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/src/components/ui/TileKeyboard.tsx:
--------------------------------------------------------------------------------
1 | import React, { type FC } from 'react';
2 | import { useStore } from '../../contexts/store';
3 | import { instantiateMeld } from '../../lib/input';
4 | import { type Tile, isAvailableTiles } from '../../lib/tile';
5 | import { TileButton } from './TileButton';
6 |
7 | export const TileKeyboard: FC = () => {
8 | const [
9 | {
10 | input,
11 | currentRule: { red },
12 | inputFocus
13 | },
14 | dispatch
15 | ] = useStore();
16 |
17 | const showNonRed = red.m !== 4 || red.p !== 4 || red.s !== 4;
18 | const showRed = red.m !== 0 || red.p !== 0 || red.s !== 0;
19 |
20 | const allInputTiles = [
21 | ...input.hand,
22 | ...input.melds.flatMap(meld => instantiateMeld(meld, red)),
23 | ...input.dora
24 | ];
25 | const isDisabled = (tile: Tile) =>
26 | inputFocus.type === 'hand'
27 | ? !isAvailableTiles(red, allInputTiles, [tile]) ||
28 | input.hand.length >= 14 - input.melds.length * 3
29 | : inputFocus.type === 'dora'
30 | ? !isAvailableTiles(red, allInputTiles, [tile]) ||
31 | input.dora.length >= 10
32 | : input.melds[inputFocus.i]?.tile !== null ||
33 | (input.melds[inputFocus.i]?.type === 'chii'
34 | ? tile.type === 'z' ||
35 | tile.n >= 8 ||
36 | !isAvailableTiles(
37 | red,
38 | allInputTiles,
39 | instantiateMeld({ type: 'chii', tile, includeRed: false }, red)
40 | )
41 | : input.melds[inputFocus.i]?.type === 'pon'
42 | ? !isAvailableTiles(
43 | red,
44 | allInputTiles,
45 | instantiateMeld({ type: 'pon', tile }, red)
46 | )
47 | : input.melds[inputFocus.i]?.type === 'kan'
48 | ? !isAvailableTiles(
49 | red,
50 | allInputTiles,
51 | instantiateMeld({ type: 'kan', tile, closed: true }, red)
52 | )
53 | : true);
54 | return (
55 |
56 | {(['m', 'p', 's'] as const).map(type => (
57 |
58 | {([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map(n => (
59 |
60 | {n === 5 && showNonRed && (
61 |
67 | dispatch({
68 | type: 'click-tile-keyboard',
69 | payload: { type, n, red: false }
70 | })
71 | }
72 | />
73 | )}
74 | {n === 5 && showRed && (
75 |
81 | dispatch({
82 | type: 'click-tile-keyboard',
83 | payload: { type, n, red: true }
84 | })
85 | }
86 | />
87 | )}
88 | {n !== 5 && (
89 |
93 | dispatch({
94 | type: 'click-tile-keyboard',
95 | payload: { type, n }
96 | })
97 | }
98 | />
99 | )}
100 |
101 | ))}
102 |
103 | ))}
104 |
105 | {([1, 2, 3, 4, 5, 6, 7] as const).map(n => (
106 |
111 | dispatch({
112 | type: 'click-tile-keyboard',
113 | payload: { type: 'z', n }
114 | })
115 | }
116 | />
117 | ))}
118 |
119 |
120 | {showNonRed && showRed && }
121 |
122 |
123 | );
124 | };
125 |
--------------------------------------------------------------------------------