fraq-plugin-doudizhu 0.1.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 +7 -0
- package/dist/index.d.mts +143 -0
- package/dist/index.mjs +844 -0
- package/image/Background.png +0 -0
- package/image/Club10.png +0 -0
- package/image/Club2.png +0 -0
- package/image/Club3.png +0 -0
- package/image/Club4.png +0 -0
- package/image/Club5.png +0 -0
- package/image/Club6.png +0 -0
- package/image/Club7.png +0 -0
- package/image/Club8.png +0 -0
- package/image/Club9.png +0 -0
- package/image/ClubA.png +0 -0
- package/image/ClubJ.png +0 -0
- package/image/ClubK.png +0 -0
- package/image/ClubQ.png +0 -0
- package/image/Diamond10.png +0 -0
- package/image/Diamond2.png +0 -0
- package/image/Diamond3.png +0 -0
- package/image/Diamond4.png +0 -0
- package/image/Diamond5.png +0 -0
- package/image/Diamond6.png +0 -0
- package/image/Diamond7.png +0 -0
- package/image/Diamond8.png +0 -0
- package/image/Diamond9.png +0 -0
- package/image/DiamondA.png +0 -0
- package/image/DiamondJ.png +0 -0
- package/image/DiamondK.png +0 -0
- package/image/DiamondQ.png +0 -0
- package/image/Heart10.png +0 -0
- package/image/Heart2.png +0 -0
- package/image/Heart3.png +0 -0
- package/image/Heart4.png +0 -0
- package/image/Heart5.png +0 -0
- package/image/Heart6.png +0 -0
- package/image/Heart7.png +0 -0
- package/image/Heart8.png +0 -0
- package/image/Heart9.png +0 -0
- package/image/HeartA.png +0 -0
- package/image/HeartJ.png +0 -0
- package/image/HeartK.png +0 -0
- package/image/HeartQ.png +0 -0
- package/image/JOKER-A.png +0 -0
- package/image/JOKER-B.png +0 -0
- package/image/Spade10.png +0 -0
- package/image/Spade2.png +0 -0
- package/image/Spade3.png +0 -0
- package/image/Spade4.png +0 -0
- package/image/Spade5.png +0 -0
- package/image/Spade6.png +0 -0
- package/image/Spade7.png +0 -0
- package/image/Spade8.png +0 -0
- package/image/Spade9.png +0 -0
- package/image/SpadeA.png +0 -0
- package/image/SpadeJ.png +0 -0
- package/image/SpadeK.png +0 -0
- package/image/SpadeQ.png +0 -0
- package/package.json +41 -0
package/README.md
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//#region src/cards.d.ts
|
|
2
|
+
type Suit = 'Club' | 'Diamond' | 'Heart' | 'Spade' | 'Joker';
|
|
3
|
+
type Rank = '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K' | 'A' | '2' | 'SJ' | 'BJ';
|
|
4
|
+
interface Card {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly suit: Suit;
|
|
7
|
+
readonly rank: Rank;
|
|
8
|
+
readonly value: number;
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly imageName: string;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/rules.d.ts
|
|
14
|
+
type PlayKind = 'single' | 'pair' | 'triple' | 'triple-single' | 'triple-pair' | 'straight' | 'pair-straight' | 'airplane' | 'airplane-single' | 'airplane-pair' | 'bomb' | 'rocket';
|
|
15
|
+
interface Play {
|
|
16
|
+
readonly cards: Card[];
|
|
17
|
+
readonly kind: PlayKind;
|
|
18
|
+
readonly mainValue: number;
|
|
19
|
+
readonly length: number;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/game.d.ts
|
|
23
|
+
type GamePhase = 'bidding' | 'doubling' | 'playing' | 'finished';
|
|
24
|
+
type PlayerKind = 'human' | 'bot';
|
|
25
|
+
interface Player {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly kind: PlayerKind;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
hand: Card[];
|
|
30
|
+
bid: number;
|
|
31
|
+
bidChoice?: 0 | 1 | 2 | 3;
|
|
32
|
+
multiplier: number;
|
|
33
|
+
doubleChoice?: 0 | 1 | 2;
|
|
34
|
+
}
|
|
35
|
+
interface GameSnapshot {
|
|
36
|
+
readonly phase: GamePhase;
|
|
37
|
+
readonly currentPlayer: number;
|
|
38
|
+
readonly landlordIndex?: number;
|
|
39
|
+
readonly highestBid: number;
|
|
40
|
+
readonly multiplier: number;
|
|
41
|
+
readonly players: readonly Player[];
|
|
42
|
+
readonly bottom: readonly Card[];
|
|
43
|
+
readonly previousPlay?: Play;
|
|
44
|
+
readonly lastPlayer?: number;
|
|
45
|
+
}
|
|
46
|
+
type GameAction = {
|
|
47
|
+
type: 'bid';
|
|
48
|
+
level: 0 | 1 | 2 | 3;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'double';
|
|
51
|
+
level: 0 | 1 | 2;
|
|
52
|
+
} | {
|
|
53
|
+
type: 'play';
|
|
54
|
+
cards: string;
|
|
55
|
+
} | {
|
|
56
|
+
type: 'pass';
|
|
57
|
+
};
|
|
58
|
+
type GameEvent = {
|
|
59
|
+
type: 'message';
|
|
60
|
+
text: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'hand';
|
|
63
|
+
playerId: string;
|
|
64
|
+
cards: Card[];
|
|
65
|
+
text: string;
|
|
66
|
+
} | {
|
|
67
|
+
type: 'played';
|
|
68
|
+
playerId: string;
|
|
69
|
+
cards: Card[];
|
|
70
|
+
text: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: 'turn';
|
|
73
|
+
playerId: string;
|
|
74
|
+
text: string;
|
|
75
|
+
} | {
|
|
76
|
+
type: 'finished';
|
|
77
|
+
winnerId: string;
|
|
78
|
+
text: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: 'expired';
|
|
81
|
+
text: string;
|
|
82
|
+
};
|
|
83
|
+
interface GameOptions {
|
|
84
|
+
readonly timeoutMs: number;
|
|
85
|
+
readonly random?: () => number;
|
|
86
|
+
readonly onExpired?: () => void | Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
declare class DoudizhuGame {
|
|
89
|
+
readonly players: [Player, Player, Player];
|
|
90
|
+
readonly bottom: Card[];
|
|
91
|
+
phase: GamePhase;
|
|
92
|
+
currentPlayer: number;
|
|
93
|
+
landlordIndex?: number;
|
|
94
|
+
highestBid: number;
|
|
95
|
+
multiplier: number;
|
|
96
|
+
previousPlay?: Play;
|
|
97
|
+
lastPlayer?: number;
|
|
98
|
+
private passCount;
|
|
99
|
+
private bidRound;
|
|
100
|
+
private timeoutHandle?;
|
|
101
|
+
private readonly options;
|
|
102
|
+
constructor(humanId: string, humanName: string, options: GameOptions);
|
|
103
|
+
get snapshot(): GameSnapshot;
|
|
104
|
+
dispose(): void;
|
|
105
|
+
begin(): Promise<GameEvent[]>;
|
|
106
|
+
act(playerId: string, action: GameAction): Promise<GameEvent[]>;
|
|
107
|
+
private applyBid;
|
|
108
|
+
private finishBidding;
|
|
109
|
+
private applyDouble;
|
|
110
|
+
private applyPass;
|
|
111
|
+
private applyPlay;
|
|
112
|
+
private advanceBots;
|
|
113
|
+
private resetTimeout;
|
|
114
|
+
private clearTimeout;
|
|
115
|
+
}
|
|
116
|
+
interface Room {
|
|
117
|
+
readonly id: number;
|
|
118
|
+
readonly humanId: string;
|
|
119
|
+
readonly game: DoudizhuGame;
|
|
120
|
+
}
|
|
121
|
+
declare class RoomManager {
|
|
122
|
+
private readonly maxRooms;
|
|
123
|
+
private readonly gameOptions;
|
|
124
|
+
private readonly onExpired?;
|
|
125
|
+
private readonly rooms;
|
|
126
|
+
private nextId;
|
|
127
|
+
constructor(maxRooms: number, gameOptions: GameOptions, onExpired?: ((room: Room) => void | Promise<void>) | undefined);
|
|
128
|
+
create(humanId: string, humanName: string): Room;
|
|
129
|
+
getByHuman(humanId: string): Room | undefined;
|
|
130
|
+
get(roomId: number): Room | undefined;
|
|
131
|
+
remove(roomId: number): void;
|
|
132
|
+
list(): readonly Room[];
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/index.d.ts
|
|
136
|
+
interface DoudizhuOptions {
|
|
137
|
+
readonly maxRooms?: number;
|
|
138
|
+
readonly turnTimeoutMs?: number;
|
|
139
|
+
readonly imageDirectory?: string;
|
|
140
|
+
}
|
|
141
|
+
declare const _default: import("@fraqjs/fraq").PluginDefinition<[options?: DoudizhuOptions | undefined]>;
|
|
142
|
+
//#endregion
|
|
143
|
+
export { DoudizhuOptions, RoomManager, _default as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { definePlugin, param, seg } from "@fraqjs/fraq";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/card-image.ts
|
|
6
|
+
const CARD_WIDTH = 120;
|
|
7
|
+
const CARD_HEIGHT = Math.round(1044 / 750 * CARD_WIDTH);
|
|
8
|
+
const CARD_OVERLAP = 50;
|
|
9
|
+
var CardImageRenderer = class {
|
|
10
|
+
cache = /* @__PURE__ */ new Map();
|
|
11
|
+
imageDirectory;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.imageDirectory = path.resolve(options.imageDirectory ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../image"));
|
|
14
|
+
}
|
|
15
|
+
async render(cards) {
|
|
16
|
+
if (cards.length === 0) throw new Error("不能拼接空牌面。");
|
|
17
|
+
const key = cards.map((card) => card.id).join("|");
|
|
18
|
+
const cached = this.cache.get(key);
|
|
19
|
+
if (cached) return cached;
|
|
20
|
+
const width = CARD_WIDTH + (cards.length - 1) * CARD_OVERLAP + 16;
|
|
21
|
+
const height = 183;
|
|
22
|
+
const composites = await Promise.all(cards.map(async (card, index) => ({
|
|
23
|
+
input: await sharp(path.join(this.imageDirectory, card.imageName)).resize(CARD_WIDTH, CARD_HEIGHT, { fit: "fill" }).png().toBuffer(),
|
|
24
|
+
left: 8 + index * CARD_OVERLAP,
|
|
25
|
+
top: 8
|
|
26
|
+
})));
|
|
27
|
+
const uri = `base64://${(await sharp({ create: {
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
channels: 4,
|
|
31
|
+
background: {
|
|
32
|
+
r: 0,
|
|
33
|
+
g: 0,
|
|
34
|
+
b: 0,
|
|
35
|
+
alpha: 0
|
|
36
|
+
}
|
|
37
|
+
} }).composite(composites).png().toBuffer()).toString("base64")}`;
|
|
38
|
+
this.cache.set(key, uri);
|
|
39
|
+
return uri;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/cards.ts
|
|
44
|
+
const SUITS = [
|
|
45
|
+
"Club",
|
|
46
|
+
"Diamond",
|
|
47
|
+
"Heart",
|
|
48
|
+
"Spade"
|
|
49
|
+
];
|
|
50
|
+
const RANKS = [
|
|
51
|
+
"3",
|
|
52
|
+
"4",
|
|
53
|
+
"5",
|
|
54
|
+
"6",
|
|
55
|
+
"7",
|
|
56
|
+
"8",
|
|
57
|
+
"9",
|
|
58
|
+
"10",
|
|
59
|
+
"J",
|
|
60
|
+
"Q",
|
|
61
|
+
"K",
|
|
62
|
+
"A",
|
|
63
|
+
"2"
|
|
64
|
+
];
|
|
65
|
+
const RANK_VALUES = {
|
|
66
|
+
"3": 3,
|
|
67
|
+
"4": 4,
|
|
68
|
+
"5": 5,
|
|
69
|
+
"6": 6,
|
|
70
|
+
"7": 7,
|
|
71
|
+
"8": 8,
|
|
72
|
+
"9": 9,
|
|
73
|
+
"10": 10,
|
|
74
|
+
J: 11,
|
|
75
|
+
Q: 12,
|
|
76
|
+
K: 13,
|
|
77
|
+
A: 14,
|
|
78
|
+
"2": 15,
|
|
79
|
+
SJ: 16,
|
|
80
|
+
BJ: 17
|
|
81
|
+
};
|
|
82
|
+
function createDeck() {
|
|
83
|
+
const cards = [];
|
|
84
|
+
for (const suit of SUITS) for (const rank of RANKS) cards.push({
|
|
85
|
+
id: `${suit}-${rank}`,
|
|
86
|
+
suit,
|
|
87
|
+
rank,
|
|
88
|
+
value: RANK_VALUES[rank],
|
|
89
|
+
label: rank,
|
|
90
|
+
imageName: `${suit}${rank}.png`
|
|
91
|
+
});
|
|
92
|
+
cards.push({
|
|
93
|
+
id: "Joker-SJ",
|
|
94
|
+
suit: "Joker",
|
|
95
|
+
rank: "SJ",
|
|
96
|
+
value: RANK_VALUES.SJ,
|
|
97
|
+
label: "小王",
|
|
98
|
+
imageName: "JOKER-A.png"
|
|
99
|
+
}, {
|
|
100
|
+
id: "Joker-BJ",
|
|
101
|
+
suit: "Joker",
|
|
102
|
+
rank: "BJ",
|
|
103
|
+
value: RANK_VALUES.BJ,
|
|
104
|
+
label: "大王",
|
|
105
|
+
imageName: "JOKER-B.png"
|
|
106
|
+
});
|
|
107
|
+
return cards;
|
|
108
|
+
}
|
|
109
|
+
function sortCards(cards) {
|
|
110
|
+
return [...cards].sort((left, right) => left.value - right.value || left.id.localeCompare(right.id));
|
|
111
|
+
}
|
|
112
|
+
function shuffle(items, random = Math.random) {
|
|
113
|
+
const result = [...items];
|
|
114
|
+
for (let index = result.length - 1; index > 0; index -= 1) {
|
|
115
|
+
const swapIndex = Math.floor(random() * (index + 1));
|
|
116
|
+
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function dealDeck(random = Math.random) {
|
|
121
|
+
const deck = shuffle(createDeck(), random);
|
|
122
|
+
return {
|
|
123
|
+
hands: [
|
|
124
|
+
sortCards(deck.slice(0, 17)),
|
|
125
|
+
sortCards(deck.slice(17, 34)),
|
|
126
|
+
sortCards(deck.slice(34, 51))
|
|
127
|
+
],
|
|
128
|
+
bottom: sortCards(deck.slice(51))
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const TOKEN_ALIASES = {
|
|
132
|
+
"小王": "SJ",
|
|
133
|
+
SJ: "SJ",
|
|
134
|
+
"大王": "BJ",
|
|
135
|
+
BJ: "BJ",
|
|
136
|
+
3: "3",
|
|
137
|
+
4: "4",
|
|
138
|
+
5: "5",
|
|
139
|
+
6: "6",
|
|
140
|
+
7: "7",
|
|
141
|
+
8: "8",
|
|
142
|
+
9: "9",
|
|
143
|
+
10: "10",
|
|
144
|
+
J: "J",
|
|
145
|
+
j: "J",
|
|
146
|
+
Q: "Q",
|
|
147
|
+
q: "Q",
|
|
148
|
+
K: "K",
|
|
149
|
+
k: "K",
|
|
150
|
+
A: "A",
|
|
151
|
+
a: "A",
|
|
152
|
+
2: "2"
|
|
153
|
+
};
|
|
154
|
+
function tokenizeCards(input) {
|
|
155
|
+
return input.replace(/[,,、]+/g, " ").trim().split(/\s+/u).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
function takeCardsFromHand(hand, input) {
|
|
158
|
+
const selected = [];
|
|
159
|
+
const remaining = [...hand];
|
|
160
|
+
for (const token of tokenizeCards(input)) {
|
|
161
|
+
const rank = TOKEN_ALIASES[token];
|
|
162
|
+
if (!rank) throw new Error(`无法识别牌面「${token}」,请使用 3-10、J、Q、K、A、2、小王或大王。`);
|
|
163
|
+
const index = remaining.findIndex((card) => card.rank === rank);
|
|
164
|
+
if (index === -1) throw new Error(`你的手牌中没有可出的「${token}」。`);
|
|
165
|
+
const [card] = remaining.splice(index, 1);
|
|
166
|
+
selected.push(card);
|
|
167
|
+
}
|
|
168
|
+
if (selected.length === 0) throw new Error("请在「出牌」后填写至少一张牌。");
|
|
169
|
+
return selected;
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/rules.ts
|
|
173
|
+
function groups(cards) {
|
|
174
|
+
const map = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const card of cards) {
|
|
176
|
+
const group = map.get(card.value) ?? [];
|
|
177
|
+
group.push(card);
|
|
178
|
+
map.set(card.value, group);
|
|
179
|
+
}
|
|
180
|
+
return [...map.entries()].sort(([left], [right]) => left - right).map(([value, groupedCards]) => ({
|
|
181
|
+
value,
|
|
182
|
+
cards: groupedCards
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
function isConsecutive(values, minimumLength) {
|
|
186
|
+
return values.length >= minimumLength && values.every((value, index) => index === 0 || value === values[index - 1] + 1 && value < 15);
|
|
187
|
+
}
|
|
188
|
+
function findTripleSequence(grouped) {
|
|
189
|
+
const triples = grouped.filter((group) => group.cards.length >= 3 && group.value < 15).map((group) => group.value);
|
|
190
|
+
if (triples.length < 2 || !isConsecutive(triples, 2)) return;
|
|
191
|
+
return triples;
|
|
192
|
+
}
|
|
193
|
+
function analyzePlay(cards) {
|
|
194
|
+
if (cards.length === 0) return void 0;
|
|
195
|
+
const sorted = sortCards(cards);
|
|
196
|
+
const grouped = groups(sorted);
|
|
197
|
+
const counts = grouped.map((group) => group.cards.length).sort((left, right) => left - right);
|
|
198
|
+
const values = grouped.map((group) => group.value);
|
|
199
|
+
if (sorted.length === 2 && values[0] === 16 && values[1] === 17) return {
|
|
200
|
+
cards: sorted,
|
|
201
|
+
kind: "rocket",
|
|
202
|
+
mainValue: 17,
|
|
203
|
+
length: 1
|
|
204
|
+
};
|
|
205
|
+
if (sorted.length === 4 && counts.length === 1) return {
|
|
206
|
+
cards: sorted,
|
|
207
|
+
kind: "bomb",
|
|
208
|
+
mainValue: grouped[0].value,
|
|
209
|
+
length: 1
|
|
210
|
+
};
|
|
211
|
+
if (sorted.length === 1) return {
|
|
212
|
+
cards: sorted,
|
|
213
|
+
kind: "single",
|
|
214
|
+
mainValue: sorted[0].value,
|
|
215
|
+
length: 1
|
|
216
|
+
};
|
|
217
|
+
if (sorted.length === 2 && counts.length === 1) return {
|
|
218
|
+
cards: sorted,
|
|
219
|
+
kind: "pair",
|
|
220
|
+
mainValue: grouped[0].value,
|
|
221
|
+
length: 1
|
|
222
|
+
};
|
|
223
|
+
if (sorted.length === 3 && counts.length === 1) return {
|
|
224
|
+
cards: sorted,
|
|
225
|
+
kind: "triple",
|
|
226
|
+
mainValue: grouped[0].value,
|
|
227
|
+
length: 1
|
|
228
|
+
};
|
|
229
|
+
if (sorted.length === 4 && counts.length === 2 && counts[1] === 3) return {
|
|
230
|
+
cards: sorted,
|
|
231
|
+
kind: "triple-single",
|
|
232
|
+
mainValue: grouped.find((group) => group.cards.length === 3).value,
|
|
233
|
+
length: 1
|
|
234
|
+
};
|
|
235
|
+
if (sorted.length === 5 && counts.length === 2 && counts[0] === 2 && counts[1] === 3) return {
|
|
236
|
+
cards: sorted,
|
|
237
|
+
kind: "triple-pair",
|
|
238
|
+
mainValue: grouped.find((group) => group.cards.length === 3).value,
|
|
239
|
+
length: 1
|
|
240
|
+
};
|
|
241
|
+
if (counts.every((count) => count === 1) && isConsecutive(values, 5)) return {
|
|
242
|
+
cards: sorted,
|
|
243
|
+
kind: "straight",
|
|
244
|
+
mainValue: values.at(-1),
|
|
245
|
+
length: values.length
|
|
246
|
+
};
|
|
247
|
+
if (counts.every((count) => count === 2) && isConsecutive(values, 3)) return {
|
|
248
|
+
cards: sorted,
|
|
249
|
+
kind: "pair-straight",
|
|
250
|
+
mainValue: values.at(-1),
|
|
251
|
+
length: values.length
|
|
252
|
+
};
|
|
253
|
+
const tripleSequence = findTripleSequence(grouped);
|
|
254
|
+
if (tripleSequence && tripleSequence.length * 3 === sorted.length) return {
|
|
255
|
+
cards: sorted,
|
|
256
|
+
kind: "airplane",
|
|
257
|
+
mainValue: tripleSequence.at(-1),
|
|
258
|
+
length: tripleSequence.length
|
|
259
|
+
};
|
|
260
|
+
if (tripleSequence && tripleSequence.length * 4 === sorted.length) {
|
|
261
|
+
if (grouped.filter((group) => !tripleSequence.includes(group.value)).every((group) => group.cards.length === 1)) return {
|
|
262
|
+
cards: sorted,
|
|
263
|
+
kind: "airplane-single",
|
|
264
|
+
mainValue: tripleSequence.at(-1),
|
|
265
|
+
length: tripleSequence.length
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (tripleSequence && tripleSequence.length * 5 === sorted.length) {
|
|
269
|
+
if (grouped.filter((group) => !tripleSequence.includes(group.value)).every((group) => group.cards.length === 2)) return {
|
|
270
|
+
cards: sorted,
|
|
271
|
+
kind: "airplane-pair",
|
|
272
|
+
mainValue: tripleSequence.at(-1),
|
|
273
|
+
length: tripleSequence.length
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function canBeat(candidate, previous) {
|
|
278
|
+
if (!previous) return true;
|
|
279
|
+
if (candidate.kind === "rocket") return true;
|
|
280
|
+
if (previous.kind === "rocket") return false;
|
|
281
|
+
if (candidate.kind === "bomb") return previous.kind !== "bomb" || candidate.mainValue > previous.mainValue;
|
|
282
|
+
if (previous.kind === "bomb") return false;
|
|
283
|
+
return candidate.kind === previous.kind && candidate.length === previous.length && candidate.mainValue > previous.mainValue;
|
|
284
|
+
}
|
|
285
|
+
function chooseCards(groupsByCount, count, value) {
|
|
286
|
+
return groupsByCount.find((group) => group.value === value).cards.slice(0, count);
|
|
287
|
+
}
|
|
288
|
+
function generateBasicPlays(hand) {
|
|
289
|
+
const grouped = groups(hand);
|
|
290
|
+
const plays = [];
|
|
291
|
+
for (const group of grouped) {
|
|
292
|
+
plays.push({
|
|
293
|
+
cards: chooseCards(grouped, 1, group.value),
|
|
294
|
+
kind: "single",
|
|
295
|
+
mainValue: group.value,
|
|
296
|
+
length: 1
|
|
297
|
+
});
|
|
298
|
+
if (group.cards.length >= 2) plays.push({
|
|
299
|
+
cards: chooseCards(grouped, 2, group.value),
|
|
300
|
+
kind: "pair",
|
|
301
|
+
mainValue: group.value,
|
|
302
|
+
length: 1
|
|
303
|
+
});
|
|
304
|
+
if (group.cards.length >= 3) plays.push({
|
|
305
|
+
cards: chooseCards(grouped, 3, group.value),
|
|
306
|
+
kind: "triple",
|
|
307
|
+
mainValue: group.value,
|
|
308
|
+
length: 1
|
|
309
|
+
});
|
|
310
|
+
if (group.cards.length === 4) plays.push({
|
|
311
|
+
cards: group.cards,
|
|
312
|
+
kind: "bomb",
|
|
313
|
+
mainValue: group.value,
|
|
314
|
+
length: 1
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const values = grouped.filter((group) => group.value < 15 && group.cards.length >= 1).map((group) => group.value);
|
|
318
|
+
for (let start = 0; start < values.length; start += 1) for (let end = start + 5; end <= values.length; end += 1) {
|
|
319
|
+
const window = values.slice(start, end);
|
|
320
|
+
if (!isConsecutive(window, 5)) break;
|
|
321
|
+
plays.push({
|
|
322
|
+
cards: window.flatMap((value) => chooseCards(grouped, 1, value)),
|
|
323
|
+
kind: "straight",
|
|
324
|
+
mainValue: window.at(-1),
|
|
325
|
+
length: window.length
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const pairValues = grouped.filter((group) => group.value < 15 && group.cards.length >= 2).map((group) => group.value);
|
|
329
|
+
for (let start = 0; start < pairValues.length; start += 1) for (let end = start + 3; end <= pairValues.length; end += 1) {
|
|
330
|
+
const window = pairValues.slice(start, end);
|
|
331
|
+
if (!isConsecutive(window, 3)) break;
|
|
332
|
+
plays.push({
|
|
333
|
+
cards: window.flatMap((value) => chooseCards(grouped, 2, value)),
|
|
334
|
+
kind: "pair-straight",
|
|
335
|
+
mainValue: window.at(-1),
|
|
336
|
+
length: window.length
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (grouped.some((group) => group.value === 16 && group.cards.length > 0) && grouped.some((group) => group.value === 17 && group.cards.length > 0)) plays.push({
|
|
340
|
+
cards: [...chooseCards(grouped, 1, 16), ...chooseCards(grouped, 1, 17)],
|
|
341
|
+
kind: "rocket",
|
|
342
|
+
mainValue: 17,
|
|
343
|
+
length: 1
|
|
344
|
+
});
|
|
345
|
+
return plays;
|
|
346
|
+
}
|
|
347
|
+
function findAiPlay(hand, previous) {
|
|
348
|
+
const candidates = generateBasicPlays(hand).filter((candidate) => canBeat(candidate, previous)).sort((left, right) => left.cards.length - right.cards.length || left.mainValue - right.mainValue);
|
|
349
|
+
if (candidates.length > 0) return candidates[0];
|
|
350
|
+
if (previous) return void 0;
|
|
351
|
+
return candidates[0];
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/game.ts
|
|
355
|
+
const BOT_NAMES = ["机器人甲", "机器人乙"];
|
|
356
|
+
function nextPlayer(index) {
|
|
357
|
+
return (index + 1) % 3;
|
|
358
|
+
}
|
|
359
|
+
function playerLabel(player) {
|
|
360
|
+
return player.kind === "human" ? "你" : player.name;
|
|
361
|
+
}
|
|
362
|
+
function bidLabel(level) {
|
|
363
|
+
return level === 0 ? "不叫" : `${level}分`;
|
|
364
|
+
}
|
|
365
|
+
function doubleLabel(level) {
|
|
366
|
+
return level === 0 ? "不加倍" : level === 1 ? "加倍" : "超级加倍";
|
|
367
|
+
}
|
|
368
|
+
function botBid(player) {
|
|
369
|
+
const highCards = player.hand.filter((card) => card.value >= 14).length;
|
|
370
|
+
if (highCards >= 4) return 3;
|
|
371
|
+
if (highCards >= 2) return 2;
|
|
372
|
+
if (highCards >= 1) return 1;
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
function botDouble(player) {
|
|
376
|
+
const highCards = player.hand.filter((card) => card.value >= 14).length;
|
|
377
|
+
if (highCards >= 4) return 2;
|
|
378
|
+
if (highCards >= 2) return 1;
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
var DoudizhuGame = class {
|
|
382
|
+
players;
|
|
383
|
+
bottom;
|
|
384
|
+
phase = "bidding";
|
|
385
|
+
currentPlayer = 0;
|
|
386
|
+
landlordIndex;
|
|
387
|
+
highestBid = 0;
|
|
388
|
+
multiplier = 1;
|
|
389
|
+
previousPlay;
|
|
390
|
+
lastPlayer;
|
|
391
|
+
passCount = 0;
|
|
392
|
+
bidRound = 1;
|
|
393
|
+
timeoutHandle;
|
|
394
|
+
options;
|
|
395
|
+
constructor(humanId, humanName, options) {
|
|
396
|
+
this.options = options;
|
|
397
|
+
const dealt = dealDeck(options.random);
|
|
398
|
+
this.bottom = dealt.bottom;
|
|
399
|
+
this.players = [
|
|
400
|
+
{
|
|
401
|
+
id: humanId,
|
|
402
|
+
kind: "human",
|
|
403
|
+
name: humanName || humanId,
|
|
404
|
+
hand: dealt.hands[0],
|
|
405
|
+
bid: 0,
|
|
406
|
+
multiplier: 1
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
id: "bot-1",
|
|
410
|
+
kind: "bot",
|
|
411
|
+
name: BOT_NAMES[0],
|
|
412
|
+
hand: dealt.hands[1],
|
|
413
|
+
bid: 0,
|
|
414
|
+
multiplier: 1
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: "bot-2",
|
|
418
|
+
kind: "bot",
|
|
419
|
+
name: BOT_NAMES[1],
|
|
420
|
+
hand: dealt.hands[2],
|
|
421
|
+
bid: 0,
|
|
422
|
+
multiplier: 1
|
|
423
|
+
}
|
|
424
|
+
];
|
|
425
|
+
this.resetTimeout();
|
|
426
|
+
}
|
|
427
|
+
get snapshot() {
|
|
428
|
+
return {
|
|
429
|
+
phase: this.phase,
|
|
430
|
+
currentPlayer: this.currentPlayer,
|
|
431
|
+
landlordIndex: this.landlordIndex,
|
|
432
|
+
highestBid: this.highestBid,
|
|
433
|
+
multiplier: this.multiplier,
|
|
434
|
+
players: this.players,
|
|
435
|
+
bottom: this.bottom,
|
|
436
|
+
previousPlay: this.previousPlay,
|
|
437
|
+
lastPlayer: this.lastPlayer
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
dispose() {
|
|
441
|
+
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
|
|
442
|
+
this.timeoutHandle = void 0;
|
|
443
|
+
this.phase = "finished";
|
|
444
|
+
}
|
|
445
|
+
async begin() {
|
|
446
|
+
const events = [{
|
|
447
|
+
type: "message",
|
|
448
|
+
text: "斗地主房间已开始,底牌已发完。你可以发送「明牌」查看手牌。"
|
|
449
|
+
}, {
|
|
450
|
+
type: "hand",
|
|
451
|
+
playerId: this.players[0].id,
|
|
452
|
+
cards: this.players[0].hand,
|
|
453
|
+
text: "你的手牌"
|
|
454
|
+
}];
|
|
455
|
+
return this.advanceBots(events);
|
|
456
|
+
}
|
|
457
|
+
async act(playerId, action) {
|
|
458
|
+
if (this.phase === "finished") return [{
|
|
459
|
+
type: "message",
|
|
460
|
+
text: "这局已经结束。"
|
|
461
|
+
}];
|
|
462
|
+
const playerIndex = this.players.findIndex((player) => player.id === playerId);
|
|
463
|
+
if (playerIndex !== this.currentPlayer) return [{
|
|
464
|
+
type: "message",
|
|
465
|
+
text: `现在轮到${playerLabel(this.players[this.currentPlayer])}操作。`
|
|
466
|
+
}];
|
|
467
|
+
if (this.players[playerIndex]?.kind !== "human") return [{
|
|
468
|
+
type: "message",
|
|
469
|
+
text: "人机正在操作,请稍候。"
|
|
470
|
+
}];
|
|
471
|
+
const events = [];
|
|
472
|
+
if (this.phase === "bidding" && action.type === "bid") this.applyBid(playerIndex, action.level, events);
|
|
473
|
+
else if (this.phase === "doubling" && action.type === "double") this.applyDouble(playerIndex, action.level, events);
|
|
474
|
+
else if (this.phase === "playing" && action.type === "pass") this.applyPass(playerIndex, events);
|
|
475
|
+
else if (this.phase === "playing" && action.type === "play") this.applyPlay(playerIndex, action.cards, events);
|
|
476
|
+
else return [{
|
|
477
|
+
type: "message",
|
|
478
|
+
text: "当前阶段不能使用这个指令。"
|
|
479
|
+
}];
|
|
480
|
+
this.resetTimeout();
|
|
481
|
+
return this.advanceBots(events);
|
|
482
|
+
}
|
|
483
|
+
applyBid(playerIndex, level, events) {
|
|
484
|
+
if (level !== 0 && level <= this.highestBid) throw new Error(`叫地主的分数必须高于当前最高分 ${this.highestBid}。`);
|
|
485
|
+
const player = this.players[playerIndex];
|
|
486
|
+
player.bid = level;
|
|
487
|
+
player.bidChoice = level;
|
|
488
|
+
this.highestBid = Math.max(this.highestBid, level);
|
|
489
|
+
events.push({
|
|
490
|
+
type: "message",
|
|
491
|
+
text: `${playerLabel(player)}${bidLabel(level)}。`
|
|
492
|
+
});
|
|
493
|
+
if (level === 3) {
|
|
494
|
+
this.landlordIndex = playerIndex;
|
|
495
|
+
this.finishBidding(events);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (this.players.every((item) => item.bidChoice !== void 0)) {
|
|
499
|
+
const bidder = this.players.reduce((best, item, index) => item.bid > this.players[best].bid ? index : best, 0);
|
|
500
|
+
if (this.highestBid === 0) {
|
|
501
|
+
this.bidRound += 1;
|
|
502
|
+
this.highestBid = 0;
|
|
503
|
+
this.players.forEach((item) => {
|
|
504
|
+
item.bid = 0;
|
|
505
|
+
item.bidChoice = void 0;
|
|
506
|
+
});
|
|
507
|
+
if (this.bidRound > 3) {
|
|
508
|
+
const random = this.options.random ?? Math.random;
|
|
509
|
+
this.landlordIndex = Math.floor(random() * this.players.length);
|
|
510
|
+
events.push({
|
|
511
|
+
type: "message",
|
|
512
|
+
text: "连续三轮无人叫地主,系统已随机选择地主。"
|
|
513
|
+
});
|
|
514
|
+
this.finishBidding(events);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.currentPlayer = 0;
|
|
518
|
+
events.push({
|
|
519
|
+
type: "message",
|
|
520
|
+
text: `第 ${this.bidRound} 轮叫地主开始。`
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.landlordIndex = bidder;
|
|
525
|
+
this.finishBidding(events);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.currentPlayer = nextPlayer(playerIndex);
|
|
529
|
+
}
|
|
530
|
+
finishBidding(events) {
|
|
531
|
+
const landlord = this.players[this.landlordIndex];
|
|
532
|
+
landlord.hand = sortCards([...landlord.hand, ...this.bottom]);
|
|
533
|
+
this.phase = "doubling";
|
|
534
|
+
this.currentPlayer = 0;
|
|
535
|
+
events.push({
|
|
536
|
+
type: "message",
|
|
537
|
+
text: `${playerLabel(landlord)}成为地主,底牌已加入手牌。请依次选择加倍方式。`
|
|
538
|
+
});
|
|
539
|
+
events.push({
|
|
540
|
+
type: "hand",
|
|
541
|
+
playerId: this.players[0].id,
|
|
542
|
+
cards: this.players[0].hand,
|
|
543
|
+
text: "你的地主手牌"
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
applyDouble(playerIndex, level, events) {
|
|
547
|
+
const player = this.players[playerIndex];
|
|
548
|
+
player.doubleChoice = level;
|
|
549
|
+
player.multiplier = level === 0 ? 1 : level === 1 ? 2 : 4;
|
|
550
|
+
this.multiplier *= level === 0 ? 1 : level === 1 ? 2 : 4;
|
|
551
|
+
events.push({
|
|
552
|
+
type: "message",
|
|
553
|
+
text: `${playerLabel(player)}${doubleLabel(level)}。`
|
|
554
|
+
});
|
|
555
|
+
if (this.players.every((item) => item.doubleChoice !== void 0)) {
|
|
556
|
+
this.phase = "playing";
|
|
557
|
+
this.currentPlayer = this.landlordIndex;
|
|
558
|
+
this.previousPlay = void 0;
|
|
559
|
+
this.lastPlayer = void 0;
|
|
560
|
+
this.passCount = 0;
|
|
561
|
+
events.push({
|
|
562
|
+
type: "message",
|
|
563
|
+
text: `加倍阶段结束,${playerLabel(this.players[this.currentPlayer])}先出牌。`
|
|
564
|
+
});
|
|
565
|
+
events.push({
|
|
566
|
+
type: "turn",
|
|
567
|
+
playerId: this.players[this.currentPlayer].id,
|
|
568
|
+
text: "轮到你出牌。"
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
this.currentPlayer = nextPlayer(playerIndex);
|
|
573
|
+
}
|
|
574
|
+
applyPass(playerIndex, events) {
|
|
575
|
+
if (!this.previousPlay) throw new Error("当前没有可不要的牌,请先出牌。");
|
|
576
|
+
const player = this.players[playerIndex];
|
|
577
|
+
this.passCount += 1;
|
|
578
|
+
events.push({
|
|
579
|
+
type: "message",
|
|
580
|
+
text: `${playerLabel(player)}要不起。`
|
|
581
|
+
});
|
|
582
|
+
if (this.passCount >= 2) {
|
|
583
|
+
this.currentPlayer = this.lastPlayer;
|
|
584
|
+
this.previousPlay = void 0;
|
|
585
|
+
this.passCount = 0;
|
|
586
|
+
events.push({
|
|
587
|
+
type: "message",
|
|
588
|
+
text: `两家都不要,${playerLabel(this.players[this.currentPlayer])}重新取得出牌权。`
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.currentPlayer = nextPlayer(playerIndex);
|
|
593
|
+
}
|
|
594
|
+
applyPlay(playerIndex, input, events) {
|
|
595
|
+
const player = this.players[playerIndex];
|
|
596
|
+
const cards = takeCardsFromHand(player.hand, input);
|
|
597
|
+
const play = analyzePlay(cards);
|
|
598
|
+
if (!play) throw new Error("这组牌不符合斗地主牌型。");
|
|
599
|
+
if (!canBeat(play, this.previousPlay)) throw new Error("这组牌压不过上一手牌。");
|
|
600
|
+
const playedIds = new Set(cards.map((card) => card.id));
|
|
601
|
+
player.hand = player.hand.filter((card) => !playedIds.has(card.id));
|
|
602
|
+
this.previousPlay = play;
|
|
603
|
+
this.lastPlayer = playerIndex;
|
|
604
|
+
this.passCount = 0;
|
|
605
|
+
events.push({
|
|
606
|
+
type: "played",
|
|
607
|
+
playerId: player.id,
|
|
608
|
+
cards: play.cards,
|
|
609
|
+
text: `${playerLabel(player)}出了 ${play.cards.length} 张牌。`
|
|
610
|
+
});
|
|
611
|
+
if (player.hand.length === 0) {
|
|
612
|
+
this.phase = "finished";
|
|
613
|
+
this.clearTimeout();
|
|
614
|
+
events.push({
|
|
615
|
+
type: "finished",
|
|
616
|
+
winnerId: player.id,
|
|
617
|
+
text: `${playerLabel(player)}获胜!本局倍率 ${this.multiplier}。`
|
|
618
|
+
});
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
this.currentPlayer = nextPlayer(playerIndex);
|
|
622
|
+
}
|
|
623
|
+
async advanceBots(events) {
|
|
624
|
+
while (this.phase !== "finished" && this.players[this.currentPlayer].kind === "bot") {
|
|
625
|
+
const playerIndex = this.currentPlayer;
|
|
626
|
+
const player = this.players[playerIndex];
|
|
627
|
+
if (this.phase === "bidding") {
|
|
628
|
+
const bid = botBid(player);
|
|
629
|
+
this.applyBid(playerIndex, bid !== 0 && bid <= this.highestBid ? 0 : bid, events);
|
|
630
|
+
} else if (this.phase === "doubling") this.applyDouble(playerIndex, botDouble(player), events);
|
|
631
|
+
else if (this.phase === "playing") {
|
|
632
|
+
const play = findAiPlay(player.hand, this.previousPlay);
|
|
633
|
+
if (play) this.applyPlay(playerIndex, play.cards.map((card) => card.label).join(" "), events);
|
|
634
|
+
else this.applyPass(playerIndex, events);
|
|
635
|
+
}
|
|
636
|
+
this.resetTimeout();
|
|
637
|
+
}
|
|
638
|
+
if (this.phase !== "finished") events.push({
|
|
639
|
+
type: "turn",
|
|
640
|
+
playerId: this.players[this.currentPlayer].id,
|
|
641
|
+
text: `轮到${playerLabel(this.players[this.currentPlayer])}操作。`
|
|
642
|
+
});
|
|
643
|
+
return events;
|
|
644
|
+
}
|
|
645
|
+
resetTimeout() {
|
|
646
|
+
this.clearTimeout();
|
|
647
|
+
if (this.phase === "finished") return;
|
|
648
|
+
this.timeoutHandle = setTimeout(() => {
|
|
649
|
+
if (this.phase === "finished") return;
|
|
650
|
+
this.phase = "finished";
|
|
651
|
+
this.options.onExpired?.();
|
|
652
|
+
}, this.options.timeoutMs);
|
|
653
|
+
}
|
|
654
|
+
clearTimeout() {
|
|
655
|
+
if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
|
|
656
|
+
this.timeoutHandle = void 0;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
var RoomManager = class {
|
|
660
|
+
maxRooms;
|
|
661
|
+
gameOptions;
|
|
662
|
+
onExpired;
|
|
663
|
+
rooms = /* @__PURE__ */ new Map();
|
|
664
|
+
nextId = 1;
|
|
665
|
+
constructor(maxRooms, gameOptions, onExpired) {
|
|
666
|
+
this.maxRooms = maxRooms;
|
|
667
|
+
this.gameOptions = gameOptions;
|
|
668
|
+
this.onExpired = onExpired;
|
|
669
|
+
}
|
|
670
|
+
create(humanId, humanName) {
|
|
671
|
+
if ([...this.rooms.values()].some((room) => room.humanId === humanId)) throw new Error("你已经在这个群的斗地主房间中。");
|
|
672
|
+
if (this.rooms.size >= this.maxRooms) throw new Error(`当前群的 ${this.maxRooms} 个房间都在进行中。`);
|
|
673
|
+
const roomId = this.nextId++;
|
|
674
|
+
let room;
|
|
675
|
+
room = {
|
|
676
|
+
id: roomId,
|
|
677
|
+
humanId,
|
|
678
|
+
game: new DoudizhuGame(humanId, humanName, {
|
|
679
|
+
...this.gameOptions,
|
|
680
|
+
onExpired: async () => {
|
|
681
|
+
this.remove(roomId);
|
|
682
|
+
await this.onExpired?.(room);
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
};
|
|
686
|
+
this.rooms.set(room.id, room);
|
|
687
|
+
return room;
|
|
688
|
+
}
|
|
689
|
+
getByHuman(humanId) {
|
|
690
|
+
return [...this.rooms.values()].find((room) => room.humanId === humanId);
|
|
691
|
+
}
|
|
692
|
+
get(roomId) {
|
|
693
|
+
return this.rooms.get(roomId);
|
|
694
|
+
}
|
|
695
|
+
remove(roomId) {
|
|
696
|
+
const room = this.rooms.get(roomId);
|
|
697
|
+
if (!room) return;
|
|
698
|
+
room.game.dispose();
|
|
699
|
+
this.rooms.delete(roomId);
|
|
700
|
+
}
|
|
701
|
+
list() {
|
|
702
|
+
return [...this.rooms.values()];
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/index.ts
|
|
707
|
+
function positiveInteger(value, fallback) {
|
|
708
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
709
|
+
}
|
|
710
|
+
var src_default = definePlugin({
|
|
711
|
+
name: "doudizhu",
|
|
712
|
+
apply(ctx, options = {}) {
|
|
713
|
+
const maxRooms = positiveInteger(options.maxRooms, 2);
|
|
714
|
+
const turnTimeoutMs = positiveInteger(options.turnTimeoutMs, 6e4);
|
|
715
|
+
const renderer = new CardImageRenderer({ imageDirectory: options.imageDirectory });
|
|
716
|
+
const registeredUsers = /* @__PURE__ */ new Set();
|
|
717
|
+
const managers = /* @__PURE__ */ new Map();
|
|
718
|
+
const roomSessions = /* @__PURE__ */ new Map();
|
|
719
|
+
function getManager(groupId) {
|
|
720
|
+
let manager = managers.get(groupId);
|
|
721
|
+
if (manager) return manager;
|
|
722
|
+
manager = new RoomManager(maxRooms, { timeoutMs: turnTimeoutMs }, async (room) => {
|
|
723
|
+
const session = roomSessions.get(room.id);
|
|
724
|
+
roomSessions.delete(room.id);
|
|
725
|
+
if (session) await session.reply("本局因操作超时已结束,房间已释放。");
|
|
726
|
+
});
|
|
727
|
+
managers.set(groupId, manager);
|
|
728
|
+
return manager;
|
|
729
|
+
}
|
|
730
|
+
function groupIdOf(session) {
|
|
731
|
+
return session.raw.message_scene === "group" ? session.raw.peer_id : void 0;
|
|
732
|
+
}
|
|
733
|
+
function requireRoom(session) {
|
|
734
|
+
const groupId = groupIdOf(session);
|
|
735
|
+
if (groupId === void 0) return void 0;
|
|
736
|
+
const manager = getManager(groupId);
|
|
737
|
+
const room = manager.getByHuman(String(session.raw.sender_id));
|
|
738
|
+
if (!room) {
|
|
739
|
+
session.reply("你当前没有斗地主房间,请先发送“开始斗地主”。");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
manager,
|
|
744
|
+
room
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async function replyEvents(session, events) {
|
|
748
|
+
for (const event of events) if (event.type === "hand") {
|
|
749
|
+
const image = await renderer.render(event.cards);
|
|
750
|
+
await session.reply([textSegment(event.text), seg.image(image, { summary: event.text })]);
|
|
751
|
+
} else if (event.type === "played") {
|
|
752
|
+
const image = await renderer.render(event.cards);
|
|
753
|
+
await session.reply([textSegment(event.text), seg.image(image, { summary: "本轮出牌" })]);
|
|
754
|
+
} else await session.reply(event.text);
|
|
755
|
+
}
|
|
756
|
+
async function act(session, action) {
|
|
757
|
+
const target = requireRoom(session);
|
|
758
|
+
if (!target) return;
|
|
759
|
+
try {
|
|
760
|
+
await replyEvents(session, await target.room.game.act(String(session.raw.sender_id), action));
|
|
761
|
+
if (target.room.game.phase === "finished") {
|
|
762
|
+
target.manager.remove(target.room.id);
|
|
763
|
+
roomSessions.delete(target.room.id);
|
|
764
|
+
}
|
|
765
|
+
} catch (error) {
|
|
766
|
+
await session.reply(error instanceof Error ? error.message : "操作失败,请稍后再试。");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const router = ctx.router.filter((session) => session.raw.message_scene === "group");
|
|
770
|
+
router.command("注册").describe("注册斗地主玩家").execute(async (session) => {
|
|
771
|
+
registeredUsers.add(session.raw.sender_id);
|
|
772
|
+
await session.reply("斗地主注册成功。发送“开始斗地主”即可开局。");
|
|
773
|
+
});
|
|
774
|
+
router.command("开始斗地主").describe("创建一局斗地主人机对局").execute(async (session) => {
|
|
775
|
+
const userId = session.raw.sender_id;
|
|
776
|
+
if (!registeredUsers.has(userId)) {
|
|
777
|
+
await session.reply("请先发送“注册”完成斗地主注册。");
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const manager = getManager(groupIdOf(session));
|
|
781
|
+
try {
|
|
782
|
+
const senderName = session.raw.message_scene === "group" ? session.raw.group_member.card || session.raw.group_member.nickname : String(userId);
|
|
783
|
+
const room = manager.create(String(userId), senderName);
|
|
784
|
+
roomSessions.set(room.id, session);
|
|
785
|
+
await replyEvents(session, await room.game.begin());
|
|
786
|
+
} catch (error) {
|
|
787
|
+
await session.reply(error instanceof Error ? error.message : "创建房间失败,请稍后再试。");
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
router.command("明牌").describe("查看自己的合成手牌").execute(async (session) => {
|
|
791
|
+
const target = requireRoom(session);
|
|
792
|
+
if (!target) return;
|
|
793
|
+
const player = target.room.game.snapshot.players.find((item) => item.id === String(session.raw.sender_id));
|
|
794
|
+
if (!player) return;
|
|
795
|
+
const image = await renderer.render(player.hand);
|
|
796
|
+
await session.reply([textSegment(`你的手牌(${player.hand.length} 张)`), seg.image(image, { summary: "斗地主手牌" })]);
|
|
797
|
+
});
|
|
798
|
+
router.command("叫地主").execute((session) => act(session, {
|
|
799
|
+
type: "bid",
|
|
800
|
+
level: 3
|
|
801
|
+
}));
|
|
802
|
+
router.command("叫地主").arg("level", param.num().refine((value) => value >= 0 && value <= 3 && Number.isInteger(value))).execute((session, { level }) => act(session, {
|
|
803
|
+
type: "bid",
|
|
804
|
+
level
|
|
805
|
+
}));
|
|
806
|
+
router.command("抢地主").execute((session) => {
|
|
807
|
+
const target = requireRoom(session);
|
|
808
|
+
if (!target) return;
|
|
809
|
+
return act(session, {
|
|
810
|
+
type: "bid",
|
|
811
|
+
level: Math.min(3, target.room.game.snapshot.highestBid + 1)
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
router.command("不叫").execute((session) => act(session, {
|
|
815
|
+
type: "bid",
|
|
816
|
+
level: 0
|
|
817
|
+
}));
|
|
818
|
+
router.command("加倍").execute((session) => act(session, {
|
|
819
|
+
type: "double",
|
|
820
|
+
level: 1
|
|
821
|
+
}));
|
|
822
|
+
router.command("超级加倍").execute((session) => act(session, {
|
|
823
|
+
type: "double",
|
|
824
|
+
level: 2
|
|
825
|
+
}));
|
|
826
|
+
router.command("不加倍").execute((session) => act(session, {
|
|
827
|
+
type: "double",
|
|
828
|
+
level: 0
|
|
829
|
+
}));
|
|
830
|
+
router.command("出牌").arg("cards", param.greedy()).execute((session, { cards }) => act(session, {
|
|
831
|
+
type: "play",
|
|
832
|
+
cards
|
|
833
|
+
}));
|
|
834
|
+
router.command("要不起").execute((session) => act(session, { type: "pass" }));
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
function textSegment(text) {
|
|
838
|
+
return {
|
|
839
|
+
type: "text",
|
|
840
|
+
data: { text }
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
//#endregion
|
|
844
|
+
export { RoomManager, src_default as default };
|
|
Binary file
|
package/image/Club10.png
ADDED
|
Binary file
|
package/image/Club2.png
ADDED
|
Binary file
|
package/image/Club3.png
ADDED
|
Binary file
|
package/image/Club4.png
ADDED
|
Binary file
|
package/image/Club5.png
ADDED
|
Binary file
|
package/image/Club6.png
ADDED
|
Binary file
|
package/image/Club7.png
ADDED
|
Binary file
|
package/image/Club8.png
ADDED
|
Binary file
|
package/image/Club9.png
ADDED
|
Binary file
|
package/image/ClubA.png
ADDED
|
Binary file
|
package/image/ClubJ.png
ADDED
|
Binary file
|
package/image/ClubK.png
ADDED
|
Binary file
|
package/image/ClubQ.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/image/Heart2.png
ADDED
|
Binary file
|
package/image/Heart3.png
ADDED
|
Binary file
|
package/image/Heart4.png
ADDED
|
Binary file
|
package/image/Heart5.png
ADDED
|
Binary file
|
package/image/Heart6.png
ADDED
|
Binary file
|
package/image/Heart7.png
ADDED
|
Binary file
|
package/image/Heart8.png
ADDED
|
Binary file
|
package/image/Heart9.png
ADDED
|
Binary file
|
package/image/HeartA.png
ADDED
|
Binary file
|
package/image/HeartJ.png
ADDED
|
Binary file
|
package/image/HeartK.png
ADDED
|
Binary file
|
package/image/HeartQ.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/image/Spade2.png
ADDED
|
Binary file
|
package/image/Spade3.png
ADDED
|
Binary file
|
package/image/Spade4.png
ADDED
|
Binary file
|
package/image/Spade5.png
ADDED
|
Binary file
|
package/image/Spade6.png
ADDED
|
Binary file
|
package/image/Spade7.png
ADDED
|
Binary file
|
package/image/Spade8.png
ADDED
|
Binary file
|
package/image/Spade9.png
ADDED
|
Binary file
|
package/image/SpadeA.png
ADDED
|
Binary file
|
package/image/SpadeJ.png
ADDED
|
Binary file
|
package/image/SpadeK.png
ADDED
|
Binary file
|
package/image/SpadeQ.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fraq-plugin-doudizhu",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "群聊斗地主人机对局插件",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"image"
|
|
9
|
+
],
|
|
10
|
+
"main": "dist/index.mjs",
|
|
11
|
+
"typings": "dist/index.d.mts",
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "fraq-plugin-doudizhu",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/fraqjs/fraq-plugin-doudizhu.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@biomejs/biome": "^2.5.9",
|
|
21
|
+
"@fraqjs/plugin-mock": "^1.1.0",
|
|
22
|
+
"@types/node": "^24.13.3",
|
|
23
|
+
"tsdown": "^0.22.14",
|
|
24
|
+
"tsx": "^4.23.12",
|
|
25
|
+
"typescript": "^6.0.3"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"sharp": "^0.34.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@fraqjs/fraq": "^1.1.0"
|
|
32
|
+
},
|
|
33
|
+
"fraq": {
|
|
34
|
+
"category": "entertainment"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"test": "tsx --test test/*.test.ts",
|
|
39
|
+
"check": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|