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; } function strArg(args: Record | undefined, key: string): string { if (!args) return ''; const v = args[key]; if (v == null) return ''; return String(v); } function numArg(args: Record | 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 | undefined, key: string): number { return Math.floor(numArg(args, key)); } function boolArg(args: Record | 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 = { 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 = { 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 = { 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 = { 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 = { 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., town_visit.type.). * Legacy rows without dots are mapped; phrase keys pass through. */ export function normalizeAdventureLogCode(code: string, args?: Record): 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, 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 = { enemy, damage, crit: phraseKey === 'log.combat.hero_stun' ? '' : crit, debuffPart, }; return t(tmpl, vars); } function resolveAdventureLogVars( locale: Locale, tr: Translations, phraseKey: string, args: Record, legacyMessage?: string, ): Record | 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, 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 ''; } }