@zhin.js/plugin-roulette 0.1.14 → 0.1.15
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 +63 -0
- package/LICENSE +21 -0
- package/README.md +7 -2
- package/commands/roulette/[[action]]/index.ts +19 -0
- package/lib/deadline-job.js +15 -3
- package/lib/game-flow.d.ts +1 -1
- package/lib/game-flow.js +20 -3
- package/lib/runtime.d.ts +10 -2
- package/lib/runtime.js +10 -5
- package/middlewares/roulette-alias/index.ts +22 -0
- package/middlewares/roulette-fire/index.ts +32 -0
- package/package.json +16 -15
- package/plugin.js +5 -1
- package/schedules/deadlines/index.ts +8 -0
- package/schema.json +38 -5
- package/src/config.ts +46 -0
- package/src/deadline-job.ts +49 -0
- package/src/engine.ts +245 -0
- package/src/game-flow.ts +339 -0
- package/src/index.ts +19 -0
- package/src/models.ts +75 -0
- package/src/mute-penalty.ts +88 -0
- package/src/roulette-actions.ts +76 -0
- package/src/roulette-command.ts +24 -0
- package/src/runtime.ts +45 -0
- package/src/session-service.ts +266 -0
- package/src/view.ts +89 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { outboundHostToken, type OutboundHost } from 'zhin.js';
|
|
2
|
+
import { gameTargetFromKey, sendGameSessionMessage } from '@zhin.js/game-kit';
|
|
3
|
+
import { expireOverdueSessions, type ExpiredSessionNotice } from './game-flow.js';
|
|
4
|
+
import { resolveRouletteFlow, rouletteFeatureGateStore, type RouletteCapabilityContext, type RouletteFeatureGate } from './runtime.js';
|
|
5
|
+
|
|
6
|
+
/** Turn and lobby deadlines are checked every five seconds. */
|
|
7
|
+
export const DEADLINE_CRON = '*/5 * * * * *';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One deadline sweep. It runs with the schedule's capability context, so a
|
|
11
|
+
* timed-out loser gets the same mute check (adapter projection) as one who
|
|
12
|
+
* was shot in a command.
|
|
13
|
+
*/
|
|
14
|
+
export async function runDeadlineSweep(context: RouletteCapabilityContext, now: number): Promise<void> {
|
|
15
|
+
const flow = resolveRouletteFlow(context);
|
|
16
|
+
const outbound = readOutbound(context);
|
|
17
|
+
const enabledFor = readFeatureGate(context);
|
|
18
|
+
await expireOverdueSessions(flow, now, (notice) => {
|
|
19
|
+
if (outbound) return announce(outbound, notice);
|
|
20
|
+
}, enabledFor && ((session) => {
|
|
21
|
+
const target = gameTargetFromKey(session.channel_key);
|
|
22
|
+
return !target || enabledFor.isEnabledFor(target);
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readFeatureGate(context: RouletteCapabilityContext): RouletteFeatureGate | undefined {
|
|
27
|
+
try {
|
|
28
|
+
return context.use(rouletteFeatureGateStore);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readOutbound(context: RouletteCapabilityContext): OutboundHost | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return context.use(outboundHostToken);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function announce(outbound: OutboundHost, notice: ExpiredSessionNotice): Promise<void> {
|
|
43
|
+
const session = notice.session;
|
|
44
|
+
try {
|
|
45
|
+
await sendGameSessionMessage({ outbound, session, content: notice.text });
|
|
46
|
+
} catch {
|
|
47
|
+
// The result is already stored; a failed notice must not stop the other sessions.
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createMessages, type Locale } from 'zhin.js/i18n';
|
|
2
|
+
/**
|
|
3
|
+
* Pure Russian roulette rules. No I/O, no clock and no randomness: callers pass
|
|
4
|
+
* the bullet position in, so every transition is deterministic and testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const ROULETTE_STATE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export type RoulettePhase = 'lobby' | 'playing' | 'finished' | 'aborted';
|
|
10
|
+
|
|
11
|
+
export type RouletteEndReason =
|
|
12
|
+
| 'shot'
|
|
13
|
+
| 'turn-timeout'
|
|
14
|
+
| 'forfeit'
|
|
15
|
+
| 'cancelled'
|
|
16
|
+
| 'lobby-expired'
|
|
17
|
+
| 'lobby-empty';
|
|
18
|
+
|
|
19
|
+
export interface RoulettePlayer {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RouletteState {
|
|
25
|
+
readonly schemaVersion: typeof ROULETTE_STATE_VERSION;
|
|
26
|
+
readonly phase: RoulettePhase;
|
|
27
|
+
readonly hostId: string;
|
|
28
|
+
readonly players: readonly RoulettePlayer[];
|
|
29
|
+
readonly chamberCount: number;
|
|
30
|
+
/** Zero-based chamber holding the bullet; -1 until the round starts. */
|
|
31
|
+
readonly bulletChamber: number;
|
|
32
|
+
/** Trigger pulls so far; the next pull fires chamber `pullsMade`. */
|
|
33
|
+
readonly pullsMade: number;
|
|
34
|
+
/** Index into `players` of whoever must pull next. */
|
|
35
|
+
readonly turnIndex: number;
|
|
36
|
+
readonly loserId?: string;
|
|
37
|
+
readonly endReason?: RouletteEndReason;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RouletteTableRules {
|
|
41
|
+
readonly chamberCount: number;
|
|
42
|
+
readonly maxPlayers: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const MIN_ROULETTE_PLAYERS = 2;
|
|
46
|
+
|
|
47
|
+
export type TriggerOutcome = 'click' | 'bang';
|
|
48
|
+
|
|
49
|
+
export interface TriggerResult {
|
|
50
|
+
readonly state: RouletteState;
|
|
51
|
+
readonly outcome: TriggerOutcome;
|
|
52
|
+
readonly shooter: RoulettePlayer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A rule violation the player should read, e.g. firing out of turn. */
|
|
56
|
+
export class RouletteRuleError extends Error {
|
|
57
|
+
constructor(readonly code: RouletteRuleCode, readonly parameters: Readonly<Record<string, string | number>> = {}) {
|
|
58
|
+
super(ruleMessages('zh-CN', code, parameters));
|
|
59
|
+
this.name = 'RouletteRuleError';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
format(locale?: Locale): string { return ruleMessages(locale, this.code, this.parameters); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createLobby(host: RoulettePlayer, rules: RouletteTableRules): RouletteState {
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: ROULETTE_STATE_VERSION,
|
|
68
|
+
phase: 'lobby',
|
|
69
|
+
hostId: host.id,
|
|
70
|
+
players: [host],
|
|
71
|
+
chamberCount: rules.chamberCount,
|
|
72
|
+
bulletChamber: -1,
|
|
73
|
+
pullsMade: 0,
|
|
74
|
+
turnIndex: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function joinLobby(
|
|
79
|
+
state: RouletteState,
|
|
80
|
+
player: RoulettePlayer,
|
|
81
|
+
rules: RouletteTableRules,
|
|
82
|
+
): RouletteState {
|
|
83
|
+
requirePhase(state, 'lobby', 'cannotJoin');
|
|
84
|
+
if (findPlayer(state, player.id)) {
|
|
85
|
+
throw new RouletteRuleError('alreadyJoined');
|
|
86
|
+
}
|
|
87
|
+
if (state.players.length >= rules.maxPlayers) {
|
|
88
|
+
throw new RouletteRuleError('full', { max: rules.maxPlayers });
|
|
89
|
+
}
|
|
90
|
+
return { ...state, players: [...state.players, player] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function startRound(
|
|
94
|
+
state: RouletteState,
|
|
95
|
+
actorId: string,
|
|
96
|
+
bulletChamber: number,
|
|
97
|
+
): RouletteState {
|
|
98
|
+
requirePhase(state, 'lobby', 'started');
|
|
99
|
+
if (actorId !== state.hostId) {
|
|
100
|
+
throw new RouletteRuleError('hostOnly');
|
|
101
|
+
}
|
|
102
|
+
if (state.players.length < MIN_ROULETTE_PLAYERS) {
|
|
103
|
+
throw new RouletteRuleError('minimum', { min: MIN_ROULETTE_PLAYERS });
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isInteger(bulletChamber) || bulletChamber < 0 || bulletChamber >= state.chamberCount) {
|
|
106
|
+
throw new RangeError(`bulletChamber must be within 0..${state.chamberCount - 1}`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
phase: 'playing',
|
|
111
|
+
bulletChamber,
|
|
112
|
+
pullsMade: 0,
|
|
113
|
+
turnIndex: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function pullTrigger(state: RouletteState, actorId: string): TriggerResult {
|
|
118
|
+
requirePhase(state, 'playing', 'notStarted');
|
|
119
|
+
const shooter = currentPlayer(state);
|
|
120
|
+
if (!shooter) throw new RouletteRuleError('noPlayer');
|
|
121
|
+
if (shooter.id !== actorId) {
|
|
122
|
+
throw new RouletteRuleError('notTurn', { name: shooter.name });
|
|
123
|
+
}
|
|
124
|
+
const firedChamber = state.pullsMade;
|
|
125
|
+
const pullsMade = state.pullsMade + 1;
|
|
126
|
+
if (firedChamber === state.bulletChamber) {
|
|
127
|
+
const finished = finishWithLoser({ ...state, pullsMade }, shooter.id, 'shot');
|
|
128
|
+
return { state: finished, outcome: 'bang', shooter };
|
|
129
|
+
}
|
|
130
|
+
const turnIndex = (state.turnIndex + 1) % state.players.length;
|
|
131
|
+
return { state: { ...state, pullsMade, turnIndex }, outcome: 'click', shooter };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Leaving the lobby just removes the player. Leaving a running round is a
|
|
136
|
+
* forfeit: the leaver loses, so nobody can dodge the bullet by walking away.
|
|
137
|
+
*/
|
|
138
|
+
export function leaveGame(state: RouletteState, playerId: string): RouletteState {
|
|
139
|
+
const leaver = findPlayer(state, playerId);
|
|
140
|
+
if (!leaver) throw new RouletteRuleError('notJoined');
|
|
141
|
+
if (state.phase === 'playing') {
|
|
142
|
+
return finishWithLoser(state, playerId, 'forfeit');
|
|
143
|
+
}
|
|
144
|
+
requirePhase(state, 'lobby', 'ended');
|
|
145
|
+
const players = state.players.filter((player) => player.id !== playerId);
|
|
146
|
+
const nextHost = players[0];
|
|
147
|
+
if (!nextHost) {
|
|
148
|
+
return { ...state, players, phase: 'aborted', endReason: 'lobby-empty' };
|
|
149
|
+
}
|
|
150
|
+
const hostId = state.hostId === playerId ? nextHost.id : state.hostId;
|
|
151
|
+
return { ...state, players, hostId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The player on turn ran out of time: treated like a shot, they lose. */
|
|
155
|
+
export function expireTurn(state: RouletteState): RouletteState {
|
|
156
|
+
requirePhase(state, 'playing', 'notStarted');
|
|
157
|
+
const late = currentPlayer(state);
|
|
158
|
+
if (!late) throw new RouletteRuleError('noPlayer');
|
|
159
|
+
return finishWithLoser(state, late.id, 'turn-timeout');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function expireLobby(state: RouletteState): RouletteState {
|
|
163
|
+
requirePhase(state, 'lobby', 'started');
|
|
164
|
+
return { ...state, phase: 'aborted', endReason: 'lobby-expired' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function cancelGame(state: RouletteState): RouletteState {
|
|
168
|
+
if (!isOpen(state)) throw new RouletteRuleError('ended');
|
|
169
|
+
return { ...state, phase: 'aborted', endReason: 'cancelled' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isOpen(state: RouletteState): boolean {
|
|
173
|
+
return state.phase === 'lobby' || state.phase === 'playing';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function currentPlayer(state: RouletteState): RoulettePlayer | undefined {
|
|
177
|
+
if (state.phase !== 'playing') return undefined;
|
|
178
|
+
return state.players[state.turnIndex];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function findPlayer(state: RouletteState, playerId: string): RoulettePlayer | undefined {
|
|
182
|
+
return state.players.find((player) => player.id === playerId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function loserOf(state: RouletteState): RoulettePlayer | undefined {
|
|
186
|
+
if (!state.loserId) return undefined;
|
|
187
|
+
return findPlayer(state, state.loserId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function survivorsOf(state: RouletteState): readonly RoulettePlayer[] {
|
|
191
|
+
return state.players.filter((player) => player.id !== state.loserId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function remainingChambers(state: RouletteState): number {
|
|
195
|
+
return state.chamberCount - state.pullsMade;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function decodeRouletteState(json: string): RouletteState {
|
|
199
|
+
const parsed: unknown = JSON.parse(json);
|
|
200
|
+
if (!isRouletteState(parsed)) {
|
|
201
|
+
throw new TypeError('Stored roulette state is malformed');
|
|
202
|
+
}
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function finishWithLoser(
|
|
207
|
+
state: RouletteState,
|
|
208
|
+
loserId: string,
|
|
209
|
+
endReason: RouletteEndReason,
|
|
210
|
+
): RouletteState {
|
|
211
|
+
return { ...state, phase: 'finished', loserId, endReason };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function requirePhase(state: RouletteState, phase: RoulettePhase, code: RouletteRuleCode): void {
|
|
215
|
+
if (state.phase !== phase) throw new RouletteRuleError(code);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isRouletteState(value: unknown): value is RouletteState {
|
|
219
|
+
if (!value || typeof value !== 'object') return false;
|
|
220
|
+
const state = value as Partial<RouletteState>;
|
|
221
|
+
return state.schemaVersion === ROULETTE_STATE_VERSION
|
|
222
|
+
&& typeof state.phase === 'string'
|
|
223
|
+
&& typeof state.hostId === 'string'
|
|
224
|
+
&& Array.isArray(state.players)
|
|
225
|
+
&& typeof state.chamberCount === 'number'
|
|
226
|
+
&& typeof state.bulletChamber === 'number'
|
|
227
|
+
&& typeof state.pullsMade === 'number'
|
|
228
|
+
&& typeof state.turnIndex === 'number';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ruleCatalog = {
|
|
232
|
+
cannotJoin: { 'zh-CN': "对局已经开始,不能再加入了。", en: "The game has started; you can no longer join." },
|
|
233
|
+
alreadyJoined: { 'zh-CN': "你已经在这局轮盘里了。", en: "You are already in this game." },
|
|
234
|
+
full: { 'zh-CN': "人数已满(最多 {max} 人)。", en: "The table is full (maximum {max} players)." },
|
|
235
|
+
started: { 'zh-CN': "对局已经开始了。", en: "The game has already started." },
|
|
236
|
+
hostOnly: { 'zh-CN': "只有开局的人可以开始。", en: "Only the host may start the game." },
|
|
237
|
+
minimum: { 'zh-CN': "至少需要 {min} 名玩家才能开始。", en: "At least {min} players are required." },
|
|
238
|
+
notStarted: { 'zh-CN': "对局还没开始。", en: "The game has not started." },
|
|
239
|
+
noPlayer: { 'zh-CN': "对局没有可以开枪的玩家。", en: "There is no player who can pull the trigger." },
|
|
240
|
+
notTurn: { 'zh-CN': "还没轮到你,现在轮到 {name}。", en: "It is not your turn. {name} plays next." },
|
|
241
|
+
notJoined: { 'zh-CN': "你不在这局轮盘里。", en: "You are not in this game." },
|
|
242
|
+
ended: { 'zh-CN': "对局已经结束。", en: "The game has ended." },
|
|
243
|
+
} as const;
|
|
244
|
+
type RouletteRuleCode = keyof typeof ruleCatalog;
|
|
245
|
+
const ruleMessages = createMessages(ruleCatalog);
|
package/src/game-flow.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createMessages, resolveLocale, type Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { gameCommandHint, GameSessionConflictError, SessionRevisionConflictError, channelKey, gameTargetFromKey, type GamePlayer } from '@zhin.js/game-kit';
|
|
3
|
+
import type { RouletteConfig } from './config.js';
|
|
4
|
+
import { RouletteRuleError, loserOf, type RouletteState } from './engine.js';
|
|
5
|
+
import type { RouletteSessionRow } from './models.js';
|
|
6
|
+
import type { MutePenaltyPort, PenaltyConversation } from './mute-penalty.js';
|
|
7
|
+
import { getRouletteHelp, type RouletteCommandAction } from './roulette-actions.js';
|
|
8
|
+
import {
|
|
9
|
+
playerFromMessage,
|
|
10
|
+
stateOf,
|
|
11
|
+
type RouletteAction,
|
|
12
|
+
type RouletteActionResult,
|
|
13
|
+
type RouletteServices,
|
|
14
|
+
} from './session-service.js';
|
|
15
|
+
import {
|
|
16
|
+
describeAbort,
|
|
17
|
+
describeClick,
|
|
18
|
+
describeEnding,
|
|
19
|
+
describeLobby,
|
|
20
|
+
describeRoundStart,
|
|
21
|
+
describeStatus,
|
|
22
|
+
} from './view.js';
|
|
23
|
+
|
|
24
|
+
/** Everything one action needs, resolved from the current runtime generation. */
|
|
25
|
+
export interface RouletteFlow {
|
|
26
|
+
readonly services: RouletteServices;
|
|
27
|
+
readonly config: RouletteConfig;
|
|
28
|
+
readonly penalty: MutePenaltyPort;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Who sent the action, from the runtime message. */
|
|
32
|
+
export interface RouletteActor {
|
|
33
|
+
readonly message: GamePlayer;
|
|
34
|
+
readonly roles: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExpiredSessionNotice {
|
|
38
|
+
readonly session: RouletteSessionRow;
|
|
39
|
+
readonly text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEADLINE_ACTOR = 'deadline';
|
|
43
|
+
/** zhin chat role `trusted` or higher (ADR 0056; platform standing does not count). */
|
|
44
|
+
const MANAGER_ROLES: readonly string[] = ['trusted'];
|
|
45
|
+
function noGame(message: GamePlayer): string { return messages(message.locale, 'noGame') + gameCommandHint(message, 'roulette', 'open'); }
|
|
46
|
+
|
|
47
|
+
export async function runRouletteAction(
|
|
48
|
+
flow: RouletteFlow,
|
|
49
|
+
actor: RouletteActor,
|
|
50
|
+
action: RouletteCommandAction,
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
return flow.services.sessions.runInScope({ scope: channelKey(actor.message), run: async () => {
|
|
53
|
+
const message = actor.message;
|
|
54
|
+
const locale = resolveLocale(message.locale);
|
|
55
|
+
if (action === 'help') return getRouletteHelp(message);
|
|
56
|
+
if (message.target.kind === 'private') return messages(locale, 'text1');
|
|
57
|
+
if (action === 'penalty-on' || action === 'penalty-off' || action === 'penalty-status') {
|
|
58
|
+
return handlePenaltySetting(flow, actor, action);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const notices: string[] = [];
|
|
62
|
+
let session = await flow.services.sessions.getActiveByChannel(channelKey(message));
|
|
63
|
+
if (session && isOverdue(session, Date.now())) {
|
|
64
|
+
const expired = await expireSession(flow, session);
|
|
65
|
+
if (expired) notices.push(expired);
|
|
66
|
+
session = null;
|
|
67
|
+
}
|
|
68
|
+
const reply = await handleGameAction(flow, actor, action, session);
|
|
69
|
+
return [...notices, reply].join('\n\n');
|
|
70
|
+
} });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Called by the deadline schedule; each returned notice still has to be delivered. */
|
|
74
|
+
export async function expireOverdueSessions(
|
|
75
|
+
flow: RouletteFlow,
|
|
76
|
+
now: number,
|
|
77
|
+
onNotice?: (notice: ExpiredSessionNotice) => Promise<void> | void,
|
|
78
|
+
isEnabled?: (session: RouletteSessionRow) => boolean,
|
|
79
|
+
): Promise<readonly ExpiredSessionNotice[]> {
|
|
80
|
+
const overdue = await flow.services.sessions.listOverdue(now);
|
|
81
|
+
const notices: ExpiredSessionNotice[] = [];
|
|
82
|
+
for (const session of overdue) {
|
|
83
|
+
try {
|
|
84
|
+
await flow.services.sessions.runInScope({ scope: session.channel_key, run: async () => {
|
|
85
|
+
// Switched off for this conversation (ADR 0050 / 0055): end the game silently, without a
|
|
86
|
+
// result, records, mute penalty or announcement.
|
|
87
|
+
if (isEnabled && !isEnabled(session)) {
|
|
88
|
+
await abortSilently(flow, session);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const text = await expireSession(flow, session);
|
|
92
|
+
if (!text) return;
|
|
93
|
+
const notice = { session, text };
|
|
94
|
+
notices.push(notice);
|
|
95
|
+
// Announce as soon as the result is stored, so a later failure cannot drop it.
|
|
96
|
+
if (onNotice) await onNotice(notice);
|
|
97
|
+
} });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// The session stays active when anything before the commit failed; the next sweep retries it.
|
|
100
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
101
|
+
console.warn(`[roulette] 处理超时对局 ${session.id} 失败,下次扫描重试:${reason}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return notices;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handleGameAction(
|
|
108
|
+
flow: RouletteFlow,
|
|
109
|
+
actor: RouletteActor,
|
|
110
|
+
action: RouletteCommandAction,
|
|
111
|
+
session: RouletteSessionRow | null,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
const message = actor.message;
|
|
114
|
+
const locale = resolveLocale(session ? session.locale : message.locale);
|
|
115
|
+
if (action === 'open') return openLobby(flow, actor, session);
|
|
116
|
+
if (!session) return noGame(message);
|
|
117
|
+
if (action === 'status') return describeStatus(stateOf(session), flow.config, { ...message, locale });
|
|
118
|
+
if (action === 'cancel' && !mayCancel(session, message, actor.roles)) {
|
|
119
|
+
return messages(locale, 'text2');
|
|
120
|
+
}
|
|
121
|
+
const gameAction = toGameAction(action, message);
|
|
122
|
+
if (!gameAction) return noGame(message);
|
|
123
|
+
try {
|
|
124
|
+
const result = await flow.services.sessions.applyAction(
|
|
125
|
+
session,
|
|
126
|
+
message.sender.id,
|
|
127
|
+
gameAction,
|
|
128
|
+
flow.config,
|
|
129
|
+
);
|
|
130
|
+
return describeResult(flow, gameAction, result, message);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof RouletteRuleError) return error.format(locale);
|
|
133
|
+
if (error instanceof GameSessionConflictError) return error.format(locale);
|
|
134
|
+
if (error instanceof SessionRevisionConflictError) return messages(locale, 'text3');
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function openLobby(
|
|
140
|
+
flow: RouletteFlow,
|
|
141
|
+
actor: RouletteActor,
|
|
142
|
+
session: RouletteSessionRow | null,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const locale = resolveLocale(session ? session.locale : actor.message.locale);
|
|
145
|
+
if (session) {
|
|
146
|
+
return messages(locale, 'text4', { p0: describeStatus(stateOf(session), flow.config, { ...actor.message, locale }) });
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const created = await flow.services.sessions.createLobby(actor.message, flow.config);
|
|
150
|
+
return describeLobby(stateOf(created), flow.config, { ...actor.message, locale });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof GameSessionConflictError) return error.format(locale);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function describeResult(
|
|
158
|
+
flow: RouletteFlow,
|
|
159
|
+
action: RouletteAction,
|
|
160
|
+
result: RouletteActionResult,
|
|
161
|
+
message: GamePlayer,
|
|
162
|
+
): Promise<string> {
|
|
163
|
+
const state = result.state;
|
|
164
|
+
const locale = resolveLocale(result.session.locale);
|
|
165
|
+
const presentation = { ...message, locale };
|
|
166
|
+
if (!result.applied) return describeStatus(state, flow.config, presentation);
|
|
167
|
+
if (state.phase === 'finished') return finishText(flow, result.session, state);
|
|
168
|
+
if (state.phase === 'aborted') return describeAbort(state, presentation);
|
|
169
|
+
if (action.type === 'start') return describeRoundStart(state, flow.config, presentation);
|
|
170
|
+
if (action.type === 'fire') {
|
|
171
|
+
return describeClick(playerFromMessage(message), state, flow.config, presentation);
|
|
172
|
+
}
|
|
173
|
+
if (action.type === 'leave') {
|
|
174
|
+
return messages(locale, 'text5', { p0: playerFromMessage(message).name, p1: describeLobby(state, flow.config, presentation) });
|
|
175
|
+
}
|
|
176
|
+
return describeLobby(state, flow.config, presentation);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function abortSilently(flow: RouletteFlow, session: RouletteSessionRow): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
await flow.services.sessions.applyAction(session, DEADLINE_ACTOR, { type: 'cancel' }, flow.config);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error instanceof SessionRevisionConflictError) return;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function expireSession(flow: RouletteFlow, session: RouletteSessionRow): Promise<string> {
|
|
189
|
+
// Read everything that can fail before the state transition is committed: once the
|
|
190
|
+
// session is stored as finished it is no longer overdue and would never be retried.
|
|
191
|
+
const penaltyEnabled = stateOf(session).phase === 'playing'
|
|
192
|
+
? await flow.services.settings.isMutePenaltyEnabled(session.channel_key)
|
|
193
|
+
: false;
|
|
194
|
+
try {
|
|
195
|
+
const result = await flow.services.sessions.applyAction(
|
|
196
|
+
session,
|
|
197
|
+
DEADLINE_ACTOR,
|
|
198
|
+
{ type: 'expire' },
|
|
199
|
+
flow.config,
|
|
200
|
+
);
|
|
201
|
+
if (!result.applied) return '';
|
|
202
|
+
if (result.state.phase === 'finished') return finishText(flow, result.session, result.state, penaltyEnabled);
|
|
203
|
+
return describeAbort(result.state, { locale: resolveLocale(result.session.locale) });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// A player acted on the session in the meantime; the next sweep re-reads it.
|
|
206
|
+
if (error instanceof SessionRevisionConflictError) return '';
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function finishText(
|
|
212
|
+
flow: RouletteFlow,
|
|
213
|
+
session: RouletteSessionRow,
|
|
214
|
+
state: RouletteState,
|
|
215
|
+
penaltyEnabled?: boolean,
|
|
216
|
+
): Promise<string> {
|
|
217
|
+
const penalty = await applyLoserPenalty(flow, session, state, penaltyEnabled);
|
|
218
|
+
return [describeEnding(state, { locale: resolveLocale(session.locale) }), penalty].filter(Boolean).join('\n');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Mute the loser only when this group opted in and the Endpoint declares muting. */
|
|
222
|
+
async function applyLoserPenalty(
|
|
223
|
+
flow: RouletteFlow,
|
|
224
|
+
session: RouletteSessionRow,
|
|
225
|
+
state: RouletteState,
|
|
226
|
+
penaltyEnabled?: boolean,
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
const locale = resolveLocale(session.locale);
|
|
229
|
+
const loser = loserOf(state);
|
|
230
|
+
if (!loser) return '';
|
|
231
|
+
const enabled = penaltyEnabled
|
|
232
|
+
?? await flow.services.settings.isMutePenaltyEnabled(session.channel_key);
|
|
233
|
+
if (!enabled) return '';
|
|
234
|
+
const conversation = conversationOfSession(session);
|
|
235
|
+
if (flow.penalty.support(conversation) !== 'supported') {
|
|
236
|
+
return messages(locale, 'text6');
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await flow.penalty.mute(conversation, loser.id, flow.config.muteSeconds);
|
|
240
|
+
return messages(locale, 'text7', { p0: loser.name, p1: flow.config.muteSeconds });
|
|
241
|
+
} catch {
|
|
242
|
+
return messages(locale, 'text8');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handlePenaltySetting(
|
|
247
|
+
flow: RouletteFlow,
|
|
248
|
+
actor: RouletteActor,
|
|
249
|
+
action: 'penalty-on' | 'penalty-off' | 'penalty-status',
|
|
250
|
+
): Promise<string> {
|
|
251
|
+
const message = actor.message;
|
|
252
|
+
const locale = resolveLocale(message.locale);
|
|
253
|
+
if (message.target.kind !== 'group') return messages(locale, 'text9');
|
|
254
|
+
const key = channelKey(message);
|
|
255
|
+
const settings = flow.services.settings;
|
|
256
|
+
const support = flow.penalty.support(conversationOfActor(actor));
|
|
257
|
+
if (action === 'penalty-status') {
|
|
258
|
+
const enabled = await settings.isMutePenaltyEnabled(key);
|
|
259
|
+
return describePenaltyStatus(enabled, support === 'supported', flow.config, locale);
|
|
260
|
+
}
|
|
261
|
+
if (!isManager(actor.roles)) return messages(locale, 'text10');
|
|
262
|
+
if (action === 'penalty-off') {
|
|
263
|
+
await settings.setMutePenalty(key, false, message.sender.id);
|
|
264
|
+
return messages(locale, 'text11');
|
|
265
|
+
}
|
|
266
|
+
if (support !== 'supported') {
|
|
267
|
+
return messages(locale, 'text12');
|
|
268
|
+
}
|
|
269
|
+
await settings.setMutePenalty(key, true, message.sender.id);
|
|
270
|
+
return messages(locale, 'text13', { p0: flow.config.muteSeconds });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function describePenaltyStatus(enabled: boolean, supported: boolean, config: RouletteConfig, locale?: Locale): string {
|
|
274
|
+
const platform = supported ? messages(locale, 'text14') : messages(locale, 'text15');
|
|
275
|
+
if (!enabled) return messages(locale, 'text16', { p0: platform });
|
|
276
|
+
return messages(locale, 'text17', { p0: config.muteSeconds, p1: platform });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toGameAction(action: RouletteCommandAction, message: GamePlayer): RouletteAction | null {
|
|
280
|
+
if (action === 'join') return { type: 'join', player: playerFromMessage(message) };
|
|
281
|
+
if (action === 'start') return { type: 'start' };
|
|
282
|
+
if (action === 'fire') return { type: 'fire' };
|
|
283
|
+
if (action === 'leave') return { type: 'leave' };
|
|
284
|
+
if (action === 'cancel') return { type: 'cancel' };
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mayCancel(
|
|
289
|
+
session: RouletteSessionRow,
|
|
290
|
+
message: GamePlayer,
|
|
291
|
+
senderRoles: readonly string[],
|
|
292
|
+
): boolean {
|
|
293
|
+
if (stateOf(session).hostId === message.sender.id) return true;
|
|
294
|
+
return isManager(senderRoles);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isManager(senderRoles: readonly string[]): boolean {
|
|
298
|
+
return senderRoles.some((role) => MANAGER_ROLES.includes(role));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isOverdue(session: RouletteSessionRow, now: number): boolean {
|
|
302
|
+
return session.deadline_at > 0 && session.deadline_at <= now;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function conversationOfSession(session: RouletteSessionRow): PenaltyConversation {
|
|
306
|
+
const target = gameTargetFromKey(session.channel_key);
|
|
307
|
+
if (!target) throw new Error('Historical roulette session awaits explicit target migration');
|
|
308
|
+
return { endpointAdapter: target.endpoint.adapter, endpointId: target.endpoint.id, kind: target.kind, id: target.id };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function conversationOfActor(actor: RouletteActor): PenaltyConversation {
|
|
312
|
+
return {
|
|
313
|
+
endpointAdapter: actor.message.target.endpoint.adapter,
|
|
314
|
+
endpointId: actor.message.target.endpoint.id,
|
|
315
|
+
kind: actor.message.target.kind,
|
|
316
|
+
id: actor.message.target.id,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const messages = createMessages({
|
|
321
|
+
noGame: { 'zh-CN': '本群没有进行中的轮盘。', en: 'This group has no active roulette game.' },
|
|
322
|
+
text1: { 'zh-CN': "轮盘需要多人参与,请在群聊里玩。", en: "Roulette needs multiple players. Please play in a group chat." },
|
|
323
|
+
text2: { 'zh-CN': "只有开局的人、群主、群管理员或机器人管理员可以取消本局。", en: "Only the host, group owner, group admin or bot administrator may cancel this game." },
|
|
324
|
+
text3: { 'zh-CN': "对局状态刚刚变了,请再发一次。", en: "The game state just changed. Please try again." },
|
|
325
|
+
text4: { 'zh-CN': "本群已经有一局轮盘了。\n{p0}", en: "This group already has a roulette game.\n{p0}" },
|
|
326
|
+
text5: { 'zh-CN': "{p0} 离开了。\n{p1}", en: "{p0} left.\n{p1}" },
|
|
327
|
+
text6: { 'zh-CN': "(本群开启了禁言惩罚,但当前平台无法禁言,本局只计分。)", en: "(Mute penalties are enabled, but this platform cannot mute players. Only scores were recorded.)" },
|
|
328
|
+
text7: { 'zh-CN': "🔇 {p0} 被禁言 {p1} 秒。", en: "🔇 {p0} was muted for {p1} seconds." },
|
|
329
|
+
text8: { 'zh-CN': "(禁言没有成功,可能是机器人没有群管理权限;本局只计分。)", en: "(Muting failed, possibly because the bot lacks group management permission. Only scores were recorded.)" },
|
|
330
|
+
text9: { 'zh-CN': "禁言惩罚只能在群聊里设置。", en: "Mute penalties can only be configured in a group chat." },
|
|
331
|
+
text10: { 'zh-CN': "只有本会话 zhin 角色 trusted 及以上(owner / admin / trusted)的用户可以修改禁言惩罚。", en: "Only users with the zhin role trusted or higher (owner / admin / trusted) in this conversation may change mute penalties." },
|
|
332
|
+
text11: { 'zh-CN': "已关闭禁言惩罚,本群轮盘只计分。", en: "Mute penalties are disabled. This group now records scores only." },
|
|
333
|
+
text12: { 'zh-CN': "当前平台不支持禁言,禁言惩罚没有开启;本群轮盘只计分。", en: "This platform does not support muting. Mute penalties remain disabled; only scores are recorded." },
|
|
334
|
+
text13: { 'zh-CN': "已开启禁言惩罚:每局输家会被禁言 {p0} 秒(需要机器人有群管理权限)。", en: "Mute penalties enabled: each loser is muted for {p0} seconds (the bot needs group management permission)." },
|
|
335
|
+
text14: { 'zh-CN': "当前平台支持禁言。", en: "This platform supports muting." },
|
|
336
|
+
text15: { 'zh-CN': "当前平台不支持禁言,只能计分。", en: "This platform does not support muting; only scores are recorded." },
|
|
337
|
+
text16: { 'zh-CN': "本群禁言惩罚:关闭。{p0}", en: "Group mute penalty: off. {p0}" },
|
|
338
|
+
text17: { 'zh-CN': "本群禁言惩罚:开启,输家禁言 {p0} 秒。{p1}", en: "Group mute penalty: on, the loser is muted for {p0} seconds. {p1}" },
|
|
339
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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 {
|
|
6
|
+
resolveMutePenalty,
|
|
7
|
+
type AdapterProjectionContext,
|
|
8
|
+
type MutePenaltyPort,
|
|
9
|
+
type MuteSupport,
|
|
10
|
+
type PenaltyConversation,
|
|
11
|
+
} from './mute-penalty.js';
|
|
12
|
+
export { ROULETTE_HELP, normalizeRouletteAction, type RouletteCommandAction } from './roulette-actions.js';
|
|
13
|
+
export { rouletteServicesToken, resolveRouletteFlow } from './runtime.js';
|
|
14
|
+
export {
|
|
15
|
+
RouletteGroupSettings,
|
|
16
|
+
RouletteSessionService,
|
|
17
|
+
createServices,
|
|
18
|
+
type RouletteServices,
|
|
19
|
+
} from './session-service.js';
|