@zhin.js/plugin-text-adventure 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.
- package/README.md +92 -0
- package/lib/achievements.d.ts +24 -0
- package/lib/achievements.d.ts.map +1 -0
- package/lib/achievements.js +62 -0
- package/lib/achievements.js.map +1 -0
- package/lib/adv-command.d.ts +5 -0
- package/lib/adv-command.d.ts.map +1 -0
- package/lib/adv-command.js +55 -0
- package/lib/adv-command.js.map +1 -0
- package/lib/commands.d.ts +6 -0
- package/lib/commands.d.ts.map +1 -0
- package/lib/commands.js +96 -0
- package/lib/commands.js.map +1 -0
- package/lib/game-flow.d.ts +9 -0
- package/lib/game-flow.d.ts.map +1 -0
- package/lib/game-flow.js +134 -0
- package/lib/game-flow.js.map +1 -0
- package/lib/hub-register.d.ts +5 -0
- package/lib/hub-register.d.ts.map +1 -0
- package/lib/hub-register.js +25 -0
- package/lib/hub-register.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/models.d.ts +45 -0
- package/lib/models.d.ts.map +1 -0
- package/lib/models.js +37 -0
- package/lib/models.js.map +1 -0
- package/lib/profile-format.d.ts +6 -0
- package/lib/profile-format.d.ts.map +1 -0
- package/lib/profile-format.js +64 -0
- package/lib/profile-format.js.map +1 -0
- package/lib/profile-parse.d.ts +4 -0
- package/lib/profile-parse.d.ts.map +1 -0
- package/lib/profile-parse.js +21 -0
- package/lib/profile-parse.js.map +1 -0
- package/lib/profile-service.d.ts +17 -0
- package/lib/profile-service.d.ts.map +1 -0
- package/lib/profile-service.js +124 -0
- package/lib/profile-service.js.map +1 -0
- package/lib/scene-view.d.ts +4 -0
- package/lib/scene-view.d.ts.map +1 -0
- package/lib/scene-view.js +32 -0
- package/lib/scene-view.js.map +1 -0
- package/lib/session-service.d.ts +22 -0
- package/lib/session-service.d.ts.map +1 -0
- package/lib/session-service.js +100 -0
- package/lib/session-service.js.map +1 -0
- package/lib/story-catalog.d.ts +13 -0
- package/lib/story-catalog.d.ts.map +1 -0
- package/lib/story-catalog.js +104 -0
- package/lib/story-catalog.js.map +1 -0
- package/lib/story-items.d.ts +5 -0
- package/lib/story-items.d.ts.map +1 -0
- package/lib/story-items.js +23 -0
- package/lib/story-items.js.map +1 -0
- package/lib/story-path.d.ts +3 -0
- package/lib/story-path.d.ts.map +1 -0
- package/lib/story-path.js +66 -0
- package/lib/story-path.js.map +1 -0
- package/lib/story-scenes.d.ts +9 -0
- package/lib/story-scenes.d.ts.map +1 -0
- package/lib/story-scenes.js +478 -0
- package/lib/story-scenes.js.map +1 -0
- package/lib/story-transitions.d.ts +3 -0
- package/lib/story-transitions.d.ts.map +1 -0
- package/lib/story-transitions.js +193 -0
- package/lib/story-transitions.js.map +1 -0
- package/lib/story-types.d.ts +30 -0
- package/lib/story-types.d.ts.map +1 -0
- package/lib/story-types.js +3 -0
- package/lib/story-types.js.map +1 -0
- package/lib/story.d.ts +21 -0
- package/lib/story.d.ts.map +1 -0
- package/lib/story.js +91 -0
- package/lib/story.js.map +1 -0
- package/package.json +54 -0
- package/plugin.yml +2 -0
- package/src/achievements.ts +90 -0
- package/src/adv-command.ts +72 -0
- package/src/commands.ts +133 -0
- package/src/game-flow.ts +196 -0
- package/src/hub-register.ts +32 -0
- package/src/index.ts +47 -0
- package/src/models.ts +84 -0
- package/src/profile-format.ts +76 -0
- package/src/profile-parse.ts +19 -0
- package/src/profile-service.ts +151 -0
- package/src/scene-view.ts +46 -0
- package/src/session-service.ts +113 -0
- package/src/story-catalog.ts +110 -0
- package/src/story-items.ts +24 -0
- package/src/story-path.ts +75 -0
- package/src/story-scenes.ts +508 -0
- package/src/story-transitions.ts +198 -0
- package/src/story-types.ts +33 -0
- package/src/story.ts +104 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Message,
|
|
3
|
+
MessageCommand,
|
|
4
|
+
getActionFromMessage,
|
|
5
|
+
type Plugin,
|
|
6
|
+
} from 'zhin.js';
|
|
7
|
+
import {
|
|
8
|
+
buildChoiceFallbackMap,
|
|
9
|
+
channelKey,
|
|
10
|
+
normalizeAdvAction,
|
|
11
|
+
parseChoicePayload,
|
|
12
|
+
registerGameTextMiddleware,
|
|
13
|
+
} from '@zhin.js/game-shared';
|
|
14
|
+
import { runAdvCommand } from './adv-command.js';
|
|
15
|
+
import { handleChoice } from './game-flow.js';
|
|
16
|
+
import { ADV_PREFIX, getScene, stateFromSession, visibleChoices } from './story.js';
|
|
17
|
+
import type { GameServices } from './session-service.js';
|
|
18
|
+
|
|
19
|
+
function registerAdvPattern(
|
|
20
|
+
plugin: Plugin,
|
|
21
|
+
pattern: string,
|
|
22
|
+
desc: string,
|
|
23
|
+
getServices: () => GameServices | null,
|
|
24
|
+
): void {
|
|
25
|
+
plugin.addCommand(
|
|
26
|
+
new MessageCommand(pattern)
|
|
27
|
+
.desc(desc)
|
|
28
|
+
.action(async (message, result) => {
|
|
29
|
+
const services = getServices();
|
|
30
|
+
if (!services) return '文字冒险需要启用 database 配置。';
|
|
31
|
+
const raw = (result.params.action as string | undefined) ?? '';
|
|
32
|
+
const action = normalizeAdvAction(raw);
|
|
33
|
+
return runAdvCommand(plugin, services, message, action);
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function resolveAdvChoice(
|
|
39
|
+
message: Message<any>,
|
|
40
|
+
services: GameServices,
|
|
41
|
+
): Promise<{ sessionId: string; choiceId: string } | null> {
|
|
42
|
+
const action = getActionFromMessage(message);
|
|
43
|
+
if (!action) return null;
|
|
44
|
+
|
|
45
|
+
const fromPayload = parseChoicePayload(action.payload, ADV_PREFIX);
|
|
46
|
+
if (fromPayload) {
|
|
47
|
+
return { sessionId: fromPayload.sessionId, choiceId: fromPayload.choiceId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const choiceId = action.id || action.payload;
|
|
51
|
+
if (!choiceId) return null;
|
|
52
|
+
|
|
53
|
+
const ch = channelKey(message);
|
|
54
|
+
const session =
|
|
55
|
+
await services.sessions.getActiveForUser(ch, message.$sender.id)
|
|
56
|
+
?? (action.sourceMessageId
|
|
57
|
+
? await services.sessions.getActiveByBoardMessageId(action.sourceMessageId)
|
|
58
|
+
: null);
|
|
59
|
+
if (!session || session.channel_key !== ch) return null;
|
|
60
|
+
|
|
61
|
+
const scene = getScene(session.scene_id);
|
|
62
|
+
if (!scene) return null;
|
|
63
|
+
const state = stateFromSession(session);
|
|
64
|
+
const choices = visibleChoices(scene, state);
|
|
65
|
+
if (!choices.some((c) => c.id === choiceId)) return null;
|
|
66
|
+
|
|
67
|
+
return { sessionId: session.id, choiceId };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function registerCommands(
|
|
71
|
+
plugin: Plugin,
|
|
72
|
+
getServices: () => GameServices | null,
|
|
73
|
+
): void {
|
|
74
|
+
registerAdvPattern(plugin, 'adv [action:word]', '秘境探险(adv)', getServices);
|
|
75
|
+
registerAdvPattern(plugin, '冒险 [action:word]', '秘境探险(中文)', getServices);
|
|
76
|
+
registerAdvPattern(plugin, '秘境 [action:word]', '秘境探险(简称)', getServices);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handleAdvAction(
|
|
80
|
+
plugin: Plugin,
|
|
81
|
+
getServices: () => GameServices | null,
|
|
82
|
+
message: Message<any>,
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
const services = getServices();
|
|
85
|
+
if (!services) return false;
|
|
86
|
+
const choice = await resolveAdvChoice(message, services);
|
|
87
|
+
if (!choice) return false;
|
|
88
|
+
const err = await handleChoice(plugin, services, message, choice.sessionId, choice.choiceId);
|
|
89
|
+
if (err) await message.$reply?.(err);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function registerInteractive(
|
|
94
|
+
plugin: Plugin,
|
|
95
|
+
getServices: () => GameServices | null,
|
|
96
|
+
): void {
|
|
97
|
+
plugin.registerInteractiveHandler(`${ADV_PREFIX}:`, (message) =>
|
|
98
|
+
handleAdvAction(plugin, getServices, message),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerTextFallback(
|
|
103
|
+
plugin: Plugin,
|
|
104
|
+
getServices: () => GameServices | null,
|
|
105
|
+
): void {
|
|
106
|
+
registerGameTextMiddleware(plugin, async (message, next) => {
|
|
107
|
+
const services = getServices();
|
|
108
|
+
if (!services) return next();
|
|
109
|
+
|
|
110
|
+
const action = getActionFromMessage(message);
|
|
111
|
+
if (action?.payload.startsWith(`${ADV_PREFIX}:`)) return next();
|
|
112
|
+
|
|
113
|
+
const ch = channelKey(message);
|
|
114
|
+
const session = await services.sessions.getActiveForUser(ch, message.$sender.id);
|
|
115
|
+
if (!session) return next();
|
|
116
|
+
|
|
117
|
+
const raw = message.$raw?.trim() ?? '';
|
|
118
|
+
const n = /^(\d+)$/.exec(raw);
|
|
119
|
+
if (!n) return next();
|
|
120
|
+
|
|
121
|
+
const scene = getScene(session.scene_id);
|
|
122
|
+
if (!scene) return next();
|
|
123
|
+
const state = stateFromSession(session);
|
|
124
|
+
const choices = visibleChoices(scene, state);
|
|
125
|
+
const map = buildChoiceFallbackMap(ADV_PREFIX, session.id, choices);
|
|
126
|
+
const payload = map[n[1]!];
|
|
127
|
+
const parsed = payload ? parseChoicePayload(payload, ADV_PREFIX) : null;
|
|
128
|
+
if (!parsed || parsed.sessionId !== session.id) return next();
|
|
129
|
+
|
|
130
|
+
const err = await handleChoice(plugin, services, message, session.id, parsed.choiceId);
|
|
131
|
+
if (err) await message.$reply?.(err);
|
|
132
|
+
}, 'adv:text');
|
|
133
|
+
}
|
package/src/game-flow.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Adapter, Message, Plugin } from 'zhin.js';
|
|
2
|
+
import type { AdvProfileRow, AdvSessionRow } from './models.js';
|
|
3
|
+
import { formatNewAchievements } from './profile-format.js';
|
|
4
|
+
import { buildSceneInteractive } from './scene-view.js';
|
|
5
|
+
import {
|
|
6
|
+
applyChoiceResult,
|
|
7
|
+
getScene,
|
|
8
|
+
resolveChoice,
|
|
9
|
+
stateFromSession,
|
|
10
|
+
} from './story.js';
|
|
11
|
+
import type { GameServices } from './session-service.js';
|
|
12
|
+
|
|
13
|
+
export async function sendOrEditScene(
|
|
14
|
+
plugin: Plugin,
|
|
15
|
+
services: GameServices,
|
|
16
|
+
message: Message<any>,
|
|
17
|
+
session: AdvSessionRow,
|
|
18
|
+
extraNarrative = '',
|
|
19
|
+
profile?: AdvProfileRow,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const prof = profile
|
|
22
|
+
?? await services.profiles.getOrCreate(session.player_id, session.player_name);
|
|
23
|
+
const content = buildSceneInteractive(session, extraNarrative, prof);
|
|
24
|
+
if (!content) {
|
|
25
|
+
return '场景数据异常,请 adv quit 后重新开始。';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const adapter = plugin.root.inject(message.$adapter) as Adapter;
|
|
29
|
+
|
|
30
|
+
if (session.board_message_id) {
|
|
31
|
+
const msgId = await adapter.editMessage({
|
|
32
|
+
messageId: session.board_message_id,
|
|
33
|
+
context: String(message.$adapter),
|
|
34
|
+
endpoint: message.$endpoint,
|
|
35
|
+
id: message.$channel.id,
|
|
36
|
+
type: message.$channel.type,
|
|
37
|
+
content,
|
|
38
|
+
});
|
|
39
|
+
if (msgId !== session.board_message_id) {
|
|
40
|
+
await services.sessions.updateSession(session.id, { board_message_id: msgId });
|
|
41
|
+
}
|
|
42
|
+
return msgId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const msgId = await message.$reply?.(content);
|
|
46
|
+
if (msgId) {
|
|
47
|
+
await services.sessions.updateSession(session.id, { board_message_id: msgId });
|
|
48
|
+
}
|
|
49
|
+
return msgId ?? '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function startAdventure(
|
|
53
|
+
plugin: Plugin,
|
|
54
|
+
services: GameServices,
|
|
55
|
+
message: Message<any>,
|
|
56
|
+
): Promise<string | undefined> {
|
|
57
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
58
|
+
const active = await services.sessions.getActiveByChannel(ch);
|
|
59
|
+
if (active) {
|
|
60
|
+
if (active.player_id === message.$sender.id) {
|
|
61
|
+
return '你已有进行中的冒险。发送 adv continue 继续,或 adv quit 放弃。';
|
|
62
|
+
}
|
|
63
|
+
return `本频道已有玩家 ${active.player_name} 的冒险进行中。`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await services.profiles.onRunStarted(message.$sender.id, message.$sender.name?.trim() || message.$sender.id);
|
|
67
|
+
const session = await services.sessions.createSession(message);
|
|
68
|
+
await services.profiles.onStep(
|
|
69
|
+
message.$sender.id,
|
|
70
|
+
session.player_name,
|
|
71
|
+
'start',
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
await sendOrEditScene(plugin, services, message, session);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleChoice(
|
|
79
|
+
plugin: Plugin,
|
|
80
|
+
services: GameServices,
|
|
81
|
+
message: Message<any>,
|
|
82
|
+
sessionId: string,
|
|
83
|
+
choiceId: string,
|
|
84
|
+
): Promise<string | null> {
|
|
85
|
+
const session = await services.sessions.getById(sessionId);
|
|
86
|
+
if (!session || session.status !== 'active') {
|
|
87
|
+
return '冒险不存在或已结束。';
|
|
88
|
+
}
|
|
89
|
+
if (session.player_id !== message.$sender.id) {
|
|
90
|
+
return '这是别人的冒险。';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const state = stateFromSession(session);
|
|
94
|
+
const scene = getScene(state.sceneId);
|
|
95
|
+
if (!scene) return '场景丢失,请 adv quit 后重新开始。';
|
|
96
|
+
|
|
97
|
+
if (scene.terminal && choiceId !== 'restart') {
|
|
98
|
+
return '本局已结束,请点击「再玩一次」或发送 adv start。';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (choiceId === 'restart') {
|
|
102
|
+
await services.sessions.updateSession(session.id, {
|
|
103
|
+
scene_id: 'start',
|
|
104
|
+
hp: 100,
|
|
105
|
+
inventory: '[]',
|
|
106
|
+
flags: '{}',
|
|
107
|
+
ending_id: '',
|
|
108
|
+
status: 'active',
|
|
109
|
+
step_count: 0,
|
|
110
|
+
board_message_id: '',
|
|
111
|
+
});
|
|
112
|
+
const updated = (await services.sessions.getById(session.id))!;
|
|
113
|
+
await services.profiles.onStep(updated.player_id, updated.player_name, 'start', []);
|
|
114
|
+
await sendOrEditScene(plugin, services, message, updated);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = resolveChoice(state, choiceId);
|
|
119
|
+
if (!result) return '该选项不可用。';
|
|
120
|
+
|
|
121
|
+
const next = applyChoiceResult(state, result);
|
|
122
|
+
const nextScene = getScene(next.sceneId);
|
|
123
|
+
const completed = !!nextScene?.terminal || next.hp <= 0;
|
|
124
|
+
|
|
125
|
+
if (next.hp <= 0 && !nextScene?.terminal) {
|
|
126
|
+
next.sceneId = 'defeat';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const finalScene = getScene(next.sceneId);
|
|
130
|
+
const status = completed || finalScene?.terminal ? 'completed' : 'active';
|
|
131
|
+
const newStepCount = session.step_count + 1;
|
|
132
|
+
|
|
133
|
+
await services.sessions.updateSession(session.id, {
|
|
134
|
+
scene_id: next.sceneId,
|
|
135
|
+
hp: next.hp,
|
|
136
|
+
inventory: JSON.stringify(next.inventory),
|
|
137
|
+
flags: JSON.stringify(next.flags),
|
|
138
|
+
ending_id: next.endingId,
|
|
139
|
+
status,
|
|
140
|
+
step_count: newStepCount,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const endingId = next.endingId || (finalScene?.terminal ? next.sceneId : '');
|
|
144
|
+
let newAchievements = await services.profiles.onStep(
|
|
145
|
+
session.player_id,
|
|
146
|
+
session.player_name,
|
|
147
|
+
next.sceneId,
|
|
148
|
+
next.inventory,
|
|
149
|
+
);
|
|
150
|
+
if (status === 'completed') {
|
|
151
|
+
const more = await services.profiles.onRunCompleted(
|
|
152
|
+
session.player_id,
|
|
153
|
+
session.player_name,
|
|
154
|
+
endingId,
|
|
155
|
+
newStepCount,
|
|
156
|
+
next.inventory,
|
|
157
|
+
);
|
|
158
|
+
newAchievements = [...new Set([...newAchievements, ...more])];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const updated = (await services.sessions.getById(session.id))!;
|
|
162
|
+
const extra = formatNewAchievements(newAchievements);
|
|
163
|
+
await sendOrEditScene(plugin, services, message, updated, extra);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function continueAdventure(
|
|
168
|
+
plugin: Plugin,
|
|
169
|
+
services: GameServices,
|
|
170
|
+
message: Message<any>,
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const session = await services.sessions.getActiveForUser(
|
|
173
|
+
`${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`,
|
|
174
|
+
message.$sender.id,
|
|
175
|
+
);
|
|
176
|
+
if (!session) return '你没有进行中的冒险,发送 adv start 开始。';
|
|
177
|
+
await sendOrEditScene(plugin, services, message, session);
|
|
178
|
+
return '已刷新当前场景。';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function sessionSummary(session: AdvSessionRow): string {
|
|
182
|
+
const scene = getScene(session.scene_id);
|
|
183
|
+
const state = stateFromSession(session);
|
|
184
|
+
const items = state.inventory;
|
|
185
|
+
const lines = [
|
|
186
|
+
`冒险 ${session.id}`,
|
|
187
|
+
`玩家:${session.player_name}`,
|
|
188
|
+
`场景:${scene?.id ?? session.scene_id}`,
|
|
189
|
+
`生命:${session.hp}`,
|
|
190
|
+
`物品:${items.length ? items.join(', ') : '无'}`,
|
|
191
|
+
`步数:${session.step_count}`,
|
|
192
|
+
`状态:${session.status}`,
|
|
193
|
+
];
|
|
194
|
+
if (session.ending_id) lines.push(`结局:${session.ending_id}`);
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getPlugin } from 'zhin.js';
|
|
2
|
+
import { ensureGameHubService } from '@zhin.js/game-shared';
|
|
3
|
+
import { runAdvCommand, ADV_HELP } from './adv-command.js';
|
|
4
|
+
import type { GameServices } from './session-service.js';
|
|
5
|
+
|
|
6
|
+
export function registerAdvHub(getServices: () => GameServices | null): () => void {
|
|
7
|
+
const plugin = getPlugin();
|
|
8
|
+
ensureGameHubService(plugin);
|
|
9
|
+
return plugin.registerGame({
|
|
10
|
+
id: 'adv',
|
|
11
|
+
title: '秘境探险',
|
|
12
|
+
icon: '🗺️',
|
|
13
|
+
description: '文字冒险,31 区域 · 15 结局 · 成就收集',
|
|
14
|
+
commandPrefix: '冒险',
|
|
15
|
+
quickStart: '开始',
|
|
16
|
+
aliases: ['adv', '秘境'],
|
|
17
|
+
menus: [
|
|
18
|
+
{ id: 'start', label: '🚪 开始冒险', style: 'primary' },
|
|
19
|
+
{ id: 'continue', label: '🔄 继续冒险' },
|
|
20
|
+
{ id: 'map', label: '🗺️ 探索进度' },
|
|
21
|
+
{ id: 'achievements', label: '🏅 成就' },
|
|
22
|
+
{ id: 'help', label: '📖 玩法说明' },
|
|
23
|
+
],
|
|
24
|
+
runAction: async (actionId, ctx) => {
|
|
25
|
+
const services = getServices();
|
|
26
|
+
if (!services) return '文字冒险需要启用 database 配置。';
|
|
27
|
+
return runAdvCommand(ctx.plugin, services, ctx.message, actionId);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { ADV_HELP };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhin.js/plugin-text-adventure — 交互式文字冒险
|
|
3
|
+
*
|
|
4
|
+
* ```yaml
|
|
5
|
+
* plugins:
|
|
6
|
+
* - "@zhin.js/plugin-text-adventure"
|
|
7
|
+
* database:
|
|
8
|
+
* dialect: sqlite
|
|
9
|
+
* storage: ./data/zhin.db
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { Cron, formatCompact, usePlugin, type DatabaseFeature } from 'zhin.js';
|
|
13
|
+
import { registerModels } from './models.js';
|
|
14
|
+
import {
|
|
15
|
+
createServices,
|
|
16
|
+
resolveGameDatabase,
|
|
17
|
+
type GameServices,
|
|
18
|
+
} from './session-service.js';
|
|
19
|
+
import { registerCommands, registerInteractive, registerTextFallback } from './commands.js';
|
|
20
|
+
import { registerAdvHub } from './hub-register.js';
|
|
21
|
+
|
|
22
|
+
const plugin = usePlugin();
|
|
23
|
+
const { logger, useContext, addCron } = plugin;
|
|
24
|
+
|
|
25
|
+
registerModels(plugin);
|
|
26
|
+
|
|
27
|
+
let services: GameServices | null = null;
|
|
28
|
+
|
|
29
|
+
useContext('database', (dbFeature: DatabaseFeature) => {
|
|
30
|
+
services = createServices(resolveGameDatabase(dbFeature));
|
|
31
|
+
logger.info(formatCompact({ 模块: '文字冒险', 数据模型: '已就绪' }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
registerAdvHub(() => services);
|
|
35
|
+
registerCommands(plugin, () => services);
|
|
36
|
+
registerInteractive(plugin, () => services);
|
|
37
|
+
registerTextFallback(plugin, () => services);
|
|
38
|
+
|
|
39
|
+
addCron(
|
|
40
|
+
new Cron('0 */15 * * * *', async () => {
|
|
41
|
+
if (!services) return;
|
|
42
|
+
const n = await services.sessions.abortStale(60 * 60 * 1000);
|
|
43
|
+
if (n > 0) logger.debug(formatCompact({ 文字冒险: '清理超时局', count: n }));
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
logger.info(formatCompact({ 模块: '文字冒险', 状态: '已加载' }));
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Models, Plugin } from 'zhin.js';
|
|
2
|
+
|
|
3
|
+
export type AdvSessionStatus = 'active' | 'completed' | 'aborted';
|
|
4
|
+
|
|
5
|
+
declare module 'zhin.js' {
|
|
6
|
+
interface Models {
|
|
7
|
+
adv_sessions: {
|
|
8
|
+
id: string;
|
|
9
|
+
adapter: string;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
channel_type: string;
|
|
12
|
+
channel_id: string;
|
|
13
|
+
channel_key: string;
|
|
14
|
+
player_id: string;
|
|
15
|
+
player_name: string;
|
|
16
|
+
scene_id: string;
|
|
17
|
+
hp: number;
|
|
18
|
+
inventory: string;
|
|
19
|
+
flags: string;
|
|
20
|
+
ending_id: string;
|
|
21
|
+
status: AdvSessionStatus;
|
|
22
|
+
board_message_id: string;
|
|
23
|
+
step_count: number;
|
|
24
|
+
updated_at: number;
|
|
25
|
+
created_at: number;
|
|
26
|
+
};
|
|
27
|
+
adv_profiles: {
|
|
28
|
+
player_id: string;
|
|
29
|
+
player_name: string;
|
|
30
|
+
visited_scenes: string;
|
|
31
|
+
endings_seen: string;
|
|
32
|
+
items_found: string;
|
|
33
|
+
achievements: string;
|
|
34
|
+
runs_started: number;
|
|
35
|
+
runs_completed: number;
|
|
36
|
+
total_steps: number;
|
|
37
|
+
best_step_count: number;
|
|
38
|
+
updated_at: number;
|
|
39
|
+
created_at: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AdvSessionRow = Models['adv_sessions'];
|
|
45
|
+
export type AdvProfileRow = Models['adv_profiles'];
|
|
46
|
+
export type AdvModelName = 'adv_sessions' | 'adv_profiles';
|
|
47
|
+
|
|
48
|
+
export function registerModels(plugin: Plugin): void {
|
|
49
|
+
plugin.defineModel('adv_sessions', {
|
|
50
|
+
id: { type: 'text', primary: 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
|
+
player_id: { type: 'text', nullable: false },
|
|
57
|
+
player_name: { type: 'text', default: '' },
|
|
58
|
+
scene_id: { type: 'text', default: 'start' },
|
|
59
|
+
hp: { type: 'integer', default: 100 },
|
|
60
|
+
inventory: { type: 'text', default: '[]' },
|
|
61
|
+
flags: { type: 'text', default: '{}' },
|
|
62
|
+
ending_id: { type: 'text', default: '' },
|
|
63
|
+
status: { type: 'text', default: 'active' },
|
|
64
|
+
board_message_id: { type: 'text', default: '' },
|
|
65
|
+
step_count: { type: 'integer', default: 0 },
|
|
66
|
+
updated_at: { type: 'integer', default: 0 },
|
|
67
|
+
created_at: { type: 'integer', default: 0 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
plugin.defineModel('adv_profiles', {
|
|
71
|
+
player_id: { type: 'text', primary: true },
|
|
72
|
+
player_name: { type: 'text', default: '' },
|
|
73
|
+
visited_scenes: { type: 'text', default: '[]' },
|
|
74
|
+
endings_seen: { type: 'text', default: '[]' },
|
|
75
|
+
items_found: { type: 'text', default: '[]' },
|
|
76
|
+
achievements: { type: 'text', default: '[]' },
|
|
77
|
+
runs_started: { type: 'integer', default: 0 },
|
|
78
|
+
runs_completed: { type: 'integer', default: 0 },
|
|
79
|
+
total_steps: { type: 'integer', default: 0 },
|
|
80
|
+
best_step_count: { type: 'integer', default: 0 },
|
|
81
|
+
updated_at: { type: 'integer', default: 0 },
|
|
82
|
+
created_at: { type: 'integer', default: 0 },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { achievementById, ACHIEVEMENTS, totalAchievements } from './achievements.js';
|
|
2
|
+
import {
|
|
3
|
+
endingIds,
|
|
4
|
+
endingLabel,
|
|
5
|
+
MAP_ZONES,
|
|
6
|
+
playableSceneIds,
|
|
7
|
+
regionLabel,
|
|
8
|
+
} from './story-catalog.js';
|
|
9
|
+
import type { AdvProfileRow } from './models.js';
|
|
10
|
+
import { parseStringSet } from './profile-parse.js';
|
|
11
|
+
|
|
12
|
+
export function formatProgressCompact(profile: AdvProfileRow): string {
|
|
13
|
+
const visited = parseStringSet(profile.visited_scenes).size;
|
|
14
|
+
const endings = parseStringSet(profile.endings_seen).size;
|
|
15
|
+
const achievements = parseStringSet(profile.achievements).size;
|
|
16
|
+
const totalRegions = playableSceneIds().length;
|
|
17
|
+
const totalEndings = endingIds().length;
|
|
18
|
+
return `🗺️ ${visited}/${totalRegions} · 🎬 ${endings}/${totalEndings} · 🏅 ${achievements}/${totalAchievements()}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatMapProgress(profile: AdvProfileRow): string {
|
|
22
|
+
const visited = parseStringSet(profile.visited_scenes);
|
|
23
|
+
const total = playableSceneIds().length;
|
|
24
|
+
const pct = total ? Math.round((visited.size / total) * 100) : 0;
|
|
25
|
+
const lines = [
|
|
26
|
+
`🗺️ **探索进度** ${visited.size}/${total}(${pct}%)`,
|
|
27
|
+
'',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const zone of MAP_ZONES) {
|
|
31
|
+
const parts = zone.sceneIds.map((id) => {
|
|
32
|
+
const mark = visited.has(id) ? '✅' : '⬜';
|
|
33
|
+
return `${mark} ${regionLabel(id)}`;
|
|
34
|
+
});
|
|
35
|
+
lines.push(`**${zone.name}**`);
|
|
36
|
+
lines.push(parts.join(' '));
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const endings = parseStringSet(profile.endings_seen);
|
|
41
|
+
const allEndings = endingIds();
|
|
42
|
+
lines.push(`**结局图鉴** ${endings.size}/${allEndings.length}`);
|
|
43
|
+
lines.push(
|
|
44
|
+
allEndings
|
|
45
|
+
.map((id) => `${endings.has(id) ? '✅' : '⬜'} ${endingLabel(id)}`)
|
|
46
|
+
.join(' '),
|
|
47
|
+
);
|
|
48
|
+
return lines.join('\n').trimEnd();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatAchievements(profile: AdvProfileRow, showHidden = false): string {
|
|
52
|
+
const unlocked = parseStringSet(profile.achievements);
|
|
53
|
+
const lines = [
|
|
54
|
+
`🏅 **成就** ${unlocked.size}/${totalAchievements()}`,
|
|
55
|
+
`完成局数:${profile.runs_completed} · 生涯步数:${profile.total_steps}`,
|
|
56
|
+
'',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const def of ACHIEVEMENTS) {
|
|
60
|
+
const got = unlocked.has(def.id);
|
|
61
|
+
if (def.hidden && !got && !showHidden) continue;
|
|
62
|
+
const mark = got ? '✅' : '⬜';
|
|
63
|
+
lines.push(`${mark} ${def.icon} **${def.title}** — ${def.description}`);
|
|
64
|
+
}
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatNewAchievements(ids: string[]): string {
|
|
69
|
+
if (!ids.length) return '';
|
|
70
|
+
const lines = ['', '🏅 **新成就解锁!**'];
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
const def = achievementById(id);
|
|
73
|
+
if (def) lines.push(`${def.icon} ${def.title} — ${def.description}`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** 解析 DB 中的 JSON 字符串集合(兼容 SQLite 已自动 parse 的数组) */
|
|
2
|
+
export function parseStringSet(value: string | string[] | unknown): Set<string> {
|
|
3
|
+
if (Array.isArray(value)) {
|
|
4
|
+
return new Set(value.filter((x): x is string => typeof x === 'string'));
|
|
5
|
+
}
|
|
6
|
+
if (typeof value !== 'string' || !value) return new Set();
|
|
7
|
+
try {
|
|
8
|
+
const v = JSON.parse(value);
|
|
9
|
+
return Array.isArray(v)
|
|
10
|
+
? new Set(v.filter((x): x is string => typeof x === 'string'))
|
|
11
|
+
: new Set();
|
|
12
|
+
} catch {
|
|
13
|
+
return new Set();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function serializeStringSet(set: Set<string>): string {
|
|
18
|
+
return JSON.stringify([...set].sort());
|
|
19
|
+
}
|