1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-24 20:54:13 +02:00

feature(mahjong): 搶槓/ドラ以外の麻雀の役を実装 (#14346)

* ビルドによる自動的なソース更新

* 麻雀関連のキーバリューペアを追加

* 役の定義をまとめてエクスポート

* タイポ修正

* Revert "麻雀関連のキーバリューペアを追加"

This reverts commit c349cdf70c.

* misskey-jsのビルドによる自動更新

* 型エラーに対処

* riichiがtrueの場合に門前であるかを確認

* EnvForCalcYakuのhouseプロパティを廃止

* 風牌の役の共通部分をクラスで定義

* タイポ修正

* 役牌をクラスで共通化

* 一盃口と二盃口のテストを通す

* 一盃口・二盃口判定関数の調整

* 一気通貫の判定にチーによる順子も考慮する

* 混全帯幺九の実装

* 純全帯幺九の実装

* 七対子の実装とテストの修正

* tsumoTileまたはronTileを必須に

* 待ちを確認して平和の判定を可能に

* 三暗刻と四暗刻、四暗刻単騎の実装

* 四暗刻であるために通常の役を判定できない牌姿のテストを修正

* 混老頭と清老頭を実装

* 三槓子と四槓子を実装

* 平和の実装とテストを修正

* 小三元のテストを修正

* 国士無双に対子の確認を追加

* 国士無双十三面待ちを実装し、テストを修正

* 一部の役の七対子形を認め、テストを追加

* 手牌の数を確認

* 役の定義をカプセル化して型エラーの対処

* ツモ・ロンの判定を修正

* calcYakusの引数のhandTilesを修正

* calcYakusに渡す風をseatWindに修正

* 嶺上開花の実装

* 海底摸月の実装

* FourMentsuOneJyantouWithWait型の作成

* 河底撈魚の実装

* ダブル立直の実装

* 天和・地和の実装

* エンジンのテストを作成

* エンジンによる地和のテストを追加

* 嶺上開花のテスト

* ライセンスの記述を追加

* ダブル立直一発ツモのテスト

* ダブル立直海底ツモのテスト

* ダブル立直河底のテスト

* 役満も処理できるように

* 点数のテスト

* 打牌時にrinshanFlags[house]をfalseに

* 七対子形の字一色を認める

* typo
This commit is contained in:
Take-John
2024-08-15 12:29:31 +09:00
committed by GitHub
parent f32b11ba12
commit bf818a6656
8 changed files with 1417 additions and 492 deletions

View File

@@ -7,7 +7,9 @@ import CRC32 from 'crc-32';
import { TileType, House, Huro, TileId } from './common.js';
import * as Common from './common.js';
import { PlayerState } from './engine.player.js';
import { YAKU_DEFINITIONS } from "./common.yaku.js";
import { calcYakusWithDetail, convertHuroForCalcYaku, YakuData, YakuSet } from './common.yaku.js';
export const INITIAL_POINT = 25000;
//#region syntax suger
function $(tid: TileId): Common.TileInstance {
@@ -134,13 +136,33 @@ class StateManager {
pattern.filter(t => hand.includes(t)).length >= 2);
}
public tsumo(): TileId {
const tile = this.$state.tiles.pop();
private withTsumoTile(tile: TileId | undefined, isRinshan: boolean): TileId {
if (tile == null) throw new Error('No tiles left');
if (this.$state.turn == null) throw new Error('Not your turn');
this.$state.handTiles[this.$state.turn].push(tile);
this.$state.rinshanFlags[this.$state.turn] = isRinshan;
return tile;
}
public tsumo(): TileId {
return this.withTsumoTile(this.$state.tiles.pop(), false);
}
public rinshanTsumo(): TileId {
return this.withTsumoTile(this.$state.tiles.shift(), true);
}
public clearFirstTurnAndIppatsus(): void {
this.$state.firstTurnFlags.e = false;
this.$state.firstTurnFlags.s = false;
this.$state.firstTurnFlags.w = false;
this.$state.firstTurnFlags.n = false;
this.$state.ippatsus.e = false;
this.$state.ippatsus.s = false;
this.$state.ippatsus.w = false;
this.$state.ippatsus.n = false;
}
}
export type MasterState = {
@@ -178,18 +200,36 @@ export type MasterState = {
w: Huro[];
n: Huro[];
};
firstTurnFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
riichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
doubleRiichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
ippatsus: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
rinshanFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
}
points: {
e: number;
s: number;
@@ -304,7 +344,7 @@ export class MasterGameEngine {
return this.stateManager.turn;
}
public static createInitialState(): MasterState {
public static createInitialState(preset: Partial<MasterState> = {}): MasterState {
const ikasama: TileId[] = [125, 129, 9, 56, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122];
const tiles = shuffle([...Common.TILE_ID_MAP.keys()]);
@@ -350,23 +390,41 @@ export class MasterGameEngine {
w: [],
n: [],
},
firstTurnFlags: {
e: true,
s: true,
w: true,
n: true,
},
riichis: {
e: false,
s: false,
w: false,
n: false,
},
doubleRiichis: {
e: false,
s: false,
w: false,
n: false,
},
ippatsus: {
e: false,
s: false,
w: false,
n: false,
},
rinshanFlags: {
e: false,
s: false,
w: false,
n: false,
},
points: {
e: 25000,
s: 25000,
w: 25000,
n: 25000,
e: INITIAL_POINT,
s: INITIAL_POINT,
w: INITIAL_POINT,
n: INITIAL_POINT,
},
turn: 'e',
nextTurnAfterAsking: null,
@@ -376,6 +434,7 @@ export class MasterGameEngine {
cii: null,
kan: null,
},
...preset,
};
}
@@ -433,8 +492,14 @@ export class MasterGameEngine {
if (riichi) {
tx.$state.riichis[house] = true;
tx.$state.ippatsus[house] = true;
if (tx.$state.firstTurnFlags[house]) {
tx.$state.doubleRiichis[house] = true;
}
}
tx.$state.firstTurnFlags[house] = false;
tx.$state.rinshanFlags[house] = false;
const canRonHouses: House[] = [];
switch (house) {
case 'e':
@@ -548,20 +613,17 @@ export class MasterGameEngine {
public commit_kakan(house: House, tid: TileId) {
const tx = this.startTransaction();
const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid));
const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid)) as Huro & {type: 'pon'};
if (pon == null) throw new Error('No such pon');
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(tid), 1);
const tiles = [tid, ...pon.tiles];
const tiles = [tid, ...pon.tiles] as const;
tx.$state.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$commit();
@@ -587,17 +649,14 @@ export class MasterGameEngine {
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t2), 1);
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t3), 1);
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t4), 1);
const tiles = [t1, t2, t3, t4];
const tiles = [t1, t2, t3, t4] as const;
tx.$state.huros[house].push({ type: 'ankan', tiles: tiles });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$commit();
@@ -611,36 +670,40 @@ export class MasterGameEngine {
* ツモ和了
* @param house
*/
public commit_tsumoHora(house: House) {
public commit_tsumoHora(house: House, doLog = true) {
const tx = this.startTransaction();
if (tx.$state.turn !== house) throw new Error('Not your turn');
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: tx.handTileTypes[house],
huros: tx.$state.huros[house],
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: tx.handTileTypes[house].at(-1)!,
ronTile: null,
firstTurn: tx.$state.firstTurnFlags[house],
riichi: tx.$state.riichis[house],
doubleRiichi: tx.$state.doubleRiichis[house],
ippatsu: tx.$state.ippatsus[house],
}));
rinshan: tx.$state.rinshanFlags[house],
haitei: tx.$state.tiles.length == 0,
});
const doraCount =
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus);
tx.$state.points.e += pointDeltas.e;
tx.$state.points.s += pointDeltas.s;
tx.$state.points.w += pointDeltas.w;
tx.$state.points.n += pointDeltas.n;
console.log('yakus', house, yakus);
if (doLog) console.log('yakus', house, yakus);
tx.$commit();
return {
handTiles: tx.$state.handTiles[house],
tsumoTile: tx.$state.handTiles[house].at(-1)!,
yakus,
};
}
@@ -649,7 +712,7 @@ export class MasterGameEngine {
cii: false | 'x__' | '_x_' | '__x';
kan: boolean;
ron: House[];
}) {
}, doLog = true) {
const tx = this.startTransaction();
if (tx.$state.askings.pon == null && tx.$state.askings.cii == null && tx.$state.askings.kan == null && tx.$state.askings.ron == null) throw new Error();
@@ -668,26 +731,31 @@ export class MasterGameEngine {
const callers = answers.ron;
const callee = ron.callee;
for (const house of callers) {
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
handTiles: tx.handTileTypes[house],
huros: tx.$state.huros[house],
const yakus: { [K in House]?: YakuSet } = Object.fromEntries(callers.map(house => {
const ronTile = tx.hoTileTypes[callee].at(-1)!;
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: tx.handTileTypes[house].concat([ronTile]),
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: null,
ronTile: tx.hoTileTypes[callee].at(-1)!,
ronTile,
firstTurn: tx.$state.firstTurnFlags[house],
riichi: tx.$state.riichis[house],
doubleRiichi: tx.$state.doubleRiichis[house],
ippatsu: tx.$state.ippatsus[house],
}));
hotei: tx.$state.tiles.length == 0,
});
const doraCount =
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const point = Common.fanToPoint(fans, house === 'e');
const point = Common.calcPoint(yakus, house === 'e');
tx.$state.points[callee] -= point;
tx.$state.points[house] += point;
console.log('fans point', fans, point);
console.log('yakus', house, yakus);
}
if (doLog) {
console.log('yakus', house, yakus);
}
return [house, yakus] as const;
}));
tx.$commit();
@@ -696,6 +764,7 @@ export class MasterGameEngine {
callers: ron.callers,
callee: ron.callee,
turn: null,
yakus,
};
} else if (kan != null && answers.kan) {
// 大明槓
@@ -712,17 +781,14 @@ export class MasterGameEngine {
tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t2), 1);
tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t3), 1);
const tiles = [tile, t1, t2, t3];
const tiles = [tile, t1, t2, t3] as const;
tx.$state.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$state.turn = kan.caller;
@@ -746,13 +812,10 @@ export class MasterGameEngine {
tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t1), 1);
tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t2), 1);
const tiles = [tile, t1, t2];
const tiles = [tile, t1, t2] as const;
tx.$state.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.turn = pon.caller;
@@ -816,10 +879,7 @@ export class MasterGameEngine {
tx.$state.huros[cii.caller].push({ type: 'cii', tiles: tiles, from: cii.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.turn = cii.caller;
@@ -891,18 +951,36 @@ export class MasterGameEngine {
w: this.$state.huros.w,
n: this.$state.huros.n,
},
firstTurnFlags: {
e: this.$state.firstTurnFlags.e,
s: this.$state.firstTurnFlags.s,
w: this.$state.firstTurnFlags.w,
n: this.$state.firstTurnFlags.n,
},
riichis: {
e: this.$state.riichis.e,
s: this.$state.riichis.s,
w: this.$state.riichis.w,
n: this.$state.riichis.n,
},
doubleRiichis: {
e: this.$state.doubleRiichis.e,
s: this.$state.doubleRiichis.s,
w: this.$state.doubleRiichis.w,
n: this.$state.doubleRiichis.n,
},
ippatsus: {
e: this.$state.ippatsus.e,
s: this.$state.ippatsus.s,
w: this.$state.ippatsus.w,
n: this.$state.ippatsus.n,
},
rinshanFlags: {
e: this.$state.rinshanFlags.e,
s: this.$state.rinshanFlags.s,
w: this.$state.rinshanFlags.w,
n: this.$state.rinshanFlags.n,
},
points: {
e: this.$state.points.e,
s: this.$state.points.s,
@@ -911,6 +989,10 @@ export class MasterGameEngine {
},
latestDahaiedTile: null,
turn: this.$state.turn,
canPon: null,
canCii: null,
canKan: null,
canRon: null,
};
}