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

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 '';
}
}