From d888589f6e0a91c449324fbd10d65d4ab7d4b135 Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:24:19 +0200 Subject: [PATCH 01/11] fix: use English text in gym battles when language is set to en - battle messages (move used, effectiveness, fainted) now use t() with proper en/ko i18n keys instead of hardcoded Korean strings - gym trainer name uses leader (English) instead of leaderKo - badge name rendered as e.g. "Boulder Badge" in English - status bar gym line now shows "{leader}'s Gym" in English - added nameEn field to moveOptions JSON output - added missing i18n keys: battle.used_move, battle.effect_super, battle.effect_not_very, battle.effect_immune, battle.fainted, gym.status_line to both en.json and ko.json --- src/cli/battle-turn.ts | 22 +++++++----- src/core/turn-battle.ts | 14 ++++---- src/i18n/en.json | 75 ++++++----------------------------------- src/i18n/ko.json | 75 ++++++----------------------------------- src/status-line.ts | 5 +-- 5 files changed, 44 insertions(+), 147 deletions(-) diff --git a/src/cli/battle-turn.ts b/src/cli/battle-turn.ts index 25afdbae..92920ec9 100644 --- a/src/cli/battle-turn.ts +++ b/src/cli/battle-turn.ts @@ -18,7 +18,7 @@ import { selectAiAction } from '../core/gym-ai.js'; import { getGymById, awardGymVictory, canChallengeGym } from '../core/gym.js'; import { getPokemonDB, getPokemonName, speciesIdToGeneration } from '../core/pokemon-data.js'; import { getActiveGeneration } from '../core/paths.js'; -import { initLocale, t } from '../i18n/index.js'; +import { initLocale, t, getLocale } from '../i18n/index.js'; import { readGlobalConfig, readConfig, writeConfig } from '../core/config.js'; import { checkAchievements, checkCommonAchievements, formatAchievementMessage } from '../core/achievements.js'; import { withLockRetry } from '../core/lock.js'; @@ -95,6 +95,7 @@ function buildQuestionContext(player: BattlePokemon, opponent: BattlePokemon): s function buildMoveOptions(player: BattlePokemon): Array<{ index: number; nameKo: string; + nameEn: string; pp: number; maxPp: number; disabled: boolean; @@ -102,6 +103,7 @@ function buildMoveOptions(player: BattlePokemon): Array<{ return player.moves.map((move, index) => ({ index: index + 1, nameKo: move.data.nameKo, + nameEn: move.data.nameEn, pp: move.currentPp, maxPp: move.data.pp, disabled: move.currentPp <= 0 || player.fainted, @@ -383,8 +385,8 @@ function handleInit(): void { output(withBattleMetadata(bsf, { status: 'ongoing', messages: [ - t('battle.gym_challenge', { leader: gym.leaderKo }), - t('battle.send_out', { leader: gym.leaderKo, pokemon: opponentActive.displayName }), + t('battle.gym_challenge', { leader: gym.leader }), + t('battle.send_out', { leader: gym.leader, pokemon: opponentActive.displayName }), t('battle.go', { pokemon: playerActive.displayName }), ], menu: buildMenu(playerActive), @@ -491,7 +493,7 @@ function handleAction(): void { if (nextIdx !== -1) { battleState.opponent.activeIndex = nextIdx; const newActive = getActivePokemon(battleState.opponent); - messages.push(t('battle.send_out', { leader: gym.leaderKo, pokemon: newActive.displayName })); + messages.push(t('battle.send_out', { leader: gym.leader, pokemon: newActive.displayName })); } } @@ -614,7 +616,7 @@ function handleFaintedSwitch(bsf: BattleStateFile, messages: string[]): void { function handleVictory(bsf: BattleStateFile, messages: string[]): void { const { battleState, gym, generation, playerPartyNames } = bsf; - messages.push(t('battle.victory', { leader: gym.leaderKo })); + messages.push(t('battle.victory', { leader: gym.leader })); // Re-read state inside lock to avoid overwriting hook changes const lockResult = withLockRetry(() => { @@ -669,13 +671,17 @@ function handleVictory(bsf: BattleStateFile, messages: string[]): void { if (isChampion) { messages.push('═══════════════════════════════'); messages.push(` 🏆 ${t('gym.champion_victory_header')} 🏆`); - messages.push(` ${t('gym.champion_victory_detail', { region: gym.badgeKo.replace(/ 챔피언배지$/, ''), leader: gym.leaderKo })}`); + const champRegion = getLocale() === 'ko' + ? gym.badgeKo.replace(/ 챔피언배지$/, '') + : gym.badge.replace(/^champion_/, '').replace(/^\w/, c => c.toUpperCase()); + messages.push(` ${t('gym.champion_victory_detail', { region: champRegion, leader: gym.leader })}`); for (const achEvent of victoryResult.achEvents) { messages.push(` ${formatAchievementMessage(achEvent)}`); } messages.push('═══════════════════════════════'); } else { - messages.push(t('gym.badge_earned', { badge: gym.badgeKo, leader: gym.leaderKo, count: victoryResult.badgeCount })); + const badgeName = getLocale() === 'ko' ? gym.badgeKo : `${gym.badge.charAt(0).toUpperCase() + gym.badge.slice(1)} Badge`; + messages.push(t('gym.badge_earned', { badge: badgeName, leader: gym.leader, count: victoryResult.badgeCount })); for (const achEvent of victoryResult.achEvents) { messages.push(formatAchievementMessage(achEvent)); } @@ -705,7 +711,7 @@ function handleVictory(bsf: BattleStateFile, messages: string[]): void { function handleDefeat(bsf: BattleStateFile, messages: string[]): void { const { battleState, gym } = bsf; - messages.push(t('battle.defeat', { leader: gym.leaderKo })); + messages.push(t('battle.defeat', { leader: gym.leader })); deleteBattleState(); diff --git a/src/core/turn-battle.ts b/src/core/turn-battle.ts index 3e38dfdd..d7ea8acd 100644 --- a/src/core/turn-battle.ts +++ b/src/core/turn-battle.ts @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { t, getLocale } from '../i18n/index.js'; import { getTypeEffectiveness } from './type-chart.js'; import { applyStatChange, @@ -362,7 +362,7 @@ function executeMove( // announced above and has no persistent PP). if (!isStruggle) { move.currentPp--; - messages.push(`${attacker.displayName}의 ${move.data.nameKo}!`); + messages.push(t('battle.used_move', { name: attacker.displayName, move: getLocale() === 'ko' ? move.data.nameKo : move.data.nameEn })); } const moveEffect = move.data.moveEffect; @@ -413,7 +413,7 @@ function executeMove( hasOpponentChange && !hasSelfChange ) { - messages.push('효과가 없는 듯하다...'); + messages.push(t('battle.effect_immune')); return { defenderFainted: false }; } @@ -451,9 +451,9 @@ function executeMove( } // Effectiveness messages - if (effMsg === 'effect_super') messages.push('효과가 굉장했다!'); - else if (effMsg === 'effect_not_very') messages.push('효과가 별로인 듯하다...'); - else if (effMsg === 'effect_immune') messages.push('효과가 없는 듯하다...'); + if (effMsg === 'effect_super') messages.push(t('battle.effect_super')); + else if (effMsg === 'effect_not_very') messages.push(t('battle.effect_not_very')); + else if (effMsg === 'effect_immune') messages.push(t('battle.effect_immune')); if (damageDealt > 0 && moveEffect?.type === 'recoil') { const recoil = Math.max(1, Math.floor(damageDealt * moveEffect.fraction)); @@ -473,7 +473,7 @@ function executeMove( // Faint check if (defender.currentHp <= 0) { defender.fainted = true; - messages.push(`${defender.displayName}은(는) 쓰러졌다!`); + messages.push(t('battle.fainted', { name: defender.displayName })); return { defenderFainted: true }; } diff --git a/src/i18n/en.json b/src/i18n/en.json index 381517e5..d5919ceb 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -18,14 +18,12 @@ "cli.status.stat_pokeballs": " 🔴 Poké Balls: {count}", "cli.status.stat_region": " Current region: {region}", "cli.status.stat_shiny_catches": " ★Shiny catches: {count}", - "cli.starter.already_chosen": "You have already chosen a starter.", "cli.starter.current_party": "Current party: {party}", "cli.starter.prompt_title": "Choose your starter Pokemon:", "cli.starter.prompt_input": "Enter number (1-{count}): ", "cli.starter.invalid_choice": "Invalid choice.", "cli.starter.chosen": "✓ You chose {pokemon}! Let the adventure begin!", - "cli.party.header": "[ Current Party ]", "cli.party.dispatch_current": "Current dispatch: {current}", "cli.party.dispatch_auto": "auto (not set)", @@ -41,13 +39,10 @@ "cli.party.remove_usage": "Usage: tokenmon party remove ", "cli.party.remove_min": "Party must have at least 1 Pokemon.", "cli.party.remove_success": "✓ Removed {pokemon} from the party.", - "cli.unlock.header": "[ Unlocked Pokemon ]", "cli.unlock.empty": " Nothing yet.", "cli.unlock.usage": "Usage: tokenmon unlock list", - "cli.achievements.header": "[ Achievements ]", - "cli.config.usage": "Usage: tokenmon config set ", "cli.config.keys_header": "Configurable keys:", "cli.config.key_tokens_per_xp": " tokens_per_xp - Tokens per XP (default: 10000)", @@ -60,7 +55,6 @@ "cli.config.bool_error": "Please enter true or false.", "cli.config.set_success": "✓ {key} = {value} set.", "cli.config.usage_set": "Usage: tokenmon config set ", - "cli.pokedex.not_found": "Pokemon \"{name}\" not found.", "cli.pokedex.status_caught": "Caught", "cli.pokedex.status_seen": "Seen", @@ -98,12 +92,10 @@ "cli.pokedex.summary_row": " {region}: {caught}/{total} caught ({pct}%)", "cli.pokedex.summary_total": " Total: {caught}/{total} ({pct}%)", "cli.pokedex.no_results": " No pokemon match the filters.", - "cli.items.header": "[ Items ]", "cli.items.empty": " No items.", "cli.items.pokeball": "Poké Ball", "cli.items.count": "{name}: {count}", - "cli.region.moved": "Moved to {region}!", "cli.region.list_title": "=== Region List ===", "cli.region.current_marker": " ← current", @@ -115,12 +107,10 @@ "cli.region.current_title": "=== Current Region: {name} ===", "cli.region.level_range": " Level range: Lv.{min} ~ Lv.{max}", "cli.region.pokemon_pool": " Pokemon pool: {count} species", - "cli.reset.warning": "Warning: All data will be reset! (Pokemon, achievements, items, etc.)", "cli.reset.confirm": "Are you sure? (y/N): ", "cli.reset.cancelled": "Reset cancelled.", "cli.reset.done": "All data has been reset. (Cheat log preserved)", - "cli.cheat.xp_usage": "Usage: tokenmon cheat xp ", "cli.cheat.xp_no_pokemon": "{pokemon} not found.", "cli.cheat.xp_success": "Added {amount} XP to {pokemon} (total {total})", @@ -136,7 +126,6 @@ "cli.cheat.multiplier_usage": "Usage: tokenmon cheat multiplier ", "cli.cheat.multiplier_success": "XP multiplier set to {value}", "cli.cheat.unknown": "Usage: tokenmon cheat ...", - "cli.help.title": "Tokénmon - Claude Code Pokemon Partner", "cli.help.usage": "Usage: tokenmon [options]", "cli.help.commands": "Commands:", @@ -162,26 +151,21 @@ "cli.evolve.cancelled": "Evolution cancelled.", "cli.evolve.failed": "Evolution failed.", "cli.evolve.success": "✨ {old} evolved into {new}!", - "cli.notifications.header": "[ Notifications ]", "cli.notifications.empty": " No notifications.", "cli.notifications.cleared": "✓ All notifications cleared.", "cli.notifications.clear_hint": "Use tokenmon notifications clear to dismiss all.", - "cli.config.key_notifications": " notifications_enabled - Show notifications true/false", "cli.config.key_pp_enabled": " pp_enabled - Show PP (Substitute) meter true/false", - "cli.help.cmd_evolve": " evolve List evolution-ready Pokemon", "cli.help.cmd_evolve_pokemon": " evolve Evolve a Pokemon (branch selection)", "cli.help.cmd_notifications": " notifications View notifications", "cli.help.cmd_notifications_clear": " notifications clear Clear all notifications", "cli.help.cmd_friendly_battle": " friendly-battle Friendly battle commands (local v1)", - "notification.evolution_ready": "{pokemon} is ready to evolve! Run: tokenmon evolve {pokemon}", "statusline.evolution_ready": "✨ {pokemon} is ready to evolve! /tkm evolve {pokemon}", "notification.region_unlocked": "{count} new region(s) unlocked! Run: tokenmon region list", "notification.achievement_near": "Achievement \"{name}\" is {pct}% complete!", - "cli.help.cmd_items": " items List items", "cli.help.cmd_region": " region View current region", "cli.help.cmd_region_list": " region list List all regions", @@ -204,8 +188,7 @@ "cli.help.ex3": " tokenmon party add Piplup", "cli.help.ex4": " tokenmon config set cry_enabled false", "cli.unknown_command": "Unknown command: {command}", - - "cli.lock_failed": "Failed to acquire lock. Please try again later.", + "cli.lock_failed": "Failed to acquire lock. Please try again.", "cli.lock_busy": "Another process is using the data. Please try again later.", "cli.config.help_renderer": " renderer - Sprite renderer (kitty/sixel/iterm2/braille)", "cli.config.key_voice_tone": " voice_tone - Text style claude/pokemon (default: claude)", @@ -217,14 +200,11 @@ "cli.cheat.achievement_unlocked": "Achievement {name} unlocked", "cli.cheat.item_added": "Added {name} x{count} (total: {total})", "cli.cheat.xp_multiplier_set": "Set XP multiplier to {value}", - "renderer.kitty_desc": "Kitty Graphics Protocol (experimental — highest quality, original PNG)", "renderer.sixel_desc": "Sixel (experimental — DEC compatible, wide terminal support)", "renderer.iterm2_desc": "iTerm2 Inline Images (experimental — macOS iTerm2/WezTerm)", "renderer.braille_desc": "Braille (recommended — classic method, all terminals compatible)", - "setup.recommended": " [Recommended]", - "guide.index.title": "=== Tokenmon Guide ===", "guide.index.usage": "Usage: tokenmon guide ", "guide.index.topics": "Topics:", @@ -234,7 +214,6 @@ "guide.index.topic_xp": " xp XP / leveling (formula, party share, dispatch bonus)", "guide.index.topic_item": " item Items (Poké Ball, drop rates, catching)", "guide.unknown_topic": "Unknown topic: {topic}", - "guide.battle.title": "=== Battle Guide ===", "guide.battle.encounter_header": "[ Encounters ]", "guide.battle.encounter_desc1": " 15% chance to encounter a wild Pokemon per session start.", @@ -266,7 +245,6 @@ "guide.battle.reward_lose": " Lose XP = 0", "guide.battle.reward_catch": " Catch chance on win = determined by each Pokemon's catch rate", "guide.battle.reward_rarity": " {gray}Rarity bonus: common=0, uncommon=30, rare=80, legendary=200, mythical=500{reset}", - "guide.region.title": "=== Region Guide ===", "guide.region.move_header": "[ Moving Regions ]", "guide.region.move_list": " tokenmon region list List all regions", @@ -282,7 +260,6 @@ "guide.region.tip1": " Higher level region = stronger wild Pokemon = more XP", "guide.region.tip2": " Each region has a unique Pokemon pool — travel to complete the Pokedex", "guide.region.tip3": " Champion Road requires 50 catches — legendary Pokemon appear there", - "guide.achievement.title": "=== Achievement Guide ===", "guide.achievement.check_header": "[ Checking Achievements ]", "guide.achievement.check_cmd": " tokenmon achievements Full achievement list with status", @@ -295,7 +272,6 @@ "guide.achievement.reward_xp": " XP bonus: permanent XP multiplier increase (stackable)", "guide.achievement.reward_ball": " Poké Ball: used to catch wild Pokemon", "guide.achievement.reward_slot": " Party slot: increase max party size", - "guide.xp.title": "=== XP / Leveling Guide ===", "guide.xp.gain_header": "[ Gaining XP ]", "guide.xp.gain_desc": " Earn XP on battle win (0 on loss)", @@ -318,7 +294,6 @@ "guide.xp.expgroup_desc1": " Each Pokemon has a different exp group (fast, medium_fast, medium_slow, slow, etc.)", "guide.xp.expgroup_desc2": " Required XP can differ even at the same level", "guide.xp.expgroup_hint": " {gray}Check a Pokemon's group: tokenmon pokedex {reset}", - "guide.item.title": "=== Item Guide ===", "guide.item.check_header": "[ Checking Items ]", "guide.item.check_cmd": " tokenmon items Current held items", @@ -333,7 +308,6 @@ "guide.item.catch_desc2": " No ball = no catch even on win (Pokedex registered + XP only)", "guide.item.catch_desc3": " No ball consumed when battling already-caught Pokemon.", "guide.item.catch_tip": " {yellow}Tip: Stock up on Poké Balls before encountering new Pokemon{reset}", - "setup.postinstall.title": " Tokénmon initial setup...", "setup.postinstall.already_exists": " ✓ {path} already exists (preserved)", "setup.postinstall.backup_created": " ℹ Backup created: {path}", @@ -346,11 +320,9 @@ "setup.postinstall.legacy_old": " Previous data: {path}", "setup.postinstall.legacy_new": " New data: {path}", "setup.postinstall.legacy_cleanup": " Please clean up the old bash hooks manually.", - "statusline.no_starter": "[Choose a starter: /tkm:tkm starter]", "statusline.party_empty": "[Party is empty]", "statusline.pp_label": "🔋", - "setup.statusline.already_set": " ✓ tokenmon statusLine already configured (skipped)", "setup.statusline.wrapper_updated": " ✓ tokenmon wrapper updated to latest version", "setup.statusline.wrapper_already": " ✓ tokenmon wrapper already configured (skipped)", @@ -358,7 +330,6 @@ "setup.statusline.wrapper_created": " ✓ Wrapper created: {path}", "setup.statusline.combined": " ✓ Configured to display both existing statusLine and tokenmon", "setup.statusline.registered": " ✓ tokenmon statusLine registered", - "battle.win": "⚔️ vs wild {defender} (Lv.{level}) → Win! (XP +{xp})", "battle.win_dispatch": "⚔️ vs wild {defender} (Lv.{level}) → Win! (XP +{xp}, dispatch +{dispatch})", "battle.win_catch": "\n🎉 Caught {defender}! (tokenmon party add {defender})", @@ -370,7 +341,6 @@ "battle.shiny_appeared": "✦ A shiny {pokemon} appeared!", "battle.shiny_catch": "\n★ Shiny caught!", "battle.shiny_escaped": "\nThe shiny {pokemon} got away...", - "battle.gym_challenge": "{leader} wants to battle!", "battle.send_out": "{leader} sent out {pokemon}!", "battle.go": "Go, {pokemon}!", @@ -391,30 +361,24 @@ "move.rest.success": "{name} went to sleep and restored its HP!", "move.recoil": "{name} is hit by recoil!", "move.drain": "{name} drained HP!", - "achievement.unlocked": "🏆 Achievement unlocked: {name}! ", "achievement.unlocked_pokemon": "🏆 Achievement unlocked: {name}! You got {pokemon}!", "achievement.unlocked_message": "🏆 Achievement unlocked: {name}! {message}", - "region.not_found": "Region \"{name}\" not found.", "region.locked_caught": "caught", "region.locked_seen": "seen", "region.locked": "This region unlocks after catching/seeing {count} {label} Pokemon.", - "hook.levelup": "⬆️ {pokemon} Lv.{from} → Lv.{to}! (XP: +{xp})", "hook.evolution": "✨ {pokemon} evolved into {newPokemon}!", "hook.party_join": "🎊 {pokemon} joined the party!", "hook.evolution_candidate_line": "- Pokemon: {pokemon}\n Use this question VERBATIM (AskUserQuestion.question, do NOT paraphrase): \"{pokemon} is ready to evolve! Choose a form:\"\n All evolution targets: {targets}", "hook.evolution_block_reason": "Party pokemon are ready to evolve.\n\nYou MUST call AskUserQuestion. One subquestion per pokemon (batch up to 4 pokemon per call).\n\nPer-subquestion rules:\n- `question` field: copy the candidate's 'Use this question VERBATIM' line EXACTLY — do not paraphrase, do not reformat.\n- `options` (buttons, max 4):\n - 3 or fewer targets → all targets + 'Refuse'.\n - 4+ targets → first 3 targets + 'Refuse'. List the remaining targets on a separate line appended to the question body as 'Other forms: A, B, C' so the user can type one via 'Other'.\n- `multiSelect`: false\n\nCandidates:\n{candidateList}\n\nHandling the user's answer:\n- Button selecting a target → run `tokenmon evolve `.\n- 'Refuse' button or Other with 'refuse'/'no'/'cancel' → do nothing (no auto re-prompt).\n- For free-text 'Other' input, VALIDATE before running `tokenmon evolve`:\n - If the input (case-insensitive, accepting localized or English names) matches a pokemon name in the 'All evolution targets' list, evolve to that target.\n - If it doesn't match, reply briefly with something like \"I didn't recognize that — please pick a button or type one of: {targets}, refuse\" and re-invoke the same AskUserQuestion (cap at 2 re-prompts to avoid loops; after that, treat as 'Refuse').", - "tier.heated": "The tall grass is rustling intensely... (Next: encounter 1.5x, XP 1.5x)", "tier.intense": "Something seems to be lurking nearby... (Next: encounter 2.5x, XP 2.5x)", "tier.legendary": "The air is crackling with powerful energy! (Next: encounter 4x, XP 5x)", - "tip.volume_xp": "Using more tokens at once seems to yield more experience points.", "tip.volume_encounter": "Wild Pokémon appear more often during longer tasks.", "tip.volume_rare": "There are rumors that stronger Pokémon appear during complex tasks.", - "tip.battle_type_advantage": "💡 Type matchups greatly affect win rate! Target weaknesses.", "tip.battle_level_matters": "💡 Large level gaps sharply shift win rate (Lv.4 vs Lv.12 = ~17%).", "tip.battle_best_pokemon": "💡 The party member with the best type matchup is chosen automatically.", @@ -445,12 +409,10 @@ "tip.item_ball_count": "🔴 Current Poké Balls: {count}. Get ready for new encounters!", "tip.item_achievement_balls": "🔴 Achievement rewards can give you large batches of Poké Balls (up to 10!).", "tip.item_ball_strategy": "🔴 Battling already-caught Pokemon does not consume a Poké Ball.", - "item_drop.tool": "You found a Poké Ball during your adventure! Obtained {n} Poké Ball(s)!", "item_drop.subagent": "Your companion brought back Poké Balls! Obtained {n} Poké Ball(s)!", "item_drop.session_end": "Received {n} Poké Ball(s) as today's adventure reward!", "item_drop.generic": "Found {n} Poké Ball(s)!", - "cli.dashboard.region": "Region: {region} (Lv.{min}-{max})", "cli.dashboard.streak": "Day streak: {days} (best: {best})", "cli.dashboard.pokedex": "Pokedex: {caught}/{total} ({pct}%)", @@ -463,7 +425,6 @@ "cli.dashboard.activity_encounters": "• {count} encounters", "cli.dashboard.events_title": "Today's Event", "cli.dashboard.events_none": "No active events", - "cli.stats.header": "=== Stats ===", "cli.stats.streak_header": "[ Streak ]", "cli.stats.streak_days": " Current streak: {days} days", @@ -478,13 +439,10 @@ "cli.stats.alltime_battles": " Battles: {wins}W / {losses}L", "cli.stats.alltime_catches": " Catches: {count}", "cli.stats.alltime_encounters": " Encounters: {count}", - "cli.help.cmd_dashboard": " dashboard Full summary dashboard", "cli.help.cmd_stats": " stats View stats (weekly + all-time)", - "event.active_header": "Active Event", "event.none": "No active events", - "rewards.milestone_reached": "🎉 Pokédex Milestone: {label}", "rewards.pokeball_reward": " → Poké Ball x{count} received!", "rewards.xp_multiplier_reward": " → XP multiplier +{value}% permanent boost!", @@ -495,7 +453,6 @@ "rewards.type_master": "⭐ Type Master: {type}! (Battle XP 1.2x)", "rewards.chain_complete": "🔗 Evolution chain complete! (Poké Ball x{count} received)", "rewards.type_master_legendary": "⭐ {count} types mastered! Special legendary group unlocked!", - "cli.legendary.header": "=== Legendary Pokémon ===", "cli.legendary.no_pending": "No legendary groups pending. Catch more Pokémon to unlock!", "cli.legendary.pool_header": "[ Legendary Pool (wild encounters) ]", @@ -503,7 +460,6 @@ "cli.legendary.invalid_choice": "Invalid selection.", "cli.legendary.selected": "✨ {pokemon} has joined your team!", "cli.legendary.pool_added": " {names} added to wild encounter pool.", - "cli.box.header": "[ Box ]", "cli.box.empty": " Box is empty.", "cli.box.sort_hint": "Use --sort level|type|name|rarity to sort. Use --search to filter by name.", @@ -511,7 +467,6 @@ "cli.box.filter": " Filter: {filter}", "cli.box.no_results": " No pokemon match the filters.", "cli.box.shiny_tag": "★", - "cli.party.swap_usage": "Usage: tokenmon party swap ", "cli.party.swap_invalid_slot": "Invalid slot number. (1-{max})", "cli.party.swap_not_in_box": "{pokemon} is not in the box.", @@ -522,69 +477,55 @@ "cli.party.reorder_success": "✓ {pokemon}: slot {from} → slot {to}", "cli.party.suggest_header": "[ Recommended for {region} ]", "cli.party.suggest_no_region": "No region set.", - "cli.help.cmd_legendary": " legendary View/select legendary Pokémon", "cli.help.cmd_box": " box [--sort key] View box (stored Pokémon)", "cli.help.cmd_party_swap": " party swap

Swap party slot with box Pokémon", "cli.help.cmd_party_reorder": " party reorder Reorder party slots", "cli.help.cmd_party_suggest": " party suggest Suggest party for current region", - "notification.legendary_unlocked": "Legendary group unlocked! Run: tokenmon legendary", - "cli.pokedex.type_master_header": "[ Type Masters ]", "cli.pokedex.type_progress": " {type}: {caught}/{total} ({pct}%)", "cli.pokedex.type_mastered": " {type}: Mastered ✓ (XP 1.2x)", - - "star.prompt": "⭐ Enjoying Tok\u00e9mon? Run tokenmon star to give us a GitHub star!", - "star.success": "⭐ Thanks for starring Tok\u00e9mon! You're awesome!", + "star.prompt": "⭐ Enjoying Tokémon? Run tokenmon star to give us a GitHub star!", + "star.success": "⭐ Thanks for starring Tokémon! You're awesome!", "star.failed": "Failed to star. Make sure gh CLI is installed and authenticated.", "star.dismissed": "Star prompt dismissed.", "rest.activate": "💤 {hours}h rest bonus activated! XP ×{mult} for {turns} turns", "tip.rest_info": "💤 Take a break sometimes for an XP bonus!", "tip.rest_threshold": "💤 Rest for 2+ hours to activate rest bonus.", - - "cli.lock_failed": "Failed to acquire lock. Please try again.", "cli.call.not_found": "Pokemon not found: {name}", "cli.nickname.not_found": "Pokemon not found: {name}", "cli.nickname.too_long": "Nickname must be 7 characters or fewer.", "cli.nickname.current": "{species}'s nickname: {nickname}", "cli.nickname.none": "{species} doesn't have a nickname yet.", "cli.nickname.set": "Set {species}'s nickname to '{nickname}'!", - "rarity.common": "Common", "rarity.uncommon": "Uncommon", "rarity.rare": "Rare", "rarity.legendary": "Legendary", "rarity.mythical": "Mythical", - "exp_group.medium_fast": "Medium Fast", "exp_group.medium_slow": "Medium Slow", "exp_group.fast": "Fast", "exp_group.slow": "Slow", "exp_group.erratic": "Erratic", "exp_group.fluctuating": "Fluctuating", - "weather.location_set": "Weather location set to {location}. Weather will update each session.", "weather.fetch_failed": "Could not fetch weather data. Will retry next session.", - "gym.gate_rejected": "You need to explore more of this region's Pokédex or train your party before challenging this gym! (Pokédex: {caught}/{required}, Party avg level: Lv.{avgLevel}/{requiredLevel})", - "tip.gym_region_rumor": "Rumors say a powerful Gym Leader awaits in each region.", "tip.gym_badge_reward": "They say you earn a special badge for defeating a Gym Leader.", "tip.gym_gate_hint": "🏟️ Fill the regional Pokédex or train your party to challenge a gym!", "tip.gym_badge_collect": "🏟️ Collect badges to challenge the Champion! Check with `/tkm gym list`", "tip.gym_next_info": "🏟️ Next gym: {leaderName} ({type} type) — prepare your type matchups!", "tip.gym_badge_progress": "You've collected {badgeCount} badges. They say {remaining} more until the Champion.", - "gym.champion_badge_required": "You must collect all 8 badges before challenging the Champion! (Current badges: {badgeCount}/8)", - "gym.badge_earned": "🥊 {badge} earned! ({leader} defeated) [{count}/8]", "gym.champion_victory_header": "CHAMPION VICTORY!", "gym.champion_victory_detail": "{region} Champion {leader} defeated!", "gym.title_earned": "Title earned: {title}", "gym.reward_pokemon": "Reward: {pokemon} obtained!", "gym.reward_xp_dump": "{pokemon} gained {xp} XP! (Lv.{oldLevel} → Lv.{newLevel})", - "achievement.first_badge": "First Badge", "achievement.four_badges": "Badge Collector", "achievement.eight_badges": "Gym Master", @@ -593,7 +534,6 @@ "achievement.total_badges_30": "Badge Maniac", "achievement.three_gen_champion": "Multi Champion", "achievement.all_gen_champion": "Pokémon Master", - "title.champion_kanto": "Kanto Champion", "title.champion_johto": "Johto Champion", "title.champion_hoenn": "Hoenn Champion", @@ -605,7 +545,6 @@ "title.champion_paldea": "Paldea Champion", "title.multi_champion": "Multi Champion", "title.pokemon_master": "Pokémon Master", - "status.paralysis.inflicted": "{name} is paralyzed! It may be unable to move!", "status.paralysis.immobile": "{name} is paralyzed! It can't move!", "status.sleep.inflicted": "{name} fell asleep!", @@ -653,5 +592,11 @@ "stat.name.sp_defense": "Sp. Def", "stat.name.speed": "Speed", "stat.name.accuracy": "Accuracy", - "stat.name.evasion": "Evasion" + "stat.name.evasion": "Evasion", + "battle.used_move": "{name} used {move}!", + "battle.effect_super": "It's super effective!", + "battle.effect_not_very": "It's not very effective...", + "battle.effect_immune": "It has no effect...", + "battle.fainted": "{name} fainted!", + "gym.status_line": "⚔️ {leader}'s Gym — {type}" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 076579d5..58f9a95b 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -18,14 +18,12 @@ "cli.status.stat_pokeballs": " 🔴 몬스터볼: {count}개", "cli.status.stat_region": " 현재 지역: {region}", "cli.status.stat_shiny_catches": " ★색이 다른 포켓몬 포획: {count}종", - "cli.starter.already_chosen": "이미 스타터를 선택했습니다.", "cli.starter.current_party": "현재 파티: {party}", "cli.starter.prompt_title": "스타터 포켓몬을 선택하세요:", "cli.starter.prompt_input": "번호를 입력하세요 (1-{count}): ", "cli.starter.invalid_choice": "잘못된 선택입니다.", "cli.starter.chosen": "✓ {pokemon:을/를} 선택했습니다! 모험을 시작하세요!", - "cli.party.header": "[ 현재 파티 ]", "cli.party.dispatch_current": "현재 디스패치: {current}", "cli.party.dispatch_auto": "자동 (미지정)", @@ -41,13 +39,10 @@ "cli.party.remove_usage": "사용법: tokenmon party remove <포켓몬이름>", "cli.party.remove_min": "파티에 최소 1마리는 있어야 합니다.", "cli.party.remove_success": "✓ {pokemon:을/를} 파티에서 제외했습니다.", - "cli.unlock.header": "[ 잠금 해제된 포켓몬 ]", "cli.unlock.empty": " 아직 아무것도 없습니다.", "cli.unlock.usage": "사용법: tokenmon unlock list", - "cli.achievements.header": "[ 업적 ]", - "cli.config.usage": "사용법: tokenmon config set <키> <값>", "cli.config.keys_header": "설정 가능한 키:", "cli.config.key_tokens_per_xp": " tokens_per_xp - 토큰당 XP 비율 (기본: 10000)", @@ -60,7 +55,6 @@ "cli.config.bool_error": "true 또는 false 값을 입력하세요.", "cli.config.set_success": "✓ {key} = {value} 로 설정했습니다.", "cli.config.usage_set": "사용법: tokenmon config set <키> <값>", - "cli.pokedex.not_found": "\"{name}\" 포켓몬을 찾을 수 없습니다.", "cli.pokedex.status_caught": "포획됨", "cli.pokedex.status_seen": "목격됨", @@ -98,12 +92,10 @@ "cli.pokedex.summary_row": " {region}: {caught}/{total} 포획 ({pct}%)", "cli.pokedex.summary_total": " 합계: {caught}/{total} ({pct}%)", "cli.pokedex.no_results": " 필터에 맞는 포켓몬이 없습니다.", - "cli.items.header": "[ 아이템 ]", "cli.items.empty": " 아이템이 없습니다.", "cli.items.pokeball": "몬스터볼", "cli.items.count": "{name}: {count}개", - "cli.region.moved": "{region}(으)로 이동했습니다!", "cli.region.list_title": "=== 지역 목록 ===", "cli.region.current_marker": " ← 현재", @@ -115,12 +107,10 @@ "cli.region.current_title": "=== 현재 지역: {name} ===", "cli.region.level_range": " 레벨 범위: Lv.{min} ~ Lv.{max}", "cli.region.pokemon_pool": " 출현 포켓몬: {count}종", - "cli.reset.warning": "경고: 모든 데이터가 초기화됩니다! (포켓몬, 업적, 아이템 등)", "cli.reset.confirm": "정말 초기화하시겠습니까? (y/N): ", "cli.reset.cancelled": "초기화가 취소되었습니다.", "cli.reset.done": "모든 데이터가 초기화되었습니다. (치트 로그는 보존됨)", - "cli.cheat.xp_usage": "사용법: tokenmon cheat xp <포켓몬> <양>", "cli.cheat.xp_no_pokemon": "{pokemon} 포켓몬이 없습니다.", "cli.cheat.xp_success": "{pokemon}에게 XP {amount} 추가 (총 {total})", @@ -136,7 +126,6 @@ "cli.cheat.multiplier_usage": "사용법: tokenmon cheat multiplier <값>", "cli.cheat.multiplier_success": "XP 배율을 {value}로 설정", "cli.cheat.unknown": "사용법: tokenmon cheat ...", - "cli.help.title": "토큰몬 (Tokénmon) - Claude Code 포켓몬 파트너", "cli.help.usage": "사용법: tokenmon <명령> [옵션]", "cli.help.commands": "명령어:", @@ -162,26 +151,21 @@ "cli.evolve.cancelled": "진화를 취소했습니다.", "cli.evolve.failed": "진화에 실패했습니다.", "cli.evolve.success": "✨ {old:이/가} {new}(으)로 진화했습니다!", - "cli.notifications.header": "[ 알림 ]", "cli.notifications.empty": " 알림이 없습니다.", "cli.notifications.cleared": "✓ 모든 알림을 삭제했습니다.", "cli.notifications.clear_hint": "tokenmon notifications clear 로 알림을 모두 삭제할 수 있습니다.", - "cli.config.key_notifications": " notifications_enabled - 알림 표시 true/false", "cli.config.key_pp_enabled": " pp_enabled - AI대타출동(PP) 표시 true/false", - "cli.help.cmd_evolve": " evolve 진화 대기 포켓몬 목록", "cli.help.cmd_evolve_pokemon": " evolve <이름> 포켓몬 진화 (분기 선택)", "cli.help.cmd_notifications": " notifications 알림 보기", "cli.help.cmd_notifications_clear": " notifications clear 알림 모두 삭제", "cli.help.cmd_friendly_battle": " friendly-battle 친선 배틀 명령 (local v1)", - "notification.evolution_ready": "{pokemon:이/가} 진화 준비 완료! 실행: tokenmon evolve {pokemon}", "statusline.evolution_ready": "✨ {pokemon:이/가} 진화 준비 완료! /tkm evolve {pokemon}", "notification.region_unlocked": "새로운 지역 {count}개 해금! 실행: tokenmon region list", "notification.achievement_near": "업적 \"{name}\" 달성률 {pct}%!", - "cli.help.cmd_items": " items 아이템 목록", "cli.help.cmd_region": " region 현재 지역 보기", "cli.help.cmd_region_list": " region list 전체 지역 목록", @@ -204,8 +188,7 @@ "cli.help.ex3": " tokenmon party add 팽도리", "cli.help.ex4": " tokenmon config set cry_enabled false", "cli.unknown_command": "알 수 없는 명령어: {command}", - - "cli.lock_failed": "락 획득 실패. 잠시 후 다시 시도하세요.", + "cli.lock_failed": "잠금 획득에 실패했습니다. 다시 시도해주세요.", "cli.lock_busy": "다른 프로세스가 데이터를 사용 중입니다. 잠시 후 다시 시도하세요.", "cli.config.help_renderer": " renderer - 스프라이트 렌더러 (kitty/sixel/iterm2/braille)", "cli.config.key_voice_tone": " voice_tone - 텍스트 말투 claude/pokemon (기본: claude)", @@ -217,14 +200,11 @@ "cli.cheat.achievement_unlocked": "업적 {name} 해금", "cli.cheat.item_added": "{name} x{count} 추가 (총 {total})", "cli.cheat.xp_multiplier_set": "XP 배율을 {value}로 설정", - "renderer.kitty_desc": "Kitty Graphics Protocol (실험적 — 최고 품질, 원본 PNG)", "renderer.sixel_desc": "Sixel (실험적 — DEC 호환, 넓은 터미널 지원)", "renderer.iterm2_desc": "iTerm2 Inline Images (실험적 — macOS iTerm2/WezTerm)", "renderer.braille_desc": "Braille (권장 — 기존 방식, 모든 터미널 호환)", - "setup.recommended": " [추천]", - "guide.index.title": "=== 토큰몬 가이드 ===", "guide.index.usage": "사용법: tokenmon guide <주제>", "guide.index.topics": "주제:", @@ -234,7 +214,6 @@ "guide.index.topic_xp": " xp XP / 레벨링 (계산식, 파티 분배, 파견 보너스)", "guide.index.topic_item": " item 아이템 (몬스터볼, 드랍률, 포획)", "guide.unknown_topic": "알 수 없는 주제: {topic}", - "guide.battle.title": "=== 전투 가이드 ===", "guide.battle.encounter_header": "[ 전투 발생 ]", "guide.battle.encounter_desc1": " 세션 시작 시 15% 확률로 야생 포켓몬과 조우합니다.", @@ -266,7 +245,6 @@ "guide.battle.reward_lose": " 패배 시 XP = 0", "guide.battle.reward_catch": " 승리 시 포획 확률 = 포켓몬별 포획률에 따라 결정", "guide.battle.reward_rarity": " {gray}희귀도 보너스: 일반=0, 고급=30, 희귀=80, 전설=200, 환상=500{reset}", - "guide.region.title": "=== 지역 가이드 ===", "guide.region.move_header": "[ 지역 이동 ]", "guide.region.move_list": " tokenmon region list 전체 지역 목록", @@ -282,7 +260,6 @@ "guide.region.tip1": " 높은 레벨 지역 = 더 강한 야생 포켓몬 = 더 많은 XP", "guide.region.tip2": " 각 지역의 고유 포켓몬 풀이 다르므로 도감 완성엔 지역 이동 필수", "guide.region.tip3": " 챔피언 로드는 50종 포획 필요 — 전설 포켓몬이 출현합니다", - "guide.achievement.title": "=== 업적 가이드 ===", "guide.achievement.check_header": "[ 업적 확인 ]", "guide.achievement.check_cmd": " tokenmon achievements 전체 업적 목록 및 달성 여부", @@ -295,7 +272,6 @@ "guide.achievement.reward_xp": " XP 보너스: 영구적 XP 배율 증가 (중첩 가능)", "guide.achievement.reward_ball": " 몬스터볼: 야생 포켓몬 포획에 사용", "guide.achievement.reward_slot": " 파티 슬롯: 최대 파티 크기 증가", - "guide.xp.title": "=== XP / 레벨링 가이드 ===", "guide.xp.gain_header": "[ XP 획득 ]", "guide.xp.gain_desc": " 전투 승리 시 XP 획득 (패배 시 0)", @@ -318,7 +294,6 @@ "guide.xp.expgroup_desc1": " 포켓몬마다 경험치 그룹이 다릅니다 (fast, medium_fast, medium_slow, slow 등)", "guide.xp.expgroup_desc2": " 같은 레벨이라도 필요 XP가 다를 수 있습니다", "guide.xp.expgroup_hint": " {gray}각 포켓몬의 그룹: tokenmon pokedex <이름>{reset}", - "guide.item.title": "=== 아이템 가이드 ===", "guide.item.check_header": "[ 아이템 확인 ]", "guide.item.check_cmd": " tokenmon items 현재 보유 아이템", @@ -333,7 +308,6 @@ "guide.item.catch_desc2": " 볼이 없으면 승리해도 포획 불가 (도감 등록 + XP만 획득)", "guide.item.catch_desc3": " 이미 포획한 포켓몬과의 전투에서는 볼이 소비되지 않습니다.", "guide.item.catch_tip": " {yellow}팁: 새로운 포켓몬을 만나기 전에 몬스터볼을 충분히 확보하세요{reset}", - "setup.postinstall.title": " 토큰몬 (Tokénmon) 초기 설정...", "setup.postinstall.already_exists": " ✓ {path} 이미 존재 (보존)", "setup.postinstall.backup_created": " ℹ 백업 생성: {path}", @@ -346,11 +320,9 @@ "setup.postinstall.legacy_old": " 이전 데이터: {path}", "setup.postinstall.legacy_new": " 새 데이터: {path}", "setup.postinstall.legacy_cleanup": " 기존 bash 훅은 수동으로 정리하세요.", - "statusline.no_starter": "[스타터를 선택하세요: /tkm:tkm starter]", "statusline.party_empty": "[파티가 비어있습니다]", "statusline.pp_label": "🔋", - "setup.statusline.already_set": " ✓ tokenmon statusLine 이미 설정됨 (건너뜀)", "setup.statusline.wrapper_updated": " ✓ tokenmon 래퍼 최신 버전으로 갱신됨", "setup.statusline.wrapper_already": " ✓ tokenmon 래퍼 이미 설정됨 (건너뜀)", @@ -358,7 +330,6 @@ "setup.statusline.wrapper_created": " ✓ 래퍼 생성: {path}", "setup.statusline.combined": " ✓ 기존 statusLine과 tokenmon을 함께 표시하도록 설정됨", "setup.statusline.registered": " ✓ tokenmon statusLine 등록 완료", - "battle.win": "⚔️ vs 야생 {defender} (Lv.{level}) → 승리! (XP +{xp})", "battle.win_dispatch": "⚔️ vs 야생 {defender} (Lv.{level}) → 승리! (XP +{xp}, 파견 +{dispatch})", "battle.win_catch": "\n🎉 {defender:을/를} 포획했습니다! (tokenmon party add {defender})", @@ -370,7 +341,6 @@ "battle.shiny_appeared": "✦ 색이 다른 {pokemon:이/가} 나타났다!", "battle.shiny_catch": "\n★ 색이 다른 포켓몬 포획 성공!", "battle.shiny_escaped": "\n색이 다른 {pokemon:이/가} 도망쳤다...", - "battle.gym_challenge": "{leader:이/가} 승부를 걸어왔다!", "battle.send_out": "{leader:은/는} {pokemon:을/를} 내보냈다!", "battle.go": "가라, {pokemon}!", @@ -391,30 +361,24 @@ "move.rest.success": "{name}은(는) 잠들면서 HP를 회복했다!", "move.recoil": "{name}은(는) 반동 데미지를 입었다!", "move.drain": "{name}은(는) HP를 흡수했다!", - "achievement.unlocked": "🏆 업적 달성: {name}! ", "achievement.unlocked_pokemon": "🏆 업적 달성: {name}! {pokemon:을/를} 얻었습니다!", "achievement.unlocked_message": "🏆 업적 달성: {name}! {message}", - "region.not_found": "\"{name}\" 지역을 찾을 수 없습니다.", "region.locked_caught": "포획", "region.locked_seen": "발견", "region.locked": "이 지역은 포켓몬 {count}종 {label} 후 해금됩니다.", - "hook.levelup": "⬆️ {pokemon} Lv.{from} → Lv.{to}! (XP: +{xp})", "hook.evolution": "✨ {pokemon:이/가} {newPokemon}(으)로 진화했습니다!", "hook.party_join": "🎊 {pokemon:이/가} 파티에 합류했습니다!", "hook.evolution_candidate_line": "- 포켓몬: {pokemon}\n 그대로 쓸 질문 (AskUserQuestion.question, 한 글자도 바꾸지 말 것): \"{pokemon:이/가} 진화할 준비가 되었어! 어느 폼으로 진화할까?\"\n 진화 대상 전체: {targets}", "hook.evolution_block_reason": "파티 포켓몬이 진화할 준비가 되었습니다.\n\n반드시 AskUserQuestion을 호출하세요. 포켓몬당 하나의 subquestion (최대 4 포켓몬까지 배치).\n\n각 subquestion 규칙:\n- `question` 필드: 아래 후보 항목의 '그대로 쓸 질문'을 문자 그대로(paraphrase 금지) 복사해서 사용합니다.\n- `options` (버튼, 최대 4개):\n - 진화 대상 3개 이하 → 모든 대상 + '거부'.\n - 진화 대상 4개 이상 → 앞 3개 대상 + '거부'. 나머지 대상은 question 본문 뒤에 별도 줄로 \"다른 폼: A, B, C\" 처럼 나열해 사용자가 'Other'로 이름을 타이핑할 수 있게 합니다.\n- `multiSelect`: false\n\n후보:\n{candidateList}\n\n사용자 응답 처리:\n- 버튼으로 대상 선택 → `tokenmon evolve ` 실행.\n- '거부' 버튼 또는 Other에 '거부'/'no'/'cancel' → 아무것도 하지 않음 (자동 재질문 없음).\n- Other 입력의 경우 `tokenmon evolve` 실행 전에 반드시 validate:\n - 입력 텍스트(대소문자 무시, 한/영 이름 모두 허용)가 위 '진화 대상 전체' 목록의 포켓몬 이름과 일치하면 evolve 실행.\n - 일치하지 않으면 \"인식되지 않은 선택입니다. 버튼을 누르거나 다음 중 하나를 입력해 주세요: {targets}, 거부\"처럼 짧게 안내하고 동일한 AskUserQuestion을 다시 호출 (무한루프 방지: 최대 2회 재질문, 그 후에는 '거부'로 간주).", - "tier.heated": "풀숲이 크게 흔들리고 있다... (다음 턴 조우율 1.5x, XP 1.5x)", "tier.intense": "주변에 수상한 기운이 감돌고 있다... (다음 턴 조우율 2.5x, XP 2.5x)", "tier.legendary": "공기 속에 강한 에너지가 차오른다! (다음 턴 조우율 4x, XP 5x)", - "tip.volume_xp": "한번에 많은 토큰을 사용하면 경험치를 더 많이 받을 수 있다고 한다.", "tip.volume_encounter": "긴 작업을 하면 야생 포켓몬이 더 자주 나타나는 것 같다.", "tip.volume_rare": "복잡한 작업일수록 강한 포켓몬이 나타날 확률이 높아진다는 소문이 있다.", - "tip.battle_type_advantage": "💡 타입 상성으로 승률이 크게 변합니다! 약점을 노려보세요", "tip.battle_level_matters": "💡 레벨 차이가 클수록 승률이 급변합니다 (Lv.4 vs Lv.12 = ~17%)", "tip.battle_best_pokemon": "💡 전투 시 타입 상성이 가장 좋은 파티원이 자동 선택됩니다", @@ -445,12 +409,10 @@ "tip.item_ball_count": "🔴 현재 몬스터볼: {count}개. 새로운 포켓몬을 만날 준비를 하세요!", "tip.item_achievement_balls": "🔴 업적 보상으로 몬스터볼을 대량 획득할 수 있습니다 (최대 10개!)", "tip.item_ball_strategy": "🔴 이미 포획한 포켓몬과의 전투에서는 몬스터볼이 소비되지 않습니다", - "item_drop.tool": "모험 도중 몬스터볼을 발견했습니다! 몬스터볼 {n}개를 손에 넣었습니다!", "item_drop.subagent": "동료가 몬스터볼을 가지고 돌아왔습니다! 몬스터볼 {n}개를 손에 넣었습니다!", "item_drop.session_end": "오늘의 모험 보수로 몬스터볼 {n}개를 받았습니다!", "item_drop.generic": "몬스터볼 {n}개를 주웠습니다!", - "cli.dashboard.region": "지역: {region} (Lv.{min}-{max})", "cli.dashboard.streak": "연속 출석: {days}일 (최고: {best})", "cli.dashboard.pokedex": "도감: {caught}/{total} ({pct}%)", @@ -463,7 +425,6 @@ "cli.dashboard.activity_encounters": "• 인카운터 {count}회", "cli.dashboard.events_title": "오늘의 이벤트", "cli.dashboard.events_none": "활성 이벤트 없음", - "cli.stats.header": "=== 통계 ===", "cli.stats.streak_header": "[ 연속 출석 ]", "cli.stats.streak_days": " 현재 연속: {days}일", @@ -478,13 +439,10 @@ "cli.stats.alltime_battles": " 전투: {wins}승 / {losses}패", "cli.stats.alltime_catches": " 포획: {count}회", "cli.stats.alltime_encounters": " 인카운터: {count}회", - "cli.help.cmd_dashboard": " dashboard 전체 대시보드", "cli.help.cmd_stats": " stats 통계 보기 (주간 + 전체)", - "event.active_header": "활성 이벤트", "event.none": "활성 이벤트 없음", - "rewards.milestone_reached": "🎉 도감 마일스톤 달성: {label}", "rewards.pokeball_reward": " → 몬스터볼 x{count} 획득!", "rewards.xp_multiplier_reward": " → XP 배율 +{value}% 영구 증가!", @@ -495,7 +453,6 @@ "rewards.type_master": "⭐ 타입 마스터 달성: {type}! (배틀 XP 1.2배)", "rewards.chain_complete": "🔗 진화 체인 완성! (몬스터볼 x{count} 획득)", "rewards.type_master_legendary": "⭐ 타입 마스터 {count}개 달성! 특별 전설 포켓몬 해금!", - "cli.legendary.header": "=== 전설의 포켓몬 ===", "cli.legendary.no_pending": "해금 대기 중인 전설 그룹이 없습니다. 더 많이 포획하세요!", "cli.legendary.pool_header": "[ 전설 풀 (야생 출현) ]", @@ -503,7 +460,6 @@ "cli.legendary.invalid_choice": "잘못된 선택입니다.", "cli.legendary.selected": "✨ {pokemon}(이)가 팀에 합류했습니다!", "cli.legendary.pool_added": " {names}(이)가 야생 인카운터 풀에 추가되었습니다.", - "cli.box.header": "[ 박스 ]", "cli.box.empty": " 박스가 비어 있습니다.", "cli.box.sort_hint": "--sort level|type|name|rarity 로 정렬. --search 로 이름 검색 가능.", @@ -511,7 +467,6 @@ "cli.box.filter": " 필터: {filter}", "cli.box.no_results": " 필터에 맞는 포켓몬이 없습니다.", "cli.box.shiny_tag": "★", - "cli.party.swap_usage": "사용법: tokenmon party swap <슬롯> <포켓몬>", "cli.party.swap_invalid_slot": "잘못된 슬롯 번호입니다. (1-{max})", "cli.party.swap_not_in_box": "{pokemon}(이)가 박스에 없습니다.", @@ -522,69 +477,55 @@ "cli.party.reorder_success": "✓ {pokemon}: 슬롯 {from} → 슬롯 {to}", "cli.party.suggest_header": "[ {region} 추천 파티 ]", "cli.party.suggest_no_region": "지역이 설정되지 않았습니다.", - "cli.help.cmd_legendary": " legendary 전설 포켓몬 보기/선택", "cli.help.cmd_box": " box [--sort key] 박스 보기 (보관된 포켓몬)", "cli.help.cmd_party_swap": " party swap

파티 슬롯과 박스 포켓몬 교환", "cli.help.cmd_party_reorder": " party reorder 파티 순서 변경", "cli.help.cmd_party_suggest": " party suggest 현재 지역 추천 파티", - "notification.legendary_unlocked": "전설 그룹 해금! 실행: tokenmon legendary", - "cli.pokedex.type_master_header": "[ 타입 마스터 ]", "cli.pokedex.type_progress": " {type}: {caught}/{total} ({pct}%)", "cli.pokedex.type_mastered": " {type}: 마스터 ✓ (XP 1.2배)", - - "star.prompt": "⭐ Tok\u00e9mon 즐기고 계신가요? tokenmon star 로 GitHub 스타를 눌러주세요!", - "star.success": "⭐ Tok\u00e9mon에 스타를 눌러주셔서 감사합니다!", + "star.prompt": "⭐ Tokémon 즐기고 계신가요? tokenmon star 로 GitHub 스타를 눌러주세요!", + "star.success": "⭐ Tokémon에 스타를 눌러주셔서 감사합니다!", "star.failed": "스타 실패. gh CLI가 설치되어 있고 인증되었는지 확인하세요.", "star.dismissed": "스타 프롬프트를 비활성화했습니다.", "rest.activate": "💤 {hours}시간 휴식 보너스가 적용됩니다! 다음 {turns}턴 경험치 {mult}배", "tip.rest_info": "💤 가끔 쉬어가면 경험치 보너스를 받을 수 있습니다!", "tip.rest_threshold": "💤 2시간 이상 쉬면 휴식 보너스가 활성화됩니다.", - - "cli.lock_failed": "잠금 획득에 실패했습니다. 다시 시도해주세요.", "cli.call.not_found": "포켓몬을 찾을 수 없습니다: {name}", "cli.nickname.not_found": "포켓몬을 찾을 수 없습니다: {name}", "cli.nickname.too_long": "닉네임은 7글자 이하로 지어주세요.", "cli.nickname.current": "{species}의 닉네임: {nickname}", "cli.nickname.none": "{species}에게 아직 닉네임이 없습니다.", "cli.nickname.set": "{species}의 닉네임을 '{nickname}'(으)로 정했습니다!", - "rarity.common": "일반", "rarity.uncommon": "고급", "rarity.rare": "희귀", "rarity.legendary": "전설", "rarity.mythical": "환상", - "exp_group.medium_fast": "보통빠름", "exp_group.medium_slow": "보통느림", "exp_group.fast": "빠름", "exp_group.slow": "느림", "exp_group.erratic": "불규칙", "exp_group.fluctuating": "변동", - "weather.location_set": "날씨 위치가 {location}(으)로 설정되었습니다. 매 세션마다 업데이트됩니다.", "weather.fetch_failed": "날씨 데이터를 가져올 수 없습니다. 다음 세션에 재시도합니다.", - "gym.gate_rejected": "이 체육관에 도전하려면 이 지역의 도감을 더 채우거나 파티를 더 성장시켜야 합니다! (도감: {caught}/{required}마리, 파티 평균 레벨: Lv.{avgLevel}/{requiredLevel})", - "tip.gym_region_rumor": "각 지역에는 강력한 관장이 기다리고 있다는 소문이 있다.", "tip.gym_badge_reward": "관장에게 이기면 특별한 배지를 얻을 수 있다고 한다.", "tip.gym_gate_hint": "🏟️ 체육관에 도전하려면 해당 지역의 도감을 채우거나 파티를 성장시키세요!", "tip.gym_badge_collect": "🏟️ 배지를 모아 챔피언에게 도전하세요! `/tkm gym list`로 확인", "tip.gym_next_info": "🏟️ 다음 체육관: {leaderName} ({type}타입) — 상성을 준비하세요!", "tip.gym_badge_progress": "배지를 {badgeCount}개 모았다. 챔피언까지 {remaining}개 남았다고 한다.", - "gym.champion_badge_required": "모든 배지를 모아야 챔피언에게 도전할 수 있습니다! (현재 배지: {badgeCount}/8)", - "gym.badge_earned": "🥊 {badge} 획득! ({leader} 격파) [{count}/8]", "gym.champion_victory_header": "챔피언 승리!", "gym.champion_victory_detail": "{region} 챔피언 {leader} 격파!", "gym.title_earned": "칭호 획득: {title}", "gym.reward_pokemon": "보상: {pokemon} 획득!", "gym.reward_xp_dump": "{pokemon}에게 {xp} XP 부여! (Lv.{oldLevel} → Lv.{newLevel})", - "achievement.first_badge": "첫 번째 배지", "achievement.four_badges": "배지 수집가", "achievement.eight_badges": "체육관 마스터", @@ -593,7 +534,6 @@ "achievement.total_badges_30": "배지 마니아", "achievement.three_gen_champion": "멀티 챔피언", "achievement.all_gen_champion": "포켓몬 마스터", - "title.champion_kanto": "관동 챔피언", "title.champion_johto": "성도 챔피언", "title.champion_hoenn": "호연 챔피언", @@ -605,7 +545,6 @@ "title.champion_paldea": "팔데아 챔피언", "title.multi_champion": "멀티 챔피언", "title.pokemon_master": "포켓몬 마스터", - "status.paralysis.inflicted": "{name:은/는} 마비되었다!", "status.paralysis.immobile": "{name:은/는} 몸이 저려 움직일 수 없다!", "status.sleep.inflicted": "{name:은/는} 잠들었다!", @@ -653,5 +592,11 @@ "stat.name.sp_defense": "특수방어", "stat.name.speed": "스피드", "stat.name.accuracy": "명중률", - "stat.name.evasion": "회피율" + "stat.name.evasion": "회피율", + "battle.used_move": "{name}의 {move}!", + "battle.effect_super": "효과가 굉장했다!", + "battle.effect_not_very": "효과가 별로인 듯하다...", + "battle.effect_immune": "효과가 없는 듯하다...", + "battle.fainted": "{name}은(는) 쓰러졌다!", + "gym.status_line": "⚔️ {leader}의 체육관 — {type}" } diff --git a/src/status-line.ts b/src/status-line.ts index 32ed1f4e..2c0b0792 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -398,7 +398,7 @@ function renderBattleMode(battleData: { console.log(padTo(oppHp, colWidth) + gapStr + playerHp); // Gym info bottom line - const gymLine = `\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800⚔️ ${gym.leaderKo}의 체육관 — ${gym.type}`; + const gymLine = `⠀⠀⠀⠀⠀⠀⠀⠀${t('gym.status_line', { leader: gym.leader, type: gym.type })}`; console.log(charWrap(gymLine, printWidth)); } @@ -624,8 +624,9 @@ function main(): void { // PP = remaining context tokens expressed as move PP for ace pokemon const baseId = parseInt(toBaseId(p.speciesId), 10); const sigMove = SIGNATURE_MOVES[baseId]; + const moveName = lang === 'ko' ? sigMove?.move_ko : sigMove?.move_en; const ppFull = (isAce && sigMove && sigMove.pp > 0) - ? ` ${sigMove.move_ko} PP:${calcPp(sigMove.pp, contextTokensUsed)}/${sigMove.pp}` + ? ` ${moveName} PP:${calcPp(sigMove.pp, contextTokensUsed)}/${sigMove.pp}` : ''; const ppShort = (isAce && sigMove && sigMove.pp > 0) ? ` PP:${calcPp(sigMove.pp, contextTokensUsed)}/${sigMove.pp}` From a3eacf6c5f41b948e4c57db82fcd6777ca727bdd Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:27:08 +0200 Subject: [PATCH 02/11] fix: i18n Struggle, surrender, confusion faint, gym-cleared message - Struggle announcement and confusion-faint now go through t() using new battle.struggle and battle.fainted keys - Surrender message uses new battle.surrender key - Gym already-cleared rejection uses new gym.already_cleared key (passes region param for Korean interpolation) - Added all new keys to both en.json and ko.json --- src/cli/battle-turn.ts | 2 +- src/core/turn-battle.ts | 10 +++++----- src/core/volatile-status.ts | 2 +- src/i18n/en.json | 5 ++++- src/i18n/ko.json | 5 ++++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/cli/battle-turn.ts b/src/cli/battle-turn.ts index 92920ec9..40b205c8 100644 --- a/src/cli/battle-turn.ts +++ b/src/cli/battle-turn.ts @@ -250,7 +250,7 @@ function handleInit(): void { output({ status: 'rejected', messages: [ - `이 지역(${currentRegion})의 체육관은 이미 클리어했어. 다른 지역으로 이동해야 새 체육관에 도전할 수 있어.`, + t('gym.already_cleared', { region: currentRegion }), ], }); process.exit(0); diff --git a/src/core/turn-battle.ts b/src/core/turn-battle.ts index d7ea8acd..e55ff2bc 100644 --- a/src/core/turn-battle.ts +++ b/src/core/turn-battle.ts @@ -315,19 +315,19 @@ function executeMove( if (!hasUsableMoves) { move = STRUGGLE_MOVE; isStruggle = true; - messages.push(`${attacker.displayName}은(는) 발버둥쳤다!`); + messages.push(t('battle.struggle', { name: attacker.displayName })); } else if (moveIndex < 0 || moveIndex >= attacker.moves.length) { // Invalid moveIndex → treat as struggle move = STRUGGLE_MOVE; isStruggle = true; - messages.push(`${attacker.displayName}은(는) 발버둥쳤다!`); + messages.push(t('battle.struggle', { name: attacker.displayName })); } else { const chosen = attacker.moves[moveIndex]; if (chosen.currentPp <= 0) { // Requested move has 0 PP → struggle move = STRUGGLE_MOVE; isStruggle = true; - messages.push(`${attacker.displayName}은(는) 발버둥쳤다!`); + messages.push(t('battle.struggle', { name: attacker.displayName })); } else { move = chosen; // Defer PP decrement + move announcement until after paralysis check so @@ -526,13 +526,13 @@ export function resolveTurn( // Handle surrender if (playerAction.type === 'surrender') { - messages.push('항복했다...'); + messages.push(t('battle.surrender')); state.phase = 'battle_end'; state.winner = 'opponent'; return { messages, playerFainted: false, opponentFainted: false }; } if (opponentAction.type === 'surrender') { - messages.push('항복했다...'); + messages.push(t('battle.surrender')); state.phase = 'battle_end'; state.winner = 'player'; return { messages, playerFainted: false, opponentFainted: false }; diff --git a/src/core/volatile-status.ts b/src/core/volatile-status.ts index 3d5fd80c..c59bb559 100644 --- a/src/core/volatile-status.ts +++ b/src/core/volatile-status.ts @@ -81,7 +81,7 @@ export function applyConfusionSelfDamage(pokemon: BattlePokemon, messages: strin messages.push(t('volatile.confusion.self_hit', { name: pokemon.displayName })); if (pokemon.currentHp <= 0) { pokemon.fainted = true; - messages.push(`${pokemon.displayName}은(는) 쓰러졌다!`); + messages.push(t('battle.fainted', { name: pokemon.displayName })); } } diff --git a/src/i18n/en.json b/src/i18n/en.json index d5919ceb..863256db 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -598,5 +598,8 @@ "battle.effect_not_very": "It's not very effective...", "battle.effect_immune": "It has no effect...", "battle.fainted": "{name} fainted!", - "gym.status_line": "⚔️ {leader}'s Gym — {type}" + "gym.status_line": "⚔️ {leader}'s Gym — {type}", + "battle.struggle": "{name} used Struggle!", + "battle.surrender": "Gave up...", + "gym.already_cleared": "This region's gym is already cleared. Move to another region to find a new gym." } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 58f9a95b..71f0b019 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -598,5 +598,8 @@ "battle.effect_not_very": "효과가 별로인 듯하다...", "battle.effect_immune": "효과가 없는 듯하다...", "battle.fainted": "{name}은(는) 쓰러졌다!", - "gym.status_line": "⚔️ {leader}의 체육관 — {type}" + "gym.status_line": "⚔️ {leader}의 체육관 — {type}", + "battle.struggle": "{name}은(는) 발버둥쳤다!", + "battle.surrender": "항복했다...", + "gym.already_cleared": "이 지역({region})의 체육관은 이미 클리어했어. 다른 지역으로 이동해야 새 체육관에 도전할 수 있어." } From 4176e70ff33a857a17fdfa735b3da6a669989560 Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:53:49 +0200 Subject: [PATCH 03/11] i18n: add en/ko keys for battle-tui, gym-list, moves and friendly-battle Covers all strings needed for the subsequent locale fixes across: battle-tui, gym-list CLI, moves CLI, friendly-battle adapter/daemon, tcp-direct transport errors, and friendly-battle-spike CLI. --- src/i18n/en.json | 37 ++++++++++++++++++++++++++++++++++++- src/i18n/ko.json | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 863256db..db4d4b01 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -601,5 +601,40 @@ "gym.status_line": "⚔️ {leader}'s Gym — {type}", "battle.struggle": "{name} used Struggle!", "battle.surrender": "Gave up...", - "gym.already_cleared": "This region's gym is already cleared. Move to another region to find a new gym." + "gym.already_cleared": "This region's gym is already cleared. Move to another region to find a new gym.", + "battle.opp_sent_out": "{name} was sent out!", + "battle.battle_start": "Battle start!", + "battle.tui.gym_name": "{leader}'s Gym", + "battle.tui.type_specialist": "{type} type", + "battle.standalone": "Battle", + "battle.tui.switch_btn": "Switch", + "battle.tui.surrender_btn": "Surrender", + "battle.tui.badge_obtained": "{badge} obtained!", + "battle.tui.victory": "Victory!", + "battle.tui.defeat": "Defeat...", + "battle.tui.defeat_to": "Lost to {leader}...", + "common.yes": "Yes", + "common.no": "No", + "cli.gym_list.title": "{gen} Gym", + "cli.gym_list.empty": "No gym data.", + "cli.gym_list.badge_count": "Badges: {count}/{total}", + "cli.gym_list.error": "Could not load gym data for {gen}.", + "cli.moves.physical": "Physical", + "cli.moves.special": "Special", + "cli.moves.no_moves": "(No moves)", + "cli.moves.not_owned": "{name} is not a pokemon you own.", + "cli.moves.learnable_title": "{name} Lv.{level} — Learnable moves", + "cli.moves.no_learnable": "(No learnable moves)", + "cli.moves.legend": "● Equipped ○ Learnable · Level insufficient", + "cli.moves.not_learnable": "Move ID {id} cannot be learned by this Pokemon.", + "cli.moves.level_required": "Level too low. (Required: Lv.{required}, Current: Lv.{current})", + "cli.moves.invalid_slot": "Slot number must be between 1-4.", + "cli.moves.lock_failed": "Failed to acquire state lock. Please try again.", + "cli.moves.swap_success": "Changed {name}'s slot {slot} move.", + "cli.moves.no_pokemon": "Your party has no Pokemon.", + "cli.moves.swap_usage": "Usage: moves swap ", + "battle.opp_label": "Opp {name} Lv.{level} HP:{hp}/{maxHp}", + "battle.self_label": "My {name} Lv.{level} HP:{hp}/{maxHp}", + "battle.self_hp_next_turn": "(Opponent HP available after next turn result)", + "fb.transport.timeout": "{label} timed out." } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 71f0b019..6e313e66 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -601,5 +601,40 @@ "gym.status_line": "⚔️ {leader}의 체육관 — {type}", "battle.struggle": "{name}은(는) 발버둥쳤다!", "battle.surrender": "항복했다...", - "gym.already_cleared": "이 지역({region})의 체육관은 이미 클리어했어. 다른 지역으로 이동해야 새 체육관에 도전할 수 있어." + "gym.already_cleared": "이 지역({region})의 체육관은 이미 클리어했어. 다른 지역으로 이동해야 새 체육관에 도전할 수 있어.", + "battle.opp_sent_out": "상대가 {name}(을)를 내보냈다!", + "battle.battle_start": "배틀 시작!", + "battle.tui.gym_name": "{leader}의 체육관", + "battle.tui.type_specialist": "{type} 타입 전문", + "battle.standalone": "배틀", + "battle.tui.switch_btn": "교체", + "battle.tui.surrender_btn": "항복", + "battle.tui.badge_obtained": "{badge}을(를) 획득했다!", + "battle.tui.victory": "승리!", + "battle.tui.defeat": "패배...", + "battle.tui.defeat_to": "{leader}에게 졌다...", + "common.yes": "예", + "common.no": "아니오", + "cli.gym_list.title": "{gen} 체육관", + "cli.gym_list.empty": "체육관 데이터가 없습니다.", + "cli.gym_list.badge_count": "배지: {count}/{total}", + "cli.gym_list.error": "{gen} 체육관 데이터를 찾을 수 없습니다.", + "cli.moves.physical": "물리", + "cli.moves.special": "특수", + "cli.moves.no_moves": "(기술 없음)", + "cli.moves.not_owned": "{name}은(는) 보유하지 않은 포켓몬입니다.", + "cli.moves.learnable_title": "{name} Lv.{level} — 습득 가능 기술", + "cli.moves.no_learnable": "(습득 가능한 기술이 없습니다)", + "cli.moves.legend": "● 장착중 ○ 습득 가능 · 레벨 부족", + "cli.moves.not_learnable": "기술 ID {id}은(는) 이 포켓몬이 배울 수 없는 기술입니다.", + "cli.moves.level_required": "레벨이 부족합니다. (필요: Lv.{required}, 현재: Lv.{current})", + "cli.moves.invalid_slot": "슬롯 번호는 1-4 사이여야 합니다.", + "cli.moves.lock_failed": "상태 잠금을 획득하지 못했습니다. 다시 시도해 주세요.", + "cli.moves.swap_success": "{name}의 슬롯 {slot} 기술을 변경했습니다.", + "cli.moves.no_pokemon": "파티에 포켓몬이 없습니다.", + "cli.moves.swap_usage": "사용법: moves <포켓몬> swap <슬롯(1-4)> <기술ID>", + "battle.opp_label": "상대 {name} Lv.{level} HP:{hp}/{maxHp}", + "battle.self_label": "내 {name} Lv.{level} HP:{hp}/{maxHp}", + "battle.self_hp_next_turn": "(상대 HP는 다음 턴 결과에서 확인)", + "fb.transport.timeout": "{label} 대기 중 시간이 초과되었습니다." } From fd7197f55fe04fde2673b9c582901aa4b103a99b Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:53:55 +0200 Subject: [PATCH 04/11] i18n: fix hardcoded Korean strings in battle-tui game-loop.ts: opp sent-out, player go, battle-start messages renderer.ts: header, switch/surrender buttons, move names (nameEn), yes/no confirm, victory/defeat banners, badge names index.ts: champion victory detail, badge-earned message All now use t() and respect the active locale. --- src/battle-tui/game-loop.ts | 9 +++++---- src/battle-tui/index.ts | 12 +++++++++--- src/battle-tui/renderer.ts | 24 +++++++++++++----------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/battle-tui/game-loop.ts b/src/battle-tui/game-loop.ts index da34e4e1..f5cf28ef 100644 --- a/src/battle-tui/game-loop.ts +++ b/src/battle-tui/game-loop.ts @@ -2,6 +2,7 @@ import { renderBattleScreen, renderSurrenderConfirm, renderBattleEnd } from './r import { createBattleState, resolveTurn, getActivePokemon, hasAlivePokemon } from '../core/turn-battle.js'; import { selectAiAction } from '../core/gym-ai.js'; import { startInput, stopInput } from './input.js'; +import { t } from '../i18n/index.js'; import type { BattleState, BattlePokemon, TurnAction, GymData } from '../core/types.js'; // ── Types ── @@ -44,7 +45,7 @@ function autoSwitchAi(game: GameLoop): void { for (let i = 0; i < oppTeam.pokemon.length; i++) { if (!oppTeam.pokemon[i].fainted) { oppTeam.activeIndex = i; - game.recentMessages.push(`상대가 ${oppTeam.pokemon[i].displayName}(을)를 내보냈다!`); + game.recentMessages.push(t('battle.opp_sent_out', { name: oppTeam.pokemon[i].displayName })); return; } } @@ -148,7 +149,7 @@ function handleSwitchKey(game: GameLoop, key: string): void { // Forced switch — no AI turn, just swap team.activeIndex = targetIndex; const newActive = getActivePokemon(team); - game.recentMessages = [`${newActive.displayName}(을)를 내보냈다!`]; + game.recentMessages = [t('battle.go', { pokemon: newActive.displayName })]; game.battleState.phase = 'select_action'; game.phase = 'action_select'; } else { @@ -192,8 +193,8 @@ export function startGameLoop( gym, phase: 'action_select', recentMessages: gym - ? [`${gym.leaderKo}이(가) 승부를 걸어왔다!`] - : ['배틀 시작!'], + ? [t('battle.gym_challenge', { leader: gym.leader })] + : [t('battle.battle_start')], onComplete, }; diff --git a/src/battle-tui/index.ts b/src/battle-tui/index.ts index 8c65a7e3..33cc386b 100644 --- a/src/battle-tui/index.ts +++ b/src/battle-tui/index.ts @@ -7,7 +7,7 @@ import { createBattlePokemon } from '../core/turn-battle.js'; import { getGymById, awardGymVictory, canChallengeGym, loadGymData } from '../core/gym.js'; import { getPokemonName, getPokemonDB, speciesIdToGeneration } from '../core/pokemon-data.js'; import { getActiveGeneration } from '../core/paths.js'; -import { initLocale, t } from '../i18n/index.js'; +import { initLocale, t, getLocale } from '../i18n/index.js'; import { readGlobalConfig, readConfig, writeConfig } from '../core/config.js'; import { checkAchievements, checkCommonAchievements, formatAchievementMessage } from '../core/achievements.js'; import { readCommonState, readState, writeCommonState, writeState } from '../core/state.js'; @@ -225,13 +225,19 @@ function main(): void { if (isChampion) { process.stderr.write('\n═══════════════════════════════\n'); process.stderr.write(` 🏆 ${t('gym.champion_victory_header')} 🏆\n`); - process.stderr.write(` ${t('gym.champion_victory_detail', { region: gym.badgeKo.replace(/ 챔피언배지$/, ''), leader: gym.leaderKo })}\n`); + const champRegion = getLocale() === 'ko' + ? gym.badgeKo.replace(/ 챔피언배지$/, '') + : gym.badge.replace(/^champion_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()); + const champLeader = getLocale() === 'ko' ? gym.leaderKo : gym.leader; + process.stderr.write(` ${t('gym.champion_victory_detail', { region: champRegion, leader: champLeader })}\n`); for (const achEvent of achEvents) { process.stderr.write(` ${formatAchievementMessage(achEvent)}\n`); } process.stderr.write('═══════════════════════════════\n'); } else { - process.stderr.write(`\n${t('gym.badge_earned', { badge: gym.badgeKo, leader: gym.leaderKo, count: badgeCount })}\n`); + const badgeName = getLocale() === 'ko' ? gym.badgeKo : `${gym.badge.charAt(0).toUpperCase() + gym.badge.slice(1)} Badge`; + const leaderName = getLocale() === 'ko' ? gym.leaderKo : gym.leader; + process.stderr.write(`\n${t('gym.badge_earned', { badge: badgeName, leader: leaderName, count: badgeCount })}\n`); for (const achEvent of achEvents) { process.stderr.write(`${formatAchievementMessage(achEvent)}\n`); } diff --git a/src/battle-tui/renderer.ts b/src/battle-tui/renderer.ts index b928b781..833aa20e 100644 --- a/src/battle-tui/renderer.ts +++ b/src/battle-tui/renderer.ts @@ -6,7 +6,7 @@ import { fg256, renderHpBar, hLine, center, typeColor, } from './ansi.js'; import { getActivePokemon } from '../core/turn-battle.js'; -import { t } from '../i18n/index.js'; +import { t, getLocale } from '../i18n/index.js'; import type { BattleState, BattlePokemon, GymData } from '../core/types.js'; const WIDTH = 50; @@ -74,8 +74,8 @@ export function renderBattleScreen( // Header lines.push(doubleLine()); const headerText = gym - ? `${BOLD}${gym.leaderKo}의 체육관${RESET} — ${fg256(typeColor(gym.type))}${gym.type}${RESET} 타입 전문` - : `${BOLD}배틀${RESET}`; + ? `${BOLD}${t('battle.tui.gym_name', { leader: gym.leader })}${RESET} — ${fg256(typeColor(gym.type))}${t('battle.tui.type_specialist', { type: gym.type })}${RESET}` + : `${BOLD}${t('battle.standalone')}${RESET}`; lines.push(center(headerText, WIDTH)); lines.push(doubleLine()); lines.push(''); @@ -130,19 +130,20 @@ function renderMoveMenu(player: BattlePokemon): string { } // Bottom row: switch & surrender - rows.push(center(`${BOLD}5${RESET}.교체 ${BOLD}6${RESET}.항복`, WIDTH)); + rows.push(center(`${BOLD}5${RESET}.${t('battle.tui.switch_btn')} ${BOLD}6${RESET}.${t('battle.tui.surrender_btn')}`, WIDTH)); return rows.join('\n'); } function formatMoveEntry( index: number, - move: { data: { nameKo: string; type: string; pp: number }; currentPp: number }, + move: { data: { nameKo: string; nameEn?: string; type: string; pp: number }; currentPp: number }, ): string { const num = index + 1; const color = fg256(typeColor(move.data.type)); const ppStr = `${move.currentPp}/${move.data.pp}`; - return `${BOLD}${num}${RESET}.${color}${move.data.nameKo}${RESET} ${DIM}${ppStr}${RESET}`; + const name = getLocale() === 'ko' ? move.data.nameKo : (move.data.nameEn ?? move.data.nameKo); + return `${BOLD}${num}${RESET}.${color}${name}${RESET} ${DIM}${ppStr}${RESET}`; } function renderSwitchMenu(state: BattleState): string { @@ -167,7 +168,7 @@ export function renderSurrenderConfirm(): string { const lines: string[] = []; lines.push(''); lines.push(` ${BOLD}${t('battle.surrender_confirm')}${RESET}`); - lines.push(` ${BOLD}1${RESET}. 예 ${BOLD}2${RESET}. 아니오`); + lines.push(` ${BOLD}1${RESET}. ${t('common.yes')} ${BOLD}2${RESET}. ${t('common.no')}`); lines.push(''); return lines.join('\n'); } @@ -185,16 +186,17 @@ export function renderBattleEnd( lines.push(doubleLine()); if (state.winner === 'player') { - lines.push(center(`${BOLD}승리!${RESET}`, WIDTH)); + lines.push(center(`${BOLD}${t('battle.tui.victory')}${RESET}`, WIDTH)); if (gym) { + const badgeName = getLocale() === 'ko' ? gym.badgeKo : `${gym.badge.charAt(0).toUpperCase() + gym.badge.slice(1)} Badge`; lines.push(''); - lines.push(center(`${fg256(typeColor(gym.type))}${gym.badgeKo}${RESET}을(를) 획득했다!`, WIDTH)); + lines.push(center(`${fg256(typeColor(gym.type))}${t('battle.tui.badge_obtained', { badge: badgeName })}${RESET}`, WIDTH)); } } else { - lines.push(center(`${BOLD}패배...${RESET}`, WIDTH)); + lines.push(center(`${BOLD}${t('battle.tui.defeat')}${RESET}`, WIDTH)); if (gym) { lines.push(''); - lines.push(center(`${gym.leaderKo}에게 졌다...`, WIDTH)); + lines.push(center(t('battle.tui.defeat_to', { leader: gym.leader }), WIDTH)); } } From 8594e21e09eb4e714f1a94d011dd54084b7914ec Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:54:01 +0200 Subject: [PATCH 05/11] i18n: fix hardcoded Korean strings in gym-list and moves CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gym-list.ts: title, empty, badge-count, error, leader/badge display moves.ts: category labels, error messages, move names, legend legend, swap success/usage — all now locale-aware via t() / getLocale() --- src/cli/gym-list.ts | 16 ++++++++++------ src/cli/moves.ts | 46 ++++++++++++++++++++++++++------------------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/cli/gym-list.ts b/src/cli/gym-list.ts index e01e6922..31aa88b5 100644 --- a/src/cli/gym-list.ts +++ b/src/cli/gym-list.ts @@ -6,6 +6,10 @@ import { loadGymData } from '../core/gym.js'; import { readState } from '../core/state.js'; import { getActiveGeneration } from '../core/paths.js'; import { getSharedDB } from '../core/pokemon-data.js'; +import { t, initLocale, getLocale } from '../i18n/index.js'; +import { readGlobalConfig } from '../core/config.js'; + +initLocale(readGlobalConfig().language); // ANSI helpers const BOLD = '\x1b[1m'; @@ -49,19 +53,19 @@ try { const genLabel = gen.toUpperCase().replace('GEN', 'GEN'); console.log(); - console.log(` ${BOLD}🏟️ ${genLabel} 체육관${RESET}`); + console.log(` ${BOLD}🏟️ ${t('cli.gym_list.title', { gen: genLabel })}${RESET}`); console.log(); if (gyms.length === 0) { - console.log(` ${GRAY}체육관 데이터가 없습니다.${RESET}`); + console.log(` ${GRAY}${t('cli.gym_list.empty')}${RESET}`); } for (const gym of gyms) { const cleared = badges.includes(gym.badge); const icon = cleared ? `${GREEN}✅` : `${GRAY}⬜`; const tc = typeColor(gym.type); - const leaderDisplay = gym.leaderKo || gym.leader; - const badgeDisplay = gym.badgeKo || gym.badge; + const leaderDisplay = getLocale() === 'ko' ? (gym.leaderKo || gym.leader) : gym.leader; + const badgeDisplay = getLocale() === 'ko' ? (gym.badgeKo || gym.badge) : (gym.badge ? `${gym.badge.charAt(0).toUpperCase() + gym.badge.slice(1)} Badge` : gym.badge); const maxLevel = gym.team.length > 0 ? Math.max(...gym.team.map(p => p.level)) : 0; const levelDisplay = maxLevel > 0 ? `${GRAY}Lv.${maxLevel}${RESET}` : ''; @@ -72,9 +76,9 @@ try { const clearedCount = gyms.filter(g => badges.includes(g.badge)).length; console.log(); - console.log(` ${CYAN}배지: ${clearedCount}/${gyms.length}${RESET}`); + console.log(` ${CYAN}${t('cli.gym_list.badge_count', { count: clearedCount, total: gyms.length })}${RESET}`); console.log(); } catch (err: any) { - console.error(` ⚠️ ${gen} 체육관 데이터를 찾을 수 없습니다.`); + console.error(` ⚠️ ${t('cli.gym_list.error', { gen })}`) process.exit(1); } diff --git a/src/cli/moves.ts b/src/cli/moves.ts index bbc81eff..7fcdf23e 100644 --- a/src/cli/moves.ts +++ b/src/cli/moves.ts @@ -10,10 +10,13 @@ */ import { readState, writeState } from '../core/state.js'; import { withLockRetry } from '../core/lock.js'; -import { readConfig } from '../core/config.js'; +import { readConfig, readGlobalConfig } from '../core/config.js'; import { getMoveData, getPokemonMovePool, assignDefaultMoves } from '../core/moves.js'; import { getPokemonName, getPokemonDB, resolveNameToId, getDisplayName } from '../core/pokemon-data.js'; import { getActiveGeneration } from '../core/paths.js'; +import { t, initLocale, getLocale } from '../i18n/index.js'; + +initLocale(readGlobalConfig().language); // ANSI helpers const BOLD = '\x1b[1m'; @@ -49,7 +52,7 @@ function typeColor(type: string): string { } function categoryLabel(cat: string): string { - return cat === 'physical' ? '물리' : '특수'; + return cat === 'physical' ? t('cli.moves.physical') : t('cli.moves.special'); } const gen = getActiveGeneration(); @@ -62,7 +65,7 @@ const args = process.argv.slice(2); function showPokemonMoves(pokemonId: string): void { const pState = state.pokemon[pokemonId]; if (!pState) { - console.error(` ${RED}${getPokemonName(pokemonId, gen)}은(는) 보유하지 않은 포켓몬입니다.${RESET}`); + console.error(` ${RED}${t('cli.moves.not_owned', { name: getPokemonName(pokemonId, gen) })}${RESET}`); return; } @@ -73,7 +76,7 @@ function showPokemonMoves(pokemonId: string): void { console.log(` ${BOLD}${displayName}${RESET} ${GRAY}Lv.${pState.level}${RESET}`); if (moves.length === 0) { - console.log(` ${GRAY}(기술 없음)${RESET}`); + console.log(` ${GRAY}${t('cli.moves.no_moves')}${RESET}`); return; } @@ -84,7 +87,7 @@ function showPokemonMoves(pokemonId: string): void { continue; } const tc = typeColor(moveData.type); - const nameDisplay = moveData.nameKo || moveData.name; + const nameDisplay = getLocale() === 'ko' ? (moveData.nameKo || moveData.name) : (moveData.nameEn || moveData.nameKo || moveData.name); const cat = categoryLabel(moveData.category); console.log( ` ${i + 1}. ${BOLD}${nameDisplay}${RESET} ${GRAY}(${tc}${moveData.type}${GRAY}/${cat})${RESET} 위력:${moveData.power} PP:${moveData.pp}`, @@ -96,7 +99,7 @@ function showPokemonMoves(pokemonId: string): void { function showLearnableMoves(pokemonId: string): void { const pState = state.pokemon[pokemonId]; if (!pState) { - console.error(` ${RED}${getPokemonName(pokemonId, gen)}은(는) 보유하지 않은 포켓몬입니다.${RESET}`); + console.error(` ${RED}${t('cli.moves.not_owned', { name: getPokemonName(pokemonId, gen) })}${RESET}`); return; } @@ -105,11 +108,12 @@ function showLearnableMoves(pokemonId: string): void { const currentMoves = new Set(pState.moves ?? []); console.log(); - console.log(` ${BOLD}${displayName}${RESET} ${GRAY}Lv.${pState.level}${RESET} — 습득 가능 기술`); + const learnableLabel = getLocale() === 'ko' ? '습득 가능 기술' : 'Learnable moves'; + console.log(` ${BOLD}${displayName}${RESET} ${GRAY}Lv.${pState.level}${RESET} — ${learnableLabel}`); console.log(); if (pool.length === 0) { - console.log(` ${GRAY}(습득 가능한 기술이 없습니다)${RESET}`); + console.log(` ${GRAY}${t('cli.moves.no_learnable')}${RESET}`); return; } @@ -120,7 +124,7 @@ function showLearnableMoves(pokemonId: string): void { const learned = currentMoves.has(entry.moveId); const canLearn = entry.learnLevel <= pState.level; const tc = typeColor(moveData.type); - const nameDisplay = moveData.nameKo || moveData.name; + const nameDisplay = getLocale() === 'ko' ? (moveData.nameKo || moveData.name) : (moveData.nameEn || moveData.nameKo || moveData.name); const cat = categoryLabel(moveData.category); const icon = learned ? `${GREEN}●` : canLearn ? `${CYAN}○` : `${GRAY}·`; const levelTag = canLearn ? '' : ` ${GRAY}(Lv.${entry.learnLevel})${RESET}`; @@ -131,7 +135,11 @@ function showLearnableMoves(pokemonId: string): void { } console.log(); - console.log(` ${GREEN}●${RESET} 장착중 ${CYAN}○${RESET} 습득 가능 ${GRAY}·${RESET} 레벨 부족`); + if (getLocale() === 'ko') { + console.log(` ${GREEN}●${RESET} 장착중 ${CYAN}○${RESET} 습득 가능 ${GRAY}·${RESET} 레벨 부족`); + } else { + console.log(` ${GREEN}●${RESET} Equipped ${CYAN}○${RESET} Learnable ${GRAY}·${RESET} Level insufficient`); + } console.log(); } @@ -147,19 +155,19 @@ function swapMove(pokemonId: string, slot: number, moveId: number): void { const pool = getPokemonMovePool(pState.id); const poolEntry = pool.find(e => e.moveId === moveId); if (!poolEntry) { - console.error(` ${RED}기술 ID ${moveId}은(는) 이 포켓몬이 배울 수 없는 기술입니다.${RESET}`); + console.error(` ${RED}${t('cli.moves.not_learnable', { id: moveId })}${RESET}`); process.exit(1); } if (poolEntry.learnLevel > pState.level) { - console.error(` ${RED}레벨이 부족합니다. (필요: Lv.${poolEntry.learnLevel}, 현재: Lv.${pState.level})${RESET}`); + console.error(` ${RED}${t('cli.moves.level_required', { required: poolEntry.learnLevel, current: pState.level })}${RESET}`); process.exit(1); } // Validate slot const moves = pState.moves ?? assignDefaultMoves(pState.id, pState.level); if (slot < 1 || slot > 4) { - console.error(` ${RED}슬롯 번호는 1-4 사이여야 합니다.${RESET}`); + console.error(` ${RED}${t('cli.moves.invalid_slot')}${RESET}`); process.exit(1); } @@ -188,15 +196,15 @@ function swapMove(pokemonId: string, slot: number, moveId: number): void { }); if (!lockResult.acquired) { - console.error(` ${RED}상태 잠금을 획득하지 못했습니다. 다시 시도해 주세요.${RESET}`); + console.error(` ${RED}${t('cli.moves.lock_failed')}${RESET}`); process.exit(1); } - const oldName = oldMove ? (oldMove.nameKo || oldMove.name) : '(없음)'; - const newName = newMove ? (newMove.nameKo || newMove.name) : `ID:${moveId}`; + const oldName = oldMove ? (getLocale() === 'ko' ? (oldMove.nameKo || oldMove.name) : (oldMove.nameEn || oldMove.nameKo || oldMove.name)) : (getLocale() === 'ko' ? '(없음)' : '(none)'); + const newName = newMove ? (getLocale() === 'ko' ? (newMove.nameKo || newMove.name) : (newMove.nameEn || newMove.nameKo || newMove.name)) : `ID:${moveId}`; console.log(); - console.log(` ${GREEN}${displayName}의 슬롯 ${slot} 기술을 변경했습니다.${RESET}`); + console.log(` ${GREEN}${t('cli.moves.swap_success', { name: displayName, slot })}${RESET}`); console.log(` ${GRAY}${oldName}${RESET} → ${BOLD}${newName}${RESET}`); console.log(); } @@ -206,7 +214,7 @@ function swapMove(pokemonId: string, slot: number, moveId: number): void { if (args.length === 0) { // Show moves for all party pokemon if (config.party.length === 0) { - console.log(` ${GRAY}파티에 포켓몬이 없습니다.${RESET}`); + console.log(` ${GRAY}${t('cli.moves.no_pokemon')}${RESET}`); process.exit(0); } for (const pokemonId of config.party) { @@ -225,7 +233,7 @@ if (args.length === 0) { const slot = parseInt(args[2], 10); const moveId = parseInt(args[3], 10); if (isNaN(slot) || isNaN(moveId)) { - console.error(` ${RED}사용법: moves <포켓몬> swap <슬롯(1-4)> <기술ID>${RESET}`); + console.error(` ${RED}${t('cli.moves.swap_usage')}${RESET}`); process.exit(1); } swapMove(pokemonId, slot, moveId); From f39cdbfa6cc90dd79fd95f43869a606792df1a09 Mon Sep 17 00:00:00 2001 From: Staninna <26458805+Staninna@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:54:06 +0200 Subject: [PATCH 06/11] i18n: fix hardcoded Korean strings in friendly-battle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit battle-adapter.ts: surrender message via t() daemon.ts: opp/self battle context labels via t() tcp-direct.ts: timeout error via t(); remaining Korean error strings → English friendly-battle-spike.ts: nextAction and stage-timeout messages → English --- src/cli/friendly-battle-spike.ts | 26 +++++++------- src/friendly-battle/battle-adapter.ts | 3 +- src/friendly-battle/daemon.ts | 12 +++---- src/friendly-battle/spike/tcp-direct.ts | 45 +++++++++++++------------ 4 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/cli/friendly-battle-spike.ts b/src/cli/friendly-battle-spike.ts index 08590fdf..baea5796 100644 --- a/src/cli/friendly-battle-spike.ts +++ b/src/cli/friendly-battle-spike.ts @@ -163,16 +163,16 @@ async function runHost(values: Map): Promise { : 'host'; const nextAction = stage === 'listen' && error.code === 'advertise_host_required' - ? 'guest가 접속할 실제 join host를 --join-host 로 지정한 뒤 다시 host 하세요.' + ? 'Specify --join-host with the actual host guests can connect to, then retry.' : stage === 'listen' - ? '입력한 host/port를 확인하거나 이미 같은 포트를 쓰는 프로세스를 종료한 뒤 다시 host 하세요.' + ? 'Check host/port or stop any process using the same port, then retry host.' : stage === 'ready' - ? 'guest가 join 후 ready 단계까지 완료했는지 확인한 뒤 다시 host 하세요.' + ? 'Check that guest completed the ready phase after joining, then retry host.' : stage === 'join' - ? 'guest가 올바른 host/port/session code로 join 했는지 확인하세요.' + ? 'Check that guest joined with the correct host/port/session code.' : stage === 'battle' - ? 'battle 시작 후 상대 행동이 도착하는지 확인하고, 필요하면 다시 host 하세요.' - : '입력한 host/port/session code와 guest 진행 상태를 확인한 뒤 다시 host 하세요.'; + ? 'Check that opponent action arrives after battle start and retry host if needed.' + : 'Check host/port/session code and guest progress, then retry host.'; printFriendlyBattleFailure({ stage, @@ -226,7 +226,7 @@ async function runHost(values: Map): Promise { const joined = await withStageTimeout( host.waitForGuestJoin(timeoutMs), 'join_timeout', - 'guest join 대기 중 시간이 초과되었습니다.', + 'Timed out waiting for guest to join.', ); console.log(`STAGE: guest_joined (${joined.guestPlayerName})`); @@ -235,7 +235,7 @@ async function runHost(values: Map): Promise { await withStageTimeout( host.waitUntilCanStart(timeoutMs), 'ready_timeout', - 'guest ready 대기 중 시간이 초과되었습니다.', + 'Timed out waiting for guest ready.', ); const battleId = `spike-${sessionCode}`; await host.startBattle(battleId); @@ -263,7 +263,7 @@ async function runHost(values: Map): Promise { const guestChoice = await withStageTimeout( host.waitForGuestChoice(timeoutMs), 'guest_choice_timeout', - 'guest choice 대기 중 시간이 초과되었습니다.', + 'Timed out waiting for guest choice.', ); console.log(`GUEST_CHOICE: ${formatFriendlyBattleChoice(guestChoice.choice)}`); @@ -336,12 +336,12 @@ async function runJoin(values: Map): Promise { : currentStage; const nextAction = stage === 'handshake' - ? 'host가 보여준 session code를 다시 확인한 뒤 다시 join 하세요.' + ? 'Double-check the session code shown by the host and retry join.' : stage === 'connect' || stage === 'join' - ? 'host 프로세스와 입력한 host/port/session code를 다시 확인하세요.' + ? 'Check the host process and verify host/port/session code.' : stage === 'ready' - ? 'host가 battle 시작 전까지 유지되고 있는지 확인한 뒤 다시 join 하세요.' - : 'host가 battle 시작 단계까지 진행됐는지 확인한 뒤 다시 join 하세요.'; + ? 'Check that host is still running before battle starts, then retry join.' + : 'Check that host has progressed to the battle start stage, then retry join.'; printFriendlyBattleFailure({ stage, diff --git a/src/friendly-battle/battle-adapter.ts b/src/friendly-battle/battle-adapter.ts index d308752a..386167b3 100644 --- a/src/friendly-battle/battle-adapter.ts +++ b/src/friendly-battle/battle-adapter.ts @@ -4,6 +4,7 @@ import { hasAlivePokemon, resolveTurn, } from '../core/turn-battle.js'; +import { t } from '../i18n/index.js'; import type { BattlePokemon, BattleState, BattleTeam, TurnAction } from '../core/types.js'; import type { FriendlyBattleBattleEvent, @@ -154,7 +155,7 @@ function resolveFaintedSwitchTurn(runtime: FriendlyBattleBattleRuntime): Friendl if (envelope.choice.type === 'surrender') { return finalizeResolution(runtime, { - messages: ['항복했다...'], + messages: [t('battle.surrender')], winner: role === 'host' ? 'guest' : 'host', reason: 'surrender', submittedAt: latestSubmittedAt(runtime.pendingChoices), diff --git a/src/friendly-battle/daemon.ts b/src/friendly-battle/daemon.ts index 2e348724..f8405d17 100644 --- a/src/friendly-battle/daemon.ts +++ b/src/friendly-battle/daemon.ts @@ -178,16 +178,16 @@ function buildBattleContext( const self = selfTeam.pokemon[selfTeam.activeIndex]; const opp = oppTeam.pokemon[oppTeam.activeIndex]; if (self && opp) { - const oppLabel = `상대 ${opp.displayName ?? 'Unknown'} Lv.${opp.level} HP:${opp.currentHp}/${opp.maxHp}`; - const selfLabel = `내 ${self.displayName ?? 'Me'} Lv.${self.level} HP:${self.currentHp}/${self.maxHp}`; + const oppLabel = t('battle.opp_label', { name: opp.displayName ?? 'Unknown', level: opp.level, hp: opp.currentHp, maxHp: opp.maxHp }); + const selfLabel = t('battle.self_label', { name: self.displayName ?? 'Me', level: self.level, hp: self.currentHp, maxHp: self.maxHp }); return `${headline}\n⚔️ ${oppLabel} | ${selfLabel}`; } } if (ownSnapshot) { const own = ownSnapshot.pokemon.slice().sort((a, b) => a.slot - b.slot)[0]; if (own) { - const selfLabel = `내 ${own.displayName} Lv.${own.level} HP:${own.baseStats.hp}/${own.baseStats.hp}`; - return `${headline}\n${selfLabel} (상대 HP는 다음 턴 결과에서 확인)`; + const selfLabel = t('battle.self_label', { name: own.displayName, level: own.level, hp: own.baseStats.hp, maxHp: own.baseStats.hp }); + return `${headline}\n${selfLabel} ${t('battle.self_hp_next_turn')}`; } } return headline; @@ -272,8 +272,8 @@ function buildEnvelopeFieldsFromLiveState(input: { const oppName = localizePokemonName(opp.active.pokemonId, opp.active.name); const selfName = localizePokemonName(self.active.pokemonId, self.active.name); - const oppLabel = `상대 ${oppName} Lv.${opp.active.level} HP:${opp.active.hp}/${opp.active.maxHp}`; - const selfLabel = `내 ${selfName} Lv.${self.active.level} HP:${self.active.hp}/${self.active.maxHp}`; + const oppLabel = t('battle.opp_label', { name: oppName, level: opp.active.level, hp: opp.active.hp, maxHp: opp.active.maxHp }); + const selfLabel = t('battle.self_label', { name: selfName, level: self.active.level, hp: self.active.hp, maxHp: self.active.maxHp }); const questionContext = `${input.headline}\n⚔️ ${oppLabel} | ${selfLabel}`; return { diff --git a/src/friendly-battle/spike/tcp-direct.ts b/src/friendly-battle/spike/tcp-direct.ts index 1b54f889..b788e677 100644 --- a/src/friendly-battle/spike/tcp-direct.ts +++ b/src/friendly-battle/spike/tcp-direct.ts @@ -18,6 +18,7 @@ import { } from '../local-harness.js'; import { assertValidFriendlyBattlePartySnapshot } from '../snapshot.js'; import { AsyncQueue } from '../async-queue.js'; +import { t } from '../../i18n/index.js'; export class FriendlyBattleTransportError extends Error { readonly code: string; @@ -94,7 +95,7 @@ function isWildcardHost(host: string): boolean { } const transportTimeoutError = (label: string, _ms: number) => - new FriendlyBattleTransportError('timeout', `${label} 대기 중 시간이 초과되었습니다.`); + new FriendlyBattleTransportError('timeout', t('fb.transport.timeout', { label })); export async function createFriendlyBattleSpikeHost(options: HostOptions) { const guestJoinQueue = new AsyncQueue(transportTimeoutError); @@ -115,7 +116,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { reject( new FriendlyBattleTransportError( 'listen_failed', - `host가 ${options.host}:${options.port}에서 listen하지 못했습니다. 이미 사용 중인 포트인지, host 주소가 유효한지 확인하세요. (${error.code ?? 'unknown'})`, + `host failed to listen on ${options.host}:${options.port}. Check if the port is in use or the host address is valid. (${error.code ?? 'unknown'})`, ), ); }; @@ -125,7 +126,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { server.off('error', onListenError); const address = server.address(); if (!address || typeof address === 'string') { - reject(new FriendlyBattleTransportError('listen_failed', 'friendly battle spike host가 바인딩 주소를 확인하지 못했습니다.')); + reject(new FriendlyBattleTransportError('listen_failed', 'friendly battle spike host could not resolve binding address.')); return; } resolve({ host: address.address, port: address.port }); @@ -138,7 +139,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { }); throw new FriendlyBattleTransportError( 'advertise_host_required', - `host가 ${listenAddress.host} 같은 wildcard 주소로 listen할 때는 guest에게 전달할 --join-host(광고용 host)가 필요합니다.`, + `when host listens on a wildcard address like ${listenAddress.host}, --join-host is required for guest advertisement.`, ); } @@ -148,7 +149,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { }); throw new FriendlyBattleTransportError( 'advertise_host_required', - `--join-host는 guest가 실제로 접속할 수 있는 구체적인 host여야 합니다. ${options.advertiseHost} 같은 wildcard 주소는 사용할 수 없습니다.`, + `--join-host must be a concrete address guests can connect to; wildcard addresses like ${options.advertiseHost} are not allowed.`, ); } @@ -165,7 +166,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'room_full', - message: '이미 guest가 연결되어 있습니다.', + message: 'A guest is already connected.', }); incomingSocket.end(); return; @@ -183,7 +184,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'unsupported_protocol', - message: `지원하지 않는 friendly battle protocol 버전입니다. host=${FRIENDLY_BATTLE_PROTOCOL_VERSION}, guest=${message.protocolVersion}`, + message: `Unsupported protocol version. host=${FRIENDLY_BATTLE_PROTOCOL_VERSION}, guest=${message.protocolVersion}`, }); if (socket === incomingSocket) { socket = null; @@ -196,7 +197,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'bad_session_code', - message: `세션 코드가 일치하지 않습니다. host가 보여준 session code(${options.sessionCode})를 다시 확인하세요.`, + message: `Session code mismatch. Expected session code: ${options.sessionCode}`, }); if (socket === incomingSocket) { socket = null; @@ -209,7 +210,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'generation_mismatch', - message: `세대가 일치하지 않습니다. host=${options.generation}, guest=${message.generation}`, + message: `Generation mismatch. host=${options.generation}, guest=${message.generation}`, }); if (socket === incomingSocket) { socket = null; @@ -224,7 +225,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'invalid_guest_snapshot', - message: `guest snapshot 검증에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, + message: `Guest snapshot validation failed: ${error instanceof Error ? error.message : String(error)}`, }); if (socket === incomingSocket) { socket = null; @@ -237,7 +238,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'generation_mismatch', - message: `guest snapshot 세대가 host 세대와 다릅니다. host=${options.generation}, snapshot=${message.guestSnapshot.generation}`, + message: `Guest snapshot generation differs from host. host=${options.generation}, snapshot=${message.guestSnapshot.generation}`, }); if (socket === incomingSocket) { socket = null; @@ -284,7 +285,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { socket = null; } if (!closed && handshakeAccepted) { - destroyQueues(new FriendlyBattleTransportError('socket_closed', 'guest 연결이 종료되었습니다.')); + destroyQueues(new FriendlyBattleTransportError('socket_closed', 'Guest connection was closed.')); } }); @@ -295,7 +296,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { const ensureSocket = (): net.Socket => { if (!socket) { - throw new FriendlyBattleTransportError('not_connected', '아직 guest가 연결되지 않았습니다. join 정보를 확인하세요.'); + throw new FriendlyBattleTransportError('not_connected', 'No guest connected yet. Check join information.'); } return socket; }; @@ -327,7 +328,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { - throw new FriendlyBattleTransportError('timeout', `${label} 대기 중 시간이 초과되었습니다.`); + throw new FriendlyBattleTransportError('timeout', t('fb.transport.timeout', { label })); } const nextReadyState = await readyStateQueue.shift(remainingMs, label); @@ -358,7 +359,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { async startBattle(battleId: string): Promise { const activeSocket = ensureSocket(); if (!hostReady || !guestReady) { - throw new FriendlyBattleTransportError('not_ready', '둘 다 ready 상태가 되어야 battle을 시작할 수 있습니다.'); + throw new FriendlyBattleTransportError('not_ready', 'Both sides must be ready before battle can start.'); } battleStarted = true; writeMessage(activeSocket, { type: 'battle_started', battleId }); @@ -369,7 +370,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { sendBattleEvent(event: FriendlyBattleBattleEvent): FriendlyBattleBattleEvent { const activeSocket = ensureSocket(); if (!battleStarted) { - throw new FriendlyBattleTransportError('battle_not_started', 'battle이 시작되기 전에는 이벤트를 보낼 수 없습니다.'); + throw new FriendlyBattleTransportError('battle_not_started', 'Cannot send events before battle has started.'); } battleEventLog.push(structuredClone(event)); writeMessage(activeSocket, { type: 'battle_event', event }); @@ -439,7 +440,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { reject( new FriendlyBattleTransportError( 'connection_failed', - `host에 연결하지 못했습니다. host가 실행 중인지, 주소(${options.host})와 포트(${options.port})가 맞는지 확인하세요. (${error.code ?? 'unknown'})`, + `Failed to connect to host. Check if host is running and address (${options.host}) and port (${options.port}) are correct. (${error.code ?? 'unknown'})`, ), ); }); @@ -484,7 +485,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { failAndDestroy( new FriendlyBattleTransportError( 'unsupported_protocol', - `protocol version이 맞지 않습니다. host=${message.protocolVersion}, guest=${FRIENDLY_BATTLE_PROTOCOL_VERSION}`, + `Protocol version mismatch. host=${message.protocolVersion}, guest=${FRIENDLY_BATTLE_PROTOCOL_VERSION}`, ), ); return; @@ -493,7 +494,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { failAndDestroy( new FriendlyBattleTransportError( 'generation_mismatch', - `generation이 맞지 않습니다. host=${message.generation}, guest=${options.generation}`, + `Generation mismatch. host=${message.generation}, guest=${options.generation}`, ), ); return; @@ -523,7 +524,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { socket.on('close', () => { if (!closed) { - closeWithError(new FriendlyBattleTransportError('socket_closed', 'host 연결이 종료되었습니다.')); + closeWithError(new FriendlyBattleTransportError('socket_closed', 'Host connection was closed.')); } }); @@ -553,7 +554,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { failAndDestroy( error instanceof Error ? error - : new FriendlyBattleTransportError('timeout', 'hello handshake 대기 중 시간이 초과되었습니다.'), + : new FriendlyBattleTransportError('timeout', t('fb.transport.timeout', { label: 'hello handshake' })), ); throw error; } @@ -571,7 +572,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { }, async submitChoice(value: string): Promise { if (!battleStarted) { - throw new FriendlyBattleTransportError('battle_not_started', 'battle 시작 신호를 받기 전에는 행동을 보낼 수 없습니다.'); + throw new FriendlyBattleTransportError('battle_not_started', 'Cannot submit action before battle start signal is received.'); } const envelope = createFriendlyBattleChoiceEnvelope('guest', value); writeMessage(socket, { type: 'submit_choice', envelope }); From 837f680f02e2a273b6e141e1abdb8b84dd622066 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:09:17 +0000 Subject: [PATCH 07/11] fix friendly-battle transport localization Agent-Logs-Url: https://github.com/Staninna/tkm/sessions/f2465532-dc33-4346-ae39-a4372a2b13c5 Co-authored-by: Staninna <26458805+Staninna@users.noreply.github.com> --- hooks/hooks.json | 14 ++-- src/cli/friendly-battle-spike.ts | 33 ++++++---- src/friendly-battle/daemon.ts | 2 +- src/friendly-battle/spike/tcp-direct.ts | 67 ++++++++++++++------ src/i18n/en.json | 35 +++++++++- src/i18n/ko.json | 35 +++++++++- test/friendly-battle-spike-cli.test.ts | 18 +++--- test/friendly-battle-transport-spike.test.ts | 9 ++- 8 files changed, 156 insertions(+), 57 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index b2cfbfc1..71b9da83 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,11 +1,11 @@ { "hooks": { - "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/package.json\" \"${CLAUDE_PLUGIN_DATA}/package.json\" >/dev/null 2>&1 || (cd \"${CLAUDE_PLUGIN_ROOT}\" && npm install --no-audit --no-fund 2>/dev/null && cp \"${CLAUDE_PLUGIN_ROOT}/package.json\" \"${CLAUDE_PLUGIN_DATA}/package.json\") || true"}, {"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/session-start.ts\""}]}], - "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/stop.ts\""}]}], - "PermissionRequest": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/permission.ts\""}]}], - "PostToolUseFailure": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/tool-fail.ts\""}]}], - "SubagentStart": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/subagent-start.ts\""}]}], - "SubagentStop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/subagent-stop.ts\""}]}], - "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/post-tool-use.ts\""}]}] + "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "diff -q \"/home/runner/work/tkm/tkm/package.json\" \"/home/runner/.claude/tokenmon/package.json\" >/dev/null 2>&1 || (cd \"/home/runner/work/tkm/tkm\" && npm install --no-audit --no-fund 2>/dev/null && cp \"/home/runner/work/tkm/tkm/package.json\" \"/home/runner/.claude/tokenmon/package.json\") || true"}, {"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/session-start.ts\""}]}], + "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/stop.ts\""}]}], + "PermissionRequest": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/permission.ts\""}]}], + "PostToolUseFailure": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/tool-fail.ts\""}]}], + "SubagentStart": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/subagent-start.ts\""}]}], + "SubagentStop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/subagent-stop.ts\""}]}], + "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/post-tool-use.ts\""}]}] } } diff --git a/src/cli/friendly-battle-spike.ts b/src/cli/friendly-battle-spike.ts index baea5796..cc699895 100644 --- a/src/cli/friendly-battle-spike.ts +++ b/src/cli/friendly-battle-spike.ts @@ -3,6 +3,8 @@ import { FriendlyBattleTransportError, connectFriendlyBattleSpikeGuest, createFr import type { FriendlyBattleBattleEvent, FriendlyBattleChoice } from '../friendly-battle/contracts.js'; import { loadFriendlyBattleCurrentProfile } from '../friendly-battle/local-harness.js'; import { buildFriendlyBattlePartySnapshot } from '../friendly-battle/snapshot.js'; +import { readGlobalConfig } from '../core/config.js'; +import { initLocale, t } from '../i18n/index.js'; type Command = 'host' | 'join'; @@ -11,6 +13,9 @@ type ParsedArgs = { values: Map; }; +const globalConfig = readGlobalConfig(); +initLocale(globalConfig.language, globalConfig.voice_tone); + function usage(): never { console.error('Usage:'); console.error(' tokenmon friendly-battle spike host --session-code [--listen-host 127.0.0.1] [--join-host ] [--port 0] [--timeout-ms 4000] [--generation gen4]'); @@ -70,7 +75,7 @@ function shellEscape(value: string): string { } function formatRetryHintFromError(errorMessage: string, fallbackCommand: string): string { - const sessionCodeMatch = errorMessage.match(/session code\(([^)]+)\)/i); + const sessionCodeMatch = errorMessage.match(/session code(?:\(|:\s*)([^)\n]+)\)?/i); if (!sessionCodeMatch) { return fallbackCommand; } @@ -163,16 +168,16 @@ async function runHost(values: Map): Promise { : 'host'; const nextAction = stage === 'listen' && error.code === 'advertise_host_required' - ? 'Specify --join-host with the actual host guests can connect to, then retry.' + ? t('fb.cli.host.next_action.join_host') : stage === 'listen' - ? 'Check host/port or stop any process using the same port, then retry host.' + ? t('fb.cli.host.next_action.listen') : stage === 'ready' - ? 'Check that guest completed the ready phase after joining, then retry host.' + ? t('fb.cli.host.next_action.ready') : stage === 'join' - ? 'Check that guest joined with the correct host/port/session code.' + ? t('fb.cli.host.next_action.join') : stage === 'battle' - ? 'Check that opponent action arrives after battle start and retry host if needed.' - : 'Check host/port/session code and guest progress, then retry host.'; + ? t('fb.cli.host.next_action.battle') + : t('fb.cli.host.next_action.generic'); printFriendlyBattleFailure({ stage, @@ -226,7 +231,7 @@ async function runHost(values: Map): Promise { const joined = await withStageTimeout( host.waitForGuestJoin(timeoutMs), 'join_timeout', - 'Timed out waiting for guest to join.', + t('fb.cli.host.timeout.join'), ); console.log(`STAGE: guest_joined (${joined.guestPlayerName})`); @@ -235,7 +240,7 @@ async function runHost(values: Map): Promise { await withStageTimeout( host.waitUntilCanStart(timeoutMs), 'ready_timeout', - 'Timed out waiting for guest ready.', + t('fb.cli.host.timeout.ready'), ); const battleId = `spike-${sessionCode}`; await host.startBattle(battleId); @@ -263,7 +268,7 @@ async function runHost(values: Map): Promise { const guestChoice = await withStageTimeout( host.waitForGuestChoice(timeoutMs), 'guest_choice_timeout', - 'Timed out waiting for guest choice.', + t('fb.cli.host.timeout.guest_choice'), ); console.log(`GUEST_CHOICE: ${formatFriendlyBattleChoice(guestChoice.choice)}`); @@ -336,12 +341,12 @@ async function runJoin(values: Map): Promise { : currentStage; const nextAction = stage === 'handshake' - ? 'Double-check the session code shown by the host and retry join.' + ? t('fb.cli.guest.next_action.handshake') : stage === 'connect' || stage === 'join' - ? 'Check the host process and verify host/port/session code.' + ? t('fb.cli.guest.next_action.connect') : stage === 'ready' - ? 'Check that host is still running before battle starts, then retry join.' - : 'Check that host has progressed to the battle start stage, then retry join.'; + ? t('fb.cli.guest.next_action.ready') + : t('fb.cli.guest.next_action.battle'); printFriendlyBattleFailure({ stage, diff --git a/src/friendly-battle/daemon.ts b/src/friendly-battle/daemon.ts index f8405d17..4ad8860b 100644 --- a/src/friendly-battle/daemon.ts +++ b/src/friendly-battle/daemon.ts @@ -38,7 +38,7 @@ import { } from './local-harness.js'; import { getLoadedMovesDB } from '../core/battle-setup.js'; import { getDisplayName as getPokemonDisplayName } from '../core/pokemon-data.js'; -import { getLocale, initLocale } from '../i18n/index.js'; +import { getLocale, initLocale, t } from '../i18n/index.js'; import { readGlobalConfig } from '../core/config.js'; import { friendlyBattleSessionsDir, diff --git a/src/friendly-battle/spike/tcp-direct.ts b/src/friendly-battle/spike/tcp-direct.ts index b788e677..804706f7 100644 --- a/src/friendly-battle/spike/tcp-direct.ts +++ b/src/friendly-battle/spike/tcp-direct.ts @@ -116,7 +116,11 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { reject( new FriendlyBattleTransportError( 'listen_failed', - `host failed to listen on ${options.host}:${options.port}. Check if the port is in use or the host address is valid. (${error.code ?? 'unknown'})`, + t('fb.transport.listen_failed', { + host: options.host, + port: options.port, + errorCode: error.code ?? 'unknown', + }), ), ); }; @@ -126,7 +130,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { server.off('error', onListenError); const address = server.address(); if (!address || typeof address === 'string') { - reject(new FriendlyBattleTransportError('listen_failed', 'friendly battle spike host could not resolve binding address.')); + reject(new FriendlyBattleTransportError('listen_failed', t('fb.transport.listen_resolve_address_failed'))); return; } resolve({ host: address.address, port: address.port }); @@ -139,7 +143,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { }); throw new FriendlyBattleTransportError( 'advertise_host_required', - `when host listens on a wildcard address like ${listenAddress.host}, --join-host is required for guest advertisement.`, + t('fb.transport.advertise_required_listen', { host: listenAddress.host }), ); } @@ -149,7 +153,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { }); throw new FriendlyBattleTransportError( 'advertise_host_required', - `--join-host must be a concrete address guests can connect to; wildcard addresses like ${options.advertiseHost} are not allowed.`, + t('fb.transport.advertise_required_concrete', { host: options.advertiseHost }), ); } @@ -166,7 +170,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'room_full', - message: 'A guest is already connected.', + message: t('fb.transport.room_full'), }); incomingSocket.end(); return; @@ -184,7 +188,10 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'unsupported_protocol', - message: `Unsupported protocol version. host=${FRIENDLY_BATTLE_PROTOCOL_VERSION}, guest=${message.protocolVersion}`, + message: t('fb.transport.unsupported_protocol', { + host: FRIENDLY_BATTLE_PROTOCOL_VERSION, + guest: message.protocolVersion, + }), }); if (socket === incomingSocket) { socket = null; @@ -197,7 +204,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'bad_session_code', - message: `Session code mismatch. Expected session code: ${options.sessionCode}`, + message: t('fb.transport.bad_session_code', { sessionCode: options.sessionCode }), }); if (socket === incomingSocket) { socket = null; @@ -210,7 +217,10 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'generation_mismatch', - message: `Generation mismatch. host=${options.generation}, guest=${message.generation}`, + message: t('fb.transport.generation_mismatch', { + host: options.generation, + guest: message.generation, + }), }); if (socket === incomingSocket) { socket = null; @@ -225,7 +235,9 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'invalid_guest_snapshot', - message: `Guest snapshot validation failed: ${error instanceof Error ? error.message : String(error)}`, + message: t('fb.transport.invalid_guest_snapshot', { + reason: error instanceof Error ? error.message : String(error), + }), }); if (socket === incomingSocket) { socket = null; @@ -238,7 +250,10 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { writeMessage(incomingSocket, { type: 'hello_reject', code: 'generation_mismatch', - message: `Guest snapshot generation differs from host. host=${options.generation}, snapshot=${message.guestSnapshot.generation}`, + message: t('fb.transport.snapshot_generation_mismatch', { + host: options.generation, + snapshot: message.guestSnapshot.generation, + }), }); if (socket === incomingSocket) { socket = null; @@ -285,7 +300,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { socket = null; } if (!closed && handshakeAccepted) { - destroyQueues(new FriendlyBattleTransportError('socket_closed', 'Guest connection was closed.')); + destroyQueues(new FriendlyBattleTransportError('socket_closed', t('fb.transport.socket_closed_guest'))); } }); @@ -296,7 +311,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { const ensureSocket = (): net.Socket => { if (!socket) { - throw new FriendlyBattleTransportError('not_connected', 'No guest connected yet. Check join information.'); + throw new FriendlyBattleTransportError('not_connected', t('fb.transport.not_connected')); } return socket; }; @@ -359,7 +374,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { async startBattle(battleId: string): Promise { const activeSocket = ensureSocket(); if (!hostReady || !guestReady) { - throw new FriendlyBattleTransportError('not_ready', 'Both sides must be ready before battle can start.'); + throw new FriendlyBattleTransportError('not_ready', t('fb.transport.not_ready')); } battleStarted = true; writeMessage(activeSocket, { type: 'battle_started', battleId }); @@ -370,7 +385,7 @@ export async function createFriendlyBattleSpikeHost(options: HostOptions) { sendBattleEvent(event: FriendlyBattleBattleEvent): FriendlyBattleBattleEvent { const activeSocket = ensureSocket(); if (!battleStarted) { - throw new FriendlyBattleTransportError('battle_not_started', 'Cannot send events before battle has started.'); + throw new FriendlyBattleTransportError('battle_not_started', t('fb.transport.battle_not_started_send')); } battleEventLog.push(structuredClone(event)); writeMessage(activeSocket, { type: 'battle_event', event }); @@ -424,7 +439,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { while (true) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { - throw new FriendlyBattleTransportError('timeout', `${label} 대기 중 시간이 초과되었습니다.`); + throw new FriendlyBattleTransportError('timeout', t('fb.transport.timeout', { label })); } const readyState = await readyStateQueue.shift(remainingMs, label); @@ -440,7 +455,11 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { reject( new FriendlyBattleTransportError( 'connection_failed', - `Failed to connect to host. Check if host is running and address (${options.host}) and port (${options.port}) are correct. (${error.code ?? 'unknown'})`, + t('fb.transport.connection_failed', { + host: options.host, + port: options.port, + errorCode: error.code ?? 'unknown', + }), ), ); }); @@ -485,7 +504,10 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { failAndDestroy( new FriendlyBattleTransportError( 'unsupported_protocol', - `Protocol version mismatch. host=${message.protocolVersion}, guest=${FRIENDLY_BATTLE_PROTOCOL_VERSION}`, + t('fb.transport.protocol_mismatch', { + host: message.protocolVersion, + guest: FRIENDLY_BATTLE_PROTOCOL_VERSION, + }), ), ); return; @@ -494,7 +516,10 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { failAndDestroy( new FriendlyBattleTransportError( 'generation_mismatch', - `Generation mismatch. host=${message.generation}, guest=${options.generation}`, + t('fb.transport.hello_ack_generation_mismatch', { + host: message.generation, + guest: options.generation, + }), ), ); return; @@ -524,7 +549,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { socket.on('close', () => { if (!closed) { - closeWithError(new FriendlyBattleTransportError('socket_closed', 'Host connection was closed.')); + closeWithError(new FriendlyBattleTransportError('socket_closed', t('fb.transport.socket_closed_host'))); } }); @@ -533,7 +558,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { closeWithError( new FriendlyBattleTransportError( 'socket_error', - `friendly battle socket error: ${error.code ?? 'unknown'}`, + t('fb.transport.socket_error', { errorCode: error.code ?? 'unknown' }), ), ); } @@ -572,7 +597,7 @@ export async function connectFriendlyBattleSpikeGuest(options: GuestOptions) { }, async submitChoice(value: string): Promise { if (!battleStarted) { - throw new FriendlyBattleTransportError('battle_not_started', 'Cannot submit action before battle start signal is received.'); + throw new FriendlyBattleTransportError('battle_not_started', t('fb.transport.battle_not_started_submit')); } const envelope = createFriendlyBattleChoiceEnvelope('guest', value); writeMessage(socket, { type: 'submit_choice', envelope }); diff --git a/src/i18n/en.json b/src/i18n/en.json index db4d4b01..1bd588ef 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -636,5 +636,38 @@ "battle.opp_label": "Opp {name} Lv.{level} HP:{hp}/{maxHp}", "battle.self_label": "My {name} Lv.{level} HP:{hp}/{maxHp}", "battle.self_hp_next_turn": "(Opponent HP available after next turn result)", - "fb.transport.timeout": "{label} timed out." + "fb.transport.timeout": "{label} timed out.", + "fb.transport.listen_failed": "Host failed to listen on {host}:{port}. Check if the port is in use or the host address is valid. ({errorCode})", + "fb.transport.listen_resolve_address_failed": "Friendly battle spike host could not resolve its binding address.", + "fb.transport.advertise_required_listen": "When host listens on a wildcard address like {host}, --join-host is required for guest advertisement.", + "fb.transport.advertise_required_concrete": "--join-host must be a concrete address guests can connect to; wildcard addresses like {host} are not allowed.", + "fb.transport.room_full": "A guest is already connected.", + "fb.transport.unsupported_protocol": "Unsupported protocol version. host={host}, guest={guest}", + "fb.transport.bad_session_code": "Session code mismatch. Double-check the host session code({sessionCode}) and retry.", + "fb.transport.generation_mismatch": "Generation mismatch. host={host}, guest={guest}", + "fb.transport.invalid_guest_snapshot": "Guest snapshot validation failed: {reason}", + "fb.transport.snapshot_generation_mismatch": "Guest snapshot generation differs from host. host={host}, snapshot={snapshot}", + "fb.transport.socket_closed_guest": "Guest connection was closed.", + "fb.transport.not_connected": "No guest connected yet. Check join information.", + "fb.transport.not_ready": "Both sides must be ready before battle can start.", + "fb.transport.battle_not_started_send": "Cannot send events before battle has started.", + "fb.transport.connection_failed": "Failed to connect to host. Check if host is running and address ({host}) and port ({port}) are correct. ({errorCode})", + "fb.transport.protocol_mismatch": "Protocol version mismatch. host={host}, guest={guest}", + "fb.transport.hello_ack_generation_mismatch": "Generation mismatch. host={host}, guest={guest}", + "fb.transport.socket_closed_host": "Host connection was closed.", + "fb.transport.socket_error": "Friendly battle socket error: {errorCode}", + "fb.transport.battle_not_started_submit": "Cannot submit action before the battle start signal is received.", + "fb.cli.host.next_action.join_host": "Specify --join-host with the actual host guests can connect to, then retry host.", + "fb.cli.host.next_action.listen": "Check host/port or stop any process using the same port, then retry host.", + "fb.cli.host.next_action.ready": "Check that guest completed the ready phase after joining, then retry host.", + "fb.cli.host.next_action.join": "Check that guest joined with the correct host/port/session code.", + "fb.cli.host.next_action.battle": "Check that opponent action arrives after battle start and retry host if needed.", + "fb.cli.host.next_action.generic": "Check host/port/session code and guest progress, then retry host.", + "fb.cli.host.timeout.join": "Timed out waiting for guest to join.", + "fb.cli.host.timeout.ready": "Timed out waiting for guest ready.", + "fb.cli.host.timeout.guest_choice": "Timed out waiting for guest choice.", + "fb.cli.guest.next_action.handshake": "Double-check the session code shown by the host and retry join.", + "fb.cli.guest.next_action.connect": "Check the host process and verify host/port/session code.", + "fb.cli.guest.next_action.ready": "Check that host is still running before battle starts, then retry join.", + "fb.cli.guest.next_action.battle": "Check that host has progressed to the battle start stage, then retry join." } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 6e313e66..11813c0c 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -636,5 +636,38 @@ "battle.opp_label": "상대 {name} Lv.{level} HP:{hp}/{maxHp}", "battle.self_label": "내 {name} Lv.{level} HP:{hp}/{maxHp}", "battle.self_hp_next_turn": "(상대 HP는 다음 턴 결과에서 확인)", - "fb.transport.timeout": "{label} 대기 중 시간이 초과되었습니다." + "fb.transport.timeout": "{label} 대기 중 시간이 초과되었습니다.", + "fb.transport.listen_failed": "host가 {host}:{port}에서 listen하지 못했습니다. 이미 사용 중인 포트인지, host 주소가 유효한지 확인하세요. ({errorCode})", + "fb.transport.listen_resolve_address_failed": "friendly battle spike host가 바인딩 주소를 확인하지 못했습니다.", + "fb.transport.advertise_required_listen": "host가 {host} 같은 wildcard 주소로 listen할 때는 guest에게 전달할 --join-host(광고용 host)가 필요합니다.", + "fb.transport.advertise_required_concrete": "--join-host는 guest가 실제로 접속할 수 있는 구체적인 host여야 합니다. {host} 같은 wildcard 주소는 사용할 수 없습니다.", + "fb.transport.room_full": "이미 guest가 연결되어 있습니다.", + "fb.transport.unsupported_protocol": "지원하지 않는 friendly battle protocol 버전입니다. host={host}, guest={guest}", + "fb.transport.bad_session_code": "세션 코드가 일치하지 않습니다. host가 보여준 session code({sessionCode})를 다시 확인하세요.", + "fb.transport.generation_mismatch": "세대가 일치하지 않습니다. host={host}, guest={guest}", + "fb.transport.invalid_guest_snapshot": "guest snapshot 검증에 실패했습니다: {reason}", + "fb.transport.snapshot_generation_mismatch": "guest snapshot 세대가 host 세대와 다릅니다. host={host}, snapshot={snapshot}", + "fb.transport.socket_closed_guest": "guest 연결이 종료되었습니다.", + "fb.transport.not_connected": "아직 guest가 연결되지 않았습니다. join 정보를 확인하세요.", + "fb.transport.not_ready": "battle을 시작하려면 양쪽 모두 ready 상태여야 합니다.", + "fb.transport.battle_not_started_send": "battle이 시작되기 전에는 이벤트를 보낼 수 없습니다.", + "fb.transport.connection_failed": "host에 연결하지 못했습니다. host 프로세스가 실행 중인지, 주소({host})와 포트({port})가 맞는지 확인하세요. ({errorCode})", + "fb.transport.protocol_mismatch": "protocol 버전이 일치하지 않습니다. host={host}, guest={guest}", + "fb.transport.hello_ack_generation_mismatch": "세대가 일치하지 않습니다. host={host}, guest={guest}", + "fb.transport.socket_closed_host": "host 연결이 종료되었습니다.", + "fb.transport.socket_error": "friendly battle socket 오류: {errorCode}", + "fb.transport.battle_not_started_submit": "battle 시작 신호를 받기 전에는 행동을 제출할 수 없습니다.", + "fb.cli.host.next_action.join_host": "guest가 접속할 실제 join host를 --join-host 로 지정한 뒤 다시 host 하세요.", + "fb.cli.host.next_action.listen": "입력한 host/port를 확인하거나 이미 같은 포트를 쓰는 프로세스를 종료한 뒤 다시 host 하세요.", + "fb.cli.host.next_action.ready": "guest가 join 후 ready 단계까지 완료했는지 확인한 뒤 다시 host 하세요.", + "fb.cli.host.next_action.join": "guest가 올바른 host/port/session code로 join 했는지 확인하세요.", + "fb.cli.host.next_action.battle": "battle 시작 후 상대 행동이 도착하는지 확인하고, 필요하면 다시 host 하세요.", + "fb.cli.host.next_action.generic": "입력한 host/port/session code와 guest 진행 상태를 확인한 뒤 다시 host 하세요.", + "fb.cli.host.timeout.join": "guest join 대기 중 시간이 초과되었습니다.", + "fb.cli.host.timeout.ready": "guest ready 대기 중 시간이 초과되었습니다.", + "fb.cli.host.timeout.guest_choice": "guest choice 대기 중 시간이 초과되었습니다.", + "fb.cli.guest.next_action.handshake": "host가 보여준 session code를 다시 확인한 뒤 다시 join 하세요.", + "fb.cli.guest.next_action.connect": "host 프로세스와 입력한 host/port/session code를 다시 확인하세요.", + "fb.cli.guest.next_action.ready": "host가 battle 시작 전까지 유지되고 있는지 확인한 뒤 다시 join 하세요.", + "fb.cli.guest.next_action.battle": "host가 battle 시작 단계까지 진행됐는지 확인한 뒤 다시 join 하세요." } diff --git a/test/friendly-battle-spike-cli.test.ts b/test/friendly-battle-spike-cli.test.ts index 557fcd7e..2237ab42 100644 --- a/test/friendly-battle-spike-cli.test.ts +++ b/test/friendly-battle-spike-cli.test.ts @@ -457,7 +457,7 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.equal(result.signal, null, `host stderr:\n${result.stderr}`); assert.notEqual(result.exitCode, 0, 'host should fail when port is already in use'); assert.match(result.stderr, /FAILED_STAGE: listen/); - assert.match(result.stderr, /NEXT_ACTION: .*포트.*다시 host/i); + assert.match(result.stderr, /NEXT_ACTION: .*host\/port.*retry host/i); assert.match(result.stderr, /INPUT_HINT: .*host=127\.0\.0\.1.*sessionCode=alpha-123/); assert.match(result.stderr, new RegExp(`RETRY_HINT: .*--port ${address.port} .*--session-code alpha-123`)); } finally { @@ -497,7 +497,7 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.equal(result.signal, null, `host stderr:\n${result.stderr}`); assert.notEqual(result.exitCode, 0, 'host should fail when guest never becomes ready'); assert.match(result.stderr, /FAILED_STAGE: ready/); - assert.match(result.stderr, /NEXT_ACTION: .*ready 단계/i); + assert.match(result.stderr, /NEXT_ACTION: .*ready phase.*retry host/i); assert.match(result.stderr, /INPUT_HINT: .*sessionCode=alpha-123/); assert.match(result.stderr, /RETRY_HINT: .*friendly-battle-spike\.ts host/); } finally { @@ -554,8 +554,8 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.match(result.stdout, /STAGE: guest_joined \(BattleDropGuest\)/); assert.match(result.stdout, /STAGE: battle_started/); assert.match(result.stderr, /FAILED_STAGE: battle/); - assert.match(result.stderr, /NEXT_ACTION: .*상대 행동이 도착하는지 확인/i); - assert.match(result.stderr, /guest 연결이 종료되었습니다/); + assert.match(result.stderr, /NEXT_ACTION: .*opponent action arrives.*retry host/i); + assert.match(result.stderr, /Guest connection was closed\./); } finally { socket.destroy(); } @@ -600,7 +600,7 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.match(result.stderr, /NEXT_ACTION: .*host.*session code/i); assert.match(result.stderr, /INPUT_HINT: .*sessionCode=alpha-123/); assert.match(result.stderr, /RETRY_HINT: .*--timeout-ms 200/); - assert.match(result.stderr, /hello (acknowledgement|handshake) 대기 중 시간이 초과/); + assert.match(result.stderr, /hello (acknowledgement|handshake) timed out/i); // Wall-clock timing signal is unreliable on CI runners when other test // files are spawning child processes in parallel — a 200ms setTimeout // can drift past 1s via scheduler starvation. Keep the check as a local @@ -674,8 +674,8 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.match(result.stdout, /STAGE: connected/); assert.doesNotMatch(result.stdout, /STAGE: ready/); assert.match(result.stderr, /FAILED_STAGE: ready/); - assert.match(result.stderr, /NEXT_ACTION: .*다시 join/i); - assert.match(result.stderr, /host 연결이 종료되었습니다/); + assert.match(result.stderr, /NEXT_ACTION: .*retry join/i); + assert.match(result.stderr, /Host connection was closed\./); } finally { await new Promise((resolve, reject) => { dummyServer.close((error) => (error ? reject(error) : resolve())); @@ -742,8 +742,8 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.match(result.stdout, /STAGE: ready/); assert.doesNotMatch(result.stdout, /STAGE: battle_started/); assert.match(result.stderr, /FAILED_STAGE: battle/); - assert.match(result.stderr, /NEXT_ACTION: .*battle 시작 단계/i); - assert.match(result.stderr, /host 연결이 종료되었습니다/); + assert.match(result.stderr, /NEXT_ACTION: .*battle start stage.*retry join/i); + assert.match(result.stderr, /Host connection was closed\./); } finally { await new Promise((resolve, reject) => { dummyServer.close((error) => (error ? reject(error) : resolve())); diff --git a/test/friendly-battle-transport-spike.test.ts b/test/friendly-battle-transport-spike.test.ts index cb8e8793..8316cf41 100644 --- a/test/friendly-battle-transport-spike.test.ts +++ b/test/friendly-battle-transport-spike.test.ts @@ -13,11 +13,14 @@ import { createFriendlyBattleSpikeHost, } from '../src/friendly-battle/spike/tcp-direct.js'; import { buildFriendlyBattlePartySnapshot } from '../src/friendly-battle/snapshot.js'; +import { initLocale } from '../src/i18n/index.js'; import { makeConfig, makeState } from './helpers.js'; const pluginRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const DEFAULT_GENERATION = 'gen4'; +initLocale('en'); + function makeGuestSnapshot(options: { generation?: string; snapshotId?: string; @@ -294,7 +297,7 @@ describe('friendly battle TCP direct transport spike', () => { (error: unknown) => { assert.ok(error instanceof FriendlyBattleTransportError); assert.equal(error.code, 'connection_failed'); - assert.match(error.message, /host.*실행|주소|포트/i); + assert.match(error.message, /host is running|address|port/i); return true; }, ); @@ -379,7 +382,7 @@ describe('friendly battle TCP direct transport spike', () => { (error: unknown) => { assert.ok(error instanceof FriendlyBattleTransportError); assert.equal(error.code, 'socket_closed'); - assert.match(error.message, /연결이 종료/); + assert.match(error.message, /connection was closed/i); return true; }, ); @@ -410,7 +413,7 @@ describe('friendly battle TCP direct transport spike', () => { (error: unknown) => { assert.ok(error instanceof FriendlyBattleTransportError); assert.equal(error.code, 'listen_failed'); - assert.match(error.message, /listen.*포트|사용 중|host 주소/i); + assert.match(error.message, /listen|port is in use|host address/i); return true; }, ); From 7e9b4eae28f885d9de7ca027c998d2a42894b63d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:11:54 +0000 Subject: [PATCH 08/11] test: align spike cli expectations Agent-Logs-Url: https://github.com/Staninna/tkm/sessions/f2465532-dc33-4346-ae39-a4372a2b13c5 Co-authored-by: Staninna <26458805+Staninna@users.noreply.github.com> --- hooks/hooks.json | 14 +++++++------- test/friendly-battle-spike-cli.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index 71b9da83..b2cfbfc1 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,11 +1,11 @@ { "hooks": { - "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "diff -q \"/home/runner/work/tkm/tkm/package.json\" \"/home/runner/.claude/tokenmon/package.json\" >/dev/null 2>&1 || (cd \"/home/runner/work/tkm/tkm\" && npm install --no-audit --no-fund 2>/dev/null && cp \"/home/runner/work/tkm/tkm/package.json\" \"/home/runner/.claude/tokenmon/package.json\") || true"}, {"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/session-start.ts\""}]}], - "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/stop.ts\""}]}], - "PermissionRequest": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/permission.ts\""}]}], - "PostToolUseFailure": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/tool-fail.ts\""}]}], - "SubagentStart": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/subagent-start.ts\""}]}], - "SubagentStop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/subagent-stop.ts\""}]}], - "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"/home/runner/work/tkm/tkm/bin/tsx-resolve.sh\" \"/home/runner/work/tkm/tkm/src/hooks/post-tool-use.ts\""}]}] + "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/package.json\" \"${CLAUDE_PLUGIN_DATA}/package.json\" >/dev/null 2>&1 || (cd \"${CLAUDE_PLUGIN_ROOT}\" && npm install --no-audit --no-fund 2>/dev/null && cp \"${CLAUDE_PLUGIN_ROOT}/package.json\" \"${CLAUDE_PLUGIN_DATA}/package.json\") || true"}, {"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/session-start.ts\""}]}], + "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/stop.ts\""}]}], + "PermissionRequest": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/permission.ts\""}]}], + "PostToolUseFailure": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/tool-fail.ts\""}]}], + "SubagentStart": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/subagent-start.ts\""}]}], + "SubagentStop": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/subagent-stop.ts\""}]}], + "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "TOKENMON_HOOK_MODE=1 \"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh\" \"${CLAUDE_PLUGIN_ROOT}/src/hooks/post-tool-use.ts\""}]}] } } diff --git a/test/friendly-battle-spike-cli.test.ts b/test/friendly-battle-spike-cli.test.ts index 2237ab42..1c96c0a7 100644 --- a/test/friendly-battle-spike-cli.test.ts +++ b/test/friendly-battle-spike-cli.test.ts @@ -373,7 +373,7 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.equal(result.signal, null, `host stderr:\n${result.stderr}`); assert.notEqual(result.exitCode, 0, 'host should fail fast without a guest-facing join host'); assert.match(result.stderr, /FAILED_STAGE: listen/); - assert.match(result.stderr, /NEXT_ACTION: .*join host/i); + assert.match(result.stderr, /NEXT_ACTION: .*--join-host.*retry host/i); assert.match(result.stderr, /INPUT_HINT: .*listenHost=0\.0\.0\.0/); assert.match(result.stderr, /RETRY_HINT: .*--listen-host 0\.0\.0\.0/); }); @@ -395,7 +395,7 @@ describe('friendly battle spike CLI', { concurrency: false }, () => { assert.equal(result.signal, null, `host stderr:\n${result.stderr}`); assert.notEqual(result.exitCode, 0, 'host should fail fast with a wildcard guest-facing join host'); assert.match(result.stderr, /FAILED_STAGE: listen/); - assert.match(result.stderr, /NEXT_ACTION: .*join host/i); + assert.match(result.stderr, /NEXT_ACTION: .*--join-host.*retry host/i); assert.match(result.stderr, /INPUT_HINT: .*joinHost=0\.0\.0\.0/); assert.match(result.stderr, /RETRY_HINT: .*--join-host 0\.0\.0\.0/); }); From 4577a10f791e9cd33fd7bacec69db545010fe702 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:13:43 +0000 Subject: [PATCH 09/11] refactor: simplify retry hint parsing Agent-Logs-Url: https://github.com/Staninna/tkm/sessions/f2465532-dc33-4346-ae39-a4372a2b13c5 Co-authored-by: Staninna <26458805+Staninna@users.noreply.github.com> --- src/cli/friendly-battle-spike.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/friendly-battle-spike.ts b/src/cli/friendly-battle-spike.ts index cc699895..7cf91535 100644 --- a/src/cli/friendly-battle-spike.ts +++ b/src/cli/friendly-battle-spike.ts @@ -75,7 +75,9 @@ function shellEscape(value: string): string { } function formatRetryHintFromError(errorMessage: string, fallbackCommand: string): string { - const sessionCodeMatch = errorMessage.match(/session code(?:\(|:\s*)([^)\n]+)\)?/i); + const sessionCodeMatch = + errorMessage.match(/session code\(([^)\n]+)\)/i) + ?? errorMessage.match(/session code:\s*([^\n]+)/i); if (!sessionCodeMatch) { return fallbackCommand; } From fcdfc8ab245083e7787bec9e0d9acd51c110da48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:15:35 +0000 Subject: [PATCH 10/11] refactor: clarify session code parsing Agent-Logs-Url: https://github.com/Staninna/tkm/sessions/f2465532-dc33-4346-ae39-a4372a2b13c5 Co-authored-by: Staninna <26458805+Staninna@users.noreply.github.com> --- src/cli/friendly-battle-spike.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/friendly-battle-spike.ts b/src/cli/friendly-battle-spike.ts index 7cf91535..f3e7d968 100644 --- a/src/cli/friendly-battle-spike.ts +++ b/src/cli/friendly-battle-spike.ts @@ -75,9 +75,9 @@ function shellEscape(value: string): string { } function formatRetryHintFromError(errorMessage: string, fallbackCommand: string): string { - const sessionCodeMatch = - errorMessage.match(/session code\(([^)\n]+)\)/i) - ?? errorMessage.match(/session code:\s*([^\n]+)/i); + const sessionCodeInParens = errorMessage.match(/session code\(([^)\n]+)\)/i); + const sessionCodeAfterColon = errorMessage.match(/session code:\s*([^\n]+)/i); + const sessionCodeMatch = sessionCodeInParens ?? sessionCodeAfterColon; if (!sessionCodeMatch) { return fallbackCommand; } From ddd44d265fa33220c92c7fc8b395a76b076f5241 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:17:12 +0000 Subject: [PATCH 11/11] refactor: simplify session code regex Agent-Logs-Url: https://github.com/Staninna/tkm/sessions/f2465532-dc33-4346-ae39-a4372a2b13c5 Co-authored-by: Staninna <26458805+Staninna@users.noreply.github.com> --- src/cli/friendly-battle-spike.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/friendly-battle-spike.ts b/src/cli/friendly-battle-spike.ts index f3e7d968..2b0beae7 100644 --- a/src/cli/friendly-battle-spike.ts +++ b/src/cli/friendly-battle-spike.ts @@ -75,8 +75,8 @@ function shellEscape(value: string): string { } function formatRetryHintFromError(errorMessage: string, fallbackCommand: string): string { - const sessionCodeInParens = errorMessage.match(/session code\(([^)\n]+)\)/i); - const sessionCodeAfterColon = errorMessage.match(/session code:\s*([^\n]+)/i); + const sessionCodeInParens = errorMessage.match(/session code\(([^)]+)\)/i); + const sessionCodeAfterColon = errorMessage.match(/session code:\s*(\S+)/i); const sessionCodeMatch = sessionCodeInParens ?? sessionCodeAfterColon; if (!sessionCodeMatch) { return fallbackCommand;