feat(help): traps/tokens/grid sections and clickable cross-links

Add instruction pages for traps, non-player tokens, and grid generator, and turn section mentions into in-modal navigation links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-28 12:13:53 +08:00
parent f270812219
commit a2b418b78a
7 changed files with 289 additions and 16 deletions
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HelpSectionId } from './helpSections';
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
const RU_TITLES: Record<HelpSectionId, string> = {
overview: 'Обзор приложения',
license: 'Лицензия и первый запуск',
projects: 'Проекты',
scenes: 'Сцены',
graph: 'Граф сцен',
sideStorylines: 'Побочные сюжетные линии',
sceneProps: 'Свойства сцены',
sceneEditor: 'Редактор сцены',
grid: 'Генератор сетки',
traps: 'Ловушки',
tokens: 'Неигровые токены',
campaignAudio: 'Аудио игры',
materials: 'Материалы',
npcs: 'НПС',
session: 'Запуск сессии',
controlPanel: 'Пульт управления',
transitions: 'Переходы между сценами',
music: 'Музыка на пульте',
effects: 'Эффекты поля и действий',
presentation: 'Экран презентации',
importExport: 'Импорт и экспорт',
settings: 'Настройки, язык и обновления',
};
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text =
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const ranges = findHelpLinkRanges(text, catalog);
assert.deepEqual(
ranges.map((r) => r.id),
['traps', 'effects', 'grid', 'tokens'],
);
});
void test('findHelpLinkRanges: длинный title предпочитается короткому алиасу', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text = 'Откройте «Редактор сцены» и «Генератор сетки».';
const ranges = findHelpLinkRanges(text, catalog);
assert.equal(ranges.length, 2);
assert.equal(ranges[0]?.id, 'sceneEditor');
assert.equal(ranges[1]?.id, 'grid');
});
void test('splitHelpTextWithLinks: EN see Scene editor / Traps', () => {
const getTitle = (id: HelpSectionId): string => {
const map: Partial<Record<HelpSectionId, string>> = {
sceneEditor: 'Scene editor',
traps: 'Traps',
tokens: 'Non-player tokens',
grid: 'Grid generator',
effects: 'Field and action effects',
controlPanel: 'Control panel',
};
return map[id] ?? id;
};
const catalog = buildHelpLinkCatalog(getTitle);
const parts = splitHelpTextWithLinks(
'For details, see Grid generator, Traps, and Non-player tokens. Also see Effects.',
catalog,
);
const links = parts.filter((p) => p.type === 'link');
assert.deepEqual(
links.map((p) => (p.type === 'link' ? p.id : null)),
['grid', 'traps', 'tokens', 'effects'],
);
});
void test('findHelpLinkRanges: полное «Пульт управления» не режется до «Пульт»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const ranges = findHelpLinkRanges('см. «Пульт управления»', catalog);
assert.equal(ranges.length, 1);
assert.equal(ranges[0]?.id, 'controlPanel');
assert.equal(ranges[0]?.text, '«Пульт управления»');
});
+115
View File
@@ -0,0 +1,115 @@
import { HELP_SECTION_IDS, type HelpSectionId } from './helpSections';
/**
* Короткие имена разделов в перекрёстных ссылках
* (когда в тексте не полное title, напр. «Эффекты» вместо «Эффекты поля и действий»).
*/
export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly string[]>> = {
effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'],
controlPanel: ['Пульт'],
};
export type HelpLinkCatalogEntry = {
id: HelpSectionId;
label: string;
};
export type HelpLinkRange = {
start: number;
end: number;
id: HelpSectionId;
text: string;
};
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Каталог подписей → id, длинные первыми. */
export function buildHelpLinkCatalog(getTitle: (id: HelpSectionId) => string): HelpLinkCatalogEntry[] {
const byLabel = new Map<string, HelpSectionId>();
for (const id of HELP_SECTION_IDS) {
const title = getTitle(id).trim();
if (title) byLabel.set(title, id);
for (const alias of HELP_SECTION_LINK_ALIASES[id] ?? []) {
const a = alias.trim();
if (a) byLabel.set(a, id);
}
}
return [...byLabel.entries()]
.map(([label, id]) => ({ id, label }))
.sort((a, b) => b.label.length - a.label.length || a.label.localeCompare(b.label));
}
function isBoundaryChar(ch: string | undefined): boolean {
if (ch === undefined) return true;
if (/^[\p{L}\p{N}]$/u.test(ch)) return false;
return true;
}
function collectMatches(text: string, label: string, id: HelpSectionId): HelpLinkRange[] {
const out: HelpLinkRange[] = [];
if (!label) return out;
const quoted = new RegExp(`«${escapeRegExp(label)}»`, 'g');
for (const m of text.matchAll(quoted)) {
if (m.index === undefined) continue;
out.push({ start: m.index, end: m.index + m[0].length, id, text: m[0] });
}
const bare = new RegExp(escapeRegExp(label), 'g');
for (const m of text.matchAll(bare)) {
if (m.index === undefined) continue;
const start = m.index;
const end = start + m[0].length;
const before = text[start - 1];
const after = text[end];
if (!isBoundaryChar(before) || !isBoundaryChar(after)) continue;
if (before === '«' && after === '»') continue;
out.push({ start, end, id, text: m[0] });
}
return out;
}
export function findHelpLinkRanges(text: string, catalog: readonly HelpLinkCatalogEntry[]): HelpLinkRange[] {
const candidates: HelpLinkRange[] = [];
for (const entry of catalog) {
candidates.push(...collectMatches(text, entry.label, entry.id));
}
candidates.sort(
(a, b) => a.start - b.start || b.end - b.start - (a.end - a.start),
);
const selected: HelpLinkRange[] = [];
let cursor = 0;
for (const range of candidates) {
if (range.start < cursor) continue;
selected.push(range);
cursor = range.end;
}
return selected;
}
export function splitHelpTextWithLinks(
text: string,
catalog: readonly HelpLinkCatalogEntry[],
): Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> {
const ranges = findHelpLinkRanges(text, catalog);
if (ranges.length === 0) return [{ type: 'text', value: text }];
const parts: Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> =
[];
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
parts.push({ type: 'text', value: text.slice(cursor, range.start) });
}
parts.push({ type: 'link', id: range.id, value: range.text });
cursor = range.end;
}
if (cursor < text.length) {
parts.push({ type: 'text', value: text.slice(cursor) });
}
return parts;
}
+3
View File
@@ -8,6 +8,9 @@ export const HELP_SECTION_IDS = [
'sideStorylines',
'sceneProps',
'sceneEditor',
'grid',
'traps',
'tokens',
'campaignAudio',
'materials',
'npcs',