You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import type { Translations } from '../i18n/types';
|
|
import type { Locale } from '../i18n/localeCodes';
|
|
import { t } from '../i18n/index';
|
|
import {
|
|
dialogueText,
|
|
enemyFamilyLabel,
|
|
npcLabel,
|
|
townLabel,
|
|
WANDERING_MERCHANT_DIALOGUE_KEY,
|
|
} from '../i18n/contentLabels';
|
|
import {
|
|
adventureLogTemplate,
|
|
achievementLogTitle,
|
|
roadsidePhraseText,
|
|
townNpcVisitPhraseText,
|
|
} from '../i18n/loadLocales';
|
|
import { localizedQuestText } from '../i18n/questCopy';
|
|
|
|
export interface AdventureLogEventWire {
|
|
code: string;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
|
|
function strArg(args: Record<string, unknown> | undefined, key: string): string {
|
|
if (!args) return '';
|
|
const v = args[key];
|
|
if (v == null) return '';
|
|
return String(v);
|
|
}
|
|
|
|
function numArg(args: Record<string, unknown> | undefined, key: string): number {
|
|
if (!args) return 0;
|
|
const v = args[key];
|
|
if (typeof v === 'number') return v;
|
|
if (typeof v === 'string') {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function intArg(args: Record<string, unknown> | undefined, key: string): number {
|
|
return Math.floor(numArg(args, key));
|
|
}
|
|
|
|
function boolArg(args: Record<string, unknown> | undefined, key: string): boolean {
|
|
if (!args) return false;
|
|
const v = args[key];
|
|
if (typeof v === 'boolean') return v;
|
|
if (v === 'true') return true;
|
|
return false;
|
|
}
|
|
|
|
function debuffName(tr: Translations, raw: string): string {
|
|
const m: Record<string, keyof Translations> = {
|
|
poison: 'debuffPoison',
|
|
freeze: 'debuffFreeze',
|
|
burn: 'debuffBurn',
|
|
stun: 'debuffStun',
|
|
slow: 'debuffSlow',
|
|
weaken: 'debuffWeaken',
|
|
ice_slow: 'debuffIceSlow',
|
|
};
|
|
const k = m[raw.toLowerCase()];
|
|
return k ? tr[k] : raw;
|
|
}
|
|
|
|
function buffName(tr: Translations, raw: string): string {
|
|
const m: Record<string, keyof Translations> = {
|
|
rush: 'buffRush',
|
|
rage: 'buffRage',
|
|
shield: 'buffShield',
|
|
luck: 'buffLuck',
|
|
resurrection: 'buffResurrection',
|
|
heal: 'buffHeal',
|
|
power_potion: 'buffPowerPotion',
|
|
war_cry: 'buffWarCry',
|
|
};
|
|
const k = m[raw.toLowerCase()];
|
|
return k ? tr[k] : raw;
|
|
}
|
|
|
|
function slotName(tr: Translations, raw: string): string {
|
|
const m: Record<string, keyof Translations> = {
|
|
main_hand: 'slotWeapon',
|
|
off_hand: 'slotOffHand',
|
|
head: 'slotHead',
|
|
chest: 'slotChest',
|
|
legs: 'slotLegs',
|
|
feet: 'slotFeet',
|
|
cloak: 'slotCloak',
|
|
neck: 'slotNeck',
|
|
finger: 'slotRing',
|
|
wrist: 'slotWrist',
|
|
hands: 'slotHands',
|
|
quiver: 'slotQuiver',
|
|
};
|
|
const k = m[raw.toLowerCase()];
|
|
return k ? tr[k] : raw;
|
|
}
|
|
|
|
function rarityName(tr: Translations, raw: string): string {
|
|
const m: Record<string, keyof Translations> = {
|
|
common: 'common',
|
|
uncommon: 'uncommon',
|
|
rare: 'rare',
|
|
epic: 'epic',
|
|
legendary: 'legendary',
|
|
};
|
|
const k = m[raw.toLowerCase()];
|
|
return k ? tr[k] : raw;
|
|
}
|
|
|
|
function subscriptionDuration(locale: Locale, key: string): string {
|
|
if (key === 'subscription.week') {
|
|
return locale === 'ru' ? 'неделю подписки' : 'one week of subscription';
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/** Map legacy DB/WS semantic codes to phrase keys (or dynamic roadside/town). */
|
|
const LEGACY_SEMANTIC_TO_PHRASE: Record<string, string> = {
|
|
defeated_enemy: 'log.defeated_enemy',
|
|
leveled_up: 'log.leveled_up',
|
|
equipped_new: 'log.equipped_new',
|
|
inventory_full_dropped: 'log.inventory_full_dropped',
|
|
buff_activated: 'log.buff_activated',
|
|
hero_revived: 'log.hero_revived',
|
|
wandering_merchant_encounter: 'log.wandering_merchant_encounter',
|
|
encountered_enemy: 'log.encountered_enemy',
|
|
died_fighting: 'log.died_fighting',
|
|
auto_revive_hours: 'log.auto_revive_hours',
|
|
auto_revive_after_sec: 'log.auto_revive_after_sec',
|
|
purchased_buff_refill: 'log.purchased_buff_refill',
|
|
purchased_buff_refill_rub: 'log.purchased_buff_refill_rub',
|
|
subscribed: 'log.subscribed',
|
|
used_healing_potion: 'log.used_healing_potion',
|
|
achievement_unlocked: 'log.achievement_unlocked',
|
|
met_npc: 'log.met_npc',
|
|
wandering_alms_equipped: 'log.wandering_alms_equipped',
|
|
wandering_alms_dropped: 'log.wandering_alms_dropped',
|
|
wandering_alms_stashed: 'log.wandering_alms_stashed',
|
|
healed_full_town: 'log.healed_full_town',
|
|
bought_potion_town: 'log.bought_potion_town',
|
|
sold_items_merchant: 'log.sold_items_merchant',
|
|
npc_skipped_visit: 'log.npc_skipped_visit',
|
|
purchased_potion_from_npc: 'log.purchased_potion_from_npc',
|
|
paid_healer_full: 'log.paid_healer_full',
|
|
quest_giver_checked: 'log.quest_giver_checked',
|
|
quest_accepted: 'log.quest_accepted',
|
|
town_npc_visit_line: '__dynamic_town_visit__',
|
|
combat_swing: 'combat_swing',
|
|
};
|
|
|
|
/**
|
|
* Normalize event.code to a phrase key (log.*, roadside.<slug>, town_visit.type.<slug|line>).
|
|
* Legacy rows without dots are mapped; phrase keys pass through.
|
|
*/
|
|
export function normalizeAdventureLogCode(code: string, args?: Record<string, unknown>): string {
|
|
if (!code) return '';
|
|
if (code.includes('.')) {
|
|
return code;
|
|
}
|
|
const mapped = LEGACY_SEMANTIC_TO_PHRASE[code];
|
|
const a = args ?? {};
|
|
if (mapped === '__dynamic_town_visit__') {
|
|
const npcType = strArg(a, 'npcType') || 'generic';
|
|
const line = intArg(a, 'line');
|
|
return `town_visit.${npcType}.${line}`;
|
|
}
|
|
if (mapped) return mapped;
|
|
return code;
|
|
}
|
|
|
|
function dynamicRoadsideLine(locale: Locale, code: string): string | undefined {
|
|
if (!code.startsWith('roadside.')) return undefined;
|
|
const text = roadsidePhraseText(locale, code);
|
|
return text || undefined;
|
|
}
|
|
|
|
function dynamicTownVisitLine(locale: Locale, code: string): string | undefined {
|
|
if (!code.startsWith('town_visit.')) return undefined;
|
|
const text = townNpcVisitPhraseText(locale, code);
|
|
return text || undefined;
|
|
}
|
|
|
|
function formatLegacyCombatSwing(
|
|
locale: Locale,
|
|
tr: Translations,
|
|
args: Record<string, unknown>,
|
|
legacyMessage?: string,
|
|
): string {
|
|
const source = strArg(args, 'source');
|
|
const outcome = strArg(args, 'outcome');
|
|
const damage = intArg(args, 'damage');
|
|
const isCrit = boolArg(args, 'isCrit');
|
|
const enemySlug = strArg(args, 'enemyType');
|
|
const enemyDbName = strArg(args, 'enemyName');
|
|
const enemy = enemyFamilyLabel(locale, enemySlug, enemyDbName || enemySlug);
|
|
const debuff = strArg(args, 'debuffType');
|
|
const dn = debuff ? debuffName(tr, debuff) : '';
|
|
const debuffPart =
|
|
debuff && dn ? (locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`) : '';
|
|
const crit = isCrit ? (locale === 'ru' ? ' (крит)' : ' (crit)') : '';
|
|
|
|
let phraseKey = 'log.combat.hero_hit';
|
|
if (source === 'hero') {
|
|
if (outcome === 'stun') phraseKey = 'log.combat.hero_stun';
|
|
else if (outcome === 'dodge') phraseKey = 'log.combat.hero_dodge';
|
|
} else if (source === 'enemy') {
|
|
phraseKey = outcome === 'block' ? 'log.combat.enemy_block' : 'log.combat.enemy_hit';
|
|
}
|
|
|
|
const tmpl = adventureLogTemplate(locale, phraseKey);
|
|
if (!tmpl) return legacyMessage ?? '';
|
|
|
|
const vars: Record<string, string | number> = {
|
|
enemy,
|
|
damage,
|
|
crit: phraseKey === 'log.combat.hero_stun' ? '' : crit,
|
|
debuffPart,
|
|
};
|
|
return t(tmpl, vars);
|
|
}
|
|
|
|
function resolveAdventureLogVars(
|
|
locale: Locale,
|
|
tr: Translations,
|
|
phraseKey: string,
|
|
args: Record<string, unknown>,
|
|
legacyMessage?: string,
|
|
): Record<string, string | number> | null {
|
|
const a = args;
|
|
|
|
switch (phraseKey) {
|
|
case 'log.defeated_enemy':
|
|
case 'log.encountered_enemy':
|
|
case 'log.died_fighting': {
|
|
const slug = strArg(a, 'enemyType');
|
|
const dbName = strArg(a, 'enemyName');
|
|
return {
|
|
enemy: enemyFamilyLabel(locale, slug, dbName || slug),
|
|
xp: numArg(a, 'xp'),
|
|
gold: numArg(a, 'gold'),
|
|
};
|
|
}
|
|
case 'log.leveled_up':
|
|
return { level: intArg(a, 'level') };
|
|
case 'log.equipped_new':
|
|
case 'log.wandering_alms_equipped': {
|
|
const slot = slotName(tr, strArg(a, 'slot'));
|
|
const rarity = rarityName(tr, strArg(a, 'rarity'));
|
|
const legacyName = strArg(a, 'itemName');
|
|
const item =
|
|
legacyName ||
|
|
(locale === 'ru' ? `${rarity} (${slot})` : `${rarity} — ${slot}`);
|
|
return { slot, item };
|
|
}
|
|
case 'log.inventory_full_dropped':
|
|
case 'log.wandering_alms_dropped':
|
|
case 'log.wandering_alms_stashed': {
|
|
const slot = slotName(tr, strArg(a, 'slot'));
|
|
const rarity = rarityName(tr, strArg(a, 'rarity'));
|
|
const legacyName = strArg(a, 'itemName');
|
|
const item =
|
|
legacyName ||
|
|
(locale === 'ru' ? `${rarity} (${slot})` : `${rarity} (${slot})`);
|
|
return { item };
|
|
}
|
|
case 'log.buff_activated':
|
|
return { buff: buffName(tr, strArg(a, 'buffType')) };
|
|
case 'log.hero_revived':
|
|
case 'log.auto_revive_hours':
|
|
case 'log.healed_full_town':
|
|
case 'log.bought_potion_town':
|
|
return {};
|
|
case 'log.auto_revive_after_sec':
|
|
return { seconds: intArg(a, 'seconds') };
|
|
case 'log.purchased_buff_refill':
|
|
return { buff: buffName(tr, strArg(a, 'buffType')) };
|
|
case 'log.purchased_buff_refill_rub':
|
|
return {
|
|
buff: buffName(tr, strArg(a, 'buffType')),
|
|
price: intArg(a, 'priceRub'),
|
|
};
|
|
case 'log.subscribed':
|
|
return {
|
|
duration: subscriptionDuration(locale, strArg(a, 'durationKey')),
|
|
price: intArg(a, 'priceRub'),
|
|
};
|
|
case 'log.used_healing_potion':
|
|
return { amount: intArg(a, 'amount') };
|
|
case 'log.achievement_unlocked': {
|
|
const rt = strArg(a, 'rewardType');
|
|
const ra = intArg(a, 'rewardAmount');
|
|
let rewardSuffix = '';
|
|
if (rt === 'gold') {
|
|
rewardSuffix = locale === 'ru' ? ` (+${ra} золота)` : ` (+${ra} gold)`;
|
|
} else if (rt === 'potion') {
|
|
rewardSuffix = locale === 'ru' ? ` (+${ra} зелий)` : ` (+${ra} potions)`;
|
|
}
|
|
const id = strArg(a, 'achievementId');
|
|
const legacyTitle = strArg(a, 'title');
|
|
const title = achievementLogTitle(locale, id) || legacyTitle || id || '';
|
|
return {
|
|
title,
|
|
rewardSuffix,
|
|
};
|
|
}
|
|
case 'log.met_npc': {
|
|
const nk = strArg(a, 'npcKey');
|
|
const tk = strArg(a, 'townKey');
|
|
return {
|
|
npc: npcLabel(locale, nk, nk),
|
|
town: townLabel(locale, tk, tk),
|
|
};
|
|
}
|
|
case 'log.sold_items_merchant': {
|
|
const nk = strArg(a, 'npcKey');
|
|
return {
|
|
count: intArg(a, 'count'),
|
|
gold: numArg(a, 'gold'),
|
|
npc: npcLabel(locale, nk, legacyMessage ?? nk),
|
|
};
|
|
}
|
|
case 'log.npc_skipped_visit':
|
|
case 'log.purchased_potion_from_npc':
|
|
case 'log.paid_healer_full':
|
|
case 'log.quest_giver_checked': {
|
|
const nk = strArg(a, 'npcKey');
|
|
return { npc: npcLabel(locale, nk, nk) };
|
|
}
|
|
case 'log.quest_accepted': {
|
|
const qk = strArg(a, 'questKey');
|
|
const legacyTitle = strArg(a, 'title');
|
|
const fromKey = localizedQuestText(locale, qk, 'title', qk || '');
|
|
return { title: fromKey || legacyTitle || qk };
|
|
}
|
|
case 'log.combat.hero_hit':
|
|
case 'log.combat.hero_dodge':
|
|
case 'log.combat.hero_stun':
|
|
case 'log.combat.enemy_hit':
|
|
case 'log.combat.enemy_block': {
|
|
const slug = strArg(a, 'enemyType');
|
|
const enemy = enemyFamilyLabel(locale, slug, slug);
|
|
const debuff = strArg(a, 'debuffType');
|
|
const dn = debuff ? debuffName(tr, debuff) : '';
|
|
const debuffPart =
|
|
debuff && dn ? (locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`) : '';
|
|
const isCrit = boolArg(a, 'isCrit');
|
|
const crit =
|
|
phraseKey === 'log.combat.hero_stun'
|
|
? ''
|
|
: isCrit
|
|
? locale === 'ru'
|
|
? ' (крит)'
|
|
: ' (crit)'
|
|
: '';
|
|
return {
|
|
enemy,
|
|
damage: intArg(a, 'damage'),
|
|
crit,
|
|
debuffPart,
|
|
};
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Localized single log line from structured event (+ optional legacy message). */
|
|
export function formatAdventureLogEvent(
|
|
locale: Locale,
|
|
tr: Translations,
|
|
code: string,
|
|
args?: Record<string, unknown>,
|
|
legacyMessage?: string,
|
|
): string {
|
|
const rawArgs = args ?? {};
|
|
const phraseKey = normalizeAdventureLogCode(code, rawArgs);
|
|
|
|
if (phraseKey === 'combat_swing') {
|
|
return formatLegacyCombatSwing(locale, tr, rawArgs, legacyMessage);
|
|
}
|
|
|
|
if (phraseKey === 'log.wandering_merchant_encounter') {
|
|
return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? '');
|
|
}
|
|
|
|
const roadside = dynamicRoadsideLine(locale, phraseKey);
|
|
if (roadside !== undefined) return roadside;
|
|
|
|
const town = dynamicTownVisitLine(locale, phraseKey);
|
|
if (town !== undefined) return town;
|
|
|
|
const template = adventureLogTemplate(locale, phraseKey);
|
|
if (!template) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[adventure log] missing template for', phraseKey, 'raw code', code);
|
|
}
|
|
return legacyMessage?.trim() ? legacyMessage : '';
|
|
}
|
|
|
|
const vars = resolveAdventureLogVars(locale, tr, phraseKey, rawArgs, legacyMessage);
|
|
if (vars === null) {
|
|
return legacyMessage?.trim() ? legacyMessage : '';
|
|
}
|
|
return t(template, vars);
|
|
}
|
|
|
|
export function formatAdventureLogPayload(
|
|
locale: Locale,
|
|
tr: Translations,
|
|
payload: { message?: string; event?: AdventureLogEventWire },
|
|
): string {
|
|
const legacy = payload.message?.trim() ? payload.message : '';
|
|
if (payload.event?.code) {
|
|
const formatted = formatAdventureLogEvent(
|
|
locale,
|
|
tr,
|
|
payload.event.code,
|
|
payload.event.args,
|
|
legacy,
|
|
);
|
|
if (formatted) return formatted;
|
|
}
|
|
return legacy;
|
|
}
|
|
|
|
/** Client-only log lines (no server event). */
|
|
export function formatClientLogLine(
|
|
tr: Translations,
|
|
templateKey: 'enteredTown' | 'declinedWanderingMerchant' | 'merchantMovedOn',
|
|
vars?: { town?: string },
|
|
): string {
|
|
switch (templateKey) {
|
|
case 'enteredTown':
|
|
return t(tr.logEnteredTown, { town: vars?.town ?? '' });
|
|
case 'declinedWanderingMerchant':
|
|
return tr.logDeclinedWanderingMerchant;
|
|
case 'merchantMovedOn':
|
|
return tr.logMerchantMovedOn;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|