Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
25df00c
feat: prompt user for evolution via Stop-hook AskUserQuestion
ThunderConch Apr 20, 2026
fb88020
fix(stop): preserve system_message on evolution block emission
ThunderConch Apr 20, 2026
7f0d832
feat(dev): add /tkm:test-evolve harness for Stop-hook evolution flow
ThunderConch Apr 20, 2026
6a6984a
refactor(test-evolve): simplify harness to manual user-driven flow
ThunderConch Apr 20, 2026
7b1a558
fix(test-evolve): auto verify + auto restore after evolution event
ThunderConch Apr 20, 2026
7f1ac4a
fix(stop): emit evolution block on first stop + drop status-line hint
ThunderConch Apr 20, 2026
72bdd78
fix(i18n): verbatim pokemon-voice question + overflow rule for 4+ bra…
ThunderConch Apr 20, 2026
c4d52c0
fix(i18n): validate Other-input targets before evolve, re-prompt on m…
ThunderConch Apr 20, 2026
8893c4b
fix(evolve+test-evolve): cross-gen name lookup, resolve target, seed …
ThunderConch Apr 20, 2026
6982d7e
fix(test-evolve): restore 8-branch Eevee + force same-turn completion…
ThunderConch Apr 20, 2026
948c11c
fix(evolve): cross-gen ensurePokemonInDB fallback for non-native source
ThunderConch Apr 20, 2026
9f636a1
fix(evolve): single-chain support in cmdEvolve + cross-gen target fal…
ThunderConch Apr 20, 2026
31eb611
fix(stop): append test-harness verify+restore to block reason when ac…
ThunderConch Apr 20, 2026
24aecbf
fix: address review comments (P1 single-chain, P2 hooks cache lookup …
ThunderConch Apr 20, 2026
9fd9215
Merge remote-tracking branch 'origin/master' into feat/evolve-prompt-…
ThunderConch Apr 20, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ config.json
.claude/
.worktrees/
TODO.txt
.tokenmon/test-backup/
38 changes: 38 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 64 additions & 0 deletions skills/test-evolve/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <pokemon> <target>` 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
177 changes: 177 additions & 0 deletions src/cli/test-evolve.ts
Original file line number Diff line number Diff line change
@@ -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 <scenario> 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<string, unknown> = existsSync(sBackup)
? JSON.parse(readFileSync(sBackup, 'utf-8')) : {};
const baseConfig: Record<string, unknown> = 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<string, number> | 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<string, unknown> = {
...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 <scenario> 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();
51 changes: 48 additions & 3 deletions src/cli/tokenmon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading