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
+
+
+
+
+
+
+ CPU
+
+
+
+
+
+
+
+ CPU
+
+
+
+
+
+
+
+ CPU
+
+
+
+
@@ -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