@zhin.js/plugin-roulette 0.0.0-stage → 0.1.2
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/CHANGELOG.md +27 -0
- package/README.md +67 -2
- package/commands/roulette/[[action]]/index.js +13 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +27 -0
- package/lib/deadline-job.d.ts +9 -0
- package/lib/deadline-job.js +43 -0
- package/lib/engine.d.ts +61 -0
- package/lib/engine.js +157 -0
- package/lib/game-flow.d.ts +26 -0
- package/lib/game-flow.js +226 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/models.d.ts +41 -0
- package/lib/models.js +29 -0
- package/lib/mute-penalty.d.ts +31 -0
- package/lib/mute-penalty.js +53 -0
- package/lib/roulette-actions.d.ts +4 -0
- package/lib/roulette-actions.js +51 -0
- package/lib/roulette-command.d.ts +6 -0
- package/lib/roulette-command.js +22 -0
- package/lib/runtime.d.ts +16 -0
- package/lib/runtime.js +40 -0
- package/lib/session-service.d.ts +59 -0
- package/lib/session-service.js +167 -0
- package/lib/view.d.ts +10 -0
- package/lib/view.js +73 -0
- package/middlewares/roulette-alias/index.js +10 -0
- package/middlewares/roulette-fire/index.js +30 -0
- package/package.json +100 -3
- package/plugin.js +39 -0
- package/schedules/deadlines/index.js +8 -0
- package/schema.json +42 -0
package/lib/game-flow.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { GameSessionConflictError, SessionRevisionConflictError, channelKey, } from '@zhin.js/game-kit';
|
|
2
|
+
import { RouletteRuleError, loserOf } from './engine.js';
|
|
3
|
+
import { ROULETTE_HELP } from './roulette-actions.js';
|
|
4
|
+
import { playerFromMessage, stateOf, } from './session-service.js';
|
|
5
|
+
import { describeAbort, describeClick, describeEnding, describeLobby, describeRoundStart, describeStatus, } from './view.js';
|
|
6
|
+
const DEADLINE_ACTOR = 'deadline';
|
|
7
|
+
const MANAGER_ROLES = ['master', 'trusted', 'owner', 'admin'];
|
|
8
|
+
const NO_GAME_TEXT = '本群没有进行中的轮盘,发送「轮盘 开局」创建一局。';
|
|
9
|
+
export async function runRouletteAction(flow, actor, action) {
|
|
10
|
+
const message = actor.message;
|
|
11
|
+
if (action === 'help')
|
|
12
|
+
return ROULETTE_HELP;
|
|
13
|
+
if (message.$channel.type === 'private')
|
|
14
|
+
return '轮盘需要多人参与,请在群聊里玩。';
|
|
15
|
+
if (action === 'penalty-on' || action === 'penalty-off' || action === 'penalty-status') {
|
|
16
|
+
return handlePenaltySetting(flow, actor, action);
|
|
17
|
+
}
|
|
18
|
+
const notices = [];
|
|
19
|
+
let session = await flow.services.sessions.getActiveByChannel(channelKey(message));
|
|
20
|
+
if (session && isOverdue(session, Date.now())) {
|
|
21
|
+
const expired = await expireSession(flow, session);
|
|
22
|
+
if (expired)
|
|
23
|
+
notices.push(expired);
|
|
24
|
+
session = null;
|
|
25
|
+
}
|
|
26
|
+
const reply = await handleGameAction(flow, actor, action, session);
|
|
27
|
+
return [...notices, reply].join('\n\n');
|
|
28
|
+
}
|
|
29
|
+
/** Called by the deadline schedule; each returned notice still has to be delivered. */
|
|
30
|
+
export async function expireOverdueSessions(flow, now, onNotice) {
|
|
31
|
+
const overdue = await flow.services.sessions.listOverdue(now);
|
|
32
|
+
const notices = [];
|
|
33
|
+
for (const session of overdue) {
|
|
34
|
+
try {
|
|
35
|
+
const text = await expireSession(flow, session);
|
|
36
|
+
if (!text)
|
|
37
|
+
continue;
|
|
38
|
+
const notice = { session, text };
|
|
39
|
+
notices.push(notice);
|
|
40
|
+
// Announce as soon as the result is stored, so a later failure cannot drop it.
|
|
41
|
+
if (onNotice)
|
|
42
|
+
await onNotice(notice);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// The session stays active when anything before the commit failed; the next sweep retries it.
|
|
46
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
47
|
+
console.warn(`[roulette] 处理超时对局 ${session.id} 失败,下次扫描重试:${reason}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return notices;
|
|
51
|
+
}
|
|
52
|
+
async function handleGameAction(flow, actor, action, session) {
|
|
53
|
+
const message = actor.message;
|
|
54
|
+
if (action === 'open')
|
|
55
|
+
return openLobby(flow, actor, session);
|
|
56
|
+
if (!session)
|
|
57
|
+
return NO_GAME_TEXT;
|
|
58
|
+
if (action === 'status')
|
|
59
|
+
return describeStatus(stateOf(session), flow.config);
|
|
60
|
+
if (action === 'cancel' && !mayCancel(session, message, actor.roles)) {
|
|
61
|
+
return '只有开局的人、群主、群管理员或机器人管理员可以取消本局。';
|
|
62
|
+
}
|
|
63
|
+
const gameAction = toGameAction(action, message);
|
|
64
|
+
if (!gameAction)
|
|
65
|
+
return NO_GAME_TEXT;
|
|
66
|
+
try {
|
|
67
|
+
const result = await flow.services.sessions.applyAction(session, message.$sender.id, gameAction, flow.config);
|
|
68
|
+
return describeResult(flow, gameAction, result, message);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof RouletteRuleError)
|
|
72
|
+
return error.message;
|
|
73
|
+
if (error instanceof GameSessionConflictError)
|
|
74
|
+
return error.message;
|
|
75
|
+
if (error instanceof SessionRevisionConflictError)
|
|
76
|
+
return '对局状态刚刚变了,请再发一次。';
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function openLobby(flow, actor, session) {
|
|
81
|
+
if (session) {
|
|
82
|
+
return `本群已经有一局轮盘了。\n${describeStatus(stateOf(session), flow.config)}`;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const created = await flow.services.sessions.createLobby(actor.message, actor.endpoint, flow.config);
|
|
86
|
+
return describeLobby(stateOf(created), flow.config);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof GameSessionConflictError)
|
|
90
|
+
return error.message;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function describeResult(flow, action, result, message) {
|
|
95
|
+
const state = result.state;
|
|
96
|
+
if (!result.applied)
|
|
97
|
+
return describeStatus(state, flow.config);
|
|
98
|
+
if (state.phase === 'finished')
|
|
99
|
+
return finishText(flow, result.session, state);
|
|
100
|
+
if (state.phase === 'aborted')
|
|
101
|
+
return describeAbort(state);
|
|
102
|
+
if (action.type === 'start')
|
|
103
|
+
return describeRoundStart(state, flow.config);
|
|
104
|
+
if (action.type === 'fire') {
|
|
105
|
+
return describeClick(playerFromMessage(message), state, flow.config);
|
|
106
|
+
}
|
|
107
|
+
if (action.type === 'leave') {
|
|
108
|
+
return `${playerFromMessage(message).name} 离开了。\n${describeLobby(state, flow.config)}`;
|
|
109
|
+
}
|
|
110
|
+
return describeLobby(state, flow.config);
|
|
111
|
+
}
|
|
112
|
+
async function expireSession(flow, session) {
|
|
113
|
+
// Read everything that can fail before the state transition is committed: once the
|
|
114
|
+
// session is stored as finished it is no longer overdue and would never be retried.
|
|
115
|
+
const penaltyEnabled = stateOf(session).phase === 'playing'
|
|
116
|
+
? await flow.services.settings.isMutePenaltyEnabled(session.channel_key)
|
|
117
|
+
: false;
|
|
118
|
+
try {
|
|
119
|
+
const result = await flow.services.sessions.applyAction(session, DEADLINE_ACTOR, { type: 'expire' }, flow.config);
|
|
120
|
+
if (!result.applied)
|
|
121
|
+
return '';
|
|
122
|
+
if (result.state.phase === 'finished')
|
|
123
|
+
return finishText(flow, result.session, result.state, penaltyEnabled);
|
|
124
|
+
return describeAbort(result.state);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// A player acted on the session in the meantime; the next sweep re-reads it.
|
|
128
|
+
if (error instanceof SessionRevisionConflictError)
|
|
129
|
+
return '';
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function finishText(flow, session, state, penaltyEnabled) {
|
|
134
|
+
const penalty = await applyLoserPenalty(flow, session, state, penaltyEnabled);
|
|
135
|
+
return [describeEnding(state), penalty].filter(Boolean).join('\n');
|
|
136
|
+
}
|
|
137
|
+
/** Mute the loser only when this group opted in and the Endpoint declares muting. */
|
|
138
|
+
async function applyLoserPenalty(flow, session, state, penaltyEnabled) {
|
|
139
|
+
const loser = loserOf(state);
|
|
140
|
+
if (!loser)
|
|
141
|
+
return '';
|
|
142
|
+
const enabled = penaltyEnabled
|
|
143
|
+
?? await flow.services.settings.isMutePenaltyEnabled(session.channel_key);
|
|
144
|
+
if (!enabled)
|
|
145
|
+
return '';
|
|
146
|
+
const conversation = conversationOfSession(session);
|
|
147
|
+
if (flow.penalty.support(conversation) !== 'supported') {
|
|
148
|
+
return '(本群开启了禁言惩罚,但当前平台无法禁言,本局只计分。)';
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await flow.penalty.mute(conversation, loser.id, flow.config.muteSeconds);
|
|
152
|
+
return `🔇 ${loser.name} 被禁言 ${flow.config.muteSeconds} 秒。`;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return '(禁言没有成功,可能是机器人没有群管理权限;本局只计分。)';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function handlePenaltySetting(flow, actor, action) {
|
|
159
|
+
const message = actor.message;
|
|
160
|
+
if (message.$channel.type !== 'group')
|
|
161
|
+
return '禁言惩罚只能在群聊里设置。';
|
|
162
|
+
const key = channelKey(message);
|
|
163
|
+
const settings = flow.services.settings;
|
|
164
|
+
const support = flow.penalty.support(conversationOfActor(actor));
|
|
165
|
+
if (action === 'penalty-status') {
|
|
166
|
+
const enabled = await settings.isMutePenaltyEnabled(key);
|
|
167
|
+
return describePenaltyStatus(enabled, support === 'supported', flow.config);
|
|
168
|
+
}
|
|
169
|
+
if (!isManager(actor.roles))
|
|
170
|
+
return '只有群主、群管理员、机器人主人或受信任用户可以修改禁言惩罚。';
|
|
171
|
+
if (action === 'penalty-off') {
|
|
172
|
+
await settings.setMutePenalty(key, false, message.$sender.id);
|
|
173
|
+
return '已关闭禁言惩罚,本群轮盘只计分。';
|
|
174
|
+
}
|
|
175
|
+
if (support !== 'supported') {
|
|
176
|
+
return '当前平台不支持禁言,禁言惩罚没有开启;本群轮盘只计分。';
|
|
177
|
+
}
|
|
178
|
+
await settings.setMutePenalty(key, true, message.$sender.id);
|
|
179
|
+
return `已开启禁言惩罚:每局输家会被禁言 ${flow.config.muteSeconds} 秒(需要机器人有群管理权限)。`;
|
|
180
|
+
}
|
|
181
|
+
function describePenaltyStatus(enabled, supported, config) {
|
|
182
|
+
const platform = supported ? '当前平台支持禁言。' : '当前平台不支持禁言,只能计分。';
|
|
183
|
+
if (!enabled)
|
|
184
|
+
return `本群禁言惩罚:关闭。${platform}`;
|
|
185
|
+
return `本群禁言惩罚:开启,输家禁言 ${config.muteSeconds} 秒。${platform}`;
|
|
186
|
+
}
|
|
187
|
+
function toGameAction(action, message) {
|
|
188
|
+
if (action === 'join')
|
|
189
|
+
return { type: 'join', player: playerFromMessage(message) };
|
|
190
|
+
if (action === 'start')
|
|
191
|
+
return { type: 'start' };
|
|
192
|
+
if (action === 'fire')
|
|
193
|
+
return { type: 'fire' };
|
|
194
|
+
if (action === 'leave')
|
|
195
|
+
return { type: 'leave' };
|
|
196
|
+
if (action === 'cancel')
|
|
197
|
+
return { type: 'cancel' };
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
function mayCancel(session, message, senderRoles) {
|
|
201
|
+
if (stateOf(session).hostId === message.$sender.id)
|
|
202
|
+
return true;
|
|
203
|
+
return isManager(senderRoles);
|
|
204
|
+
}
|
|
205
|
+
function isManager(senderRoles) {
|
|
206
|
+
return senderRoles.some((role) => MANAGER_ROLES.includes(role));
|
|
207
|
+
}
|
|
208
|
+
function isOverdue(session, now) {
|
|
209
|
+
return session.deadline_at > 0 && session.deadline_at <= now;
|
|
210
|
+
}
|
|
211
|
+
export function conversationOfSession(session) {
|
|
212
|
+
return {
|
|
213
|
+
endpointAdapter: session.endpoint_adapter,
|
|
214
|
+
endpointId: session.endpoint_id,
|
|
215
|
+
kind: session.channel_type,
|
|
216
|
+
id: session.channel_id,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function conversationOfActor(actor) {
|
|
220
|
+
return {
|
|
221
|
+
endpointAdapter: actor.endpoint.adapter,
|
|
222
|
+
endpointId: actor.endpoint.id,
|
|
223
|
+
kind: actor.message.$channel.type,
|
|
224
|
+
id: actor.message.$channel.id,
|
|
225
|
+
};
|
|
226
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_ROULETTE_CONFIG, resolveRouletteConfig, type RouletteConfig } from './config.js';
|
|
2
|
+
export * from './engine.js';
|
|
3
|
+
export { expireOverdueSessions, runRouletteAction, type RouletteActor, type RouletteFlow } from './game-flow.js';
|
|
4
|
+
export { DEADLINE_CRON, runDeadlineSweep } from './deadline-job.js';
|
|
5
|
+
export { resolveMutePenalty, type AdapterProjectionContext, type MutePenaltyPort, type MuteSupport, type PenaltyConversation, } from './mute-penalty.js';
|
|
6
|
+
export { ROULETTE_HELP, normalizeRouletteAction, type RouletteCommandAction } from './roulette-actions.js';
|
|
7
|
+
export { endpointOf, rouletteServicesToken, resolveRouletteFlow } from './runtime.js';
|
|
8
|
+
export { RouletteGroupSettings, RouletteSessionService, createServices, type EndpointIdentity, type RouletteServices, } from './session-service.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_ROULETTE_CONFIG, resolveRouletteConfig } from './config.js';
|
|
2
|
+
export * from './engine.js';
|
|
3
|
+
export { expireOverdueSessions, runRouletteAction } from './game-flow.js';
|
|
4
|
+
export { DEADLINE_CRON, runDeadlineSweep } from './deadline-job.js';
|
|
5
|
+
export { resolveMutePenalty, } from './mute-penalty.js';
|
|
6
|
+
export { ROULETTE_HELP, normalizeRouletteAction } from './roulette-actions.js';
|
|
7
|
+
export { endpointOf, rouletteServicesToken, resolveRouletteFlow } from './runtime.js';
|
|
8
|
+
export { RouletteGroupSettings, RouletteSessionService, createServices, } from './session-service.js';
|
package/lib/models.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Models } from '@zhin.js/core';
|
|
2
|
+
export type RouletteSessionStatus = 'active' | 'finished' | 'aborted';
|
|
3
|
+
declare module '@zhin.js/core' {
|
|
4
|
+
interface Models {
|
|
5
|
+
roulette_sessions: {
|
|
6
|
+
id: string;
|
|
7
|
+
adapter: string;
|
|
8
|
+
endpoint: string;
|
|
9
|
+
channel_type: string;
|
|
10
|
+
channel_id: string;
|
|
11
|
+
channel_key: string;
|
|
12
|
+
/** Exact Endpoint identity (`conversation.endpoint`), used for the mute lookup. */
|
|
13
|
+
endpoint_adapter: string;
|
|
14
|
+
endpoint_id: string;
|
|
15
|
+
host_id: string;
|
|
16
|
+
state_json: string;
|
|
17
|
+
phase: string;
|
|
18
|
+
turn: string;
|
|
19
|
+
revision: number;
|
|
20
|
+
processed_actions: string;
|
|
21
|
+
/** Epoch ms when the lobby or the current turn times out; 0 once the game is over. */
|
|
22
|
+
deadline_at: number;
|
|
23
|
+
status: RouletteSessionStatus;
|
|
24
|
+
updated_at: number;
|
|
25
|
+
created_at: number;
|
|
26
|
+
};
|
|
27
|
+
roulette_group_settings: {
|
|
28
|
+
/** The channel key, one row per group. */
|
|
29
|
+
id: string;
|
|
30
|
+
mute_penalty: number;
|
|
31
|
+
updated_by: string;
|
|
32
|
+
updated_at: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export type RouletteSessionRow = Models['roulette_sessions'];
|
|
37
|
+
export type RouletteGroupSettingsRow = Models['roulette_group_settings'];
|
|
38
|
+
export declare const ROULETTE_TABLES: readonly ["roulette_sessions", "roulette_group_settings"];
|
|
39
|
+
export declare function defineHostTables(database: {
|
|
40
|
+
define(name: string, definition: Record<string, unknown>): void;
|
|
41
|
+
}): void;
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const ROULETTE_TABLES = ['roulette_sessions', 'roulette_group_settings'];
|
|
2
|
+
export function defineHostTables(database) {
|
|
3
|
+
database.define('roulette_sessions', {
|
|
4
|
+
id: { type: 'text', primary: true },
|
|
5
|
+
adapter: { type: 'text', nullable: false },
|
|
6
|
+
endpoint: { type: 'text', nullable: false },
|
|
7
|
+
channel_type: { type: 'text', nullable: false },
|
|
8
|
+
channel_id: { type: 'text', nullable: false },
|
|
9
|
+
channel_key: { type: 'text', nullable: false },
|
|
10
|
+
endpoint_adapter: { type: 'text', default: '' },
|
|
11
|
+
endpoint_id: { type: 'text', default: '' },
|
|
12
|
+
host_id: { type: 'text', nullable: false },
|
|
13
|
+
state_json: { type: 'text', nullable: false },
|
|
14
|
+
phase: { type: 'text', default: 'lobby' },
|
|
15
|
+
turn: { type: 'text', default: '' },
|
|
16
|
+
revision: { type: 'integer', default: 0 },
|
|
17
|
+
processed_actions: { type: 'text', default: '[]' },
|
|
18
|
+
deadline_at: { type: 'bigint', default: 0 },
|
|
19
|
+
status: { type: 'text', default: 'active' },
|
|
20
|
+
updated_at: { type: 'bigint', default: 0 },
|
|
21
|
+
created_at: { type: 'bigint', default: 0 },
|
|
22
|
+
});
|
|
23
|
+
database.define('roulette_group_settings', {
|
|
24
|
+
id: { type: 'text', primary: true },
|
|
25
|
+
mute_penalty: { type: 'integer', default: 0 },
|
|
26
|
+
updated_by: { type: 'text', default: '' },
|
|
27
|
+
updated_at: { type: 'bigint', default: 0 },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { adapterFeatureId } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* Where a game was played: the exact Endpoint identity from `message.conversation.endpoint`
|
|
4
|
+
* (adapter plugin owner + Endpoint capability id) plus the conversation itself.
|
|
5
|
+
*/
|
|
6
|
+
export interface PenaltyConversation {
|
|
7
|
+
readonly endpointAdapter: string;
|
|
8
|
+
readonly endpointId: string;
|
|
9
|
+
readonly kind: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
}
|
|
12
|
+
export type MuteSupport =
|
|
13
|
+
/** The live Endpoint's management port implements `muteGroupMember` and this is a group. */
|
|
14
|
+
'supported'
|
|
15
|
+
/** Muting only exists for group members (not private chats or channels). */
|
|
16
|
+
| 'not-a-group'
|
|
17
|
+
/** No adapter projection, no such Endpoint, or its management port cannot mute. */
|
|
18
|
+
| 'unsupported';
|
|
19
|
+
/**
|
|
20
|
+
* The only mute surface the game uses. It never looks at adapter names: support
|
|
21
|
+
* is whatever the live Endpoint's platform-neutral `EndpointManagement` implements.
|
|
22
|
+
*/
|
|
23
|
+
export interface MutePenaltyPort {
|
|
24
|
+
support(conversation: PenaltyConversation): MuteSupport;
|
|
25
|
+
mute(conversation: PenaltyConversation, userId: string, durationSeconds: number): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** The capability context of a command, middleware or schedule run. */
|
|
28
|
+
export interface AdapterProjectionContext {
|
|
29
|
+
project<T>(feature: typeof adapterFeatureId): T;
|
|
30
|
+
}
|
|
31
|
+
export declare function resolveMutePenalty(context: AdapterProjectionContext): MutePenaltyPort;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { adapterFeatureId, resolveEndpointManagement } from 'zhin.js/adapter';
|
|
2
|
+
export function resolveMutePenalty(context) {
|
|
3
|
+
return {
|
|
4
|
+
support(conversation) {
|
|
5
|
+
if (conversation.kind !== 'group')
|
|
6
|
+
return 'not-a-group';
|
|
7
|
+
return findMuteGroupMember(context, conversation) ? 'supported' : 'unsupported';
|
|
8
|
+
},
|
|
9
|
+
async mute(conversation, userId, durationSeconds) {
|
|
10
|
+
const muteGroupMember = findMuteGroupMember(context, conversation);
|
|
11
|
+
if (!muteGroupMember) {
|
|
12
|
+
throw new Error('This Endpoint cannot mute group members');
|
|
13
|
+
}
|
|
14
|
+
await muteGroupMember(conversation.id, userId, durationSeconds);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** project(adapterFeatureId) → connection(adapter, endpointId) → resolveEndpointManagement → muteGroupMember. */
|
|
19
|
+
function findMuteGroupMember(context, conversation) {
|
|
20
|
+
const adapters = readAdapterProjection(context);
|
|
21
|
+
if (!adapters)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (!conversation.endpointAdapter || !conversation.endpointId)
|
|
24
|
+
return undefined;
|
|
25
|
+
let endpoint;
|
|
26
|
+
try {
|
|
27
|
+
endpoint = adapters.connection(conversation.endpointAdapter, conversation.endpointId);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const management = resolveEndpointManagement(endpoint);
|
|
33
|
+
const muteGroupMember = management?.muteGroupMember;
|
|
34
|
+
if (typeof muteGroupMember !== 'function')
|
|
35
|
+
return undefined;
|
|
36
|
+
return muteGroupMember.bind(management);
|
|
37
|
+
}
|
|
38
|
+
function readAdapterProjection(context) {
|
|
39
|
+
let projection;
|
|
40
|
+
try {
|
|
41
|
+
projection = context.project(adapterFeatureId);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// No Adapter Feature in this runtime: score only.
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (!projection || typeof projection !== 'object')
|
|
48
|
+
return undefined;
|
|
49
|
+
const lookup = projection;
|
|
50
|
+
if (typeof lookup.connection !== 'function')
|
|
51
|
+
return undefined;
|
|
52
|
+
return lookup;
|
|
53
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type RouletteCommandAction = 'help' | 'open' | 'join' | 'start' | 'fire' | 'leave' | 'cancel' | 'status' | 'penalty-on' | 'penalty-off' | 'penalty-status';
|
|
2
|
+
/** Empty input means help; unknown words return null so normal chat is never hijacked. */
|
|
3
|
+
export declare function normalizeRouletteAction(raw: string): RouletteCommandAction | null;
|
|
4
|
+
export declare const ROULETTE_HELP: string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const ACTION_WORDS = {
|
|
2
|
+
help: 'help',
|
|
3
|
+
帮助: 'help',
|
|
4
|
+
规则: 'help',
|
|
5
|
+
open: 'open',
|
|
6
|
+
开局: 'open',
|
|
7
|
+
创建: 'open',
|
|
8
|
+
join: 'join',
|
|
9
|
+
加入: 'join',
|
|
10
|
+
start: 'start',
|
|
11
|
+
开始: 'start',
|
|
12
|
+
fire: 'fire',
|
|
13
|
+
shoot: 'fire',
|
|
14
|
+
开枪: 'fire',
|
|
15
|
+
扣动扳机: 'fire',
|
|
16
|
+
leave: 'leave',
|
|
17
|
+
退出: 'leave',
|
|
18
|
+
离开: 'leave',
|
|
19
|
+
认输: 'leave',
|
|
20
|
+
cancel: 'cancel',
|
|
21
|
+
取消: 'cancel',
|
|
22
|
+
status: 'status',
|
|
23
|
+
状态: 'status',
|
|
24
|
+
'mute-on': 'penalty-on',
|
|
25
|
+
开启禁言: 'penalty-on',
|
|
26
|
+
'mute-off': 'penalty-off',
|
|
27
|
+
关闭禁言: 'penalty-off',
|
|
28
|
+
mute: 'penalty-status',
|
|
29
|
+
禁言: 'penalty-status',
|
|
30
|
+
};
|
|
31
|
+
/** Empty input means help; unknown words return null so normal chat is never hijacked. */
|
|
32
|
+
export function normalizeRouletteAction(raw) {
|
|
33
|
+
const word = raw.trim();
|
|
34
|
+
if (!word)
|
|
35
|
+
return 'help';
|
|
36
|
+
return ACTION_WORDS[word] ?? ACTION_WORDS[word.toLowerCase()] ?? null;
|
|
37
|
+
}
|
|
38
|
+
export const ROULETTE_HELP = [
|
|
39
|
+
'🔫 俄罗斯轮盘(2 人起,轮流开枪,中弹者输)',
|
|
40
|
+
'轮盘 开局 — 创建一局并上桌',
|
|
41
|
+
'轮盘 加入 — 加入等待中的对局',
|
|
42
|
+
'轮盘 开始 — 房主开局(弹巢随机装 1 发子弹)',
|
|
43
|
+
'开枪 / 轮盘 开枪 — 轮到你时扣动扳机',
|
|
44
|
+
'轮盘 退出 — 等待时离开;对局中离开算认输',
|
|
45
|
+
'轮盘 取消 — 房主、群主、群管理员或机器人管理员取消本局(不计分)',
|
|
46
|
+
'轮盘 状态 — 查看当前对局',
|
|
47
|
+
'轮盘 禁言 / 开启禁言 / 关闭禁言 — 查看或切换本群的禁言惩罚(默认关闭)',
|
|
48
|
+
'',
|
|
49
|
+
'规则:按加入顺序轮流开枪;轮到的人超时不开枪判负;幸存者各记 1 胜,输家记 1 负。',
|
|
50
|
+
'战绩与排行:「战绩」「排行 轮盘」。',
|
|
51
|
+
].join('\n');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RouletteCapabilityContext } from './runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared entry for the `roulette` command and the `轮盘` alias middleware.
|
|
4
|
+
* Returns null for unknown words so the alias middleware lets the chat through.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runRouletteCommand(context: RouletteCapabilityContext, input: unknown, rawAction: string): Promise<string | null>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { messageFromCommandInput } from '@zhin.js/game-kit';
|
|
2
|
+
import { runRouletteAction } from './game-flow.js';
|
|
3
|
+
import { normalizeRouletteAction, ROULETTE_HELP } from './roulette-actions.js';
|
|
4
|
+
import { endpointOf, resolveRouletteFlow, senderRolesOf } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* Shared entry for the `roulette` command and the `轮盘` alias middleware.
|
|
7
|
+
* Returns null for unknown words so the alias middleware lets the chat through.
|
|
8
|
+
*/
|
|
9
|
+
export async function runRouletteCommand(context, input, rawAction) {
|
|
10
|
+
const action = normalizeRouletteAction(rawAction);
|
|
11
|
+
if (!action)
|
|
12
|
+
return null;
|
|
13
|
+
if (action === 'help')
|
|
14
|
+
return ROULETTE_HELP;
|
|
15
|
+
const flow = resolveRouletteFlow(context);
|
|
16
|
+
const actor = {
|
|
17
|
+
message: messageFromCommandInput(input),
|
|
18
|
+
endpoint: endpointOf(input),
|
|
19
|
+
roles: senderRolesOf(input),
|
|
20
|
+
};
|
|
21
|
+
return runRouletteAction(flow, actor, action);
|
|
22
|
+
}
|
package/lib/runtime.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Token } from 'zhin.js';
|
|
2
|
+
import type { RouletteFlow } from './game-flow.js';
|
|
3
|
+
import { type AdapterProjectionContext } from './mute-penalty.js';
|
|
4
|
+
import type { EndpointIdentity, RouletteServices } from './session-service.js';
|
|
5
|
+
/** Owner-scoped services consumed by discovered commands, middleware and the deadline job. */
|
|
6
|
+
export declare const rouletteServicesToken: Token<RouletteServices>;
|
|
7
|
+
/** A command, middleware or schedule capability context. */
|
|
8
|
+
export interface RouletteCapabilityContext extends AdapterProjectionContext {
|
|
9
|
+
use<T>(token: Token<T>): T;
|
|
10
|
+
readonly config?: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveRouletteFlow(context: RouletteCapabilityContext): RouletteFlow;
|
|
13
|
+
/** Framework roles (master / trusted / user) of whoever sent the message. */
|
|
14
|
+
export declare function senderRolesOf(input: unknown): readonly string[];
|
|
15
|
+
/** `message.conversation.endpoint`, or an empty identity (no mute possible) when absent. */
|
|
16
|
+
export declare function endpointOf(input: unknown): EndpointIdentity;
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createToken } from 'zhin.js';
|
|
2
|
+
import { resolveRouletteConfig } from './config.js';
|
|
3
|
+
import { resolveMutePenalty } from './mute-penalty.js';
|
|
4
|
+
/** Owner-scoped services consumed by discovered commands, middleware and the deadline job. */
|
|
5
|
+
export const rouletteServicesToken = createToken('zhin.game.roulette.services');
|
|
6
|
+
export function resolveRouletteFlow(context) {
|
|
7
|
+
return {
|
|
8
|
+
services: context.use(rouletteServicesToken),
|
|
9
|
+
config: resolveRouletteConfig(context.config),
|
|
10
|
+
penalty: resolveMutePenalty(context),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Framework roles (master / trusted / user) of whoever sent the message. */
|
|
14
|
+
export function senderRolesOf(input) {
|
|
15
|
+
if (!input || typeof input !== 'object')
|
|
16
|
+
return [];
|
|
17
|
+
const sender = input.sender;
|
|
18
|
+
if (!sender || typeof sender !== 'object')
|
|
19
|
+
return [];
|
|
20
|
+
const roles = sender.roles;
|
|
21
|
+
if (!Array.isArray(roles))
|
|
22
|
+
return [];
|
|
23
|
+
return roles.filter((role) => typeof role === 'string');
|
|
24
|
+
}
|
|
25
|
+
/** `message.conversation.endpoint`, or an empty identity (no mute possible) when absent. */
|
|
26
|
+
export function endpointOf(input) {
|
|
27
|
+
const empty = { adapter: '', id: '' };
|
|
28
|
+
if (!input || typeof input !== 'object')
|
|
29
|
+
return empty;
|
|
30
|
+
const conversation = input.conversation;
|
|
31
|
+
if (!conversation || typeof conversation !== 'object')
|
|
32
|
+
return empty;
|
|
33
|
+
const endpoint = conversation.endpoint;
|
|
34
|
+
if (!endpoint || typeof endpoint !== 'object')
|
|
35
|
+
return empty;
|
|
36
|
+
const identity = endpoint;
|
|
37
|
+
if (typeof identity.adapter !== 'string' || typeof identity.id !== 'string')
|
|
38
|
+
return empty;
|
|
39
|
+
return { adapter: identity.adapter, id: identity.id };
|
|
40
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Database, Models } from '@zhin.js/core';
|
|
2
|
+
import { VersionedSessionService, type GameMessageLike } from '@zhin.js/game-kit';
|
|
3
|
+
import type { RouletteConfig } from './config.js';
|
|
4
|
+
import { type RoulettePlayer, type RouletteState } from './engine.js';
|
|
5
|
+
import type { RouletteSessionRow } from './models.js';
|
|
6
|
+
export type RouletteDatabase = Database<unknown, Models, string>;
|
|
7
|
+
/** `message.conversation.endpoint`: the adapter plugin owner and the Endpoint capability id. */
|
|
8
|
+
export interface EndpointIdentity {
|
|
9
|
+
readonly adapter: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
}
|
|
12
|
+
export type RouletteAction = {
|
|
13
|
+
readonly type: 'join';
|
|
14
|
+
readonly player: RoulettePlayer;
|
|
15
|
+
} | {
|
|
16
|
+
readonly type: 'start';
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: 'fire';
|
|
19
|
+
} | {
|
|
20
|
+
readonly type: 'leave';
|
|
21
|
+
} | {
|
|
22
|
+
readonly type: 'cancel';
|
|
23
|
+
} | {
|
|
24
|
+
readonly type: 'expire';
|
|
25
|
+
};
|
|
26
|
+
export interface RouletteActionResult {
|
|
27
|
+
readonly session: RouletteSessionRow;
|
|
28
|
+
readonly state: RouletteState;
|
|
29
|
+
/** False when an identical action id was already applied (adapter retry). */
|
|
30
|
+
readonly applied: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface RouletteServiceOptions {
|
|
33
|
+
/** Picks the loaded chamber when a round starts; secure random by default. */
|
|
34
|
+
readonly chooseBulletChamber?: (chamberCount: number) => number;
|
|
35
|
+
}
|
|
36
|
+
export declare class RouletteSessionService extends VersionedSessionService<RouletteSessionRow> {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(database: RouletteDatabase, options?: RouletteServiceOptions);
|
|
39
|
+
createLobby(message: GameMessageLike, endpoint: EndpointIdentity, config: RouletteConfig): Promise<RouletteSessionRow>;
|
|
40
|
+
/** Every seated player counts as busy, not just the host. */
|
|
41
|
+
getActiveForUser(channelKeyValue: string, userId: string): Promise<RouletteSessionRow | null>;
|
|
42
|
+
applyAction(session: RouletteSessionRow, actorId: string, action: RouletteAction, config: RouletteConfig): Promise<RouletteActionResult>;
|
|
43
|
+
/** Open sessions whose lobby or turn deadline has passed. */
|
|
44
|
+
listOverdue(now: number): Promise<readonly RouletteSessionRow[]>;
|
|
45
|
+
}
|
|
46
|
+
/** Per-group switches. The mute penalty is off until a group turns it on. */
|
|
47
|
+
export declare class RouletteGroupSettings {
|
|
48
|
+
#private;
|
|
49
|
+
constructor(database: RouletteDatabase);
|
|
50
|
+
isMutePenaltyEnabled(channelKeyValue: string): Promise<boolean>;
|
|
51
|
+
setMutePenalty(channelKeyValue: string, enabled: boolean, actorId: string): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export interface RouletteServices {
|
|
54
|
+
readonly sessions: RouletteSessionService;
|
|
55
|
+
readonly settings: RouletteGroupSettings;
|
|
56
|
+
}
|
|
57
|
+
export declare function createServices(database: RouletteDatabase, options?: RouletteServiceOptions): RouletteServices;
|
|
58
|
+
export declare function stateOf(session: RouletteSessionRow): RouletteState;
|
|
59
|
+
export declare function playerFromMessage(message: GameMessageLike): RoulettePlayer;
|