Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/battle-tui/game-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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')],
Comment on lines 195 to +197
onComplete,
};

Expand Down
12 changes: 9 additions & 3 deletions src/battle-tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`);
}
Expand Down
24 changes: 13 additions & 11 deletions src/battle-tui/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
}
Expand All @@ -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));
Comment on lines +191 to +193
}
} 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));
}
Comment on lines +196 to 200
}

Expand Down
24 changes: 15 additions & 9 deletions src/cli/battle-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,13 +95,15 @@ function buildQuestionContext(player: BattlePokemon, opponent: BattlePokemon): s
function buildMoveOptions(player: BattlePokemon): Array<{
index: number;
nameKo: string;
nameEn: string;
pp: number;
maxPp: number;
disabled: boolean;
}> {
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,
Expand Down Expand Up @@ -248,7 +250,7 @@ function handleInit(): void {
output({
status: 'rejected',
messages: [
`이 지역(${currentRegion})의 체육관은 이미 클리어했어. 다른 지역으로 이동해야 새 체육관에 도전할 수 있어.`,
t('gym.already_cleared', { region: currentRegion }),
],
});
process.exit(0);
Expand Down Expand Up @@ -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 }),
Comment on lines +388 to 390
],
menu: buildMenu(playerActive),
Expand Down Expand Up @@ -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 }));
}
}

Expand Down Expand Up @@ -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 }));

Comment on lines +619 to 620
// Re-read state inside lock to avoid overwriting hook changes
const lockResult = withLockRetry(() => {
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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();

Expand Down
35 changes: 21 additions & 14 deletions src/cli/friendly-battle-spike.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,6 +13,9 @@ type ParsedArgs = {
values: Map<string, string>;
};

const globalConfig = readGlobalConfig();
initLocale(globalConfig.language, globalConfig.voice_tone);

function usage(): never {
console.error('Usage:');
console.error(' tokenmon friendly-battle spike host --session-code <code> [--listen-host 127.0.0.1] [--join-host <host>] [--port 0] [--timeout-ms 4000] [--generation gen4]');
Expand Down Expand Up @@ -70,7 +75,9 @@ function shellEscape(value: string): string {
}

function formatRetryHintFromError(errorMessage: string, fallbackCommand: string): string {
const sessionCodeMatch = errorMessage.match(/session code\(([^)]+)\)/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;
}
Expand Down Expand Up @@ -163,16 +170,16 @@ async function runHost(values: Map<string, string>): Promise<void> {
: 'host';

const nextAction = stage === 'listen' && error.code === 'advertise_host_required'
? 'guest가 접속할 실제 join host를 --join-host 로 지정한 뒤 다시 host 하세요.'
? t('fb.cli.host.next_action.join_host')
: stage === 'listen'
? '입력한 host/port를 확인하거나 이미 같은 포트를 쓰는 프로세스를 종료한 뒤 다시 host 하세요.'
? t('fb.cli.host.next_action.listen')
: stage === 'ready'
? 'guest가 join 후 ready 단계까지 완료했는지 확인한 뒤 다시 host 하세요.'
? t('fb.cli.host.next_action.ready')
: stage === 'join'
? 'guest가 올바른 host/port/session code로 join 했는지 확인하세요.'
? t('fb.cli.host.next_action.join')
: stage === 'battle'
? 'battle 시작 후 상대 행동이 도착하는지 확인하고, 필요하면 다시 host 하세요.'
: '입력한 host/port/session code와 guest 진행 상태를 확인한 뒤 다시 host 하세요.';
? t('fb.cli.host.next_action.battle')
: t('fb.cli.host.next_action.generic');

printFriendlyBattleFailure({
stage,
Expand Down Expand Up @@ -226,7 +233,7 @@ async function runHost(values: Map<string, string>): Promise<void> {
const joined = await withStageTimeout(
host.waitForGuestJoin(timeoutMs),
'join_timeout',
'guest join 대기 중 시간이 초과되었습니다.',
t('fb.cli.host.timeout.join'),
);
console.log(`STAGE: guest_joined (${joined.guestPlayerName})`);

Expand All @@ -235,7 +242,7 @@ async function runHost(values: Map<string, string>): Promise<void> {
await withStageTimeout(
host.waitUntilCanStart(timeoutMs),
'ready_timeout',
'guest ready 대기 중 시간이 초과되었습니다.',
t('fb.cli.host.timeout.ready'),
);
const battleId = `spike-${sessionCode}`;
await host.startBattle(battleId);
Expand Down Expand Up @@ -263,7 +270,7 @@ async function runHost(values: Map<string, string>): Promise<void> {
const guestChoice = await withStageTimeout(
host.waitForGuestChoice(timeoutMs),
'guest_choice_timeout',
'guest choice 대기 중 시간이 초과되었습니다.',
t('fb.cli.host.timeout.guest_choice'),
);
console.log(`GUEST_CHOICE: ${formatFriendlyBattleChoice(guestChoice.choice)}`);

Expand Down Expand Up @@ -336,12 +343,12 @@ async function runJoin(values: Map<string, string>): Promise<void> {
: currentStage;

const nextAction = stage === 'handshake'
? 'host가 보여준 session code를 다시 확인한 뒤 다시 join 하세요.'
? t('fb.cli.guest.next_action.handshake')
: stage === 'connect' || stage === 'join'
? 'host 프로세스와 입력한 host/port/session code를 다시 확인하세요.'
? t('fb.cli.guest.next_action.connect')
: stage === 'ready'
? 'host가 battle 시작 전까지 유지되고 있는지 확인한 뒤 다시 join 하세요.'
: 'host가 battle 시작 단계까지 진행됐는지 확인한 뒤 다시 join 하세요.';
? t('fb.cli.guest.next_action.ready')
: t('fb.cli.guest.next_action.battle');

printFriendlyBattleFailure({
stage,
Expand Down
16 changes: 10 additions & 6 deletions src/cli/gym-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}` : '';

Expand All @@ -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);
}
Loading