@zhin.js/plugin-dungeon-expedition 1.0.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.
@@ -0,0 +1,126 @@
1
+ import {
2
+ GameSessionConflictError,
3
+ SessionRevisionConflictError,
4
+ channelKey,
5
+ type GameMessageLike,
6
+ type GameReply,
7
+ } from '@zhin.js/game-kit';
8
+ import {
9
+ DungeonRuleError,
10
+ type DungeonAction,
11
+ } from './engine.js';
12
+ import type { DungeonSessionRow } from './models.js';
13
+ import {
14
+ type SessionService,
15
+ stateFromSession,
16
+ } from './session-service.js';
17
+ import {
18
+ buildDungeonView,
19
+ parseDungeonSessionToken,
20
+ } from './view.js';
21
+
22
+ export async function startDungeon(
23
+ service: SessionService,
24
+ message: GameMessageLike,
25
+ ): Promise<GameReply> {
26
+ const active = await service.getActiveByChannel(channelKey(message));
27
+ if (active) return buildDungeonView(active, stateFromSession(active), message.$channel.type);
28
+ try {
29
+ const session = await service.createSession(message);
30
+ return buildDungeonView(session, stateFromSession(session), message.$channel.type);
31
+ } catch (error) {
32
+ if (error instanceof GameSessionConflictError) return error.message;
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ export async function continueDungeon(
38
+ service: SessionService,
39
+ message: GameMessageLike,
40
+ ): Promise<GameReply> {
41
+ const session = await service.getActiveByChannel(channelKey(message));
42
+ if (!session) return '当前频道没有进行中的地牢远征。发送「地牢 开始」创建队伍。';
43
+ return buildDungeonView(session, stateFromSession(session), message.$channel.type);
44
+ }
45
+
46
+ export async function handleDungeonChoice(
47
+ service: SessionService,
48
+ message: GameMessageLike,
49
+ sessionToken: string,
50
+ choiceId: string,
51
+ ): Promise<GameReply> {
52
+ const token = parseDungeonSessionToken(sessionToken);
53
+ if (!token) return '无效的地牢操作。';
54
+ const session = await service.getById(token.sessionId);
55
+ if (!session || session.channel_key !== channelKey(message)) {
56
+ return '远征不存在,或不属于当前频道。';
57
+ }
58
+ if (choiceId === 'restart') {
59
+ if (session.status === 'active') return '当前远征尚未结束。';
60
+ if (session.owner_id !== message.$sender.id) return '只有原队长可以重新创建远征。';
61
+ return startDungeon(service, message);
62
+ }
63
+ const action = actionFromChoice(choiceId, message);
64
+ if (!action) return '未知的地牢操作。';
65
+ try {
66
+ const result = await service.performAction({
67
+ sessionId: session.id,
68
+ actorId: message.$sender.id,
69
+ actorName: message.$sender.name?.trim() || message.$sender.id,
70
+ action,
71
+ expectedRevision: token.revision,
72
+ actionId: `${session.id}:${token.revision}:${message.$sender.id}:${choiceId}`,
73
+ });
74
+ return buildDungeonView(
75
+ result.session,
76
+ stateFromSession(result.session),
77
+ message.$channel.type,
78
+ );
79
+ } catch (error) {
80
+ if (error instanceof DungeonRuleError
81
+ || error instanceof GameSessionConflictError) {
82
+ return error.message;
83
+ }
84
+ if (error instanceof SessionRevisionConflictError) {
85
+ const latest = await service.getById(session.id);
86
+ if (!latest) return '远征状态已变化,请重新打开当前界面。';
87
+ const view = buildDungeonView(
88
+ latest,
89
+ stateFromSession(latest),
90
+ message.$channel.type,
91
+ );
92
+ return Array.isArray(view)
93
+ ? ['操作来自旧回合,已为你刷新最新状态。', ...view]
94
+ : ['操作来自旧回合,已为你刷新最新状态。', view];
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ export function commandActionId(
101
+ session: DungeonSessionRow,
102
+ actorId: string,
103
+ choiceId: string,
104
+ ): string {
105
+ return `${session.id}:${session.revision}:${actorId}:${choiceId}`;
106
+ }
107
+
108
+ function actionFromChoice(
109
+ choiceId: string,
110
+ message: GameMessageLike,
111
+ ): DungeonAction | null {
112
+ if (choiceId === 'join') {
113
+ return {
114
+ type: 'join',
115
+ name: message.$sender.name?.trim() || message.$sender.id,
116
+ };
117
+ }
118
+ if (choiceId === 'ready') return { type: 'ready' };
119
+ if (choiceId === 'start') return { type: 'start' };
120
+ if (choiceId === 'explore') return { type: 'explore' };
121
+ if (choiceId === 'attack') return { type: 'attack' };
122
+ if (choiceId === 'defend') return { type: 'defend' };
123
+ if (choiceId === 'potion') return { type: 'potion' };
124
+ if (choiceId === 'abort') return { type: 'abort' };
125
+ return null;
126
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export {
2
+ DUNGEON_HELP,
3
+ normalizeDungeonAction,
4
+ runDungeonCommand,
5
+ } from './dungeon-command.js';
6
+ export {
7
+ DUNGEON_SCHEMA_VERSION,
8
+ DungeonRuleError,
9
+ applyDungeonAction,
10
+ createDungeonState,
11
+ decodeDungeonState,
12
+ type DungeonAction,
13
+ type DungeonState,
14
+ } from './engine.js';
15
+ export {
16
+ createServices,
17
+ SessionService,
18
+ stateFromSession,
19
+ } from './session-service.js';
20
+ export {
21
+ DUNGEON_PREFIX,
22
+ buildDungeonView,
23
+ choicesForState,
24
+ } from './view.js';
25
+ export {
26
+ gameServicesToken,
27
+ resolveGameServices,
28
+ } from './runtime-store.js';
package/src/models.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Models } from '@zhin.js/core';
2
+
3
+ export type DungeonSessionStatus = 'active' | 'completed' | 'aborted';
4
+
5
+ declare module '@zhin.js/core' {
6
+ interface Models {
7
+ dungeon_sessions: {
8
+ id: string;
9
+ adapter: string;
10
+ endpoint: string;
11
+ channel_type: string;
12
+ channel_id: string;
13
+ channel_key: string;
14
+ owner_id: string;
15
+ owner_name: string;
16
+ state_json: string;
17
+ phase: string;
18
+ turn: string;
19
+ rng_state: number;
20
+ schema_version: number;
21
+ revision: number;
22
+ processed_actions: string;
23
+ deadline_at: number;
24
+ status: DungeonSessionStatus;
25
+ updated_at: number;
26
+ created_at: number;
27
+ };
28
+ }
29
+ }
30
+
31
+ export type DungeonSessionRow = Models['dungeon_sessions'];
32
+
33
+ export function defineHostTables(
34
+ database: {
35
+ define(name: string, definition: Record<string, unknown>): void;
36
+ },
37
+ ): void {
38
+ database.define('dungeon_sessions', {
39
+ id: { type: 'text', primary: true },
40
+ adapter: { type: 'text', nullable: false },
41
+ endpoint: { type: 'text', nullable: false },
42
+ channel_type: { type: 'text', nullable: false },
43
+ channel_id: { type: 'text', nullable: false },
44
+ channel_key: { type: 'text', nullable: false },
45
+ owner_id: { type: 'text', nullable: false },
46
+ owner_name: { type: 'text', default: '' },
47
+ state_json: { type: 'text', nullable: false },
48
+ phase: { type: 'text', default: 'lobby' },
49
+ turn: { type: 'text', default: '' },
50
+ rng_state: { type: 'integer', default: 1 },
51
+ schema_version: { type: 'integer', default: 1 },
52
+ revision: { type: 'integer', default: 0 },
53
+ processed_actions: { type: 'text', default: '[]' },
54
+ deadline_at: { type: 'integer', default: 0 },
55
+ status: { type: 'text', default: 'active' },
56
+ updated_at: { type: 'integer', default: 0 },
57
+ created_at: { type: 'integer', default: 0 },
58
+ });
59
+ }
@@ -0,0 +1,12 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ import type { SessionService } from './session-service.js';
3
+
4
+ export const gameServicesToken = createToken<SessionService>(
5
+ 'zhin.game.dungeon-expedition.services',
6
+ );
7
+
8
+ export function resolveGameServices(
9
+ context: { use<T>(token: typeof gameServicesToken): T },
10
+ ): SessionService {
11
+ return context.use(gameServicesToken) as SessionService;
12
+ }
@@ -0,0 +1,157 @@
1
+ import type { Database, Models } from '@zhin.js/core';
2
+ import {
3
+ DeterministicRandom,
4
+ VersionedSessionService,
5
+ channelKey,
6
+ gameSessionCoordinator,
7
+ generateCompactId,
8
+ type GameMessageLike,
9
+ type GameOutcome,
10
+ type GameSessionDatabase,
11
+ type SessionMutationResult,
12
+ } from '@zhin.js/game-kit';
13
+ import {
14
+ activePlayer,
15
+ applyDungeonAction,
16
+ createDungeonState,
17
+ decodeDungeonState,
18
+ type DungeonAction,
19
+ type DungeonState,
20
+ } from './engine.js';
21
+ import type { DungeonSessionRow } from './models.js';
22
+
23
+ export type DungeonDatabase = Database<unknown, Models, string>;
24
+
25
+ export interface PerformDungeonActionOptions {
26
+ readonly sessionId: string;
27
+ readonly actorId: string;
28
+ readonly actorName: string;
29
+ readonly action: DungeonAction;
30
+ readonly actionId: string;
31
+ readonly expectedRevision?: number;
32
+ }
33
+
34
+ export class SessionService extends VersionedSessionService<DungeonSessionRow> {
35
+ constructor(database: DungeonDatabase) {
36
+ super(database as unknown as GameSessionDatabase<DungeonSessionRow>, {
37
+ gameId: 'dungeon',
38
+ table: 'dungeon_sessions',
39
+ userFields: ['owner_id'],
40
+ projectOutcomes: projectDungeonOutcomes,
41
+ });
42
+ }
43
+
44
+ async createSession(
45
+ message: GameMessageLike,
46
+ seed?: string | number,
47
+ ): Promise<DungeonSessionRow> {
48
+ const now = Date.now();
49
+ const id = generateCompactId('dng_');
50
+ const ownerName = message.$sender.name?.trim() || message.$sender.id;
51
+ const state = createDungeonState(message.$sender.id, ownerName);
52
+ const random = DeterministicRandom.fromSeed(seed ?? id);
53
+ return this.createRow({
54
+ id,
55
+ adapter: String(message.$adapter),
56
+ endpoint: message.$endpoint,
57
+ channel_type: message.$channel.type,
58
+ channel_id: message.$channel.id,
59
+ channel_key: channelKey(message),
60
+ owner_id: message.$sender.id,
61
+ owner_name: ownerName,
62
+ state_json: JSON.stringify(state),
63
+ phase: state.phase,
64
+ turn: activePlayer(state)?.id ?? '',
65
+ rng_state: random.state,
66
+ schema_version: state.schemaVersion,
67
+ revision: 0,
68
+ processed_actions: '[]',
69
+ deadline_at: now + 5 * 60_000,
70
+ status: 'active',
71
+ updated_at: now,
72
+ created_at: now,
73
+ });
74
+ }
75
+
76
+ override async getActiveForUser(
77
+ channelKeyValue: string,
78
+ userId: string,
79
+ ): Promise<DungeonSessionRow | null> {
80
+ const rows = await this.model.findAll({
81
+ channel_key: channelKeyValue,
82
+ status: 'active',
83
+ });
84
+ return rows.find((row) =>
85
+ decodeDungeonState(row.state_json).players.some(
86
+ (player) => player.id === userId,
87
+ )) ?? null;
88
+ }
89
+
90
+ async performAction(
91
+ options: PerformDungeonActionOptions,
92
+ ): Promise<SessionMutationResult<DungeonSessionRow>> {
93
+ const existing = await this.getById(options.sessionId);
94
+ if (!existing) {
95
+ return this.mutateSession(options.sessionId, {
96
+ actionId: options.actionId,
97
+ apply: {},
98
+ });
99
+ }
100
+ if (options.action.type === 'join') {
101
+ await gameSessionCoordinator.assertAvailable(
102
+ this.gameId,
103
+ existing.channel_key,
104
+ [options.actorId],
105
+ );
106
+ }
107
+ return this.mutateSession(options.sessionId, {
108
+ actionId: options.actionId,
109
+ expectedRevision: options.expectedRevision,
110
+ apply: (session) => {
111
+ const state = decodeDungeonState(session.state_json);
112
+ const random = DeterministicRandom.fromState(session.rng_state);
113
+ const next = applyDungeonAction(
114
+ state,
115
+ options.actorId,
116
+ options.action,
117
+ random,
118
+ );
119
+ return {
120
+ state_json: JSON.stringify(next),
121
+ phase: next.phase,
122
+ turn: activePlayer(next)?.id ?? '',
123
+ rng_state: random.state,
124
+ schema_version: next.schemaVersion,
125
+ deadline_at: Date.now() + 5 * 60_000,
126
+ status: next.phase === 'completed'
127
+ ? next.result === 'aborted' ? 'aborted' : 'completed'
128
+ : 'active',
129
+ };
130
+ },
131
+ });
132
+ }
133
+ }
134
+
135
+ export function createServices(database: DungeonDatabase): SessionService {
136
+ return new SessionService(database);
137
+ }
138
+
139
+ export function stateFromSession(session: DungeonSessionRow): DungeonState {
140
+ return decodeDungeonState(session.state_json);
141
+ }
142
+
143
+ function projectDungeonOutcomes(
144
+ session: Readonly<DungeonSessionRow>,
145
+ ): readonly GameOutcome[] {
146
+ const state = decodeDungeonState(session.state_json);
147
+ return state.players.map((player) => ({
148
+ userId: player.id,
149
+ userName: player.name,
150
+ result: session.status === 'aborted' || state.result === 'aborted'
151
+ ? 'aborted'
152
+ : state.result === 'victory'
153
+ ? player.hp > 0 ? 'won' : 'lost'
154
+ : 'lost',
155
+ score: state.roomsCleared * 10 + player.gold,
156
+ }));
157
+ }
package/src/view.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { SendContent } from '@zhin.js/core';
2
+ import {
3
+ buildChoiceKeyboard,
4
+ type ChoiceOption,
5
+ } from '@zhin.js/game-kit';
6
+ import { activePlayer, type DungeonState } from './engine.js';
7
+ import type { DungeonSessionRow } from './models.js';
8
+
9
+ export const DUNGEON_PREFIX = 'dungeon';
10
+
11
+ export function dungeonSessionToken(session: DungeonSessionRow): string {
12
+ return `${session.id}@${session.revision}`;
13
+ }
14
+
15
+ export function parseDungeonSessionToken(
16
+ token: string,
17
+ ): { sessionId: string; revision: number } | null {
18
+ const match = /^(.+)@(\d+)$/.exec(token);
19
+ if (!match?.[1] || match[2] === undefined) return null;
20
+ return { sessionId: match[1], revision: Number(match[2]) };
21
+ }
22
+
23
+ export function choicesForState(state: Readonly<DungeonState>): ChoiceOption[] {
24
+ if (state.phase === 'completed') {
25
+ return [{
26
+ id: 'restart',
27
+ label: '再来一局',
28
+ style: 'primary',
29
+ keepEnabledWhenTerminal: true,
30
+ }];
31
+ }
32
+ if (state.phase === 'lobby') {
33
+ return [
34
+ { id: 'join', label: '加入队伍', style: 'secondary' },
35
+ { id: 'ready', label: '准备/取消', style: 'secondary' },
36
+ { id: 'start', label: '开始远征', style: 'primary' },
37
+ { id: 'abort', label: '解散队伍', style: 'danger' },
38
+ ];
39
+ }
40
+ if (state.phase === 'combat') {
41
+ return [
42
+ { id: 'attack', label: '攻击', style: 'primary' },
43
+ { id: 'defend', label: '防御', style: 'secondary' },
44
+ { id: 'potion', label: '药水', style: 'secondary' },
45
+ { id: 'abort', label: '结束远征', style: 'danger' },
46
+ ];
47
+ }
48
+ return [
49
+ { id: 'explore', label: '探索下一房间', style: 'primary' },
50
+ { id: 'potion', label: '使用药水', style: 'secondary' },
51
+ { id: 'abort', label: '结束远征', style: 'danger' },
52
+ ];
53
+ }
54
+
55
+ export function buildDungeonView(
56
+ session: DungeonSessionRow,
57
+ state: Readonly<DungeonState>,
58
+ channelType?: string,
59
+ ): SendContent {
60
+ const current = activePlayer(state);
61
+ const lines = [
62
+ `地牢远征 · 第 ${state.floor}/${3} 层 · 房间 ${state.room}/${4}`,
63
+ `状态:${phaseLabel(state)}`,
64
+ '',
65
+ ...state.players.map((player, index) => {
66
+ const turn = current?.id === player.id && state.phase !== 'lobby' ? ' <- 当前回合' : '';
67
+ const ready = state.phase === 'lobby' ? player.ready ? ' [已准备]' : ' [未准备]' : '';
68
+ return `${index + 1}. ${player.name}${ready} HP ${player.hp}/${player.maxHp}`
69
+ + ` · 药水 ${player.potions} · 金币 ${player.gold}${turn}`;
70
+ }),
71
+ ];
72
+ if (state.enemy) {
73
+ lines.push(
74
+ '',
75
+ `${state.enemy.boss ? '守层者' : '敌人'}:${state.enemy.name}`,
76
+ `HP ${state.enemy.hp}/${state.enemy.maxHp} · 攻击 ${state.enemy.attack}`,
77
+ );
78
+ }
79
+ lines.push('', '最近事件:', ...state.log.slice(-4).map((line) => `- ${line}`));
80
+ if (state.phase === 'completed') {
81
+ lines.push(
82
+ '',
83
+ state.result === 'victory'
84
+ ? '远征胜利,地牢已被征服。'
85
+ : state.result === 'aborted'
86
+ ? '本次远征由队长结束。'
87
+ : '远征失败,队伍全员倒下。',
88
+ );
89
+ }
90
+
91
+ const choices = choicesForState(state);
92
+ return buildChoiceKeyboard({
93
+ gamePrefix: DUNGEON_PREFIX,
94
+ sessionId: dungeonSessionToken(session),
95
+ narrative: lines.join('\n'),
96
+ choices,
97
+ terminal: state.phase === 'completed',
98
+ buttonsPerRow: 2,
99
+ fallbackHint: choices.map((choice, index) =>
100
+ `${index + 1} ${choice.label}`).join(' · '),
101
+ interactionProfile: state.phase === 'completed' ? 'terminal' : 'gameplay',
102
+ channelType,
103
+ });
104
+ }
105
+
106
+ function phaseLabel(state: Readonly<DungeonState>): string {
107
+ if (state.phase === 'lobby') return '等待队员';
108
+ if (state.phase === 'exploring') return '探索中';
109
+ if (state.phase === 'combat') return '战斗中';
110
+ if (state.result === 'victory') return '胜利';
111
+ if (state.result === 'aborted') return '已结束';
112
+ return '失败';
113
+ }