Skip to content

Commit

Permalink
feat: support Mjai & restructure the modules
Browse files Browse the repository at this point in the history
  • Loading branch information
HomeArchbishop committed Dec 3, 2023
1 parent d1508c8 commit 70f9e41
Show file tree
Hide file tree
Showing 19 changed files with 2,585 additions and 1,945 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Majsoul-analyser 雀力展开!
# Majsoul-analyser 雀力展开!<sup>v2.x</sup>

> START AT 2023-7
>
> 23-08-18 修复了由于命令行应用造成的线程阻塞
>
> 23-12-3 发布Ma2: 重新组织业务逻辑。统一使用 [Mjai Protocol](https://mjai.app/docs/mjai-protocol)。降低代码耦合度
>
> 【这个项目还在更新,请及时关注。有想法和 Bug 欢迎 issue。】
适用于:
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
"do-install": "build-opencv build",
"install:dependence": "sh scripts/install.sh",
"build:user": "sh scripts/build-user.sh",
"start": "npx ts-node server/index.ts"
"start": "npx ts-node server/index.ts",
"dev:wip": "npx ts-node --transpile-only server/index.ts"
},
"devDependencies": {
"@koa/cors": "^4.0.0",
"@types/jsdom": "^21.1.1",
"@types/koa-cors": "^0.0.2",
"@types/koa-router": "^7.4.4",
"@types/koa__cors": "^4.0.0",
"@types/lodash.findlastindex": "^4.6.9",
"@types/request": "^2.48.8",
"@types/shelljs": "^0.8.12",
"@types/ungap__structured-clone": "^0.3.0",
Expand Down Expand Up @@ -43,6 +45,7 @@
"koa": "^2.14.2",
"koa-body": "^6.0.1",
"koa-router": "^12.0.0",
"lodash.findlastindex": "^4.6.0",
"protobufjs": "^7.2.4",
"request": "^2.88.2",
"robotjs": "^0.6.0",
Expand Down
8 changes: 4 additions & 4 deletions server/UI/roundState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ const feng = ['东', '南', '西', '北']

function roundState (round: Round): string {
let text = ''
text += `${feng[round.chang]}${round.ju + 1}${round.ben + 1}本場 場風${feng[round.changfeng]} 自風${feng[round.zifeng]}\n`
text += `寶牌指示${round.doras.join(' ')}\n`
text += `${feng[+round.bakaze.slice(0, 1) - 1]}${round.kyoku + 1}${round.honba + 1}本場 場風${feng[+round.bakaze.slice(0, 1) - 1]} 莊位${round.oya}\n`
text += `寶牌指示${round.doraMarkers.join(' ')}\n`
for (let i = 0; i < round.players.length; i++) {
const player = round.players[(round.meSeat + i) % 4]
text += ['自家', '下家', '對家', '上家'][i] + '\n'
if ((round.meSeat + i) % 4 === round.meSeat) {
text += formatTiles(player.hand as Tile[])
}
if (player.anGang.length > 0) {
text += `(${player.anGang.map(t => t.join('')).join(' ')})`
if (player.ankan.length > 0) {
text += `(${player.ankan.map(t => t.join('')).join(' ')})`
}
if (player.fulu.length > 0) {
text += ' # ' + player.fulu.map(t => t.join('')).join(' ')
Expand Down
113 changes: 79 additions & 34 deletions server/analyser/analyser-mahjong_helper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { tile2nameSimplified } from '../../utils/tile2name'
import { nextTile } from '../../utils/nextTile'
import os from 'node:os'
import logger from '../../logger'
import { OperationDahai, ParsedOperation, ParsedOperationList } from '../../types/ParsedOperation'

let binPath: string

Expand All @@ -27,34 +28,34 @@ function callMahjongHelperShell (command: string): string {
return stdout
}

class Analyser extends BaseAnalyser {
analyseDiscard (round: Round): { choice: Tile, info: string } {
const operationJudge: Record<string, (round: Round, targetTile?: Tile) => { choice: boolean, info?: string, discard?: Tile }> = {
dahai: function analyseDiscard (round: Round): { choice: true, discard: Tile, info: string } {
const meHand = round.players[round.meSeat].hand as Tile[]
const fulu = round.players[round.meSeat].fulu
const anGang = round.players[round.meSeat].anGang
const daraArgs = `-d=${formatTiles(round.doras.map(nextTile)).replace(/\s/g, '')}`
const anGang = round.players[round.meSeat].ankan
const daraArgs = `-d=${formatTiles(round.doraMarkers.map(nextTile)).replace(/\s/g, '')}`
const args = formatTiles(meHand) + '#' + fulu.map(formatTiles).join(' ') + ' ' + anGang.map(formatTiles).join(' ').toUpperCase()
const out = callMahjongHelperShell(`${binPath} ${daraArgs} ${args}`)
const choiceName = out.split('\n').find(l => l.match(//) === null && l.match(/(?<=(|)\s*?)\S*?(?=\s*?=>)/) !== null)?.match(/(?<=(|)\s*?)\S*?(?=\s*?=>)/)
if (choiceName !== null && choiceName !== undefined) {
const choice = tile2nameSimplified(choiceName[0]) as Tile
return { choice, info: `分析打出${choice}` }
const discard = tile2nameSimplified(choiceName[0]) as Tile
return { choice: true, discard, info: `分析打出${discard}` }
} else {
logger.info(`<analyser> Got unexpected output: \`${out}\`, command: \`${binPath} ${daraArgs} ${args}\``)
const choice = meHand[~~(Math.random() * meHand.length)]
return { choice, info: `随机打出${choice}` }
const discard = meHand[~~(Math.random() * meHand.length)]
return { choice: true, discard, info: `随机打出${discard}` }
}
}
},

analyseChi (round: Round, targetTile: Tile): { choice: boolean, info: string } {
chi: function analyseChi (round: Round, targetTile: Tile): { choice: boolean, info: string } {
return { choice: false, info: '不副露' }
}
},

analysePeng (round: Round, targetTile: Tile): { choice: boolean, info: string } {
pon: function analysePeng (round: Round, targetTile: Tile): { choice: boolean, info: string } {
const meHand = round.players[round.meSeat].hand as Tile[]
const fulu = round.players[round.meSeat].fulu
const anGang = round.players[round.meSeat].anGang
const daraArgs = `-d=${formatTiles(round.doras.map(nextTile)).replace(/\s/g, '')}`
const anGang = round.players[round.meSeat].ankan
const daraArgs = `-d=${formatTiles(round.doraMarkers.map(nextTile)).replace(/\s/g, '')}`
const args = formatTiles(meHand) + '#' + fulu.map(formatTiles).join(' ') + ' ' + anGang.map(formatTiles).join(' ').toUpperCase() + ' + ' + targetTile
const out = callMahjongHelperShell(`${binPath} ${daraArgs} ${args}`)
const currentLine = {
Expand Down Expand Up @@ -94,48 +95,92 @@ class Analyser extends BaseAnalyser {
}
}
return { choice: false, info: '不副露' }
}
},

analyseGang (round: Round, targetTile: Tile): { choice: boolean, info: string } {
if (targetTile === '5z' || targetTile === '6z' || targetTile === '7z' || Number(targetTile[0]) === (round.changfeng + 1) % 4 || Number(targetTile[0]) === (round.zifeng + 1) % 4) {
daiminkan: function analyseGang (round: Round, targetTile: Tile): { choice: boolean, info: string } {
if (targetTile === '5z' || targetTile === '6z' || targetTile === '7z' || targetTile === round.bakaze || Number(targetTile[0]) === (round.meSeat - round.oya) % 4 + 1) {
return { choice: true, info: '杠' + targetTile }
}
return { choice: false, info: '不副露' }
}
},

analyseAnGang (round: Round, targetTile: Tile): { choice: boolean, info: string, discard: Tile } {
const discard = this.analyseDiscard(round).choice
ankan: function analyseAnGang (round: Round, targetTile: Tile): { choice: boolean, info: string } {
const discard = operationJudge.dahai(round).discard
const choice = discard === targetTile
return { choice, info: '', discard }
}
return { choice, info: '' }
},

analyseAddGang (round: Round, targetTile: Tile): { choice: boolean, info: string, discard: Tile } {
const discard = this.analyseDiscard(round).choice
kakan: function analyseAddGang (round: Round, targetTile: Tile): { choice: boolean, info: string } {
const discard = operationJudge.dahai(round).discard
const choice = discard !== targetTile
return { choice, info: '', discard }
}
return { choice, info: '' }
},

analyseLiqi (round: Round): { choice: boolean, discard: Tile, info: string } {
reach: function analyseLiqi (round: Round): { choice: boolean, discard: Tile, info: string } {
const choice = round.leftTileCnt >= 10
const discard = this.analyseDiscard(round).choice
const discard = operationJudge.dahai(round).discard as Tile
const info = (choice ? '立直 ' : '默听 ') + `切${discard}`
return { choice, discard, info }
}
},

analyseHule (round: Round): { choice: boolean, info: string } {
horaron: function analyseHule (round: Round): { choice: boolean, info: string } {
const choice = true
return { choice, info: '荣和' }
}
},

analyseZimo (round: Round): { choice: boolean, info: string } {
horatsumo: function analyseZimo (round: Round): { choice: boolean, info: string } {
const choice = true
return { choice, info: '自摸' }
}
},

analyseBabei (round: Round): { choice: boolean, info: string } {
babei: function analyseBabei (round: Round): { choice: boolean, info: string } {
const meHand = round.players[round.meSeat].hand as Tile[]
const choice = meHand.reduce((p, c) => { c === '3z' ? p += 1 : p += 0; return p }, 0) < 3
return { choice, info: '拔北' }
},

skip: function analyseSkip (round: Round): { choice: true, info: string } {
return { choice: true, info: 'skip' }
}
}

class Analyser extends BaseAnalyser {
analyseOperations (parsedOperationList: ParsedOperationList, round: Round): { choice: ParsedOperation, info?: string } {
if (parsedOperationList.length === 0) { return { choice: { type: 'skip' }, info: 'No operation to analyse' } }
const priority = ['horatsumo', 'horaron', 'reach', 'chi', 'pon', 'ankan', 'daiminkan', 'kakan', 'babei', 'dahai', 'skip']
parsedOperationList
.sort(({ type: t1 }, { type: t2 }) => priority.findIndex(n => n === t1) - priority.findIndex(n => n === t2))
const handledOperationType: Array<ParsedOperation['type']> = []
for (const parsedOperation of parsedOperationList) {
if (handledOperationType.includes(parsedOperation.type)) { continue }
handledOperationType.push(parsedOperation.type)
const judgeResult = operationJudge[parsedOperation.type](round, 'pai' in parsedOperation ? parsedOperation.pai : undefined)
if (judgeResult.choice) {
if (parsedOperation.type === 'dahai') {
return {
choice: {
...parsedOperation,
pai: judgeResult.discard as Tile,
tsumogiri: (parsedOperationList.find(o => o.type === 'dahai' && o.pai === judgeResult.discard) as OperationDahai)?.tsumogiri ?? false
},
info: judgeResult.info
}
} else if (parsedOperation.type === 'reach') {
return {
choice: { ...parsedOperation, pai: judgeResult.discard as Tile }, info: judgeResult.info
}
} else {
return {
choice: parsedOperation, info: judgeResult.info
}
}
}
}
/* Logically, by no means will the code following run */
return {
choice: parsedOperationList[~~(Math.random() * parsedOperationList.length)],
info: 'Random selection'
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Analyser as A_mahjong_helper } from './analyser-mahjong_helper'
import { detailizeParsedOperationList } from './detailizeParsedOperationList'

const reference = [
A_mahjong_helper // 0
]

const analyser = {
const analyserModule = {
select (index: number) {
if (typeof index !== 'number') {
throw new RangeError(`Analyser module index should be type number, but get ${typeof index}`)
Expand All @@ -13,9 +14,10 @@ const analyser = {
throw new RangeError(`Analyser module index should be in [0, ${reference.length - 1}], but get ${index}`)
}
return reference[index]
}
},
detailizeParsedOperationList
}

export {
analyser
analyserModule
}
136 changes: 136 additions & 0 deletions server/analyser/detailizeParsedOperationList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import findLastIndex from 'lodash.findlastindex'
import { Round } from '../gameRecords/Round'
import { ParsedOperationList, ParsedRoughOperationList } from '../types/ParsedOperation'
import { MsgDahai } from '../types/ParsedMsg'
import { Tile } from '../types/General'

function detailizeParsedOperationList (parsedRoughOperationList: ParsedRoughOperationList, round: Round): ParsedOperationList {
if (parsedRoughOperationList.length === 0) { return [] }

const parsedOperationList: ParsedOperationList = []

const latestMeTileStepIndex = findLastIndex(round.steps, step => {
return (
step.type === 'chi' || step.type === 'pon' || step.type === 'daiminkan' ||
step.type === 'ankan' || step.type === 'kakan' || step.type === 'tsumo'
) &&
step.actor === round.meSeat
})
const latestMeTile = latestMeTileStepIndex !== -1 ? (round.steps[latestMeTileStepIndex] as { pai: Tile }).pai : undefined

const latestDiscardTileStepIndex = findLastIndex(round.steps, step => {
return step.type === 'dahai' && step.actor !== round.meSeat
})
const latestDiscardTileStep = latestDiscardTileStepIndex !== -1 ? round.steps[latestDiscardTileStepIndex] as MsgDahai : undefined

for (const parsedRoughOperation of parsedRoughOperationList) {
if (parsedRoughOperation.type === 'dahai') { /* 舍张 */
if (latestMeTile === undefined) { continue }
let isLatestTileFound = false
for (const tile of round.players[round.meSeat].hand) {
if (tile === '?') { break }
parsedOperationList.push({
type: 'dahai',
pai: tile,
tsumogiri: !isLatestTileFound && tile === latestMeTile
})
isLatestTileFound = tile === latestMeTile
}
}
if (parsedRoughOperation.type === 'pon') { /* 碰 */
if (latestDiscardTileStep === undefined) { continue }
for (const consumed of parsedRoughOperation.consumedList) {
parsedOperationList.push({
type: 'pon',
pai: latestDiscardTileStep.pai,
target: latestDiscardTileStep.actor,
consumed
})
}
}
if (parsedRoughOperation.type === 'chi') { /* 吃 */
if (latestDiscardTileStep === undefined) { continue }
for (const consumed of parsedRoughOperation.consumedList) {
parsedOperationList.push({
type: 'chi',
pai: latestDiscardTileStep.pai,
target: latestDiscardTileStep.actor,
consumed
})
}
}
if (parsedRoughOperation.type === 'kakan') { /* 加杠 */
if (latestMeTile === undefined) { continue }
for (const consumed of parsedRoughOperation.consumedList) {
parsedOperationList.push({
type: 'kakan',
pai: latestMeTile,
consumed
})
}
}
if (parsedRoughOperation.type === 'daiminkan') { /* 杠 */
if (latestDiscardTileStep === undefined) { continue }
for (const consumed of parsedRoughOperation.consumedList) {
parsedOperationList.push({
type: 'daiminkan',
pai: latestDiscardTileStep.pai,
target: latestDiscardTileStep.actor,
consumed
})
}
}
if (parsedRoughOperation.type === 'ankan') { /* 暗杠 */
if (latestMeTile === undefined) { continue }
for (const consumed of parsedRoughOperation.consumedList) {
parsedOperationList.push({
type: 'ankan',
pai: latestMeTile,
consumed
})
}
}
if (parsedRoughOperation.type === 'reach') { /* 立直 */
for (const pai of parsedRoughOperation.pais) {
parsedOperationList.push({
type: 'reach',
pai
})
}
}
if (parsedRoughOperation.type === 'babei') { /* 拔北 */
parsedOperationList.push({
type: 'babei'
})
}
if (parsedRoughOperation.type === 'horaron') { /* 荣和 */
if (latestDiscardTileStep === undefined) { continue }
parsedOperationList.push({
type: 'horaron',
target: latestDiscardTileStep.actor
})
}
if (parsedRoughOperation.type === 'horatsumo') { /* 自摸 */
parsedOperationList.push({
type: 'horatsumo'
})
}
if (parsedRoughOperation.type === 'ryukyoku') { /* 九种九牌 */
parsedOperationList.push({
type: 'ryukyoku'
})
}
}

if (!parsedRoughOperationList.some(op => op.type === 'dahai')) { /* 没有要求舍张,则添加 OperationSkip */
parsedOperationList.push({
type: 'skip'
})
}

return parsedOperationList
}

export {
detailizeParsedOperationList
}
Loading

0 comments on commit 70f9e41

Please sign in to comment.