@pai-forge/riichi-mahjong 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/core/tehai.d.ts +4 -0
- package/dist/core/tehai.js +70 -1
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +59 -0
- package/dist/features/machi/index.js +11 -0
- package/dist/features/points/constants.d.ts +5 -5
- package/dist/features/points/constants.js +7 -7
- package/dist/features/points/index.js +3 -3
- package/dist/features/score/constants.d.ts +27 -0
- package/dist/features/score/constants.js +30 -0
- package/dist/features/score/index.d.ts +45 -0
- package/dist/features/score/index.js +207 -0
- package/dist/features/score/lib/fu/constants.d.ts +37 -0
- package/dist/features/score/lib/fu/constants.js +45 -0
- package/dist/features/score/lib/fu/index.d.ts +11 -0
- package/dist/features/score/lib/fu/index.js +23 -0
- package/dist/features/score/lib/fu/lib/chiitoitsu.d.ts +5 -0
- package/dist/features/score/lib/fu/lib/chiitoitsu.js +17 -0
- package/dist/features/score/lib/fu/lib/kokushi.d.ts +6 -0
- package/dist/features/score/lib/fu/lib/kokushi.js +18 -0
- package/dist/features/score/lib/fu/lib/mentsu.d.ts +11 -0
- package/dist/features/score/lib/fu/lib/mentsu.js +136 -0
- package/dist/features/score/lib/fu/types.d.ts +56 -0
- package/dist/features/score/lib/fu/types.js +1 -0
- package/dist/features/score/types.d.ts +78 -0
- package/dist/features/score/types.js +22 -0
- package/dist/features/yaku/factory.d.ts +1 -1
- package/dist/features/yaku/factory.js +1 -1
- package/dist/features/yaku/index.d.ts +11 -2
- package/dist/features/yaku/index.js +30 -33
- package/dist/features/yaku/lib/definitions/daisuushii.js +1 -1
- package/dist/features/yaku/lib/definitions/iipeikou.d.ts +2 -0
- package/dist/features/yaku/lib/definitions/{iipeiko.js → iipeikou.js} +2 -2
- package/dist/features/yaku/lib/definitions/index.d.ts +2 -2
- package/dist/features/yaku/lib/definitions/index.js +5 -5
- package/dist/features/yaku/types.d.ts +6 -6
- package/dist/index.d.ts +12 -3
- package/dist/index.js +45 -1
- package/dist/types.d.ts +10 -0
- package/package.json +3 -2
- package/dist/features/yaku/lib/definitions/iipeiko.d.ts +0 -2
- /package/dist/features/yaku/lib/definitions/{ryanpeiko.d.ts → ryanpeikou.d.ts} +0 -0
- /package/dist/features/yaku/lib/definitions/{ryanpeiko.js → ryanpeikou.js} +0 -0
package/README.md
CHANGED
package/dist/core/tehai.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export declare function validateTehai13<T extends HaiKindId | HaiId>(tehai: Teha
|
|
|
18
18
|
* 手牌がTehai14(有効枚数14枚)であるか検証します。
|
|
19
19
|
* @throws {ShoushaiError} 枚数が不足している場合
|
|
20
20
|
* @throws {TahaiError} 枚数が超過している場合
|
|
21
|
+
* @throws {InvalidHaiQuantityError} 同一種の牌が5枚以上ある場合
|
|
22
|
+
* @throws {DuplicatedHaiIdError} 物理牌モードでIDが重複している場合
|
|
21
23
|
*/
|
|
22
24
|
export declare function validateTehai14<T extends HaiKindId | HaiId>(tehai: Tehai<T>): void;
|
|
23
25
|
/**
|
|
@@ -26,6 +28,8 @@ export declare function validateTehai14<T extends HaiKindId | HaiId>(tehai: Teha
|
|
|
26
28
|
*
|
|
27
29
|
* @throws {ShoushaiError} 枚数が不足している場合 (< 13)
|
|
28
30
|
* @throws {TahaiError} 枚数が超過している場合 (> 14)
|
|
31
|
+
* @throws {InvalidHaiQuantityError} 同一種の牌が5枚以上ある場合
|
|
32
|
+
* @throws {DuplicatedHaiIdError} 物理牌モードでIDが重複している場合
|
|
29
33
|
*/
|
|
30
34
|
export declare function validateTehai<T extends HaiKindId | HaiId>(tehai: Tehai<T>): void;
|
|
31
35
|
/**
|
package/dist/core/tehai.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ShoushaiError, TahaiError } from "../errors";
|
|
1
|
+
import { DuplicatedHaiIdError, InvalidHaiQuantityError, ShoushaiError, TahaiError, } from "../errors";
|
|
2
2
|
/**
|
|
3
3
|
* 手牌の有効枚数を計算します。
|
|
4
4
|
* 副露(槓子含む)は一律3枚として計算します。
|
|
@@ -30,11 +30,14 @@ export function validateTehai13(tehai) {
|
|
|
30
30
|
if (count > 13) {
|
|
31
31
|
throw new TahaiError();
|
|
32
32
|
}
|
|
33
|
+
validateHaiConsistency(tehai);
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
36
|
* 手牌がTehai14(有効枚数14枚)であるか検証します。
|
|
36
37
|
* @throws {ShoushaiError} 枚数が不足している場合
|
|
37
38
|
* @throws {TahaiError} 枚数が超過している場合
|
|
39
|
+
* @throws {InvalidHaiQuantityError} 同一種の牌が5枚以上ある場合
|
|
40
|
+
* @throws {DuplicatedHaiIdError} 物理牌モードでIDが重複している場合
|
|
38
41
|
*/
|
|
39
42
|
export function validateTehai14(tehai) {
|
|
40
43
|
const count = calculateTehaiCount(tehai);
|
|
@@ -44,6 +47,7 @@ export function validateTehai14(tehai) {
|
|
|
44
47
|
if (count > 14) {
|
|
45
48
|
throw new TahaiError();
|
|
46
49
|
}
|
|
50
|
+
validateHaiConsistency(tehai);
|
|
47
51
|
}
|
|
48
52
|
/**
|
|
49
53
|
* 手牌がTehai13またはTehai14(有効枚数が13または14枚)であるか検証します。
|
|
@@ -51,6 +55,8 @@ export function validateTehai14(tehai) {
|
|
|
51
55
|
*
|
|
52
56
|
* @throws {ShoushaiError} 枚数が不足している場合 (< 13)
|
|
53
57
|
* @throws {TahaiError} 枚数が超過している場合 (> 14)
|
|
58
|
+
* @throws {InvalidHaiQuantityError} 同一種の牌が5枚以上ある場合
|
|
59
|
+
* @throws {DuplicatedHaiIdError} 物理牌モードでIDが重複している場合
|
|
54
60
|
*/
|
|
55
61
|
export function validateTehai(tehai) {
|
|
56
62
|
const count = calculateTehaiCount(tehai);
|
|
@@ -60,6 +66,69 @@ export function validateTehai(tehai) {
|
|
|
60
66
|
if (count > 14) {
|
|
61
67
|
throw new TahaiError();
|
|
62
68
|
}
|
|
69
|
+
validateHaiConsistency(tehai);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 手牌の整合性を検証します。
|
|
73
|
+
* - 物理的な牌ID (`HaiId`) が使われている場合、重複チェックを行います。
|
|
74
|
+
* - 牌種ID (`HaiKindId`) の場合(または変換後)、同一牌種が5枚以上ないかチェックします。
|
|
75
|
+
*
|
|
76
|
+
* @throws {DuplicatedHaiIdError}
|
|
77
|
+
* @throws {InvalidHaiQuantityError}
|
|
78
|
+
*/
|
|
79
|
+
function validateHaiConsistency(tehai) {
|
|
80
|
+
const allHais = [
|
|
81
|
+
...tehai.closed,
|
|
82
|
+
...tehai.exposed.flatMap((m) => m.hais),
|
|
83
|
+
];
|
|
84
|
+
// 1. Check for physical HaiId usage (any id > 33)
|
|
85
|
+
// HaiKindId is 0-33. Any value > 33 implies HaiId (0-135).
|
|
86
|
+
// Note: Low HaiIds (0-33) are ambiguous, but if mix of high and low exists, it's HaiId.
|
|
87
|
+
// If only low values exist, we can't strictly distinguish, but treating as KindId is safe
|
|
88
|
+
// unless the user provided [0, 0] intending HaiId 0 and HaiId 0.
|
|
89
|
+
// However, normally HaiKindId 0 is ManZu1.
|
|
90
|
+
// Strategy: If MAX(id) > 33, treat as HaiId.
|
|
91
|
+
const isHaiIdMode = allHais.some((h) => h > 33);
|
|
92
|
+
if (isHaiIdMode) {
|
|
93
|
+
// Check for duplicate HaiIds
|
|
94
|
+
const uniqueIds = new Set(allHais);
|
|
95
|
+
if (uniqueIds.size !== allHais.length) {
|
|
96
|
+
throw new DuplicatedHaiIdError();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 2. Check for Kind quantity (max 4 per kind)
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const hai of allHais) {
|
|
102
|
+
// If HaiId mode, convert to KindId
|
|
103
|
+
// If KindId mode, use as is
|
|
104
|
+
// import { haiIdToKindId } from "./hai"; <--- Need to import or implement logic
|
|
105
|
+
// Since we are in core/tehai, and core/hai depends on types.
|
|
106
|
+
// Let's defer strict conversion.
|
|
107
|
+
// For now, assume generic T validation behavior.
|
|
108
|
+
// But we need `haiIdToKindId`.
|
|
109
|
+
// Let's implement logic inline or use import.
|
|
110
|
+
// Circular dependency risk? core/tehai -> core/hai.
|
|
111
|
+
// core/hai imports types. core/tehai imports types. Should be fine.
|
|
112
|
+
// But I need to import it at top of file.
|
|
113
|
+
// Using inline logic to avoid circular deps if any (though likely safe)
|
|
114
|
+
// 0-35 -> 0-8, etc.
|
|
115
|
+
let kind = hai;
|
|
116
|
+
if (hai > 33) {
|
|
117
|
+
if (hai < 36)
|
|
118
|
+
kind = Math.floor(hai / 4);
|
|
119
|
+
else if (hai < 72)
|
|
120
|
+
kind = Math.floor((hai - 36) / 4) + 9;
|
|
121
|
+
else if (hai < 108)
|
|
122
|
+
kind = Math.floor((hai - 72) / 4) + 18;
|
|
123
|
+
else
|
|
124
|
+
kind = Math.floor((hai - 108) / 4) + 27;
|
|
125
|
+
}
|
|
126
|
+
const current = counts.get(kind) ?? 0;
|
|
127
|
+
if (current + 1 > 4) {
|
|
128
|
+
throw new InvalidHaiQuantityError();
|
|
129
|
+
}
|
|
130
|
+
counts.set(kind, current + 1);
|
|
131
|
+
}
|
|
63
132
|
}
|
|
64
133
|
/**
|
|
65
134
|
* Type Guard for Tehai13
|
package/dist/errors.d.ts
CHANGED
|
@@ -38,3 +38,46 @@ export declare class MahjongArgumentError extends MahjongError {
|
|
|
38
38
|
*/
|
|
39
39
|
constructor(message: string);
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* 牌IDが重複している場合のエラー
|
|
43
|
+
* (物理的な牌IDは一意である必要があります)
|
|
44
|
+
*/
|
|
45
|
+
export declare class DuplicatedHaiIdError extends MahjongError {
|
|
46
|
+
/**
|
|
47
|
+
*
|
|
48
|
+
*/
|
|
49
|
+
constructor(message?: string);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 牌の枚数が不正な場合のエラー
|
|
53
|
+
* (同種の牌は最大4枚までです)
|
|
54
|
+
*/
|
|
55
|
+
export declare class InvalidHaiQuantityError extends MahjongError {
|
|
56
|
+
/**
|
|
57
|
+
*
|
|
58
|
+
*/
|
|
59
|
+
constructor(message?: string);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* チョンボ(錯和)の基底エラークラス
|
|
63
|
+
*
|
|
64
|
+
* 不正な和了宣言に関するエラーの基底クラス。
|
|
65
|
+
* 具体的なチョンボ種別(役なし、フリテン等)はサブクラスで定義する。
|
|
66
|
+
*/
|
|
67
|
+
export declare class ChomboError extends MahjongError {
|
|
68
|
+
/**
|
|
69
|
+
*
|
|
70
|
+
*/
|
|
71
|
+
constructor(message?: string);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 役なし和了のエラー
|
|
75
|
+
*
|
|
76
|
+
* 和了形は成立しているが、役が一つも成立していない場合にスローされます。
|
|
77
|
+
*/
|
|
78
|
+
export declare class NoYakuError extends ChomboError {
|
|
79
|
+
/**
|
|
80
|
+
*
|
|
81
|
+
*/
|
|
82
|
+
constructor(message?: string);
|
|
83
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -56,3 +56,62 @@ export class MahjongArgumentError extends MahjongError {
|
|
|
56
56
|
Object.setPrototypeOf(this, MahjongArgumentError.prototype);
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 牌IDが重複している場合のエラー
|
|
61
|
+
* (物理的な牌IDは一意である必要があります)
|
|
62
|
+
*/
|
|
63
|
+
export class DuplicatedHaiIdError extends MahjongError {
|
|
64
|
+
/**
|
|
65
|
+
*
|
|
66
|
+
*/
|
|
67
|
+
constructor(message = "牌IDが重複しています。") {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "DuplicatedHaiIdError";
|
|
70
|
+
Object.setPrototypeOf(this, DuplicatedHaiIdError.prototype);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 牌の枚数が不正な場合のエラー
|
|
75
|
+
* (同種の牌は最大4枚までです)
|
|
76
|
+
*/
|
|
77
|
+
export class InvalidHaiQuantityError extends MahjongError {
|
|
78
|
+
/**
|
|
79
|
+
*
|
|
80
|
+
*/
|
|
81
|
+
constructor(message = "同種の牌が5枚以上存在します。") {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "InvalidHaiQuantityError";
|
|
84
|
+
Object.setPrototypeOf(this, InvalidHaiQuantityError.prototype);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* チョンボ(錯和)の基底エラークラス
|
|
89
|
+
*
|
|
90
|
+
* 不正な和了宣言に関するエラーの基底クラス。
|
|
91
|
+
* 具体的なチョンボ種別(役なし、フリテン等)はサブクラスで定義する。
|
|
92
|
+
*/
|
|
93
|
+
export class ChomboError extends MahjongError {
|
|
94
|
+
/**
|
|
95
|
+
*
|
|
96
|
+
*/
|
|
97
|
+
constructor(message = "不正な和了です。") {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = "ChomboError";
|
|
100
|
+
Object.setPrototypeOf(this, ChomboError.prototype);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 役なし和了のエラー
|
|
105
|
+
*
|
|
106
|
+
* 和了形は成立しているが、役が一つも成立していない場合にスローされます。
|
|
107
|
+
*/
|
|
108
|
+
export class NoYakuError extends ChomboError {
|
|
109
|
+
/**
|
|
110
|
+
*
|
|
111
|
+
*/
|
|
112
|
+
constructor(message = "役が成立していません。") {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "NoYakuError";
|
|
115
|
+
Object.setPrototypeOf(this, NoYakuError.prototype);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { countHaiKind } from "../../core/tehai";
|
|
1
2
|
import { calculateMentsuTeShanten } from "../shanten";
|
|
2
3
|
/**
|
|
3
4
|
* 手牌の受け入れ(有効牌)を計算する。
|
|
@@ -9,10 +10,20 @@ import { calculateMentsuTeShanten } from "../shanten";
|
|
|
9
10
|
export function getUkeire(tehai) {
|
|
10
11
|
const currentShanten = calculateMentsuTeShanten(tehai);
|
|
11
12
|
const ukeireList = [];
|
|
13
|
+
// 手牌の全ての牌(純手牌 + 副露)をカウント
|
|
14
|
+
const allHais = [
|
|
15
|
+
...tehai.closed,
|
|
16
|
+
...tehai.exposed.flatMap((m) => m.hais),
|
|
17
|
+
];
|
|
18
|
+
const haiCounts = countHaiKind(allHais);
|
|
12
19
|
// 全34種の牌について、1枚加えてシャンテン数が下がるか試す
|
|
13
20
|
for (let i = 0; i < 34; i++) {
|
|
14
21
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
15
22
|
const tile = i;
|
|
23
|
+
// 4枚使い切っている牌種はスキップ(山に残っていない)
|
|
24
|
+
if (haiCounts[tile] >= 4) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
16
27
|
// 手牌のコピーを作成(副作用を防ぐため、常に新しいオブジェクトで試行)
|
|
17
28
|
// Tehai13に1枚足すので、厳密にはTehai14として扱う必要があるが、
|
|
18
29
|
// calculateMentsuTeShanten は Tehai<HaiKindId> を受け付けるので、
|
|
@@ -5,17 +5,17 @@ export declare const SCORE_BASE_BAIMAN = 4000;
|
|
|
5
5
|
export declare const SCORE_BASE_SANBAIMAN = 6000;
|
|
6
6
|
export declare const SCORE_BASE_YAKUMAN = 8000;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* 満貫以上の判定基準となる翻数
|
|
9
9
|
*
|
|
10
|
-
* "5
|
|
10
|
+
* "5翻" であれば符数に関わらず満貫以上が確定するため 5 を設定しています。
|
|
11
11
|
*
|
|
12
|
-
* Q: 4
|
|
13
|
-
* A: 4
|
|
12
|
+
* Q: 4翻は満貫ではないのか?
|
|
13
|
+
* A: 4翻でも符数が高ければ(40符以上など)満貫になりますが、以下のようなケースでは満貫(8000点)に届きません。
|
|
14
14
|
* - 七対子 (25符): 6400点
|
|
15
15
|
* - 門前ツモ・愚形など (30符): 7900点 (※切り上げ満貫なしの場合)
|
|
16
16
|
* - 鳴き手・ロン (30符): 7700点 (※切り上げ満貫なしの場合)
|
|
17
17
|
*
|
|
18
|
-
* そのため、4
|
|
18
|
+
* そのため、4翻以下の場合は計算による基本点が基準値(2000)を超えたかどうかで判定します。
|
|
19
19
|
*/
|
|
20
20
|
export declare const HAN_MANGAN = 5;
|
|
21
21
|
export declare const HAN_HANEMAN = 6;
|
|
@@ -5,26 +5,26 @@ export const SCORE_BASE_BAIMAN = 4000;
|
|
|
5
5
|
export const SCORE_BASE_SANBAIMAN = 6000;
|
|
6
6
|
export const SCORE_BASE_YAKUMAN = 8000;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* 満貫以上の判定基準となる翻数
|
|
9
9
|
*
|
|
10
|
-
* "5
|
|
10
|
+
* "5翻" であれば符数に関わらず満貫以上が確定するため 5 を設定しています。
|
|
11
11
|
*
|
|
12
|
-
* Q: 4
|
|
13
|
-
* A: 4
|
|
12
|
+
* Q: 4翻は満貫ではないのか?
|
|
13
|
+
* A: 4翻でも符数が高ければ(40符以上など)満貫になりますが、以下のようなケースでは満貫(8000点)に届きません。
|
|
14
14
|
* - 七対子 (25符): 6400点
|
|
15
15
|
* - 門前ツモ・愚形など (30符): 7900点 (※切り上げ満貫なしの場合)
|
|
16
16
|
* - 鳴き手・ロン (30符): 7700点 (※切り上げ満貫なしの場合)
|
|
17
17
|
*
|
|
18
|
-
* そのため、4
|
|
18
|
+
* そのため、4翻以下の場合は計算による基本点が基準値(2000)を超えたかどうかで判定します。
|
|
19
19
|
*/
|
|
20
20
|
export const HAN_MANGAN = 5;
|
|
21
21
|
export const HAN_HANEMAN = 6;
|
|
22
22
|
export const HAN_BAIMAN = 8;
|
|
23
23
|
export const HAN_SANBAIMAN = 11;
|
|
24
24
|
export const HAN_YAKUMAN = 13;
|
|
25
|
-
export const HAS_YAKUMAN = 13; //
|
|
25
|
+
export const HAS_YAKUMAN = 13; // 便宜上の翻数
|
|
26
26
|
export const HAS_DOUBLE_YAKUMAN = 26;
|
|
27
|
-
// 切り上げ満貫の閾値 (30符4
|
|
27
|
+
// 切り上げ満貫の閾値 (30符4翻 = 1920 -> 2000? 60符3翻=1920)
|
|
28
28
|
// 一般的には 2000点(子) / 3000点(親) が満貫の最低点(ベース)
|
|
29
29
|
// 符計算による基本点が 2000 を超えたら満貫
|
|
30
30
|
export const BASE_POINT_LIMIT = 2000;
|
|
@@ -80,9 +80,9 @@ export function calculateBasicScore(yakuHansu, fuResult, dora, context) {
|
|
|
80
80
|
let basePoints = fu * Math.pow(2, 2 + totalHan);
|
|
81
81
|
let level = "Normal";
|
|
82
82
|
// 満貫以上の判定
|
|
83
|
-
// 5
|
|
84
|
-
// 4
|
|
85
|
-
// ここでは厳密な計算 (2000以上) とする。※30符4
|
|
83
|
+
// 5翻以上 は満貫確定
|
|
84
|
+
// 4翻以下でも 基本点が2000を超えたら満貫 (切り上げ満貫採用なら1920->2000)
|
|
85
|
+
// ここでは厳密な計算 (2000以上) とする。※30符4翻は1920なのでNormal、60符3翻は1920、70符3翻(2240)は満貫
|
|
86
86
|
if (totalHan >= HAN_YAKUMAN) {
|
|
87
87
|
// 役満(数え役満)
|
|
88
88
|
// ダブル役満等は呼び出し元でHandを判定して Han=26 とかに固定して渡してもらう想定
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const SCORE_OYA_MULTIPLIER = 1.5;
|
|
2
|
+
export declare const SCORE_BASE_MANGAN = 2000;
|
|
3
|
+
export declare const SCORE_BASE_HANEMAN = 3000;
|
|
4
|
+
export declare const SCORE_BASE_BAIMAN = 4000;
|
|
5
|
+
export declare const SCORE_BASE_SANBAIMAN = 6000;
|
|
6
|
+
export declare const SCORE_BASE_YAKUMAN = 8000;
|
|
7
|
+
/**
|
|
8
|
+
* 満貫以上の判定基準となる翻数
|
|
9
|
+
*
|
|
10
|
+
* "5翻" であれば符数に関わらず満貫以上が確定するため 5 を設定しています。
|
|
11
|
+
*
|
|
12
|
+
* Q: 4翻は満貫ではないのか?
|
|
13
|
+
* A: 4翻でも符数が高ければ(40符以上など)満貫になりますが、以下のようなケースでは満貫(8000点)に届きません。
|
|
14
|
+
* - 七対子 (25符): 6400点
|
|
15
|
+
* - 門前ツモ・愚形など (30符): 7900点 (※切り上げ満貫なしの場合)
|
|
16
|
+
* - 鳴き手・ロン (30符): 7700点 (※切り上げ満貫なしの場合)
|
|
17
|
+
*
|
|
18
|
+
* そのため、4翻以下の場合は計算による基本点が基準値(2000)を超えたかどうかで判定します。
|
|
19
|
+
*/
|
|
20
|
+
export declare const HAN_MANGAN = 5;
|
|
21
|
+
export declare const HAN_HANEMAN = 6;
|
|
22
|
+
export declare const HAN_BAIMAN = 8;
|
|
23
|
+
export declare const HAN_SANBAIMAN = 11;
|
|
24
|
+
export declare const HAN_YAKUMAN = 13;
|
|
25
|
+
export declare const HAS_YAKUMAN = 13;
|
|
26
|
+
export declare const HAS_DOUBLE_YAKUMAN = 26;
|
|
27
|
+
export declare const BASE_SCORE_LIMIT = 2000;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const SCORE_OYA_MULTIPLIER = 1.5; // 親は1.5倍
|
|
2
|
+
export const SCORE_BASE_MANGAN = 2000;
|
|
3
|
+
export const SCORE_BASE_HANEMAN = 3000;
|
|
4
|
+
export const SCORE_BASE_BAIMAN = 4000;
|
|
5
|
+
export const SCORE_BASE_SANBAIMAN = 6000;
|
|
6
|
+
export const SCORE_BASE_YAKUMAN = 8000;
|
|
7
|
+
/**
|
|
8
|
+
* 満貫以上の判定基準となる翻数
|
|
9
|
+
*
|
|
10
|
+
* "5翻" であれば符数に関わらず満貫以上が確定するため 5 を設定しています。
|
|
11
|
+
*
|
|
12
|
+
* Q: 4翻は満貫ではないのか?
|
|
13
|
+
* A: 4翻でも符数が高ければ(40符以上など)満貫になりますが、以下のようなケースでは満貫(8000点)に届きません。
|
|
14
|
+
* - 七対子 (25符): 6400点
|
|
15
|
+
* - 門前ツモ・愚形など (30符): 7900点 (※切り上げ満貫なしの場合)
|
|
16
|
+
* - 鳴き手・ロン (30符): 7700点 (※切り上げ満貫なしの場合)
|
|
17
|
+
*
|
|
18
|
+
* そのため、4翻以下の場合は計算による基本点が基準値(2000)を超えたかどうかで判定します。
|
|
19
|
+
*/
|
|
20
|
+
export const HAN_MANGAN = 5;
|
|
21
|
+
export const HAN_HANEMAN = 6;
|
|
22
|
+
export const HAN_BAIMAN = 8;
|
|
23
|
+
export const HAN_SANBAIMAN = 11;
|
|
24
|
+
export const HAN_YAKUMAN = 13;
|
|
25
|
+
export const HAS_YAKUMAN = 13; // 便宜上の翻数
|
|
26
|
+
export const HAS_DOUBLE_YAKUMAN = 26;
|
|
27
|
+
// 切り上げ満貫の閾値 (30符4翻 = 1920 -> 2000? 60符3翻=1920)
|
|
28
|
+
// 一般的には 2000点(子) / 3000点(親) が満貫の最低点(ベース)
|
|
29
|
+
// 符計算による基本点が 2000 を超えたら満貫
|
|
30
|
+
export const BASE_SCORE_LIMIT = 2000;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Tehai14, type Fu } from "../../types";
|
|
2
|
+
import type { FuResult } from "./lib/fu/types";
|
|
3
|
+
import { ScoreLevel, type ScoreCalculationConfig, type ScoreContext, type ScoreResult, type Payment, type Ron, type KoTsumo, type OyaTsumo } from "./types";
|
|
4
|
+
export type { ScoreCalculationConfig, ScoreResult, Payment, Ron, KoTsumo, OyaTsumo, };
|
|
5
|
+
export { ScoreLevel };
|
|
6
|
+
/**
|
|
7
|
+
* 基本点を計算する
|
|
8
|
+
* 基本点 = 符 × 2^(2+翻)
|
|
9
|
+
*
|
|
10
|
+
* 基本点は子がツモ和了したときの他の子1人あたりの支払い点数に相当する。
|
|
11
|
+
* - 子ツモ: 子の支払い = 基本点, 親の支払い = 基本点 × 2
|
|
12
|
+
* - 子ロン: 支払い = 基本点 × 4
|
|
13
|
+
* - 親ツモ: 各子の支払い = 基本点 × 2
|
|
14
|
+
* - 親ロン: 支払い = 基本点 × 6
|
|
15
|
+
*/
|
|
16
|
+
export declare function calculateBasePoints(fu: Fu, han: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* 支払い情報から和了者が受け取る総点数を計算する
|
|
19
|
+
*/
|
|
20
|
+
export declare function getPaymentTotal(payment: Readonly<Payment>): number;
|
|
21
|
+
/**
|
|
22
|
+
* 翻数と基本点から点数レベルを判定する
|
|
23
|
+
*
|
|
24
|
+
* @param han 翻数
|
|
25
|
+
* @param basePoints 基本点(符 × 2^(2+翻))
|
|
26
|
+
* @returns 点数レベル
|
|
27
|
+
*/
|
|
28
|
+
export declare function getScoreLevel(han: number, basePoints: number): ScoreLevel;
|
|
29
|
+
/**
|
|
30
|
+
* 手牌とコンテキストから点数を計算する(公開API)
|
|
31
|
+
*
|
|
32
|
+
* 手牌の構造解析を行い、最も高点となる解釈を採用して点数を返します。
|
|
33
|
+
*
|
|
34
|
+
* 注: 同一手牌で「翻数が高いが符が低い解釈」と「翻数が低いが符が高い解釈」が
|
|
35
|
+
* 両立するケースは実質的に存在しないため、翻数最大の解釈を採用しています。
|
|
36
|
+
*
|
|
37
|
+
* @param tehai 手牌 (14枚)
|
|
38
|
+
* @param config 点数計算の設定 (場風、自風、ドラなど)
|
|
39
|
+
* @returns 点数計算結果
|
|
40
|
+
*/
|
|
41
|
+
export declare function calculateScoreForTehai(tehai: Tehai14, config: Readonly<ScoreCalculationConfig>): ScoreResult;
|
|
42
|
+
/**
|
|
43
|
+
* 基本的な点数計算ロジック (内部用・テスト用)
|
|
44
|
+
*/
|
|
45
|
+
export declare function calculateScoreFromHanAndFu(yakuHansu: number, fuResult: Readonly<FuResult>, dora: number, context: Readonly<ScoreContext>): ScoreResult;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { HaiKind } from "../../types";
|
|
2
|
+
import { NoYakuError } from "../../errors";
|
|
3
|
+
import { countDora } from "../../core/dora";
|
|
4
|
+
import { getHouraStructures } from "../yaku/lib/structures";
|
|
5
|
+
import { detectYakuForStructure } from "../yaku";
|
|
6
|
+
import { calculateFu } from "./lib/fu";
|
|
7
|
+
import { isMenzen } from "../yaku/utils";
|
|
8
|
+
import { BASE_SCORE_LIMIT, HAN_BAIMAN, HAN_HANEMAN, HAN_MANGAN, HAN_SANBAIMAN, HAN_YAKUMAN, SCORE_BASE_BAIMAN, SCORE_BASE_HANEMAN, SCORE_BASE_MANGAN, SCORE_BASE_SANBAIMAN, SCORE_BASE_YAKUMAN, } from "./constants";
|
|
9
|
+
import { ScoreLevel, } from "./types";
|
|
10
|
+
export { ScoreLevel };
|
|
11
|
+
/**
|
|
12
|
+
* 100点単位で切り上げる
|
|
13
|
+
*/
|
|
14
|
+
function ceil100(points) {
|
|
15
|
+
return Math.ceil(points / 100) * 100;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 基本点を計算する
|
|
19
|
+
* 基本点 = 符 × 2^(2+翻)
|
|
20
|
+
*
|
|
21
|
+
* 基本点は子がツモ和了したときの他の子1人あたりの支払い点数に相当する。
|
|
22
|
+
* - 子ツモ: 子の支払い = 基本点, 親の支払い = 基本点 × 2
|
|
23
|
+
* - 子ロン: 支払い = 基本点 × 4
|
|
24
|
+
* - 親ツモ: 各子の支払い = 基本点 × 2
|
|
25
|
+
* - 親ロン: 支払い = 基本点 × 6
|
|
26
|
+
*/
|
|
27
|
+
export function calculateBasePoints(fu, han) {
|
|
28
|
+
return fu * Math.pow(2, 2 + han);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 支払い情報から和了者が受け取る総点数を計算する
|
|
32
|
+
*/
|
|
33
|
+
export function getPaymentTotal(payment) {
|
|
34
|
+
switch (payment.type) {
|
|
35
|
+
case "ron":
|
|
36
|
+
return payment.amount;
|
|
37
|
+
case "koTsumo":
|
|
38
|
+
return payment.amount[0] * 2 + payment.amount[1];
|
|
39
|
+
case "oyaTsumo":
|
|
40
|
+
return payment.amount * 3;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 翻数と基本点から点数レベルを判定する
|
|
45
|
+
*
|
|
46
|
+
* @param han 翻数
|
|
47
|
+
* @param basePoints 基本点(符 × 2^(2+翻))
|
|
48
|
+
* @returns 点数レベル
|
|
49
|
+
*/
|
|
50
|
+
export function getScoreLevel(han, basePoints) {
|
|
51
|
+
if (han >= 26) {
|
|
52
|
+
return ScoreLevel.DoubleYakuman;
|
|
53
|
+
}
|
|
54
|
+
if (han >= HAN_YAKUMAN) {
|
|
55
|
+
return ScoreLevel.Yakuman;
|
|
56
|
+
}
|
|
57
|
+
if (han >= HAN_SANBAIMAN) {
|
|
58
|
+
return ScoreLevel.Sanbaiman;
|
|
59
|
+
}
|
|
60
|
+
if (han >= HAN_BAIMAN) {
|
|
61
|
+
return ScoreLevel.Baiman;
|
|
62
|
+
}
|
|
63
|
+
if (han >= HAN_HANEMAN) {
|
|
64
|
+
return ScoreLevel.Haneman;
|
|
65
|
+
}
|
|
66
|
+
if (han >= HAN_MANGAN || basePoints >= BASE_SCORE_LIMIT) {
|
|
67
|
+
return ScoreLevel.Mangan;
|
|
68
|
+
}
|
|
69
|
+
return ScoreLevel.Normal;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 点数レベルに対応する基本点を取得する
|
|
73
|
+
*
|
|
74
|
+
* @param level 点数レベル
|
|
75
|
+
* @returns 基本点(Normal の場合は null)
|
|
76
|
+
*/
|
|
77
|
+
function getLimitBasePoints(level) {
|
|
78
|
+
switch (level) {
|
|
79
|
+
case ScoreLevel.DoubleYakuman:
|
|
80
|
+
return SCORE_BASE_YAKUMAN * 2;
|
|
81
|
+
case ScoreLevel.Yakuman:
|
|
82
|
+
return SCORE_BASE_YAKUMAN;
|
|
83
|
+
case ScoreLevel.Sanbaiman:
|
|
84
|
+
return SCORE_BASE_SANBAIMAN;
|
|
85
|
+
case ScoreLevel.Baiman:
|
|
86
|
+
return SCORE_BASE_BAIMAN;
|
|
87
|
+
case ScoreLevel.Haneman:
|
|
88
|
+
return SCORE_BASE_HANEMAN;
|
|
89
|
+
case ScoreLevel.Mangan:
|
|
90
|
+
return SCORE_BASE_MANGAN;
|
|
91
|
+
case ScoreLevel.Normal:
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 点数計算用コンテキストを作成する
|
|
97
|
+
*
|
|
98
|
+
* @param tehai 手牌 (14枚)
|
|
99
|
+
* @param config 点数計算の設定
|
|
100
|
+
* @returns 点数計算用コンテキスト
|
|
101
|
+
*/
|
|
102
|
+
function createScoreContext(tehai, config) {
|
|
103
|
+
return {
|
|
104
|
+
isMenzen: isMenzen(tehai),
|
|
105
|
+
agariHai: config.agariHai,
|
|
106
|
+
bakaze: config.bakaze,
|
|
107
|
+
jikaze: config.jikaze,
|
|
108
|
+
isTsumo: config.isTsumo,
|
|
109
|
+
isOya: config.jikaze === HaiKind.Ton,
|
|
110
|
+
doraMarkers: config.doraMarkers,
|
|
111
|
+
...(config.uraDoraMarkers ? { uraDoraMarkers: config.uraDoraMarkers } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 手牌とコンテキストから点数を計算する(公開API)
|
|
116
|
+
*
|
|
117
|
+
* 手牌の構造解析を行い、最も高点となる解釈を採用して点数を返します。
|
|
118
|
+
*
|
|
119
|
+
* 注: 同一手牌で「翻数が高いが符が低い解釈」と「翻数が低いが符が高い解釈」が
|
|
120
|
+
* 両立するケースは実質的に存在しないため、翻数最大の解釈を採用しています。
|
|
121
|
+
*
|
|
122
|
+
* @param tehai 手牌 (14枚)
|
|
123
|
+
* @param config 点数計算の設定 (場風、自風、ドラなど)
|
|
124
|
+
* @returns 点数計算結果
|
|
125
|
+
*/
|
|
126
|
+
export function calculateScoreForTehai(tehai, config) {
|
|
127
|
+
const context = createScoreContext(tehai, config);
|
|
128
|
+
const structuralInterpretations = getHouraStructures(tehai);
|
|
129
|
+
let bestResult = null;
|
|
130
|
+
let maxTotalPoints = -1;
|
|
131
|
+
for (const hand of structuralInterpretations) {
|
|
132
|
+
// 1. 役の判定
|
|
133
|
+
const yakuResult = detectYakuForStructure(hand, context);
|
|
134
|
+
const yakuHansu = yakuResult.reduce((sum, [, han]) => sum + han, 0);
|
|
135
|
+
// 役がない場合はこの構造は不成立
|
|
136
|
+
if (yakuHansu === 0)
|
|
137
|
+
continue;
|
|
138
|
+
// 2. 符の計算
|
|
139
|
+
const isPinfu = yakuResult.some(([name]) => name === "Pinfu");
|
|
140
|
+
const fuResult = calculateFu(hand, context, isPinfu);
|
|
141
|
+
// 3. ドラの計算
|
|
142
|
+
const dora = countDora(tehai, context.doraMarkers);
|
|
143
|
+
// 4. 点数計算
|
|
144
|
+
const result = calculateScoreFromHanAndFu(yakuHansu, fuResult, dora, context);
|
|
145
|
+
const total = getPaymentTotal(result.payment);
|
|
146
|
+
if (total > maxTotalPoints) {
|
|
147
|
+
maxTotalPoints = total;
|
|
148
|
+
bestResult = result;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!bestResult) {
|
|
152
|
+
throw new NoYakuError();
|
|
153
|
+
}
|
|
154
|
+
return bestResult;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 基本的な点数計算ロジック (内部用・テスト用)
|
|
158
|
+
*/
|
|
159
|
+
export function calculateScoreFromHanAndFu(yakuHansu, fuResult, dora, context) {
|
|
160
|
+
const totalHan = yakuHansu + dora;
|
|
161
|
+
const fu = fuResult.total;
|
|
162
|
+
// 基本点の計算
|
|
163
|
+
const rawBasePoints = calculateBasePoints(fu, totalHan);
|
|
164
|
+
// 点数レベルの判定
|
|
165
|
+
const scoreLevel = getScoreLevel(totalHan, rawBasePoints);
|
|
166
|
+
// 満貫以上なら固定の基本点、それ以外は計算値を使用
|
|
167
|
+
const basePoints = getLimitBasePoints(scoreLevel) ?? rawBasePoints;
|
|
168
|
+
// 支払い計算
|
|
169
|
+
const payment = calculatePayment(basePoints, context);
|
|
170
|
+
return {
|
|
171
|
+
han: totalHan,
|
|
172
|
+
fu: fu,
|
|
173
|
+
scoreLevel,
|
|
174
|
+
payment,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 基本点から支払い情報を計算する
|
|
179
|
+
*/
|
|
180
|
+
function calculatePayment(basePoints, context) {
|
|
181
|
+
if (context.isTsumo) {
|
|
182
|
+
if (context.isOya) {
|
|
183
|
+
// 親ツモ: オール (基本点 * 2)
|
|
184
|
+
const allPay = ceil100(basePoints * 2);
|
|
185
|
+
return { type: "oyaTsumo", amount: allPay };
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// 子ツモ: 親の支払い = 基本点 * 2, 子の支払い = 基本点 * 1
|
|
189
|
+
const parentPay = ceil100(basePoints * 2);
|
|
190
|
+
const childPay = ceil100(basePoints * 1);
|
|
191
|
+
return { type: "koTsumo", amount: [childPay, parentPay] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// ロン和了
|
|
196
|
+
if (context.isOya) {
|
|
197
|
+
// 親ロン: 基本点 * 6
|
|
198
|
+
const pay = ceil100(basePoints * 6);
|
|
199
|
+
return { type: "ron", amount: pay };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// 子ロン: 基本点 * 4
|
|
203
|
+
const pay = ceil100(basePoints * 4);
|
|
204
|
+
return { type: "ron", amount: pay };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|