@zhin.js/plugin-roulette 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +45 -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 +208 -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/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;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { VersionedSessionService, channelKey, generateCompactId, secureRandomInt, } from '@zhin.js/game-kit';
|
|
2
|
+
import { cancelGame, createLobby, currentPlayer, decodeRouletteState, expireLobby, expireTurn, isOpen, joinLobby, leaveGame, pullTrigger, startRound, } from './engine.js';
|
|
3
|
+
export class RouletteSessionService extends VersionedSessionService {
|
|
4
|
+
#chooseBulletChamber;
|
|
5
|
+
constructor(database, options = {}) {
|
|
6
|
+
super(database, {
|
|
7
|
+
gameId: 'roulette',
|
|
8
|
+
table: 'roulette_sessions',
|
|
9
|
+
userFields: ['host_id'],
|
|
10
|
+
projectOutcomes: projectRouletteOutcomes,
|
|
11
|
+
});
|
|
12
|
+
this.#chooseBulletChamber = options.chooseBulletChamber ?? secureRandomInt;
|
|
13
|
+
}
|
|
14
|
+
async createLobby(message, endpoint, config) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const host = playerFromMessage(message);
|
|
17
|
+
const state = createLobby(host, tableRules(config));
|
|
18
|
+
return this.createRow({
|
|
19
|
+
id: generateCompactId('rlt_'),
|
|
20
|
+
adapter: String(message.$adapter),
|
|
21
|
+
endpoint: message.$endpoint,
|
|
22
|
+
channel_type: message.$channel.type,
|
|
23
|
+
channel_id: message.$channel.id,
|
|
24
|
+
channel_key: channelKey(message),
|
|
25
|
+
endpoint_adapter: endpoint.adapter,
|
|
26
|
+
endpoint_id: endpoint.id,
|
|
27
|
+
host_id: host.id,
|
|
28
|
+
state_json: JSON.stringify(state),
|
|
29
|
+
phase: state.phase,
|
|
30
|
+
turn: '',
|
|
31
|
+
revision: 0,
|
|
32
|
+
processed_actions: '[]',
|
|
33
|
+
deadline_at: now + config.lobbyTimeoutSeconds * 1000,
|
|
34
|
+
status: 'active',
|
|
35
|
+
updated_at: now,
|
|
36
|
+
created_at: now,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Every seated player counts as busy, not just the host. */
|
|
40
|
+
async getActiveForUser(channelKeyValue, userId) {
|
|
41
|
+
const rows = await this.model.findAll({ channel_key: channelKeyValue, status: 'active' });
|
|
42
|
+
const seated = rows.find((row) => decodeRouletteState(row.state_json).players.some((player) => player.id === userId));
|
|
43
|
+
return seated ?? null;
|
|
44
|
+
}
|
|
45
|
+
async applyAction(session, actorId, action, config) {
|
|
46
|
+
if (action.type === 'join') {
|
|
47
|
+
await this.assertPlayersAvailable(session.channel_key, [action.player.id]);
|
|
48
|
+
}
|
|
49
|
+
const result = await this.mutateSession(session.id, {
|
|
50
|
+
actionId: `${session.id}:${session.revision}:${actorId}:${action.type}`,
|
|
51
|
+
expectedRevision: session.revision,
|
|
52
|
+
apply: (current) => {
|
|
53
|
+
const before = decodeRouletteState(current.state_json);
|
|
54
|
+
const after = this.#transition(before, actorId, action, config);
|
|
55
|
+
return rowPatch(after, config, Date.now());
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
session: result.session,
|
|
60
|
+
state: decodeRouletteState(result.session.state_json),
|
|
61
|
+
applied: result.kind === 'applied',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Open sessions whose lobby or turn deadline has passed. */
|
|
65
|
+
async listOverdue(now) {
|
|
66
|
+
const rows = await this.model.findAll({ status: 'active' });
|
|
67
|
+
return rows.filter((row) => row.deadline_at > 0 && row.deadline_at <= now);
|
|
68
|
+
}
|
|
69
|
+
#transition(state, actorId, action, config) {
|
|
70
|
+
switch (action.type) {
|
|
71
|
+
case 'join':
|
|
72
|
+
return joinLobby(state, action.player, tableRules(config));
|
|
73
|
+
case 'start':
|
|
74
|
+
return startRound(state, actorId, this.#chooseBulletChamber(state.chamberCount));
|
|
75
|
+
case 'fire':
|
|
76
|
+
return pullTrigger(state, actorId).state;
|
|
77
|
+
case 'leave':
|
|
78
|
+
return leaveGame(state, actorId);
|
|
79
|
+
case 'cancel':
|
|
80
|
+
return cancelGame(state);
|
|
81
|
+
case 'expire':
|
|
82
|
+
return state.phase === 'lobby' ? expireLobby(state) : expireTurn(state);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Per-group switches. The mute penalty is off until a group turns it on. */
|
|
87
|
+
export class RouletteGroupSettings {
|
|
88
|
+
#model;
|
|
89
|
+
constructor(database) {
|
|
90
|
+
const model = database.models.get('roulette_group_settings');
|
|
91
|
+
if (!model)
|
|
92
|
+
throw new Error('roulette_group_settings not registered');
|
|
93
|
+
this.#model = model;
|
|
94
|
+
}
|
|
95
|
+
async isMutePenaltyEnabled(channelKeyValue) {
|
|
96
|
+
const row = await this.#model.findOne({ id: channelKeyValue });
|
|
97
|
+
return row?.mute_penalty === 1;
|
|
98
|
+
}
|
|
99
|
+
async setMutePenalty(channelKeyValue, enabled, actorId) {
|
|
100
|
+
const patch = {
|
|
101
|
+
mute_penalty: enabled ? 1 : 0,
|
|
102
|
+
updated_by: actorId,
|
|
103
|
+
updated_at: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
const existing = await this.#model.findOne({ id: channelKeyValue });
|
|
106
|
+
if (existing) {
|
|
107
|
+
await this.#model.updateWhere({ id: channelKeyValue }, patch);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await this.#model.create({ id: channelKeyValue, ...patch });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function createServices(database, options = {}) {
|
|
114
|
+
return {
|
|
115
|
+
sessions: new RouletteSessionService(database, options),
|
|
116
|
+
settings: new RouletteGroupSettings(database),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function stateOf(session) {
|
|
120
|
+
return decodeRouletteState(session.state_json);
|
|
121
|
+
}
|
|
122
|
+
export function playerFromMessage(message) {
|
|
123
|
+
const name = message.$sender.name?.trim() || message.$sender.id;
|
|
124
|
+
return { id: message.$sender.id, name };
|
|
125
|
+
}
|
|
126
|
+
function tableRules(config) {
|
|
127
|
+
return { chamberCount: config.chambers, maxPlayers: config.maxPlayers };
|
|
128
|
+
}
|
|
129
|
+
function rowPatch(state, config, now) {
|
|
130
|
+
return {
|
|
131
|
+
state_json: JSON.stringify(state),
|
|
132
|
+
phase: state.phase,
|
|
133
|
+
turn: currentPlayer(state)?.id ?? '',
|
|
134
|
+
host_id: state.hostId,
|
|
135
|
+
deadline_at: deadlineFor(state, config, now),
|
|
136
|
+
status: statusFor(state),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function deadlineFor(state, config, now) {
|
|
140
|
+
if (state.phase === 'playing')
|
|
141
|
+
return now + config.turnTimeoutSeconds * 1000;
|
|
142
|
+
if (state.phase === 'lobby')
|
|
143
|
+
return now + config.lobbyTimeoutSeconds * 1000;
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
function statusFor(state) {
|
|
147
|
+
if (isOpen(state))
|
|
148
|
+
return 'active';
|
|
149
|
+
if (state.phase === 'finished')
|
|
150
|
+
return 'finished';
|
|
151
|
+
return 'aborted';
|
|
152
|
+
}
|
|
153
|
+
/** Survivors win one point each; the loser records a loss. Aborted games record nothing. */
|
|
154
|
+
function projectRouletteOutcomes(session) {
|
|
155
|
+
if (session.status !== 'finished')
|
|
156
|
+
return [];
|
|
157
|
+
const state = decodeRouletteState(session.state_json);
|
|
158
|
+
return state.players.map((player) => {
|
|
159
|
+
const lost = player.id === state.loserId;
|
|
160
|
+
return {
|
|
161
|
+
userId: player.id,
|
|
162
|
+
userName: player.name,
|
|
163
|
+
result: lost ? 'lost' : 'won',
|
|
164
|
+
score: lost ? 0 : 1,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
package/lib/view.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RouletteConfig } from './config.js';
|
|
2
|
+
import { type RoulettePlayer, type RouletteState } from './engine.js';
|
|
3
|
+
export declare const ROULETTE_TITLE = "\uD83D\uDD2B \u4FC4\u7F57\u65AF\u8F6E\u76D8";
|
|
4
|
+
export declare function describeLobby(state: RouletteState, config: RouletteConfig): string;
|
|
5
|
+
export declare function describeRoundStart(state: RouletteState, config: RouletteConfig): string;
|
|
6
|
+
export declare function describeTurn(state: RouletteState, config: RouletteConfig): string;
|
|
7
|
+
export declare function describeClick(shooter: RoulettePlayer, state: RouletteState, config: RouletteConfig): string;
|
|
8
|
+
export declare function describeEnding(state: RouletteState): string;
|
|
9
|
+
export declare function describeAbort(state: RouletteState): string;
|
|
10
|
+
export declare function describeStatus(state: RouletteState, config: RouletteConfig): string;
|
package/lib/view.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { MIN_ROULETTE_PLAYERS, currentPlayer, loserOf, remainingChambers, survivorsOf, } from './engine.js';
|
|
2
|
+
export const ROULETTE_TITLE = '🔫 俄罗斯轮盘';
|
|
3
|
+
export function describeLobby(state, config) {
|
|
4
|
+
const host = state.players.find((player) => player.id === state.hostId);
|
|
5
|
+
const lines = [
|
|
6
|
+
`${ROULETTE_TITLE} · 等待玩家(${state.players.length}/${config.maxPlayers})`,
|
|
7
|
+
`玩家:${listNames(state.players)}`,
|
|
8
|
+
`发送「轮盘 加入」上桌;${host?.name ?? '房主'} 发送「轮盘 开始」开局(至少 ${MIN_ROULETTE_PLAYERS} 人)。`,
|
|
9
|
+
`${config.lobbyTimeoutSeconds} 秒内没有开始会自动解散。`,
|
|
10
|
+
];
|
|
11
|
+
return lines.join('\n');
|
|
12
|
+
}
|
|
13
|
+
export function describeRoundStart(state, config) {
|
|
14
|
+
const order = state.players.map((player, index) => `${index + 1}. ${player.name}`);
|
|
15
|
+
const lines = [
|
|
16
|
+
`${ROULETTE_TITLE} · 开局!${state.chamberCount} 个弹巢里装了 1 发子弹,已转好弹巢。`,
|
|
17
|
+
'开枪顺序:',
|
|
18
|
+
...order,
|
|
19
|
+
describeTurn(state, config),
|
|
20
|
+
];
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
export function describeTurn(state, config) {
|
|
24
|
+
const shooter = currentPlayer(state);
|
|
25
|
+
if (!shooter)
|
|
26
|
+
return '';
|
|
27
|
+
return `👉 轮到 ${shooter.name}:发送「开枪」,${config.turnTimeoutSeconds} 秒内不开枪判负。`
|
|
28
|
+
+ `(剩余 ${remainingChambers(state)} 个弹巢)`;
|
|
29
|
+
}
|
|
30
|
+
export function describeClick(shooter, state, config) {
|
|
31
|
+
return [`咔哒……${shooter.name} 的这一枪是空膛。`, describeTurn(state, config)].join('\n');
|
|
32
|
+
}
|
|
33
|
+
export function describeEnding(state) {
|
|
34
|
+
const loser = loserOf(state);
|
|
35
|
+
if (!loser)
|
|
36
|
+
return describeAbort(state);
|
|
37
|
+
const headline = endingHeadline(state, loser);
|
|
38
|
+
const survivors = survivorsOf(state);
|
|
39
|
+
return [
|
|
40
|
+
headline,
|
|
41
|
+
`🏆 幸存:${listNames(survivors)}(各 +1 胜)`,
|
|
42
|
+
'发送「排行 轮盘」查看本群排行。',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
export function describeAbort(state) {
|
|
46
|
+
if (state.endReason === 'lobby-expired')
|
|
47
|
+
return `${ROULETTE_TITLE} 长时间没有开始,已自动解散。`;
|
|
48
|
+
if (state.endReason === 'lobby-empty')
|
|
49
|
+
return `${ROULETTE_TITLE} 所有人都离开了,已解散。`;
|
|
50
|
+
return `${ROULETTE_TITLE} 已取消,本局不计分。`;
|
|
51
|
+
}
|
|
52
|
+
export function describeStatus(state, config) {
|
|
53
|
+
if (state.phase === 'lobby')
|
|
54
|
+
return describeLobby(state, config);
|
|
55
|
+
const lines = [
|
|
56
|
+
`${ROULETTE_TITLE} · 进行中,已开 ${state.pullsMade} 枪`,
|
|
57
|
+
`玩家:${listNames(state.players)}`,
|
|
58
|
+
describeTurn(state, config),
|
|
59
|
+
];
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
function endingHeadline(state, loser) {
|
|
63
|
+
if (state.endReason === 'turn-timeout')
|
|
64
|
+
return `⌛ ${loser.name} 超时没有开枪,判负!`;
|
|
65
|
+
if (state.endReason === 'forfeit')
|
|
66
|
+
return `🏳️ ${loser.name} 中途离场,判负!`;
|
|
67
|
+
return `💥 砰!${loser.name} 中弹出局(第 ${state.pullsMade} 枪)。`;
|
|
68
|
+
}
|
|
69
|
+
function listNames(players) {
|
|
70
|
+
if (players.length === 0)
|
|
71
|
+
return '无';
|
|
72
|
+
return players.map((player) => player.name).join('、');
|
|
73
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineGameCommandAliasMiddleware } from '@zhin.js/game-kit';
|
|
3
|
+
import { runRouletteCommand } from "../../lib/roulette-command.js";
|
|
4
|
+
/** `轮盘 加入` / `/轮盘赌 开枪` / `roulette start` route to the same logic as the command. */
|
|
5
|
+
export default defineGameCommandAliasMiddleware({
|
|
6
|
+
aliases: ['轮盘', '轮盘赌', 'roulette'],
|
|
7
|
+
async run(action, input, context) {
|
|
8
|
+
return runRouletteCommand(context, input, action);
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { channelKey, messageFromCommandInput } from '@zhin.js/game-kit';
|
|
3
|
+
import { defineMiddleware } from 'zhin.js/middleware';
|
|
4
|
+
import { runRouletteCommand } from "../../lib/roulette-command.js";
|
|
5
|
+
import { rouletteServicesToken } from "../../lib/runtime.js";
|
|
6
|
+
const FIRE_WORDS = ['开枪', '扣动扳机'];
|
|
7
|
+
/**
|
|
8
|
+
* A bare 「开枪」 fires only for the player whose turn it is in this group,
|
|
9
|
+
* so the word stays ordinary chat everywhere else.
|
|
10
|
+
*/
|
|
11
|
+
export default defineMiddleware({
|
|
12
|
+
target: 'inbound',
|
|
13
|
+
async handle(context, next) {
|
|
14
|
+
const text = context.input.content?.trim() ?? '';
|
|
15
|
+
if (!FIRE_WORDS.includes(text)) {
|
|
16
|
+
await next();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const message = messageFromCommandInput(context.input);
|
|
20
|
+
const services = context.use(rouletteServicesToken);
|
|
21
|
+
const session = await services.sessions.getActiveByChannel(channelKey(message));
|
|
22
|
+
if (!session || session.turn !== message.$sender.id) {
|
|
23
|
+
await next();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const reply = await runRouletteCommand(context, context.input, 'fire');
|
|
27
|
+
if (reply)
|
|
28
|
+
await context.input.$reply(reply);
|
|
29
|
+
},
|
|
30
|
+
});
|