This commit is contained in:
syuilo 2024-02-11 12:45:50 +09:00
parent c99d55e0cb
commit c47203b888
12 changed files with 1236 additions and 353 deletions

View file

@ -2631,7 +2631,7 @@ _mahjong:
"chinroto": "清老頭" "chinroto": "清老頭"
"sukantsu": "四槓子" "sukantsu": "四槓子"
"churen": "九蓮宝燈" "churen": "九蓮宝燈"
"pure-churen": "純正九連宝灯" "churen-9": "九連宝灯九面待"
"tenho": "天和" "tenho": "天和"
"chiho": "地和" "chiho": "地和"

View file

@ -657,7 +657,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
if (mj.riichis[house]) { if (mj.riichis[house]) {
// リーチ時はアガリ牌でない限りツモ切り // リーチ時はアガリ牌でない限りツモ切り
if (!Mmj.canHora(mj.handTileTypes[house])) { if (!Mmj.isAgarikei(mj.handTileTypes[house])) {
setTimeout(() => { setTimeout(() => {
this.dahai(room, mj, house, mj.handTiles[house].at(-1)); this.dahai(room, mj, house, mj.handTiles[house].at(-1));
}, 500); }, 500);

View file

@ -123,6 +123,33 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div :class="$style.playersContainer">
<div :class="[$style.playerOfToimen, $style.player]">
<template v-if="users[Mmj.prevHouse(Mmj.prevHouse(mj.myHouse))] != null">
<MkAvatar :user="users[Mmj.prevHouse(Mmj.prevHouse(mj.myHouse))]" style="width: 30px; height: 30px;"/>
</template>
<template v-else>
CPU
</template>
</div>
<div :class="[$style.playerOfKamitya, $style.player]">
<template v-if="users[Mmj.prevHouse(mj.myHouse)] != null">
<MkAvatar :user="users[Mmj.prevHouse(mj.myHouse)]" style="width: 30px; height: 30px;"/>
</template>
<template v-else>
CPU
</template>
</div>
<div :class="[$style.playerOfSimotya, $style.player]">
<template v-if="users[Mmj.nextHouse(mj.myHouse)] != null">
<MkAvatar :user="users[Mmj.nextHouse(mj.myHouse)]" style="width: 30px; height: 30px;"/>
</template>
<template v-else>
CPU
</template>
</div>
</div>
<XHandTiles :class="$style.handTilesOfMe" :tiles="mj.myHandTiles" :doras="mj.doras" :selectableTiles="selectableTiles" :separateLast="isMyTurn && iTsumoed" @choose="chooseTile"/> <XHandTiles :class="$style.handTilesOfMe" :tiles="mj.myHandTiles" :doras="mj.doras" :selectableTiles="selectableTiles" :separateLast="isMyTurn && iTsumoed" @choose="chooseTile"/>
<div :class="$style.serifContainer"> <div :class="$style.serifContainer">
@ -292,9 +319,16 @@ const isMyTurn = computed(() => {
}); });
const canHora = 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<Mmj.TileType[] | null>(null); const selectableTiles = ref<Mmj.TileType[] | null>(null);
const ronSerifHouses = reactive<Record<Mmj.House, boolean>>({ e: false, s: false, w: false, n: false }); const ronSerifHouses = reactive<Record<Mmj.House, boolean>>({ e: false, s: false, w: false, n: false });
const ciiSerifHouses = reactive<Record<Mmj.House, boolean>>({ e: false, s: false, w: false, n: false }); const ciiSerifHouses = reactive<Record<Mmj.House, boolean>>({ e: false, s: false, w: false, n: false });
@ -801,7 +835,7 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-width: 800px; max-width: 600px;
min-height: 600px; min-height: 600px;
margin: auto; margin: auto;
box-sizing: border-box; box-sizing: border-box;
@ -947,7 +981,7 @@ onUnmounted(() => {
.handTilesOfToimen { .handTilesOfToimen {
position: absolute; position: absolute;
top: 0; top: 0;
left: 80px; right: 40px;
} }
.handTileImgOfToimen { .handTileImgOfToimen {
display: inline-block; display: inline-block;
@ -963,14 +997,14 @@ onUnmounted(() => {
.handTilesOfSimotya { .handTilesOfSimotya {
position: absolute; position: absolute;
top: 80px; bottom: 80px;
right: 0; right: 0;
} }
.handTilesOfMe { .handTilesOfMe {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 80px; left: 0px;
} }
.huroTilesOfMe { .huroTilesOfMe {
@ -1066,6 +1100,43 @@ onUnmounted(() => {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; 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 { .serifContainer {
position: absolute; position: absolute;
top: 0; top: 0;

View file

@ -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: [
"<rootDir>"
],
// 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)",
"<rootDir>/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
};

View file

@ -22,16 +22,22 @@
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"", "watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx", "eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint",
"jest": "jest --coverage --detectOpenHandles",
"test": "npm run jest"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0", "@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/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0", "eslint": "8.56.0",
"nodemon": "3.0.2", "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": [ "files": [
"built" "built"

View file

@ -237,297 +237,68 @@ export const PREV_TILE_FOR_SHUNTSU: Record<TileType, TileType | null> = {
chun: null, chun: null,
}; };
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; export const TILE_NUMBER_MAP: Record<TileType, number | null> = {
m1: 1,
type EnvForCalcYaku = { m2: 2,
house: House; m3: 3,
m4: 4,
/** m5: 5,
* () m6: 6,
*/ m7: 7,
handTiles: TileType[]; m8: 8,
m9: 9,
/** p1: 1,
* p2: 2,
*/ p3: 3,
hoTiles: TileType[]; p4: 4,
p5: 5,
/** p6: 6,
* p7: 7,
*/ p8: 8,
huros: Huro[]; p9: 9,
s1: 1,
/** s2: 2,
* s3: 3,
*/ s4: 4,
tsumoTile: TileType | null; s5: 5,
s6: 6,
/** s7: 7,
* s8: 8,
*/ s9: 9,
ronTile: TileType | null; e: null,
s: null,
/** w: null,
* n: null,
*/ haku: null,
doraTiles: TileType[]; hatsu: null,
chun: null,
/**
*
*/
redDoraTiles: TileType[];
/**
*
*/
fieldWind: House;
/**
*
*/
seatWind: House;
/**
*
*/
riichi: boolean;
/**
*
*/
ippatsu: boolean;
}; };
export const YAKU_DEFINITIONS = [{ export const MANZU_TILES = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9'] as const satisfies TileType[];
name: 'riichi', export const PINZU_TILES = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9'] as const satisfies TileType[];
fan: 1, export const SOUZU_TILES = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9'] as const satisfies TileType[];
isYakuman: false, export const CHAR_TILES = ['e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[];
calc: (state: EnvForCalcYaku) => { export const YAOCHU_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[];
return state.riichi; const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
},
}, {
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;
// TODO: 両面待ちかどうか export function isManzu<T extends TileType>(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) export function isPinzu<T extends TileType>(tile: T): tile is typeof PINZU_TILES[number] {
if (horaSet.head === state.seatWind) return false; return PINZU_TILES.includes(tile);
if (horaSet.head === state.fieldWind) return false; }
// 全て順子か? export function isSouzu<T extends TileType>(tile: T): tile is typeof SOUZU_TILES[number] {
if (horaSet.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false; return SOUZU_TILES.includes(tile);
}); }
},
}, { export function isSameNumberTile(a: TileType, b: TileType): boolean {
name: 'iipeko', const aNumber = TILE_NUMBER_MAP[a];
fan: 1, const bNumber = TILE_NUMBER_MAP[b];
isYakuman: false, if (aNumber == null || bNumber == null) return false;
calc: (state: EnvForCalcYaku) => { return aNumber === bNumber;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
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 fanToPoint(fan: number, isParent: boolean): number { export function fanToPoint(fan: number, isParent: boolean): number {
let point; let point;
@ -658,11 +429,19 @@ export function prevHouse(house: House): House {
} }
} }
type HoraSet = { export type FourMentsuOneJyantou = {
head: TileType; head: TileType;
mentsus: [TileType, TileType, 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][] = [ export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [
['m1', 'm2', 'm3'], ['m1', 'm2', 'm3'],
['m2', 'm3', 'm4'], ['m2', 'm3', 'm4'],
@ -720,13 +499,8 @@ function extractShuntsus(tiles: TileType[]): [TileType, TileType, TileType][] {
return shuntsus; return shuntsus;
} }
/** export function analyzeFourMentsuOneJyantou(handTiles: TileType[], all = true): FourMentsuOneJyantou[] {
* const horaSets: FourMentsuOneJyantou[] = [];
* @param handTiles
* @returns
*/
function analyze1head3mentsuSets(handTiles: TileType[]): HoraSet[] {
const horaSets: HoraSet[] = [];
const headSet: TileType[] = []; const headSet: TileType[] = [];
const countMap = new Map<TileType, number>(); const countMap = new Map<TileType, number>();
@ -817,6 +591,8 @@ function analyze1head3mentsuSets(handTiles: TileType[]): HoraSet[] {
head, head,
mentsus: [...kotsuPattern.map(t => [t, t, t] as [TileType, TileType, TileType]), ...shuntsus], 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; 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<TileType, number>();
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 { export function nextTileForDora(tile: TileType): TileType {
return NEXT_TILE_FOR_DORA_MAP[tile]; return NEXT_TILE_FOR_DORA_MAP[tile];
} }
@ -893,3 +627,40 @@ export function getAvailableCiiPatterns(handTiles: TileType[], targetTile: TileT
} }
return patterns; 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<TileType, number>();
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);
});
}

View file

@ -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<TileType, number>();
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);
}

View file

@ -108,7 +108,7 @@ class StateManager {
// TODO: ポンされるなどして自分の河にない場合の考慮 // TODO: ポンされるなどして自分の河にない場合の考慮
if (this.hoTileTypes[house].includes($type(tid))) return false; 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 // TODO
//const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc(this.state, { tsumoTile: null, ronTile: tile })); //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'); if (tx.$state.riichis[house]) throw new Error('Already riichi');
const tempHandTiles = [...tx.handTileTypes[house]]; const tempHandTiles = [...tx.handTileTypes[house]];
tempHandTiles.splice(tempHandTiles.indexOf($type(tid)), 1); 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'); if (tx.$state.points[house] < 1000) throw new Error('Not enough points');
} }

View file

@ -217,8 +217,12 @@ export class PlayerGameEngine {
this.state.turn = null; this.state.turn = null;
if (house === this.myHouse) { if (house === this.myHouse) {
this.state.canRon = null;
this.state.canPon = null;
this.state.canKan = null;
this.state.canCii = null;
} else { } 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 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 canKan = !this.isMeRiichi && this.myHandTileTypes.filter(t => t === $type(tid)).length === 3;
const canCii = !this.isMeRiichi && house === Common.prevHouse(this.myHouse) && const canCii = !this.isMeRiichi && house === Common.prevHouse(this.myHouse) &&

View file

@ -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']);
});
}
}

View file

@ -27,7 +27,6 @@
"src/**/*" "src/**/*"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules"
"test/**/*"
] ]
} }

View file

@ -1190,9 +1190,12 @@ importers:
'@misskey-dev/eslint-plugin': '@misskey-dev/eslint-plugin':
specifier: 1.0.0 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) 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': '@types/node':
specifier: 20.11.5 specifier: 20.11.17
version: 20.11.5 version: 20.11.17
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: 6.18.1 specifier: 6.18.1
version: 6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3) version: 6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3)
@ -1202,9 +1205,18 @@ importers:
eslint: eslint:
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
jest:
specifier: 29.7.0
version: 29.7.0(@types/node@20.11.17)
nodemon: nodemon:
specifier: 3.0.2 specifier: 3.0.2
version: 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: typescript:
specifier: 5.3.3 specifier: 5.3.3
version: 5.3.3 version: 5.3.3
@ -8252,6 +8264,13 @@ packages:
pretty-format: 29.7.0 pretty-format: 29.7.0
dev: true 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: /@types/js-yaml@4.0.9:
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
dev: true dev: true
@ -10024,6 +10043,13 @@ packages:
node-releases: 2.0.14 node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.22.2) 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: /bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
dependencies: dependencies:
@ -15048,7 +15074,6 @@ packages:
/lodash.memoize@4.1.2: /lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: false
/lodash.merge@4.6.2: /lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@ -15193,6 +15218,10 @@ packages:
semver: 7.5.4 semver: 7.5.4
dev: true dev: true
/make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: true
/make-fetch-happen@13.0.0: /make-fetch-happen@13.0.0:
resolution: {integrity: sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==} resolution: {integrity: sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==}
engines: {node: ^16.14.0 || >=18.0.0} engines: {node: ^16.14.0 || >=18.0.0}
@ -19399,6 +19428,47 @@ packages:
engines: {node: '>=6.10'} engines: {node: '>=6.10'}
dev: true 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: /ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
dev: true dev: true