卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
// Rich-text description formatting (pure, no DOM / Preact — unit-tested in Node).
//
// Official descriptions (data/*.json `descRaw`, DATA.md §0) carry Arknights markup:
// <@ba.vup>+15%</> styled span (class `ba.vup`: value-up highlight)
// <$ba.stun>晕眩</> term span (a status keyword with a glossary entry)
// <@autochess.dgreen>3</> autochess-specific colours
// Tags nest. Anything else that looks like a tag — `<获得时>`, `<在场6名不同【炎】干员>` — is literal
// text and is kept verbatim (its closing `>` included). `\n` (real newline or the two-char escape)
// is a line break.
//
// API:
// parseRichText(src) → Seg[] where Seg = { text, cls: string[], term: boolean } | { br: true }
// richTextPlain(src) → string (markup stripped, newlines kept)
// rtClassName(cls) → CSS class for an official style id ('ba.vup' → 'rt-vup')
// formatBondEffect(bond, layers)→ bond `effectDescRaw` with its `{i:fmt}` placeholders resolved
// formatPlaceholder(value, fmt) → '15%' / '3' for the official number formats ('0%', '0.0%', '0', '0.0')
// fillPlaceholders(src, values) → replace `{i:fmt}` / `{i}` by values[i] (unknown indexes kept)
/** Official style id → our CSS modifier (css/screens/game-panels.css `.rt-*`). */
const STYLE_CLASS = {
'ba.vup': 'rt-vup',
'ba.vdown': 'rt-vdown',
'ba.rem': 'rt-rem',
'ba.acrem': 'rt-note',
'ba.kw': 'rt-kw',
'ba.talpu': 'rt-vup',
'autochess.gray': 'rt-note',
'autochess.dgreen': 'rt-mint',
'autochess.green': 'rt-mint',
'autochess.red': 'rt-vdown',
'autochess.yellow': 'rt-rem',
'eb.key': 'rt-kw',
'eb.danger': 'rt-vdown',
};
/**
* CSS class for an official style id. Term tags (`$…`) map to `rt-term`, unknown styles to `rt-hl`.
* @param {string} cls style id without the sigil, e.g. 'ba.vup'
* @param {boolean} [term]
* @returns {string}
*/
export function rtClassName(cls, term = false) {
if (term) return 'rt-term';
return STYLE_CLASS[cls] || 'rt-hl';
}
const TAG_OPEN = /^<([@$])([A-Za-z0-9_.\-]{1,48})>/;
const TAG_CLOSE = '</>';
/**
* Parse official rich text into flat styled segments.
* @param {any} src
* @returns {Array<{ text: string, cls: string[], term: boolean } | { br: true }>}
*/
export function parseRichText(src) {
if (src == null) return [];
const s = String(src).replace(/\\n/g, '\n').replace(/\r\n?/g, '\n');
/** @type {Array<{ cls: string, term: boolean }>} */
const stack = [];
const out = [];
let buf = '';
const flush = () => {
if (!buf) return;
const cls = stack.map((t) => t.cls);
const term = stack.some((t) => t.term);
const prev = out[out.length - 1];
// merge with the previous segment when styles are identical
if (prev && !prev.br && prev.term === term && prev.cls.length === cls.length && prev.cls.every((c, i) => c === cls[i])) {
prev.text += buf;
} else {
out.push({ text: buf, cls, term });
}
buf = '';
};
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '\n') {
flush();
out.push({ br: true });
i += 1;
continue;
}
if (ch === '<') {
if (s.startsWith(TAG_CLOSE, i)) {
if (stack.length) {
flush();
stack.pop();
} else {
buf += TAG_CLOSE; // stray closer: keep literally
}
i += TAG_CLOSE.length;
continue;
}
const m = TAG_OPEN.exec(s.slice(i, i + 52));
if (m) {
flush();
stack.push({ cls: m[2], term: m[1] === '$' });
i += m[0].length;
continue;
}
}
buf += ch;
i += 1;
}
flush();
return out;
}
/**
* Markup-free text (newlines kept).
* @param {any} src
* @returns {string}
*/
export function richTextPlain(src) {
return parseRichText(src).map((seg) => (seg.br ? '\n' : seg.text)).join('');
}
/**
* Format a number with an official format string.
* '0%' → rounded percent, '0.0%' → 1 decimal percent, '0' → integer, '0.0' → 1 decimal. Unknown → trimmed number.
* @param {number} value
* @param {string} [fmt]
* @returns {string}
*/
export function formatPlaceholder(value, fmt = '0') {
const v = Number(value);
if (!Number.isFinite(v)) return '?';
const f = typeof fmt === 'string' ? fmt : '0';
const pct = f.endsWith('%');
const core = pct ? f.slice(0, -1) : f;
const dot = core.indexOf('.');
const decimals = dot >= 0 ? Math.min(4, core.length - dot - 1) : 0;
const n = pct ? v * 100 : v;
const fixed = n.toFixed(decimals);
// avoid "-0"
const clean = Number(fixed) === 0 ? (0).toFixed(decimals) : fixed;
return pct ? `${clean}%` : clean;
}
/**
* Replace `{i:fmt}` / `{i}` placeholders with values (unknown indexes are kept as-is).
* @param {string} src
* @param {Array<number|string|null|undefined>} values
* @param {string[]} [formats] optional per-index format override
* @returns {string}
*/
export function fillPlaceholders(src, values, formats = []) {
if (src == null) return '';
return String(src).replace(/\{(\d{1,2})(?::([^{}]{1,12}))?\}/g, (whole, idx, fmt) => {
const i = Number(idx);
const v = Array.isArray(values) ? values[i] : undefined;
if (v == null) return whole;
if (typeof v === 'string') return v;
return formatPlaceholder(v, formats[i] || fmt || '0');
});
}
/**
* The bond's in-battle effect text with its layer-dependent numbers resolved (DATA.md §3:
* `{i:fmt}` = `bb[base] + bb[perStack] × layers`).
* @param {any} bond bonds.json record
* @param {number} [layers]
* @returns {string} rich text (markup kept)
*/
export function formatBondEffect(bond, layers = 0) {
if (!bond || typeof bond !== 'object') return '';
const src = bond.effectDescRaw || bond.effectDesc || '';
const params = Array.isArray(bond.effectDescParams) ? bond.effectDescParams : [];
const bb = bond.bb && typeof bond.bb === 'object' ? bond.bb : {};
const L = Number.isFinite(Number(layers)) ? Math.max(0, Number(layers)) : 0;
const values = [];
const formats = [];
for (const p of params) {
if (!p || !Number.isInteger(p.index)) continue;
const base = Number(bb[p.base]) || 0;
const per = Number(bb[p.perStack]) || 0;
values[p.index] = base + per * L;
formats[p.index] = p.format || '0';
}
return fillPlaceholders(src, values, formats);
}