diff --git a/.gitignore b/.gitignore index dd0c3e12..1a524a36 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ config.json .claude/ .worktrees/ TODO.txt +.tokenmon/test-backup/ diff --git a/package.json b/package.json index 428c566b..2a1cf271 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,44 @@ "typescript": "^5.5.0" }, "license": "MIT", + "files": [ + "bin/", + ".claude-plugin/", + "src/cli/battle-turn.ts", + "src/cli/friendly-battle.ts", + "src/cli/friendly-battle-local.ts", + "src/cli/friendly-battle-spike.ts", + "src/cli/friendly-battle-turn.ts", + "src/cli/gym-list.ts", + "src/cli/moves.ts", + "src/cli/tokenmon.ts", + "src/core/", + "src/hooks/", + "src/setup/", + "src/friendly-battle/", + "src/i18n/", + "src/status-line.ts", + "skills/call/", + "skills/doctor/", + "skills/friendly-battle/", + "skills/gym/", + "skills/language/", + "skills/moves/", + "skills/name/", + "skills/relay-setup/", + "skills/reset/", + "skills/setup/", + "skills/tkm/", + "skills/uninstall/", + "data/", + "hooks/", + "cries/", + "sprites/", + "sfx/", + "README.md", + "README.ko.md", + "LICENSE" + ], "repository": { "type": "git", "url": "https://github.com/ThunderConch/tkm.git" diff --git a/skills/test-evolve/SKILL.md b/skills/test-evolve/SKILL.md new file mode 100644 index 00000000..7fce0885 --- /dev/null +++ b/skills/test-evolve/SKILL.md @@ -0,0 +1,64 @@ +--- +description: "Dev-only: manual test harness for the evolution AskUserQuestion flow. Backs up state, seeds a scenario party, auto-verifies + auto-restores after the user completes the evolution prompt." +--- + +Dev-only test harness for the evolution AskUserQuestion flow. No tmux, no spawning — the user triggers the evolution prompt manually in this live session. Verify and restore run automatically once the evolution cycle completes; the user only has to pick the scenario and click through the `AskUserQuestion` UI. + +```bash +P="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/marketplaces/tkm 2>/dev/null || ls -d ~/.claude/plugins/cache/tkm/tkm/*/ 2>/dev/null | sort -V | tail -1)}" +``` + +## Dispatch for flag arguments + +- `--list` → `"$P/bin/tsx-resolve.sh" "$P/src/cli/test-evolve.ts" --list`, show output, stop. +- `--restore` → same, `--restore`. Emergency cleanup when an earlier cycle did not auto-restore (e.g. the session was killed mid-test). +- `--help` → same, `--help`. +- `--verify` is present in the CLI but should not be invoked directly by users; it is called automatically as part of the lifecycle below. + +## Scenario lifecycle (when `$ARGUMENTS` is a scenario name) + +### Step 1 — setup (this turn) + +1. Run: `"$P/bin/tsx-resolve.sh" "$P/src/cli/test-evolve.ts" --setup ${ARGUMENTS}` +2. Show the setup output verbatim. +3. Tell the user, verbatim: + + > Party seeded for **${ARGUMENTS}**. Send any short message to trigger the Stop-hook evolution prompt. After you click an option (or `Refuse`), I'll auto-verify and auto-restore. + +4. Stop the turn here. Do **not** pre-run verify or restore. + +### Step 2 — user-triggered evolution turn + +When the user sends any message after setup, the Stop hook emits `{"decision":"block", "reason": ...}` and Claude Code feeds the block's `reason` field back as the next turn's instruction. **Within that same turn you MUST complete the entire cycle below without stopping early** — do not wait for another user turn between steps, do not acknowledge and stop before the cycle finishes. + +**In order, without pausing:** + +1. Render the evolution prompt. Call `AskUserQuestion` exactly as the block reason directs (one subquestion per pokemon; question text copied verbatim; up to 4 buttons with the 3-eligible-plus-Refuse rule; remaining targets listed inline). +2. When the user answers: + - **Button picking a target** → resolve the button label to its target id if needed, then run `"$P/bin/tsx-resolve.sh" "$P/src/cli/tokenmon.ts" evolve ` in a single Bash call. + - **`Refuse` button** or **Other containing `refuse`/`no`/`cancel`/`거부`** → skip the evolve call. + - **Other containing a pokemon name** → validate it against the candidate's `All evolution targets` list; if it matches, run evolve with the resolved target; if it does not match, reply with a short "I didn't recognize that" and re-invoke the same `AskUserQuestion` (max two re-prompts, then treat as Refuse). +3. After the evolve call returns (or after the refuse path settles), print a one-line summary to the user that names the pokemon and its new form (or says "refused") so they see that their pick took effect. +4. **Immediately**, in the same turn, run: + 1. `"$P/bin/tsx-resolve.sh" "$P/src/cli/test-evolve.ts" --verify` + 2. `"$P/bin/tsx-resolve.sh" "$P/src/cli/test-evolve.ts" --restore` +5. Show a compact final report: scenario name, user's pick, verify verdict (PASS / FAIL with any failing fields), and restore confirmation. + +**Critical:** `--restore` must run even when `--verify` reports FAIL. The user's real state/config/hooks.json only become safe again after the restore completes. + +## Usage + +| Command | Behavior | +|---------|---------| +| `/tkm:test-evolve branch-eevee` | Setup → user triggers → auto verify + auto restore | +| `/tkm:test-evolve --list` | List all 6 scenarios | +| `/tkm:test-evolve --restore` | Emergency restore (only needed if auto-restore was skipped) | + +## Scenarios (see `src/test-scenarios/*.json`) + +- `branch-eevee` — full 8-way branch, 3 eligible via stones + 2 via friendship; exercises the overflow rule +- `single-charmander` — single-chain, expect Charmeleon +- `multi-3` — 3 pokemon ready, batch in one `AskUserQuestion` +- `overflow-5` — 5 pokemon ready, first 4 this turn, 5th deferred +- `refuse-persist` — user refuses, verify `evolution_prompt_shown` is set +- `accept-clear-reprompt` — accept → flag cleared on the new pokemon key diff --git a/src/cli/test-evolve.ts b/src/cli/test-evolve.ts new file mode 100644 index 00000000..57638d28 --- /dev/null +++ b/src/cli/test-evolve.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env -S npx tsx +/** + * test-evolve.ts — Dev-only manual test harness for the evolution AskUserQuestion flow. + * + * Subcommands: + * --list list all scenarios + * --setup backup, swap hooks.json, seed state/config + * --verify compare live state vs scenario expected_after + * --restore restore backup and clean up current.json + * --help print usage + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join, resolve } from 'path'; +import { DATA_DIR, configPath, getActiveGeneration, statePath } from '../core/paths.js'; +import { createBackup, restoreBackup, swapHooksJson } from '../test-evolve/backup.js'; +import { verifyState, type Scenario } from '../test-evolve/verify.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, '..', '..'); +const SCENARIOS_DIR = join(REPO_ROOT, 'src', 'test-scenarios'); +const CURRENT_PTR = join(DATA_DIR, 'test-backup', 'current.json'); + +interface CurrentPtr { backupDir: string; scenario: string; gen: string } + +// ── Scenario loading ── + +function loadScenarios(): Scenario[] { + if (!existsSync(SCENARIOS_DIR)) throw new Error(`test-evolve: scenarios dir missing: ${SCENARIOS_DIR}`); + return readdirSync(SCENARIOS_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(readFileSync(join(SCENARIOS_DIR, f), 'utf-8')) as Scenario); +} + +function loadScenarioByName(name: string): Scenario { + const path = join(SCENARIOS_DIR, `${name}.json`); + if (!existsSync(path)) throw new Error(`test-evolve: scenario not found: ${name} (expected at ${path})`); + return JSON.parse(readFileSync(path, 'utf-8')) as Scenario; +} + +// ── Seed writer ── + +function writeSeed(gen: string, scenario: Scenario, backupDir: string): void { + const sfile = statePath(gen); + const cfile = configPath(gen); + mkdirSync(dirname(sfile), { recursive: true }); + mkdirSync(dirname(cfile), { recursive: true }); + + const sBackup = join(backupDir, 'state.json'); + const cBackup = join(backupDir, 'config.json'); + const baseState: Record = existsSync(sBackup) + ? JSON.parse(readFileSync(sBackup, 'utf-8')) : {}; + const baseConfig: Record = existsSync(cBackup) + ? JSON.parse(readFileSync(cBackup, 'utf-8')) : {}; + + // Merge scenario items on top of backed-up state.items so the evolve CLI's + // condition check sees the needed stones/held-items. Backs-up take + // precedence only for keys the scenario did not set. + const mergedItems = { + ...(baseState.items as Record | undefined ?? {}), + ...(scenario.seed.items ?? {}), + }; + + writeFileSync(sfile, JSON.stringify({ + ...baseState, + pokemon: scenario.seed.pokemon, + unlocked: scenario.seed.unlocked, + items: mergedItems, + }, null, 2), 'utf-8'); + + const configOverlay: Record = { + ...baseConfig, + party: scenario.seed.party, + starter_chosen: true, + }; + if (scenario.seed.current_region) configOverlay.current_region = scenario.seed.current_region; + writeFileSync(cfile, JSON.stringify(configOverlay, null, 2), 'utf-8'); +} + +// ── Subcommands ── + +function doList(): void { + const scenarios = loadScenarios(); + process.stdout.write(`\ntest-evolve scenarios (${scenarios.length}):\n\n`); + for (const s of scenarios) { + const readyCount = Object.values(s.seed.pokemon).filter((p: any) => p?.evolution_ready).length; + process.stdout.write(` ${s.name.padEnd(26)} ${s.description}\n`); + process.stdout.write(` ${''.padEnd(26)} party=${s.seed.party.join(',')} ready=${readyCount} choice=${s.expected_choice}\n\n`); + } +} + +function doSetup(scenarioName: string): void { + const gen = getActiveGeneration(); + const scenario = loadScenarioByName(scenarioName); + const backup = createBackup(gen); + process.stdout.write(`test-evolve: backup @ ${backup.dir}\n`); + + const swap = swapHooksJson(REPO_ROOT); + process.stdout.write(`test-evolve: hooks.json swapped (mode=${swap.mode} path=${swap.hooksPath})\n`); + + writeSeed(gen, scenario, backup.dir); + process.stdout.write(`test-evolve: state seeded for scenario "${scenarioName}"\n`); + + mkdirSync(dirname(CURRENT_PTR), { recursive: true }); + const ptr: CurrentPtr = { backupDir: backup.dir, scenario: scenarioName, gen }; + writeFileSync(CURRENT_PTR, JSON.stringify(ptr, null, 2), 'utf-8'); + process.stdout.write(`test-evolve: pointer written to ${CURRENT_PTR}\n`); + process.stdout.write(`\nReady. Send any short message to trigger the evolution prompt.\nWhen done: tokenmon test-evolve --verify then tokenmon test-evolve --restore\n`); +} + +function doVerify(): void { + if (!existsSync(CURRENT_PTR)) { + process.stderr.write('test-evolve --verify: no current.json found. Run --setup first.\n'); + process.exit(1); + } + const ptr = JSON.parse(readFileSync(CURRENT_PTR, 'utf-8')) as CurrentPtr; + const scenario = loadScenarioByName(ptr.scenario); + const result = verifyState(scenario, ptr.gen); + + process.stdout.write(`\ntest-evolve verify: scenario=${ptr.scenario}\n`); + for (const [field, expected] of Object.entries(scenario.expected_after)) { + const diff = result.diffs.find((d) => d.field === field || d.field.startsWith(`${field}[`)); + const pass = !diff; + process.stdout.write(` ${pass ? 'PASS' : 'FAIL'} ${field}: expected=${JSON.stringify(expected)}${diff ? ` actual=${JSON.stringify(diff.actual)}` : ''}\n`); + } + process.stdout.write(`\nOverall: ${result.pass ? 'PASS' : `FAIL (${result.diffs.length} diff(s))`}\n\n`); + if (!result.pass) process.exit(1); +} + +function doRestore(): void { + if (!existsSync(CURRENT_PTR)) { + process.stderr.write('test-evolve --restore: no current.json found. Nothing to restore.\n'); + process.exit(1); + } + const ptr = JSON.parse(readFileSync(CURRENT_PTR, 'utf-8')) as CurrentPtr; + restoreBackup(ptr.backupDir, ptr.gen); + process.stdout.write(`test-evolve: restored from ${ptr.backupDir}\n`); + unlinkSync(CURRENT_PTR); + process.stdout.write('test-evolve: current.json removed. Restore complete.\n'); +} + +function printHelp(): void { + process.stdout.write([ + 'test-evolve — dev-only manual harness for the evolution AskUserQuestion flow', + '', + 'Usage:', + ' tokenmon test-evolve --list list all scenarios', + ' tokenmon test-evolve --setup backup + seed + swap hooks.json', + ' tokenmon test-evolve --verify compare live state vs expected_after', + ' tokenmon test-evolve --restore restore backup, remove current.json', + ' tokenmon test-evolve --help show this help', + '', + ].join('\n')); +} + +// ── Main ── + +function main(): void { + const argv = process.argv.slice(2); + const flag = argv[0]; + + if (!flag || flag === '--help' || flag === '-h') { printHelp(); return; } + if (flag === '--list') { doList(); return; } + if (flag === '--setup') { + const name = argv[1]; + if (!name) { process.stderr.write('test-evolve --setup: scenario name required\n'); process.exit(1); } + doSetup(name); return; + } + if (flag === '--verify') { doVerify(); return; } + if (flag === '--restore') { doRestore(); return; } + + process.stderr.write(`test-evolve: unknown subcommand: ${flag}\nRun with --help for usage.\n`); + process.exit(1); +} + +main(); diff --git a/src/cli/tokenmon.ts b/src/cli/tokenmon.ts index 8e6b2eda..09bd97ee 100644 --- a/src/cli/tokenmon.ts +++ b/src/cli/tokenmon.ts @@ -4,14 +4,14 @@ import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { readState, readCommonState, writeState } from '../core/state.js'; import { readConfig, writeConfig, getDefaultConfig, readGlobalConfig, writeGlobalConfig } from '../core/config.js'; -import { getPokemonDB, getAchievementsDB, getCommonAchievementsDB, getAchievementName, getAchievementDescription, getAchievementRarityLabel, getRegionName, getRegionDescription, getPokemonName, getGenerationsDB, invalidateGenCache, pokemonIdByName, resolveNameToId, getDisplayName, formatMetInfo } from '../core/pokemon-data.js'; +import { getPokemonDB, getAchievementsDB, getCommonAchievementsDB, getAchievementName, getAchievementDescription, getAchievementRarityLabel, getRegionName, getRegionDescription, getPokemonName, getGenerationsDB, invalidateGenCache, pokemonIdByName, resolveNameToId, getDisplayName, formatMetInfo, ensurePokemonInDB } from '../core/pokemon-data.js'; import { levelToXp } from '../core/xp.js'; import { playCry } from '../audio/play-cry.js'; import { getCompletion, getPokedexList, syncPokedexFromUnlocked, getRegionSummary } from '../core/pokedex.js'; import { getBoxList } from '../core/box.js'; import { getCurrentRegion, getRegionList, moveToRegion } from '../core/regions.js'; import { renderGuide, renderGuideIndex } from '../core/guide.js'; -import { getEligibleBranches, applyBranchEvolution } from '../core/evolution.js'; +import { getEligibleBranches, applyBranchEvolution, applySingleChainEvolution, checkEvolution } from '../core/evolution.js'; import { getActiveNotifications, dismissAll } from '../core/notifications.js'; import { getActiveEvents } from '../core/encounter.js'; import { getEventsDB, getRegionsDB, getPokedexRewardsDB } from '../core/pokemon-data.js'; @@ -957,6 +957,10 @@ function cmdCheat(subcmd: string, arg1?: string, arg2?: string): void { function cmdEvolve(pokemonArg?: string, targetArg?: string): void { if (pokemonArg) pokemonArg = resolvePokemonArg(pokemonArg); + // Also resolve the target so the user (or Claude) may pass a localized name + // (e.g., "샤미드") instead of the numeric ID — branch.name values in the + // database are IDs, so the downstream string-equal comparison needs the ID. + if (targetArg) targetArg = resolvePokemonArg(targetArg); const config = readConfig(); const state = readState(); const pokemonDB = getPokemonDB(); @@ -997,6 +1001,29 @@ function cmdEvolve(pokemonArg?: string, targetArg?: string): void { unlockedAchievements: Object.keys(state.achievements).filter(k => state.achievements[k]), items: state.items ?? {}, }; + + // Single-chain path: data.evolves_to is a string (or legacy line[stage+1]). + // cmdEvolve originally only handled branch evolutions, so single-chain + // pokemon reached the AskUserQuestion prompt from the Stop-hook block but + // could not actually complete the evolve. Route single-chain through + // checkEvolution (no state → returns an EvolutionResult with the resolved + // target) and then executeEvolve's dispatcher, which calls + // applySingleChainEvolution. + const baseData = pokemonDB.pokemon[toBaseId(pokemonArg)] ?? ensurePokemonInDB(toBaseId(pokemonArg)); + if (baseData && !Array.isArray(baseData.evolves_to)) { + const result = checkEvolution(pokemonArg, ctx); + if (!result) { + warn(t('cli.evolve.no_eligible', { pokemon: getPokemonName(pokemonArg) })); + return; + } + if (targetArg && targetArg !== result.newPokemon) { + error(t('cli.evolve.invalid_target', { target: targetArg })); + return; + } + executeEvolve(pokemonArg, result.newPokemon, config); + return; + } + const branches = getEligibleBranches(pokemonArg, ctx); // UX-only: hide branches whose evolved form is already in unlocked (safety guards are in checkEvolution/applyBranchEvolution) const eligible = branches.filter(b => { @@ -1070,8 +1097,26 @@ function executeEvolve(pokemonName: string, targetName: string, _config: unknown const evolveResult = withLock(() => { const freshState = readState(); const freshConfig = readConfig(); - const result = applyBranchEvolution(freshState, freshConfig, pokemonName, targetName); + const db = getPokemonDB(); + const data = db.pokemon[toBaseId(pokemonName)]; + + let result = null; + if (data && Array.isArray(data.evolves_to)) { + // Branch evolution (e.g., Kirlia -> Gardevoir/Gallade) + result = applyBranchEvolution(freshState, freshConfig, pokemonName, targetName); + } else { + // Single-chain evolution (e.g., Turtwig -> Grotle) + result = applySingleChainEvolution(freshState, freshConfig, pokemonName, targetName); + } + if (!result) return { ok: false as const }; + + // Clear evolution_prompt_shown on the new pokemon key (if carried over) + const newKey = isShinyKey(pokemonName) ? toShinyKey(result.newPokemon) : result.newPokemon; + if (freshState.pokemon[newKey]) { + freshState.pokemon[newKey].evolution_prompt_shown = undefined; + } + writeState(freshState); writeConfig(freshConfig); return { ok: true as const, result }; diff --git a/src/core/evolution.ts b/src/core/evolution.ts index 9cef0e33..538f5790 100644 --- a/src/core/evolution.ts +++ b/src/core/evolution.ts @@ -1,6 +1,6 @@ import { getPokemonDB, parseCrossGenRef, ensurePokemonInDB } from './pokemon-data.js'; import { isShinyKey, toBaseId, toShinyKey } from './shiny-utils.js'; -import type { State, Config, EvolutionResult, EvolutionContext, BranchEvolution } from './types.js'; +import type { State, Config, EvolutionResult, EvolutionContext, BranchEvolution, PokemonState } from './types.js'; const FRIENDSHIP_THRESHOLD = 220; @@ -10,6 +10,19 @@ export interface BranchInfo { conditionLabel: string; } +/** + * Mark a pokemon ready for evolution prompt. Returns true if already prompted + * (caller should return null). Used by both single-chain paths in checkEvolution. + */ +function markEvolutionReady(pState: PokemonState, target: string): boolean { + if (pState.evolution_prompt_shown) return true; + if (!pState.evolution_ready) { + pState.evolution_ready = true; + pState.evolution_options = [target]; + } + return false; +} + /** * Check if a pokemon should evolve given the current context. * Supports: level, friendship, trade (achievement proxy), item, region. @@ -24,7 +37,8 @@ export function checkEvolution( state?: State, ): EvolutionResult | null { const db = getPokemonDB(); - const data = db.pokemon[toBaseId(pokemonName)]; + const baseId = toBaseId(pokemonName); + const data = db.pokemon[baseId] ?? ensurePokemonInDB(baseId) ?? undefined; if (!data) return null; // Branching evolution: block auto-evolve, set flags on state. @@ -40,7 +54,7 @@ export function checkEvolution( }); const conditionMet = filtered.filter(b => b.conditionMet); const pState = state.pokemon[pokemonName]; - if (pState) { + if (pState && !pState.evolution_prompt_shown) { if (conditionMet.length > 0 && !pState.evolution_ready) { pState.evolution_ready = true; pState.evolution_options = conditionMet.map(b => b.name); @@ -64,6 +78,9 @@ export function checkEvolution( if (crossRef) { targetName = crossRef.id; targetData = ensurePokemonInDB(targetName) ?? undefined; + } else if (!targetData) { + // Plain ID that's not in the active gen's db — try cross-gen injection. + targetData = ensurePokemonInDB(targetName) ?? undefined; } if (!targetData) return null; @@ -82,6 +99,14 @@ export function checkEvolution( } else { return null; } + + // Flag-based flow when state is provided (mirrors branch evolution pattern) + if (state) { + const pState = state.pokemon[pokemonName]; + if (pState) markEvolutionReady(pState, targetName); + return null; + } + return { oldPokemon: pokemonName, newPokemon: targetName, newId: targetData.id, level: context.newLevel }; } @@ -89,7 +114,7 @@ export function checkEvolution( const nextStage = data.stage + 1; if (nextStage >= data.line.length) return null; const nextPokemon = data.line[nextStage]; - const nextData = db.pokemon[nextPokemon]; + const nextData = db.pokemon[nextPokemon] ?? ensurePokemonInDB(nextPokemon) ?? undefined; if (!nextData) return null; // Block re-evolution if direct evolved form already in unlocked @@ -105,6 +130,13 @@ export function checkEvolution( const triggered = checkCondition(condition, context); if (!triggered) return null; + // Flag-based flow when state is provided + if (state) { + const pState = state.pokemon[pokemonName]; + if (pState) markEvolutionReady(pState, nextPokemon); + return null; + } + return { oldPokemon: pokemonName, newPokemon: nextPokemon, @@ -116,6 +148,13 @@ export function checkEvolution( // Level-based evolution (default) if (data.evolves_at == null) return null; if (context.newLevel >= data.evolves_at && context.oldLevel < data.evolves_at) { + // Flag-based flow when state is provided + if (state) { + const pState = state.pokemon[pokemonName]; + if (pState) markEvolutionReady(pState, nextPokemon); + return null; + } + return { oldPokemon: pokemonName, newPokemon: nextPokemon, @@ -135,7 +174,10 @@ export function getEligibleBranches( context: EvolutionContext, ): BranchInfo[] { const db = getPokemonDB(); - const data = db.pokemon[toBaseId(pokemonName)]; + const baseId = toBaseId(pokemonName); + // Cross-gen fallback: load the source pokemon into the active generation's + // DB when it originates from another gen (e.g. Eevee in a gen4 save). + const data = db.pokemon[baseId] ?? ensurePokemonInDB(baseId) ?? undefined; if (!data || !Array.isArray(data.evolves_to)) return []; return (data.evolves_to as BranchEvolution[]).map(branch => ({ @@ -155,13 +197,15 @@ export function applyBranchEvolution( targetName: string, ): EvolutionResult | null { const db = getPokemonDB(); - const data = db.pokemon[toBaseId(pokemonName)]; + const baseId = toBaseId(pokemonName); + const data = db.pokemon[baseId] ?? ensurePokemonInDB(baseId) ?? undefined; if (!data || !Array.isArray(data.evolves_to)) return null; const branch = (data.evolves_to as BranchEvolution[]).find(b => b.name === targetName); if (!branch) return null; - const targetData = db.pokemon[targetName]; + // Cross-gen fallback for the target data too (Vaporeon etc. may live in gen1). + const targetData = db.pokemon[targetName] ?? ensurePokemonInDB(targetName) ?? undefined; if (!targetData) return null; // Block re-evolution if direct evolved form already in unlocked (defense-in-depth) @@ -183,6 +227,78 @@ export function applyBranchEvolution( // Clear branching flags pState.evolution_ready = undefined; pState.evolution_options = undefined; + pState.evolution_prompt_shown = undefined; + + return result; +} + +/** + * Apply a user-selected single-chain evolution (string `evolves_to` or legacy line[stage+1]). + * Mirrors applyBranchEvolution for non-branching pokemon. + */ +export function applySingleChainEvolution( + state: State, + config: Config, + pokemonName: string, + targetName: string, +): EvolutionResult | null { + const db = getPokemonDB(); + const baseId = toBaseId(pokemonName); + const data = db.pokemon[baseId] ?? ensurePokemonInDB(baseId) ?? undefined; + if (!data) return null; + + // Must be single-chain (not branching) + if (Array.isArray(data.evolves_to)) return null; + + // Validate target: either string evolves_to (optionally cross-gen) or legacy line[stage+1] + let resolvedTarget: string | null = null; + let targetData: typeof db.pokemon[string] | undefined; + + if (typeof data.evolves_to === 'string') { + resolvedTarget = data.evolves_to; + targetData = db.pokemon[resolvedTarget]; + const crossRef = parseCrossGenRef(resolvedTarget); + if (crossRef) { + resolvedTarget = crossRef.id; + targetData = ensurePokemonInDB(resolvedTarget) ?? undefined; + } else if (!targetData) { + // Plain numeric ID target that only lives in another generation's dex + // (e.g. Charmeleon #5 on a gen4-active save). Pull it in so single-chain + // evolutions complete instead of erroring out after the prompt. + targetData = ensurePokemonInDB(resolvedTarget) ?? undefined; + } + } else { + // Legacy path: line[stage+1] + const nextStage = data.stage + 1; + if (nextStage < data.line.length) { + resolvedTarget = data.line[nextStage]; + targetData = db.pokemon[resolvedTarget] ?? ensurePokemonInDB(resolvedTarget) ?? undefined; + } + } + + if (!resolvedTarget || !targetData) return null; + if (resolvedTarget !== targetName) return null; + + // Block re-evolution if already unlocked (defense-in-depth) + const evolvedKey = isShinyKey(pokemonName) ? toShinyKey(targetName) : targetName; + if (state.unlocked.includes(evolvedKey)) return null; + + const pState = state.pokemon[pokemonName]; + if (!pState) return null; + + const result: EvolutionResult = { + oldPokemon: pokemonName, + newPokemon: targetName, + newId: targetData.id, + level: pState.level, + }; + + applyEvolution(state, config, result, pState.xp); + + // Clear flags + pState.evolution_ready = undefined; + pState.evolution_options = undefined; + pState.evolution_prompt_shown = undefined; return result; } diff --git a/src/core/notifications.ts b/src/core/notifications.ts index 59b22177..15773dec 100644 --- a/src/core/notifications.ts +++ b/src/core/notifications.ts @@ -16,6 +16,7 @@ export function checkPendingNotifications(state: State, config: Config, commonSt // 1. Evolution ready for (const [name, pState] of Object.entries(state.pokemon)) { if (!pState.evolution_ready) continue; + if (pState.evolution_prompt_shown) continue; // already prompted via stop block if (!config.party.includes(name)) continue; const id = `evolution_ready:${name}`; if (state.dismissed_notifications.includes(id)) continue; diff --git a/src/core/pokemon-data.ts b/src/core/pokemon-data.ts index e897960b..a6302ab4 100644 --- a/src/core/pokemon-data.ts +++ b/src/core/pokemon-data.ts @@ -222,13 +222,29 @@ export function getGameI18n(locale?: string, gen?: string): GameI18nData { return _gameI18n[key]; } +const NAME_LOOKUP_GENS = ['gen1', 'gen2', 'gen3', 'gen4', 'gen5', 'gen6', 'gen7', 'gen8', 'gen9']; + export function getPokemonName(id: string | number, gen?: string, shiny?: boolean): string { const g = gen ?? getActiveGeneration(); getPokemonDB(g); const strId = String(id); const baseId = toBaseId(strId); - const i18n = getGameI18n(undefined, g); - const name = i18n.pokemon[baseId] || baseId; + let name = getGameI18n(undefined, g).pokemon[baseId]; + if (!name) { + // Cross-gen fallback: a pokemon may be displayed in an active gen that + // does not natively index it (e.g. seed data, migration, cross-gen refs). + // Search other gens' i18n so we surface a real name instead of the ID. + for (const og of NAME_LOOKUP_GENS) { + if (og === g) continue; + try { + const hit = getGameI18n(undefined, og).pokemon[baseId]; + if (hit) { name = hit; break; } + } catch { + // gen's data not installed — skip silently + } + } + } + if (!name) name = baseId; if (shiny || isShinyKey(strId)) return '★' + name; return name; } @@ -346,10 +362,20 @@ export function pokemonIdByName(name: string, gen?: string): string | undefined } } - for (const locale of ['ko', 'en']) { - const i18n = getGameI18n(locale, gen); - for (const [id, pokeName] of Object.entries(i18n.pokemon)) { - if (pokeName === name) return id; + // Active generation first, then cross-gen fallback so a localized name + // from another generation's dex (e.g. "이브이" in a gen4-active save) + // still resolves. + const gensToSearch = [gen ?? getActiveGeneration(), ...NAME_LOOKUP_GENS.filter(g => g !== (gen ?? getActiveGeneration()))]; + for (const g of gensToSearch) { + for (const locale of ['ko', 'en']) { + try { + const i18n = getGameI18n(locale, g); + for (const [id, pokeName] of Object.entries(i18n.pokemon)) { + if (pokeName === name) return id; + } + } catch { + // Skip gens with no installed data + } } } return undefined; diff --git a/src/core/types.ts b/src/core/types.ts index 601acf24..88eafa82 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -92,6 +92,7 @@ export interface PokemonState { call_count?: number; evolution_ready?: boolean; evolution_options?: string[]; + evolution_prompt_shown?: boolean; moves?: number[]; met?: MetType; met_detail?: MetDetail; diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index c41086ff..cc82656e 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -5,7 +5,7 @@ import { readState, writeState, pruneSessionTokens, readSessionGenMap, writeSess import { readConfig, writeConfig, readGlobalConfig, writeGlobalConfig } from '../core/config.js'; import { getPokemonDB, getPokemonName, ensurePokemonInDB } from '../core/pokemon-data.js'; import { levelToXp, xpToLevel } from '../core/xp.js'; -import { checkEvolution, applyEvolution, addFriendship, FRIENDSHIP_PER_LEVELUP, FRIENDSHIP_PER_SESSION } from '../core/evolution.js'; +import { checkEvolution, addFriendship, FRIENDSHIP_PER_LEVELUP, FRIENDSHIP_PER_SESSION } from '../core/evolution.js'; import { checkAchievements, checkCommonAchievements, formatAchievementMessage } from '../core/achievements.js'; import { t, initLocale } from '../i18n/index.js'; import type { HookInput, HookOutput, ExpGroup } from '../core/types.js'; @@ -17,7 +17,7 @@ import { addItem, randInt, getDropRateMultiplier } from '../core/items.js'; import { getRegionDropMessage } from '../core/region-messages.js'; import { getVolumeTier, getVolumeTierByName } from '../core/volume-tier.js'; import { withLock, withLockRetry } from '../core/lock.js'; -import { setActiveGenerationCache, getActiveGeneration } from '../core/paths.js'; +import { setActiveGenerationCache, getActiveGeneration, DATA_DIR, PLUGIN_ROOT } from '../core/paths.js'; import { isShinyKey, toBaseId, toShinyKey } from '../core/shiny-utils.js'; import { recordXp, recordBattle, recordCatch, recordEncounter, recordShinyEncounter, recordShinyCatch, recordShinyEscaped } from '../core/stats.js'; import { loadGymData } from '../core/gym.js'; @@ -334,20 +334,10 @@ async function main(): Promise { unlockedAchievements: Object.keys(state.achievements).filter(k => state.achievements[k]), items: state.items ?? {}, }; - const evolution = checkEvolution(pokemonName, evoContext, state); - if (evolution) { - applyEvolution(state, config, evolution, newXp); - messages.push(t('hook.evolution', { pokemon: getPokemonName(pokemonName), newPokemon: getPokemonName(evolution.newPokemon) })); - playSfx('gacha'); - - // Check first_evolution achievement immediately - const achEvents = checkAchievements(state, config, commonState, gen); - for (const achEvent of achEvents) { - const msg = formatAchievementMessage(achEvent); - messages.push(msg); - achievementMessages.push(msg); - } - } + // Flag-based evolution: checkEvolution sets evolution_ready on the state + // for both branch and single-chain evolutions. Auto-evolve no longer happens + // here — block emission in post-lock scan triggers AskUserQuestion flow. + checkEvolution(pokemonName, evoContext, state); } // ── Codex flat XP (no volume tier / rest bonus, normal turn) ── @@ -589,6 +579,87 @@ async function main(): Promise { return; } + // ── Evolution block detection (post-lock, runs regardless of result type) ── + // Scan party for pokemon with evolution_ready && !evolution_prompt_shown. + // If found, emit decision:"block" with a reason instructing Claude to use + // AskUserQuestion. Runs BEFORE the first_stop/no_delta early return so the + // prompt fires on the very first turn of a session where evolution is + // already pending (e.g. after a cheat/test seed, or a resumed session + // where evolution conditions were met but the user had not yet been + // prompted). Flag is set AFTER block emission (Risk 6: duplication > loss). + { + const postConfig = readConfig(gen); + const postState = readState(gen); + const candidates: Array<{ name: string; options: string[] }> = []; + for (const name of postConfig.party) { + const ps = postState.pokemon[name]; + if (ps?.evolution_ready && !ps.evolution_prompt_shown) { + candidates.push({ name, options: ps.evolution_options ?? [] }); + } + } + if (candidates.length > 0) { + const batch = candidates.slice(0, 4); + const candidateList = batch + .map(c => t('hook.evolution_candidate_line', { + pokemon: getPokemonName(c.name), + targets: c.options.map(o => getPokemonName(o)).join(', '), + })) + .join('\n'); + let reason = t('hook.evolution_block_reason', { candidateList }); + // Test-harness awareness: when the dev /tkm:test-evolve harness has an + // active cycle (marker file at .tokenmon/test-backup/current.json), + // tack on the verify + restore instructions so Claude closes the + // cycle inside the same turn even if the skill-invocation context + // has fallen out of the current prompt window. + try { + const currentPtr = join(DATA_DIR, 'test-backup', 'current.json'); + if (existsSync(currentPtr)) { + const tsxResolve = join(PLUGIN_ROOT, 'bin', 'tsx-resolve.sh'); + const testCli = join(PLUGIN_ROOT, 'src', 'cli', 'test-evolve.ts'); + reason += '\n\n[TEST HARNESS ACTIVE] ' + + 'After you run tokenmon evolve (or after a Refuse), you MUST, in the same turn without stopping, run exactly:\n' + + ` "${tsxResolve}" "${testCli}" --verify\n` + + ` "${tsxResolve}" "${testCli}" --restore\n` + + 'Show both outputs to the user, then print a compact final report (scenario name, pick, verify verdict, restore confirmation).'; + } + } catch { + // Harness check is best-effort; missing paths/stat errors must not + // suppress the core evolution block. + } + playCry(); + // Preserve level-up/achievement messages from the parent lock; systemMessage is + // user-facing only, so merging it here does not interfere with the block reason + // that Claude consumes. + const blockOutput: { decision: 'block'; reason: string; system_message?: string } = { + decision: 'block', + reason, + }; + if (messages.length > 0) { + blockOutput.system_message = messages.join('\n'); + } + console.log(JSON.stringify(blockOutput)); + + // Set evolution_prompt_shown AFTER block emission to avoid silent loss on crash. + // If this write fails, the block will re-emit on next Stop — duplicate prompt + // (UX degradation) is strictly preferable to silent infinite-block loops. + try { + const lockResult = withLock(() => { + const s = readState(gen); + for (const c of batch) { + if (s.pokemon[c.name]) s.pokemon[c.name].evolution_prompt_shown = true; + } + writeState(s, gen); + }); + if (!lockResult.acquired) { + process.stderr.write('tokenmon stop: lock busy during evolution_prompt_shown write; will re-prompt next stop\n'); + } + } catch (err) { + process.stderr.write(`tokenmon stop: evolution_prompt_shown write failed: ${err}\n`); + } + return; + } + } + if (result.value === 'first_stop' || result.value === 'no_delta') { playCry(); console.log(JSON.stringify(output)); diff --git a/src/i18n/en.json b/src/i18n/en.json index 57dc103d..381517e5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -404,6 +404,8 @@ "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)", diff --git a/src/i18n/en.pokemon.json b/src/i18n/en.pokemon.json index 058d395d..d0452aec 100644 --- a/src/i18n/en.pokemon.json +++ b/src/i18n/en.pokemon.json @@ -371,6 +371,8 @@ "hook.levelup": "⬆️ {pokemon} Lv.{from} grew to Lv.{to}! (XP +{xp})", "hook.evolution": "✨ What? {pokemon} evolved into {newPokemon}!", "hook.party_join": "✨ {pokemon} joined the team!", + "hook.evolution_candidate_line": "- Pokemon: {pokemon}\n Use this question VERBATIM (AskUserQuestion.question, do NOT paraphrase): \"What? {pokemon} is trying to evolve!\"\n All evolution targets: {targets}", + "hook.evolution_block_reason": "What?! Party pokemon are trying 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 Trainer can type one via 'Other'.\n- `multiSelect`: false\n\nCandidates:\n{candidateList}\n\nHandling the Trainer'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 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)", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 8040c17b..076579d5 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -404,6 +404,8 @@ "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)", diff --git a/src/i18n/ko.pokemon.json b/src/i18n/ko.pokemon.json index aa3f47a9..b95b94e9 100644 --- a/src/i18n/ko.pokemon.json +++ b/src/i18n/ko.pokemon.json @@ -371,6 +371,8 @@ "hook.levelup": "⬆️ {pokemon}은(는) Lv.{from}에서 레벨 {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)", diff --git a/src/status-line.ts b/src/status-line.ts index 97916ccf..32ed1f4e 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -596,16 +596,11 @@ function main(): void { print(state.last_drop); } else if (state.last_tip) { print(state.last_tip.text); - } else { - // Show evolution_ready hint for party pokemon with pending branching evolution - for (const pokemonName of config.party) { - const pState = state.pokemon[pokemonName]; - if (pState?.evolution_ready) { - print(t('statusline.evolution_ready', { pokemon: getPokemonName(pokemonName) })); - break; - } - } } + // Note: evolution_ready no longer shows in the status line. The Stop hook + // emits a decision:"block" with an AskUserQuestion instruction on any stop + // where evolution is pending, so surfacing the same pokemon twice (status + // line + block prompt) is redundant noise. // === Tier preview line (independent, always shown when non-normal) === if (state.pending_tier) { diff --git a/src/test-evolve/backup.ts b/src/test-evolve/backup.ts new file mode 100644 index 00000000..441be6ef --- /dev/null +++ b/src/test-evolve/backup.ts @@ -0,0 +1,230 @@ +/** + * backup.ts — Dev-only backup/restore utility for the test-evolve harness. + * + * Backs up the user's live `state.json`, `config.json`, and the installed + * plugin's `hooks/hooks.json` to `.tokenmon/test-backup//`. + * Restores byte-perfect copies on completion or via `--restore`. + * + * `swapHooksJson()` supports BOTH baked-absolute paths (post-install form) AND + * `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` template form for parity + * with `src/setup/postinstall.ts:bakeHookPaths()`. + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { homedir } from 'os'; +import { DATA_DIR, PLUGIN_ROOT, configPath, statePath } from '../core/paths.js'; + +export interface BackupManifest { + timestamp: string; + dir: string; + generation: string; + hooksSource: string; + files: { + state: string; + config: string; + hooks: string; + }; +} + +/** + * Resolve the user's active hooks.json path. Prefers `CLAUDE_PLUGIN_ROOT` + * (via `core/paths.ts:PLUGIN_ROOT` which walks up to find `package.json`), + * then checks the canonical plugin marketplace install location under + * `~/.claude/plugins/marketplaces/tkm/`. Throws if none exist so the dev + * harness fails loudly instead of writing to a non-existent path. + */ +export function getInstalledHooksPath(): string { + const checked: string[] = []; + + const pluginRootHooks = join(PLUGIN_ROOT, 'hooks', 'hooks.json'); + checked.push(pluginRootHooks); + if (existsSync(pluginRootHooks)) return pluginRootHooks; + + const marketplaceHooks = join(homedir(), '.claude', 'plugins', 'marketplaces', 'tkm', 'hooks', 'hooks.json'); + checked.push(marketplaceHooks); + if (existsSync(marketplaceHooks)) return marketplaceHooks; + + // Cached install location: ~/.claude/plugins/cache/tkm/tkm//hooks/hooks.json + // Scan every installed version directory so a release-style install still works. + const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'tkm', 'tkm'); + checked.push(join(cacheBase, '', 'hooks', 'hooks.json')); + if (existsSync(cacheBase)) { + try { + const versions = readdirSync(cacheBase).sort().reverse(); + for (const v of versions) { + const candidate = join(cacheBase, v, 'hooks', 'hooks.json'); + if (existsSync(candidate)) return candidate; + } + } catch { + // Directory read errors fall through to the throw below. + } + } + + throw new Error( + `Cannot locate active hooks.json. Checked:\n${checked.map(p => ` - ${p}`).join('\n')}\n` + + `Set CLAUDE_PLUGIN_ROOT to your tkm install location and retry.`, + ); +} + +/** Byte-copy helper with ancestor mkdir. */ +function byteCopy(src: string, dst: string): void { + mkdirSync(dirname(dst), { recursive: true }); + copyFileSync(src, dst); +} + +/** + * Create a timestamped backup of state, config, and hooks.json. + * @param gen Active generation from `getActiveGeneration()` at call time. + */ +export function createBackup(gen: string): BackupManifest { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const dir = join(DATA_DIR, 'test-backup', timestamp); + mkdirSync(dir, { recursive: true }); + + const statefile = statePath(gen); + const configfile = configPath(gen); + const hooksfile = getInstalledHooksPath(); + + const backupState = join(dir, 'state.json'); + const backupConfig = join(dir, 'config.json'); + const backupHooks = join(dir, 'hooks.json'); + + if (existsSync(statefile)) byteCopy(statefile, backupState); + if (existsSync(configfile)) byteCopy(configfile, backupConfig); + if (existsSync(hooksfile)) byteCopy(hooksfile, backupHooks); + + const manifest: BackupManifest = { + timestamp, + dir, + generation: gen, + hooksSource: hooksfile, + files: { state: backupState, config: backupConfig, hooks: backupHooks }, + }; + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8'); + return manifest; +} + +/** + * Restore all 3 files from a backup directory. Byte-identical restore. + */ +export function restoreBackup(backupDir: string, gen: string): void { + const manifestPath = join(backupDir, 'manifest.json'); + let hooksTarget = getInstalledHooksPath(); + if (existsSync(manifestPath)) { + try { + const m = JSON.parse(readFileSync(manifestPath, 'utf-8')) as BackupManifest; + if (m.hooksSource) hooksTarget = m.hooksSource; + } catch { + /* fall through */ + } + } + + const backupState = join(backupDir, 'state.json'); + const backupConfig = join(backupDir, 'config.json'); + const backupHooks = join(backupDir, 'hooks.json'); + + if (existsSync(backupState)) { + try { + byteCopy(backupState, statePath(gen)); + } catch (err) { + process.stderr.write(`test-evolve restore state: ${err}\n`); + } + } + if (existsSync(backupConfig)) { + try { + byteCopy(backupConfig, configPath(gen)); + } catch (err) { + process.stderr.write(`test-evolve restore config: ${err}\n`); + } + } + if (existsSync(backupHooks)) { + try { + byteCopy(backupHooks, hooksTarget); + } catch (err) { + process.stderr.write(`test-evolve restore hooks: ${err}\n`); + } + } +} + +/** Restore only hooks.json from a backup dir (independent restore for finally blocks). */ +export function restoreHooksJson(backupDir: string): void { + const manifestPath = join(backupDir, 'manifest.json'); + let hooksTarget = getInstalledHooksPath(); + if (existsSync(manifestPath)) { + try { + const m = JSON.parse(readFileSync(manifestPath, 'utf-8')) as BackupManifest; + if (m.hooksSource) hooksTarget = m.hooksSource; + } catch { + /* fall through */ + } + } + const backupHooks = join(backupDir, 'hooks.json'); + if (existsSync(backupHooks)) { + try { + byteCopy(backupHooks, hooksTarget); + } catch (err) { + process.stderr.write(`test-evolve restoreHooksJson: ${err}\n`); + } + } +} + +/** Find the most recent backup directory (lexicographic timestamp sort). */ +export function getLatestBackup(): string | null { + const base = join(DATA_DIR, 'test-backup'); + if (!existsSync(base)) return null; + try { + const entries = readdirSync(base) + .map((name) => ({ name, full: join(base, name) })) + .filter((e) => statSync(e.full).isDirectory()) + .sort((a, b) => b.name.localeCompare(a.name)); + return entries[0]?.full ?? null; + } catch { + return null; + } +} + +/** + * Rewrite hooks.json so all plugin paths point at the worktree. + * + * Detects both forms: + * - Template: `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` present + * - Baked: absolute path prefix (post-install form, where each hook + * command has the plugin's install directory inlined as a literal path) + * + * Writes the rewritten content to the same path. The ORIGINAL is preserved in + * the backup dir via `createBackup()`, so callers MUST create a backup first. + */ +export function swapHooksJson(worktreePath: string): { mode: 'template' | 'baked' | 'noop'; hooksPath: string } { + const hooksPath = getInstalledHooksPath(); + if (!existsSync(hooksPath)) { + return { mode: 'noop', hooksPath }; + } + const original = readFileSync(hooksPath, 'utf-8'); + + // Template form first — preserve parity with postinstall.ts:bakeHookPaths() + if (original.includes('${CLAUDE_PLUGIN_ROOT}') || original.includes('${CLAUDE_PLUGIN_DATA}')) { + const rewritten = original + .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, worktreePath) + .replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, worktreePath); + writeFileSync(hooksPath, rewritten, 'utf-8'); + return { mode: 'template', hooksPath }; + } + + // Baked form: extract the common plugin-root prefix and replace. + // Heuristic: find the first baked absolute path ending in `/bin/tsx-resolve.sh`. + // Terminator is NOT required to be `"` because hook command strings store the + // quote as the JSON escape `\"`, leaving `\` right after `.sh`. Accept anything. + const m = original.match(/(\/(?:[^"\s$\\]|\\(?!["\s$]))+)\/bin\/tsx-resolve\.sh/); + if (m?.[1]) { + const bakedRoot = m[1]; + if (bakedRoot !== worktreePath) { + // Escape regex metacharacters in bakedRoot + const escaped = bakedRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rewritten = original.replace(new RegExp(escaped, 'g'), worktreePath); + writeFileSync(hooksPath, rewritten, 'utf-8'); + return { mode: 'baked', hooksPath }; + } + } + + return { mode: 'noop', hooksPath }; +} diff --git a/src/test-evolve/verify.ts b/src/test-evolve/verify.ts new file mode 100644 index 00000000..407dd5a8 --- /dev/null +++ b/src/test-evolve/verify.ts @@ -0,0 +1,136 @@ +/** + * verify.ts — State-only assertion for test-evolve scenarios. + * + * `verifyState(scenario, gen)` reads the live state.json / config.json and + * compares each `expected_after` field. Returns a structured result with + * per-field diffs so the CLI can print PASS/FAIL per field. + */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +export interface Scenario { + name: string; + description: string; + seed: { + party: string[]; + pokemon: Record; + unlocked: string[]; + /** Optional bag items keyed by canonical item id (e.g. "water-stone"). */ + items?: Record; + /** Optional current_region override on config (e.g. "4") for location-based evolutions. */ + current_region?: string; + }; + expected_block: { + decision: string; + reason_contains: string[]; + }; + expected_choice: string; + expected_after: Record; +} + +export interface StateDiffEntry { + field: string; + expected: unknown; + actual: unknown; +} + +export interface StateVerifyResult { + pass: boolean; + detail: string; + diffs: StateDiffEntry[]; +} + +interface ReadableState { + pokemon?: Record; + unlocked?: string[]; + [k: string]: any; +} + +interface ReadableConfig { + party?: string[]; + [k: string]: any; +} + +function readJsonSafe(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +} + +function getByPath(obj: any, path: string): unknown { + if (!path) return obj; + let cur = obj; + for (const p of path.split('.')) { + if (cur == null) return undefined; + cur = cur[p]; + } + return cur; +} + +function deepEqualOrNull(actual: unknown, expected: unknown): boolean { + if (expected === null) return actual === undefined || actual === null; + return JSON.stringify(actual) === JSON.stringify(expected); +} + +/** + * Read live state.json / config.json and compare against scenario.expected_after. + * + * Supported field forms: + * `pokemon..` — equality (null = field absent) + * `unlocked.includes` — array of ids that MUST be present in state.unlocked + * `unlocked.excludes` — array of ids that MUST NOT be present + * `party.includes` — array of ids that MUST be present in config.party + */ +export function verifyState(scenario: Scenario, gen: string): StateVerifyResult { + const claudeDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'); + const tokenmonDir = join(claudeDir, 'tokenmon', gen); + const state = readJsonSafe(join(tokenmonDir, 'state.json')) ?? {}; + const config = readJsonSafe(join(tokenmonDir, 'config.json')) ?? {}; + + const diffs: StateDiffEntry[] = []; + + for (const [field, expected] of Object.entries(scenario.expected_after)) { + if (field.startsWith('pokemon.')) { + const parts = field.split('.'); + const id = parts[1]; + const key = parts.slice(2).join('.'); + const p = state.pokemon?.[id]; + const actual = p ? getByPath(p, key) : undefined; + if (!deepEqualOrNull(actual, expected)) diffs.push({ field, expected, actual }); + } else if (field === 'unlocked.includes') { + const arr = Array.isArray(expected) ? expected : []; + const unlocked = state.unlocked ?? []; + for (const id of arr) { + if (!unlocked.includes(id)) + diffs.push({ field: `unlocked.includes[${id}]`, expected: true, actual: false }); + } + } else if (field === 'unlocked.excludes') { + const arr = Array.isArray(expected) ? expected : []; + const unlocked = state.unlocked ?? []; + for (const id of arr) { + if (unlocked.includes(id)) + diffs.push({ field: `unlocked.excludes[${id}]`, expected: false, actual: true }); + } + } else if (field === 'party.includes') { + const arr = Array.isArray(expected) ? expected : []; + const party = config.party ?? []; + for (const id of arr) { + if (!party.includes(id)) + diffs.push({ field: `party.includes[${id}]`, expected: true, actual: false }); + } + } else { + const actual = (state as any)[field]; + if (!deepEqualOrNull(actual, expected)) diffs.push({ field, expected, actual }); + } + } + + return { + pass: diffs.length === 0, + detail: diffs.length === 0 ? 'all state assertions passed' : `${diffs.length} diff(s)`, + diffs, + }; +} diff --git a/src/test-scenarios/accept-clear-reprompt.json b/src/test-scenarios/accept-clear-reprompt.json new file mode 100644 index 00000000..4040bb46 --- /dev/null +++ b/src/test-scenarios/accept-clear-reprompt.json @@ -0,0 +1,31 @@ +{ + "name": "accept-clear-reprompt", + "description": "User accepts evolve — evolution_prompt_shown cleared on new pokemon key (tokenmon.ts:1078)", + "seed": { + "party": ["4"], + "pokemon": { + "4": { + "id": 4, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["5"], + "met": "starter" + } + }, + "unlocked": ["4"] + }, + "expected_block": { + "decision": "block", + "reason_contains": ["Charmander", "AskUserQuestion", "tokenmon evolve"] + }, + "expected_choice": "5", + "expected_after": { + "pokemon.5.met": "evolution", + "pokemon.5.evolution_prompt_shown": null, + "unlocked.includes": ["5"], + "party.includes": ["5"], + "pokemon.4.evolution_prompt_shown": null + } +} diff --git a/src/test-scenarios/branch-eevee.json b/src/test-scenarios/branch-eevee.json new file mode 100644 index 00000000..3ae4c39c --- /dev/null +++ b/src/test-scenarios/branch-eevee.json @@ -0,0 +1,36 @@ +{ + "name": "branch-eevee", + "description": "Eevee (#133) branch evolution — 3 eligible branches in the data (Vaporeon/Jolteon/Flareon via stones). All 3 fit as buttons alongside Refuse (4 options total, no overflow). Cross-gen data path: Eevee is gen1-only, so this also exercises the cross-gen ensurePokemonInDB fallback when the save is on gen4+.", + "seed": { + "party": ["133"], + "pokemon": { + "133": { + "id": 133, + "level": 25, + "xp": 15625, + "friendship": 220, + "evolution_ready": true, + "evolution_options": ["134", "135", "136"], + "met": "starter" + } + }, + "unlocked": ["133"], + "items": { + "water-stone": 1, + "thunder-stone": 1, + "fire-stone": 1 + } + }, + "expected_block": { + "decision": "block", + "reason_contains": ["이브이", "Eevee", "AskUserQuestion", "tokenmon evolve"] + }, + "expected_choice": "134", + "expected_after": { + "pokemon.134.met": "evolution", + "unlocked.includes": ["134"], + "unlocked.excludes": [], + "party.includes": ["134"], + "pokemon.133.evolution_prompt_shown": null + } +} diff --git a/src/test-scenarios/multi-3.json b/src/test-scenarios/multi-3.json new file mode 100644 index 00000000..4c048bdb --- /dev/null +++ b/src/test-scenarios/multi-3.json @@ -0,0 +1,50 @@ +{ + "name": "multi-3", + "description": "3 pokemon simultaneously evolution-ready — batch block (stop.ts slice(0,4))", + "seed": { + "party": ["4", "7", "25"], + "pokemon": { + "4": { + "id": 4, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["5"], + "met": "starter" + }, + "7": { + "id": 7, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["8"], + "met": "wild" + }, + "25": { + "id": 25, + "level": 16, + "xp": 4096, + "friendship": 220, + "evolution_ready": true, + "evolution_options": ["26"], + "met": "wild" + } + }, + "unlocked": ["4", "7", "25"], + "items": { "thunder-stone": 1 } + }, + "expected_block": { + "decision": "block", + "reason_contains": ["Charmander", "Squirtle", "Pikachu", "AskUserQuestion"] + }, + "expected_choice": "5", + "expected_after": { + "pokemon.5.met": "evolution", + "unlocked.includes": ["5"], + "unlocked.excludes": [], + "party.includes": ["5"], + "pokemon.4.evolution_prompt_shown": null + } +} diff --git a/src/test-scenarios/overflow-5.json b/src/test-scenarios/overflow-5.json new file mode 100644 index 00000000..24035c30 --- /dev/null +++ b/src/test-scenarios/overflow-5.json @@ -0,0 +1,68 @@ +{ + "name": "overflow-5", + "description": "5 pokemon evolution-ready — only first 4 prompted this turn (batch cap at slice(0,4))", + "seed": { + "party": ["4", "7", "25", "133", "172"], + "pokemon": { + "4": { + "id": 4, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["5"], + "met": "starter" + }, + "7": { + "id": 7, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["8"], + "met": "wild" + }, + "25": { + "id": 25, + "level": 20, + "xp": 8000, + "friendship": 220, + "evolution_ready": true, + "evolution_options": ["26"], + "met": "wild" + }, + "133": { + "id": 133, + "level": 25, + "xp": 15625, + "friendship": 220, + "evolution_ready": true, + "evolution_options": ["134", "135", "136"], + "met": "wild" + }, + "172": { + "id": 172, + "level": 15, + "xp": 3375, + "friendship": 220, + "evolution_ready": true, + "evolution_options": ["25"], + "met": "wild" + } + }, + "unlocked": ["4", "7", "25", "133", "172"], + "items": { "water-stone": 1, "thunder-stone": 1, "fire-stone": 1 } + }, + "expected_block": { + "decision": "block", + "reason_contains": ["AskUserQuestion", "tokenmon evolve"] + }, + "expected_choice": "5", + "expected_after": { + "pokemon.5.met": "evolution", + "unlocked.includes": ["5"], + "unlocked.excludes": [], + "party.includes": ["5"], + "pokemon.172.evolution_prompt_shown": null + } +} diff --git a/src/test-scenarios/refuse-persist.json b/src/test-scenarios/refuse-persist.json new file mode 100644 index 00000000..88c23384 --- /dev/null +++ b/src/test-scenarios/refuse-persist.json @@ -0,0 +1,30 @@ +{ + "name": "refuse-persist", + "description": "User refuses evolution via Other:no — evolution_prompt_shown set, no re-prompt next turn", + "seed": { + "party": ["4"], + "pokemon": { + "4": { + "id": 4, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["5"], + "met": "starter" + } + }, + "unlocked": ["4"] + }, + "expected_block": { + "decision": "block", + "reason_contains": ["Charmander", "AskUserQuestion"] + }, + "expected_choice": "no", + "expected_after": { + "pokemon.4.evolution_prompt_shown": true, + "pokemon.4.evolution_ready": true, + "unlocked.excludes": ["5"], + "party.includes": ["4"] + } +} diff --git a/src/test-scenarios/single-charmander.json b/src/test-scenarios/single-charmander.json new file mode 100644 index 00000000..965ea781 --- /dev/null +++ b/src/test-scenarios/single-charmander.json @@ -0,0 +1,31 @@ +{ + "name": "single-charmander", + "description": "Charmander (#4) single-chain evolution — user accepts Charmeleon (#5)", + "seed": { + "party": ["4"], + "pokemon": { + "4": { + "id": 4, + "level": 16, + "xp": 4096, + "friendship": 70, + "evolution_ready": true, + "evolution_options": ["5"], + "met": "starter" + } + }, + "unlocked": ["4"] + }, + "expected_block": { + "decision": "block", + "reason_contains": ["Charmander", "AskUserQuestion", "tokenmon evolve"] + }, + "expected_choice": "5", + "expected_after": { + "pokemon.5.met": "evolution", + "unlocked.includes": ["5"], + "unlocked.excludes": [], + "party.includes": ["5"], + "pokemon.4.evolution_prompt_shown": null + } +} diff --git a/test/e2e/evolve-askuserquestion.test.ts b/test/e2e/evolve-askuserquestion.test.ts new file mode 100644 index 00000000..b25de847 --- /dev/null +++ b/test/e2e/evolve-askuserquestion.test.ts @@ -0,0 +1,202 @@ +/** + * E2E test: evolution AskUserQuestion via stop hook block emission. + * + * Verifies that when party pokemon have `evolution_ready && !evolution_prompt_shown`, + * the stop hook emits `{decision:"block", reason}` containing the AskUserQuestion + * instruction, and then sets `evolution_prompt_shown=true` on the scanned candidates. + * + * NOTE: Per plan Step 8, the canonical harness is tmux-based (spec AC9 calls for + * full Claude Code session launch). That infrastructure is heavier than the current + * time budget for this PR, so this test uses the `child_process` fallback path the + * plan explicitly permits (Risk 4 mitigation). It isolates the tokenmon data dir + * via `CLAUDE_CONFIG_DIR`, pipes a fake stdin JSON into stop.ts, captures stdout, + * and asserts on the block output. The tmux variant is TODO: see AC9 — the rationale + * is that the actual block JSON contract is fully tested here; tmux only adds coverage + * for the real-session harness integration which is a separate concern. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeState, makeConfig } from '../helpers.js'; +import type { State, Config } from '../../src/core/types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const STOP_HOOK_PATH = join(REPO_ROOT, 'src', 'hooks', 'stop.ts'); + +interface RunOutput { + stdout: string; + stderr: string; + status: number | null; +} + +function runStopHook(dataDir: string, stdinJson: string): RunOutput { + // Write minimal gen-map so session is recognized without session-start hook + const genMapPath = join(dataDir, 'tokenmon', 'session-gen-map.json'); + if (!existsSync(dirname(genMapPath))) mkdirSync(dirname(genMapPath), { recursive: true }); + + const result = spawnSync( + process.execPath, + ['--import', 'tsx', STOP_HOOK_PATH], + { + input: stdinJson, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: dataDir, + CLAUDE_PLUGIN_ROOT: REPO_ROOT, + }, + encoding: 'utf-8', + cwd: REPO_ROOT, + timeout: 15000, + }, + ); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + status: result.status, + }; +} + +function seedState(dataDir: string, gen: string, stateOverrides: Partial, configOverrides: Partial): void { + const genDir = join(dataDir, 'tokenmon', gen); + mkdirSync(genDir, { recursive: true }); + const state = makeState(stateOverrides); + const config = makeConfig(configOverrides); + writeFileSync(join(genDir, 'state.json'), JSON.stringify(state, null, 2)); + writeFileSync(join(genDir, 'config.json'), JSON.stringify(config, null, 2)); + // global config for active generation + const globalConfig = { + active_generation: gen, + language: 'en', + voice_tone: 'claude', + weather_enabled: false, + weather_location: '', + }; + mkdirSync(join(dataDir, 'tokenmon'), { recursive: true }); + writeFileSync(join(dataDir, 'tokenmon', 'global-config.json'), JSON.stringify(globalConfig, null, 2)); + // common state + writeFileSync(join(dataDir, 'tokenmon', 'common_state.json'), JSON.stringify({ + achievements: {}, + encounter_rate_bonus: 0, + xp_bonus_multiplier: 1.0, + items: {}, + max_party_size_bonus: 0, + session_count: 0, + total_tokens_consumed: 0, + battle_count: 0, + battle_wins: 0, + catch_count: 0, + evolution_count: 0, + error_count: 0, + permission_count: 0, + total_gym_badges: 0, + completed_gym_gens: 0, + titles: [], + rare_weight_multiplier: 1.0, + last_codex_tokens_total: 0, + last_turn_ts: Date.now(), + }, null, 2)); +} + +describe('evolve AskUserQuestion via stop hook', () => { + let tmpDir: string; + + before(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'tkm-evolve-e2e-')); + }); + + after(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('block JSON is emitted with AskUserQuestion instruction when candidate is evolution_ready', () => { + const gen = 'gen4'; + const sessionId = 'test-session-evolve-1'; + // Turtwig (387) with evolution_ready already set, not yet prompted + seedState(tmpDir, gen, { + pokemon: { + '387': { + id: 387, xp: 5000, level: 18, friendship: 0, ev: 0, + evolution_ready: true, evolution_options: ['388'], + }, + }, + unlocked: ['387'], + last_session_tokens: { [sessionId]: 1000 }, // avoid first_stop + }, { + party: ['387'], + language: 'en', + }); + + const stdinJson = JSON.stringify({ session_id: sessionId }); + const out = runStopHook(tmpDir, stdinJson); + + assert.equal(out.status, 0, `stop hook should exit 0; stderr: ${out.stderr}`); + // Find the last JSON line in stdout (in case cry or other stdout appears) + const lines = out.stdout.trim().split('\n').filter(l => l.trim().startsWith('{')); + assert.ok(lines.length > 0, `expected JSON output, got: ${out.stdout}`); + const lastLine = lines[lines.length - 1]; + let parsed: any; + try { + parsed = JSON.parse(lastLine); + } catch (e) { + assert.fail(`could not parse JSON line "${lastLine}": ${e}`); + } + + // AC1 / AC2: decision:"block" with reason containing AskUserQuestion instruction + assert.equal(parsed.decision, 'block', `expected decision:"block", got: ${JSON.stringify(parsed)}`); + assert.ok(typeof parsed.reason === 'string', 'reason should be a string'); + assert.match(parsed.reason, /AskUserQuestion/i, 'reason should instruct to call AskUserQuestion'); + assert.match(parsed.reason, /tokenmon evolve/i, 'reason should include the tokenmon evolve command'); + + // Verify flag was set after block emission + const stateAfter = JSON.parse( + readFileSync(join(tmpDir, 'tokenmon', gen, 'state.json'), 'utf-8'), + ); + assert.equal( + stateAfter.pokemon['387'].evolution_prompt_shown, true, + 'evolution_prompt_shown should be set after block emission', + ); + }); + + it('no block when evolution_prompt_shown is already true', () => { + // Fresh tmp dir for isolation + const isolatedDir = mkdtempSync(join(tmpdir(), 'tkm-evolve-e2e-skip-')); + try { + const gen = 'gen4'; + const sessionId = 'test-session-evolve-2'; + seedState(isolatedDir, gen, { + pokemon: { + '387': { + id: 387, xp: 5000, level: 18, friendship: 0, ev: 0, + evolution_ready: true, evolution_options: ['388'], + evolution_prompt_shown: true, // already prompted + }, + }, + unlocked: ['387'], + last_session_tokens: { [sessionId]: 1000 }, + }, { + party: ['387'], + language: 'en', + }); + + const stdinJson = JSON.stringify({ session_id: sessionId }); + const out = runStopHook(isolatedDir, stdinJson); + + assert.equal(out.status, 0); + const lines = out.stdout.trim().split('\n').filter(l => l.trim().startsWith('{')); + const lastLine = lines[lines.length - 1]; + const parsed = JSON.parse(lastLine); + + // Should be a normal continue, not a block + assert.notEqual(parsed.decision, 'block', 'should not block when prompt_shown is true'); + assert.equal(parsed.continue, true, 'should continue normally'); + } finally { + rmSync(isolatedDir, { recursive: true, force: true }); + } + }); +});