@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/README.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhin.js/plugin-roulette
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
俄罗斯轮盘:群里多人轮流扣动扳机,弹巢里只有一发子弹,中弹的人输。接入游戏大厅,战绩和排行由 `@zhin.js/plugin-game-hub` 统一展示。每个群可以单独开启「输家禁言」惩罚,默认关闭。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
plugins:
|
|
9
|
+
game-hub: "@zhin.js/plugin-game-hub"
|
|
10
|
+
roulette: "@zhin.js/plugin-roulette"
|
|
11
|
+
database:
|
|
12
|
+
dialect: sqlite
|
|
13
|
+
storage: ./data/zhin.db
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
没有数据库时对局保存在内存里,重启后丢失。
|
|
17
|
+
|
|
18
|
+
## 命令
|
|
19
|
+
|
|
20
|
+
| 命令 | 说明 |
|
|
21
|
+
|------|------|
|
|
22
|
+
| `轮盘` / `轮盘 帮助` | 玩法说明 |
|
|
23
|
+
| `轮盘 开局` | 创建一局,自己先上桌 |
|
|
24
|
+
| `轮盘 加入` | 加入等待中的对局 |
|
|
25
|
+
| `轮盘 开始` | 房主开局,至少 2 人 |
|
|
26
|
+
| `开枪`(或 `轮盘 开枪`) | 轮到你时扣动扳机;没轮到你时「开枪」只是普通聊天 |
|
|
27
|
+
| `轮盘 退出` | 等待时离开;对局中离开算认输 |
|
|
28
|
+
| `轮盘 取消` | 房主或机器人管理员取消本局,不计分 |
|
|
29
|
+
| `轮盘 状态` | 查看当前对局 |
|
|
30
|
+
| `轮盘 禁言` | 查看本群禁言惩罚和平台是否支持禁言 |
|
|
31
|
+
| `轮盘 开启禁言` / `轮盘 关闭禁言` | 机器人主人(`master`)或受信任用户(`trusted`)切换本群禁言惩罚 |
|
|
32
|
+
|
|
33
|
+
`/轮盘赌`、`roulette` 是同一组命令的别名;也可以从 `/游戏` 大厅进入。
|
|
34
|
+
|
|
35
|
+
## 规则
|
|
36
|
+
|
|
37
|
+
- 一个群同一时间只有一局。玩家按加入顺序轮流开枪,弹巢数量和人数上限见配置。
|
|
38
|
+
- 开局时随机把子弹装进其中一个弹巢,之后按顺序逐个击发,所以最多一轮弹巢就会分出胜负。
|
|
39
|
+
- 轮到的人超过 `turnTimeoutSeconds` 不开枪,判负;每开一枪重新计时。
|
|
40
|
+
- 开局后超过 `lobbyTimeoutSeconds` 还没开始,自动解散,不计分。
|
|
41
|
+
- 对局中离开等同认输。
|
|
42
|
+
- 结束后幸存者各记 1 胜(得 1 分),输家记 1 负。对局和群设置都存在数据库表里(`roulette_sessions`、`roulette_group_settings`),不使用插件 state store。取消或解散的对局不记录。用 `战绩`、`排行 轮盘` 查看。
|
|
43
|
+
|
|
44
|
+
## 禁言惩罚
|
|
45
|
+
|
|
46
|
+
- 按群开关,默认关闭。只有机器人主人或受信任用户可以切换,判断的是框架角色,不读取平台的群管理员身份。
|
|
47
|
+
- 插件不判断平台名称。每次需要时,用消息自带的 `conversation.endpoint`(适配器插件和 Endpoint 能力 id)走 `project(adapterFeatureId).connection(adapter, endpointId)`,再看 `resolveEndpointManagement(endpoint)?.muteGroupMember` 是否存在。存在就能禁言,不存在(或运行时没有 Adapter Feature)就只计分。
|
|
48
|
+
- 超时判负由 `schedules/deadlines` 每 5 秒检查一次,它拿到的也是同样的能力上下文,所以超时的输家同样会按上面的规则处理。
|
|
49
|
+
- 平台不支持禁言时无法开启;开启后平台失去这项能力,或禁言被拒绝(例如机器人不是群管理员),这一局照常结算,只是不禁言。
|
|
50
|
+
- 只在群聊里生效,输家禁言 `muteSeconds` 秒。
|
|
51
|
+
|
|
52
|
+
## 配置
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
roulette:
|
|
56
|
+
chambers: 6 # 弹巢数,2-12
|
|
57
|
+
maxPlayers: 6 # 每局人数上限,2-10
|
|
58
|
+
turnTimeoutSeconds: 60 # 轮到的人多久不开枪判负,10-600
|
|
59
|
+
lobbyTimeoutSeconds: 300 # 开局后多久不开始自动解散,30-3600
|
|
60
|
+
muteSeconds: 60 # 开启禁言惩罚的群里输家禁言秒数,10-3600
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 开发
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pnpm --filter @zhin.js/plugin-roulette build
|
|
67
|
+
pnpm vitest run plugins/games/roulette/tests
|
|
68
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { ROULETTE_HELP } from "../../../lib/roulette-actions.js";
|
|
4
|
+
import { runRouletteCommand } from "../../../lib/roulette-command.js";
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
description: '俄罗斯轮盘:多人轮流开枪,中弹者输',
|
|
7
|
+
params: { action: { type: 'string', default: '' } },
|
|
8
|
+
async execute(context) {
|
|
9
|
+
const action = String(context.params.action ?? '');
|
|
10
|
+
const reply = await runRouletteCommand(context, context.input, action);
|
|
11
|
+
return reply ?? `未知操作:${action}\n\n${ROULETTE_HELP}`;
|
|
12
|
+
},
|
|
13
|
+
});
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Plugin instance configuration (`schema.json`), with defaults and safe bounds. */
|
|
2
|
+
export interface RouletteConfig {
|
|
3
|
+
/** Chambers in the cylinder; one holds the bullet. */
|
|
4
|
+
readonly chambers: number;
|
|
5
|
+
readonly maxPlayers: number;
|
|
6
|
+
/** The player on turn loses when they do not fire within this time. */
|
|
7
|
+
readonly turnTimeoutSeconds: number;
|
|
8
|
+
/** A lobby that never starts is dissolved after this time. */
|
|
9
|
+
readonly lobbyTimeoutSeconds: number;
|
|
10
|
+
/** Mute length for the loser, used only in groups that enabled the mute penalty. */
|
|
11
|
+
readonly muteSeconds: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_ROULETTE_CONFIG: RouletteConfig;
|
|
14
|
+
export declare function resolveRouletteConfig(raw: unknown): RouletteConfig;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const CONFIG_BOUNDS = {
|
|
2
|
+
chambers: { fallback: 6, min: 2, max: 12 },
|
|
3
|
+
maxPlayers: { fallback: 6, min: 2, max: 10 },
|
|
4
|
+
turnTimeoutSeconds: { fallback: 60, min: 10, max: 600 },
|
|
5
|
+
lobbyTimeoutSeconds: { fallback: 300, min: 30, max: 3600 },
|
|
6
|
+
muteSeconds: { fallback: 60, min: 10, max: 3600 },
|
|
7
|
+
};
|
|
8
|
+
export const DEFAULT_ROULETTE_CONFIG = resolveRouletteConfig(undefined);
|
|
9
|
+
export function resolveRouletteConfig(raw) {
|
|
10
|
+
const source = raw && typeof raw === 'object' ? raw : {};
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
chambers: readInteger(source.chambers, CONFIG_BOUNDS.chambers),
|
|
13
|
+
maxPlayers: readInteger(source.maxPlayers, CONFIG_BOUNDS.maxPlayers),
|
|
14
|
+
turnTimeoutSeconds: readInteger(source.turnTimeoutSeconds, CONFIG_BOUNDS.turnTimeoutSeconds),
|
|
15
|
+
lobbyTimeoutSeconds: readInteger(source.lobbyTimeoutSeconds, CONFIG_BOUNDS.lobbyTimeoutSeconds),
|
|
16
|
+
muteSeconds: readInteger(source.muteSeconds, CONFIG_BOUNDS.muteSeconds),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function readInteger(value, bounds) {
|
|
20
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
21
|
+
return bounds.fallback;
|
|
22
|
+
if (value < bounds.min)
|
|
23
|
+
return bounds.min;
|
|
24
|
+
if (value > bounds.max)
|
|
25
|
+
return bounds.max;
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RouletteCapabilityContext } from './runtime.js';
|
|
2
|
+
/** Turn and lobby deadlines are checked every five seconds. */
|
|
3
|
+
export declare const DEADLINE_CRON = "*/5 * * * * *";
|
|
4
|
+
/**
|
|
5
|
+
* One deadline sweep. It runs with the schedule's capability context, so a
|
|
6
|
+
* timed-out loser gets the same mute check (adapter projection) as one who
|
|
7
|
+
* was shot in a command.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runDeadlineSweep(context: RouletteCapabilityContext, now: number): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { outboundHostToken } from 'zhin.js';
|
|
2
|
+
import { expireOverdueSessions } from './game-flow.js';
|
|
3
|
+
import { resolveRouletteFlow } from './runtime.js';
|
|
4
|
+
/** Turn and lobby deadlines are checked every five seconds. */
|
|
5
|
+
export const DEADLINE_CRON = '*/5 * * * * *';
|
|
6
|
+
/**
|
|
7
|
+
* One deadline sweep. It runs with the schedule's capability context, so a
|
|
8
|
+
* timed-out loser gets the same mute check (adapter projection) as one who
|
|
9
|
+
* was shot in a command.
|
|
10
|
+
*/
|
|
11
|
+
export async function runDeadlineSweep(context, now) {
|
|
12
|
+
const flow = resolveRouletteFlow(context);
|
|
13
|
+
const notices = await expireOverdueSessions(flow, now);
|
|
14
|
+
const outbound = readOutbound(context);
|
|
15
|
+
if (!outbound)
|
|
16
|
+
return;
|
|
17
|
+
for (const notice of notices) {
|
|
18
|
+
await announce(outbound, notice);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function readOutbound(context) {
|
|
22
|
+
try {
|
|
23
|
+
return context.use(outboundHostToken);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function announce(outbound, notice) {
|
|
30
|
+
const session = notice.session;
|
|
31
|
+
try {
|
|
32
|
+
await outbound.send({
|
|
33
|
+
adapter: session.adapter,
|
|
34
|
+
endpointKey: session.endpoint,
|
|
35
|
+
conversation: {
|
|
36
|
+
kind: session.channel_type,
|
|
37
|
+
id: session.channel_id,
|
|
38
|
+
},
|
|
39
|
+
content: notice.text,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// The result is already stored; a failed notice must not stop the other sessions.
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/engine.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Russian roulette rules. No I/O, no clock and no randomness: callers pass
|
|
3
|
+
* the bullet position in, so every transition is deterministic and testable.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ROULETTE_STATE_VERSION = 1;
|
|
6
|
+
export type RoulettePhase = 'lobby' | 'playing' | 'finished' | 'aborted';
|
|
7
|
+
export type RouletteEndReason = 'shot' | 'turn-timeout' | 'forfeit' | 'cancelled' | 'lobby-expired' | 'lobby-empty';
|
|
8
|
+
export interface RoulettePlayer {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RouletteState {
|
|
13
|
+
readonly schemaVersion: typeof ROULETTE_STATE_VERSION;
|
|
14
|
+
readonly phase: RoulettePhase;
|
|
15
|
+
readonly hostId: string;
|
|
16
|
+
readonly players: readonly RoulettePlayer[];
|
|
17
|
+
readonly chamberCount: number;
|
|
18
|
+
/** Zero-based chamber holding the bullet; -1 until the round starts. */
|
|
19
|
+
readonly bulletChamber: number;
|
|
20
|
+
/** Trigger pulls so far; the next pull fires chamber `pullsMade`. */
|
|
21
|
+
readonly pullsMade: number;
|
|
22
|
+
/** Index into `players` of whoever must pull next. */
|
|
23
|
+
readonly turnIndex: number;
|
|
24
|
+
readonly loserId?: string;
|
|
25
|
+
readonly endReason?: RouletteEndReason;
|
|
26
|
+
}
|
|
27
|
+
export interface RouletteTableRules {
|
|
28
|
+
readonly chamberCount: number;
|
|
29
|
+
readonly maxPlayers: number;
|
|
30
|
+
}
|
|
31
|
+
export declare const MIN_ROULETTE_PLAYERS = 2;
|
|
32
|
+
export type TriggerOutcome = 'click' | 'bang';
|
|
33
|
+
export interface TriggerResult {
|
|
34
|
+
readonly state: RouletteState;
|
|
35
|
+
readonly outcome: TriggerOutcome;
|
|
36
|
+
readonly shooter: RoulettePlayer;
|
|
37
|
+
}
|
|
38
|
+
/** A rule violation the player should read, e.g. firing out of turn. */
|
|
39
|
+
export declare class RouletteRuleError extends Error {
|
|
40
|
+
constructor(message: string);
|
|
41
|
+
}
|
|
42
|
+
export declare function createLobby(host: RoulettePlayer, rules: RouletteTableRules): RouletteState;
|
|
43
|
+
export declare function joinLobby(state: RouletteState, player: RoulettePlayer, rules: RouletteTableRules): RouletteState;
|
|
44
|
+
export declare function startRound(state: RouletteState, actorId: string, bulletChamber: number): RouletteState;
|
|
45
|
+
export declare function pullTrigger(state: RouletteState, actorId: string): TriggerResult;
|
|
46
|
+
/**
|
|
47
|
+
* Leaving the lobby just removes the player. Leaving a running round is a
|
|
48
|
+
* forfeit: the leaver loses, so nobody can dodge the bullet by walking away.
|
|
49
|
+
*/
|
|
50
|
+
export declare function leaveGame(state: RouletteState, playerId: string): RouletteState;
|
|
51
|
+
/** The player on turn ran out of time: treated like a shot, they lose. */
|
|
52
|
+
export declare function expireTurn(state: RouletteState): RouletteState;
|
|
53
|
+
export declare function expireLobby(state: RouletteState): RouletteState;
|
|
54
|
+
export declare function cancelGame(state: RouletteState): RouletteState;
|
|
55
|
+
export declare function isOpen(state: RouletteState): boolean;
|
|
56
|
+
export declare function currentPlayer(state: RouletteState): RoulettePlayer | undefined;
|
|
57
|
+
export declare function findPlayer(state: RouletteState, playerId: string): RoulettePlayer | undefined;
|
|
58
|
+
export declare function loserOf(state: RouletteState): RoulettePlayer | undefined;
|
|
59
|
+
export declare function survivorsOf(state: RouletteState): readonly RoulettePlayer[];
|
|
60
|
+
export declare function remainingChambers(state: RouletteState): number;
|
|
61
|
+
export declare function decodeRouletteState(json: string): RouletteState;
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Russian roulette rules. No I/O, no clock and no randomness: callers pass
|
|
3
|
+
* the bullet position in, so every transition is deterministic and testable.
|
|
4
|
+
*/
|
|
5
|
+
export const ROULETTE_STATE_VERSION = 1;
|
|
6
|
+
export const MIN_ROULETTE_PLAYERS = 2;
|
|
7
|
+
/** A rule violation the player should read, e.g. firing out of turn. */
|
|
8
|
+
export class RouletteRuleError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'RouletteRuleError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function createLobby(host, rules) {
|
|
15
|
+
return {
|
|
16
|
+
schemaVersion: ROULETTE_STATE_VERSION,
|
|
17
|
+
phase: 'lobby',
|
|
18
|
+
hostId: host.id,
|
|
19
|
+
players: [host],
|
|
20
|
+
chamberCount: rules.chamberCount,
|
|
21
|
+
bulletChamber: -1,
|
|
22
|
+
pullsMade: 0,
|
|
23
|
+
turnIndex: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function joinLobby(state, player, rules) {
|
|
27
|
+
requirePhase(state, 'lobby', '对局已经开始,不能再加入了。');
|
|
28
|
+
if (findPlayer(state, player.id)) {
|
|
29
|
+
throw new RouletteRuleError('你已经在这局轮盘里了。');
|
|
30
|
+
}
|
|
31
|
+
if (state.players.length >= rules.maxPlayers) {
|
|
32
|
+
throw new RouletteRuleError(`人数已满(最多 ${rules.maxPlayers} 人)。`);
|
|
33
|
+
}
|
|
34
|
+
return { ...state, players: [...state.players, player] };
|
|
35
|
+
}
|
|
36
|
+
export function startRound(state, actorId, bulletChamber) {
|
|
37
|
+
requirePhase(state, 'lobby', '对局已经开始了。');
|
|
38
|
+
if (actorId !== state.hostId) {
|
|
39
|
+
throw new RouletteRuleError('只有开局的人可以开始。');
|
|
40
|
+
}
|
|
41
|
+
if (state.players.length < MIN_ROULETTE_PLAYERS) {
|
|
42
|
+
throw new RouletteRuleError(`至少需要 ${MIN_ROULETTE_PLAYERS} 名玩家才能开始。`);
|
|
43
|
+
}
|
|
44
|
+
if (!Number.isInteger(bulletChamber) || bulletChamber < 0 || bulletChamber >= state.chamberCount) {
|
|
45
|
+
throw new RangeError(`bulletChamber must be within 0..${state.chamberCount - 1}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
phase: 'playing',
|
|
50
|
+
bulletChamber,
|
|
51
|
+
pullsMade: 0,
|
|
52
|
+
turnIndex: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function pullTrigger(state, actorId) {
|
|
56
|
+
requirePhase(state, 'playing', '对局还没开始。');
|
|
57
|
+
const shooter = currentPlayer(state);
|
|
58
|
+
if (!shooter)
|
|
59
|
+
throw new RouletteRuleError('对局没有可以开枪的玩家。');
|
|
60
|
+
if (shooter.id !== actorId) {
|
|
61
|
+
throw new RouletteRuleError(`还没轮到你,现在轮到 ${shooter.name}。`);
|
|
62
|
+
}
|
|
63
|
+
const firedChamber = state.pullsMade;
|
|
64
|
+
const pullsMade = state.pullsMade + 1;
|
|
65
|
+
if (firedChamber === state.bulletChamber) {
|
|
66
|
+
const finished = finishWithLoser({ ...state, pullsMade }, shooter.id, 'shot');
|
|
67
|
+
return { state: finished, outcome: 'bang', shooter };
|
|
68
|
+
}
|
|
69
|
+
const turnIndex = (state.turnIndex + 1) % state.players.length;
|
|
70
|
+
return { state: { ...state, pullsMade, turnIndex }, outcome: 'click', shooter };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Leaving the lobby just removes the player. Leaving a running round is a
|
|
74
|
+
* forfeit: the leaver loses, so nobody can dodge the bullet by walking away.
|
|
75
|
+
*/
|
|
76
|
+
export function leaveGame(state, playerId) {
|
|
77
|
+
const leaver = findPlayer(state, playerId);
|
|
78
|
+
if (!leaver)
|
|
79
|
+
throw new RouletteRuleError('你不在这局轮盘里。');
|
|
80
|
+
if (state.phase === 'playing') {
|
|
81
|
+
return finishWithLoser(state, playerId, 'forfeit');
|
|
82
|
+
}
|
|
83
|
+
requirePhase(state, 'lobby', '对局已经结束。');
|
|
84
|
+
const players = state.players.filter((player) => player.id !== playerId);
|
|
85
|
+
const nextHost = players[0];
|
|
86
|
+
if (!nextHost) {
|
|
87
|
+
return { ...state, players, phase: 'aborted', endReason: 'lobby-empty' };
|
|
88
|
+
}
|
|
89
|
+
const hostId = state.hostId === playerId ? nextHost.id : state.hostId;
|
|
90
|
+
return { ...state, players, hostId };
|
|
91
|
+
}
|
|
92
|
+
/** The player on turn ran out of time: treated like a shot, they lose. */
|
|
93
|
+
export function expireTurn(state) {
|
|
94
|
+
requirePhase(state, 'playing', '对局还没开始。');
|
|
95
|
+
const late = currentPlayer(state);
|
|
96
|
+
if (!late)
|
|
97
|
+
throw new RouletteRuleError('对局没有可以开枪的玩家。');
|
|
98
|
+
return finishWithLoser(state, late.id, 'turn-timeout');
|
|
99
|
+
}
|
|
100
|
+
export function expireLobby(state) {
|
|
101
|
+
requirePhase(state, 'lobby', '对局已经开始了。');
|
|
102
|
+
return { ...state, phase: 'aborted', endReason: 'lobby-expired' };
|
|
103
|
+
}
|
|
104
|
+
export function cancelGame(state) {
|
|
105
|
+
if (!isOpen(state))
|
|
106
|
+
throw new RouletteRuleError('对局已经结束。');
|
|
107
|
+
return { ...state, phase: 'aborted', endReason: 'cancelled' };
|
|
108
|
+
}
|
|
109
|
+
export function isOpen(state) {
|
|
110
|
+
return state.phase === 'lobby' || state.phase === 'playing';
|
|
111
|
+
}
|
|
112
|
+
export function currentPlayer(state) {
|
|
113
|
+
if (state.phase !== 'playing')
|
|
114
|
+
return undefined;
|
|
115
|
+
return state.players[state.turnIndex];
|
|
116
|
+
}
|
|
117
|
+
export function findPlayer(state, playerId) {
|
|
118
|
+
return state.players.find((player) => player.id === playerId);
|
|
119
|
+
}
|
|
120
|
+
export function loserOf(state) {
|
|
121
|
+
if (!state.loserId)
|
|
122
|
+
return undefined;
|
|
123
|
+
return findPlayer(state, state.loserId);
|
|
124
|
+
}
|
|
125
|
+
export function survivorsOf(state) {
|
|
126
|
+
return state.players.filter((player) => player.id !== state.loserId);
|
|
127
|
+
}
|
|
128
|
+
export function remainingChambers(state) {
|
|
129
|
+
return state.chamberCount - state.pullsMade;
|
|
130
|
+
}
|
|
131
|
+
export function decodeRouletteState(json) {
|
|
132
|
+
const parsed = JSON.parse(json);
|
|
133
|
+
if (!isRouletteState(parsed)) {
|
|
134
|
+
throw new TypeError('Stored roulette state is malformed');
|
|
135
|
+
}
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
function finishWithLoser(state, loserId, endReason) {
|
|
139
|
+
return { ...state, phase: 'finished', loserId, endReason };
|
|
140
|
+
}
|
|
141
|
+
function requirePhase(state, phase, message) {
|
|
142
|
+
if (state.phase !== phase)
|
|
143
|
+
throw new RouletteRuleError(message);
|
|
144
|
+
}
|
|
145
|
+
function isRouletteState(value) {
|
|
146
|
+
if (!value || typeof value !== 'object')
|
|
147
|
+
return false;
|
|
148
|
+
const state = value;
|
|
149
|
+
return state.schemaVersion === ROULETTE_STATE_VERSION
|
|
150
|
+
&& typeof state.phase === 'string'
|
|
151
|
+
&& typeof state.hostId === 'string'
|
|
152
|
+
&& Array.isArray(state.players)
|
|
153
|
+
&& typeof state.chamberCount === 'number'
|
|
154
|
+
&& typeof state.bulletChamber === 'number'
|
|
155
|
+
&& typeof state.pullsMade === 'number'
|
|
156
|
+
&& typeof state.turnIndex === 'number';
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GameMessageLike } from '@zhin.js/game-kit';
|
|
2
|
+
import type { RouletteConfig } from './config.js';
|
|
3
|
+
import type { RouletteSessionRow } from './models.js';
|
|
4
|
+
import type { MutePenaltyPort, PenaltyConversation } from './mute-penalty.js';
|
|
5
|
+
import { type RouletteCommandAction } from './roulette-actions.js';
|
|
6
|
+
import { type EndpointIdentity, type RouletteServices } from './session-service.js';
|
|
7
|
+
/** Everything one action needs, resolved from the current runtime generation. */
|
|
8
|
+
export interface RouletteFlow {
|
|
9
|
+
readonly services: RouletteServices;
|
|
10
|
+
readonly config: RouletteConfig;
|
|
11
|
+
readonly penalty: MutePenaltyPort;
|
|
12
|
+
}
|
|
13
|
+
/** Who sent the action, from the runtime message. */
|
|
14
|
+
export interface RouletteActor {
|
|
15
|
+
readonly message: GameMessageLike;
|
|
16
|
+
readonly endpoint: EndpointIdentity;
|
|
17
|
+
readonly roles: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface ExpiredSessionNotice {
|
|
20
|
+
readonly session: RouletteSessionRow;
|
|
21
|
+
readonly text: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function runRouletteAction(flow: RouletteFlow, actor: RouletteActor, action: RouletteCommandAction): Promise<string>;
|
|
24
|
+
/** Called by the deadline schedule; each returned notice still has to be delivered. */
|
|
25
|
+
export declare function expireOverdueSessions(flow: RouletteFlow, now: number): Promise<readonly ExpiredSessionNotice[]>;
|
|
26
|
+
export declare function conversationOfSession(session: RouletteSessionRow): PenaltyConversation;
|
package/lib/game-flow.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { GameSessionConflictError, SessionRevisionConflictError, channelKey, } from '@zhin.js/game-kit';
|
|
2
|
+
import { RouletteRuleError, loserOf } from './engine.js';
|
|
3
|
+
import { ROULETTE_HELP } from './roulette-actions.js';
|
|
4
|
+
import { playerFromMessage, stateOf, } from './session-service.js';
|
|
5
|
+
import { describeAbort, describeClick, describeEnding, describeLobby, describeRoundStart, describeStatus, } from './view.js';
|
|
6
|
+
const DEADLINE_ACTOR = 'deadline';
|
|
7
|
+
const MANAGER_ROLES = ['master', 'trusted'];
|
|
8
|
+
const NO_GAME_TEXT = '本群没有进行中的轮盘,发送「轮盘 开局」创建一局。';
|
|
9
|
+
export async function runRouletteAction(flow, actor, action) {
|
|
10
|
+
const message = actor.message;
|
|
11
|
+
if (action === 'help')
|
|
12
|
+
return ROULETTE_HELP;
|
|
13
|
+
if (message.$channel.type === 'private')
|
|
14
|
+
return '轮盘需要多人参与,请在群聊里玩。';
|
|
15
|
+
if (action === 'penalty-on' || action === 'penalty-off' || action === 'penalty-status') {
|
|
16
|
+
return handlePenaltySetting(flow, actor, action);
|
|
17
|
+
}
|
|
18
|
+
const notices = [];
|
|
19
|
+
let session = await flow.services.sessions.getActiveByChannel(channelKey(message));
|
|
20
|
+
if (session && isOverdue(session, Date.now())) {
|
|
21
|
+
const expired = await expireSession(flow, session);
|
|
22
|
+
if (expired)
|
|
23
|
+
notices.push(expired);
|
|
24
|
+
session = null;
|
|
25
|
+
}
|
|
26
|
+
const reply = await handleGameAction(flow, actor, action, session);
|
|
27
|
+
return [...notices, reply].join('\n\n');
|
|
28
|
+
}
|
|
29
|
+
/** Called by the deadline schedule; each returned notice still has to be delivered. */
|
|
30
|
+
export async function expireOverdueSessions(flow, now) {
|
|
31
|
+
const overdue = await flow.services.sessions.listOverdue(now);
|
|
32
|
+
const notices = [];
|
|
33
|
+
for (const session of overdue) {
|
|
34
|
+
const text = await expireSession(flow, session);
|
|
35
|
+
if (text)
|
|
36
|
+
notices.push({ session, text });
|
|
37
|
+
}
|
|
38
|
+
return notices;
|
|
39
|
+
}
|
|
40
|
+
async function handleGameAction(flow, actor, action, session) {
|
|
41
|
+
const message = actor.message;
|
|
42
|
+
if (action === 'open')
|
|
43
|
+
return openLobby(flow, actor, session);
|
|
44
|
+
if (!session)
|
|
45
|
+
return NO_GAME_TEXT;
|
|
46
|
+
if (action === 'status')
|
|
47
|
+
return describeStatus(stateOf(session), flow.config);
|
|
48
|
+
if (action === 'cancel' && !mayCancel(session, message, actor.roles)) {
|
|
49
|
+
return '只有开局的人或机器人管理员可以取消本局。';
|
|
50
|
+
}
|
|
51
|
+
const gameAction = toGameAction(action, message);
|
|
52
|
+
if (!gameAction)
|
|
53
|
+
return NO_GAME_TEXT;
|
|
54
|
+
try {
|
|
55
|
+
const result = await flow.services.sessions.applyAction(session, message.$sender.id, gameAction, flow.config);
|
|
56
|
+
return describeResult(flow, gameAction, result, message);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof RouletteRuleError)
|
|
60
|
+
return error.message;
|
|
61
|
+
if (error instanceof GameSessionConflictError)
|
|
62
|
+
return error.message;
|
|
63
|
+
if (error instanceof SessionRevisionConflictError)
|
|
64
|
+
return '对局状态刚刚变了,请再发一次。';
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function openLobby(flow, actor, session) {
|
|
69
|
+
if (session) {
|
|
70
|
+
return `本群已经有一局轮盘了。\n${describeStatus(stateOf(session), flow.config)}`;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const created = await flow.services.sessions.createLobby(actor.message, actor.endpoint, flow.config);
|
|
74
|
+
return describeLobby(stateOf(created), flow.config);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof GameSessionConflictError)
|
|
78
|
+
return error.message;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function describeResult(flow, action, result, message) {
|
|
83
|
+
const state = result.state;
|
|
84
|
+
if (!result.applied)
|
|
85
|
+
return describeStatus(state, flow.config);
|
|
86
|
+
if (state.phase === 'finished')
|
|
87
|
+
return finishText(flow, result.session, state);
|
|
88
|
+
if (state.phase === 'aborted')
|
|
89
|
+
return describeAbort(state);
|
|
90
|
+
if (action.type === 'start')
|
|
91
|
+
return describeRoundStart(state, flow.config);
|
|
92
|
+
if (action.type === 'fire') {
|
|
93
|
+
return describeClick(playerFromMessage(message), state, flow.config);
|
|
94
|
+
}
|
|
95
|
+
if (action.type === 'leave') {
|
|
96
|
+
return `${playerFromMessage(message).name} 离开了。\n${describeLobby(state, flow.config)}`;
|
|
97
|
+
}
|
|
98
|
+
return describeLobby(state, flow.config);
|
|
99
|
+
}
|
|
100
|
+
async function expireSession(flow, session) {
|
|
101
|
+
try {
|
|
102
|
+
const result = await flow.services.sessions.applyAction(session, DEADLINE_ACTOR, { type: 'expire' }, flow.config);
|
|
103
|
+
if (!result.applied)
|
|
104
|
+
return '';
|
|
105
|
+
if (result.state.phase === 'finished')
|
|
106
|
+
return finishText(flow, result.session, result.state);
|
|
107
|
+
return describeAbort(result.state);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// A player acted on the session in the meantime; the next sweep re-reads it.
|
|
111
|
+
if (error instanceof SessionRevisionConflictError)
|
|
112
|
+
return '';
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function finishText(flow, session, state) {
|
|
117
|
+
const penalty = await applyLoserPenalty(flow, session, state);
|
|
118
|
+
return [describeEnding(state), penalty].filter(Boolean).join('\n');
|
|
119
|
+
}
|
|
120
|
+
/** Mute the loser only when this group opted in and the Endpoint declares muting. */
|
|
121
|
+
async function applyLoserPenalty(flow, session, state) {
|
|
122
|
+
const loser = loserOf(state);
|
|
123
|
+
if (!loser)
|
|
124
|
+
return '';
|
|
125
|
+
const enabled = await flow.services.settings.isMutePenaltyEnabled(session.channel_key);
|
|
126
|
+
if (!enabled)
|
|
127
|
+
return '';
|
|
128
|
+
const conversation = conversationOfSession(session);
|
|
129
|
+
if (flow.penalty.support(conversation) !== 'supported') {
|
|
130
|
+
return '(本群开启了禁言惩罚,但当前平台无法禁言,本局只计分。)';
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await flow.penalty.mute(conversation, loser.id, flow.config.muteSeconds);
|
|
134
|
+
return `🔇 ${loser.name} 被禁言 ${flow.config.muteSeconds} 秒。`;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return '(禁言没有成功,可能是机器人没有群管理权限;本局只计分。)';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function handlePenaltySetting(flow, actor, action) {
|
|
141
|
+
const message = actor.message;
|
|
142
|
+
if (message.$channel.type !== 'group')
|
|
143
|
+
return '禁言惩罚只能在群聊里设置。';
|
|
144
|
+
const key = channelKey(message);
|
|
145
|
+
const settings = flow.services.settings;
|
|
146
|
+
const support = flow.penalty.support(conversationOfActor(actor));
|
|
147
|
+
if (action === 'penalty-status') {
|
|
148
|
+
const enabled = await settings.isMutePenaltyEnabled(key);
|
|
149
|
+
return describePenaltyStatus(enabled, support === 'supported', flow.config);
|
|
150
|
+
}
|
|
151
|
+
if (!isManager(actor.roles))
|
|
152
|
+
return '只有机器人主人或受信任用户可以修改禁言惩罚。';
|
|
153
|
+
if (action === 'penalty-off') {
|
|
154
|
+
await settings.setMutePenalty(key, false, message.$sender.id);
|
|
155
|
+
return '已关闭禁言惩罚,本群轮盘只计分。';
|
|
156
|
+
}
|
|
157
|
+
if (support !== 'supported') {
|
|
158
|
+
return '当前平台不支持禁言,禁言惩罚没有开启;本群轮盘只计分。';
|
|
159
|
+
}
|
|
160
|
+
await settings.setMutePenalty(key, true, message.$sender.id);
|
|
161
|
+
return `已开启禁言惩罚:每局输家会被禁言 ${flow.config.muteSeconds} 秒(需要机器人有群管理权限)。`;
|
|
162
|
+
}
|
|
163
|
+
function describePenaltyStatus(enabled, supported, config) {
|
|
164
|
+
const platform = supported ? '当前平台支持禁言。' : '当前平台不支持禁言,只能计分。';
|
|
165
|
+
if (!enabled)
|
|
166
|
+
return `本群禁言惩罚:关闭。${platform}`;
|
|
167
|
+
return `本群禁言惩罚:开启,输家禁言 ${config.muteSeconds} 秒。${platform}`;
|
|
168
|
+
}
|
|
169
|
+
function toGameAction(action, message) {
|
|
170
|
+
if (action === 'join')
|
|
171
|
+
return { type: 'join', player: playerFromMessage(message) };
|
|
172
|
+
if (action === 'start')
|
|
173
|
+
return { type: 'start' };
|
|
174
|
+
if (action === 'fire')
|
|
175
|
+
return { type: 'fire' };
|
|
176
|
+
if (action === 'leave')
|
|
177
|
+
return { type: 'leave' };
|
|
178
|
+
if (action === 'cancel')
|
|
179
|
+
return { type: 'cancel' };
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function mayCancel(session, message, senderRoles) {
|
|
183
|
+
if (stateOf(session).hostId === message.$sender.id)
|
|
184
|
+
return true;
|
|
185
|
+
return isManager(senderRoles);
|
|
186
|
+
}
|
|
187
|
+
function isManager(senderRoles) {
|
|
188
|
+
return senderRoles.some((role) => MANAGER_ROLES.includes(role));
|
|
189
|
+
}
|
|
190
|
+
function isOverdue(session, now) {
|
|
191
|
+
return session.deadline_at > 0 && session.deadline_at <= now;
|
|
192
|
+
}
|
|
193
|
+
export function conversationOfSession(session) {
|
|
194
|
+
return {
|
|
195
|
+
endpointAdapter: session.endpoint_adapter,
|
|
196
|
+
endpointId: session.endpoint_id,
|
|
197
|
+
kind: session.channel_type,
|
|
198
|
+
id: session.channel_id,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function conversationOfActor(actor) {
|
|
202
|
+
return {
|
|
203
|
+
endpointAdapter: actor.endpoint.adapter,
|
|
204
|
+
endpointId: actor.endpoint.id,
|
|
205
|
+
kind: actor.message.$channel.type,
|
|
206
|
+
id: actor.message.$channel.id,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_ROULETTE_CONFIG, resolveRouletteConfig, type RouletteConfig } from './config.js';
|
|
2
|
+
export * from './engine.js';
|
|
3
|
+
export { expireOverdueSessions, runRouletteAction, type RouletteActor, type RouletteFlow } from './game-flow.js';
|
|
4
|
+
export { DEADLINE_CRON, runDeadlineSweep } from './deadline-job.js';
|
|
5
|
+
export { resolveMutePenalty, type AdapterProjectionContext, type MutePenaltyPort, type MuteSupport, type PenaltyConversation, } from './mute-penalty.js';
|
|
6
|
+
export { ROULETTE_HELP, normalizeRouletteAction, type RouletteCommandAction } from './roulette-actions.js';
|
|
7
|
+
export { endpointOf, rouletteServicesToken, resolveRouletteFlow } from './runtime.js';
|
|
8
|
+
export { RouletteGroupSettings, RouletteSessionService, createServices, type EndpointIdentity, type RouletteServices, } from './session-service.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_ROULETTE_CONFIG, resolveRouletteConfig } from './config.js';
|
|
2
|
+
export * from './engine.js';
|
|
3
|
+
export { expireOverdueSessions, runRouletteAction } from './game-flow.js';
|
|
4
|
+
export { DEADLINE_CRON, runDeadlineSweep } from './deadline-job.js';
|
|
5
|
+
export { resolveMutePenalty, } from './mute-penalty.js';
|
|
6
|
+
export { ROULETTE_HELP, normalizeRouletteAction } from './roulette-actions.js';
|
|
7
|
+
export { endpointOf, rouletteServicesToken, resolveRouletteFlow } from './runtime.js';
|
|
8
|
+
export { RouletteGroupSettings, RouletteSessionService, createServices, } from './session-service.js';
|