@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Database, Models, RelatedModel } from 'zhin.js';
|
|
2
|
+
import { evaluateAchievements, type AchievementContext } from './achievements.js';
|
|
3
|
+
import type { AdvModelName, AdvProfileRow } from './models.js';
|
|
4
|
+
import { parseStringSet, serializeStringSet } from './profile-parse.js';
|
|
5
|
+
import { playableSceneIds } from './story-catalog.js';
|
|
6
|
+
import type { AdvDatabase } from './session-service.js';
|
|
7
|
+
|
|
8
|
+
const PLAYABLE = new Set(playableSceneIds());
|
|
9
|
+
|
|
10
|
+
type AdvModel<K extends AdvModelName> = RelatedModel<unknown, Models, K>;
|
|
11
|
+
|
|
12
|
+
function getModel<K extends AdvModelName>(db: AdvDatabase, name: K): AdvModel<K> {
|
|
13
|
+
const model = db.models.get(name);
|
|
14
|
+
if (!model) throw new Error(`Model ${name} is not registered`);
|
|
15
|
+
return model as AdvModel<K>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ProfileService {
|
|
19
|
+
constructor(private readonly db: AdvDatabase) {}
|
|
20
|
+
|
|
21
|
+
async get(playerId: string): Promise<AdvProfileRow | null> {
|
|
22
|
+
return getModel(this.db, 'adv_profiles').findOne({ player_id: playerId });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async getOrCreate(playerId: string, playerName: string): Promise<AdvProfileRow> {
|
|
26
|
+
const existing = await this.get(playerId);
|
|
27
|
+
if (existing) {
|
|
28
|
+
if (playerName && existing.player_name !== playerName) {
|
|
29
|
+
await this.patch(playerId, { player_name: playerName });
|
|
30
|
+
return (await this.get(playerId))!;
|
|
31
|
+
}
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const row: AdvProfileRow = {
|
|
36
|
+
player_id: playerId,
|
|
37
|
+
player_name: playerName || playerId,
|
|
38
|
+
visited_scenes: '[]',
|
|
39
|
+
endings_seen: '[]',
|
|
40
|
+
items_found: '[]',
|
|
41
|
+
achievements: '[]',
|
|
42
|
+
runs_started: 0,
|
|
43
|
+
runs_completed: 0,
|
|
44
|
+
total_steps: 0,
|
|
45
|
+
best_step_count: 0,
|
|
46
|
+
updated_at: now,
|
|
47
|
+
created_at: now,
|
|
48
|
+
};
|
|
49
|
+
await getModel(this.db, 'adv_profiles').create(row);
|
|
50
|
+
return row;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async patch(playerId: string, patch: Partial<AdvProfileRow>): Promise<void> {
|
|
54
|
+
await getModel(this.db, 'adv_profiles').updateWhere(
|
|
55
|
+
{ player_id: playerId },
|
|
56
|
+
{ ...patch, updated_at: Date.now() },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async onRunStarted(playerId: string, playerName: string): Promise<AdvProfileRow> {
|
|
61
|
+
const profile = await this.getOrCreate(playerId, playerName);
|
|
62
|
+
await this.patch(playerId, { runs_started: profile.runs_started + 1 });
|
|
63
|
+
return (await this.get(playerId))!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 记录进入场景、步数、背包变化;返回新解锁成就 ID */
|
|
67
|
+
async onStep(
|
|
68
|
+
playerId: string,
|
|
69
|
+
playerName: string,
|
|
70
|
+
sceneId: string,
|
|
71
|
+
inventory: string[],
|
|
72
|
+
): Promise<string[]> {
|
|
73
|
+
const profile = await this.getOrCreate(playerId, playerName);
|
|
74
|
+
const visited = parseStringSet(profile.visited_scenes);
|
|
75
|
+
const items = parseStringSet(profile.items_found);
|
|
76
|
+
let changed = false;
|
|
77
|
+
if (PLAYABLE.has(sceneId) && !visited.has(sceneId)) {
|
|
78
|
+
visited.add(sceneId);
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
for (const item of inventory) {
|
|
82
|
+
if (!items.has(item)) {
|
|
83
|
+
items.add(item);
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const patch: Partial<AdvProfileRow> = {
|
|
88
|
+
total_steps: profile.total_steps + 1,
|
|
89
|
+
};
|
|
90
|
+
if (changed) {
|
|
91
|
+
patch.visited_scenes = serializeStringSet(visited);
|
|
92
|
+
patch.items_found = serializeStringSet(items);
|
|
93
|
+
}
|
|
94
|
+
await this.patch(playerId, patch);
|
|
95
|
+
const updated = (await this.get(playerId))!;
|
|
96
|
+
return this.unlockPending(updated);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 局终:记录结局、完成数、最佳步数 */
|
|
100
|
+
async onRunCompleted(
|
|
101
|
+
playerId: string,
|
|
102
|
+
playerName: string,
|
|
103
|
+
endingId: string,
|
|
104
|
+
stepCount: number,
|
|
105
|
+
inventory: string[],
|
|
106
|
+
): Promise<string[]> {
|
|
107
|
+
const profile = await this.getOrCreate(playerId, playerName);
|
|
108
|
+
const endings = parseStringSet(profile.endings_seen);
|
|
109
|
+
const items = parseStringSet(profile.items_found);
|
|
110
|
+
if (endingId) endings.add(endingId);
|
|
111
|
+
for (const item of inventory) items.add(item);
|
|
112
|
+
|
|
113
|
+
await this.patch(playerId, {
|
|
114
|
+
endings_seen: serializeStringSet(endings),
|
|
115
|
+
items_found: serializeStringSet(items),
|
|
116
|
+
runs_completed: profile.runs_completed + 1,
|
|
117
|
+
best_step_count: Math.max(profile.best_step_count, stepCount),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const updated = (await this.get(playerId))!;
|
|
121
|
+
return this.unlockPending(updated, { stepCount, inventory, endingId });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async unlockPending(
|
|
125
|
+
profile: AdvProfileRow,
|
|
126
|
+
lastRun?: AchievementContext['lastRun'],
|
|
127
|
+
): Promise<string[]> {
|
|
128
|
+
const ctx: AchievementContext = {
|
|
129
|
+
visited: parseStringSet(profile.visited_scenes),
|
|
130
|
+
endings: parseStringSet(profile.endings_seen),
|
|
131
|
+
itemsFound: parseStringSet(profile.items_found),
|
|
132
|
+
runsCompleted: profile.runs_completed,
|
|
133
|
+
bestStepCount: profile.best_step_count,
|
|
134
|
+
lastRun,
|
|
135
|
+
};
|
|
136
|
+
const current = [...parseStringSet(profile.achievements)];
|
|
137
|
+
const newly = evaluateAchievements(current, ctx);
|
|
138
|
+
if (!newly.length) return [];
|
|
139
|
+
|
|
140
|
+
const merged = new Set(current);
|
|
141
|
+
for (const id of newly) merged.add(id);
|
|
142
|
+
await this.patch(profile.player_id, {
|
|
143
|
+
achievements: serializeStringSet(merged),
|
|
144
|
+
});
|
|
145
|
+
return newly;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createProfileService(db: AdvDatabase): ProfileService {
|
|
150
|
+
return new ProfileService(db);
|
|
151
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SendContent } from 'zhin.js';
|
|
2
|
+
import { buildChoiceKeyboard } from '@zhin.js/game-shared';
|
|
3
|
+
import type { AdvProfileRow, AdvSessionRow } from './models.js';
|
|
4
|
+
import { formatProgressCompact } from './profile-format.js';
|
|
5
|
+
import {
|
|
6
|
+
ADV_PREFIX,
|
|
7
|
+
getScene,
|
|
8
|
+
sceneNarrative,
|
|
9
|
+
stateFromSession,
|
|
10
|
+
visibleChoices,
|
|
11
|
+
} from './story.js';
|
|
12
|
+
|
|
13
|
+
export function buildSceneInteractive(
|
|
14
|
+
session: AdvSessionRow,
|
|
15
|
+
extraNarrative = '',
|
|
16
|
+
profile?: AdvProfileRow,
|
|
17
|
+
): SendContent | null {
|
|
18
|
+
const scene = getScene(session.scene_id);
|
|
19
|
+
if (!scene) return null;
|
|
20
|
+
|
|
21
|
+
const state = stateFromSession(session);
|
|
22
|
+
const choices = visibleChoices(scene, state);
|
|
23
|
+
const terminal = scene.terminal || session.status !== 'active';
|
|
24
|
+
|
|
25
|
+
let narrative = sceneNarrative(scene, state);
|
|
26
|
+
if (profile && !terminal) {
|
|
27
|
+
narrative = `${formatProgressCompact(profile)}\n\n${narrative}`;
|
|
28
|
+
}
|
|
29
|
+
if (extraNarrative) {
|
|
30
|
+
narrative += extraNarrative;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return buildChoiceKeyboard({
|
|
34
|
+
gamePrefix: ADV_PREFIX,
|
|
35
|
+
sessionId: session.id,
|
|
36
|
+
narrative,
|
|
37
|
+
choices: choices.map((c) => ({
|
|
38
|
+
id: c.id,
|
|
39
|
+
label: c.label,
|
|
40
|
+
style: c.style,
|
|
41
|
+
})),
|
|
42
|
+
terminal,
|
|
43
|
+
buttonsPerRow: 2,
|
|
44
|
+
fallbackHint: '回复数字选择(仅编号)',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
|
|
2
|
+
import { channelKey, generateSessionId } from '@zhin.js/game-shared';
|
|
3
|
+
import type { AdvModelName, AdvSessionRow } from './models.js';
|
|
4
|
+
import { createProfileService, ProfileService } from './profile-service.js';
|
|
5
|
+
|
|
6
|
+
export type AdvDatabase = Database<unknown, Models, string>;
|
|
7
|
+
type AdvModel<K extends AdvModelName> = RelatedModel<unknown, Models, K>;
|
|
8
|
+
|
|
9
|
+
function getModel<K extends AdvModelName>(db: AdvDatabase, name: K): AdvModel<K> {
|
|
10
|
+
const model = db.models.get(name);
|
|
11
|
+
if (!model) throw new Error(`Model ${name} is not registered`);
|
|
12
|
+
return model as AdvModel<K>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GameServices {
|
|
16
|
+
sessions: SessionService;
|
|
17
|
+
profiles: ProfileService;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SessionService {
|
|
21
|
+
constructor(private readonly db: AdvDatabase) {}
|
|
22
|
+
|
|
23
|
+
async getActiveByChannel(channel: string): Promise<AdvSessionRow | null> {
|
|
24
|
+
const rows = await getModel(this.db, 'adv_sessions').findAll({
|
|
25
|
+
channel_key: channel,
|
|
26
|
+
status: 'active',
|
|
27
|
+
});
|
|
28
|
+
return rows[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getActiveForUser(channel: string, userId: string): Promise<AdvSessionRow | null> {
|
|
32
|
+
const row = await this.getActiveByChannel(channel);
|
|
33
|
+
if (!row || row.player_id !== userId) return null;
|
|
34
|
+
return row;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getById(id: string): Promise<AdvSessionRow | null> {
|
|
38
|
+
return getModel(this.db, 'adv_sessions').findOne({ id });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getActiveByBoardMessageId(messageId: string): Promise<AdvSessionRow | null> {
|
|
42
|
+
if (!messageId) return null;
|
|
43
|
+
const rows = await getModel(this.db, 'adv_sessions').findAll({ status: 'active' });
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
const stored = row.board_message_id;
|
|
46
|
+
if (!stored) continue;
|
|
47
|
+
if (stored === messageId) return row;
|
|
48
|
+
if (stored.endsWith(`:${messageId}`)) return row;
|
|
49
|
+
const tail = stored.split(':').pop();
|
|
50
|
+
if (tail && messageId.endsWith(tail)) return row;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async createSession(message: Message<any>): Promise<AdvSessionRow> {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const id = generateSessionId();
|
|
58
|
+
const row: AdvSessionRow = {
|
|
59
|
+
id,
|
|
60
|
+
adapter: String(message.$adapter),
|
|
61
|
+
endpoint: message.$endpoint,
|
|
62
|
+
channel_type: message.$channel.type,
|
|
63
|
+
channel_id: message.$channel.id,
|
|
64
|
+
channel_key: channelKey(message),
|
|
65
|
+
player_id: message.$sender.id,
|
|
66
|
+
player_name: message.$sender.name?.trim() || message.$sender.id,
|
|
67
|
+
scene_id: 'start',
|
|
68
|
+
hp: 100,
|
|
69
|
+
inventory: '[]',
|
|
70
|
+
flags: '{}',
|
|
71
|
+
ending_id: '',
|
|
72
|
+
status: 'active',
|
|
73
|
+
board_message_id: '',
|
|
74
|
+
step_count: 0,
|
|
75
|
+
updated_at: now,
|
|
76
|
+
created_at: now,
|
|
77
|
+
};
|
|
78
|
+
await getModel(this.db, 'adv_sessions').create(row);
|
|
79
|
+
return row;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async updateSession(id: string, patch: Partial<AdvSessionRow>): Promise<void> {
|
|
83
|
+
await getModel(this.db, 'adv_sessions').updateWhere(
|
|
84
|
+
{ id },
|
|
85
|
+
{ ...patch, updated_at: Date.now() },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async abortStale(idleMs: number): Promise<number> {
|
|
90
|
+
const cutoff = Date.now() - idleMs;
|
|
91
|
+
const sessions = getModel(this.db, 'adv_sessions');
|
|
92
|
+
const rows = await sessions.findAll({ status: 'active' });
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
if (row.updated_at < cutoff) {
|
|
96
|
+
await sessions.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
|
|
97
|
+
n++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createServices(db: AdvDatabase): GameServices {
|
|
105
|
+
return {
|
|
106
|
+
sessions: new SessionService(db),
|
|
107
|
+
profiles: createProfileService(db),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveGameDatabase(feature: DatabaseFeature): AdvDatabase {
|
|
112
|
+
return feature.db;
|
|
113
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { SCENES } from './story-scenes.js';
|
|
2
|
+
|
|
3
|
+
/** 区域中文名(用于地图 / 成就展示) */
|
|
4
|
+
export const REGION_LABELS: Record<string, string> = {
|
|
5
|
+
start: '秘境入口',
|
|
6
|
+
rubble: '碎石廊',
|
|
7
|
+
watchtower: '瞭望石塔',
|
|
8
|
+
observatory: '星象台',
|
|
9
|
+
vine_path: '秘径',
|
|
10
|
+
corridor: '幽暗走廊',
|
|
11
|
+
mural_room: '铭文壁画',
|
|
12
|
+
whisper_gallery: '低语画廊',
|
|
13
|
+
spring: '精灵泉',
|
|
14
|
+
flooded_hall: '积水厅',
|
|
15
|
+
bell_tower: '钟楼',
|
|
16
|
+
garden: '古庭院',
|
|
17
|
+
greenhouse: '遗迹温室',
|
|
18
|
+
library: '尘封书库',
|
|
19
|
+
ember_forge: '锻炉间',
|
|
20
|
+
market_ruins: '废墟市集',
|
|
21
|
+
ghost_bridge: '冥河石桥',
|
|
22
|
+
crystal_cave: '晶洞',
|
|
23
|
+
moon_pool: '月池',
|
|
24
|
+
sunken_shrine: '水下沉殿',
|
|
25
|
+
depths: '墓穴岔口',
|
|
26
|
+
crypt_entry: '墓穴入口',
|
|
27
|
+
catacombs: '骸骨甬道',
|
|
28
|
+
ossuary: '龛室',
|
|
29
|
+
prison_cell: '囚室',
|
|
30
|
+
spider_nest: '蛛巢',
|
|
31
|
+
shadow_maze: '暗影迷宫',
|
|
32
|
+
sealed_gates: '封印密室',
|
|
33
|
+
vault: '宝藏前厅',
|
|
34
|
+
vault_open: '宝库',
|
|
35
|
+
throne_room: '王座厅',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ENDING_LABELS: Record<string, string> = {
|
|
39
|
+
treasure: '遗迹宝藏',
|
|
40
|
+
ascension: '星辉归位',
|
|
41
|
+
wisdom: '智者归途',
|
|
42
|
+
curse: '贪婪代价',
|
|
43
|
+
escape: '生还',
|
|
44
|
+
defeat: '陨落',
|
|
45
|
+
coward: '怯懦归途',
|
|
46
|
+
hermit: '庭院隐者',
|
|
47
|
+
redemption: '幽魂解脱',
|
|
48
|
+
drowned: '寒水沉眠',
|
|
49
|
+
spider: '茧中亡魂',
|
|
50
|
+
labyrinth: '迷宫迷失',
|
|
51
|
+
star_keeper: '星守誓约',
|
|
52
|
+
mask_king: '面具之王',
|
|
53
|
+
merchant: '行商结局',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** 地图分区(便于 adv map 分组展示) */
|
|
57
|
+
export const MAP_ZONES: Array<{ name: string; sceneIds: string[] }> = [
|
|
58
|
+
{
|
|
59
|
+
name: '入口与塔',
|
|
60
|
+
sceneIds: ['start', 'rubble', 'watchtower', 'observatory', 'vine_path'],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: '主廊与泉',
|
|
64
|
+
sceneIds: ['corridor', 'mural_room', 'whisper_gallery', 'spring', 'flooded_hall', 'bell_tower'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: '庭院书库',
|
|
68
|
+
sceneIds: ['garden', 'greenhouse', 'library', 'ember_forge'],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: '冥域水线',
|
|
72
|
+
sceneIds: ['market_ruins', 'ghost_bridge', 'crystal_cave', 'moon_pool', 'sunken_shrine'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: '墓穴深处',
|
|
76
|
+
sceneIds: [
|
|
77
|
+
'depths',
|
|
78
|
+
'crypt_entry',
|
|
79
|
+
'catacombs',
|
|
80
|
+
'ossuary',
|
|
81
|
+
'prison_cell',
|
|
82
|
+
'spider_nest',
|
|
83
|
+
'shadow_maze',
|
|
84
|
+
'sealed_gates',
|
|
85
|
+
'vault',
|
|
86
|
+
'vault_open',
|
|
87
|
+
'throne_room',
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export function playableSceneIds(): string[] {
|
|
93
|
+
return Object.values(SCENES)
|
|
94
|
+
.filter((s) => !s.terminal)
|
|
95
|
+
.map((s) => s.id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function endingIds(): string[] {
|
|
99
|
+
return Object.values(SCENES)
|
|
100
|
+
.filter((s) => s.terminal)
|
|
101
|
+
.map((s) => s.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function regionLabel(id: string): string {
|
|
105
|
+
return REGION_LABELS[id] ?? ENDING_LABELS[id] ?? id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function endingLabel(id: string): string {
|
|
109
|
+
return ENDING_LABELS[id] ?? id;
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** 15 种可收集道具 */
|
|
2
|
+
export const ITEM_LABELS: Record<string, string> = {
|
|
3
|
+
torch: '火把',
|
|
4
|
+
key: '锈蚀钥匙',
|
|
5
|
+
gem: '幽蓝宝石',
|
|
6
|
+
herb: '荧光草药',
|
|
7
|
+
scroll: '残破卷轴',
|
|
8
|
+
amulet: '精灵护符',
|
|
9
|
+
silver_coin: '古银币',
|
|
10
|
+
rusted_blade: '生锈短剑',
|
|
11
|
+
moon_shard: '月华碎片',
|
|
12
|
+
elixir: '治愈灵药',
|
|
13
|
+
compass: '星象罗盘',
|
|
14
|
+
spirit_bell: '回音铃',
|
|
15
|
+
elf_feather: '银羽',
|
|
16
|
+
masquerade_mask: '仪式面具',
|
|
17
|
+
star_chart: '星图残页',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ITEM_IDS = Object.keys(ITEM_LABELS);
|
|
21
|
+
|
|
22
|
+
export function itemLabel(id: string): string {
|
|
23
|
+
return ITEM_LABELS[id] ?? id;
|
|
24
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 估算最长可玩步数:允许往返同一场景(DFS + 深度上限)
|
|
3
|
+
*/
|
|
4
|
+
import type { GameState } from './story-types.js';
|
|
5
|
+
import { SCENES } from './story-scenes.js';
|
|
6
|
+
import { ITEM_IDS } from './story-items.js';
|
|
7
|
+
import { resolveTransition } from './story-transitions.js';
|
|
8
|
+
|
|
9
|
+
const OPTIMISTIC_FLAGS: Record<string, boolean> = {
|
|
10
|
+
spirit_met: true,
|
|
11
|
+
saw_map: true,
|
|
12
|
+
knows_sequence: true,
|
|
13
|
+
whisper_lore: true,
|
|
14
|
+
healed: true,
|
|
15
|
+
rested: true,
|
|
16
|
+
read_archive: true,
|
|
17
|
+
forged_blade: true,
|
|
18
|
+
amulet_upgraded: true,
|
|
19
|
+
moon_blessed: true,
|
|
20
|
+
shadow_banished: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function optimisticState(sceneId: string): GameState {
|
|
24
|
+
return {
|
|
25
|
+
sceneId,
|
|
26
|
+
hp: 100,
|
|
27
|
+
inventory: [...ITEM_IDS],
|
|
28
|
+
flags: { ...OPTIMISTIC_FLAGS },
|
|
29
|
+
endingId: '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function visibleChoiceIds(state: GameState): string[] {
|
|
34
|
+
const scene = SCENES[state.sceneId];
|
|
35
|
+
if (!scene || scene.terminal) return [];
|
|
36
|
+
return scene.choices
|
|
37
|
+
.filter((c) => !c.requires || c.requires(state))
|
|
38
|
+
.map((c) => c.id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 允许回路的最长路径(玩家可多次往返) */
|
|
42
|
+
export function estimateLongestPlaythrough(maxDepth = 70): number {
|
|
43
|
+
let best = 0;
|
|
44
|
+
const memo = new Map<string, number>();
|
|
45
|
+
|
|
46
|
+
function walk(sceneId: string, depth: number): number {
|
|
47
|
+
if (depth >= maxDepth) return depth;
|
|
48
|
+
const key = `${sceneId}:${depth}`;
|
|
49
|
+
const cached = memo.get(key);
|
|
50
|
+
if (cached != null) return cached;
|
|
51
|
+
|
|
52
|
+
const scene = SCENES[sceneId];
|
|
53
|
+
if (scene?.terminal) {
|
|
54
|
+
best = Math.max(best, depth);
|
|
55
|
+
memo.set(key, depth);
|
|
56
|
+
return depth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
best = Math.max(best, depth);
|
|
60
|
+
let localBest = depth;
|
|
61
|
+
const state = optimisticState(sceneId);
|
|
62
|
+
|
|
63
|
+
for (const choiceId of visibleChoiceIds(state)) {
|
|
64
|
+
const result = resolveTransition(state, choiceId);
|
|
65
|
+
if (!result) continue;
|
|
66
|
+
localBest = Math.max(localBest, walk(result.nextSceneId, depth + 1));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
memo.set(key, localBest);
|
|
70
|
+
return localBest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
walk('start', 0);
|
|
74
|
+
return best;
|
|
75
|
+
}
|