From c47203b8887e8d6eaba17d83b8c0230c50f74905 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 11 Feb 2024 12:45:50 +0900 Subject: [PATCH] wip --- locales/ja-JP.yml | 2 +- packages/backend/src/core/MahjongService.ts | 2 +- .../frontend/src/pages/mahjong/room.game.vue | 81 +- packages/misskey-mahjong/jest.config.cjs | 212 +++++ packages/misskey-mahjong/package.json | 12 +- packages/misskey-mahjong/src/common.ts | 441 +++-------- packages/misskey-mahjong/src/common.yaku.ts | 730 ++++++++++++++++++ packages/misskey-mahjong/src/engine.master.ts | 4 +- packages/misskey-mahjong/src/engine.player.ts | 6 +- packages/misskey-mahjong/test/yaku.ts | 20 + packages/misskey-mahjong/tsconfig.json | 3 +- pnpm-lock.yaml | 76 +- 12 files changed, 1236 insertions(+), 353 deletions(-) create mode 100644 packages/misskey-mahjong/jest.config.cjs create mode 100644 packages/misskey-mahjong/src/common.yaku.ts create mode 100644 packages/misskey-mahjong/test/yaku.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 632b82b7ef..2f89bb022f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2631,7 +2631,7 @@ _mahjong: "chinroto": "清老頭" "sukantsu": "四槓子" "churen": "九蓮宝燈" - "pure-churen": "純正九連宝灯" + "churen-9": "九連宝灯九面待" "tenho": "天和" "chiho": "地和" diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index 98803590e6..e8e73d4155 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -657,7 +657,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { if (mj.riichis[house]) { // リーチ時はアガリ牌でない限りツモ切り - if (!Mmj.canHora(mj.handTileTypes[house])) { + if (!Mmj.isAgarikei(mj.handTileTypes[house])) { setTimeout(() => { this.dahai(room, mj, house, mj.handTiles[house].at(-1)); }, 500); diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index 7de89858ad..6d42883454 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -123,6 +123,33 @@ SPDX-License-Identifier: AGPL-3.0-only +
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -292,9 +319,16 @@ const isMyTurn = computed(() => { }); const canHora = computed(() => { - return Mmj.canHora(mj.value.myHandTileTypes).length; + return Mmj.isAgarikei(mj.value.myHandTileTypes); }); +const users = computed(() => ({ + e: houseToUser('e'), + s: houseToUser('s'), + w: houseToUser('w'), + n: houseToUser('n'), +})); + const selectableTiles = ref(null); const ronSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); const ciiSerifHouses = reactive>({ e: false, s: false, w: false, n: false }); @@ -801,7 +835,7 @@ onUnmounted(() => { position: relative; width: 100%; height: 100%; - max-width: 800px; + max-width: 600px; min-height: 600px; margin: auto; box-sizing: border-box; @@ -947,7 +981,7 @@ onUnmounted(() => { .handTilesOfToimen { position: absolute; top: 0; - left: 80px; + right: 40px; } .handTileImgOfToimen { display: inline-block; @@ -963,14 +997,14 @@ onUnmounted(() => { .handTilesOfSimotya { position: absolute; - top: 80px; + bottom: 80px; right: 0; } .handTilesOfMe { position: absolute; bottom: 0; - left: 80px; + left: 0px; } .huroTilesOfMe { @@ -1066,6 +1100,43 @@ onUnmounted(() => { grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; } +.playersContainer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + pointer-events: none; +} +.player { + position: absolute; + margin: auto; + width: 180px; + height: min-content; + padding: 10px; + box-sizing: border-box; + background: #0009; + color: #fff; + border-radius: 5px; + font-size: 90%; +} +.playerOfToimen { + top: 0; + left: 0; + right: 0; +} +.playerOfKamitya { + top: 0; + left: 0; + bottom: 0; +} +.playerOfSimotya { + top: 0; + right: 0; + bottom: 0; +} + .serifContainer { position: absolute; top: 0; diff --git a/packages/misskey-mahjong/jest.config.cjs b/packages/misskey-mahjong/jest.config.cjs new file mode 100644 index 0000000000..4c87106bd6 --- /dev/null +++ b/packages/misskey-mahjong/jest.config.cjs @@ -0,0 +1,212 @@ +/* +* For a detailed explanation regarding each configuration property and type check, visit: +* https://jestjs.io/docs/en/configuration.html +*/ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls and instances between every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['src/**/*.ts'], + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + //globals: { + //"ts-jest": { + //"useESM": true, + //diagnostics: { + //exclude: ['!test/**/*.ts'], + //}, + //} + //}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: "ts-jest/presets/js-with-ts-esm", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + resolver: "ts-jest-resolver", + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[tj]s?(x)", + "/test/**/*" + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + transform: { + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + "useESM": true, + diagnostics: { + exclude: ['!test/**/*.ts'], + }, + }, + ], + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true +}; diff --git a/packages/misskey-mahjong/package.json b/packages/misskey-mahjong/package.json index a7942b6475..f6b9f79cfb 100644 --- a/packages/misskey-mahjong/package.json +++ b/packages/misskey-mahjong/package.json @@ -22,16 +22,22 @@ "watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"", "eslint": "eslint . --ext .js,.jsx,.ts,.tsx", "typecheck": "tsc --noEmit", - "lint": "pnpm typecheck && pnpm eslint" + "lint": "pnpm typecheck && pnpm eslint", + "jest": "jest --coverage --detectOpenHandles", + "test": "npm run jest" }, "devDependencies": { "@misskey-dev/eslint-plugin": "1.0.0", - "@types/node": "20.11.5", + "@types/node": "20.11.17", + "@types/jest": "29.5.12", "@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/parser": "6.18.1", "eslint": "8.56.0", "nodemon": "3.0.2", - "typescript": "5.3.3" + "typescript": "5.3.3", + "jest": "29.7.0", + "ts-jest": "29.1.2", + "ts-jest-resolver": "2.0.1" }, "files": [ "built" diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index 2278a2e81f..a90fb15c56 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -237,297 +237,68 @@ export const PREV_TILE_FOR_SHUNTSU: Record = { chun: null, }; -const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; - -type EnvForCalcYaku = { - house: House; - - /** - * 和了る人の手牌(副露牌および和了る際のツモ牌・ロン牌は含まない) - */ - handTiles: TileType[]; - - /** - * 河 - */ - hoTiles: TileType[]; - - /** - * 副露 - */ - huros: Huro[]; - - /** - * ツモ牌 - */ - tsumoTile: TileType | null; - - /** - * ロン牌 - */ - ronTile: TileType | null; - - /** - * ドラ表示牌 - */ - doraTiles: TileType[]; - - /** - * 赤ドラ表示牌 - */ - redDoraTiles: TileType[]; - - /** - * 場風 - */ - fieldWind: House; - - /** - * 自風 - */ - seatWind: House; - - /** - * リーチしたかどうか - */ - riichi: boolean; - - /** - * 一巡目以内かどうか - */ - ippatsu: boolean; +export const TILE_NUMBER_MAP: Record = { + m1: 1, + m2: 2, + m3: 3, + m4: 4, + m5: 5, + m6: 6, + m7: 7, + m8: 8, + m9: 9, + p1: 1, + p2: 2, + p3: 3, + p4: 4, + p5: 5, + p6: 6, + p7: 7, + p8: 8, + p9: 9, + s1: 1, + s2: 2, + s3: 3, + s4: 4, + s5: 5, + s6: 6, + s7: 7, + s8: 8, + s9: 9, + e: null, + s: null, + w: null, + n: null, + haku: null, + hatsu: null, + chun: null, }; -export const YAKU_DEFINITIONS = [{ - name: 'riichi', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.riichi; - }, -}, { - name: 'tsumo', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.tsumoTile != null; - }, -}, { - name: 'ippatsu', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.ippatsu; - }, -}, { - name: 'red', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return ( - (state.handTiles.filter(t => t === 'chun').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'chun' : - huro.type === 'ankan' ? huro.tile === 'chun' : - huro.type === 'minkan' ? huro.tile === 'chun' : - false).length >= 3) - ); - }, -}, { - name: 'white', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return ( - (state.handTiles.filter(t => t === 'haku').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'haku' : - huro.type === 'ankan' ? huro.tile === 'haku' : - huro.type === 'minkan' ? huro.tile === 'haku' : - false).length >= 3) - ); - }, -}, { - name: 'green', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return ( - (state.handTiles.filter(t => t === 'hatsu').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'hatsu' : - huro.type === 'ankan' ? huro.tile === 'hatsu' : - huro.type === 'minkan' ? huro.tile === 'hatsu' : - false).length >= 3) - ); - }, -}, { - name: 'field-wind-e', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.fieldWind === 'e' && ( - (state.handTiles.filter(t => t === 'e').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'e' : - huro.type === 'ankan' ? huro.tile === 'e' : - huro.type === 'minkan' ? huro.tile === 'e' : - false).length >= 3) - ); - }, -}, { - name: 'field-wind-s', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.fieldWind === 's' && ( - (state.handTiles.filter(t => t === 's').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 's' : - huro.type === 'ankan' ? huro.tile === 's' : - huro.type === 'minkan' ? huro.tile === 's' : - false).length >= 3) - ); - }, -}, { - name: 'seat-wind-e', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.house === 'e' && ( - (state.handTiles.filter(t => t === 'e').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'e' : - huro.type === 'ankan' ? huro.tile === 'e' : - huro.type === 'minkan' ? huro.tile === 'e' : - false).length >= 3) - ); - }, -}, { - name: 'seat-wind-s', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.house === 's' && ( - (state.handTiles.filter(t => t === 's').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 's' : - huro.type === 'ankan' ? huro.tile === 's' : - huro.type === 'minkan' ? huro.tile === 's' : - false).length >= 3) - ); - }, -}, { - name: 'seat-wind-w', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.house === 'w' && ( - (state.handTiles.filter(t => t === 'w').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'w' : - huro.type === 'ankan' ? huro.tile === 'w' : - huro.type === 'minkan' ? huro.tile === 'w' : - false).length >= 3) - ); - }, -}, { - name: 'seat-wind-n', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return state.house === 'n' && ( - (state.handTiles.filter(t => t === 'n').length >= 3) || - (state.huros.filter(huro => - huro.type === 'pon' ? huro.tile === 'n' : - huro.type === 'ankan' ? huro.tile === 'n' : - huro.type === 'minkan' ? huro.tile === 'n' : - false).length >= 3) - ); - }, -}, { - name: 'tanyao', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - const yaochuTiles: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; - return ( - (!state.handTiles.some(t => yaochuTiles.includes(t))) && - (state.huros.filter(huro => - huro.type === 'pon' ? yaochuTiles.includes(huro.tile) : - huro.type === 'ankan' ? yaochuTiles.includes(huro.tile) : - huro.type === 'minkan' ? yaochuTiles.includes(huro.tile) : - huro.type === 'cii' ? huro.tiles.some(t2 => yaochuTiles.includes(t2)) : - false).length === 0) - ); - }, -}, { - name: 'pinfu', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - // 面前じゃないとダメ - if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; - // 三元牌はダメ - if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false; +export const MANZU_TILES = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9'] as const satisfies TileType[]; +export const PINZU_TILES = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9'] as const satisfies TileType[]; +export const SOUZU_TILES = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9'] as const satisfies TileType[]; +export const CHAR_TILES = ['e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; +export const YAOCHU_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; +const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; - // TODO: 両面待ちかどうか +export function isManzu(tile: T): tile is typeof MANZU_TILES[number] { + return MANZU_TILES.includes(tile); +} - const horaSets = analyze1head3mentsuSets(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); - return horaSets.some(horaSet => { - // 風牌判定(役牌でなければOK) - if (horaSet.head === state.seatWind) return false; - if (horaSet.head === state.fieldWind) return false; +export function isPinzu(tile: T): tile is typeof PINZU_TILES[number] { + return PINZU_TILES.includes(tile); +} - // 全て順子か? - if (horaSet.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false; - }); - }, -}, { - name: 'iipeko', - fan: 1, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - // 面前じゃないとダメ - if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; +export function isSouzu(tile: T): tile is typeof SOUZU_TILES[number] { + return SOUZU_TILES.includes(tile); +} - const horaSets = analyze1head3mentsuSets(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); - return horaSets.some(horaSet => { - // 同じ順子が2つあるか? - return horaSet.mentsus.some((mentsu) => - horaSet.mentsus.filter((mentsu2) => - mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 2); - }); - }, -}, { - name: 'toitoi', - fan: 2, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - if (state.huros.length > 0) { - if (state.huros.some(huro => huro.type === 'cii')) return false; - } - const horaSets = analyze1head3mentsuSets(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); - return horaSets.some(horaSet => { - // 全て刻子か? - if (!horaSet.mentsus.every((mentsu) => mentsu[0] === mentsu[1])) return false; - }); - }, -}, { - name: 'chitoitsu', - fan: 2, - isYakuman: false, - calc: (state: EnvForCalcYaku) => { - return isChitoitsu(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); - }, -}, { - name: 'kokushi', - fan: 13, - isYakuman: true, - calc: (state: EnvForCalcYaku) => { - return isKokushi(state.handTiles.concat(state.tsumoTile ?? state.ronTile)); - }, -}]; +export function isSameNumberTile(a: TileType, b: TileType): boolean { + const aNumber = TILE_NUMBER_MAP[a]; + const bNumber = TILE_NUMBER_MAP[b]; + if (aNumber == null || bNumber == null) return false; + return aNumber === bNumber; +} export function fanToPoint(fan: number, isParent: boolean): number { let point; @@ -658,11 +429,19 @@ export function prevHouse(house: House): House { } } -type HoraSet = { +export type FourMentsuOneJyantou = { head: TileType; mentsus: [TileType, TileType, TileType][]; }; +export function isShuntu(tiles: [TileType, TileType, TileType]): boolean { + return tiles[0] !== tiles[1]; +} + +export function isKotsu(tiles: [TileType, TileType, TileType]): boolean { + return tiles[0] === tiles[1]; +} + export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [ ['m1', 'm2', 'm3'], ['m2', 'm3', 'm4'], @@ -720,13 +499,8 @@ function extractShuntsus(tiles: TileType[]): [TileType, TileType, TileType][] { return shuntsus; } -/** - * アガリ形パターン一覧を取得 - * @param handTiles ポン、チー、カンした牌を含まない手牌 - * @returns - */ -function analyze1head3mentsuSets(handTiles: TileType[]): HoraSet[] { - const horaSets: HoraSet[] = []; +export function analyzeFourMentsuOneJyantou(handTiles: TileType[], all = true): FourMentsuOneJyantou[] { + const horaSets: FourMentsuOneJyantou[] = []; const headSet: TileType[] = []; const countMap = new Map(); @@ -817,6 +591,8 @@ function analyze1head3mentsuSets(handTiles: TileType[]): HoraSet[] { head, mentsus: [...kotsuPattern.map(t => [t, t, t] as [TileType, TileType, TileType]), ...shuntsus], }); + + if (!all) return horaSets; } } } @@ -824,48 +600,6 @@ function analyze1head3mentsuSets(handTiles: TileType[]): HoraSet[] { return horaSets; } -export function canHora(handTiles: TileType[]): boolean { - if (isKokushi(handTiles)) return true; - if (isChitoitsu(handTiles)) return true; - - const horaSets = analyze1head3mentsuSets(handTiles); - return horaSets.length > 0; -} - -/** - * アガリ牌リストを取得 - * @param handTiles ポン、チー、カンした牌を含まない手牌 - */ -export function getHoraTiles(handTiles: TileType[]): TileType[] { - return TILE_TYPES.filter(tile => { - const tempHandTiles = [...handTiles, tile]; - const horaSets = analyze1head3mentsuSets(tempHandTiles); - return horaSets.length > 0; - }); -} - -function isKokushi(handTiles: TileType[]): boolean { - return KOKUSHI_TILES.every(t => handTiles.includes(t)); -} - -function isChitoitsu(handTiles: TileType[]): boolean { - const countMap = new Map(); - for (const tile of handTiles) { - const count = (countMap.get(tile) ?? 0) + 1; - countMap.set(tile, count); - } - return Array.from(countMap.values()).every(c => c === 2); -} - -export function getTilesForRiichi(handTiles: TileType[]): TileType[] { - return handTiles.filter(tile => { - const tempHandTiles = [...handTiles]; - tempHandTiles.splice(tempHandTiles.indexOf(tile), 1); - const horaTiles = getHoraTiles(tempHandTiles); - return horaTiles.length > 0; - }); -} - export function nextTileForDora(tile: TileType): TileType { return NEXT_TILE_FOR_DORA_MAP[tile]; } @@ -893,3 +627,40 @@ export function getAvailableCiiPatterns(handTiles: TileType[], targetTile: TileT } return patterns; } + +function isKokushiPattern(handTiles: TileType[]): boolean { + return KOKUSHI_TILES.every(t => handTiles.includes(t)); +} + +function isChitoitsuPattern(handTiles: TileType[]): boolean { + if (handTiles.length !== 14) return false; + const countMap = new Map(); + for (const tile of handTiles) { + const count = (countMap.get(tile) ?? 0) + 1; + countMap.set(tile, count); + } + return Array.from(countMap.values()).every(c => c === 2); +} + +export function isAgarikei(handTiles: TileType[]): boolean { + if (isKokushiPattern(handTiles)) return true; + if (isChitoitsuPattern(handTiles)) return true; + + const agarikeis = analyzeFourMentsuOneJyantou(handTiles, false); + return agarikeis.length > 0; +} + +export function isTenpai(handTiles: TileType[]): boolean { + return TILE_TYPES.some(tile => { + const tempHandTiles = [...handTiles, tile]; + return isAgarikei(tempHandTiles); + }); +} + +export function getTilesForRiichi(handTiles: TileType[]): TileType[] { + return handTiles.filter(tile => { + const tempHandTiles = [...handTiles]; + tempHandTiles.splice(tempHandTiles.indexOf(tile), 1); + return isTenpai(tempHandTiles); + }); +} diff --git a/packages/misskey-mahjong/src/common.yaku.ts b/packages/misskey-mahjong/src/common.yaku.ts new file mode 100644 index 0000000000..c993bd5ae8 --- /dev/null +++ b/packages/misskey-mahjong/src/common.yaku.ts @@ -0,0 +1,730 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu } from './common.js'; + +const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu']; +const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + +export const NORMAL_YAKU_NAMES = [ + 'riichi', + 'ippatsu', + 'tsumo', + 'tanyao', + 'pinfu', + 'iipeko', + 'field-wind', + 'seat-wind', + 'white', + 'green', + 'red', + 'rinshan', + 'chankan', + 'haitei', + 'hotei', + 'sanshoku-dojun', + 'sanshoku-doko', + 'ittsu', + 'chanta', + 'chitoitsu', + 'toitoi', + 'sananko', + 'honroto', + 'sankantsu', + 'shosangen', + 'double-riichi', + 'honitsu', + 'junchan', + 'ryampeko', + 'chinitsu', + 'dora', + 'red-dora', +] as const; + +export const YAKUMAN_NAMES = [ + 'kokushi', + 'kokushi-13', + 'suanko', + 'suanko-tanki', + 'daisangen', + 'tsuiso', + 'shosushi', + 'daisushi', + 'ryuiso', + 'chinroto', + 'sukantsu', + 'churen', + 'churen-9', + 'tenho', + 'chiho', +] as const; + +export type YakuName = typeof NORMAL_YAKU_NAMES[number] | typeof YAKUMAN_NAMES[number]; + +export type EnvForCalcYaku = { + house: House; + + /** + * 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む) + */ + handTiles: TileType[]; + + tenpaiTiles: TileType[]; + + /** + * 河 + */ + hoTiles: TileType[]; + + /** + * 副露 + */ + huros: ({ + type: 'pon'; + tile: TileType; + } | { + type: 'cii'; + tiles: [TileType, TileType, TileType]; + } | { + type: 'ankan'; + tile: TileType; + } | { + type: 'minkan'; + tile: TileType; + })[]; + + tsumoTile: TileType; + ronTile: TileType; + + /** + * 場風 + */ + fieldWind: House; + + /** + * 自風 + */ + seatWind: House; + + /** + * リーチしたかどうか + */ + riichi: boolean; + + /** + * 一巡目以内かどうか + */ + ippatsu: boolean; +}; + +type YakuDefiniyion = { + name: YakuName; + fan: number; + isYakuman?: boolean; + kuisagari?: boolean; + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => boolean; +}; + +function countTiles(tiles: TileType[], target: TileType): number { + return tiles.filter(t => t === target).length; +} + +export const YAKU_DEFINITIONS: YakuDefiniyion[] = [{ + name: 'tsumo', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + + return state.isTsumo; + }, +}, { + name: 'riichi', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return state.riichi; + }, +}, { + name: 'ippatsu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return state.ippatsu; + }, +}, { + name: 'red', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'chun') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'chun' : + huro.type === 'ankan' ? huro.tile === 'chun' : + huro.type === 'minkan' ? huro.tile === 'chun' : + false).length >= 3) + ); + }, +}, { + name: 'white', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'haku') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'haku' : + huro.type === 'ankan' ? huro.tile === 'haku' : + huro.type === 'minkan' ? huro.tile === 'haku' : + false).length >= 3) + ); + }, +}, { + name: 'green', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'hatsu') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'hatsu' : + huro.type === 'ankan' ? huro.tile === 'hatsu' : + huro.type === 'minkan' ? huro.tile === 'hatsu' : + false).length >= 3) + ); + }, +}, { + name: 'field-wind-e', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.fieldWind === 'e' && ( + (countTiles(state.handTiles, 'e') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'e' : + huro.type === 'ankan' ? huro.tile === 'e' : + huro.type === 'minkan' ? huro.tile === 'e' : + false).length >= 3) + ); + }, +}, { + name: 'field-wind-s', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.fieldWind === 's' && ( + (countTiles(state.handTiles, 's') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 's' : + huro.type === 'ankan' ? huro.tile === 's' : + huro.type === 'minkan' ? huro.tile === 's' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-e', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'e' && ( + (countTiles(state.handTiles, 'e') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'e' : + huro.type === 'ankan' ? huro.tile === 'e' : + huro.type === 'minkan' ? huro.tile === 'e' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-s', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 's' && ( + (countTiles(state.handTiles, 's') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 's' : + huro.type === 'ankan' ? huro.tile === 's' : + huro.type === 'minkan' ? huro.tile === 's' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-w', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'w' && ( + (countTiles(state.handTiles, 'w') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'w' : + huro.type === 'ankan' ? huro.tile === 'w' : + huro.type === 'minkan' ? huro.tile === 'w' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-n', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'n' && ( + (countTiles(state.handTiles, 'n') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'n' : + huro.type === 'ankan' ? huro.tile === 'n' : + huro.type === 'minkan' ? huro.tile === 'n' : + false).length >= 3) + ); + }, +}, { + name: 'tanyao', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (!state.handTiles.some(t => YAOCHU_TILES.includes(t))) && + (state.huros.filter(huro => + huro.type === 'pon' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'ankan' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'minkan' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'cii' ? huro.tiles.some(t2 => YAOCHU_TILES.includes(t2)) : + false).length === 0) + ); + }, +}, { + name: 'pinfu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + // 三元牌はダメ + if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false; + + // TODO: 両面待ちかどうか + + // 風牌判定(役牌でなければOK) + if (fourMentsuOneJyantou.head === state.seatWind) return false; + if (fourMentsuOneJyantou.head === state.fieldWind) return false; + + // 全て順子か? + if (fourMentsuOneJyantou.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false; + + return true; + }, +}, { + name: 'honitsu', + fan: 3, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length; + let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length; + charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length; + } + + if (manzuCount > 0 && pinzuCount > 0) return false; + if (manzuCount > 0 && souzuCount > 0) return false; + if (pinzuCount > 0 && souzuCount > 0) return false; + if (charCount === 0) return false; + + return true; + }, +}, { + name: 'chinitsu', + fan: 6, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length; + let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length; + charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length; + } + + if (charCount > 0) return false; + if (manzuCount > 0 && pinzuCount > 0) return false; + if (manzuCount > 0 && souzuCount > 0) return false; + if (pinzuCount > 0 && souzuCount > 0) return false; + + return true; + }, +}, { + name: 'iipeko', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + + // 同じ順子が2つあるか? + return fourMentsuOneJyantou.mentsus.some((mentsu) => + fourMentsuOneJyantou.mentsus.filter((mentsu2) => + mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 2); + }, +}, { + name: 'toitoi', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + if (state.huros.length > 0) { + if (state.huros.some(huro => huro.type === 'cii')) return false; + } + + // 全て刻子か? + if (!fourMentsuOneJyantou.mentsus.every((mentsu) => mentsu[0] === mentsu[1])) return false; + + return true; + }, +}, { + name: 'sananko', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + + }, +}, { + name: 'sanshoku-dojun', + fan: 2, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles)); + + for (const shuntsu of shuntsus) { + if (isManzu(shuntsu[0])) { + if (shuntsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } else if (isPinzu(shuntsu[0])) { + if (shuntsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } else if (isSouzu(shuntsu[0])) { + if (shuntsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } + } + + return false; + }, +}, { + name: 'sanshoku-doko', + fan: 2, + isYakuman: false, + kuisagari: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsus = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)); + + for (const kotsu of kotsus) { + if (isManzu(kotsu[0])) { + if (kotsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } else if (isPinzu(kotsu[0])) { + if (kotsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } else if (isSouzu(kotsu[0])) { + if (kotsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } + } + + return false; + }, +}, { + name: 'ittsu', + fan: 2, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles)); + + if (shuntsus.some(tiles => tiles[0] === 'm1' && tiles[1] === 'm2' && tiles[2] === 'm3')) { + if (shuntsus.some(tiles => tiles[0] === 'm4' && tiles[1] === 'm5' && tiles[2] === 'm6')) { + if (shuntsus.some(tiles => tiles[0] === 'm7' && tiles[1] === 'm8' && tiles[2] === 'm9')) { + return true; + } + } + } + if (shuntsus.some(tiles => tiles[0] === 'p1' && tiles[1] === 'p2' && tiles[2] === 'p3')) { + if (shuntsus.some(tiles => tiles[0] === 'p4' && tiles[1] === 'p5' && tiles[2] === 'p6')) { + if (shuntsus.some(tiles => tiles[0] === 'p7' && tiles[1] === 'p8' && tiles[2] === 'p9')) { + return true; + } + } + } + if (shuntsus.some(tiles => tiles[0] === 's1' && tiles[1] === 's2' && tiles[2] === 's3')) { + if (shuntsus.some(tiles => tiles[0] === 's4' && tiles[1] === 's5' && tiles[2] === 's6')) { + if (shuntsus.some(tiles => tiles[0] === 's7' && tiles[1] === 's8' && tiles[2] === 's9')) { + return true; + } + } + } + + return false; + }, +}, { + name: 'chitoitsu', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (state.huros.length > 0) return false; + const countMap = new Map(); + for (const tile of state.handTiles) { + const count = (countMap.get(tile) ?? 0) + 1; + countMap.set(tile, count); + } + return Array.from(countMap.values()).every(c => c === 2); + }, +}, { + name: 'shosangen', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + switch (fourMentsuOneJyantou.head) { + case 'haku': return kotsuTiles.includes('hatsu') && kotsuTiles.includes('chun'); + case 'hatsu': return kotsuTiles.includes('haku') && kotsuTiles.includes('chun'); + case 'chun': return kotsuTiles.includes('haku') && kotsuTiles.includes('hatsu'); + } + + return false; + }, +}, { + name: 'daisangen', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + return kotsuTiles.includes('haku') && kotsuTiles.includes('hatsu') && kotsuTiles.includes('chun'); + }, +}, { + name: 'shosushi', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + let all = [...state.handTiles]; + for (const huro of state.huros) { + if (huro.type === 'cii') { + all = [...all, ...huro.tiles]; + } else if (huro.type === 'pon') { + all = [...all, huro.tile, huro.tile, huro.tile]; + } else { + all = [...all, huro.tile, huro.tile, huro.tile, huro.tile]; + } + } + + switch (fourMentsuOneJyantou.head) { + case 'e': return (countTiles(all, 's') === 3) && (countTiles(all, 'w') === 3) && (countTiles(all, 'n') === 3); + case 's': return (countTiles(all, 'e') === 3) && (countTiles(all, 'w') === 3) && (countTiles(all, 'n') === 3); + case 'w': return (countTiles(all, 'e') === 3) && (countTiles(all, 's') === 3) && (countTiles(all, 'n') === 3); + case 'n': return (countTiles(all, 'e') === 3) && (countTiles(all, 's') === 3) && (countTiles(all, 'w') === 3); + } + + return false; + }, +}, { + name: 'daisushi', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + return kotsuTiles.includes('e') && kotsuTiles.includes('s') && kotsuTiles.includes('w') && kotsuTiles.includes('n'); + }, +}, { + name: 'tsuiso', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length; + } + + if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false; + + return true; + }, +}, { + name: 'ryuiso', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + if (state.handTiles.some(t => !RYUISO_TILES.includes(t))) return false; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + if (huroTiles.some(t => !RYUISO_TILES.includes(t))) return false; + } + + return true; + }, +}, { + name: 'kokushi', + fan: 13, + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return KOKUSHI_TILES.every(t => state.handTiles.includes(t)); + }, +}]; + +export function calcYakus(state: EnvForCalcYaku): YakuName[] { + const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles); + if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null); + + const yakuPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => { + return YAKU_DEFINITIONS.map(yakuDef => { + const result = yakuDef.calc(state, fourMentsuOneJyantou); + return result ? yakuDef : null; + }).filter(yaku => yaku != null) as YakuDefiniyion[]; + }).filter(yakus => yakus.length > 0); + + const isMenzen = state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type)); + + let maxYakus = yakuPatterns[0]; + let maxFan = 0; + for (const yakus of yakuPatterns) { + let fan = 0; + for (const yaku of yakus) { + if (yaku.kuisagari && !isMenzen) { + fan += yaku.fan - 1; + } else { + fan += yaku.fan; + } + } + if (fan > maxFan) { + maxFan = fan; + maxYakus = yakus; + } + } + + return maxYakus.map(yaku => yaku.name); +} diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 8821192867..f98a0dd03c 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -108,7 +108,7 @@ class StateManager { // TODO: ポンされるなどして自分の河にない場合の考慮 if (this.hoTileTypes[house].includes($type(tid))) return false; - if (!Common.canHora(this.handTileTypes[house].concat($type(tid)))) return false; // 完成形じゃない + if (!Common.isAgarikei(this.handTileTypes[house].concat($type(tid)))) return false; // 完成形じゃない // TODO //const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc(this.state, { tsumoTile: null, ronTile: tile })); @@ -416,7 +416,7 @@ export class MasterGameEngine { if (tx.$state.riichis[house]) throw new Error('Already riichi'); const tempHandTiles = [...tx.handTileTypes[house]]; tempHandTiles.splice(tempHandTiles.indexOf($type(tid)), 1); - if (Common.getHoraTiles(tempHandTiles).length === 0) throw new Error('Not tenpai'); + if (!Common.isTenpai(tempHandTiles)) throw new Error('Not tenpai'); if (tx.$state.points[house] < 1000) throw new Error('Not enough points'); } diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index d1a9094133..7262785324 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -217,8 +217,12 @@ export class PlayerGameEngine { this.state.turn = null; if (house === this.myHouse) { + this.state.canRon = null; + this.state.canPon = null; + this.state.canKan = null; + this.state.canCii = null; } else { - const canRon = Common.canHora(this.myHandTiles.concat(tid).map(id => $type(id))); + const canRon = Common.isAgarikei(this.myHandTiles.concat(tid).map(id => $type(id))); const canPon = !this.isMeRiichi && this.myHandTileTypes.filter(t => t === $type(tid)).length === 2; const canKan = !this.isMeRiichi && this.myHandTileTypes.filter(t => t === $type(tid)).length === 3; const canCii = !this.isMeRiichi && house === Common.prevHouse(this.myHouse) && diff --git a/packages/misskey-mahjong/test/yaku.ts b/packages/misskey-mahjong/test/yaku.ts new file mode 100644 index 0000000000..ef67a6d80a --- /dev/null +++ b/packages/misskey-mahjong/test/yaku.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { calcYakus } from '../src/common.yaku.js'; + +describe('Yaku', () => { + describe('Riichi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + riichi: true, + }), ['riichi']); + }); + } +} diff --git a/packages/misskey-mahjong/tsconfig.json b/packages/misskey-mahjong/tsconfig.json index f56b65e868..da94aefea0 100644 --- a/packages/misskey-mahjong/tsconfig.json +++ b/packages/misskey-mahjong/tsconfig.json @@ -27,7 +27,6 @@ "src/**/*" ], "exclude": [ - "node_modules", - "test/**/*" + "node_modules" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 617e79e660..0b0ac08603 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1190,9 +1190,12 @@ importers: '@misskey-dev/eslint-plugin': specifier: 1.0.0 version: 1.0.0(@typescript-eslint/eslint-plugin@6.18.1)(@typescript-eslint/parser@6.18.1)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + '@types/jest': + specifier: 29.5.12 + version: 29.5.12 '@types/node': - specifier: 20.11.5 - version: 20.11.5 + specifier: 20.11.17 + version: 20.11.17 '@typescript-eslint/eslint-plugin': specifier: 6.18.1 version: 6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3) @@ -1202,9 +1205,18 @@ importers: eslint: specifier: 8.56.0 version: 8.56.0 + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.11.17) nodemon: specifier: 3.0.2 version: 3.0.2 + ts-jest: + specifier: 29.1.2 + version: 29.1.2(@babel/core@7.23.5)(esbuild@0.19.11)(jest@29.7.0)(typescript@5.3.3) + ts-jest-resolver: + specifier: 2.0.1 + version: 2.0.1 typescript: specifier: 5.3.3 version: 5.3.3 @@ -8252,6 +8264,13 @@ packages: pretty-format: 29.7.0 dev: true + /@types/jest@29.5.12: + resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + dev: true + /@types/js-yaml@4.0.9: resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} dev: true @@ -10024,6 +10043,13 @@ packages: node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.2) + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + /bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} dependencies: @@ -15048,7 +15074,6 @@ packages: /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -15193,6 +15218,10 @@ packages: semver: 7.5.4 dev: true + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + /make-fetch-happen@13.0.0: resolution: {integrity: sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==} engines: {node: ^16.14.0 || >=18.0.0} @@ -19399,6 +19428,47 @@ packages: engines: {node: '>=6.10'} dev: true + /ts-jest-resolver@2.0.1: + resolution: {integrity: sha512-FolE73BqVZCs8/RbLKxC67iaAtKpBWx7PeLKFW2zJQlOf9j851I7JRxSDenri2NFvVH3QP7v3S8q1AmL24Zb9Q==} + dependencies: + jest-resolve: 29.7.0 + dev: true + + /ts-jest@29.1.2(@babel/core@7.23.5)(esbuild@0.19.11)(jest@29.7.0)(typescript@5.3.3): + resolution: {integrity: sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==} + engines: {node: ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.23.5 + bs-logger: 0.2.6 + esbuild: 0.19.11 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.11.17) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.4 + typescript: 5.3.3 + yargs-parser: 21.1.1 + dev: true + /ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} dev: true