This commit is contained in:
syuilo
2024-01-26 14:25:00 +09:00
parent 2133d0552c
commit 67e6184a75
56 changed files with 3035 additions and 92 deletions

View File

@@ -0,0 +1,7 @@
node_modules
/built
/coverage
/.eslintrc.js
/jest.config.ts
/test
/test-d

View File

@@ -0,0 +1,10 @@
module.exports = {
root: true,
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',
],
};

View File

@@ -0,0 +1,31 @@
import { build } from "esbuild";
import { globSync } from "glob";
const entryPoints = globSync("./src/**/**.{ts,tsx}");
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints,
minify: true,
outdir: "./built/esm",
target: "es2022",
platform: "browser",
format: "esm",
};
if (process.env.WATCH === "true") {
options.watch = {
onRebuild(error, result) {
if (error) {
console.error("watch build failed:", error);
} else {
console.log("watch build succeeded:", result);
}
},
};
}
build(options).catch((err) => {
process.stderr.write(err.stderr);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
{
"type": "module",
"name": "misskey-mahjong",
"version": "0.0.1",
"exports": {
".": {
"import": "./built/esm/index.js",
"types": "./built/dts/index.d.ts"
},
"./*": {
"import": "./built/esm/*",
"types": "./built/dts/*"
}
},
"scripts": {
"build": "node ./build.js",
"build:tsc": "npm run tsc",
"tsc": "npm run tsc-esm && npm run tsc-dts",
"tsc-esm": "tsc --outDir built/esm",
"tsc-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/node": "20.11.5",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0",
"nodemon": "3.0.2",
"typescript": "5.3.3"
},
"files": [
"built"
],
"dependencies": {
"crc-32": "1.2.2",
"esbuild": "0.19.11",
"glob": "10.3.10"
}
}

View File

@@ -0,0 +1,523 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import CRC32 from 'crc-32';
export const TILE_TYPES = [
'bamboo1',
'bamboo2',
'bamboo3',
'bamboo4',
'bamboo5',
'bamboo6',
'bamboo7',
'bamboo8',
'bamboo9',
'character1',
'character2',
'character3',
'character4',
'character5',
'character6',
'character7',
'character8',
'character9',
'circle1',
'circle2',
'circle3',
'circle4',
'circle5',
'circle6',
'circle7',
'circle8',
'circle9',
'wind-east',
'wind-south',
'wind-west',
'wind-north',
'dragon-red',
'dragon-green',
'dragon-white',
] as const;
export type Tile = typeof TILE_TYPES[number];
export function isTile(tile: string): tile is Tile {
return TILE_TYPES.includes(tile as Tile);
}
export type House = 'e' | 's' | 'w' | 'n';
export type MasterState = {
user1House: House;
user2House: House;
user3House: House;
user4House: House;
tiles: Tile[];
eHandTiles: Tile[];
sHandTiles: Tile[];
wHandTiles: Tile[];
nHandTiles: Tile[];
eHoTiles: Tile[];
sHoTiles: Tile[];
wHoTiles: Tile[];
nHoTiles: Tile[];
ePonnedTiles: { tile: Tile; from: House; }[];
sPonnedTiles: { tile: Tile; from: House; }[];
wPonnedTiles: { tile: Tile; from: House; }[];
nPonnedTiles: { tile: Tile; from: House; }[];
eCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
sCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
wCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
nCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
eRiichi: boolean;
sRiichi: boolean;
wRiichi: boolean;
nRiichi: boolean;
ePoints: number;
sPoints: number;
wPoints: number;
nPoints: number;
turn: House | null;
ponAsking: {
source: House;
target: House;
} | null;
ciiAsking: {
source: House;
} | null;
};
export class Common {
public static nextHouse(house: House): House {
switch (house) {
case 'e':
return 's';
case 's':
return 'w';
case 'w':
return 'n';
case 'n':
return 'e';
}
}
public static checkYaku(tiles: Tile[]) {
}
}
export class MasterGameEngine {
public state: MasterState;
constructor(state: MasterState) {
this.state = state;
}
public static createInitialState(): MasterState {
const tiles = TILE_TYPES.slice();
tiles.sort(() => Math.random() - 0.5);
const eHandTiles = tiles.splice(0, 14);
const sHandTiles = tiles.splice(0, 13);
const wHandTiles = tiles.splice(0, 13);
const nHandTiles = tiles.splice(0, 13);
return {
user1House: 'e',
user2House: 's',
user3House: 'w',
user4House: 'n',
tiles,
eHandTiles,
sHandTiles,
wHandTiles,
nHandTiles,
eHoTiles: [],
sHoTiles: [],
wHoTiles: [],
nHoTiles: [],
ePonnedTiles: [],
sPonnedTiles: [],
wPonnedTiles: [],
nPonnedTiles: [],
eCiiedTiles: [],
sCiiedTiles: [],
wCiiedTiles: [],
nCiiedTiles: [],
eRiichi: false,
sRiichi: false,
wRiichi: false,
nRiichi: false,
ePoints: 25000,
sPoints: 25000,
wPoints: 25000,
nPoints: 25000,
turn: 'e',
ponAsking: null,
ciiAsking: null,
};
}
private (): Tile {
const tile = this.state.tiles.pop();
switch (this.state.turn) {
case 'e':
this.state.eHandTiles.push(tile);
break;
case 's':
this.state.sHandTiles.push(tile);
break;
case 'w':
this.state.wHandTiles.push(tile);
break;
case 'n':
this.state.nHandTiles.push(tile);
break;
}
return tile;
}
public op_dahai(house: House, tile: Tile) {
if (this.state.turn !== house) throw new Error('Not your turn');
switch (house) {
case 'e':
if (!this.state.eHandTiles.includes(tile)) throw new Error('Invalid tile');
this.state.eHandTiles.splice(this.state.eHandTiles.indexOf(tile), 1);
this.state.eHoTiles.push(tile);
break;
case 's':
if (!this.state.sHandTiles.includes(tile)) throw new Error('Invalid tile');
this.state.sHandTiles.splice(this.state.sHandTiles.indexOf(tile), 1);
this.state.sHoTiles.push(tile);
break;
case 'w':
if (!this.state.wHandTiles.includes(tile)) throw new Error('Invalid tile');
this.state.wHandTiles.splice(this.state.wHandTiles.indexOf(tile), 1);
this.state.wHoTiles.push(tile);
break;
case 'n':
if (!this.state.nHandTiles.includes(tile)) throw new Error('Invalid tile');
this.state.nHandTiles.splice(this.state.nHandTiles.indexOf(tile), 1);
this.state.nHoTiles.push(tile);
break;
}
let canPonHouse: House | null = null;
if (house === 'e') {
canPonHouse = this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null;
} else if (house === 's') {
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null;
} else if (house === 'w') {
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('n', tile) ? 'n' : null;
} else if (house === 'n') {
canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : 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...
//}
if (canPonHouse) {
this.state.ponAsking = {
source: house,
target: canPonHouse,
};
return {
canPonHouse: canPonHouse,
};
}
this.state.turn = Common.nextHouse(house);
const tsumoTile = this.();
return {
tsumo: tsumoTile,
};
}
public op_pon(house: House) {
if (this.state.ponAsking == null) throw new Error('No one is asking for pon');
if (this.state.ponAsking.target !== house) throw new Error('Not you');
const source = this.state.ponAsking.source;
const target = this.state.ponAsking.target;
this.state.ponAsking = null;
let tile: Tile;
switch (source) {
case 'e':
tile = this.state.eHoTiles.pop();
break;
case 's':
tile = this.state.sHoTiles.pop();
break;
case 'w':
tile = this.state.wHoTiles.pop();
break;
case 'n':
tile = this.state.nHoTiles.pop();
break;
default: throw new Error('Invalid source');
}
switch (target) {
case 'e':
this.state.ePonnedTiles.push({ tile, from: source });
break;
case 's':
this.state.sPonnedTiles.push({ tile, from: source });
break;
case 'w':
this.state.wPonnedTiles.push({ tile, from: source });
break;
case 'n':
this.state.nPonnedTiles.push({ tile, from: source });
break;
}
this.state.turn = target;
}
public op_noOnePon() {
if (this.state.ponAsking == null) throw new Error('No one is asking for pon');
this.state.ponAsking = null;
this.state.turn = Common.nextHouse(this.state.turn);
const tile = this.();
return {
house: this.state.turn,
tile,
};
}
private canPon(house: House, tile: Tile): boolean {
switch (house) {
case 'e':
return this.state.eHandTiles.filter(t => t === tile).length === 2;
case 's':
return this.state.sHandTiles.filter(t => t === tile).length === 2;
case 'w':
return this.state.wHandTiles.filter(t => t === tile).length === 2;
case 'n':
return this.state.nHandTiles.filter(t => t === tile).length === 2;
}
}
public calcCrc32ForUser1(): number {
// TODO
}
public calcCrc32ForUser2(): number {
// TODO
}
public calcCrc32ForUser3(): number {
// TODO
}
public calcCrc32ForUser4(): number {
// TODO
}
}
export type PlayerState = {
user1House: House;
user2House: House;
user3House: House;
user4House: House;
tilesCount: number;
eHandTiles: Tile[] | null[];
sHandTiles: Tile[] | null[];
wHandTiles: Tile[] | null[];
nHandTiles: Tile[] | null[];
eHoTiles: Tile[];
sHoTiles: Tile[];
wHoTiles: Tile[];
nHoTiles: Tile[];
ePonnedTiles: { tile: Tile; from: House; }[];
sPonnedTiles: { tile: Tile; from: House; }[];
wPonnedTiles: { tile: Tile; from: House; }[];
nPonnedTiles: { tile: Tile; from: House; }[];
eCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
sCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
wCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
nCiiedTiles: { tiles: [Tile, Tile, Tile]; from: House; }[];
eRiichi: boolean;
sRiichi: boolean;
wRiichi: boolean;
nRiichi: boolean;
ePoints: number;
sPoints: number;
wPoints: number;
nPoints: number;
latestDahaiedTile: Tile | null;
turn: House | null;
};
export class PlayerGameEngine {
/**
* このエラーが発生したときはdesyncが疑われる
*/
public static InvalidOperationError = class extends Error {};
private myUserNumber: 1 | 2 | 3 | 4;
public state: PlayerState;
constructor(myUserNumber: PlayerGameEngine['myUserNumber'], state: PlayerState) {
this.myUserNumber = myUserNumber;
this.state = state;
}
public get myHouse(): House {
switch (this.myUserNumber) {
case 1: return this.state.user1House;
case 2: return this.state.user2House;
case 3: return this.state.user3House;
case 4: return this.state.user4House;
}
}
public get myHandTiles(): Tile[] {
switch (this.myHouse) {
case 'e': return this.state.eHandTiles as Tile[];
case 's': return this.state.sHandTiles as Tile[];
case 'w': return this.state.wHandTiles as Tile[];
case 'n': return this.state.nHandTiles as Tile[];
}
}
public get myHoTiles(): Tile[] {
switch (this.myHouse) {
case 'e': return this.state.eHoTiles;
case 's': return this.state.sHoTiles;
case 'w': return this.state.wHoTiles;
case 'n': return this.state.nHoTiles;
}
}
public op_tsumo(house: House, tile: Tile) {
if (house === this.myHouse) {
this.myHandTiles.push(tile);
} else {
switch (house) {
case 'e':
this.state.eHandTiles.push(null);
break;
case 's':
this.state.sHandTiles.push(null);
break;
case 'w':
this.state.wHandTiles.push(null);
break;
case 'n':
this.state.nHandTiles.push(null);
break;
}
}
}
public op_dahai(house: House, tile: Tile) {
if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError();
if (house === this.myHouse) {
this.myHandTiles.splice(this.myHandTiles.indexOf(tile), 1);
this.myHoTiles.push(tile);
} else {
switch (house) {
case 'e':
this.state.eHandTiles.pop();
this.state.eHoTiles.push(tile);
break;
case 's':
this.state.sHandTiles.pop();
this.state.sHoTiles.push(tile);
break;
case 'w':
this.state.wHandTiles.pop();
this.state.wHoTiles.push(tile);
break;
case 'n':
this.state.nHandTiles.pop();
this.state.nHoTiles.push(tile);
break;
}
}
if (house === this.myHouse) {
this.state.turn = null;
} else {
const canPon = this.myHandTiles.filter(t => t === tile).length === 2;
// TODO: canCii
return {
canPon,
};
}
}
public op_pon(source: House, target: House) {
let tile: Tile;
switch (source) {
case 'e': {
const lastTile = this.state.eHoTiles.pop();
if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError();
tile = lastTile;
break;
}
case 's': {
const lastTile = this.state.sHoTiles.pop();
if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError();
tile = lastTile;
break;
}
case 'w': {
const lastTile = this.state.wHoTiles.pop();
if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError();
tile = lastTile;
break;
}
case 'n': {
const lastTile = this.state.nHoTiles.pop();
if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError();
tile = lastTile;
break;
}
default: throw new Error('Invalid source');
}
switch (target) {
case 'e':
this.state.ePonnedTiles.push({ tile, from: source });
break;
case 's':
this.state.sPonnedTiles.push({ tile, from: source });
break;
case 'w':
this.state.wPonnedTiles.push({ tile, from: source });
break;
case 'n':
this.state.nPonnedTiles.push({ tile, from: source });
break;
}
this.state.turn = target;
}
}

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export * as Engine from './engine.js';
export * as Serializer from './serializer.js';

View File

@@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Tile } from './engine.js';
export type Log = {
time: number;
player: 1 | 2 | 3 | 4;
operation: 'dahai';
tile: string;
};
export type SerializedLog = number[];
export const TILE_MAP: Record<Tile, number> = {
'bamboo1': 1,
'bamboo2': 2,
'bamboo3': 3,
'bamboo4': 4,
'bamboo5': 5,
'bamboo6': 6,
'bamboo7': 7,
'bamboo8': 8,
'bamboo9': 9,
'character1': 10,
'character2': 11,
'character3': 12,
'character4': 13,
'character5': 14,
'character6': 15,
'character7': 16,
'character8': 17,
'character9': 18,
'circle1': 19,
'circle2': 20,
'circle3': 21,
'circle4': 22,
'circle5': 23,
'circle6': 24,
'circle7': 25,
'circle8': 26,
'circle9': 27,
'wind-east': 28,
'wind-south': 29,
'wind-west': 30,
'wind-north': 31,
'dragon-red': 32,
'dragon-green': 33,
'dragon-white': 34,
};
export function serializeTile(tile: Tile): 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 serializeLogs(logs: Log[]) {
const _logs: number[][] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
const timeDelta = i === 0 ? log.time : log.time - logs[i - 1].time;
switch (log.operation) {
case 'dahai':
_logs.push([timeDelta, log.player, 1, serializeTile(log.tile)]);
break;
//case 'surrender':
// _logs.push([timeDelta, log.player, 1]);
// break;
}
}
return _logs;
}
export function deserializeLogs(logs: SerializedLog[]) {
const _logs: Log[] = [];
let time = 0;
for (const log of logs) {
const timeDelta = log[0];
time += timeDelta;
const player = log[1];
const operation = log[2];
switch (operation) {
case 1:
_logs.push({
time,
player: player,
operation: 'dahai',
tile: log[3],
});
break;
//case 1:
// _logs.push({
// time,
// player: player === 1,
// operation: 'surrender',
// });
// break;
}
}
return _logs;
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./built/",
"removeComments": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"esnext",
"dom"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"test/**/*"
]
}