@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/src/models.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Models } from '@zhin.js/core';
2
+
3
+ export type RouletteSessionStatus = 'active' | 'finished' | 'aborted';
4
+
5
+ declare module '@zhin.js/core' {
6
+ interface Models {
7
+ roulette_sessions: {
8
+ id: string;
9
+ locale?: 'zh-CN' | 'en' | null;
10
+ adapter: string;
11
+ endpoint: string;
12
+ channel_type: string;
13
+ channel_id: string;
14
+ channel_key: string;
15
+ /** Exact Endpoint identity (`conversation.endpoint`), used for the mute lookup. */
16
+ endpoint_adapter: string;
17
+ endpoint_id: string;
18
+ host_id: string;
19
+ state_json: string;
20
+ phase: string;
21
+ turn: string;
22
+ revision: number;
23
+ processed_actions: string;
24
+ /** Epoch ms when the lobby or the current turn times out; 0 once the game is over. */
25
+ deadline_at: number;
26
+ status: RouletteSessionStatus;
27
+ updated_at: number;
28
+ created_at: number;
29
+ };
30
+ roulette_group_settings: {
31
+ /** The channel key, one row per group. */
32
+ id: string;
33
+ mute_penalty: number;
34
+ updated_by: string;
35
+ updated_at: number;
36
+ };
37
+ }
38
+ }
39
+
40
+ export type RouletteSessionRow = Models['roulette_sessions'];
41
+ export type RouletteGroupSettingsRow = Models['roulette_group_settings'];
42
+
43
+ export const ROULETTE_TABLES = ['roulette_sessions', 'roulette_group_settings'] as const;
44
+
45
+ export function defineHostTables(
46
+ database: { define(name: string, definition: Record<string, unknown>, options?: { readonly addColumns?: readonly string[] }): void },
47
+ ): void {
48
+ database.define('roulette_sessions', {
49
+ id: { type: 'text', primary: true },
50
+ locale: { type: 'text', nullable: true },
51
+ adapter: { type: 'text', nullable: false },
52
+ endpoint: { type: 'text', nullable: false },
53
+ channel_type: { type: 'text', nullable: false },
54
+ channel_id: { type: 'text', nullable: false },
55
+ channel_key: { type: 'text', nullable: false },
56
+ endpoint_adapter: { type: 'text', default: '' },
57
+ endpoint_id: { type: 'text', default: '' },
58
+ host_id: { type: 'text', nullable: false },
59
+ state_json: { type: 'text', nullable: false },
60
+ phase: { type: 'text', default: 'lobby' },
61
+ turn: { type: 'text', default: '' },
62
+ revision: { type: 'integer', default: 0 },
63
+ processed_actions: { type: 'text', default: '[]' },
64
+ deadline_at: { type: 'bigint', default: 0 },
65
+ status: { type: 'text', default: 'active' },
66
+ updated_at: { type: 'bigint', default: 0 },
67
+ created_at: { type: 'bigint', default: 0 },
68
+ }, { addColumns: ['locale'] });
69
+ database.define('roulette_group_settings', {
70
+ id: { type: 'text', primary: true },
71
+ mute_penalty: { type: 'integer', default: 0 },
72
+ updated_by: { type: 'text', default: '' },
73
+ updated_at: { type: 'bigint', default: 0 },
74
+ });
75
+ }
@@ -0,0 +1,88 @@
1
+ import { adapterFeatureId, type AdapterIndex, type EndpointMuteRequest } from 'zhin.js/adapter';
2
+
3
+ /**
4
+ * Where a game was played: the exact Endpoint identity from `message.conversation.endpoint`
5
+ * (adapter plugin owner + Endpoint capability id) plus the conversation itself.
6
+ */
7
+ export interface PenaltyConversation {
8
+ readonly endpointAdapter: string;
9
+ readonly endpointId: string;
10
+ readonly kind: string;
11
+ readonly id: string;
12
+ }
13
+
14
+ export type MuteSupport =
15
+ /** The live Endpoint's management port implements `muteGroupMember` and this is a group. */
16
+ | 'supported'
17
+ /** Muting only exists for group members (not private chats or channels). */
18
+ | 'not-a-group'
19
+ /** No adapter projection, no such Endpoint, or its management port cannot mute. */
20
+ | 'unsupported';
21
+
22
+ /**
23
+ * The only mute surface the game uses. It never looks at adapter names: support
24
+ * is whatever the live Endpoint's platform-neutral `EndpointManagement` implements.
25
+ */
26
+ export interface MutePenaltyPort {
27
+ support(conversation: PenaltyConversation): MuteSupport;
28
+ mute(conversation: PenaltyConversation, userId: string, durationSeconds: number): Promise<void>;
29
+ }
30
+
31
+ /** The capability context of a command, middleware or schedule run. */
32
+ export interface AdapterProjectionContext {
33
+ project<T>(feature: typeof adapterFeatureId): T;
34
+ }
35
+
36
+ type MuteGroupMember = (request: EndpointMuteRequest) => Promise<void>;
37
+
38
+ type EndpointLookup = Pick<AdapterIndex, 'resolve' | 'management'>;
39
+
40
+ export function resolveMutePenalty(context: AdapterProjectionContext): MutePenaltyPort {
41
+ return {
42
+ support(conversation) {
43
+ if (conversation.kind !== 'group') return 'not-a-group';
44
+ return findMuteGroupMember(context, conversation) ? 'supported' : 'unsupported';
45
+ },
46
+ async mute(conversation, userId, durationSeconds) {
47
+ const muteGroupMember = findMuteGroupMember(context, conversation);
48
+ if (!muteGroupMember) {
49
+ throw new Error('This Endpoint cannot mute group members');
50
+ }
51
+ await muteGroupMember({ groupId: conversation.id, userId, durationSeconds });
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Resolve canonical identity, then capture its guarded management port from the same Index. */
57
+ function findMuteGroupMember(
58
+ context: AdapterProjectionContext,
59
+ conversation: PenaltyConversation,
60
+ ): MuteGroupMember | undefined {
61
+ const adapters = readAdapterProjection(context);
62
+ if (!adapters) return undefined;
63
+ if (!conversation.endpointAdapter || !conversation.endpointId) return undefined;
64
+ let management: ReturnType<EndpointLookup['management']>;
65
+ try {
66
+ const id = adapters.resolve(conversation.endpointAdapter, conversation.endpointId);
67
+ management = id ? adapters.management(id) : undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ const muteGroupMember = management?.muteGroupMember;
72
+ if (typeof muteGroupMember !== 'function') return undefined;
73
+ return muteGroupMember.bind(management);
74
+ }
75
+
76
+ function readAdapterProjection(context: AdapterProjectionContext): EndpointLookup | undefined {
77
+ let projection: unknown;
78
+ try {
79
+ projection = context.project<unknown>(adapterFeatureId);
80
+ } catch {
81
+ // No Adapter Feature in this runtime: score only.
82
+ return undefined;
83
+ }
84
+ if (!projection || typeof projection !== 'object') return undefined;
85
+ const lookup = projection as Partial<EndpointLookup>;
86
+ if (typeof lookup.resolve !== 'function' || typeof lookup.management !== 'function') return undefined;
87
+ return lookup as EndpointLookup;
88
+ }
@@ -0,0 +1,76 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+ import type { GamePlayer } from '@zhin.js/game-kit';
3
+ export type RouletteCommandAction =
4
+ | 'help'
5
+ | 'open'
6
+ | 'join'
7
+ | 'start'
8
+ | 'fire'
9
+ | 'leave'
10
+ | 'cancel'
11
+ | 'status'
12
+ | 'penalty-on'
13
+ | 'penalty-off'
14
+ | 'penalty-status';
15
+
16
+ const ACTION_WORDS: Readonly<Record<string, RouletteCommandAction>> = {
17
+ help: 'help',
18
+ 帮助: 'help',
19
+ 规则: 'help',
20
+ open: 'open',
21
+ 开局: 'open',
22
+ 创建: 'open',
23
+ join: 'join',
24
+ 加入: 'join',
25
+ start: 'start',
26
+ 开始: 'start',
27
+ fire: 'fire',
28
+ shoot: 'fire',
29
+ 开枪: 'fire',
30
+ 扣动扳机: 'fire',
31
+ leave: 'leave',
32
+ 退出: 'leave',
33
+ 离开: 'leave',
34
+ 认输: 'leave',
35
+ cancel: 'cancel',
36
+ 取消: 'cancel',
37
+ status: 'status',
38
+ 状态: 'status',
39
+ 'mute-on': 'penalty-on',
40
+ 开启禁言: 'penalty-on',
41
+ 'mute-off': 'penalty-off',
42
+ 关闭禁言: 'penalty-off',
43
+ mute: 'penalty-status',
44
+ 禁言: 'penalty-status',
45
+ };
46
+
47
+ /** Empty input means help; unknown words return null so normal chat is never hijacked. */
48
+ export function normalizeRouletteAction(raw: string): RouletteCommandAction | null {
49
+ const word = raw.trim();
50
+ if (!word) return 'help';
51
+ return ACTION_WORDS[word] ?? ACTION_WORDS[word.toLowerCase()] ?? null;
52
+ }
53
+
54
+
55
+ const messages = createMessages({
56
+ title: { 'zh-CN': '🔫 俄罗斯轮盘(2 人起,轮流开枪,中弹者输)', en: '🔫 Russian Roulette (two or more players, take turns; the player hit loses)' },
57
+ open: { 'zh-CN': '创建一局并上桌', en: 'Open a lobby and join it' },
58
+ join: { 'zh-CN': '加入等待中的对局', en: 'Join a waiting lobby' },
59
+ start: { 'zh-CN': '房主开局(弹巢随机装 1 发子弹)', en: 'Host starts the game (one randomly placed bullet)' },
60
+ fire: { 'zh-CN': '轮到你时扣动扳机', en: 'Pull the trigger on your turn' },
61
+ leave: { 'zh-CN': '等待时离开;对局中离开算认输', en: 'Leave the lobby; leaving during play forfeits the game' },
62
+ cancel: { 'zh-CN': '房主或管理员取消本局(不计分)', en: 'Host or manager cancels the game (no score)' },
63
+ status: { 'zh-CN': '查看当前对局', en: 'View the current game' },
64
+ mute: { 'zh-CN': '查看禁言惩罚', en: 'View the mute penalty setting' },
65
+ 'mute-on': { 'zh-CN': '开启本群禁言惩罚', en: 'Enable mute penalties for this group' },
66
+ 'mute-off': { 'zh-CN': '关闭本群禁言惩罚', en: 'Disable mute penalties for this group' },
67
+ rules: { 'zh-CN': '规则:按加入顺序轮流开枪;轮到的人超时不开枪判负;幸存者各记 1 胜,输家记 1 负。禁言惩罚默认关闭。', en: 'Players take turns in join order. Missing your turn deadline loses the game. Each survivor gets one win; the loser gets one loss. Mute penalties are off by default.' },
68
+ });
69
+ export function getRouletteHelp(message: Pick<GamePlayer, 'locale' | 'formatCommand'> = {}): string {
70
+ const command = message.formatCommand?.('roulette');
71
+ const actions = ['open', 'join', 'start', 'fire', 'leave', 'cancel', 'status', 'mute', 'mute-on', 'mute-off'] as const;
72
+ return [messages(message.locale, 'title'),
73
+ ...(command === undefined ? [] : actions.map(action => `${command} ${action} — ${messages(message.locale, action)}`)),
74
+ '', messages(message.locale, 'rules')].join('\n');
75
+ }
76
+ export const ROULETTE_HELP = getRouletteHelp();
@@ -0,0 +1,24 @@
1
+ import { gamePlayerFromInput } from '@zhin.js/game-kit';
2
+ import { runRouletteAction } from './game-flow.js';
3
+ import { normalizeRouletteAction, getRouletteHelp } from './roulette-actions.js';
4
+ import { resolveRouletteFlow, senderRolesOf, type RouletteCapabilityContext } from './runtime.js';
5
+
6
+ /**
7
+ * Shared entry for the `roulette` command and the `轮盘` alias middleware.
8
+ * Returns null for unknown words so the alias middleware lets the chat through.
9
+ */
10
+ export async function runRouletteCommand(
11
+ context: RouletteCapabilityContext,
12
+ input: unknown,
13
+ rawAction: string,
14
+ ): Promise<string | null> {
15
+ const action = normalizeRouletteAction(rawAction);
16
+ if (!action) return null;
17
+ if (action === 'help') return getRouletteHelp(gamePlayerFromInput(input, context));
18
+ const flow = resolveRouletteFlow(context);
19
+ const actor = {
20
+ message: gamePlayerFromInput(input, context),
21
+ roles: senderRolesOf(input),
22
+ };
23
+ return runRouletteAction(flow, actor, action);
24
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Locale } from 'zhin.js/i18n';
2
+ import { definePluginStore, type DeliveryTarget, type Token } from 'zhin.js';
3
+ import { resolveRouletteConfig } from './config.js';
4
+ import type { RouletteFlow } from './game-flow.js';
5
+ import { resolveMutePenalty, type AdapterProjectionContext } from './mute-penalty.js';
6
+ import type { RouletteServices } from './session-service.js';
7
+
8
+ /** Owner-scoped services consumed by discovered commands, middleware and the deadline job. */
9
+ export const rouletteServicesStore = definePluginStore<RouletteServices>('zhin.game.roulette.services');
10
+ export const rouletteServicesToken = rouletteServicesStore;
11
+
12
+ /**
13
+ * Whether roulette is enabled for one game conversation (ADR 0050 / 0055). The deadline sweep is
14
+ * not a message, so the dispatch gate does not see it; the sweep asks before announcing a result.
15
+ */
16
+ export const rouletteFeatureGateStore = definePluginStore<RouletteFeatureGate>('zhin.game.roulette.feature-gate');
17
+ export interface RouletteFeatureGate { isEnabledFor(target: DeliveryTarget): boolean }
18
+
19
+ /** A command, middleware or schedule capability context. */
20
+ export interface RouletteCapabilityContext extends AdapterProjectionContext {
21
+ readonly locale?: Locale;
22
+ readonly formatCommand?: (path: string) => string | undefined;
23
+ use<T>(token: Token<T>): T;
24
+ readonly config?: unknown;
25
+ }
26
+
27
+ export function resolveRouletteFlow(context: RouletteCapabilityContext): RouletteFlow {
28
+ return {
29
+ services: rouletteServicesStore.use(context),
30
+ config: resolveRouletteConfig(context.config),
31
+ penalty: resolveMutePenalty(context),
32
+ };
33
+ }
34
+
35
+ const CHAT_ROLE_CHAIN = ['owner', 'admin', 'trusted'] as const;
36
+
37
+ /** Chat roles (ADR 0056, expanded: owner → owner / admin / trusted) of whoever sent the message. */
38
+ export function senderRolesOf(input: unknown): readonly string[] {
39
+ if (!input || typeof input !== 'object') return [];
40
+ const sender = (input as { readonly sender?: unknown }).sender;
41
+ if (!sender || typeof sender !== 'object') return [];
42
+ const role = (sender as { readonly chatRole?: unknown }).chatRole;
43
+ const index = CHAT_ROLE_CHAIN.indexOf(role as typeof CHAT_ROLE_CHAIN[number]);
44
+ return index < 0 ? [] : CHAT_ROLE_CHAIN.slice(index);
45
+ }
@@ -0,0 +1,266 @@
1
+ import { encodeStorageIdentity } from 'zhin.js/plugin-runtime';
2
+ import type { Database, Models } from '@zhin.js/core';
3
+ import {
4
+ VersionedSessionService,
5
+ generateCompactId,
6
+ secureRandomInt,
7
+ type GamePlayer,
8
+ type GameOutcome,
9
+ type GameSessionDatabase,
10
+ } from '@zhin.js/game-kit';
11
+ import type { RouletteConfig } from './config.js';
12
+ import {
13
+ cancelGame,
14
+ createLobby,
15
+ currentPlayer,
16
+ decodeRouletteState,
17
+ expireLobby,
18
+ expireTurn,
19
+ isOpen,
20
+ joinLobby,
21
+ leaveGame,
22
+ pullTrigger,
23
+ startRound,
24
+ type RoulettePlayer,
25
+ type RouletteState,
26
+ type RouletteTableRules,
27
+ } from './engine.js';
28
+ import type {
29
+ RouletteGroupSettingsRow,
30
+ RouletteSessionRow,
31
+ RouletteSessionStatus,
32
+ } from './models.js';
33
+
34
+ export type RouletteDatabase = Database<unknown, Models, string>;
35
+
36
+ export type RouletteAction =
37
+ | { readonly type: 'join'; readonly player: RoulettePlayer }
38
+ | { readonly type: 'start' }
39
+ | { readonly type: 'fire' }
40
+ | { readonly type: 'leave' }
41
+ | { readonly type: 'cancel' }
42
+ | { readonly type: 'expire' };
43
+
44
+ export interface RouletteActionResult {
45
+ readonly session: RouletteSessionRow;
46
+ readonly state: RouletteState;
47
+ /** False when an identical action id was already applied (adapter retry). */
48
+ readonly applied: boolean;
49
+ }
50
+
51
+ export interface RouletteServiceOptions {
52
+ /** Picks the loaded chamber when a round starts; secure random by default. */
53
+ readonly chooseBulletChamber?: (chamberCount: number) => number;
54
+ }
55
+
56
+ export class RouletteSessionService extends VersionedSessionService<RouletteSessionRow> {
57
+ readonly #chooseBulletChamber: (chamberCount: number) => number;
58
+
59
+ constructor(database: RouletteDatabase, options: RouletteServiceOptions = {}) {
60
+ super(database as unknown as GameSessionDatabase<RouletteSessionRow>, {
61
+ gameId: 'roulette',
62
+ table: 'roulette_sessions',
63
+ userFields: ['host_id'],
64
+ projectOutcomes: projectRouletteOutcomes,
65
+ });
66
+ this.#chooseBulletChamber = options.chooseBulletChamber ?? secureRandomInt;
67
+ }
68
+
69
+ async createLobby(
70
+ message: GamePlayer,
71
+ config: RouletteConfig,
72
+ ): Promise<RouletteSessionRow> {
73
+ const now = Date.now();
74
+ const host = playerFromMessage(message);
75
+ const state = createLobby(host, tableRules(config));
76
+ return this.createRow({
77
+ target: message.target,
78
+ fields: {
79
+ id: generateCompactId('rlt_'),
80
+ locale: message.locale ?? 'zh-CN',
81
+ endpoint_adapter: encodeStorageIdentity(message.target.endpoint.adapter),
82
+ endpoint_id: encodeStorageIdentity(message.target.endpoint.id),
83
+ host_id: host.id,
84
+ state_json: JSON.stringify(state),
85
+ phase: state.phase,
86
+ turn: '',
87
+ revision: 0,
88
+ processed_actions: '[]',
89
+ deadline_at: now + config.lobbyTimeoutSeconds * 1000,
90
+ status: 'active',
91
+ updated_at: now,
92
+ created_at: now,
93
+ },
94
+ });
95
+ }
96
+
97
+ /** Every seated player counts as busy, not just the host. */
98
+ override async getActiveForUser(
99
+ channelKeyValue: string,
100
+ userId: string,
101
+ ): Promise<RouletteSessionRow | null> {
102
+ const rows = await this.model.findAll({ channel_key: channelKeyValue, status: 'active' });
103
+ const seated = rows.find((row) =>
104
+ decodeRouletteState(row.state_json).players.some((player) => player.id === userId));
105
+ return seated ?? null;
106
+ }
107
+
108
+ async applyAction(
109
+ session: RouletteSessionRow,
110
+ actorId: string,
111
+ action: RouletteAction,
112
+ config: RouletteConfig,
113
+ ): Promise<RouletteActionResult> {
114
+ if (action.type === 'join') {
115
+ await this.assertPlayersAvailable(session.channel_key, [action.player.id]);
116
+ }
117
+ const result = await this.mutateSession(session.id, {
118
+ actionId: `${session.id}:${session.revision}:${actorId}:${action.type}`,
119
+ expectedRevision: session.revision,
120
+ apply: (current) => {
121
+ const before = decodeRouletteState(current.state_json);
122
+ const after = this.#transition(before, actorId, action, config);
123
+ return rowPatch(after, config, Date.now());
124
+ },
125
+ });
126
+ return {
127
+ session: result.session,
128
+ state: decodeRouletteState(result.session.state_json),
129
+ applied: result.kind === 'applied',
130
+ };
131
+ }
132
+
133
+ /** Open sessions whose lobby or turn deadline has passed. */
134
+ async listOverdue(now: number): Promise<readonly RouletteSessionRow[]> {
135
+ const rows = await this.model.findAll({ status: 'active' });
136
+ return rows.filter((row) => row.deadline_at > 0 && row.deadline_at <= now);
137
+ }
138
+
139
+ #transition(
140
+ state: RouletteState,
141
+ actorId: string,
142
+ action: RouletteAction,
143
+ config: RouletteConfig,
144
+ ): RouletteState {
145
+ switch (action.type) {
146
+ case 'join':
147
+ return joinLobby(state, action.player, tableRules(config));
148
+ case 'start':
149
+ return startRound(state, actorId, this.#chooseBulletChamber(state.chamberCount));
150
+ case 'fire':
151
+ return pullTrigger(state, actorId).state;
152
+ case 'leave':
153
+ return leaveGame(state, actorId);
154
+ case 'cancel':
155
+ return cancelGame(state);
156
+ case 'expire':
157
+ return state.phase === 'lobby' ? expireLobby(state) : expireTurn(state);
158
+ }
159
+ }
160
+ }
161
+
162
+ interface GroupSettingsModel {
163
+ findOne(query: Record<string, unknown>): Promise<RouletteGroupSettingsRow | null>;
164
+ create(row: RouletteGroupSettingsRow): Promise<unknown>;
165
+ updateWhere(where: Record<string, unknown>, patch: Partial<RouletteGroupSettingsRow>): Promise<unknown>;
166
+ }
167
+
168
+ interface GroupSettingsDatabase {
169
+ readonly models: { get(name: string): unknown };
170
+ }
171
+
172
+ /** Per-group switches. The mute penalty is off until a group turns it on. */
173
+ export class RouletteGroupSettings {
174
+ readonly #model: GroupSettingsModel;
175
+
176
+ constructor(database: RouletteDatabase) {
177
+ const model = (database as unknown as GroupSettingsDatabase).models.get('roulette_group_settings');
178
+ if (!model) throw new Error('roulette_group_settings not registered');
179
+ this.#model = model as GroupSettingsModel;
180
+ }
181
+
182
+ async isMutePenaltyEnabled(channelKeyValue: string): Promise<boolean> {
183
+ const row = await this.#model.findOne({ id: channelKeyValue });
184
+ return row?.mute_penalty === 1;
185
+ }
186
+
187
+ async setMutePenalty(channelKeyValue: string, enabled: boolean, actorId: string): Promise<void> {
188
+ const patch = {
189
+ mute_penalty: enabled ? 1 : 0,
190
+ updated_by: actorId,
191
+ updated_at: Date.now(),
192
+ };
193
+ const existing = await this.#model.findOne({ id: channelKeyValue });
194
+ if (existing) {
195
+ await this.#model.updateWhere({ id: channelKeyValue }, patch);
196
+ return;
197
+ }
198
+ await this.#model.create({ id: channelKeyValue, ...patch });
199
+ }
200
+ }
201
+
202
+ export interface RouletteServices {
203
+ readonly sessions: RouletteSessionService;
204
+ readonly settings: RouletteGroupSettings;
205
+ }
206
+
207
+ export function createServices(
208
+ database: RouletteDatabase,
209
+ options: RouletteServiceOptions = {},
210
+ ): RouletteServices {
211
+ return {
212
+ sessions: new RouletteSessionService(database, options),
213
+ settings: new RouletteGroupSettings(database),
214
+ };
215
+ }
216
+
217
+ export function stateOf(session: RouletteSessionRow): RouletteState {
218
+ return decodeRouletteState(session.state_json);
219
+ }
220
+
221
+ export function playerFromMessage(message: GamePlayer): RoulettePlayer {
222
+ const name = message.sender.name?.trim() || message.sender.id;
223
+ return { id: message.sender.id, name };
224
+ }
225
+
226
+ function tableRules(config: RouletteConfig): RouletteTableRules {
227
+ return { chamberCount: config.chambers, maxPlayers: config.maxPlayers };
228
+ }
229
+
230
+ function rowPatch(state: RouletteState, config: RouletteConfig, now: number): Partial<RouletteSessionRow> {
231
+ return {
232
+ state_json: JSON.stringify(state),
233
+ phase: state.phase,
234
+ turn: currentPlayer(state)?.id ?? '',
235
+ host_id: state.hostId,
236
+ deadline_at: deadlineFor(state, config, now),
237
+ status: statusFor(state),
238
+ };
239
+ }
240
+
241
+ function deadlineFor(state: RouletteState, config: RouletteConfig, now: number): number {
242
+ if (state.phase === 'playing') return now + config.turnTimeoutSeconds * 1000;
243
+ if (state.phase === 'lobby') return now + config.lobbyTimeoutSeconds * 1000;
244
+ return 0;
245
+ }
246
+
247
+ function statusFor(state: RouletteState): RouletteSessionStatus {
248
+ if (isOpen(state)) return 'active';
249
+ if (state.phase === 'finished') return 'finished';
250
+ return 'aborted';
251
+ }
252
+
253
+ /** Survivors win one point each; the loser records a loss. Aborted games record nothing. */
254
+ function projectRouletteOutcomes(session: Readonly<RouletteSessionRow>): readonly GameOutcome[] {
255
+ if (session.status !== 'finished') return [];
256
+ const state = decodeRouletteState(session.state_json);
257
+ return state.players.map((player) => {
258
+ const lost = player.id === state.loserId;
259
+ return {
260
+ userId: player.id,
261
+ userName: player.name,
262
+ result: lost ? 'lost' : 'won',
263
+ score: lost ? 0 : 1,
264
+ };
265
+ });
266
+ }
package/src/view.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+ import type { GamePlayer } from '@zhin.js/game-kit';
3
+ import type { RouletteConfig } from './config.js';
4
+ import { MIN_ROULETTE_PLAYERS, currentPlayer, loserOf, remainingChambers, survivorsOf,
5
+ type RoulettePlayer, type RouletteState } from './engine.js';
6
+
7
+ type Presentation = Pick<GamePlayer, 'locale' | 'formatCommand'>;
8
+ const messages = createMessages({
9
+ title: { 'zh-CN': '🔫 俄罗斯轮盘', en: '🔫 Russian Roulette' },
10
+ lobby: { 'zh-CN': '{title} · 等待玩家({count}/{max})', en: '{title} · Waiting for players ({count}/{max})' },
11
+ players: { 'zh-CN': '玩家:{players}', en: 'Players: {players}' },
12
+ host: { 'zh-CN': '房主', en: 'Host' },
13
+ joinStart: { 'zh-CN': '发送「{join}」上桌;{host} 发送「{start}」开局(至少 {min} 人)。', en: 'Send “{join}” to join. {host}: send “{start}” to start (at least {min} players).' },
14
+ lobbyExpiry: { 'zh-CN': '{seconds} 秒内没有开始会自动解散。', en: 'The lobby closes if the game does not start within {seconds} seconds.' },
15
+ started: { 'zh-CN': '{title} · 开局!{chambers} 个弹巢里装了 1 发子弹,已转好弹巢。', en: '{title} · Started! One bullet in {chambers} chambers. The cylinder has been spun.' },
16
+ order: { 'zh-CN': '开枪顺序:', en: 'Turn order:' },
17
+ turn: { 'zh-CN': '👉 轮到 {name}:{instruction}{seconds} 秒内不开枪判负。(剩余 {chambers} 个弹巢)', en: '👉 {name} to play: {instruction}pull the trigger within {seconds} seconds or lose. ({chambers} chambers remaining)' },
18
+ fire: { 'zh-CN': '发送「{command}」,', en: 'send “{command}”; ' },
19
+ click: { 'zh-CN': '咔哒……{name} 的这一枪是空膛。', en: 'Click… {name} found an empty chamber.' },
20
+ survived: { 'zh-CN': '🏆 幸存:{names}(各 +1 胜)', en: '🏆 Survivors: {names} (+1 win each)' },
21
+ leaderboard: { 'zh-CN': '可在游戏大厅查看本群排行。', en: 'The group leaderboard is available in the game lobby.' },
22
+ expired: { 'zh-CN': '{title} 长时间没有开始,已自动解散。', en: '{title}: the lobby expired before the game started.' },
23
+ empty: { 'zh-CN': '{title} 所有人都离开了,已解散。', en: '{title}: everyone left; the lobby is closed.' },
24
+ aborted: { 'zh-CN': '{title} 已取消,本局不计分。', en: '{title}: cancelled. No scores were recorded.' },
25
+ status: { 'zh-CN': '{title} · 进行中,已开 {pulls} 枪', en: '{title} · In progress, {pulls} trigger pulls' },
26
+ timeout: { 'zh-CN': '⌛ {name} 超时没有开枪,判负!', en: '⌛ {name} ran out of time and loses!' },
27
+ forfeit: { 'zh-CN': '🏳️ {name} 中途离场,判负!', en: '🏳️ {name} left the game and forfeits!' },
28
+ shot: { 'zh-CN': '💥 砰!{name} 中弹出局(第 {pulls} 枪)。', en: '💥 Bang! {name} is out (trigger pull {pulls}).' },
29
+ none: { 'zh-CN': '无', en: 'None' },
30
+ });
31
+ export const ROULETTE_TITLE = messages('zh-CN', 'title');
32
+
33
+ export function describeLobby(state: RouletteState, config: RouletteConfig, presentation: Presentation = {}): string {
34
+ const locale = presentation.locale;
35
+ const host = state.players.find(player => player.id === state.hostId);
36
+ const command = presentation.formatCommand?.('roulette');
37
+ return [messages(locale, 'lobby', { title: messages(locale, 'title'), count: state.players.length, max: config.maxPlayers }),
38
+ messages(locale, 'players', { players: listNames(state.players, presentation) }),
39
+ ...(command === undefined ? [] : [messages(locale, 'joinStart', { join: `${command} join`, start: `${command} start`, host: host?.name ?? messages(locale, 'host'), min: MIN_ROULETTE_PLAYERS })]),
40
+ messages(locale, 'lobbyExpiry', { seconds: config.lobbyTimeoutSeconds }),
41
+ ].join('\n');
42
+ }
43
+
44
+ export function describeRoundStart(state: RouletteState, config: RouletteConfig, presentation: Presentation = {}): string {
45
+ return [messages(presentation.locale, 'started', { title: messages(presentation.locale, 'title'), chambers: state.chamberCount }),
46
+ messages(presentation.locale, 'order'), ...state.players.map((player, index) => `${index + 1}. ${player.name}`),
47
+ describeTurn(state, config, presentation),
48
+ ].join('\n');
49
+ }
50
+
51
+ export function describeTurn(state: RouletteState, config: RouletteConfig, presentation: Presentation = {}): string {
52
+ const shooter = currentPlayer(state);
53
+ if (!shooter) return '';
54
+ const command = presentation.formatCommand?.('roulette');
55
+ return messages(presentation.locale, 'turn', { name: shooter.name, seconds: config.turnTimeoutSeconds,
56
+ chambers: remainingChambers(state), instruction: command === undefined ? ''
57
+ : messages(presentation.locale, 'fire', { command: `${command} fire` }) });
58
+ }
59
+
60
+ export function describeClick(shooter: RoulettePlayer, state: RouletteState, config: RouletteConfig, presentation: Presentation = {}): string {
61
+ return [messages(presentation.locale, 'click', { name: shooter.name }), describeTurn(state, config, presentation)].join('\n');
62
+ }
63
+
64
+ export function describeEnding(state: RouletteState, presentation: Presentation = {}): string {
65
+ const loser = loserOf(state);
66
+ if (!loser) return describeAbort(state, presentation);
67
+ const key = state.endReason === 'turn-timeout' ? 'timeout' : state.endReason === 'forfeit' ? 'forfeit' : 'shot';
68
+ return [messages(presentation.locale, key, { name: loser.name, pulls: state.pullsMade }),
69
+ messages(presentation.locale, 'survived', { names: listNames(survivorsOf(state), presentation) }),
70
+ messages(presentation.locale, 'leaderboard'),
71
+ ].join('\n');
72
+ }
73
+
74
+ export function describeAbort(state: RouletteState, presentation: Presentation = {}): string {
75
+ return messages(presentation.locale, state.endReason === 'lobby-expired' ? 'expired' : state.endReason === 'lobby-empty' ? 'empty' : 'aborted',
76
+ { title: messages(presentation.locale, 'title') });
77
+ }
78
+
79
+ export function describeStatus(state: RouletteState, config: RouletteConfig, presentation: Presentation = {}): string {
80
+ if (state.phase === 'lobby') return describeLobby(state, config, presentation);
81
+ return [messages(presentation.locale, 'status', { title: messages(presentation.locale, 'title'), pulls: state.pullsMade }),
82
+ messages(presentation.locale, 'players', { players: listNames(state.players, presentation) }),
83
+ describeTurn(state, config, presentation),
84
+ ].join('\n');
85
+ }
86
+
87
+ function listNames(players: readonly RoulettePlayer[], presentation: Presentation): string {
88
+ return players.length ? players.map(player => player.name).join(presentation.locale === 'en' ? ', ' : '、') : messages(presentation.locale, 'none');
89
+ }