This commit is contained in:
syuilo 2024-02-03 18:02:00 +09:00
parent 586a458c7a
commit 00bf57d243
5 changed files with 203 additions and 72 deletions

View File

@ -26,7 +26,7 @@ import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 7; // 7sec
const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 10; // 10sec
const TURN_TIMEOUT_MS = 1000 * 30; // 30sec
const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec
@ -58,9 +58,9 @@ type Room = {
gameState?: Mahjong.MasterState;
};
type CallAndRonAnswers = {
type CallingAnswers = {
pon: null | boolean;
cii: null | boolean;
cii: null | false | [Mahjong.Tile, Mahjong.Tile, Mahjong.Tile];
kan: null | boolean;
ron: {
e: null | boolean;
@ -305,8 +305,8 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
private async answer(room: Room, engine: Mahjong.MasterGameEngine, answers: CallAndRonAnswers) {
const res = engine.commit_resolveCallAndRonInterruption({
private async answer(room: Room, engine: Mahjong.MasterGameEngine, answers: CallingAnswers) {
const res = engine.commit_resolveCallingInterruption({
pon: answers.pon ?? false,
cii: answers.cii ?? false,
kan: answers.kan ?? false,
@ -386,7 +386,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
if (res.asking) {
console.log('asking', res);
const answers: CallAndRonAnswers = {
const answers: CallingAnswers = {
pon: null,
cii: null,
kan: null,
@ -428,12 +428,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
}
}
this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(answers));
this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(answers));
const waitingStartedAt = Date.now();
const interval = setInterval(async () => {
const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`);
if (current == null) throw new Error('arienai (gameCallAndRonAsking)');
const currentAnswers = JSON.parse(current) as CallAndRonAnswers;
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('arienai (gameCallingAsking)');
const currentAnswers = JSON.parse(current) as CallingAnswers;
const allAnswered = !(
(res.canPonHouse != null && currentAnswers.pon == null) ||
(res.canCiiHouse != null && currentAnswers.cii == null) ||
@ -445,7 +445,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
);
if (allAnswered || (Date.now() - waitingStartedAt > CALL_AND_RON_ASKING_TIMEOUT_MS)) {
console.log(allAnswered ? 'ask all answerd' : 'ask timeout');
await this.redisClient.del(`mahjong:gameCallAndRonAsking:${room.id}`);
await this.redisClient.del(`mahjong:gameCallingAsking:${room.id}`);
clearInterval(interval);
this.answer(room, engine, currentAnswers);
return;
@ -511,7 +511,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser) {
public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser, tile: string) {
const room = await this.getRoom(roomId);
if (room == null) return;
if (room.gameState == null) return;
@ -521,7 +521,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
await this.clearTurnWaitingTimer(room.id);
const res = engine.commit_kakan(myHouse);
const res = engine.commit_kakan(myHouse, tile);
this.globalEventService.publishMahjongRoomStream(room.id, 'kakanned', { });
}
@ -551,14 +551,14 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
const engine = new Mahjong.MasterGameEngine(room.gameState);
const myHouse = getHouseOfUserId(room, engine, user.id);
// TODO: 自分にロン回答する権利がある状態かバリデーション
// TODO: 自分に回答する権利がある状態かバリデーション
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`);
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('no asking found');
const currentAnswers = JSON.parse(current) as CallAndRonAnswers;
const currentAnswers = JSON.parse(current) as CallingAnswers;
currentAnswers.ron[myHouse] = true;
await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers));
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
}
@bindThis
@ -567,14 +567,46 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
if (room == null) return;
if (room.gameState == null) return;
// TODO: 自分にポン回答する権利がある状態かバリデーション
// TODO: 自分に回答する権利がある状態かバリデーション
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`);
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('no asking found');
const currentAnswers = JSON.parse(current) as CallAndRonAnswers;
const currentAnswers = JSON.parse(current) as CallingAnswers;
currentAnswers.pon = true;
await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers));
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
}
@bindThis
public async commit_kan(roomId: MiMahjongGame['id'], user: MiUser) {
const room = await this.getRoom(roomId);
if (room == null) return;
if (room.gameState == null) return;
// TODO: 自分に回答する権利がある状態かバリデーション
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('no asking found');
const currentAnswers = JSON.parse(current) as CallingAnswers;
currentAnswers.kan = true;
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
}
@bindThis
public async commit_cii(roomId: MiMahjongGame['id'], user: MiUser, tiles: [Mahjong.Tile, Mahjong.Tile, Mahjong.Tile]) {
const room = await this.getRoom(roomId);
if (room == null) return;
if (room.gameState == null) return;
// TODO: 自分に回答する権利がある状態かバリデーション
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('no asking found');
const currentAnswers = JSON.parse(current) as CallingAnswers;
currentAnswers.cii = tiles;
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
}
@bindThis
@ -587,14 +619,14 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit {
const myHouse = getHouseOfUserId(room, engine, user.id);
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`);
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
if (current == null) throw new Error('no asking found');
const currentAnswers = JSON.parse(current) as CallAndRonAnswers;
const currentAnswers = JSON.parse(current) as CallingAnswers;
if (engine.state.ponAsking?.caller === myHouse) currentAnswers.pon = false;
if (engine.state.ciiAsking?.caller === myHouse) currentAnswers.cii = false;
if (engine.state.kanAsking?.caller === myHouse) currentAnswers.kan = false;
if (engine.state.ronAsking != null && engine.state.ronAsking.callers.includes(myHouse)) currentAnswers.ron[myHouse] = false;
await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers));
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
}
/**

View File

@ -56,6 +56,10 @@ class MahjongRoomChannel extends Channel {
case 'tsumoHora': this.tsumoHora(); break;
case 'ronHora': this.ronHora(); break;
case 'pon': this.pon(); break;
case 'cii': this.cii(body.tiles); break;
case 'kan': this.kan(); break;
case 'ankan': this.ankan(body.tile); break;
case 'kakan': this.kakan(body.tile); break;
case 'nop': this.nop(); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
}
@ -117,6 +121,34 @@ class MahjongRoomChannel extends Channel {
this.mahjongService.commit_pon(this.roomId!, this.user);
}
@bindThis
private async cii(tiles: string[]) {
if (this.user == null) return;
this.mahjongService.commit_cii(this.roomId!, this.user, tiles);
}
@bindThis
private async kan() {
if (this.user == null) return;
this.mahjongService.commit_kan(this.roomId!, this.user);
}
@bindThis
private async ankan(tile: string) {
if (this.user == null) return;
this.mahjongService.commit_ankan(this.roomId!, this.user, tile);
}
@bindThis
private async kakan(tile: string) {
if (this.user == null) return;
this.mahjongService.commit_kakan(this.roomId!, this.user, tile);
}
@bindThis
private async nop() {
if (this.user == null) return;

View File

@ -187,9 +187,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.actions" class="_buttons">
<MkButton v-if="engine.state.canRonSource != null" primary gradate @click="ron">Ron</MkButton>
<MkButton v-if="engine.state.canPonSource != null" primary @click="pon">Pon</MkButton>
<MkButton v-if="engine.state.canRonSource != null || engine.state.canPonSource != null" @click="skip">Skip</MkButton>
<MkButton v-if="engine.state.canRon != null" primary gradate @click="ron">Ron</MkButton>
<MkButton v-if="engine.state.canPon != null" primary @click="pon">Pon</MkButton>
<MkButton v-if="engine.state.canCii != null" primary @click="cii">Cii</MkButton>
<MkButton v-if="engine.state.canKan != null" primary @click="kan">Kan</MkButton>
<MkButton v-if="engine.state.canRon != null || engine.state.canPon != null || engine.state.canCii != null || engine.state.canKan != null" @click="skip">Skip</MkButton>
<MkButton v-if="isMyTurn && engine.canAnkan()" @click="ankan">Ankan</MkButton>
<MkButton v-if="isMyTurn && engine.canKakan()" @click="kakan">Kakan</MkButton>
<MkButton v-if="isMyTurn && canHora" primary gradate @click="tsumoHora">Tsumo</MkButton>
<MkButton v-if="isMyTurn && engine.canRiichi()" primary @click="riichi">Riichi</MkButton>
</div>
@ -197,7 +201,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="showKyokuResults" :class="$style.kyokuResult">
<div v-for="(res, house) in kyokuResults" :key="house">
<div v-if="res != null">
<div>{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}</div>
<div>
<div>{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}</div>
<template v-if="houseToUser(house) != null">
<MkAvatar :user="houseToUser(house)" style="width: 30px; height: 30px;"/>
</template>
<template v-else>
CPU
</template>
</div>
<div v-for="yaku in res.yakus">
<div>{{ i18n.ts._mahjong._yakus[yaku.name] }} {{ yaku.fan }}{{ i18n.ts._mahjong.fan }}</div>
</div>
@ -333,13 +345,38 @@ if (!props.room.isEnded) {
}
*/
function houseToUser(house: Mahjong.House) {
return room.value.gameState.user1House === house ? room.value.user1 : room.value.gameState.user2House === house ? room.value.user2 : room.value.gameState.user3House === house ? room.value.user3 : room.value.user4;
}
let riichiSelect = false;
let ankanSelect = false;
let kakanSelect = false;
let ciiSelect = false;
function chooseTile(tile: Mahjong.Tile, ev: MouseEvent) {
if (!isMyTurn.value) return;
iTsumoed.value = false;
if (ankanSelect) {
props.connection!.send('ankan', {
tile: tile,
});
ankanSelect = false;
selectableTiles.value = null;
return;
} else if (kakanSelect) {
props.connection!.send('kakan', {
tile: tile,
});
kakanSelect = false;
selectableTiles.value = null;
return;
} else if (ciiSelect) {
return;
}
props.connection!.send('dahai', {
tile: tile,
riichi: riichiSelect,
@ -357,11 +394,18 @@ function riichi() {
console.log(Mahjong.getTilesForRiichi(engine.value.myHandTiles));
}
function ankan() {
if (!isMyTurn.value) return;
ankanSelect = true;
selectableTiles.value = engine.value.getAnkanableTiles();
}
function kakan() {
if (!isMyTurn.value) return;
props.connection!.send('kakan', {
});
kakanSelect = true;
selectableTiles.value = engine.value.getKakanableTiles();
}
function tsumoHora() {
@ -381,6 +425,16 @@ function pon() {
});
}
function cii() {
props.connection!.send('cii', {
});
}
function kan() {
props.connection!.send('kan', {
});
}
function skip() {
engine.value.commit_nop(engine.value.myHouse);
triggerRef(engine);
@ -649,6 +703,7 @@ onUnmounted(() => {
height: 100%;
max-width: 800px;
margin: auto;
padding: 30px;
box-sizing: border-box;
background: #0009;
color: #fff;

View File

@ -310,34 +310,21 @@ export class MasterGameEngine {
let canPonHouse: House | null = null;
switch (house) {
case 'e':
canPonHouse = this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null;
break;
case 's':
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null;
break;
case 'w':
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('n', tile) ? 'n' : null;
break;
case 'n':
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : null;
break;
case 'e': canPonHouse = this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; break;
case 's': canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; break;
case 'w': canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('n', tile) ? 'n' : null; break;
case 'n': canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : null; break;
}
const canCiiHouse: House | null = null;
// TODO
//let canCii: boolean = false;
//if (house === 'e') {
// canCii = this.state.sHandTiles...
//} else if (house === 's') {
// canCii = this.state.wHandTiles...
//} else if (house === 'w') {
// canCii = this.state.nHandTiles...
//} else if (house === 'n') {
// canCii = this.state.eHandTiles...
//}
let canCiiHouse: House | null = null;
switch (house) {
case 'e': canCiiHouse = this.canCii('s', house, tile) ? 's' : this.canCii('w', house, tile) ? 'w' : this.canCii('n', house, tile) ? 'n' : null; break;
case 's': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('w', house, tile) ? 'w' : this.canCii('n', house, tile) ? 'n' : null; break;
case 'w': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('s', house, tile) ? 's' : this.canCii('n', house, tile) ? 'n' : null; break;
case 'n': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('s', house, tile) ? 's' : this.canCii('w', house, tile) ? 'w' : null; break;
}
if (canRonHouses.length > 0 || canPonHouse != null) {
if (canRonHouses.length > 0 || canPonHouse != null || canCiiHouse != null) {
if (canRonHouses.length > 0) {
this.state.ronAsking = {
callee: house,
@ -447,9 +434,9 @@ export class MasterGameEngine {
};
}
public commit_resolveCallAndRonInterruption(answers: {
public commit_resolveCallingInterruption(answers: {
pon: boolean;
cii: false | [Tile, Tile];
cii: false | [Tile, Tile, Tile];
kan: boolean;
ron: House[];
}) {
@ -484,6 +471,7 @@ export class MasterGameEngine {
const rinsyan = this.tsumo();
this.state.turn = kan.caller;
return {
type: 'kanned' as const,
caller: kan.caller,
@ -499,6 +487,7 @@ export class MasterGameEngine {
this.state.huros[pon.caller].push({ type: 'pon', tile, from: pon.callee });
this.state.turn = pon.caller;
return {
type: 'ponned' as const,
caller: pon.caller,
@ -513,6 +502,7 @@ export class MasterGameEngine {
this.state.huros[cii.caller].push({ type: 'cii', tiles: [tile, answers.cii[0], answers.cii[1]], from: cii.callee });
this.state.turn = cii.caller;
return {
type: 'ciied' as const,
caller: cii.caller,

View File

@ -55,13 +55,10 @@ export type PlayerState = {
};
latestDahaiedTile: Tile | null;
turn: House | null;
canPonSource: House | null;
canCiiSource: House | null;
canKanSource: House | null;
canRonSource: House | null;
canCiiTo: House | null;
canKanTo: House | null;
canRonTo: House | null;
canPon: { callee: House } | null;
canCii: { callee: House } | null;
canKan: { callee: House } | null; // = 大明槓
canRon: { callee: House } | null;
};
export type KyokuResult = {
@ -138,11 +135,16 @@ export class PlayerGameEngine {
} else {
const canRon = Common.getHoraSets(this.myHandTiles.concat(tile)).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);
// TODO: canCii
if (canRon) this.state.canRonSource = house;
if (canPon) this.state.canPonSource = house;
if (canRon) this.state.canRon = { callee: house };
if (canPon) this.state.canPon = { callee: house };
if (canKan) this.state.canKan = { callee: house };
if (canCii) this.state.canCii = { callee: house };
}
}
@ -193,7 +195,7 @@ export class PlayerGameEngine {
}): Record<House, KyokuResult | null> {
console.log('commit_ronHora', this.state.turn, callers, callee);
this.state.canRonSource = null;
this.state.canRon = null;
const resultMap: Record<House, KyokuResult> = {
e: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
@ -236,7 +238,7 @@ export class PlayerGameEngine {
* @param callee
*/
public commit_pon(caller: House, callee: House) {
this.state.canPonSource = null;
this.state.canPon = null;
const lastTile = this.state.hoTiles[callee].pop();
if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError();
@ -253,8 +255,10 @@ export class PlayerGameEngine {
}
public commit_nop() {
this.state.canRonSource = null;
this.state.canPonSource = null;
this.state.canRon = null;
this.state.canPon = null;
this.state.canKan = null;
this.state.canCii = null;
}
public get isMenzen(): boolean {
@ -270,4 +274,22 @@ export class PlayerGameEngine {
if (Common.getTilesForRiichi(this.myHandTiles).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;
}
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;
}
public getAnkanableTiles(): Tile[] {
return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => tt === 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);
}
}