From be3b2558d1fb5fde2f474f3623865a87103d2fed Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 4 Feb 2024 13:26:40 +0900 Subject: [PATCH] wip --- packages/misskey-mahjong/src/common.ts | 126 ++++++++---- packages/misskey-mahjong/src/engine.master.ts | 188 ++++++++++++------ packages/misskey-mahjong/src/engine.player.ts | 108 +++++----- packages/misskey-mahjong/src/serializer.ts | 10 +- 4 files changed, 287 insertions(+), 145 deletions(-) diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index c0f44d4c1f..27ac381b56 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -43,28 +43,84 @@ export const TILE_TYPES = [ 'chun', ] as const; -export type Tile = typeof TILE_TYPES[number]; +export type TileType = typeof TILE_TYPES[number]; + +export type TileInstance = { + t: TileType; + red?: boolean; +}; + +export type TileId = number; + +export const TILE_ID_MAP = new Map([ + /* eslint-disable no-multi-spaces */ + [1, { t: 'm1' }], [2, { t: 'm1' }], [3, { t: 'm1' }], [4, { t: 'm1' }], + [5, { t: 'm2' }], [6, { t: 'm2' }], [7, { t: 'm2' }], [8, { t: 'm2' }], + [9, { t: 'm3' }], [10, { t: 'm3' }], [11, { t: 'm3' }], [12, { t: 'm3' }], + [13, { t: 'm4' }], [14, { t: 'm4' }], [15, { t: 'm4' }], [16, { t: 'm4' }], + [17, { t: 'm5' }], [18, { t: 'm5' }], [19, { t: 'm5' }], [20, { t: 'm5', red: true }], + [21, { t: 'm6' }], [22, { t: 'm6' }], [23, { t: 'm6' }], [24, { t: 'm6' }], + [25, { t: 'm7' }], [26, { t: 'm7' }], [27, { t: 'm7' }], [28, { t: 'm7' }], + [29, { t: 'm8' }], [30, { t: 'm8' }], [31, { t: 'm8' }], [32, { t: 'm8' }], + [33, { t: 'm9' }], [34, { t: 'm9' }], [35, { t: 'm9' }], [36, { t: 'm9' }], + [37, { t: 'p1' }], [38, { t: 'p1' }], [39, { t: 'p1' }], [40, { t: 'p1' }], + [41, { t: 'p2' }], [42, { t: 'p2' }], [43, { t: 'p2' }], [44, { t: 'p2' }], + [45, { t: 'p3' }], [46, { t: 'p3' }], [47, { t: 'p3' }], [48, { t: 'p3' }], + [49, { t: 'p4' }], [50, { t: 'p4' }], [51, { t: 'p4' }], [52, { t: 'p4' }], + [53, { t: 'p5' }], [54, { t: 'p5' }], [55, { t: 'p5' }], [56, { t: 'p5', red: true }], + [57, { t: 'p6' }], [58, { t: 'p6' }], [59, { t: 'p6' }], [60, { t: 'p6' }], + [61, { t: 'p7' }], [62, { t: 'p7' }], [63, { t: 'p7' }], [64, { t: 'p7' }], + [65, { t: 'p8' }], [66, { t: 'p8' }], [67, { t: 'p8' }], [68, { t: 'p8' }], + [69, { t: 'p9' }], [70, { t: 'p9' }], [71, { t: 'p9' }], [72, { t: 'p9' }], + [73, { t: 's1' }], [74, { t: 's1' }], [75, { t: 's1' }], [76, { t: 's1' }], + [77, { t: 's2' }], [78, { t: 's2' }], [79, { t: 's2' }], [80, { t: 's2' }], + [81, { t: 's3' }], [82, { t: 's3' }], [83, { t: 's3' }], [84, { t: 's3' }], + [85, { t: 's4' }], [86, { t: 's4' }], [87, { t: 's4' }], [88, { t: 's4' }], + [89, { t: 's5' }], [90, { t: 's5' }], [91, { t: 's5' }], [92, { t: 's5', red: true }], + [93, { t: 's6' }], [94, { t: 's6' }], [95, { t: 's6' }], [96, { t: 's6' }], + [97, { t: 's7' }], [98, { t: 's7' }], [99, { t: 's7' }], [100, { t: 's7' }], + [101, { t: 's8' }], [102, { t: 's8' }], [103, { t: 's8' }], [104, { t: 's8' }], + [105, { t: 's9' }], [106, { t: 's9' }], [107, { t: 's9' }], [108, { t: 's9' }], + [109, { t: 'e' }], [110, { t: 'e' }], [111, { t: 'e' }], [112, { t: 'e' }], + [113, { t: 's' }], [114, { t: 's' }], [115, { t: 's' }], [116, { t: 's' }], + [117, { t: 'w' }], [118, { t: 'w' }], [119, { t: 'w' }], [120, { t: 'w' }], + [121, { t: 'n' }], [122, { t: 'n' }], [123, { t: 'n' }], [124, { t: 'n' }], + [125, { t: 'haku' }], [126, { t: 'haku' }], [127, { t: 'haku' }], [128, { t: 'haku' }], + [129, { t: 'hatsu' }], [130, { t: 'hatsu' }], [131, { t: 'hatsu' }], [132, { t: 'hatsu' }], + [133, { t: 'chun' }], [134, { t: 'chun' }], [135, { t: 'chun' }], [136, { t: 'chun' }], + /* eslint-enable no-multi-spaces */ +]); + +export function findTileByIdOrFail(tileId: TileId): TileInstance { + const tile = TILE_ID_MAP.get(tileId); + if (tile == null) throw new Error(`tile not found: ${tileId}`); + return tile; +} + +export function findTileById(tileId: TileId): TileInstance | null { + return TILE_ID_MAP.get(tileId) ?? null; +} export type House = 'e' | 's' | 'w' | 'n'; export type Huro = { type: 'pon'; - tile: Tile; + tiles: [TileId, TileId, TileId]; from: House; } | { type: 'cii'; - tiles: [Tile, Tile, Tile]; + tiles: [TileId, TileId, TileId]; from: House; } | { type: 'ankan'; - tile: Tile; + tiles: [TileId, TileId, TileId, TileId]; } | { type: 'minkan'; - tile: Tile; + tiles: [TileId, TileId, TileId, TileId]; from: House | null; // null で加槓 }; -export const NEXT_TILE_FOR_DORA_MAP: Record = { +export const NEXT_TILE_FOR_DORA_MAP: Record = { m1: 'm2', m2: 'm3', m3: 'm4', @@ -101,7 +157,7 @@ export const NEXT_TILE_FOR_DORA_MAP: Record = { chun: 'haku', }; -export const NEXT_TILE_FOR_SHUNTSU: Record = { +export const NEXT_TILE_FOR_SHUNTSU: Record = { m1: 'm2', m2: 'm3', m3: 'm4', @@ -138,7 +194,7 @@ export const NEXT_TILE_FOR_SHUNTSU: Record = { chun: null, }; -export const PREV_TILE_FOR_SHUNTSU: Record = { +export const PREV_TILE_FOR_SHUNTSU: Record = { m1: null, m2: 'm1', m3: 'm2', @@ -181,12 +237,12 @@ type EnvForCalcYaku = { /** * 和了る人の手牌(副露牌および和了る際のツモ牌・ロン牌は含まない) */ - handTiles: Tile[]; + handTiles: TileType[]; /** * 河 */ - hoTiles: Tile[]; + hoTiles: TileType[]; /** * 副露 @@ -196,22 +252,22 @@ type EnvForCalcYaku = { /** * ツモ牌 */ - tsumoTile: Tile | null; + tsumoTile: TileType | null; /** * ロン牌 */ - ronTile: Tile | null; + ronTile: TileType | null; /** * ドラ表示牌 */ - doraTiles: Tile[]; + doraTiles: TileType[]; /** * 赤ドラ表示牌 */ - redDoraTiles: Tile[]; + redDoraTiles: TileType[]; /** * 場風 @@ -362,7 +418,7 @@ export const YAKU_DEFINITIONS = [{ name: 'tanyao', fan: 1, calc: (state: EnvForCalcYaku) => { - const yaochuTiles: Tile[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + 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 => @@ -424,7 +480,7 @@ export function fanToPoint(fan: number, isParent: boolean): number { return point; } -export function calcOwnedDoraCount(handTiles: Tile[], huros: Huro[], doras: Tile[]): number { +export function calcOwnedDoraCount(handTiles: TileType[], huros: Huro[], doras: TileType[]): number { let count = 0; for (const t of handTiles) { if (doras.includes(t)) count++; @@ -474,11 +530,11 @@ export function calcTsumoHoraPointDeltas(house: House, fans: number): Record { const aIndex = TILE_TYPES.indexOf(a); const bIndex = TILE_TYPES.indexOf(b); @@ -508,11 +564,11 @@ export function prevHouse(house: House): House { } type HoraSet = { - head: Tile; - mentsus: [Tile, Tile, Tile][]; + head: TileType; + mentsus: [TileType, TileType, TileType][]; }; -export const SHUNTU_PATTERNS: [Tile, Tile, Tile][] = [ +export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [ ['m1', 'm2', 'm3'], ['m2', 'm3', 'm4'], ['m3', 'm4', 'm5'], @@ -536,7 +592,7 @@ export const SHUNTU_PATTERNS: [Tile, Tile, Tile][] = [ ['s7', 's8', 's9'], ]; -function extractShuntsus(tiles: Tile[]): [Tile, Tile, Tile][] { +function extractShuntsus(tiles: TileType[]): [TileType, TileType, TileType][] { const tempTiles = [...tiles]; tempTiles.sort((a, b) => { @@ -545,7 +601,7 @@ function extractShuntsus(tiles: Tile[]): [Tile, Tile, Tile][] { return aIndex - bIndex; }); - const shuntsus: [Tile, Tile, Tile][] = []; + const shuntsus: [TileType, TileType, TileType][] = []; while (tempTiles.length > 0) { let isShuntu = false; for (const shuntuPattern of SHUNTU_PATTERNS) { @@ -574,11 +630,11 @@ function extractShuntsus(tiles: Tile[]): [Tile, Tile, Tile][] { * @param handTiles ポン、チー、カンした牌を含まない手牌 * @returns */ -export function getHoraSets(handTiles: Tile[]): HoraSet[] { +export function getHoraSets(handTiles: TileType[]): HoraSet[] { const horaSets: HoraSet[] = []; - const headSet: Tile[] = []; - const countMap = new Map(); + const headSet: TileType[] = []; + const countMap = new Map(); for (const tile of handTiles) { const count = (countMap.get(tile) ?? 0) + 1; countMap.set(tile, count); @@ -592,7 +648,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] { tempHandTiles.splice(tempHandTiles.indexOf(head), 1); tempHandTiles.splice(tempHandTiles.indexOf(head), 1); - const kotsuTileSet: Tile[] = []; // インデックスアクセスしたいため配列だが実態はSet + const kotsuTileSet: TileType[] = []; // インデックスアクセスしたいため配列だが実態はSet for (const [t, c] of countMap.entries()) { if (t === head) continue; // 同じ牌種は4枚しかないので、頭と同じ牌種は刻子になりえない if (c >= 3) { @@ -600,7 +656,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] { } } - let kotsuPatterns: Tile[][]; + let kotsuPatterns: TileType[][]; if (kotsuTileSet.length === 0) { kotsuPatterns = [ [], @@ -664,7 +720,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] { if (shuntsus.length * 3 === tempHandTilesWithoutKotsu.length) { // アガリ形 horaSets.push({ head, - mentsus: [...kotsuPattern.map(t => [t, t, t] as [Tile, Tile, Tile]), ...shuntsus], + mentsus: [...kotsuPattern.map(t => [t, t, t] as [TileType, TileType, TileType]), ...shuntsus], }); } } @@ -677,7 +733,7 @@ export function getHoraSets(handTiles: Tile[]): HoraSet[] { * アガリ牌リストを取得 * @param handTiles ポン、チー、カンした牌を含まない手牌 */ -export function getHoraTiles(handTiles: Tile[]): Tile[] { +export function getHoraTiles(handTiles: TileType[]): TileType[] { return TILE_TYPES.filter(tile => { const tempHandTiles = [...handTiles, tile]; const horaSets = getHoraSets(tempHandTiles); @@ -689,7 +745,7 @@ export function getHoraTiles(handTiles: Tile[]): Tile[] { // TODO: 七対子判定関数 -export function getTilesForRiichi(handTiles: Tile[]): Tile[] { +export function getTilesForRiichi(handTiles: TileType[]): TileType[] { return handTiles.filter(tile => { const tempHandTiles = [...handTiles]; tempHandTiles.splice(tempHandTiles.indexOf(tile), 1); @@ -698,12 +754,12 @@ export function getTilesForRiichi(handTiles: Tile[]): Tile[] { }); } -export function nextTileForDora(tile: Tile): Tile { +export function nextTileForDora(tile: TileType): TileType { return NEXT_TILE_FOR_DORA_MAP[tile]; } -export function getAvailableCiiPatterns(handTiles: Tile[], targetTile: Tile): [Tile, Tile, Tile][] { - const patterns: [Tile, Tile, Tile][] = []; +export function getAvailableCiiPatterns(handTiles: TileType[], targetTile: TileType): [TileType, TileType, TileType][] { + const patterns: [TileType, TileType, TileType][] = []; const prev1 = PREV_TILE_FOR_SHUNTSU[targetTile]; const prev2 = prev1 != null ? PREV_TILE_FOR_SHUNTSU[prev1] : null; const next1 = NEXT_TILE_FOR_SHUNTSU[targetTile]; diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 4bd436a2e3..b375296fe3 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -4,10 +4,20 @@ */ import CRC32 from 'crc-32'; -import { Tile, House, Huro, TILE_TYPES, YAKU_DEFINITIONS } from './common.js'; +import { TileType, House, Huro, TILE_TYPES, YAKU_DEFINITIONS, TileId } from './common.js'; import * as Common from './common.js'; import { PlayerState } from './engine.player.js'; +//#region syntax suger +function $(tileId: TileId): Common.TileInstance { + return Common.findTileByIdOrFail(tileId); +} + +function $type(tileId: TileId): TileType { + return Common.findTileByIdOrFail(tileId).t; +} +//#endregion + export type MasterState = { user1House: House; user2House: House; @@ -17,25 +27,25 @@ export type MasterState = { round: 'e' | 's' | 'w' | 'n'; kyoku: number; - tiles: Tile[]; - kingTiles: Tile[]; + tiles: TileId[]; + kingTiles: TileId[]; activatedDorasCount: number; /** * 副露した牌を含まない手牌 */ handTiles: { - e: Tile[]; - s: Tile[]; - w: Tile[]; - n: Tile[]; + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; }; hoTiles: { - e: Tile[]; - s: Tile[]; - w: Tile[]; - n: Tile[]; + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; }; huros: { e: Huro[]; @@ -114,14 +124,33 @@ export class MasterGameEngine { this.state = state; } - public get doras(): Tile[] { - return this.state.kingTiles.slice(0, this.state.activatedDorasCount).map(t => Common.nextTileForDora(t)); + public get doras(): TileType[] { + return this.state.kingTiles.slice(0, this.state.activatedDorasCount) + .map(id => Common.nextTileForDora($type(id))); + } + + public get handTileTypes(): Record { + return { + e: this.state.handTiles.e.map(id => $type(id)), + s: this.state.handTiles.s.map(id => $type(id)), + w: this.state.handTiles.w.map(id => $type(id)), + n: this.state.handTiles.n.map(id => $type(id)), + }; + } + + public get hoTileTypes(): Record { + return { + e: this.state.hoTiles.e.map(id => $type(id)), + s: this.state.hoTiles.s.map(id => $type(id)), + w: this.state.hoTiles.w.map(id => $type(id)), + n: this.state.hoTiles.n.map(id => $type(id)), + }; } public static createInitialState(): MasterState { - const ikasama: Tile[] = ['haku', 'hatsu', 'm3', 'p5', 'p6', 'p7', 's2', 's3', 's4', 'chun', 'chun', 'chun', 'n', 'n']; + const ikasama: TileId[] = [125, 129, 9, 54, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122]; - const tiles = [...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice()]; + const tiles = [...Common.TILE_ID_MAP.keys()]; tiles.sort(() => Math.random() - 0.5); for (const tile of ikasama) { @@ -185,7 +214,7 @@ export class MasterGameEngine { }; } - private tsumo(): Tile { + private tsumo(): TileId { const tile = this.state.tiles.pop(); if (tile == null) throw new Error('No tiles left'); if (this.state.turn == null) throw new Error('Not your turn'); @@ -193,12 +222,12 @@ export class MasterGameEngine { return tile; } - private canRon(house: House, tile: Tile): boolean { + private canRon(house: House, tile: TileId): boolean { // フリテン // TODO: ポンされるなどして自分の河にない場合の考慮 - if (this.state.hoTiles[house].includes(tile)) return false; + if (this.hoTileTypes[house].includes($type(tile))) return false; - const horaSets = Common.getHoraSets(this.state.handTiles[house].concat(tile)); + const horaSets = Common.getHoraSets(this.handTileTypes[house].concat($type(tile))); if (horaSets.length === 0) return false; // 完成形じゃない // TODO @@ -208,15 +237,15 @@ export class MasterGameEngine { return true; } - private canPon(house: House, tile: Tile): boolean { - return this.state.handTiles[house].filter(t => t === tile).length === 2; + private canPon(house: House, tile: TileId): boolean { + return this.handTileTypes[house].filter(t => t === $type(tile)).length === 2; } - private canCii(caller: House, callee: House, tile: Tile): boolean { + private canCii(caller: House, callee: House, tile: TileId): boolean { if (callee !== Common.prevHouse(caller)) return false; - const hand = this.state.handTiles[caller]; + const hand = this.handTileTypes[caller]; return Common.SHUNTU_PATTERNS.some(pattern => - pattern.includes(tile) && + pattern.includes($type(tile)) && pattern.filter(t => hand.includes(t)).length >= 2); } @@ -245,13 +274,14 @@ export class MasterGameEngine { for (const house of callers) { const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ house: house, - handTiles: this.state.handTiles[house], + handTiles: this.handTileTypes[house], huros: this.state.huros[house], tsumoTile: null, - ronTile: this.state.hoTiles[callee].at(-1)!, + ronTile: this.hoTileTypes[callee].at(-1)!, riichi: this.state.riichis[house], })); - const doraCount = Common.calcOwnedDoraCount(this.state.handTiles[house], this.state.huros[house], this.doras); + const doraCount = Common.calcOwnedDoraCount(this.handTileTypes[house], this.state.huros[house], this.doras); + // TODO: 赤ドラ const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; const point = Common.fanToPoint(fans, house === 'e'); this.state.points[callee] -= point; @@ -263,12 +293,12 @@ export class MasterGameEngine { this.endKyoku(); } - public commit_dahai(house: House, tile: Tile, riichi = false) { + public commit_dahai(house: House, tile: TileId, riichi = false) { if (this.state.turn !== house) throw new Error('Not your turn'); if (riichi) { - const tempHandTiles = [...this.state.handTiles[house]]; - tempHandTiles.splice(tempHandTiles.indexOf(tile), 1); + const tempHandTiles = [...this.handTileTypes[house]]; + tempHandTiles.splice(tempHandTiles.indexOf($type(tile)), 1); if (Common.getHoraTiles(tempHandTiles).length === 0) throw new Error('Not tenpai'); if (this.state.points[house] < 1000) throw new Error('Not enough points'); } @@ -371,11 +401,11 @@ export class MasterGameEngine { }; } - public commit_kakan(house: House, tile: Tile) { - const pon = this.state.huros[house].find(h => h.type === 'pon' && h.tile === tile); + public commit_kakan(house: House, tile: TileId) { + const pon = this.state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tile)); if (pon == null) throw new Error('No such pon'); this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(tile), 1); - this.state.huros[house].push({ type: 'minkan', tile, from: pon.from }); + this.state.huros[house].push({ type: 'minkan', tiles: [...pon.tiles, tile], from: pon.from }); this.state.activatedDorasCount++; @@ -386,12 +416,20 @@ export class MasterGameEngine { }; } - public commit_ankan(house: House, tile: Tile) { - this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(tile), 1); - this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(tile), 1); - this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(tile), 1); - this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(tile), 1); - this.state.huros[house].push({ type: 'ankan', tile }); + public commit_ankan(house: House, tile: TileId) { + const t1 = this.state.handTiles[house].filter(t => $type(t) === $type(tile)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = this.state.handTiles[house].filter(t => $type(t) === $type(tile)).at(1); + if (t2 == null) throw new Error('No such tile'); + const t3 = this.state.handTiles[house].filter(t => $type(t) === $type(tile)).at(2); + if (t3 == null) throw new Error('No such tile'); + const t4 = this.state.handTiles[house].filter(t => $type(t) === $type(tile)).at(3); + if (t4 == null) throw new Error('No such tile'); + this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(t1), 1); + this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(t2), 1); + this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(t3), 1); + this.state.handTiles[house].splice(this.state.handTiles[house].indexOf(t4), 1); + this.state.huros[house].push({ type: 'ankan', tiles: [t1, t2, t3, t4] }); this.state.activatedDorasCount++; @@ -411,13 +449,14 @@ export class MasterGameEngine { const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ house: house, - handTiles: this.state.handTiles[house], + handTiles: this.handTileTypes[house], huros: this.state.huros[house], - tsumoTile: this.state.handTiles[house].at(-1)!, + tsumoTile: this.handTileTypes[house].at(-1)!, ronTile: null, riichi: this.state.riichis[house], })); - const doraCount = Common.calcOwnedDoraCount(this.state.handTiles[house], this.state.huros[house], this.doras); + const doraCount = Common.calcOwnedDoraCount(this.handTileTypes[house], this.state.huros[house], this.doras); + // TODO: 赤ドラ const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); this.state.points.e += pointDeltas.e; @@ -463,8 +502,19 @@ export class MasterGameEngine { } else if (kan != null && answers.kan) { // 大明槓 + const t1 = this.state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = this.state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(1); + if (t2 == null) throw new Error('No such tile'); + const t3 = this.state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(2); + if (t3 == null) throw new Error('No such tile'); const tile = this.state.hoTiles[kan.callee].pop()!; - this.state.huros[kan.caller].push({ type: 'minkan', tile, from: kan.callee }); + + this.state.handTiles[kan.caller].splice(this.state.handTiles[kan.caller].indexOf(t1), 1); + this.state.handTiles[kan.caller].splice(this.state.handTiles[kan.caller].indexOf(t2), 1); + this.state.handTiles[kan.caller].splice(this.state.handTiles[kan.caller].indexOf(t3), 1); + + this.state.huros[kan.caller].push({ type: 'minkan', tiles: [tile, t1, t2, t3], from: kan.callee }); this.state.activatedDorasCount++; @@ -481,10 +531,16 @@ export class MasterGameEngine { turn: this.state.turn, }; } else if (pon != null && answers.pon) { + const t1 = this.state.handTiles[pon.caller].filter(t => $type(t) === $type(tile)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = this.state.handTiles[pon.caller].filter(t => $type(t) === $type(tile)).at(1); + if (t2 == null) throw new Error('No such tile'); const tile = this.state.hoTiles[pon.callee].pop()!; - this.state.handTiles[pon.caller].splice(this.state.handTiles[pon.caller].indexOf(tile), 1); - this.state.handTiles[pon.caller].splice(this.state.handTiles[pon.caller].indexOf(tile), 1); - this.state.huros[pon.caller].push({ type: 'pon', tile, from: pon.callee }); + + this.state.handTiles[pon.caller].splice(this.state.handTiles[pon.caller].indexOf(t1), 1); + this.state.handTiles[pon.caller].splice(this.state.handTiles[pon.caller].indexOf(t2), 1); + + this.state.huros[pon.caller].push({ type: 'pon', tiles: [tile, t1, t2], from: pon.callee }); this.state.turn = pon.caller; @@ -497,37 +553,49 @@ export class MasterGameEngine { }; } else if (cii != null && answers.cii) { const tile = this.state.hoTiles[cii.callee].pop()!; - let tiles: [Tile, Tile, Tile]; + let tiles: [TileId, TileId, TileId]; switch (answers.cii) { case 'x__': { - const a = Common.NEXT_TILE_FOR_SHUNTSU[tile]; + const a = Common.NEXT_TILE_FOR_SHUNTSU[$type(tile)]; if (a == null) throw new Error(); const b = Common.NEXT_TILE_FOR_SHUNTSU[a]; if (b == null) throw new Error(); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(a), 1); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(b), 1); - tiles = [tile, a, b]; + const aTile = this.state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = this.state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(aTile), 1); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [tile, aTile, bTile]; break; } case '_x_': { - const a = Common.PREV_TILE_FOR_SHUNTSU[tile]; + const a = Common.PREV_TILE_FOR_SHUNTSU[$type(tile)]; if (a == null) throw new Error(); - const b = Common.NEXT_TILE_FOR_SHUNTSU[tile]; + const b = Common.NEXT_TILE_FOR_SHUNTSU[$type(tile)]; if (b == null) throw new Error(); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(a), 1); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(b), 1); - tiles = [a, tile, b]; + const aTile = this.state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = this.state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(aTile), 1); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [aTile, tile, bTile]; break; } case '__x': { - const a = Common.PREV_TILE_FOR_SHUNTSU[tile]; + const a = Common.PREV_TILE_FOR_SHUNTSU[$type(tile)]; if (a == null) throw new Error(); const b = Common.PREV_TILE_FOR_SHUNTSU[a]; if (b == null) throw new Error(); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(a), 1); - this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(b), 1); - tiles = [b, a, tile]; + const aTile = this.state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = this.state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(aTile), 1); + this.state.handTiles[cii.caller].splice(this.state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [bTile, aTile, tile]; break; } } diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index 40b45b3599..f4a7e8d119 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -4,9 +4,19 @@ */ import CRC32 from 'crc-32'; -import { Tile, House, Huro, TILE_TYPES, YAKU_DEFINITIONS } from './common.js'; +import { TileType, House, Huro, TileId, YAKU_DEFINITIONS } from './common.js'; import * as Common from './common.js'; +//#region syntax suger +function $(tileId: TileId): Common.TileInstance { + return Common.findTileByIdOrFail(tileId); +} + +function $type(tileId: TileId): TileType { + return Common.findTileByIdOrFail(tileId).t; +} +//#endregion + export type PlayerState = { user1House: House; user2House: House; @@ -17,23 +27,23 @@ export type PlayerState = { kyoku: number; tilesCount: number; - doraIndicateTiles: Tile[]; + doraIndicateTiles: TileId[]; /** * 副露した牌を含まない手牌 */ handTiles: { - e: Tile[] | null[]; - s: Tile[] | null[]; - w: Tile[] | null[]; - n: Tile[] | null[]; + e: TileId[] | null[]; + s: TileId[] | null[]; + w: TileId[] | null[]; + n: TileId[] | null[]; }; hoTiles: { - e: Tile[]; - s: Tile[]; - w: Tile[]; - n: Tile[]; + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; }; huros: { e: Huro[]; @@ -53,7 +63,7 @@ export type PlayerState = { w: number; n: number; }; - latestDahaiedTile: Tile | null; + latestDahaiedTile: TileId | null; turn: House | null; canPon: { callee: House } | null; canCii: { callee: House } | null; @@ -90,19 +100,23 @@ export class PlayerGameEngine { } } - public get myHandTiles(): Tile[] { - return this.state.handTiles[this.myHouse] as Tile[]; + public get myHandTiles(): TileId[] { + return this.state.handTiles[this.myHouse] as TileId[]; + } + + public get myHandTileTypes(): TileType[] { + return this.myHandTiles.map(t => $type(t)); } public get isMeRiichi(): boolean { return this.state.riichis[this.myHouse]; } - public get doras(): Tile[] { - return this.state.doraIndicateTiles.map(t => Common.nextTileForDora(t)); + public get doras(): TileType[] { + return this.state.doraIndicateTiles.map(t => Common.nextTileForDora($type(t))); } - public commit_tsumo(house: House, tile: Tile) { + public commit_tsumo(house: House, tile: TileId) { console.log('commit_tsumo', this.state.turn, house, tile); this.state.tilesCount--; this.state.turn = house; @@ -113,7 +127,7 @@ export class PlayerGameEngine { } } - public commit_dahai(house: House, tile: Tile, riichi = false) { + public commit_dahai(house: House, tile: TileId, riichi = false) { console.log('commit_dahai', this.state.turn, house, tile, riichi); if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); @@ -133,13 +147,13 @@ export class PlayerGameEngine { if (house === this.myHouse) { } else { - const canRon = Common.getHoraSets(this.myHandTiles.concat(tile)).length > 0; + const canRon = Common.getHoraSets(this.myHandTiles.concat(tile).map(id => $type(id))).length > 0; const canPon = this.myHandTiles.filter(t => t === tile).length === 2; const canKan = this.myHandTiles.filter(t => t === tile).length === 3; const canCii = house === Common.prevHouse(this.myHouse) && Common.SHUNTU_PATTERNS.some(pattern => - pattern.includes(tile) && - pattern.filter(t => this.myHandTiles.includes(t)).length >= 2); + pattern.includes($type(tile)) && + pattern.filter(t => this.myHandTileTypes.includes(t)).length >= 2); if (canRon) this.state.canRon = { callee: house }; if (canPon) this.state.canPon = { callee: house }; @@ -148,23 +162,24 @@ export class PlayerGameEngine { } } - public commit_kakan(house: House, tile: Tile) { + public commit_kakan(house: House, tile: TileId) { console.log('commit_kakan', this.state.turn, house, tile); if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); } - public commit_tsumoHora(house: House, handTiles: Tile[], tsumoTile: Tile): KyokuResult { + public commit_tsumoHora(house: House, handTiles: TileId[], tsumoTile: TileId): KyokuResult { console.log('commit_tsumoHora', this.state.turn, house); const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ house: house, - handTiles: handTiles, + handTiles: handTiles.map(id => $type(id)), huros: this.state.huros[house], - tsumoTile: tsumoTile, + tsumoTile: $type(tsumoTile), ronTile: null, riichi: this.state.riichis[house], })); - const doraCount = Common.calcOwnedDoraCount(handTiles, this.state.huros[house], this.doras); + const doraCount = Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras); + // TODO: 赤ドラ const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); this.state.points.e += pointDeltas.e; @@ -188,10 +203,10 @@ export class PlayerGameEngine { * @param callee 牌を捨てた人 */ public commit_ronHora(callers: House[], callee: House, handTiles: { - e: Tile[]; - s: Tile[]; - w: Tile[]; - n: Tile[]; + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; }): Record { console.log('commit_ronHora', this.state.turn, callers, callee); @@ -207,13 +222,14 @@ export class PlayerGameEngine { for (const house of callers) { const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ house: house, - handTiles: handTiles[house], + handTiles: handTiles[house].map(id => $type(id)), huros: this.state.huros[house], tsumoTile: null, - ronTile: this.state.hoTiles[callee].at(-1)!, + ronTile: $type(this.state.hoTiles[callee].at(-1)!), riichi: this.state.riichis[house], })); - const doraCount = Common.calcOwnedDoraCount(handTiles[house], this.state.huros[house], this.doras); + const doraCount = Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras); + // TODO: 赤ドラ const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; const point = Common.fanToPoint(fans, house === 'e'); this.state.points[callee] -= point; @@ -237,19 +253,19 @@ export class PlayerGameEngine { * @param caller ポンした人 * @param callee 牌を捨てた人 */ - public commit_pon(caller: House, callee: House) { + public commit_pon(caller: House, callee: House, tiles: TileId[]) { this.state.canPon = null; - const lastTile = this.state.hoTiles[callee].pop(); - if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); + this.state.hoTiles[callee].pop(); if (caller === this.myHouse) { - this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); - this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); + if (this.myHandTiles.includes(tiles[0])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[0]), 1); + if (this.myHandTiles.includes(tiles[1])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[1]), 1); + if (this.myHandTiles.includes(tiles[2])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[2]), 1); } else { this.state.handTiles[caller].unshift(); this.state.handTiles[caller].unshift(); } - this.state.huros[caller].push({ type: 'pon', tile: lastTile, from: callee }); + this.state.huros[caller].push({ type: 'pon', tiles: tiles, from: callee }); this.state.turn = caller; } @@ -271,25 +287,27 @@ export class PlayerGameEngine { if (this.state.riichis[this.myHouse]) return false; if (this.state.points[this.myHouse] < 1000) return false; if (!this.isMenzen) return false; - if (Common.getTilesForRiichi(this.myHandTiles).length === 0) return false; + if (Common.getTilesForRiichi(this.myHandTileTypes).length === 0) return false; return true; } public canAnkan(): boolean { if (this.state.turn !== this.myHouse) return false; - return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => tt === t).length >= 4).length > 0; + return this.myHandTiles + .filter(t => this.myHandTiles + .filter(tt => $type(tt) === $type(t)).length >= 4).length > 0; } public canKakan(): boolean { if (this.state.turn !== this.myHouse) return false; - return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTiles.includes(h.tile)).length > 0; + return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTileTypes.includes($type(h.tiles[0]))).length > 0; } - public getAnkanableTiles(): Tile[] { - return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => tt === t).length >= 4); + public getAnkanableTiles(): TileId[] { + return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => $type(tt) === $type(t)).length >= 4); } - public getKakanableTiles(): Tile[] { - return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTiles.includes(h.tile)).map(h => h.tile); + public getKakanableTiles(): TileId[] { + return this.myHandTiles.filter(t => this.state.huros[this.myHouse].some(h => h.type === 'pon' && $type(t) === $type(h.tiles[0]))); } } diff --git a/packages/misskey-mahjong/src/serializer.ts b/packages/misskey-mahjong/src/serializer.ts index 12b249880d..36279f1c87 100644 --- a/packages/misskey-mahjong/src/serializer.ts +++ b/packages/misskey-mahjong/src/serializer.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Tile } from './common.js'; +import { TileType } from './common.js'; export type Log = { time: number; @@ -14,7 +14,7 @@ export type Log = { export type SerializedLog = number[]; -export const TILE_MAP: Record = { +export const TILE_MAP: Record = { 'm1': 1, 'm2': 2, 'm3': 3, @@ -51,12 +51,12 @@ export const TILE_MAP: Record = { 'chun': 34, }; -export function serializeTile(tile: Tile): number { +export function serializeTile(tile: TileType): number { return TILE_MAP[tile]; } -export function deserializeTile(tile: number): Tile { - return Object.keys(TILE_MAP).find(key => TILE_MAP[key as Tile] === tile) as Tile; +export function deserializeTile(tile: number): TileType { + return Object.keys(TILE_MAP).find(key => TILE_MAP[key as TileType] === tile) as TileType; } export function serializeLogs(logs: Log[]) {