feat: 集成 Battle tab 到 BuddyPanel,重命名 data/ 为 dex/ 规避 gitignore

- BuddyPanel 新增 Battle tab,BattleFlow 加 isActive 控制
- BattleFlow configSelect 阶段支持 ↑↓ 选择物种
- packages/pokemon/src/data/ → dex/,解决根 .gitignore 匹配问题
- 全量 Tab→2空格 缩进转换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 08:35:19 +08:00
parent 25067e78af
commit f22caf0e97
62 changed files with 4699 additions and 4476 deletions

View File

@@ -7,7 +7,6 @@
"types": "./src/index.ts",
"dependencies": {
"@pkmn/client": "^0.7.2",
"@pkmn/protocol": "^0.7.2",
"@pkmn/view": "^0.7.2"
"@pkmn/protocol": "^0.7.2"
}
}

View File

@@ -5,465 +5,465 @@ import { chooseAIMove } from '../battle/ai'
import type { Creature, BuddyData } from '../types'
function makeTestCreature(overrides: Partial<Creature> = {}): Creature {
return {
id: overrides.id ?? 'test-1',
speciesId: overrides.speciesId ?? 'charmander',
gender: overrides.gender ?? 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: overrides.nature ?? 'adamant',
ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: overrides.moves ?? [
{ id: 'flamethrower', pp: 15, maxPp: 15 },
{ id: 'airslash', pp: 15, maxPp: 15 },
{ id: 'dragontail', pp: 10, maxPp: 10 },
{ id: 'slash', pp: 20, maxPp: 20 },
],
ability: overrides.ability ?? 'blaze',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
return {
id: overrides.id ?? 'test-1',
speciesId: overrides.speciesId ?? 'charmander',
gender: overrides.gender ?? 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: overrides.nature ?? 'adamant',
ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: overrides.moves ?? [
{ id: 'flamethrower', pp: 15, maxPp: 15 },
{ id: 'airslash', pp: 15, maxPp: 15 },
{ id: 'dragontail', pp: 10, maxPp: 10 },
{ id: 'slash', pp: 20, maxPp: 20 },
],
ability: overrides.ability ?? 'blaze',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData {
return {
version: 2,
party: [creatures[0]!.id, null, null, null, null, null],
boxes: [],
creatures: creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
return {
version: 2,
party: [creatures[0]!.id, null, null, null, null, null],
boxes: [],
creatures: creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
describe('createBattle', () => {
test('creates battle with valid initial state', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state).toBeDefined()
expect(init.state.playerPokemon).toBeDefined()
expect(init.state.opponentPokemon).toBeDefined()
expect(init.state.finished).toBe(false)
})
test('creates battle with valid initial state', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state).toBeDefined()
expect(init.state.playerPokemon).toBeDefined()
expect(init.state.opponentPokemon).toBeDefined()
expect(init.state.finished).toBe(false)
})
test('player pokemon has correct species', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'bulbasaur', 30)
expect(init.state.playerPokemon.speciesId).toBe('charmander')
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
})
test('player pokemon has correct species', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'bulbasaur', 30)
expect(init.state.playerPokemon.speciesId).toBe('charmander')
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
})
test('player pokemon has moves', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
})
test('player pokemon has moves', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
})
})
describe('executeTurn', () => {
test('move action generates events', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const initialEventCount = init.state.events.length
test('move action generates events', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const initialEventCount = init.state.events.length
const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
})
const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
})
test('battle eventually ends within 50 turns', () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
const init = createBattle([creature], 'squirtle', 5)
test('battle eventually ends within 50 turns', () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
const init = createBattle([creature], 'squirtle', 5)
let state = init.state
for (let i = 0; i < 50 && !state.finished; i++) {
state = executeTurn(init, { type: 'move', moveIndex: 0 })
}
let state = init.state
for (let i = 0; i < 50 && !state.finished; i++) {
state = executeTurn(init, { type: 'move', moveIndex: 0 })
}
expect(state.finished).toBe(true)
})
expect(state.finished).toBe(true)
})
})
describe('settleBattle', () => {
test('player win increments battlesWon', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'player' as const,
turns: 5,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
test('player win increments battlesWon', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'player' as const,
turns: 5,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
})
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
})
test('player loss returns unchanged data', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
test('player loss returns unchanged data', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
// Loss early-returns unchanged data
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
expect(settlement.learnableMoves).toEqual([])
expect(settlement.pendingEvolutions).toEqual([])
})
const settlement = await settleBattle(data, result, 'squirtle', 20)
// Loss early-returns unchanged data
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
expect(settlement.learnableMoves).toEqual([])
expect(settlement.pendingEvolutions).toEqual([])
})
})
describe('applyMoveLearn', () => {
test('replaces move at given index', () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
})
test('replaces move at given index', () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
})
})
describe('applyEvolution', () => {
test('evolves charmander to charmeleon and increments counter', () => {
const creature = makeTestCreature({ speciesId: 'charmander' })
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
expect(updated.stats.totalEvolutions).toBe(1)
})
test('evolves charmander to charmeleon and increments counter', () => {
const creature = makeTestCreature({ speciesId: 'charmander' })
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
expect(updated.stats.totalEvolutions).toBe(1)
})
})
describe('chooseAIMove', () => {
test('returns a valid move index', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const aiPokemon = init.state.opponentPokemon
const idx = chooseAIMove(aiPokemon)
expect(idx).toBeGreaterThanOrEqual(0)
expect(idx).toBeLessThan(aiPokemon.moves.length)
})
test('returns a valid move index', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const aiPokemon = init.state.opponentPokemon
const idx = chooseAIMove(aiPokemon)
expect(idx).toBeGreaterThanOrEqual(0)
expect(idx).toBeLessThan(aiPokemon.moves.length)
})
test('returns 0 when all moves have 0 PP', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(0) // Struggle fallback
})
test('returns 0 when all moves have 0 PP', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(0) // Struggle fallback
})
test('skips disabled moves', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(1) // Only non-disabled move
})
test('skips disabled moves', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(1) // Only non-disabled move
})
})
describe('settleBattle - advanced', () => {
test('player win awards XP to creature', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
})
test('player win awards XP to creature', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
})
test('player win awards EVs (capped at 252 per stat)', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
}
})
test('player win awards EVs (capped at 252 per stat)', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
}
})
test('player loss does not increment battlesWon', async () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(0)
})
test('player loss does not increment battlesWon', async () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(0)
})
})
describe('createBattle - extended', () => {
test('battle state has turn initialized', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.turn).toBeGreaterThanOrEqual(1)
})
test('battle state has turn initialized', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.turn).toBeGreaterThanOrEqual(1)
})
test('player pokemon has correct level', () => {
const creature = makeTestCreature({ level: 25 })
const init = createBattle([creature], 'bulbasaur', 10)
expect(init.state.playerPokemon.level).toBe(25)
})
test('player pokemon has correct level', () => {
const creature = makeTestCreature({ level: 25 })
const init = createBattle([creature], 'bulbasaur', 10)
expect(init.state.playerPokemon.level).toBe(25)
})
test('opponent pokemon has correct level', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 15)
expect(init.state.opponentPokemon.level).toBe(15)
})
test('opponent pokemon has correct level', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 15)
expect(init.state.opponentPokemon.level).toBe(15)
})
test('battle state has player party', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.playerParty.length).toBeGreaterThan(0)
})
test('battle state has player party', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.playerParty.length).toBeGreaterThan(0)
})
test('battle state has usable items (empty bag)', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.usableItems).toEqual([])
})
test('battle state has usable items (empty bag)', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
expect(init.state.usableItems).toEqual([])
})
})
describe('executeTurn - extended', () => {
test('item action defaults to move 1', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const state = executeTurn(init, { type: 'item', itemId: 'potion' })
expect(state).toBeDefined()
expect(state.events.length).toBeGreaterThan(0)
})
test('item action defaults to move 1', () => {
const creature = makeTestCreature()
const init = createBattle([creature], 'squirtle', 50)
const state = executeTurn(init, { type: 'item', itemId: 'potion' })
expect(state).toBeDefined()
expect(state.events.length).toBeGreaterThan(0)
})
test('battle produces damage or heal events', () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
const init = createBattle([creature], 'squirtle', 5)
const state = executeTurn(init, { type: 'move', moveIndex: 0 })
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
expect(hasDamageOrHeal).toBe(true)
})
test('battle produces damage or heal events', () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
const init = createBattle([creature], 'squirtle', 5)
const state = executeTurn(init, { type: 'move', moveIndex: 0 })
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
expect(hasDamageOrHeal).toBe(true)
})
})
describe('settleBattle - EV limits', () => {
test('EV total cannot exceed 510', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
expect(totalEV).toBeLessThanOrEqual(510)
})
test('EV total cannot exceed 510', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
expect(totalEV).toBeLessThanOrEqual(510)
})
test('non-participant creatures are unchanged', async () => {
const participant = makeTestCreature({ id: 'p1', level: 5 })
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([participant, bystander])
data.party = [participant.id, bystander.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [participant.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
})
test('non-participant creatures are unchanged', async () => {
const participant = makeTestCreature({ id: 'p1', level: 5 })
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([participant, bystander])
data.party = [participant.id, bystander.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [participant.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
})
test('uses all party members as participants when participantIds is empty', async () => {
const c1 = makeTestCreature({ id: 'p1', level: 5 })
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
data.party = [c1.id, c2.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [] as string[],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0)
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
})
test('uses all party members as participants when participantIds is empty', async () => {
const c1 = makeTestCreature({ id: 'p1', level: 5 })
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
data.party = [c1.id, c2.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [] as string[],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0)
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
})
test('player win increments battlesWon but not battlesLost', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
expect(settlement.data.stats.battlesLost).toBe(0)
})
test('player win increments battlesWon but not battlesLost', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
expect(settlement.data.stats.battlesLost).toBe(0)
})
})
describe('applyMoveLearn - extended', () => {
test('new move has correct PP from Dex', () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
const move = updated.creatures[0]!.moves[0]!
expect(move.id).toBe('fireblast')
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
})
test('new move has correct PP from Dex', () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
const move = updated.creatures[0]!.moves[0]!
expect(move.id).toBe('fireblast')
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
})
test('non-target creatures are unchanged', () => {
const c1 = makeTestCreature({ id: 't1' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
const unchanged = updated.creatures.find(c => c.id === 't2')!
expect(unchanged.moves[0]!.id).toBe('flamethrower')
})
test('non-target creatures are unchanged', () => {
const c1 = makeTestCreature({ id: 't1' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
const unchanged = updated.creatures.find(c => c.id === 't2')!
expect(unchanged.moves[0]!.id).toBe('flamethrower')
})
})
describe('applyEvolution - extended', () => {
test('friendship increases by 10', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(80)
})
test('friendship increases by 10', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(80)
})
test('friendship capped at 255', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(255)
})
test('friendship capped at 255', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(255)
})
test('multiple evolutions increment counter correctly', () => {
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
let updated = applyEvolution(data, 't1', 'charmeleon')
updated = applyEvolution(updated, 't2', 'ivysaur')
expect(updated.stats.totalEvolutions).toBe(2)
})
test('multiple evolutions increment counter correctly', () => {
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
let updated = applyEvolution(data, 't1', 'charmeleon')
updated = applyEvolution(updated, 't2', 'ivysaur')
expect(updated.stats.totalEvolutions).toBe(2)
})
})

View File

@@ -1,184 +1,184 @@
import { describe, test, expect } from 'bun:test'
import type { SpeciesId, Creature } from '../types'
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
describe('generateCreature', () => {
test('creates a creature with correct defaults', async () => {
const c = await generateCreature('bulbasaur', 42)
expect(c.speciesId).toBe('bulbasaur')
expect(c.level).toBe(1)
expect(c.xp).toBe(0)
expect(c.totalXp).toBe(0)
expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
expect(c.isShiny).toBeDefined()
expect(c.id).toBeTruthy()
expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true)
expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true)
})
test('creates a creature with correct defaults', async () => {
const c = await generateCreature('bulbasaur', 42)
expect(c.speciesId).toBe('bulbasaur')
expect(c.level).toBe(1)
expect(c.xp).toBe(0)
expect(c.totalXp).toBe(0)
expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
expect(c.isShiny).toBeDefined()
expect(c.id).toBeTruthy()
expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true)
expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true)
})
test('deterministic IV generation from seed', async () => {
const c1 = await generateCreature('charmander', 12345)
const c2 = await generateCreature('charmander', 12345)
expect(c1.iv).toEqual(c2.iv)
})
test('deterministic IV generation from seed', async () => {
const c1 = await generateCreature('charmander', 12345)
const c2 = await generateCreature('charmander', 12345)
expect(c1.iv).toEqual(c2.iv)
})
test('different seeds produce different IVs', async () => {
const c1 = await generateCreature('squirtle', 100)
const c2 = await generateCreature('squirtle', 200)
expect(c1.iv).not.toEqual(c2.iv)
})
test('different seeds produce different IVs', async () => {
const c1 = await generateCreature('squirtle', 100)
const c2 = await generateCreature('squirtle', 200)
expect(c1.iv).not.toEqual(c2.iv)
})
test('all MVP species can be generated', async () => {
const species: SpeciesId[] = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
for (const s of species) {
const c = await generateCreature(s)
expect(c.speciesId).toBe(s)
}
})
test('all MVP species can be generated', async () => {
const species: SpeciesId[] = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
for (const s of species) {
const c = await generateCreature(s)
expect(c.speciesId).toBe(s)
}
})
})
describe('calculateStats', () => {
test('level 1 stats are reasonable', async () => {
const c = await generateCreature('bulbasaur', 0)
const stats = calculateStats(c)
// HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10
// With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11
expect(stats.hp).toBeGreaterThanOrEqual(11)
expect(stats.hp).toBeLessThanOrEqual(12)
// Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5
expect(stats.attack).toBeGreaterThanOrEqual(5)
expect(stats.attack).toBeLessThanOrEqual(6)
})
test('level 1 stats are reasonable', async () => {
const c = await generateCreature('bulbasaur', 0)
const stats = calculateStats(c)
// HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10
// With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11
expect(stats.hp).toBeGreaterThanOrEqual(11)
expect(stats.hp).toBeLessThanOrEqual(12)
// Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5
expect(stats.attack).toBeGreaterThanOrEqual(5)
expect(stats.attack).toBeLessThanOrEqual(6)
})
test('stats increase with level', async () => {
const c1 = await generateCreature('charmander', 0)
c1.level = 1
const stats1 = calculateStats(c1)
test('stats increase with level', async () => {
const c1 = await generateCreature('charmander', 0)
c1.level = 1
const stats1 = calculateStats(c1)
const c50 = { ...c1, level: 50 }
const stats50 = calculateStats(c50)
// All stats should be higher at level 50
expect(stats50.hp).toBeGreaterThan(stats1.hp)
expect(stats50.attack).toBeGreaterThan(stats1.attack)
})
const c50 = { ...c1, level: 50 }
const stats50 = calculateStats(c50)
// All stats should be higher at level 50
expect(stats50.hp).toBeGreaterThan(stats1.hp)
expect(stats50.attack).toBeGreaterThan(stats1.attack)
})
test('EVs affect stats', async () => {
const c = await generateCreature('pikachu', 0)
const statsNoEV = calculateStats(c)
test('EVs affect stats', async () => {
const c = await generateCreature('pikachu', 0)
const statsNoEV = calculateStats(c)
const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } }
const statsWithEV = calculateStats(cWithEV)
const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } }
const statsWithEV = calculateStats(cWithEV)
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
})
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
})
})
describe('getCreatureName', () => {
test('returns species name when no nickname', async () => {
const c = await generateCreature('pikachu')
c.nickname = undefined
expect(getCreatureName(c)).toBe('Pikachu')
})
test('returns species name when no nickname', async () => {
const c = await generateCreature('pikachu')
c.nickname = undefined
expect(getCreatureName(c)).toBe('Pikachu')
})
test('returns nickname when set', async () => {
const c = await generateCreature('pikachu')
c.nickname = 'Sparky'
expect(getCreatureName(c)).toBe('Sparky')
})
test('returns nickname when set', async () => {
const c = await generateCreature('pikachu')
c.nickname = 'Sparky'
expect(getCreatureName(c)).toBe('Sparky')
})
})
describe('getTotalEV', () => {
test('returns 0 for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getTotalEV(c)).toBe(0)
})
test('returns 0 for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getTotalEV(c)).toBe(0)
})
test('sums all EV values', async () => {
const c = await generateCreature('bulbasaur')
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
expect(getTotalEV(c)).toBe(210)
})
test('sums all EV values', async () => {
const c = await generateCreature('bulbasaur')
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
expect(getTotalEV(c)).toBe(210)
})
})
describe('recalculateLevel', () => {
test('returns same creature if level unchanged', async () => {
const c = await generateCreature('bulbasaur', 42)
const result = recalculateLevel(c)
expect(result.level).toBe(c.level)
})
test('returns same creature if level unchanged', async () => {
const c = await generateCreature('bulbasaur', 42)
const result = recalculateLevel(c)
expect(result.level).toBe(c.level)
})
test('updates level based on totalXp', async () => {
const c = await generateCreature('charmander', 42)
c.totalXp = 8000
const result = recalculateLevel(c)
expect(result.level).toBeGreaterThan(1)
})
test('updates level based on totalXp', async () => {
const c = await generateCreature('charmander', 42)
c.totalXp = 8000
const result = recalculateLevel(c)
expect(result.level).toBeGreaterThan(1)
})
})
describe('getActiveCreature', () => {
test('returns null when party is empty', async () => {
const c = await generateCreature('bulbasaur')
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
expect(result).toBeNull()
})
test('returns null when party is empty', async () => {
const c = await generateCreature('bulbasaur')
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
expect(result).toBeNull()
})
test('returns creature from party[0]', async () => {
const c = await generateCreature('pikachu')
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('returns creature from party[0]', async () => {
const c = await generateCreature('pikachu')
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('returns creature from activeCreatureId (legacy)', async () => {
const c = await generateCreature('squirtle')
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('returns creature from activeCreatureId (legacy)', async () => {
const c = await generateCreature('squirtle')
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('prefers party[0] over activeCreatureId', async () => {
const c1 = await generateCreature('bulbasaur')
const c2 = await generateCreature('charmander')
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
expect(result!.id).toBe(c1.id)
})
test('prefers party[0] over activeCreatureId', async () => {
const c1 = await generateCreature('bulbasaur')
const c2 = await generateCreature('charmander')
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
expect(result!.id).toBe(c1.id)
})
test('returns null when creature ID not found', () => {
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
expect(result).toBeNull()
})
test('returns null when creature ID not found', () => {
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
expect(result).toBeNull()
})
})
describe('calculateStats - nature effects', () => {
test('adamant nature boosts attack and lowers spAtk', async () => {
const c = await generateCreature('charmander', 42)
c.level = 50
c.nature = 'adamant'
const adamantStats = calculateStats(c)
test('adamant nature boosts attack and lowers spAtk', async () => {
const c = await generateCreature('charmander', 42)
c.level = 50
c.nature = 'adamant'
const adamantStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
})
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
})
test('timid nature boosts speed and lowers attack', async () => {
const c = await generateCreature('pikachu', 42)
c.level = 50
c.nature = 'timid'
const timidStats = calculateStats(c)
test('timid nature boosts speed and lowers attack', async () => {
const c = await generateCreature('pikachu', 42)
c.level = 50
c.nature = 'timid'
const timidStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
})
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
})
})

View File

@@ -1,79 +1,79 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
beforeEach(() => {
resetEVCooldowns()
resetEVCooldowns()
})
describe('awardEV', () => {
test('mapped tool awards correct EV', async () => {
let c = await generateCreature('bulbasaur')
// Clear cooldown by using old timestamp
c = awardEV(c, 'Bash', 0)
expect(c.ev.attack).toBeGreaterThan(0)
expect(c.ev.speed).toBeGreaterThan(0)
})
test('mapped tool awards correct EV', async () => {
let c = await generateCreature('bulbasaur')
// Clear cooldown by using old timestamp
c = awardEV(c, 'Bash', 0)
expect(c.ev.attack).toBeGreaterThan(0)
expect(c.ev.speed).toBeGreaterThan(0)
})
test('unmapped tool awards random EV', async () => {
let c = await generateCreature('bulbasaur')
c = awardEV(c, 'UnknownTool', 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
test('unmapped tool awards random EV', async () => {
let c = await generateCreature('bulbasaur')
c = awardEV(c, 'UnknownTool', 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
test('cooldown prevents repeated awards', async () => {
const now = Date.now()
let c = await generateCreature('bulbasaur')
c = awardEV(c, 'Bash', now)
const ev1 = { ...c.ev }
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
expect(c.ev).toEqual(ev1) // No change
})
test('cooldown prevents repeated awards', async () => {
const now = Date.now()
let c = await generateCreature('bulbasaur')
c = awardEV(c, 'Bash', now)
const ev1 = { ...c.ev }
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
expect(c.ev).toEqual(ev1) // No change
})
test('respects per-stat EV cap', async () => {
let c = await generateCreature('bulbasaur')
// Bash gives attack:2 + speed:1
for (let i = 0; i < 200; i++) {
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
}
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
})
test('respects per-stat EV cap', async () => {
let c = await generateCreature('bulbasaur')
// Bash gives attack:2 + speed:1
for (let i = 0; i < 200; i++) {
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
}
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
})
test('respects total EV cap', async () => {
let c = await generateCreature('bulbasaur')
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
for (let i = 0; i < 200; i++) {
for (const tool of tools) {
c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000)
}
}
const total = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
})
test('respects total EV cap', async () => {
let c = await generateCreature('bulbasaur')
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
for (let i = 0; i < 200; i++) {
for (const tool of tools) {
c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000)
}
}
const total = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
})
})
describe('awardTurnEV', () => {
test('awards EV for multiple tools', async () => {
let c = await generateCreature('bulbasaur')
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
test('awards EV for multiple tools', async () => {
let c = await generateCreature('bulbasaur')
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
})
describe('getEVSummary', () => {
test('returns "None" for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getEVSummary(c)).toBe('None')
})
test('returns "None" for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getEVSummary(c)).toBe('None')
})
test('shows stat breakdown', async () => {
const c = await generateCreature('bulbasaur')
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
const summary = getEVSummary(c)
expect(summary).toContain('ATK+5')
expect(summary).toContain('SPA+3')
})
test('shows stat breakdown', async () => {
const c = await generateCreature('bulbasaur')
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
const summary = getEVSummary(c)
expect(summary).toContain('ATK+5')
expect(summary).toContain('SPA+3')
})
})

View File

@@ -4,157 +4,157 @@ import type { BuddyData } from '../types'
import { generateCreature } from '../core/creature'
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
const creature = generateCreature('bulbasaur')
// Sync mock — generateCreature is async but for test setup we use the resolved structure
return {
version: 2,
party: ['test-creature-id', null, null, null, null, null],
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
creatures: [{
id: 'test-creature-id',
speciesId: 'bulbasaur',
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}],
eggs: [],
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
bag: { items: [] },
stats: {
totalTurns: 50,
consecutiveDays: 7,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
...overrides,
},
}
const creature = generateCreature('bulbasaur')
// Sync mock — generateCreature is async but for test setup we use the resolved structure
return {
version: 2,
party: ['test-creature-id', null, null, null, null, null],
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
creatures: [{
id: 'test-creature-id',
speciesId: 'bulbasaur',
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}],
eggs: [],
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
bag: { items: [] },
stats: {
totalTurns: 50,
consecutiveDays: 7,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
...overrides,
},
}
}
describe('checkEggEligibility', () => {
test('eligible when conditions met', () => {
const data = makeBuddyData()
expect(checkEggEligibility(data)).toBe(true)
})
test('eligible when conditions met', () => {
const data = makeBuddyData()
expect(checkEggEligibility(data)).toBe(true)
})
test('not eligible with existing egg', () => {
const data = makeBuddyData()
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible with existing egg', () => {
const data = makeBuddyData()
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible with low consecutive days', () => {
const data = makeBuddyData({ consecutiveDays: 2 })
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible with low consecutive days', () => {
const data = makeBuddyData({ consecutiveDays: 2 })
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible when turns not multiple of 50', () => {
const data = makeBuddyData({ totalTurns: 51 })
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible when turns not multiple of 50', () => {
const data = makeBuddyData({ totalTurns: 51 })
expect(checkEggEligibility(data)).toBe(false)
})
})
describe('generateEgg', () => {
test('prefers uncollected species', () => {
const data = makeBuddyData()
// Already have bulbasaur, so egg should prefer others
const egg = generateEgg(data)
expect(egg.speciesId).not.toBe('bulbasaur')
})
test('prefers uncollected species', () => {
const data = makeBuddyData()
// Already have bulbasaur, so egg should prefer others
const egg = generateEgg(data)
expect(egg.speciesId).not.toBe('bulbasaur')
})
test('egg has valid steps', () => {
const data = makeBuddyData()
const egg = generateEgg(data)
expect(egg.stepsRemaining).toBeGreaterThan(0)
expect(egg.totalSteps).toBe(egg.stepsRemaining)
})
test('egg has valid steps', () => {
const data = makeBuddyData()
const egg = generateEgg(data)
expect(egg.stepsRemaining).toBeGreaterThan(0)
expect(egg.totalSteps).toBe(egg.stepsRemaining)
})
})
describe('advanceEggSteps', () => {
test('reduces steps remaining', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 30)
expect(advanced.stepsRemaining).toBe(70)
})
test('reduces steps remaining', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 30)
expect(advanced.stepsRemaining).toBe(70)
})
test('steps do not go below 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 50)
expect(advanced.stepsRemaining).toBe(0)
})
test('steps do not go below 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 50)
expect(advanced.stepsRemaining).toBe(0)
})
})
describe('isEggReadyToHatch', () => {
test('ready when steps = 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(true)
})
test('ready when steps = 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(true)
})
test('not ready when steps > 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(false)
})
test('not ready when steps > 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(false)
})
})
describe('hatchEgg', () => {
test('creates a creature and removes egg', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
test('creates a creature and removes egg', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
expect(result.creature.speciesId).toBe('charmander')
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
expect(result.buddyData.eggs.length).toBe(0)
})
expect(result.creature.speciesId).toBe('charmander')
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
expect(result.buddyData.eggs.length).toBe(0)
})
test('adds creature to party when slot available', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
const result = await hatchEgg(data, egg)
const newCreature = result.creature
const inParty = result.buddyData.party.includes(newCreature.id)
expect(inParty).toBe(true)
})
test('adds creature to party when slot available', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
const result = await hatchEgg(data, egg)
const newCreature = result.creature
const inParty = result.buddyData.party.includes(newCreature.id)
expect(inParty).toBe(true)
})
test('increments totalEggsObtained', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
const result = await hatchEgg(data, egg)
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
})
test('increments totalEggsObtained', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
const result = await hatchEgg(data, egg)
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
})
test('updates dex entry with new species', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
expect(entry).toBeDefined()
expect(entry!.caughtCount).toBe(1)
})
test('updates dex entry with new species', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
expect(entry).toBeDefined()
expect(entry!.caughtCount).toBe(1)
})
test('increments caughtCount for existing dex entry', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
expect(entry!.caughtCount).toBe(2)
})
test('increments caughtCount for existing dex entry', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
expect(entry!.caughtCount).toBe(2)
})
})

View File

@@ -1,39 +1,39 @@
import { describe, test, expect } from 'bun:test'
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
describe('getEVForTool', () => {
test('returns EV mapping for known tools', () => {
const bashEV = getEVForTool('Bash')
expect(bashEV).toBeDefined()
expect(bashEV!.attack).toBe(2)
expect(bashEV!.speed).toBe(1)
})
test('returns EV mapping for known tools', () => {
const bashEV = getEVForTool('Bash')
expect(bashEV).toBeDefined()
expect(bashEV!.attack).toBe(2)
expect(bashEV!.speed).toBe(1)
})
test('returns undefined for unknown tools', () => {
expect(getEVForTool('UnknownTool')).toBeUndefined()
})
test('returns undefined for unknown tools', () => {
expect(getEVForTool('UnknownTool')).toBeUndefined()
})
test('all mapped tools have correct stat shape', () => {
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
expect(ev.hp).toBeDefined()
expect(ev.attack).toBeDefined()
expect(ev.defense).toBeDefined()
expect(ev.spAtk).toBeDefined()
expect(ev.spDef).toBeDefined()
expect(ev.speed).toBeDefined()
// EVs should sum to > 0
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
expect(total).toBeGreaterThan(0)
}
})
test('all mapped tools have correct stat shape', () => {
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
expect(ev.hp).toBeDefined()
expect(ev.attack).toBeDefined()
expect(ev.defense).toBeDefined()
expect(ev.spAtk).toBeDefined()
expect(ev.spDef).toBeDefined()
expect(ev.speed).toBeDefined()
// EVs should sum to > 0
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
expect(total).toBeGreaterThan(0)
}
})
})
describe('EV constants', () => {
test('MAX_EV_PER_STAT is 252', () => {
expect(MAX_EV_PER_STAT).toBe(252)
})
test('MAX_EV_PER_STAT is 252', () => {
expect(MAX_EV_PER_STAT).toBe(252)
})
test('MAX_EV_TOTAL is 510', () => {
expect(MAX_EV_TOTAL).toBe(510)
})
test('MAX_EV_TOTAL is 510', () => {
expect(MAX_EV_TOTAL).toBe(510)
})
})

View File

@@ -3,122 +3,122 @@ import type { Creature } from '../types'
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
function makeEvolutionCreature(overrides: Partial<Creature> = {}): Creature {
return {
id: 'test-evo',
speciesId: overrides.speciesId ?? 'bulbasaur',
gender: 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: 'growl', pp: 40, maxPp: 40 },
{ id: 'vinewhip', pp: 15, maxPp: 15 },
{ id: 'razorleaf', pp: 10, maxPp: 10 },
],
ability: 'overgrow',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
return {
id: 'test-evo',
speciesId: overrides.speciesId ?? 'bulbasaur',
gender: 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: 'growl', pp: 40, maxPp: 40 },
{ id: 'vinewhip', pp: 15, maxPp: 15 },
{ id: 'razorleaf', pp: 10, maxPp: 10 },
],
ability: 'overgrow',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
describe('checkEvolution', () => {
test('bulbasaur at level 15 cannot evolve', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
expect(checkEvolution(creature)).toBeNull()
})
test('bulbasaur at level 15 cannot evolve', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
expect(checkEvolution(creature)).toBeNull()
})
test('bulbasaur at level 16 can evolve into ivysaur', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.from).toBe('bulbasaur')
expect(result!.to).toBe('ivysaur')
})
test('bulbasaur at level 16 can evolve into ivysaur', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.from).toBe('bulbasaur')
expect(result!.to).toBe('ivysaur')
})
test('charmander at level 16 evolves into charmeleon', () => {
const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charmeleon')
})
test('charmander at level 16 evolves into charmeleon', () => {
const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charmeleon')
})
test('charmeleon at level 36 evolves into charizard', () => {
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charizard')
})
test('charmeleon at level 36 evolves into charizard', () => {
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charizard')
})
test('squirtle at level 16 evolves into wartortle', () => {
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('wartortle')
})
test('squirtle at level 16 evolves into wartortle', () => {
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('wartortle')
})
test('wartortle at level 36 evolves into blastoise', () => {
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('blastoise')
})
test('wartortle at level 36 evolves into blastoise', () => {
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('blastoise')
})
test('venusaur cannot evolve further', () => {
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
expect(checkEvolution(creature)).toBeNull()
})
test('venusaur cannot evolve further', () => {
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
expect(checkEvolution(creature)).toBeNull()
})
test('pikachu cannot evolve in MVP', () => {
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
expect(checkEvolution(creature)).toBeNull()
})
test('pikachu cannot evolve in MVP', () => {
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
expect(checkEvolution(creature)).toBeNull()
})
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.to).toBe('ivysaur')
})
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.to).toBe('ivysaur')
})
})
describe('evolve', () => {
test('changes species and boosts friendship', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.speciesId).toBe('ivysaur')
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
})
test('changes species and boosts friendship', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.speciesId).toBe('ivysaur')
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
})
test('friendship is capped at 255', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.friendship).toBe(255)
})
test('friendship is capped at 255', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.friendship).toBe(255)
})
})
describe('canEvolveFurther', () => {
test('starter species can evolve', () => {
expect(canEvolveFurther('bulbasaur')).toBe(true)
expect(canEvolveFurther('charmander')).toBe(true)
expect(canEvolveFurther('squirtle')).toBe(true)
})
test('starter species can evolve', () => {
expect(canEvolveFurther('bulbasaur')).toBe(true)
expect(canEvolveFurther('charmander')).toBe(true)
expect(canEvolveFurther('squirtle')).toBe(true)
})
test('middle evolution can evolve', () => {
expect(canEvolveFurther('ivysaur')).toBe(true)
expect(canEvolveFurther('charmeleon')).toBe(true)
expect(canEvolveFurther('wartortle')).toBe(true)
})
test('middle evolution can evolve', () => {
expect(canEvolveFurther('ivysaur')).toBe(true)
expect(canEvolveFurther('charmeleon')).toBe(true)
expect(canEvolveFurther('wartortle')).toBe(true)
})
test('final evolution cannot evolve', () => {
expect(canEvolveFurther('venusaur')).toBe(false)
expect(canEvolveFurther('charizard')).toBe(false)
expect(canEvolveFurther('blastoise')).toBe(false)
})
test('final evolution cannot evolve', () => {
expect(canEvolveFurther('venusaur')).toBe(false)
expect(canEvolveFurther('charizard')).toBe(false)
expect(canEvolveFurther('blastoise')).toBe(false)
})
test('pikachu cannot evolve in MVP', () => {
expect(canEvolveFurther('pikachu')).toBe(false)
})
test('pikachu cannot evolve in MVP', () => {
expect(canEvolveFurther('pikachu')).toBe(false)
})
})

View File

@@ -1,153 +1,153 @@
import { describe, test, expect } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardXP, getXpProgress } from '../core/experience'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
describe('xpForLevel', () => {
test('level 1 requires 0 XP', () => {
expect(xpForLevel(1, 'medium-slow')).toBe(0)
})
test('level 1 requires 0 XP', () => {
expect(xpForLevel(1, 'medium-slow')).toBe(0)
})
test('medium-fast: level N requires N^3 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
})
test('medium-fast: level N requires N^3 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
})
test('fast: level N requires floor(N^3 * 4/5)', () => {
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
})
test('fast: level N requires floor(N^3 * 4/5)', () => {
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
})
test('slow: level N requires floor(N^3 * 5/4)', () => {
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
})
test('slow: level N requires floor(N^3 * 5/4)', () => {
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
})
test('higher levels require more XP', () => {
for (let i = 2; i < 99; i++) {
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
}
})
test('higher levels require more XP', () => {
for (let i = 2; i < 99; i++) {
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
}
})
})
describe('levelFromXp', () => {
test('0 XP = level 1', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('0 XP = level 1', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('roundtrip: level → XP → level', () => {
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
const xp = xpForLevel(level, growth)
expect(levelFromXp(xp, growth)).toBe(level)
}
}
})
test('roundtrip: level → XP → level', () => {
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
const xp = xpForLevel(level, growth)
expect(levelFromXp(xp, growth)).toBe(level)
}
}
})
test('XP slightly below threshold stays at lower level', () => {
const xp20 = xpForLevel(20, 'medium-fast')
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
})
test('XP slightly below threshold stays at lower level', () => {
const xp20 = xpForLevel(20, 'medium-fast')
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
})
})
describe('awardXP', () => {
test('awards XP and returns updated creature', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 10)
expect(result.creature.totalXp).toBe(10)
expect(result.leveledUp).toBeDefined()
})
test('awards XP and returns updated creature', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 10)
expect(result.creature.totalXp).toBe(10)
expect(result.leveledUp).toBeDefined()
})
test('large XP can cause level up', async () => {
const c = await generateCreature('bulbasaur')
// Award enough XP for several levels
const result = awardXP(c, 10000)
expect(result.creature.level).toBeGreaterThan(1)
expect(result.leveledUp).toBe(true)
})
test('large XP can cause level up', async () => {
const c = await generateCreature('bulbasaur')
// Award enough XP for several levels
const result = awardXP(c, 10000)
expect(result.creature.level).toBeGreaterThan(1)
expect(result.leveledUp).toBe(true)
})
test('level capped at 100', async () => {
const c = await generateCreature('bulbasaur')
c.level = 100
c.totalXp = 1000000
const result = awardXP(c, 999999)
expect(result.creature.level).toBe(100)
expect(result.leveledUp).toBe(false)
})
test('level capped at 100', async () => {
const c = await generateCreature('bulbasaur')
c.level = 100
c.totalXp = 1000000
const result = awardXP(c, 999999)
expect(result.creature.level).toBe(100)
expect(result.leveledUp).toBe(false)
})
})
describe('getXpProgress', () => {
test('new creature has 0 XP progress', async () => {
const c = await generateCreature('bulbasaur')
const progress = getXpProgress(c)
expect(progress.current).toBe(0)
expect(progress.percentage).toBe(0)
})
test('new creature has 0 XP progress', async () => {
const c = await generateCreature('bulbasaur')
const progress = getXpProgress(c)
expect(progress.current).toBe(0)
expect(progress.percentage).toBe(0)
})
test('level 100 creature has 100% progress', async () => {
const c = await generateCreature('charmander')
c.level = 100
c.totalXp = 1000000
const progress = getXpProgress(c)
expect(progress.percentage).toBe(100)
})
test('level 100 creature has 100% progress', async () => {
const c = await generateCreature('charmander')
c.level = 100
c.totalXp = 1000000
const progress = getXpProgress(c)
expect(progress.percentage).toBe(100)
})
test('needed is positive for sub-100 creatures', async () => {
const c = await generateCreature('bulbasaur')
c.level = 5
c.totalXp = xpForLevel(5, 'medium-slow')
const progress = getXpProgress(c)
expect(progress.needed).toBeGreaterThan(0)
expect(progress.current).toBe(0)
})
test('needed is positive for sub-100 creatures', async () => {
const c = await generateCreature('bulbasaur')
c.level = 5
c.totalXp = xpForLevel(5, 'medium-slow')
const progress = getXpProgress(c)
expect(progress.needed).toBeGreaterThan(0)
expect(progress.current).toBe(0)
})
})
describe('xpToNextLevel', () => {
test('returns XP needed from current to next level', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const needed = xpToNextLevel(10, xp10, 'medium-fast')
expect(needed).toBe(xp11 - xp10)
})
test('returns XP needed from current to next level', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const needed = xpToNextLevel(10, xp10, 'medium-fast')
expect(needed).toBe(xp11 - xp10)
})
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
})
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
})
test('accounts for partial XP already earned', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
expect(needed).toBe(xp11 - halfWay)
})
test('accounts for partial XP already earned', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
expect(needed).toBe(xp11 - halfWay)
})
})
describe('awardXP - extended', () => {
test('awarding 0 XP returns unchanged creature', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 0)
expect(result.creature.totalXp).toBe(c.totalXp)
expect(result.leveledUp).toBe(false)
})
test('awarding 0 XP returns unchanged creature', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 0)
expect(result.creature.totalXp).toBe(c.totalXp)
expect(result.leveledUp).toBe(false)
})
test('XP progress is correctly calculated after award', async () => {
const c = await generateCreature('squirtle')
const xpNeeded = xpForLevel(2, 'medium-slow')
const result = awardXP(c, Math.floor(xpNeeded / 2))
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
})
test('XP progress is correctly calculated after award', async () => {
const c = await generateCreature('squirtle')
const xpNeeded = xpForLevel(2, 'medium-slow')
const result = awardXP(c, Math.floor(xpNeeded / 2))
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
})
test('multiple small XP awards equal one large award', async () => {
const c1 = await generateCreature('bulbasaur', 42)
const c2 = await generateCreature('bulbasaur', 42)
c2.totalXp = c1.totalXp
test('multiple small XP awards equal one large award', async () => {
const c1 = await generateCreature('bulbasaur', 42)
const c2 = await generateCreature('bulbasaur', 42)
c2.totalXp = c1.totalXp
let current = c1
for (let i = 0; i < 10; i++) {
current = awardXP(current, 100).creature
}
const bigResult = awardXP(c2, 1000)
let current = c1
for (let i = 0; i < 10; i++) {
current = awardXP(current, 100).creature
}
const bigResult = awardXP(c2, 1000)
expect(current.totalXp).toBe(bigResult.creature.totalXp)
expect(current.level).toBe(bigResult.creature.level)
})
expect(current.totalXp).toBe(bigResult.creature.totalXp)
expect(current.level).toBe(bigResult.creature.level)
})
})

View File

@@ -3,26 +3,26 @@ import { getFallbackSprite } from '../sprites/fallback'
import { ALL_SPECIES_IDS } from '../types'
describe('getFallbackSprite', () => {
test('returns 5 lines for every species', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
expect(sprite.length).toBe(5)
}
})
test('returns 5 lines for every species', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
expect(sprite.length).toBe(5)
}
})
test('returns pikachu fallback for unknown species', () => {
const sprite = getFallbackSprite('unknown' as any)
expect(sprite).toEqual(getFallbackSprite('pikachu'))
})
test('returns pikachu fallback for unknown species', () => {
const sprite = getFallbackSprite('unknown' as any)
expect(sprite).toEqual(getFallbackSprite('pikachu'))
})
test('each line has consistent width', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
const widths = sprite.map(line => line.length)
// All lines should be roughly the same width
const maxWidth = Math.max(...widths)
const minWidth = Math.min(...widths)
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
}
})
test('each line has consistent width', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
const widths = sprite.map(line => line.length)
// All lines should be roughly the same width
const maxWidth = Math.max(...widths)
const minWidth = Math.min(...widths)
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
}
})
})

View File

@@ -1,51 +1,51 @@
import { describe, test, expect } from 'bun:test'
import { determineGender, getGenderSymbol } from '../core/gender'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
describe('determineGender', () => {
test('genderless species', () => {
// Pikachu has genderRate 4 (50% female)
// Venusaur has genderRate 1 (12.5% female)
// For testing genderless, we'd need a species with genderRate -1
// None in MVP are genderless, so test the basic logic
const pikachu = getSpeciesData('pikachu')
expect(pikachu.genderRate).toBe(4)
})
test('genderless species', () => {
// Pikachu has genderRate 4 (50% female)
// Venusaur has genderRate 1 (12.5% female)
// For testing genderless, we'd need a species with genderRate -1
// None in MVP are genderless, so test the basic logic
const pikachu = getSpeciesData('pikachu')
expect(pikachu.genderRate).toBe(4)
})
test('pikachu 50% female ratio', () => {
const pikachu = getSpeciesData('pikachu')
let males = 0
let females = 0
for (let seed = 0; seed < 1000; seed++) {
const g = determineGender(pikachu, seed)
if (g === 'male') males++
else females++
}
// Should be roughly 50/50 with some tolerance
expect(females).toBeGreaterThan(300)
expect(males).toBeGreaterThan(300)
})
test('pikachu 50% female ratio', () => {
const pikachu = getSpeciesData('pikachu')
let males = 0
let females = 0
for (let seed = 0; seed < 1000; seed++) {
const g = determineGender(pikachu, seed)
if (g === 'male') males++
else females++
}
// Should be roughly 50/50 with some tolerance
expect(females).toBeGreaterThan(300)
expect(males).toBeGreaterThan(300)
})
test('starters are ~12.5% female', () => {
const bulbasaur = getSpeciesData('bulbasaur')
let females = 0
for (let seed = 0; seed < 1000; seed++) {
if (determineGender(bulbasaur, seed) === 'female') females++
}
// ~12.5% female = ~125 out of 1000
expect(females).toBeGreaterThan(50)
expect(females).toBeLessThan(250)
})
test('starters are ~12.5% female', () => {
const bulbasaur = getSpeciesData('bulbasaur')
let females = 0
for (let seed = 0; seed < 1000; seed++) {
if (determineGender(bulbasaur, seed) === 'female') females++
}
// ~12.5% female = ~125 out of 1000
expect(females).toBeGreaterThan(50)
expect(females).toBeLessThan(250)
})
})
describe('getGenderSymbol', () => {
test('male symbol', () => {
expect(getGenderSymbol('male')).toBe('♂')
})
test('female symbol', () => {
expect(getGenderSymbol('female')).toBe('♀')
})
test('genderless has no symbol', () => {
expect(getGenderSymbol('genderless')).toBe('')
})
test('male symbol', () => {
expect(getGenderSymbol('male')).toBe('♂')
})
test('female symbol', () => {
expect(getGenderSymbol('female')).toBe('♀')
})
test('genderless has no symbol', () => {
expect(getGenderSymbol('genderless')).toBe('')
})
})

View File

@@ -1,59 +1,59 @@
import { describe, test, expect } from 'bun:test'
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../data/learnsets'
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../dex/learnsets'
import { EMPTY_MOVE } from '../types'
describe('getDefaultMoveset', () => {
test('charmander at level 1 has at least one move', async () => {
const moves = await getDefaultMoveset('charmander', 1)
expect(moves.length).toBe(4)
expect(moves[0]!.id).not.toBe('')
})
test('charmander at level 1 has at least one move', async () => {
const moves = await getDefaultMoveset('charmander', 1)
expect(moves.length).toBe(4)
expect(moves[0]!.id).not.toBe('')
})
test('charmander at level 10 has more moves', async () => {
const moves = await getDefaultMoveset('charmander', 10)
const nonEmpty = moves.filter(m => m.id !== '')
expect(nonEmpty.length).toBeGreaterThan(1)
})
test('charmander at level 10 has more moves', async () => {
const moves = await getDefaultMoveset('charmander', 10)
const nonEmpty = moves.filter(m => m.id !== '')
expect(nonEmpty.length).toBeGreaterThan(1)
})
test('all moves have valid pp', async () => {
const moves = await getDefaultMoveset('bulbasaur', 20)
for (const move of moves) {
if (move.id) {
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
}
}
})
test('all moves have valid pp', async () => {
const moves = await getDefaultMoveset('bulbasaur', 20)
for (const move of moves) {
if (move.id) {
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
}
}
})
test('invalid species returns empty moves', async () => {
const moves = await getDefaultMoveset('nonexistent' as any, 10)
expect(moves.every(m => m.id === '')).toBe(true)
})
test('invalid species returns empty moves', async () => {
const moves = await getDefaultMoveset('nonexistent' as any, 10)
expect(moves.every(m => m.id === '')).toBe(true)
})
})
describe('getDefaultAbility', () => {
test('charmander has blaze', () => {
expect(getDefaultAbility('charmander')).toBe('blaze')
})
test('charmander has blaze', () => {
expect(getDefaultAbility('charmander')).toBe('blaze')
})
test('bulbasaur has overgrow', () => {
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
})
test('bulbasaur has overgrow', () => {
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
})
test('squirtle has torrent', () => {
expect(getDefaultAbility('squirtle')).toBe('torrent')
})
test('squirtle has torrent', () => {
expect(getDefaultAbility('squirtle')).toBe('torrent')
})
})
describe('getNewLearnableMoves', () => {
test('charmander gains ember at level 4', async () => {
const moves = await getNewLearnableMoves('charmander', 1, 4)
expect(moves.length).toBeGreaterThan(0)
expect(moves.some(m => m.id === 'ember')).toBe(true)
})
test('charmander gains ember at level 4', async () => {
const moves = await getNewLearnableMoves('charmander', 1, 4)
expect(moves.length).toBeGreaterThan(0)
expect(moves.some(m => m.id === 'ember')).toBe(true)
})
test('no new moves when level stays same', async () => {
const moves = await getNewLearnableMoves('charmander', 5, 5)
expect(moves.length).toBe(0)
})
test('no new moves when level stays same', async () => {
const moves = await getNewLearnableMoves('charmander', 5, 5)
expect(moves.length).toBe(0)
})
})

View File

@@ -1,44 +1,44 @@
import { describe, test, expect } from 'bun:test'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
import { ALL_SPECIES_IDS } from '../types'
describe('SPECIES_NAMES', () => {
test('has name for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_NAMES[id]).toBeTruthy()
}
})
test('has name for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_NAMES[id]).toBeTruthy()
}
})
test('Charmander name is correct', () => {
expect(SPECIES_NAMES.charmander).toBe('Charmander')
})
test('Charmander name is correct', () => {
expect(SPECIES_NAMES.charmander).toBe('Charmander')
})
})
describe('SPECIES_I18N', () => {
test('has i18n for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_I18N[id]).toBeTruthy()
expect(SPECIES_I18N[id]!.en).toBeTruthy()
}
})
test('has i18n for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_I18N[id]).toBeTruthy()
expect(SPECIES_I18N[id]!.en).toBeTruthy()
}
})
test('has Chinese translations', () => {
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
})
test('has Chinese translations', () => {
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
})
})
describe('SPECIES_PERSONALITY', () => {
test('has personality for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
}
})
test('has personality for every species', () => {
for (const id of ALL_SPECIES_IDS) {
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
}
})
test('personality is non-empty string', () => {
for (const id of ALL_SPECIES_IDS) {
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
}
})
test('personality is non-empty string', () => {
for (const id of ALL_SPECIES_IDS) {
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
}
})
})

View File

@@ -1,53 +1,53 @@
import { describe, test, expect } from 'bun:test'
import { getAllNatureNames, randomNature, getNatureEffect } from '../data/nature'
import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature'
describe('getAllNatureNames', () => {
test('returns 25 nature names', () => {
const names = getAllNatureNames()
expect(names.length).toBe(25)
})
test('returns 25 nature names', () => {
const names = getAllNatureNames()
expect(names.length).toBe(25)
})
test('includes hardy and quirky', () => {
const names = getAllNatureNames()
expect(names).toContain('hardy')
expect(names).toContain('quirky')
})
test('includes hardy and quirky', () => {
const names = getAllNatureNames()
expect(names).toContain('hardy')
expect(names).toContain('quirky')
})
})
describe('randomNature', () => {
test('returns a valid nature name', () => {
const nature = randomNature()
expect(getAllNatureNames()).toContain(nature)
})
test('returns a valid nature name', () => {
const nature = randomNature()
expect(getAllNatureNames()).toContain(nature)
})
test('produces different natures over multiple calls', () => {
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
expect(natures.size).toBeGreaterThan(1)
})
test('produces different natures over multiple calls', () => {
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
expect(natures.size).toBeGreaterThan(1)
})
})
describe('getNatureEffect', () => {
test('hardy is neutral (no effect)', () => {
const effect = getNatureEffect('hardy')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
test('hardy is neutral (no effect)', () => {
const effect = getNatureEffect('hardy')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
test('adamant boosts attack and lowers spAtk', () => {
const effect = getNatureEffect('adamant')
expect(effect.plus).toBe('attack')
expect(effect.minus).toBe('spAtk')
})
test('adamant boosts attack and lowers spAtk', () => {
const effect = getNatureEffect('adamant')
expect(effect.plus).toBe('attack')
expect(effect.minus).toBe('spAtk')
})
test('timid boosts speed and lowers attack', () => {
const effect = getNatureEffect('timid')
expect(effect.plus).toBe('speed')
expect(effect.minus).toBe('attack')
})
test('timid boosts speed and lowers attack', () => {
const effect = getNatureEffect('timid')
expect(effect.plus).toBe('speed')
expect(effect.minus).toBe('attack')
})
test('invalid nature returns neutral', () => {
const effect = getNatureEffect('nonexistent')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
test('invalid nature returns neutral', () => {
const effect = getNatureEffect('nonexistent')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
})

View File

@@ -1,46 +1,46 @@
import { describe, test, expect } from 'bun:test'
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../data/pkmn'
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../dex/pkmn'
describe('FROM_DEX_STAT', () => {
test('maps all 6 stats', () => {
expect(FROM_DEX_STAT.hp).toBe('hp')
expect(FROM_DEX_STAT.atk).toBe('attack')
expect(FROM_DEX_STAT.def).toBe('defense')
expect(FROM_DEX_STAT.spa).toBe('spAtk')
expect(FROM_DEX_STAT.spd).toBe('spDef')
expect(FROM_DEX_STAT.spe).toBe('speed')
})
test('maps all 6 stats', () => {
expect(FROM_DEX_STAT.hp).toBe('hp')
expect(FROM_DEX_STAT.atk).toBe('attack')
expect(FROM_DEX_STAT.def).toBe('defense')
expect(FROM_DEX_STAT.spa).toBe('spAtk')
expect(FROM_DEX_STAT.spd).toBe('spDef')
expect(FROM_DEX_STAT.spe).toBe('speed')
})
})
describe('TO_DEX_STAT', () => {
test('reverse maps all 6 stats', () => {
expect(TO_DEX_STAT.hp).toBe('hp')
expect(TO_DEX_STAT.attack).toBe('atk')
expect(TO_DEX_STAT.defense).toBe('def')
expect(TO_DEX_STAT.spAtk).toBe('spa')
expect(TO_DEX_STAT.spDef).toBe('spd')
expect(TO_DEX_STAT.speed).toBe('spe')
})
test('reverse maps all 6 stats', () => {
expect(TO_DEX_STAT.hp).toBe('hp')
expect(TO_DEX_STAT.attack).toBe('atk')
expect(TO_DEX_STAT.defense).toBe('def')
expect(TO_DEX_STAT.spAtk).toBe('spa')
expect(TO_DEX_STAT.spDef).toBe('spd')
expect(TO_DEX_STAT.speed).toBe('spe')
})
})
describe('mapBaseStats', () => {
test('converts Dex stat format to our format', () => {
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
expect(result).toEqual({
hp: 45, attack: 49, defense: 49,
spAtk: 65, spDef: 65, speed: 45,
})
})
test('converts Dex stat format to our format', () => {
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
expect(result).toEqual({
hp: 45, attack: 49, defense: 49,
spAtk: 65, spDef: 65, speed: 45,
})
})
})
describe('mapGenderRatio', () => {
test('returns -1 for genderless', () => {
expect(mapGenderRatio(undefined)).toBe(-1)
expect(mapGenderRatio('N')).toBe(-1)
})
test('returns -1 for genderless', () => {
expect(mapGenderRatio(undefined)).toBe(-1)
expect(mapGenderRatio('N')).toBe(-1)
})
test('calculates female ratio', () => {
expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1
expect(mapGenderRatio({ M: 0.5, F: 0.5 })).toBe(4) // 50% F → 4
})
test('calculates female ratio', () => {
expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1
expect(mapGenderRatio({ M: 0.5, F: 0.5 })).toBe(4) // 50% F → 4
})
})

View File

@@ -2,102 +2,102 @@ import { describe, expect, test } from 'bun:test'
import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
describe('renderAnimatedSprite', () => {
const testSprite = [
' AB',
' C D',
]
const testSprite = [
' AB',
' C D',
]
test('idle mode returns original sprite (with ANSI resets)', () => {
const result = renderAnimatedSprite(testSprite, 0, 'idle')
expect(result.length).toBe(2)
// Each row should contain the original characters
expect(result[0]).toContain('A')
expect(result[0]).toContain('B')
})
test('idle mode returns original sprite (with ANSI resets)', () => {
const result = renderAnimatedSprite(testSprite, 0, 'idle')
expect(result.length).toBe(2)
// Each row should contain the original characters
expect(result[0]).toContain('A')
expect(result[0]).toContain('B')
})
test('flip reverses rows', () => {
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
expect(flipped[0]).toContain('B')
expect(flipped[0]).toContain('A')
})
test('flip reverses rows', () => {
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
expect(flipped[0]).toContain('B')
expect(flipped[0]).toContain('A')
})
test('blink replaces eye characters with dash', () => {
const sprite = [' O ', ' O ']
const result = renderAnimatedSprite(sprite, 0, 'blink')
expect(result[0]).toContain('—')
expect(result[1]).toContain('—')
})
test('blink replaces eye characters with dash', () => {
const sprite = [' O ', ' O ']
const result = renderAnimatedSprite(sprite, 0, 'blink')
expect(result[0]).toContain('—')
expect(result[1]).toContain('—')
})
test('bounce shifts sprite up', () => {
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
// Bounce at tick 2 should shift up by some amount
expect(result.length).toBe(2)
})
test('bounce shifts sprite up', () => {
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
// Bounce at tick 2 should shift up by some amount
expect(result.length).toBe(2)
})
test('excited mode shifts horizontally', () => {
const result = renderAnimatedSprite(testSprite, 0, 'excited')
expect(result.length).toBe(2)
})
test('excited mode shifts horizontally', () => {
const result = renderAnimatedSprite(testSprite, 0, 'excited')
expect(result.length).toBe(2)
})
test('walkRight shifts progressively', () => {
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
// Different ticks should produce different horizontal positions
expect(r0).toBeDefined()
expect(r1).toBeDefined()
})
test('walkRight shifts progressively', () => {
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
// Different ticks should produce different horizontal positions
expect(r0).toBeDefined()
expect(r1).toBeDefined()
})
test('walkLeft mode shifts', () => {
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
expect(result.length).toBe(2)
})
test('walkLeft mode shifts', () => {
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
expect(result.length).toBe(2)
})
test('pet mode returns sprite unchanged', () => {
const result = renderAnimatedSprite(testSprite, 0, 'pet')
expect(result.length).toBe(2)
})
test('pet mode returns sprite unchanged', () => {
const result = renderAnimatedSprite(testSprite, 0, 'pet')
expect(result.length).toBe(2)
})
})
describe('getIdleAnimMode', () => {
test('returns valid AnimMode for any tick', () => {
const modes = new Set<string>()
for (let i = 0; i < 100; i++) {
modes.add(getIdleAnimMode(i))
}
expect(modes.size).toBeGreaterThan(1)
})
test('returns valid AnimMode for any tick', () => {
const modes = new Set<string>()
for (let i = 0; i < 100; i++) {
modes.add(getIdleAnimMode(i))
}
expect(modes.size).toBeGreaterThan(1)
})
test('cycles through sequence', () => {
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
expect(getIdleAnimMode(0)).toBe('idle')
})
test('cycles through sequence', () => {
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
expect(getIdleAnimMode(0)).toBe('idle')
})
test('wraps around after sequence length', () => {
const mode0 = getIdleAnimMode(0)
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
expect(mode0).toBe(modeAfterFullCycle)
})
test('wraps around after sequence length', () => {
const mode0 = getIdleAnimMode(0)
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
expect(mode0).toBe(modeAfterFullCycle)
})
})
describe('getPetOverlay', () => {
test('returns two lines', () => {
const overlay = getPetOverlay(0)
expect(overlay.length).toBe(2)
})
test('returns two lines', () => {
const overlay = getPetOverlay(0)
expect(overlay.length).toBe(2)
})
test('contains heart characters', () => {
const overlay = getPetOverlay(0)
const combined = overlay.join('')
expect(combined).toContain('♥')
})
test('contains heart characters', () => {
const overlay = getPetOverlay(0)
const combined = overlay.join('')
expect(combined).toContain('♥')
})
test('cycles through overlays', () => {
const o0 = getPetOverlay(0)
const o1 = getPetOverlay(1)
expect(o0).not.toEqual(o1)
})
test('cycles through overlays', () => {
const o0 = getPetOverlay(0)
const o1 = getPetOverlay(1)
expect(o0).not.toEqual(o1)
})
test('wraps around', () => {
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
})
test('wraps around', () => {
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
})
})

View File

@@ -1,95 +1,95 @@
import { describe, test, expect } from 'bun:test'
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../data/species'
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../dex/species'
import { ALL_SPECIES_IDS } from '../types'
import type { SpeciesId } from '../types'
describe('getSpeciesData', () => {
test('returns valid data for charmander', () => {
const data = getSpeciesData('charmander')
expect(data.id).toBe('charmander')
expect(data.name).toBe('Charmander')
expect(data.dexNumber).toBe(4)
expect(data.growthRate).toBe('medium-slow')
expect(data.captureRate).toBe(45)
expect(data.flavorText).toBeTruthy()
})
test('returns valid data for charmander', () => {
const data = getSpeciesData('charmander')
expect(data.id).toBe('charmander')
expect(data.name).toBe('Charmander')
expect(data.dexNumber).toBe(4)
expect(data.growthRate).toBe('medium-slow')
expect(data.captureRate).toBe(45)
expect(data.flavorText).toBeTruthy()
})
test('returns valid data for pikachu', () => {
const data = getSpeciesData('pikachu')
expect(data.id).toBe('pikachu')
expect(data.dexNumber).toBe(25)
expect(data.growthRate).toBe('medium-fast')
})
test('returns valid data for pikachu', () => {
const data = getSpeciesData('pikachu')
expect(data.id).toBe('pikachu')
expect(data.dexNumber).toBe(25)
expect(data.growthRate).toBe('medium-fast')
})
test('has baseStats with all 6 stats', () => {
const data = getSpeciesData('bulbasaur')
expect(data.baseStats).toHaveProperty('hp')
expect(data.baseStats).toHaveProperty('attack')
expect(data.baseStats).toHaveProperty('defense')
expect(data.baseStats).toHaveProperty('spAtk')
expect(data.baseStats).toHaveProperty('spDef')
expect(data.baseStats).toHaveProperty('speed')
})
test('has baseStats with all 6 stats', () => {
const data = getSpeciesData('bulbasaur')
expect(data.baseStats).toHaveProperty('hp')
expect(data.baseStats).toHaveProperty('attack')
expect(data.baseStats).toHaveProperty('defense')
expect(data.baseStats).toHaveProperty('spAtk')
expect(data.baseStats).toHaveProperty('spDef')
expect(data.baseStats).toHaveProperty('speed')
})
test('has types array', () => {
const data = getSpeciesData('squirtle')
expect(data.types.length).toBeGreaterThan(0)
expect(data.types[0]).toBe('water')
})
test('has types array', () => {
const data = getSpeciesData('squirtle')
expect(data.types.length).toBeGreaterThan(0)
expect(data.types[0]).toBe('water')
})
test('has evolutionChain for species with evolutions', () => {
const data = getSpeciesData('charmander')
expect(data.evolutionChain).toBeDefined()
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
})
test('has evolutionChain for species with evolutions', () => {
const data = getSpeciesData('charmander')
expect(data.evolutionChain).toBeDefined()
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
})
test('has no evolutionChain for final evolutions', () => {
const data = getSpeciesData('charizard')
expect(data.evolutionChain).toBeUndefined()
})
test('has no evolutionChain for final evolutions', () => {
const data = getSpeciesData('charizard')
expect(data.evolutionChain).toBeUndefined()
})
})
describe('getAllSpeciesData', () => {
test('returns data for all species', () => {
const all = getAllSpeciesData()
for (const id of ALL_SPECIES_IDS) {
expect(all[id]).toBeDefined()
expect(all[id]!.id).toBe(id)
}
})
test('returns data for all species', () => {
const all = getAllSpeciesData()
for (const id of ALL_SPECIES_IDS) {
expect(all[id]).toBeDefined()
expect(all[id]!.id).toBe(id)
}
})
})
describe('DEX_TO_SPECIES', () => {
test('maps dex numbers correctly', () => {
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
expect(DEX_TO_SPECIES[4]).toBe('charmander')
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
})
test('maps dex numbers correctly', () => {
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
expect(DEX_TO_SPECIES[4]).toBe('charmander')
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
})
})
describe('ensureSpeciesData', () => {
test('resolves without error', async () => {
await expect(ensureSpeciesData()).resolves.toBeUndefined()
})
test('resolves without error', async () => {
await expect(ensureSpeciesData()).resolves.toBeUndefined()
})
})
describe('getSpeciesData - supplementary fields', () => {
test('has baseHappiness', () => {
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
})
test('has baseHappiness', () => {
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
})
test('pikachu has higher captureRate', () => {
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
})
test('pikachu has higher captureRate', () => {
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
})
test('has names with en key', () => {
const data = getSpeciesData('charmander')
expect(data.names).toBeDefined()
expect(data.names.en).toBe('Charmander')
})
test('has names with en key', () => {
const data = getSpeciesData('charmander')
expect(data.names).toBeDefined()
expect(data.names.en).toBe('Charmander')
})
test('shinyChance is 1/4096', () => {
expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096)
})
test('shinyChance is 1/4096', () => {
expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096)
})
})

View File

@@ -2,28 +2,28 @@ import { describe, test, expect } from 'bun:test'
import { getSpeciesDisplay, loadSprite } from '../core/spriteCache'
describe('getSpeciesDisplay', () => {
test('formats charmander display', () => {
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
})
test('formats charmander display', () => {
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
})
test('formats pikachu display', () => {
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
})
test('formats pikachu display', () => {
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
})
test('formats bulbasaur display', () => {
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
})
test('formats bulbasaur display', () => {
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
})
test('pads dex number to 3 digits', () => {
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
})
test('pads dex number to 3 digits', () => {
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
})
})
describe('loadSprite', () => {
test('returns null when no cache exists', () => {
// Uses a temp directory via getSpritesDir, should return null for non-cached
const result = loadSprite('nonexistent_pokemon' as any)
// Will be null since the file doesn't exist
expect(result).toBeNull()
})
test('returns null when no cache exists', () => {
// Uses a temp directory via getSpritesDir, should return null for non-cached
const result = loadSprite('nonexistent_pokemon' as any)
// Will be null since the file doesn't exist
expect(result).toBeNull()
})
})

View File

@@ -1,380 +1,380 @@
import { describe, test, expect } from 'bun:test'
import {
getDefaultBuddyData,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
updateDailyStats, incrementTurns,
getDefaultBuddyData,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
updateDailyStats, incrementTurns,
} from '../core/storage'
import type { BuddyData } from '../types'
function makeData(creatureCount = 1): BuddyData {
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
id: `creature-${i}`,
speciesId: 'bulbasaur' as const,
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
] as [any, any, any, any],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}))
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
id: `creature-${i}`,
speciesId: 'bulbasaur' as const,
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
] as [any, any, any, any],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}))
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
if (creatureCount > 1) party[1] = creatures[1]!.id
if (creatureCount > 2) party[2] = creatures[2]!.id
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
if (creatureCount > 1) party[1] = creatures[1]!.id
if (creatureCount > 2) party[2] = creatures[2]!.id
return {
version: 2,
party,
boxes: [
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
],
creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 10,
consecutiveDays: 5,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 3,
battlesLost: 1,
},
}
return {
version: 2,
party,
boxes: [
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
],
creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 10,
consecutiveDays: 5,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 3,
battlesLost: 1,
},
}
}
// ─── Default data ───
describe('getDefaultBuddyData', () => {
test('returns v2 data with correct structure', async () => {
const data = await getDefaultBuddyData()
expect(data.version).toBe(2)
expect(data.party.length).toBe(6)
expect(data.party[0]).toBeTruthy()
expect(data.boxes.length).toBe(8)
expect(data.boxes[0]!.slots.length).toBe(30)
expect(data.bag.items).toEqual([])
expect(data.stats.battlesWon).toBe(0)
expect(data.stats.battlesLost).toBe(0)
})
test('returns v2 data with correct structure', async () => {
const data = await getDefaultBuddyData()
expect(data.version).toBe(2)
expect(data.party.length).toBe(6)
expect(data.party[0]).toBeTruthy()
expect(data.boxes.length).toBe(8)
expect(data.boxes[0]!.slots.length).toBe(30)
expect(data.bag.items).toEqual([])
expect(data.stats.battlesWon).toBe(0)
expect(data.stats.battlesLost).toBe(0)
})
test('has one creature matching party[0]', async () => {
const data = await getDefaultBuddyData()
expect(data.creatures.length).toBe(1)
expect(data.creatures[0]!.id).toBe(data.party[0]!)
})
test('has one creature matching party[0]', async () => {
const data = await getDefaultBuddyData()
expect(data.creatures.length).toBe(1)
expect(data.creatures[0]!.id).toBe(data.party[0]!)
})
test('creature has v2 fields', async () => {
const data = await getDefaultBuddyData()
const creature = data.creatures[0]!
expect(creature.nature).toBeTruthy()
expect(creature.moves.length).toBe(4)
expect(creature.ability).toBeTruthy()
expect(creature.heldItem).toBeNull()
expect(creature.pokeball).toBe('pokeball')
})
test('creature has v2 fields', async () => {
const data = await getDefaultBuddyData()
const creature = data.creatures[0]!
expect(creature.nature).toBeTruthy()
expect(creature.moves.length).toBe(4)
expect(creature.ability).toBeTruthy()
expect(creature.heldItem).toBeNull()
expect(creature.pokeball).toBe('pokeball')
})
})
// ─── Party operations ───
describe('addToParty', () => {
test('adds creature to first empty slot', () => {
const data = makeData()
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(true)
expect(result.data.party[1]).toBe('new-creature')
})
test('adds creature to first empty slot', () => {
const data = makeData()
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(true)
expect(result.data.party[1]).toBe('new-creature')
})
test('returns false when party is full', () => {
const data = makeData()
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(false)
})
test('returns false when party is full', () => {
const data = makeData()
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(false)
})
})
describe('removeFromParty', () => {
test('removes creature at index', () => {
const data = makeData(2)
const updated = removeFromParty(data, 1)
expect(updated.party[1]).toBeNull()
})
test('removes creature at index', () => {
const data = makeData(2)
const updated = removeFromParty(data, 1)
expect(updated.party[1]).toBeNull()
})
test('does nothing for out-of-bounds index', () => {
const data = makeData()
const updated = removeFromParty(data, 10)
expect(updated.party).toEqual(data.party)
})
test('does nothing for out-of-bounds index', () => {
const data = makeData()
const updated = removeFromParty(data, 10)
expect(updated.party).toEqual(data.party)
})
})
describe('swapPartySlots', () => {
test('swaps two party slots', () => {
const data = makeData(2)
const updated = swapPartySlots(data, 0, 1)
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
test('swaps two party slots', () => {
const data = makeData(2)
const updated = swapPartySlots(data, 0, 1)
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
})
describe('setActivePartyMember', () => {
test('swaps creature to slot 0', () => {
const data = makeData(2)
const updated = setActivePartyMember(data, 'creature-1')
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
test('swaps creature to slot 0', () => {
const data = makeData(2)
const updated = setActivePartyMember(data, 'creature-1')
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
test('no change if already active', () => {
const data = makeData()
const updated = setActivePartyMember(data, 'creature-0')
expect(updated).toEqual(data)
})
test('no change if already active', () => {
const data = makeData()
const updated = setActivePartyMember(data, 'creature-0')
expect(updated).toEqual(data)
})
})
// ─── PC Box operations ───
describe('depositToBox', () => {
test('deposits creature to first empty box slot', () => {
const data = makeData()
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
})
test('deposits creature to first empty box slot', () => {
const data = makeData()
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
})
test('fills second box when first is full', () => {
const data = makeData()
data.boxes[0]!.slots = Array(30).fill('x')
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
})
test('fills second box when first is full', () => {
const data = makeData()
data.boxes[0]!.slots = Array(30).fill('x')
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
})
})
describe('withdrawFromBox', () => {
test('withdraws creature from box', () => {
const data = makeData()
data.boxes[0]!.slots[5] = 'box-creature'
const result = withdrawFromBox(data, 'box-creature')
expect(result.withdrawn).toBe(true)
expect(result.data.boxes[0]!.slots[5]).toBeNull()
})
test('withdraws creature from box', () => {
const data = makeData()
data.boxes[0]!.slots[5] = 'box-creature'
const result = withdrawFromBox(data, 'box-creature')
expect(result.withdrawn).toBe(true)
expect(result.data.boxes[0]!.slots[5]).toBeNull()
})
test('returns false when creature not in boxes', () => {
const data = makeData()
const result = withdrawFromBox(data, 'nonexistent')
expect(result.withdrawn).toBe(false)
})
test('returns false when creature not in boxes', () => {
const data = makeData()
const result = withdrawFromBox(data, 'nonexistent')
expect(result.withdrawn).toBe(false)
})
})
describe('moveInBox', () => {
test('moves creature between slots', () => {
const data = makeData()
data.boxes[0]!.slots[0] = 'moving-creature'
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated.boxes[0]!.slots[0]).toBeNull()
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
})
test('moves creature between slots', () => {
const data = makeData()
data.boxes[0]!.slots[0] = 'moving-creature'
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated.boxes[0]!.slots[0]).toBeNull()
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
})
test('does nothing for empty source slot', () => {
const data = makeData()
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated).toEqual(data)
})
test('does nothing for empty source slot', () => {
const data = makeData()
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated).toEqual(data)
})
})
describe('renameBox', () => {
test('renames a box', () => {
const data = makeData()
const updated = renameBox(data, 0, 'My Box')
expect(updated.boxes[0]!.name).toBe('My Box')
})
test('renames a box', () => {
const data = makeData()
const updated = renameBox(data, 0, 'My Box')
expect(updated.boxes[0]!.name).toBe('My Box')
})
})
describe('findCreatureLocation', () => {
test('finds creature in party', () => {
const data = makeData()
const loc = findCreatureLocation(data, 'creature-0')
expect(loc).toEqual({ area: 'party', slot: 0 })
})
test('finds creature in party', () => {
const data = makeData()
const loc = findCreatureLocation(data, 'creature-0')
expect(loc).toEqual({ area: 'party', slot: 0 })
})
test('finds creature in box', () => {
const data = makeData()
data.boxes[0]!.slots[3] = 'box-creature'
const loc = findCreatureLocation(data, 'box-creature')
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
})
test('finds creature in box', () => {
const data = makeData()
data.boxes[0]!.slots[3] = 'box-creature'
const loc = findCreatureLocation(data, 'box-creature')
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
})
test('returns null for nonexistent', () => {
const data = makeData()
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
})
test('returns null for nonexistent', () => {
const data = makeData()
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
})
})
describe('releaseCreature', () => {
test('removes creature from party and creatures array', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
})
test('removes creature from party and creatures array', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
})
})
describe('getTotalCreatureCount', () => {
test('returns creature count', () => {
expect(getTotalCreatureCount(makeData(3))).toBe(3)
})
test('returns creature count', () => {
expect(getTotalCreatureCount(makeData(3))).toBe(3)
})
})
describe('getAllCreatureIds', () => {
test('returns all ids', () => {
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
})
test('returns all ids', () => {
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
})
})
// ─── Bag operations ───
describe('addItemToBag', () => {
test('adds new item', () => {
const data = makeData()
const updated = addItemToBag(data, 'potion', 3)
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
})
test('adds new item', () => {
const data = makeData()
const updated = addItemToBag(data, 'potion', 3)
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
})
test('stacks existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const stacked = addItemToBag(withItem, 'potion', 3)
expect(stacked.bag.items[0]!.count).toBe(5)
})
test('stacks existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const stacked = addItemToBag(withItem, 'potion', 3)
expect(stacked.bag.items[0]!.count).toBe(5)
})
})
describe('removeItemFromBag', () => {
test('removes item quantity', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 5)
const result = removeItemFromBag(withItem, 'potion', 3)
expect(result.removed).toBe(true)
expect(result.data.bag.items[0]!.count).toBe(2)
})
test('removes item quantity', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 5)
const result = removeItemFromBag(withItem, 'potion', 3)
expect(result.removed).toBe(true)
expect(result.data.bag.items[0]!.count).toBe(2)
})
test('removes item entirely when count reaches 0', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const result = removeItemFromBag(withItem, 'potion', 2)
expect(result.removed).toBe(true)
expect(result.data.bag.items.length).toBe(0)
})
test('removes item entirely when count reaches 0', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const result = removeItemFromBag(withItem, 'potion', 2)
expect(result.removed).toBe(true)
expect(result.data.bag.items.length).toBe(0)
})
test('returns false when not enough items', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 1)
const result = removeItemFromBag(withItem, 'potion', 5)
expect(result.removed).toBe(false)
})
test('returns false when not enough items', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 1)
const result = removeItemFromBag(withItem, 'potion', 5)
expect(result.removed).toBe(false)
})
test('returns false for nonexistent item', () => {
const data = makeData()
const result = removeItemFromBag(data, 'potion', 1)
expect(result.removed).toBe(false)
})
test('returns false for nonexistent item', () => {
const data = makeData()
const result = removeItemFromBag(data, 'potion', 1)
expect(result.removed).toBe(false)
})
})
describe('getItemCount', () => {
test('returns count for existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 3)
expect(getItemCount(withItem, 'potion')).toBe(3)
})
test('returns count for existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 3)
expect(getItemCount(withItem, 'potion')).toBe(3)
})
test('returns 0 for nonexistent item', () => {
expect(getItemCount(makeData(), 'potion')).toBe(0)
})
test('returns 0 for nonexistent item', () => {
expect(getItemCount(makeData(), 'potion')).toBe(0)
})
})
// ─── Stats ───
describe('updateDailyStats', () => {
test('same day does not increment consecutive', () => {
const data = makeData()
const updated = updateDailyStats(data)
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
})
test('same day does not increment consecutive', () => {
const data = makeData()
const updated = updateDailyStats(data)
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
})
})
describe('incrementTurns', () => {
test('increments totalTurns by 1', () => {
const data = makeData()
const updated = incrementTurns(data)
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
})
test('increments totalTurns by 1', () => {
const data = makeData()
const updated = incrementTurns(data)
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
})
})
// ─── Extended coverage ───
describe('depositToBox - full boxes', () => {
test('fails when all boxes are full', () => {
const data = makeData()
for (const box of data.boxes) {
for (let i = 0; i < 30; i++) {
box.slots[i] = `filler-${i}`
}
}
const result = depositToBox(data, 'test-id')
expect(result.deposited).toBe(false)
})
test('fails when all boxes are full', () => {
const data = makeData()
for (const box of data.boxes) {
for (let i = 0; i < 30; i++) {
box.slots[i] = `filler-${i}`
}
}
const result = depositToBox(data, 'test-id')
expect(result.deposited).toBe(false)
})
})
describe('withdrawFromBox - roundtrip', () => {
test('deposit then withdraw leaves box empty', () => {
const data = makeData()
const deposited = depositToBox(data, 'test-id')
expect(deposited.deposited).toBe(true)
const result = withdrawFromBox(deposited.data, 'test-id')
expect(result.withdrawn).toBe(true)
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
expect(slot).toBeUndefined()
})
test('deposit then withdraw leaves box empty', () => {
const data = makeData()
const deposited = depositToBox(data, 'test-id')
expect(deposited.deposited).toBe(true)
const result = withdrawFromBox(deposited.data, 'test-id')
expect(result.withdrawn).toBe(true)
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
expect(slot).toBeUndefined()
})
})
describe('findCreatureLocation - deposit', () => {
test('finds creature after depositing to box', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const loc = findCreatureLocation(deposited.data, 'box-mon')
expect(loc).not.toBeNull()
expect(loc!.area).toBe('box')
})
test('finds creature after depositing to box', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const loc = findCreatureLocation(deposited.data, 'box-mon')
expect(loc).not.toBeNull()
expect(loc!.area).toBe('box')
})
})
describe('releaseCreature - box', () => {
test('removes creature from box and creatures array', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const released = releaseCreature(deposited.data, 'box-mon')
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
})
test('removes creature from box and creatures array', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const released = releaseCreature(deposited.data, 'box-mon')
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
})
test('clears party slot when releasing party member', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.party[1]).toBeNull()
expect(updated.creatures.length).toBe(1)
})
test('clears party slot when releasing party member', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.party[1]).toBeNull()
expect(updated.creatures.length).toBe(1)
})
})

View File

@@ -1,64 +1,64 @@
import { describe, test, expect } from 'bun:test'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
describe('xpForLevel', () => {
test('returns 0 for level 1', () => {
expect(xpForLevel(1, 'medium-fast')).toBe(0)
})
test('returns 0 for level 1', () => {
expect(xpForLevel(1, 'medium-fast')).toBe(0)
})
test('returns 0 for level 0', () => {
expect(xpForLevel(0, 'medium-fast')).toBe(0)
})
test('returns 0 for level 0', () => {
expect(xpForLevel(0, 'medium-fast')).toBe(0)
})
test('medium-fast: level 5 = 125 XP', () => {
expect(xpForLevel(5, 'medium-fast')).toBe(125)
})
test('medium-fast: level 5 = 125 XP', () => {
expect(xpForLevel(5, 'medium-fast')).toBe(125)
})
test('medium-fast: level 10 = 1000 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
})
test('medium-fast: level 10 = 1000 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
})
test('slow: level 5 = 156 XP', () => {
expect(xpForLevel(5, 'slow')).toBe(156)
})
test('slow: level 5 = 156 XP', () => {
expect(xpForLevel(5, 'slow')).toBe(156)
})
test('fast: level 5 = 100 XP', () => {
expect(xpForLevel(5, 'fast')).toBe(100)
})
test('fast: level 5 = 100 XP', () => {
expect(xpForLevel(5, 'fast')).toBe(100)
})
})
describe('levelFromXp', () => {
test('returns 1 for 0 XP', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('returns 1 for 0 XP', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('returns 5 for 125 XP medium-fast', () => {
expect(levelFromXp(125, 'medium-fast')).toBe(5)
})
test('returns 5 for 125 XP medium-fast', () => {
expect(levelFromXp(125, 'medium-fast')).toBe(5)
})
test('caps at 100', () => {
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
})
test('caps at 100', () => {
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
})
test('roundtrip: xpForLevel then levelFromXp', () => {
for (let lv = 1; lv <= 100; lv += 10) {
const xp = xpForLevel(lv, 'medium-fast')
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
}
})
test('roundtrip: xpForLevel then levelFromXp', () => {
for (let lv = 1; lv <= 100; lv += 10) {
const xp = xpForLevel(lv, 'medium-fast')
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
}
})
})
describe('xpToNextLevel', () => {
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
})
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
})
test('returns difference to next level', () => {
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
})
test('returns difference to next level', () => {
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
})
test('returns full next level XP from 0', () => {
expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8
})
test('returns full next level XP from 0', () => {
expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8
})
})

View File

@@ -4,10 +4,10 @@ import type { BattlePokemon } from './types'
* Simple AI: pick a random usable move.
*/
export function chooseAIMove(pokemon: BattlePokemon): number {
const usable = pokemon.moves
.map((m, i) => ({ move: m, index: i }))
.filter(({ move }) => move.pp > 0 && !move.disabled)
const usable = pokemon.moves
.map((m, i) => ({ move: m, index: i }))
.filter(({ move }) => move.pp > 0 && !move.disabled)
if (usable.length === 0) return 0 // Struggle
return usable[Math.floor(Math.random() * usable.length)]!.index
if (usable.length === 0) return 0 // Struggle
return usable[Math.floor(Math.random() * usable.length)]!.index
}

View File

@@ -1,7 +1,7 @@
import { Battle, Teams, toID } from '@pkmn/sim'
import { Dex } from '@pkmn/sim'
import type { Creature, SpeciesId } from '../types'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../data/pkmn'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
import { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
import { chooseAIMove } from './ai'
@@ -9,291 +9,291 @@ import { chooseAIMove } from './ai'
// ─── Adapter: Creature → Showdown Set ───
function creatureToSetString(creature: Creature): string {
const species = Dex.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
const species = Dex.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
const moves = creature.moves
.filter(m => m.id)
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
const moves = creature.moves
.filter(m => m.id)
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
const DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
const formatStatLine = (vals: Record<string, number>) =>
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
const ivs = formatStatLine(creature.iv)
const evs = formatStatLine(creature.ev)
const DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
const formatStatLine = (vals: Record<string, number>) =>
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
const ivs = formatStatLine(creature.iv)
const evs = formatStatLine(creature.ev)
const lines = [
species.name,
`Level: ${creature.level}`,
`Ability: ${abilityName}`,
`Nature: ${natureName}`,
`IVs: ${ivs}`,
`EVs: ${evs}`,
]
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
for (const move of moves) lines.push(`- ${move}`)
const lines = [
species.name,
`Level: ${creature.level}`,
`Ability: ${abilityName}`,
`Nature: ${natureName}`,
`IVs: ${ivs}`,
`EVs: ${evs}`,
]
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
for (const move of moves) lines.push(`- ${move}`)
return lines.join('\n')
return lines.join('\n')
}
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
const species = Dex.species.get(speciesId)
if (!species) throw new Error(`Species ${speciesId} not found`)
const ability = species.abilities['0'] ?? ''
// Get first 4 level-up moves (from species data)
const moves = getSpeciesMoves(speciesId, level)
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
const species = Dex.species.get(speciesId)
if (!species) throw new Error(`Species ${speciesId} not found`)
const ability = species.abilities['0'] ?? ''
// Get first 4 level-up moves (from species data)
const moves = getSpeciesMoves(speciesId, level)
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
}
function getSpeciesMoves(speciesId: string, _level: number): string[] {
// In @pkmn/sim, Dex.species doesn't expose learnsets directly.
// Use common moves that exist in the sim's data for basic battles.
// The actual move pool is resolved by the Battle engine during construction.
const species = Dex.species.get(speciesId)
if (!species) return ['Tackle']
// Use type-appropriate basic moves as fallback
const type = species.types[0]?.toLowerCase() ?? 'normal'
const basicMoves: Record<string, string[]> = {
normal: ['Tackle', 'Scratch'],
fire: ['Ember', 'FireSpin'],
water: ['WaterGun', 'Bubble'],
grass: ['VineWhip', 'RazorLeaf'],
electric: ['ThunderShock', 'Spark'],
poison: ['PoisonSting', 'Smog'],
ice: ['IceShard', 'PowderSnow'],
fighting: ['KarateChop', 'LowKick'],
ground: ['MudSlap', 'SandAttack'],
flying: ['Gust', 'WingAttack'],
psychic: ['Confusion', 'Psybeam'],
bug: ['BugBite', 'StringShot'],
rock: ['RockThrow', 'SandAttack'],
ghost: ['Lick', 'ShadowSneak'],
dragon: ['DragonRage', 'Twister'],
dark: ['Bite', 'Pursuit'],
steel: ['MetalClaw', 'IronTail'],
fairy: ['FairyWind', 'DisarmingVoice'],
}
return basicMoves[type] ?? ['Tackle', 'Scratch']
// In @pkmn/sim, Dex.species doesn't expose learnsets directly.
// Use common moves that exist in the sim's data for basic battles.
// The actual move pool is resolved by the Battle engine during construction.
const species = Dex.species.get(speciesId)
if (!species) return ['Tackle']
// Use type-appropriate basic moves as fallback
const type = species.types[0]?.toLowerCase() ?? 'normal'
const basicMoves: Record<string, string[]> = {
normal: ['Tackle', 'Scratch'],
fire: ['Ember', 'FireSpin'],
water: ['WaterGun', 'Bubble'],
grass: ['VineWhip', 'RazorLeaf'],
electric: ['ThunderShock', 'Spark'],
poison: ['PoisonSting', 'Smog'],
ice: ['IceShard', 'PowderSnow'],
fighting: ['KarateChop', 'LowKick'],
ground: ['MudSlap', 'SandAttack'],
flying: ['Gust', 'WingAttack'],
psychic: ['Confusion', 'Psybeam'],
bug: ['BugBite', 'StringShot'],
rock: ['RockThrow', 'SandAttack'],
ghost: ['Lick', 'ShadowSneak'],
dragon: ['DragonRage', 'Twister'],
dark: ['Bite', 'Pursuit'],
steel: ['MetalClaw', 'IronTail'],
fairy: ['FairyWind', 'DisarmingVoice'],
}
return basicMoves[type] ?? ['Tackle', 'Scratch']
}
// ─── State Projection ───
function projectPokemon(pkm: any): BattlePokemon {
if (!pkm) throw new Error('No active pokemon')
const species = pkm.species
const hp = pkm.hp ?? 0
const maxHp = pkm.maxhp ?? 1
if (!pkm) throw new Error('No active pokemon')
const species = pkm.species
const hp = pkm.hp ?? 0
const maxHp = pkm.maxhp ?? 1
return {
id: pkm.name, // sim doesn't store our UUID, use name as temp id
speciesId: toID(species.name) as SpeciesId,
name: species.name,
level: pkm.level,
hp,
maxHp,
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({
id: toID(m.name ?? m),
name: m.name ?? m,
type: m.type ?? 'Normal',
pp: m.pp ?? 0,
maxPp: m.maxPp ?? m.pp ?? 0,
disabled: m.disabled ?? false,
})),
ability: pkm.ability ?? '',
heldItem: pkm.item ?? null,
status: mapStatus(pkm.status),
statStages: projectBoosts(pkm.boosts),
}
return {
id: pkm.name, // sim doesn't store our UUID, use name as temp id
speciesId: toID(species.name) as SpeciesId,
name: species.name,
level: pkm.level,
hp,
maxHp,
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({
id: toID(m.name ?? m),
name: m.name ?? m,
type: m.type ?? 'Normal',
pp: m.pp ?? 0,
maxPp: m.maxPp ?? m.pp ?? 0,
disabled: m.disabled ?? false,
})),
ability: pkm.ability ?? '',
heldItem: pkm.item ?? null,
status: mapStatus(pkm.status),
statStages: projectBoosts(pkm.boosts),
}
}
function mapStatus(status: string): StatusCondition {
if (!status) return 'none'
const s = status.toLowerCase()
if (s === 'psn') return 'poison'
if (s === 'tox') return 'bad_poison'
if (s === 'brn') return 'burn'
if (s === 'par') return 'paralysis'
if (s === 'frz') return 'freeze'
if (s === 'slp') return 'sleep'
return 'none'
if (!status) return 'none'
const s = status.toLowerCase()
if (s === 'psn') return 'poison'
if (s === 'tox') return 'bad_poison'
if (s === 'brn') return 'burn'
if (s === 'par') return 'paralysis'
if (s === 'frz') return 'freeze'
if (s === 'slp') return 'sleep'
return 'none'
}
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
if (!boosts) return {}
const result: Record<string, number> = {}
for (const [k, v] of Object.entries(boosts)) {
const mapped = FROM_DEX_STAT[k]
if (mapped) result[mapped] = v
else result[k] = v
}
return result
if (!boosts) return {}
const result: Record<string, number> = {}
for (const [k, v] of Object.entries(boosts)) {
const mapped = FROM_DEX_STAT[k]
if (mapped) result[mapped] = v
else result[k] = v
}
return result
}
// ─── Log Parsing ───
function parseLogToEvents(log: string[]): BattleEvent[] {
const events: BattleEvent[] = []
const parseSide = (s: string | undefined): 'player' | 'opponent' =>
s?.startsWith('p1a') ? 'player' : 'opponent'
const events: BattleEvent[] = []
const parseSide = (s: string | undefined): 'player' | 'opponent' =>
s?.startsWith('p1a') ? 'player' : 'opponent'
for (const line of log) {
const parts = line.split('|')
const side = parseSide(parts[2])
for (const line of log) {
const parts = line.split('|')
const side = parseSide(parts[2])
if (line.startsWith('|move|')) {
events.push({ type: 'move', side, move: parts[3], user: parts[2] })
} else if (line.startsWith('|-damage|')) {
const [cur, max] = parseHpString(parts[3])
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
} else if (line.startsWith('|-heal|')) {
const [cur, max] = parseHpString(parts[3])
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
} else if (line.startsWith('|faint|')) {
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
} else if (line.startsWith('|switch|')) {
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
} else if (line.startsWith('|-supereffective|')) {
events.push({ type: 'effectiveness', multiplier: 2 })
} else if (line.startsWith('|-resisted|')) {
events.push({ type: 'effectiveness', multiplier: 0.5 })
} else if (line.startsWith('|-crit|')) {
events.push({ type: 'crit' })
} else if (line.startsWith('|-miss|')) {
events.push({ type: 'miss', side })
} else if (line.startsWith('|-status|')) {
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
events.push({ type: 'statChange', side, stat: parts[3], stages })
} else if (line.startsWith('|-ability|')) {
events.push({ type: 'ability', side, ability: parts[3] })
} else if (line.startsWith('|turn|')) {
events.push({ type: 'turn', number: parseInt(parts[2]) })
}
}
return events
if (line.startsWith('|move|')) {
events.push({ type: 'move', side, move: parts[3], user: parts[2] })
} else if (line.startsWith('|-damage|')) {
const [cur, max] = parseHpString(parts[3])
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
} else if (line.startsWith('|-heal|')) {
const [cur, max] = parseHpString(parts[3])
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
} else if (line.startsWith('|faint|')) {
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
} else if (line.startsWith('|switch|')) {
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
} else if (line.startsWith('|-supereffective|')) {
events.push({ type: 'effectiveness', multiplier: 2 })
} else if (line.startsWith('|-resisted|')) {
events.push({ type: 'effectiveness', multiplier: 0.5 })
} else if (line.startsWith('|-crit|')) {
events.push({ type: 'crit' })
} else if (line.startsWith('|-miss|')) {
events.push({ type: 'miss', side })
} else if (line.startsWith('|-status|')) {
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
events.push({ type: 'statChange', side, stat: parts[3], stages })
} else if (line.startsWith('|-ability|')) {
events.push({ type: 'ability', side, ability: parts[3] })
} else if (line.startsWith('|turn|')) {
events.push({ type: 'turn', number: parseInt(parts[2]) })
}
}
return events
}
function parseHpString(hpStr: string): [number, number] {
if (!hpStr) return [0, 1]
// Remove status suffix like "[1]"
const clean = hpStr.replace(/\[.*\]/, '')
const parts = clean.split('/')
if (parts.length !== 2) return [0, 1]
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
if (!hpStr) return [0, 1]
// Remove status suffix like "[1]"
const clean = hpStr.replace(/\[.*\]/, '')
const parts = clean.split('/')
if (parts.length !== 2) return [0, 1]
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
}
// ─── Engine ───
export type BattleInit = {
battle: any // @pkmn/sim Battle instance
state: BattleState
battle: any // @pkmn/sim Battle instance
state: BattleState
}
export function createBattle(
partyCreatures: Creature[],
opponentSpeciesId: SpeciesId,
opponentLevel: number,
_bagItems?: { id: string; count: number }[],
partyCreatures: Creature[],
opponentSpeciesId: SpeciesId,
opponentLevel: number,
_bagItems?: { id: string; count: number }[],
): BattleInit {
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
const p1Team = Teams.import(p1Sets.join('\n\n'))
const p2Team = Teams.import(p2Set)
const p1Team = Teams.import(p1Sets.join('\n\n'))
const p2Team = Teams.import(p2Set)
// Create battle
const battle = new Battle({
formatid: 'gen9customgame' as any,
p1: { name: 'Player', team: p1Team },
p2: { name: 'Opponent', team: p2Team },
})
// Create battle
const battle = new Battle({
formatid: 'gen9customgame' as any,
p1: { name: 'Player', team: p1Team },
p2: { name: 'Opponent', team: p2Team },
})
// Handle team preview → auto-select leads
battle.makeChoices('team 1', 'team 1')
// Handle team preview → auto-select leads
battle.makeChoices('team 1', 'team 1')
// Project initial state
const state = projectState(battle, _bagItems)
return { battle, state }
// Project initial state
const state = projectState(battle, _bagItems)
return { battle, state }
}
export function executeTurn(
battleInit: BattleInit,
action: PlayerAction,
battleInit: BattleInit,
action: PlayerAction,
): BattleState {
const { battle } = battleInit
const prevLogLen = battle.log.length
const { battle } = battleInit
const prevLogLen = battle.log.length
// Build choice string
let p1Choice: string
switch (action.type) {
case 'move':
p1Choice = `move ${action.moveIndex + 1}`
break
case 'switch': {
// Find the party slot number for this creature (sim uses 1-based index)
const p1Pokemon: any[] = battle.p1.pokemon
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
break
}
case 'item':
p1Choice = 'move 1' // Items handled via settlement
break
default:
p1Choice = 'move 1'
}
// Build choice string
let p1Choice: string
switch (action.type) {
case 'move':
p1Choice = `move ${action.moveIndex + 1}`
break
case 'switch': {
// Find the party slot number for this creature (sim uses 1-based index)
const p1Pokemon: any[] = battle.p1.pokemon
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
break
}
case 'item':
p1Choice = 'move 1' // Items handled via settlement
break
default:
p1Choice = 'move 1'
}
// AI choice
const aiPokemon = projectPokemon(battle.p2.active[0])
const aiMoveIndex = chooseAIMove(aiPokemon)
const p2Choice = `move ${aiMoveIndex + 1}`
// AI choice
const aiPokemon = projectPokemon(battle.p2.active[0])
const aiMoveIndex = chooseAIMove(aiPokemon)
const p2Choice = `move ${aiMoveIndex + 1}`
// Execute
battle.makeChoices(p1Choice, p2Choice)
// Execute
battle.makeChoices(p1Choice, p2Choice)
// Parse new log entries
const newLog = battle.log.slice(prevLogLen)
const newEvents = parseLogToEvents(newLog)
// Parse new log entries
const newLog = battle.log.slice(prevLogLen)
const newEvents = parseLogToEvents(newLog)
// Project new state
const state = projectState(battle, battleInit.state.usableItems)
state.events = [...battleInit.state.events, ...newEvents]
// Project new state
const state = projectState(battle, battleInit.state.usableItems)
state.events = [...battleInit.state.events, ...newEvents]
// Check for battle end
if (battle.ended) {
state.finished = true
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
state.result = {
winner,
turns: state.turn,
xpGained: 0, // calculated in settlement
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [],
}
}
// Check for battle end
if (battle.ended) {
state.finished = true
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
state.result = {
winner,
turns: state.turn,
xpGained: 0, // calculated in settlement
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [],
}
}
battleInit.state = state
return state
battleInit.state = state
return state
}
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
const p1 = battle.p1
const p2 = battle.p2
const p1 = battle.p1
const p2 = battle.p2
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
turn: battle.turn ?? 1,
events: [],
finished: battle.ended,
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
}
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
turn: battle.turn ?? 1,
events: [],
finished: battle.ended,
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
}
}

View File

@@ -1,191 +1,191 @@
import type { StatName, SpeciesId } from '../types'
import { STAT_NAMES } from '../types'
import { TO_DEX_STAT } from '../data/pkmn'
import { TO_DEX_STAT } from '../dex/pkmn'
import type { BattleResult } from './types'
import type { BuddyData } from '../types'
import { levelFromXp } from '../data/xpTable'
import { getSpeciesData } from '../data/species'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { levelFromXp } from '../dex/xpTable'
import { getSpeciesData } from '../dex/species'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
import { Dex } from '@pkmn/sim'
/**
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
*/
export async function settleBattle(
data: BuddyData,
result: BattleResult,
opponentSpeciesId: SpeciesId,
opponentLevel: number,
data: BuddyData,
result: BattleResult,
opponentSpeciesId: SpeciesId,
opponentLevel: number,
): Promise<{
data: BuddyData
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
data: BuddyData
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
}> {
if (result.winner !== 'player') {
return { data, learnableMoves: [], pendingEvolutions: [] }
}
if (result.winner !== 'player') {
return { data, learnableMoves: [], pendingEvolutions: [] }
}
// Calculate XP reward (simplified: base XP from species)
const oppSpecies = Dex.species.get(opponentSpeciesId)
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
const xpGained = Math.max(1, Math.floor(baseXp))
// Calculate XP reward (simplified: base XP from species)
const oppSpecies = Dex.species.get(opponentSpeciesId)
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
const xpGained = Math.max(1, Math.floor(baseXp))
// Calculate EV reward
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
const evYield = getEvYield(opponentSpeciesId)
for (const stat of STAT_NAMES) {
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
}
// Calculate EV reward
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
const evYield = getEvYield(opponentSpeciesId)
for (const stat of STAT_NAMES) {
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
}
// Award XP/EV to participant creatures
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
// Award XP/EV to participant creatures
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
const updatedCreatures: typeof data.creatures = []
for (const creature of data.creatures) {
if (!participantIds.has(creature.id)) {
updatedCreatures.push(creature)
continue
}
const updatedCreatures: typeof data.creatures = []
for (const creature of data.creatures) {
if (!participantIds.has(creature.id)) {
updatedCreatures.push(creature)
continue
}
// Award EVs (capped)
const newEv = { ...creature.ev }
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
for (const stat of STAT_NAMES) {
if (totalEV >= MAX_EV_TOTAL) break
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
newEv[stat] += gain
totalEV += gain
}
// Award EVs (capped)
const newEv = { ...creature.ev }
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
for (const stat of STAT_NAMES) {
if (totalEV >= MAX_EV_TOTAL) break
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
newEv[stat] += gain
totalEV += gain
}
// Award XP
const oldLevel = creature.level
const newTotalXp = creature.totalXp + xpGained
const species = getSpeciesData(creature.speciesId)
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
// Award XP
const oldLevel = creature.level
const newTotalXp = creature.totalXp + xpGained
const species = getSpeciesData(creature.speciesId)
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
// Detect new learnable moves on level up
if (newLevel > oldLevel) {
const learnset = await Dex.learnsets.get(creature.speciesId)
if (learnset?.learnset) {
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith('9L')) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
learnableMoves.push({
creatureId: creature.id,
moveId,
moveName: dexMove?.name ?? moveId,
})
}
break
}
}
}
}
}
// Detect new learnable moves on level up
if (newLevel > oldLevel) {
const learnset = await Dex.learnsets.get(creature.speciesId)
if (learnset?.learnset) {
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith('9L')) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
learnableMoves.push({
creatureId: creature.id,
moveId,
moveName: dexMove?.name ?? moveId,
})
}
break
}
}
}
}
}
// Detect evolution
if (newLevel > oldLevel) {
const species = Dex.species.get(creature.speciesId)
if (species?.evos?.length) {
const targetId = species.evos[0]!.toLowerCase()
const target = Dex.species.get(targetId)
if (target?.evoLevel && newLevel >= target.evoLevel) {
pendingEvolutions.push({
creatureId: creature.id,
from: creature.speciesId,
to: targetId as SpeciesId,
})
}
}
}
// Detect evolution
if (newLevel > oldLevel) {
const species = Dex.species.get(creature.speciesId)
if (species?.evos?.length) {
const targetId = species.evos[0]!.toLowerCase()
const target = Dex.species.get(targetId)
if (target?.evoLevel && newLevel >= target.evoLevel) {
pendingEvolutions.push({
creatureId: creature.id,
from: creature.speciesId,
to: targetId as SpeciesId,
})
}
}
}
updatedCreatures.push({
...creature,
level: newLevel,
totalXp: newTotalXp,
ev: newEv,
})
}
updatedCreatures.push({
...creature,
level: newLevel,
totalXp: newTotalXp,
ev: newEv,
})
}
// Update data
const updatedData: BuddyData = {
...data,
creatures: updatedCreatures,
stats: {
...data.stats,
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0),
},
}
// Update data
const updatedData: BuddyData = {
...data,
creatures: updatedCreatures,
stats: {
...data.stats,
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0),
},
}
return { data: updatedData, learnableMoves, pendingEvolutions }
return { data: updatedData, learnableMoves, pendingEvolutions }
}
/**
* Apply move learning - replace a move at the given index.
*/
export function applyMoveLearn(
data: BuddyData,
creatureId: string,
moveId: string,
replaceIndex: number,
data: BuddyData,
creatureId: string,
moveId: string,
replaceIndex: number,
): BuddyData {
return {
...data,
creatures: data.creatures.map(c => {
if (c.id !== creatureId) return c
const dexMove = Dex.moves.get(moveId)
const newMoves = [...c.moves] as typeof c.moves
newMoves[replaceIndex] = {
id: moveId,
pp: dexMove?.pp ?? 10,
maxPp: dexMove?.pp ?? 10,
}
return { ...c, moves: newMoves as typeof c.moves }
}),
}
return {
...data,
creatures: data.creatures.map(c => {
if (c.id !== creatureId) return c
const dexMove = Dex.moves.get(moveId)
const newMoves = [...c.moves] as typeof c.moves
newMoves[replaceIndex] = {
id: moveId,
pp: dexMove?.pp ?? 10,
maxPp: dexMove?.pp ?? 10,
}
return { ...c, moves: newMoves as typeof c.moves }
}),
}
}
/**
* Apply evolution to a creature.
*/
export function applyEvolution(
data: BuddyData,
creatureId: string,
newSpeciesId: SpeciesId,
data: BuddyData,
creatureId: string,
newSpeciesId: SpeciesId,
): BuddyData {
return {
...data,
creatures: data.creatures.map(c =>
c.id === creatureId
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
: c,
),
stats: {
...data.stats,
totalEvolutions: data.stats.totalEvolutions + 1,
},
}
return {
...data,
creatures: data.creatures.map(c =>
c.id === creatureId
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
: c,
),
stats: {
...data.stats,
totalEvolutions: data.stats.totalEvolutions + 1,
},
}
}
function getEvYield(speciesId: string): Record<string, number> {
// @pkmn/sim Dex.species doesn't have evs field
// Use baseStats as proxy: highest base stat gets 1-2 EVs
const species = Dex.species.get(speciesId)
if (!species?.baseStats) return {}
const stats = species.baseStats as Record<string, number>
const entries = Object.entries(stats)
if (entries.length === 0) return {}
// Sort by value descending, give 1-2 EV to top stats
entries.sort((a, b) => b[1] - a[1])
const result: Record<string, number> = {}
// Top stat gets 2 EVs, second gets 1
if (entries[0]) result[entries[0][0]] = 2
if (entries[1]) result[entries[1][0]] = 1
return result
// @pkmn/sim Dex.species doesn't have evs field
// Use baseStats as proxy: highest base stat gets 1-2 EVs
const species = Dex.species.get(speciesId)
if (!species?.baseStats) return {}
const stats = species.baseStats as Record<string, number>
const entries = Object.entries(stats)
if (entries.length === 0) return {}
// Sort by value descending, give 1-2 EV to top stats
entries.sort((a, b) => b[1] - a[1])
const result: Record<string, number> = {}
// Top stat gets 2 EVs, second gets 1
if (entries[0]) result[entries[0][0]] = 2
if (entries[1]) result[entries[1][0]] = 1
return result
}

View File

@@ -3,66 +3,66 @@ import type { StatName, SpeciesId } from '../types'
export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'none'
export type BattlePokemon = {
id: string // creature ID
speciesId: SpeciesId
name: string
level: number
hp: number // current HP in battle
maxHp: number
types: string[]
moves: MoveOption[]
ability: string
heldItem: string | null
status: StatusCondition
statStages: Record<string, number> // -6 to +6
id: string // creature ID
speciesId: SpeciesId
name: string
level: number
hp: number // current HP in battle
maxHp: number
types: string[]
moves: MoveOption[]
ability: string
heldItem: string | null
status: StatusCondition
statStages: Record<string, number> // -6 to +6
}
export type MoveOption = {
id: string
name: string
type: string
pp: number
maxPp: number
disabled: boolean
id: string
name: string
type: string
pp: number
maxPp: number
disabled: boolean
}
export type PlayerAction =
| { type: 'move'; moveIndex: number }
| { type: 'switch'; creatureId: string }
| { type: 'item'; itemId: string }
| { type: 'move'; moveIndex: number }
| { type: 'switch'; creatureId: string }
| { type: 'item'; itemId: string }
export type BattleEvent =
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
| { type: 'effectiveness'; multiplier: number }
| { type: 'crit' }
| { type: 'miss'; side: 'player' | 'opponent' }
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
| { type: 'item'; side: 'player' | 'opponent'; item: string }
| { type: 'fail'; reason: string }
| { type: 'turn'; number: number }
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
| { type: 'effectiveness'; multiplier: number }
| { type: 'crit' }
| { type: 'miss'; side: 'player' | 'opponent' }
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
| { type: 'item'; side: 'player' | 'opponent'; item: string }
| { type: 'fail'; reason: string }
| { type: 'turn'; number: number }
export type BattleResult = {
winner: 'player' | 'opponent'
turns: number
xpGained: number
evGained: Record<StatName, number>
participantIds: string[]
winner: 'player' | 'opponent'
turns: number
xpGained: number
evGained: Record<StatName, number>
participantIds: string[]
}
export type BattleState = {
playerPokemon: BattlePokemon
opponentPokemon: BattlePokemon
playerParty: BattlePokemon[]
opponentParty: BattlePokemon[]
turn: number
events: BattleEvent[]
finished: boolean
result?: BattleResult
usableItems: { id: string; name: string; count: number }[]
playerPokemon: BattlePokemon
opponentPokemon: BattlePokemon
playerParty: BattlePokemon[]
opponentParty: BattlePokemon[]
turn: number
events: BattleEvent[]
finished: boolean
result?: BattleResult
usableItems: { id: string; name: string; count: number }[]
}

View File

@@ -1,47 +1,47 @@
import { randomUUID } from 'node:crypto'
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
import { STAT_NAMES } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { determineGender } from './gender'
import { levelFromXp } from '../data/xpTable'
import { gen, TO_DEX_STAT } from '../data/pkmn'
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
import { randomNature } from '../data/nature'
import { levelFromXp } from '../dex/xpTable'
import { gen, TO_DEX_STAT } from '../dex/pkmn'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
/**
* Generate a new creature of the given species.
*/
export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise<Creature> {
const species = getSpeciesData(speciesId)
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
const species = getSpeciesData(speciesId)
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
// Generate IVs (0-31) using simple hash from seed
const iv = generateIVs(actualSeed)
// Generate IVs (0-31) using simple hash from seed
const iv = generateIVs(actualSeed)
// Determine gender
const gender = determineGender(species, actualSeed & 0xff)
// Determine gender
const gender = determineGender(species, actualSeed & 0xff)
// Determine shiny status
const isShiny = Math.random() < species.shinyChance
// Determine shiny status
const isShiny = Math.random() < species.shinyChance
return {
id: randomUUID(),
speciesId,
gender,
level: 1,
xp: 0,
totalXp: 0,
nature: randomNature(),
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv,
moves: await getDefaultMoveset(speciesId, 1),
ability: getDefaultAbility(speciesId),
heldItem: null,
friendship: species.baseHappiness,
isShiny,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
return {
id: randomUUID(),
speciesId,
gender,
level: 1,
xp: 0,
totalXp: 0,
nature: randomNature(),
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv,
moves: await getDefaultMoveset(speciesId, 1),
ability: getDefaultAbility(speciesId),
heldItem: null,
friendship: species.baseHappiness,
isShiny,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
/**
@@ -49,47 +49,47 @@ export async function generateCreature(speciesId: SpeciesId, seed?: number): Pro
* Handles base stats, IV, EV, level, and nature correction internally.
*/
export function calculateStats(creature: Creature): StatsResult {
const species = gen.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
const species = gen.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
// Get nature if creature has one (Phase 1 adds nature field)
const nature = 'nature' in creature && creature.nature
? gen.natures.get(creature.nature as string)
: undefined
// Get nature if creature has one (Phase 1 adds nature field)
const nature = 'nature' in creature && creature.nature
? gen.natures.get(creature.nature as string)
: undefined
const result = {} as StatsResult
for (const stat of STAT_NAMES) {
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
result[stat] = gen.stats.calc(
dexKey,
species.baseStats[dexKey],
creature.iv[stat],
creature.ev[stat],
creature.level,
nature ?? undefined,
)
}
return result
const result = {} as StatsResult
for (const stat of STAT_NAMES) {
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
result[stat] = gen.stats.calc(
dexKey,
species.baseStats[dexKey],
creature.iv[stat],
creature.ev[stat],
creature.level,
nature ?? undefined,
)
}
return result
}
/**
* Get display name for a creature (nickname or species name).
*/
export function getCreatureName(creature: Creature): string {
if (creature.nickname) return creature.nickname
return getSpeciesData(creature.speciesId).name
if (creature.nickname) return creature.nickname
return getSpeciesData(creature.speciesId).name
}
/**
* Recalculate level from total XP (e.g. after XP gain).
*/
export function recalculateLevel(creature: Creature): Creature {
const species = getSpeciesData(creature.speciesId)
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
if (newLevel !== creature.level) {
return { ...creature, level: newLevel }
}
return creature
const species = getSpeciesData(creature.speciesId)
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
if (newLevel !== creature.level) {
return { ...creature, level: newLevel }
}
return creature
}
/**
@@ -97,33 +97,33 @@ export function recalculateLevel(creature: Creature): Creature {
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
*/
export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
if (!activeId) return null
return buddyData.creatures.find((c) => c.id === activeId) ?? null
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
if (!activeId) return null
return buddyData.creatures.find((c) => c.id === activeId) ?? null
}
/**
* Generate IVs from a seed value. Each stat gets 0-31.
*/
function generateIVs(seed: number): Record<StatName, number> {
let s = seed
const nextRand = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff
return s
}
return {
hp: nextRand() % 32,
attack: nextRand() % 32,
defense: nextRand() % 32,
spAtk: nextRand() % 32,
spDef: nextRand() % 32,
speed: nextRand() % 32,
}
let s = seed
const nextRand = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff
return s
}
return {
hp: nextRand() % 32,
attack: nextRand() % 32,
defense: nextRand() % 32,
spAtk: nextRand() % 32,
spDef: nextRand() % 32,
speed: nextRand() % 32,
}
}
/**
* Get total EV across all stats.
*/
export function getTotalEV(creature: Creature): number {
return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0)
return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0)
}

View File

@@ -1,6 +1,6 @@
import type { Creature, StatName } from '../types'
import { STAT_NAMES } from '../types'
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../data/evMapping'
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../dex/evMapping'
import { getTotalEV } from './creature'
// Track last EV award time per tool to enforce cooldown
@@ -10,7 +10,7 @@ const evCooldowns = new Map<string, number>()
* Reset EV cooldown state (for testing).
*/
export function resetEVCooldowns(): void {
evCooldowns.clear()
evCooldowns.clear()
}
/**
@@ -18,35 +18,35 @@ export function resetEVCooldowns(): void {
* Returns updated creature and actual EV awarded.
*/
export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature {
const now = timestamp ?? Date.now()
const now = timestamp ?? Date.now()
// Check cooldown
const lastTime = evCooldowns.get(toolName)
if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature
// Check cooldown
const lastTime = evCooldowns.get(toolName)
if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature
const currentTotal = getTotalEV(creature)
if (currentTotal >= MAX_EV_TOTAL) return creature
const currentTotal = getTotalEV(creature)
if (currentTotal >= MAX_EV_TOTAL) return creature
let evGains = getEVForTool(toolName)
if (!evGains) {
// Random EV for unmapped tools
evGains = generateRandomEV()
}
let evGains = getEVForTool(toolName)
if (!evGains) {
// Random EV for unmapped tools
evGains = generateRandomEV()
}
const updated = { ...creature, ev: { ...creature.ev } }
for (const stat of STAT_NAMES) {
const gain = evGains[stat]
if (gain > 0) {
const current = updated.ev[stat]
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
if (canAdd > 0) {
updated.ev[stat] = current + canAdd
}
}
}
const updated = { ...creature, ev: { ...creature.ev } }
for (const stat of STAT_NAMES) {
const gain = evGains[stat]
if (gain > 0) {
const current = updated.ev[stat]
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
if (canAdd > 0) {
updated.ev[stat] = current + canAdd
}
}
}
evCooldowns.set(toolName, now)
return updated
evCooldowns.set(toolName, now)
return updated
}
/**
@@ -54,45 +54,45 @@ export function awardEV(creature: Creature, toolName: string, timestamp?: number
* Deduplicates tool names and spaces timestamps to avoid cooldown issues.
*/
export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature {
const uniqueTools = [...new Set(toolNames)]
const baseTime = timestamp ?? Date.now()
let current = creature
for (let i = 0; i < uniqueTools.length; i++) {
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
}
return current
const uniqueTools = [...new Set(toolNames)]
const baseTime = timestamp ?? Date.now()
let current = creature
for (let i = 0; i < uniqueTools.length; i++) {
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
}
return current
}
/**
* Generate random 1-2 EV points in a random stat.
*/
function generateRandomEV(): Record<StatName, number> {
const stats = [...STAT_NAMES]
const stat = stats[Math.floor(Math.random() * stats.length)]
const amount = Math.random() < 0.5 ? 1 : 2
const result: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
result[stat] = amount
return result
const stats = [...STAT_NAMES]
const stat = stats[Math.floor(Math.random() * stats.length)]
const amount = Math.random() < 0.5 ? 1 : 2
const result: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
result[stat] = amount
return result
}
/**
* Get formatted EV summary string.
*/
export function getEVSummary(creature: Creature): string {
const parts: string[] = []
for (const stat of STAT_NAMES) {
const val = creature.ev[stat]
if (val > 0) {
const labels: Record<StatName, string> = {
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
}
parts.push(`${labels[stat]}+${val}`)
}
}
return parts.join(' ') || 'None'
const parts: string[] = []
for (const stat of STAT_NAMES) {
const val = creature.ev[stat]
if (val > 0) {
const labels: Record<StatName, string> = {
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
}
parts.push(`${labels[stat]}+${val}`)
}
}
return parts.join(' ') || 'None'
}

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { generateCreature } from './creature'
import { addToParty, depositToBox } from './storage'
@@ -13,10 +13,10 @@ export const EGG_REQUIRED_DAYS = 3
* Conditions: consecutiveDays >= EGG_REQUIRED_DAYS AND totalTurns % 50 === 0 AND eggs.length < 1
*/
export function checkEggEligibility(buddyData: BuddyData): boolean {
if (buddyData.eggs.length >= 1) return false
if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false
if (buddyData.stats.totalTurns % 50 !== 0) return false
return true
if (buddyData.eggs.length >= 1) return false
if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false
if (buddyData.stats.totalTurns % 50 !== 0) return false
return true
}
/**
@@ -24,27 +24,27 @@ export function checkEggEligibility(buddyData: BuddyData): boolean {
* Priority: uncollected species > random from all species.
*/
export function generateEgg(buddyData: BuddyData): Egg {
// Find uncollected species
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
// Find uncollected species
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
// Pick species (prefer uncollected, fall back to random starter)
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
const speciesId = uncollected.length > 0
? uncollected[Math.floor(Math.random() * uncollected.length)]
: starters[Math.floor(Math.random() * starters.length)]
// Pick species (prefer uncollected, fall back to random starter)
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
const speciesId = uncollected.length > 0
? uncollected[Math.floor(Math.random() * uncollected.length)]
: starters[Math.floor(Math.random() * starters.length)]
// Steps based on rarity (capture rate: lower = rarer = more steps)
const species = getSpeciesData(speciesId)
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
// Steps based on rarity (capture rate: lower = rarer = more steps)
const species = getSpeciesData(speciesId)
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
return {
id: randomUUID(),
obtainedAt: Date.now(),
stepsRemaining: baseSteps,
totalSteps: baseSteps,
speciesId,
}
return {
id: randomUUID(),
obtainedAt: Date.now(),
stepsRemaining: baseSteps,
totalSteps: baseSteps,
speciesId,
}
}
/**
@@ -52,15 +52,15 @@ export function generateEgg(buddyData: BuddyData): Egg {
* Returns updated egg or null if egg hatched.
*/
export function advanceEggSteps(egg: Egg, steps: number): Egg {
const newSteps = Math.max(0, egg.stepsRemaining - steps)
return { ...egg, stepsRemaining: newSteps }
const newSteps = Math.max(0, egg.stepsRemaining - steps)
return { ...egg, stepsRemaining: newSteps }
}
/**
* Check if an egg is ready to hatch.
*/
export function isEggReadyToHatch(egg: Egg): boolean {
return egg.stepsRemaining <= 0
return egg.stepsRemaining <= 0
}
/**
@@ -68,44 +68,44 @@ export function isEggReadyToHatch(egg: Egg): boolean {
* Tries to add to party first, then deposits to PC box.
*/
export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> {
const creature = await generateCreature(egg.speciesId)
creature.hatchedAt = Date.now()
const creature = await generateCreature(egg.speciesId)
creature.hatchedAt = Date.now()
// Add creature to list
let updatedData: BuddyData = {
...buddyData,
creatures: [...buddyData.creatures, creature],
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
stats: {
...buddyData.stats,
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
},
}
// Add creature to list
let updatedData: BuddyData = {
...buddyData,
creatures: [...buddyData.creatures, creature],
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
stats: {
...buddyData.stats,
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
},
}
// Place in party or PC box
const partyResult = addToParty(updatedData, creature.id)
if (partyResult.added) {
updatedData = partyResult.data
} else {
const boxResult = depositToBox(updatedData, creature.id)
if (boxResult.deposited) updatedData = boxResult.data
}
// Place in party or PC box
const partyResult = addToParty(updatedData, creature.id)
if (partyResult.added) {
updatedData = partyResult.data
} else {
const boxResult = depositToBox(updatedData, creature.id)
if (boxResult.deposited) updatedData = boxResult.data
}
return { buddyData: updatedData, creature }
return { buddyData: updatedData, creature }
}
/**
* Update or create a dex entry for a species.
*/
function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] {
const existing = dex.find((d) => d.speciesId === speciesId)
if (existing) {
return dex.map((d) =>
d.speciesId === speciesId
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
: d,
)
}
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
const existing = dex.find((d) => d.speciesId === speciesId)
if (existing) {
return dex.map((d) =>
d.speciesId === speciesId
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
: d,
)
}
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
}

View File

@@ -1,27 +1,27 @@
import type { Creature, EvolutionResult, SpeciesId } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
/**
* Check if a creature meets evolution conditions.
* Returns the evolution result if evolution should occur, null otherwise.
*/
export function checkEvolution(creature: Creature): EvolutionResult | null {
if (creature.level > 100) return null
if (creature.level > 100) return null
const nextEvo = getNextEvolution(creature.speciesId)
if (!nextEvo) return null
const nextEvo = getNextEvolution(creature.speciesId)
if (!nextEvo) return null
// Check level-up conditions
if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) {
return {
from: creature.speciesId,
to: nextEvo.to,
newLevel: creature.level,
}
}
// Check level-up conditions
if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) {
return {
from: creature.speciesId,
to: nextEvo.to,
newLevel: creature.level,
}
}
return null
return null
}
/**
@@ -29,18 +29,18 @@ export function checkEvolution(creature: Creature): EvolutionResult | null {
* Returns the updated creature with new species and recalculated data.
*/
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
const newSpecies = getSpeciesData(targetSpeciesId)
const newSpecies = getSpeciesData(targetSpeciesId)
return {
...creature,
speciesId: targetSpeciesId,
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
}
return {
...creature,
speciesId: targetSpeciesId,
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
}
}
/**
* Check if a species can evolve further.
*/
export function canEvolveFurther(speciesId: SpeciesId): boolean {
return getNextEvolution(speciesId) !== undefined
return getNextEvolution(speciesId) !== undefined
}

View File

@@ -1,52 +1,52 @@
import type { Creature } from '../types'
import { getSpeciesData } from '../data/species'
import { levelFromXp, xpForLevel } from '../data/xpTable'
import { getSpeciesData } from '../dex/species'
import { levelFromXp, xpForLevel } from '../dex/xpTable'
/**
* Award XP to a creature. Returns updated creature and whether level up occurred.
*/
export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } {
const species = getSpeciesData(creature.speciesId)
if (creature.level >= 100) {
return { creature, leveledUp: false, newLevel: creature.level }
}
const species = getSpeciesData(creature.speciesId)
if (creature.level >= 100) {
return { creature, leveledUp: false, newLevel: creature.level }
}
const newTotalXp = creature.totalXp + amount
const oldLevel = creature.level
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
const newTotalXp = creature.totalXp + amount
const oldLevel = creature.level
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
// XP progress within current level
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
const xp = newTotalXp - currentLevelXp
// XP progress within current level
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
const xp = newTotalXp - currentLevelXp
const updated: Creature = {
...creature,
totalXp: newTotalXp,
xp: Math.max(0, xp),
level: newLevel,
}
const updated: Creature = {
...creature,
totalXp: newTotalXp,
xp: Math.max(0, xp),
level: newLevel,
}
return {
creature: updated,
leveledUp: newLevel > oldLevel,
newLevel,
}
return {
creature: updated,
leveledUp: newLevel > oldLevel,
newLevel,
}
}
/**
* Get XP needed to reach next level from current state.
*/
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
const species = getSpeciesData(creature.speciesId)
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
const needed = nextLevelXp - currentLevelXp
const current = creature.totalXp - currentLevelXp
const species = getSpeciesData(creature.speciesId)
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
const needed = nextLevelXp - currentLevelXp
const current = creature.totalXp - currentLevelXp
return {
current: Math.max(0, current),
needed,
percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100,
}
return {
current: Math.max(0, current),
needed,
percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100,
}
}

View File

@@ -5,22 +5,22 @@ import type { Gender, SpeciesData } from '../types'
* genderRate: -1 = genderless, 0 = always male, 1-7 = female chance = genderRate/8, 8 = always female
*/
export function determineGender(speciesData: SpeciesData, seed: number): Gender {
if (speciesData.genderRate === -1) return 'genderless'
if (speciesData.genderRate === 0) return 'male'
if (speciesData.genderRate === 8) return 'female'
// Use seed value (0-255) to determine gender
const threshold = (speciesData.genderRate / 8) * 256
return (seed % 256) < threshold ? 'female' : 'male'
if (speciesData.genderRate === -1) return 'genderless'
if (speciesData.genderRate === 0) return 'male'
if (speciesData.genderRate === 8) return 'female'
// Use seed value (0-255) to determine gender
const threshold = (speciesData.genderRate / 8) * 256
return (seed % 256) < threshold ? 'female' : 'male'
}
/** Get gender symbol for display */
export function getGenderSymbol(gender: Gender): string {
switch (gender) {
case 'male':
return '♂'
case 'female':
return '♀'
case 'genderless':
return ''
}
switch (gender) {
case 'male':
return '♂'
case 'female':
return '♀'
case 'genderless':
return ''
}
}

View File

@@ -1,41 +1,41 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { SpeciesId, SpriteCache } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { getSpritesDir } from './storage'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
/** Mapping of speciesId to cow file prefix */
const COW_FILE_MAP: Record<SpeciesId, string> = {
bulbasaur: '001_bulbasaur',
ivysaur: '002_ivysaur',
venusaur: '003_venusaur',
charmander: '004_charmander',
charmeleon: '005_charmeleon',
charizard: '006_charizard',
squirtle: '007_squirtle',
wartortle: '008_wartortle',
blastoise: '009_blastoise',
pikachu: '025_pikachu',
bulbasaur: '001_bulbasaur',
ivysaur: '002_ivysaur',
venusaur: '003_venusaur',
charmander: '004_charmander',
charmeleon: '005_charmeleon',
charizard: '006_charizard',
squirtle: '007_squirtle',
wartortle: '008_wartortle',
blastoise: '009_blastoise',
pikachu: '025_pikachu',
}
/**
* Load sprite from local cache. Returns null if not cached.
*/
export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
if (!existsSync(filePath)) return null
if (!existsSync(filePath)) return null
try {
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as SpriteCache
} catch (e) {
console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e)
return null
}
try {
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as SpriteCache
} catch (e) {
console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e)
return null
}
}
/**
@@ -43,41 +43,41 @@ export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
* Returns the cached sprite data, or null if fetch failed.
*/
export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteCache | null> {
// Try local cache first
const cached = loadSprite(speciesId)
if (cached) return cached
// Try local cache first
const cached = loadSprite(speciesId)
if (cached) return cached
const cowFileName = COW_FILE_MAP[speciesId]
if (!cowFileName) return null
const cowFileName = COW_FILE_MAP[speciesId]
if (!cowFileName) return null
const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow`
const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow`
try {
const response = await fetch(url)
if (!response.ok) return null
try {
const response = await fetch(url)
if (!response.ok) return null
const cowContent = await response.text()
const lines = convertCowToLines(cowContent)
if (lines.length === 0) return null
const cowContent = await response.text()
const lines = convertCowToLines(cowContent)
if (lines.length === 0) return null
const sprite: SpriteCache = {
speciesId,
lines,
width: Math.max(...lines.map((l) => stripAnsi(l).length)),
height: lines.length,
fetchedAt: Date.now(),
}
const sprite: SpriteCache = {
speciesId,
lines,
width: Math.max(...lines.map((l) => stripAnsi(l).length)),
height: lines.length,
fetchedAt: Date.now(),
}
// Cache to disk
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
// Cache to disk
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
return sprite
} catch (e) {
console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e)
return null
}
return sprite
} catch (e) {
console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e)
return null
}
}
/**
@@ -85,57 +85,57 @@ export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteC
* Extracts heredoc content, converts Unicode escapes, strips thought lines.
*/
function convertCowToLines(cowContent: string): string[] {
// Extract content between $the_cow =<<EOC; and EOC
const startMarker = '$the_cow =<<EOC;'
const endMarker = 'EOC'
// Extract content between $the_cow =<<EOC; and EOC
const startMarker = '$the_cow =<<EOC;'
const endMarker = 'EOC'
const startIdx = cowContent.indexOf(startMarker)
if (startIdx === -1) return []
const startIdx = cowContent.indexOf(startMarker)
if (startIdx === -1) return []
const contentStart = startIdx + startMarker.length
const endIdx = cowContent.indexOf(endMarker, contentStart)
if (endIdx === -1) return []
const contentStart = startIdx + startMarker.length
const endIdx = cowContent.indexOf(endMarker, contentStart)
if (endIdx === -1) return []
let content = cowContent.slice(contentStart, endIdx)
let content = cowContent.slice(contentStart, endIdx)
// Convert \N{U+XXXX} to actual Unicode characters
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
// Convert \N{U+XXXX} to actual Unicode characters
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
// Convert \e to actual escape character (for ANSI sequences)
content = content.replace(/\\e/g, '\x1b')
// Convert \e to actual escape character (for ANSI sequences)
content = content.replace(/\\e/g, '\x1b')
// Split into lines
let lines = content.split('\n')
// Split into lines
let lines = content.split('\n')
// Strip leading/trailing empty lines
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
// Strip leading/trailing empty lines
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
// Remove first 4 lines (cowsay thought bubble guide)
if (lines.length > 4) {
lines = lines.slice(4)
}
// Remove first 4 lines (cowsay thought bubble guide)
if (lines.length > 4) {
lines = lines.slice(4)
}
// Trim trailing whitespace on each line (preserve leading for alignment)
lines = lines.map((line) => line.trimEnd())
// Trim trailing whitespace on each line (preserve leading for alignment)
lines = lines.map((line) => line.trimEnd())
return lines
return lines
}
/**
* Strip ANSI escape sequences from a string.
*/
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '')
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '')
}
/**
* Get species name with dex number for display.
*/
export function getSpeciesDisplay(speciesId: SpeciesId): string {
const data = getSpeciesData(speciesId)
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
const data = getSpeciesData(speciesId)
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
}

View File

@@ -4,9 +4,9 @@ import { homedir } from 'node:os'
import type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { generateCreature } from './creature'
import { getSpeciesData } from '../data/species'
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
import { randomNature } from '../data/nature'
import { getSpeciesData } from '../dex/species'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
@@ -16,10 +16,10 @@ const BOX_SIZE = 30
/** Create empty boxes */
function makeDefaultBoxes(): PCBox[] {
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
name: `Box ${i + 1}`,
slots: Array.from({ length: BOX_SIZE }, () => null),
}))
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
name: `Box ${i + 1}`,
slots: Array.from({ length: BOX_SIZE }, () => null),
}))
}
/**
@@ -27,28 +27,28 @@ function makeDefaultBoxes(): PCBox[] {
* Auto-migrates from any older version.
*/
export async function loadBuddyData(): Promise<BuddyData> {
if (!existsSync(BUDDY_DATA_PATH)) {
return getDefaultBuddyData()
}
try {
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
const data = JSON.parse(raw)
return migrateToV2(data)
} catch (e) {
console.error('[buddy] Failed to load buddy data:', e)
return getDefaultBuddyData()
}
if (!existsSync(BUDDY_DATA_PATH)) {
return getDefaultBuddyData()
}
try {
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
const data = JSON.parse(raw)
return migrateToV2(data)
} catch (e) {
console.error('[buddy] Failed to load buddy data:', e)
return getDefaultBuddyData()
}
}
/**
* Save buddy data to disk.
*/
export function saveBuddyData(data: BuddyData): void {
const dir = join(BUDDY_DATA_PATH, '..')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2))
const dir = join(BUDDY_DATA_PATH, '..')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2))
}
/**
@@ -56,356 +56,356 @@ export function saveBuddyData(data: BuddyData): void {
* Randomly assigns one of the three starters.
*/
export async function getDefaultBuddyData(): Promise<BuddyData> {
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
const creature = await generateCreature(randomStarter)
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
const creature = await generateCreature(randomStarter)
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [
{
speciesId: randomStarter,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: 1,
},
],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [
{
speciesId: randomStarter,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: 1,
},
],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
/**
* Get the sprites cache directory path.
*/
export function getSpritesDir(): string {
if (!existsSync(BUDDY_SPRITES_DIR)) {
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
}
return BUDDY_SPRITES_DIR
if (!existsSync(BUDDY_SPRITES_DIR)) {
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
}
return BUDDY_SPRITES_DIR
}
/**
* Migrate from legacy buddy system.
*/
export async function migrateFromLegacy(
storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string },
storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string },
): Promise<BuddyData> {
const speciesMap: Record<string, SpeciesId> = {
duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur',
cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle',
owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle',
snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle',
capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander',
rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander',
}
const speciesMap: Record<string, SpeciesId> = {
duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur',
cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle',
owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle',
snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle',
capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander',
rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander',
}
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
const creature = await generateCreature(speciesId)
creature.level = 5
creature.totalXp = 100
creature.friendship = 120
const creature = await generateCreature(speciesId)
creature.level = 5
creature.totalXp = 100
creature.friendship = 120
const speciesInfo = getSpeciesData(speciesId)
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
creature.nickname = storedCompanion.name
}
const speciesInfo = getSpeciesData(speciesId)
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
creature.nickname = storedCompanion.name
}
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 1,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 1,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
// ─── Migration ───
/** Migrate any version to v2 */
async function migrateToV2(data: Record<string, unknown>): Promise<BuddyData> {
const version = (data.version as number) ?? 1
const version = (data.version as number) ?? 1
if (version >= 2) return data as unknown as BuddyData
if (version >= 2) return data as unknown as BuddyData
// v1 → v2
const v1 = data as Record<string, unknown>
const party = ensureParty(v1)
// v1 → v2
const v1 = data as Record<string, unknown>
const party = ensureParty(v1)
// Migrate creatures: add new fields
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
// Migrate creatures: add new fields
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
// Build boxes — put non-party creatures into Box 1
const partyIds = new Set(party.filter(Boolean))
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
const boxes = makeDefaultBoxes()
const box1Slots = [...boxes[0]!.slots]
let boxIdx = 0
for (const c of nonPartyCreatures) {
if (boxIdx < BOX_SIZE) {
box1Slots[boxIdx] = c.id
boxIdx++
}
}
boxes[0] = { name: 'Box 1', slots: box1Slots }
// Build boxes — put non-party creatures into Box 1
const partyIds = new Set(party.filter(Boolean))
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
const boxes = makeDefaultBoxes()
const box1Slots = [...boxes[0]!.slots]
let boxIdx = 0
for (const c of nonPartyCreatures) {
if (boxIdx < BOX_SIZE) {
box1Slots[boxIdx] = c.id
boxIdx++
}
}
boxes[0] = { name: 'Box 1', slots: box1Slots }
return {
version: 2,
party,
boxes,
creatures,
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
dex: (v1.dex as BuddyData['dex']) ?? [],
bag: { items: [] },
stats: {
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
battlesWon: 0,
battlesLost: 0,
},
}
return {
version: 2,
party,
boxes,
creatures,
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
dex: (v1.dex as BuddyData['dex']) ?? [],
bag: { items: [] },
stats: {
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
/** Ensure party field is valid */
function ensureParty(data: Record<string, unknown>): (string | null)[] {
const existing = data.party as (string | null)[] | undefined
if (existing && existing.length === 6) return existing
const existing = data.party as (string | null)[] | undefined
if (existing && existing.length === 6) return existing
const party: (string | null)[] = new Array(6).fill(null)
const activeId = data.activeCreatureId ?? existing?.[0]
if (activeId) party[0] = activeId as string
const party: (string | null)[] = new Array(6).fill(null)
const activeId = data.activeCreatureId ?? existing?.[0]
if (activeId) party[0] = activeId as string
const creatures = data.creatures as Creature[] ?? []
let slot = 1
for (const c of creatures) {
if (c.id === activeId) continue
if (slot >= 6) break
party[slot] = c.id
slot++
}
return party
const creatures = data.creatures as Creature[] ?? []
let slot = 1
for (const c of creatures) {
if (c.id === activeId) continue
if (slot >= 6) break
party[slot] = c.id
slot++
}
return party
}
/** Migrate creatures from v1 format to v2 */
async function migrateCreatures(creatures: Creature[]): Promise<Creature[]> {
const result: Creature[] = []
for (const c of creatures) {
// Already v2 (has nature field)
if ('nature' in c && c.nature) {
result.push(c)
continue
}
const result: Creature[] = []
for (const c of creatures) {
// Already v2 (has nature field)
if ('nature' in c && c.nature) {
result.push(c)
continue
}
result.push({
...c,
nature: randomNature(),
moves: await getDefaultMoveset(c.speciesId, c.level),
ability: getDefaultAbility(c.speciesId),
heldItem: null,
pokeball: 'pokeball',
})
}
return result
result.push({
...c,
nature: randomNature(),
moves: await getDefaultMoveset(c.speciesId, c.level),
ability: getDefaultAbility(c.speciesId),
heldItem: null,
pokeball: 'pokeball',
})
}
return result
}
// ─── Daily / Turn stats ───
export function updateDailyStats(data: BuddyData): BuddyData {
const today = new Date().toISOString().split('T')[0]
const lastDate = data.stats.lastActiveDate
const today = new Date().toISOString().split('T')[0]
const lastDate = data.stats.lastActiveDate
let consecutiveDays = data.stats.consecutiveDays
if (lastDate !== today) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
}
let consecutiveDays = data.stats.consecutiveDays
if (lastDate !== today) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
}
return {
...data,
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
}
return {
...data,
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
}
}
export function incrementTurns(data: BuddyData): BuddyData {
return {
...data,
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
}
return {
...data,
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
}
}
// ─── Party operations ───
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
const party = [...data.party]
const emptyIdx = party.findIndex(p => p === null)
if (emptyIdx === -1) return { data, added: false }
party[emptyIdx] = creatureId
return { data: { ...data, party }, added: true }
const party = [...data.party]
const emptyIdx = party.findIndex(p => p === null)
if (emptyIdx === -1) return { data, added: false }
party[emptyIdx] = creatureId
return { data: { ...data, party }, added: true }
}
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
if (slotIndex < 0 || slotIndex >= 6) return data
const party = [...data.party]
party[slotIndex] = null
return { ...data, party }
if (slotIndex < 0 || slotIndex >= 6) return data
const party = [...data.party]
party[slotIndex] = null
return { ...data, party }
}
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
const party = [...data.party]
const a = party[indexA]
const b = party[indexB]
party[indexA] = b
party[indexB] = a
return { ...data, party }
const party = [...data.party]
const a = party[indexA]
const b = party[indexB]
party[indexA] = b
party[indexB] = a
return { ...data, party }
}
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
const party = [...data.party]
const existingIdx = party.findIndex(id => id === creatureId)
if (existingIdx === 0) return data
if (existingIdx > 0) {
party[0] = creatureId
party[existingIdx] = data.party[0]
} else {
party[0] = creatureId
}
return { ...data, party }
const party = [...data.party]
const existingIdx = party.findIndex(id => id === creatureId)
if (existingIdx === 0) return data
if (existingIdx > 0) {
party[0] = creatureId
party[existingIdx] = data.party[0]
} else {
party[0] = creatureId
}
return { ...data, party }
}
// ─── PC Box operations ───
export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } {
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const emptyIdx = slots.findIndex(s => s === null)
if (emptyIdx !== -1) {
slots[emptyIdx] = creatureId
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, deposited: true }
}
}
return { data, deposited: false }
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const emptyIdx = slots.findIndex(s => s === null)
if (emptyIdx !== -1) {
slots[emptyIdx] = creatureId
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, deposited: true }
}
}
return { data, deposited: false }
}
export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } {
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const idx = slots.findIndex(s => s === creatureId)
if (idx !== -1) {
slots[idx] = null
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, withdrawn: true }
}
}
return { data, withdrawn: false }
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const idx = slots.findIndex(s => s === creatureId)
if (idx !== -1) {
slots[idx] = null
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, withdrawn: true }
}
}
return { data, withdrawn: false }
}
export function moveInBox(data: BuddyData, fromBox: number, fromSlot: number, toBox: number, toSlot: number): BuddyData {
const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] }))
const creatureId = boxes[fromBox]?.slots[fromSlot]
if (!creatureId) return data
boxes[fromBox]!.slots[fromSlot] = null
boxes[toBox]!.slots[toSlot] = creatureId
return { ...data, boxes }
const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] }))
const creatureId = boxes[fromBox]?.slots[fromSlot]
if (!creatureId) return data
boxes[fromBox]!.slots[fromSlot] = null
boxes[toBox]!.slots[toSlot] = creatureId
return { ...data, boxes }
}
export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData {
const boxes = [...data.boxes]
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
return { ...data, boxes }
const boxes = [...data.boxes]
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
return { ...data, boxes }
}
export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null {
const partyIdx = data.party.findIndex(id => id === creatureId)
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
const partyIdx = data.party.findIndex(id => id === creatureId)
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
for (let b = 0; b < data.boxes.length; b++) {
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
}
return null
for (let b = 0; b < data.boxes.length; b++) {
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
}
return null
}
export function releaseCreature(data: BuddyData, creatureId: string): BuddyData {
// Remove from party
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
// Remove from boxes
const withdrawResult = withdrawFromBox(updated, creatureId)
if (withdrawResult.withdrawn) updated = withdrawResult.data
// Remove from creatures array
return {
...updated,
creatures: updated.creatures.filter(c => c.id !== creatureId),
}
// Remove from party
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
// Remove from boxes
const withdrawResult = withdrawFromBox(updated, creatureId)
if (withdrawResult.withdrawn) updated = withdrawResult.data
// Remove from creatures array
return {
...updated,
creatures: updated.creatures.filter(c => c.id !== creatureId),
}
}
export function getTotalCreatureCount(data: BuddyData): number {
return data.creatures.length
return data.creatures.length
}
export function getAllCreatureIds(data: BuddyData): string[] {
return data.creatures.map(c => c.id)
return data.creatures.map(c => c.id)
}
// ─── Bag operations ───
export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData {
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (existing) {
existing.count += count
} else {
items.push({ id: itemId, count })
}
return { ...data, bag: { items } }
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (existing) {
existing.count += count
} else {
items.push({ id: itemId, count })
}
return { ...data, bag: { items } }
}
export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } {
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (!existing || existing.count < count) return { data, removed: false }
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (!existing || existing.count < count) return { data, removed: false }
existing.count -= count
if (existing.count <= 0) {
const idx = items.indexOf(existing)
items.splice(idx, 1)
}
return { data: { ...data, bag: { items } }, removed: true }
existing.count -= count
if (existing.count <= 0) {
const idx = items.indexOf(existing)
items.splice(idx, 1)
}
return { data: { ...data, bag: { items } }, removed: true }
}
export function getItemCount(data: BuddyData, itemId: string): number {
return data.bag.items.find(e => e.id === itemId)?.count ?? 0
return data.bag.items.find(e => e.id === itemId)?.count ?? 0
}

View File

@@ -1,37 +0,0 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
export interface EvolutionChainStep {
from: SpeciesId
to: SpeciesId
trigger: 'level_up' | 'item' | 'trade' | 'friendship'
minLevel?: number
}
/** Find the next evolution for a species, dynamically from Dex */
export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined {
const dex = Dex.species.get(speciesId)
if (!dex?.evos?.length) return undefined
// Take the first evolution target (most species have single evo path)
const target = dex.evos[0]!.toLowerCase()
if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined
const targetDex = Dex.species.get(target)
if (!targetDex?.exists) return undefined
const trigger = dex.evoType === 'trade' ? 'trade'
: dex.evoType === 'useItem' ? 'item'
: dex.evoType === 'levelFriendship' ? 'friendship'
: 'level_up'
return {
from: speciesId,
to: target as SpeciesId,
trigger,
minLevel: targetDex.evoLevel ?? undefined,
}
}

View File

@@ -1,59 +0,0 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId, MoveSlot } from '../types'
import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves: { id: string; level: number }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) })
break
}
}
}
levelUpMoves.sort((a, b) => a.level - b.level)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
})
while (slots.length < 4) slots.push(EMPTY_MOVE)
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
}
/** Get the default ability for a species (first non-hidden ability) */
export function getDefaultAbility(speciesId: SpeciesId): string {
const species = Dex.species.get(speciesId)
return species?.abilities?.['0']?.toLowerCase() ?? ''
}
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return []
const result: { id: string; name: string }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
result.push({ id: moveId, name: dexMove?.name ?? moveId })
}
break
}
}
}
return result
}

View File

@@ -1,191 +0,0 @@
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
import { getNextEvolution } from './evolution'
import { SPECIES_I18N, SPECIES_PERSONALITY } from './names'
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
const SUPPLEMENT: Record<SpeciesId, {
growthRate: GrowthRate
captureRate: number
baseHappiness: number
flavorText: string
}> = {
bulbasaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.',
},
ivysaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.',
},
venusaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.',
},
charmander: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.',
},
charmeleon: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.',
},
charizard: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.',
},
squirtle: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.',
},
wartortle: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.',
},
blastoise: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.',
},
pikachu: {
growthRate: 'medium-fast',
captureRate: 190,
baseHappiness: 70,
flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.',
},
}
// ─── Evolution chain builder (from Dex evos field) ───
function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] {
const evo = getNextEvolution(speciesId)
if (!evo) return undefined
return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }]
}
// ─── Build SpeciesData from Dex + supplement ───
function buildSpeciesData(id: SpeciesId): SpeciesData {
const dex = getSpecies(id)
const sup = SUPPLEMENT[id]
const i18n = SPECIES_I18N[id]
const personality = SPECIES_PERSONALITY[id]
if (!dex) {
// Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP)
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
}
return {
id,
name: dex.name,
names: i18n ?? { en: dex.name },
dexNumber: dex.num,
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
baseStats: mapBaseStats(dex.baseStats),
types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?],
baseHappiness: sup.baseHappiness,
growthRate: sup.growthRate,
captureRate: sup.captureRate,
personality: personality ?? '',
evolutionChain: buildEvolutionChain(id),
shinyChance: 1 / 4096,
flavorText: sup.flavorText,
}
}
// ─── In-memory cache (built once, immutable) ───
const speciesCache = new Map<SpeciesId, SpeciesData>()
function getCached(id: SpeciesId): SpeciesData {
let data = speciesCache.get(id)
if (!data) {
data = buildSpeciesData(id)
speciesCache.set(id, data)
}
return data
}
// ─── Sync getters (used by all consumers) ───
/** Get species data by ID. */
export function getSpeciesData(id: SpeciesId): SpeciesData {
return getCached(id)
}
/** Get all species data as a Record. */
export function getAllSpeciesData(): Record<SpeciesId, SpeciesData> {
const result = {} as Record<SpeciesId, SpeciesData>
for (const id of ALL_SPECIES_IDS) {
result[id] = getCached(id)
}
return result
}
/**
* Synchronous getter that returns the full map.
* @deprecated Use getSpeciesData / getAllSpeciesData
*/
export const SPECIES_DATA: Record<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
get(_, prop: string) {
return getSpeciesData(prop as SpeciesId)
},
ownKeys() {
return ALL_SPECIES_IDS as unknown as string[]
},
has(_, prop) {
return ALL_SPECIES_IDS.includes(prop as SpeciesId)
},
getOwnPropertyDescriptor(_, prop) {
if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) {
return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) }
}
return undefined
},
})
/** No-op — data is now built-in from @pkmn/sim */
export function ensureSpeciesData(): Promise<void> {
return Promise.resolve()
}
/** No-op — data is now built-in from @pkmn/sim */
export async function refreshAllSpeciesData(): Promise<void> {
// Clear cache to force rebuild
speciesCache.clear()
}
// ─── Dex number mapping ───
export const DEX_TO_SPECIES: Record<number, SpeciesId> = {
1: 'bulbasaur',
2: 'ivysaur',
3: 'venusaur',
4: 'charmander',
5: 'charmeleon',
6: 'charizard',
7: 'squirtle',
8: 'wartortle',
9: 'blastoise',
25: 'pikachu',
}

View File

@@ -0,0 +1,31 @@
import type { StatName } from '../types'
/**
* Default EV mapping: tool name → EV gains per use.
* Tools not in this mapping get random 1-2 EV points.
*/
export const DEFAULT_EV_MAPPING: Record<string, Record<StatName, number>> = {
Bash: { hp: 0, attack: 2, defense: 0, spAtk: 0, spDef: 0, speed: 1 },
Edit: { hp: 0, attack: 0, defense: 1, spAtk: 2, spDef: 0, speed: 0 },
Write: { hp: 0, attack: 0, defense: 0, spAtk: 3, spDef: 0, speed: 0 },
Read: { hp: 1, attack: 0, defense: 2, spAtk: 0, spDef: 0, speed: 0 },
Grep: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Glob: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Agent: { hp: 0, attack: 1, defense: 0, spAtk: 0, spDef: 0, speed: 2 },
WebSearch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
WebFetch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
}
// EV limits (matching original Pokémon)
export const MAX_EV_PER_STAT = 252
export const MAX_EV_TOTAL = 510
// EV cooldown: same tool type only counts once per 30 seconds
export const EV_COOLDOWN_MS = 30_000
/**
* Get EV gains for a tool. Returns undefined if not mapped (→ random).
*/
export function getEVForTool(toolName: string): Record<StatName, number> | undefined {
return DEFAULT_EV_MAPPING[toolName]
}

View File

@@ -0,0 +1,37 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
export interface EvolutionChainStep {
from: SpeciesId
to: SpeciesId
trigger: 'level_up' | 'item' | 'trade' | 'friendship'
minLevel?: number
}
/** Find the next evolution for a species, dynamically from Dex */
export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined {
const dex = Dex.species.get(speciesId)
if (!dex?.evos?.length) return undefined
// Take the first evolution target (most species have single evo path)
const target = dex.evos[0]!.toLowerCase()
if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined
const targetDex = Dex.species.get(target)
if (!targetDex?.exists) return undefined
const trigger = dex.evoType === 'trade' ? 'trade'
: dex.evoType === 'useItem' ? 'item'
: dex.evoType === 'levelFriendship' ? 'friendship'
: 'level_up'
return {
from: speciesId,
to: target as SpeciesId,
trigger,
minLevel: targetDex.evoLevel ?? undefined,
}
}

View File

@@ -0,0 +1,59 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId, MoveSlot } from '../types'
import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves: { id: string; level: number }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) })
break
}
}
}
levelUpMoves.sort((a, b) => a.level - b.level)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
})
while (slots.length < 4) slots.push(EMPTY_MOVE)
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
}
/** Get the default ability for a species (first non-hidden ability) */
export function getDefaultAbility(speciesId: SpeciesId): string {
const species = Dex.species.get(speciesId)
return species?.abilities?.['0']?.toLowerCase() ?? ''
}
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return []
const result: { id: string; name: string }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
result.push({ id: moveId, name: dexMove?.name ?? moveId })
}
break
}
}
}
return result
}

View File

@@ -0,0 +1,43 @@
import type { SpeciesId } from '../types'
/** Default names for each species (English) */
export const SPECIES_NAMES: Record<SpeciesId, string> = {
bulbasaur: 'Bulbasaur',
ivysaur: 'Ivysaur',
venusaur: 'Venusaur',
charmander: 'Charmander',
charmeleon: 'Charmeleon',
charizard: 'Charizard',
squirtle: 'Squirtle',
wartortle: 'Wartortle',
blastoise: 'Blastoise',
pikachu: 'Pikachu',
}
/** Multilingual names */
export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' },
charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' },
charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' },
squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' },
wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' },
blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' },
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
}
/** Personality descriptions for each species */
export const SPECIES_PERSONALITY: Record<SpeciesId, string> = {
bulbasaur: 'Calm and collected, a reliable partner',
ivysaur: 'Steady growth, patient and resilient',
venusaur: 'Majestic and powerful, a natural leader',
charmander: 'Energetic and curious, loves adventure',
charmeleon: 'Fierce and determined, always pushing forward',
charizard: 'Proud and strong-willed, a formidable ally',
squirtle: 'Cheerful and playful, adapts easily',
wartortle: 'Loyal and protective, wise beyond years',
blastoise: 'Steadfast and powerful, a defensive fortress',
pikachu: 'Friendly and energetic, always by your side',
}

View File

@@ -4,36 +4,36 @@ import type { NatureName, NatureEffect, NatureStat } from '../types'
// All 25 canonical nature names (Dex.natures is not iterable, so we list them)
const NATURE_IDS: NatureName[] = [
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
'bold', 'docile', 'relaxed', 'impish', 'lax',
'timid', 'hasty', 'serious', 'jolly', 'naive',
'modest', 'mild', 'quiet', 'bashful', 'rash',
'calm', 'gentle', 'sassy', 'careful', 'quirky',
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
'bold', 'docile', 'relaxed', 'impish', 'lax',
'timid', 'hasty', 'serious', 'jolly', 'naive',
'modest', 'mild', 'quiet', 'bashful', 'rash',
'calm', 'gentle', 'sassy', 'careful', 'quirky',
]
/** Get all nature names */
export function getAllNatureNames(): NatureName[] {
return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists)
return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists)
}
/** Randomly assign a nature */
export function randomNature(): NatureName {
const names = getAllNatureNames()
return names[Math.floor(Math.random() * names.length)]!
const names = getAllNatureNames()
return names[Math.floor(Math.random() * names.length)]!
}
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
function mapDexStat(stat: string | undefined): NatureStat | null {
if (!stat) return null
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
if (!stat) return null
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
}
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
export function getNatureEffect(nature: NatureName): NatureEffect {
const n = Dex.natures.get(nature)
if (!n?.exists) return { plus: null, minus: null }
return {
plus: mapDexStat(n.plus),
minus: mapDexStat(n.minus),
}
const n = Dex.natures.get(nature)
if (!n?.exists) return { plus: null, minus: null }
return {
plus: mapDexStat(n.plus),
minus: mapDexStat(n.minus),
}
}

View File

@@ -8,32 +8,32 @@ export const gen = gens.get(9)
// Stat name mapping: @pkmn/sim → our StatName
export const FROM_DEX_STAT: Record<string, StatName> = {
hp: 'hp', atk: 'attack', def: 'defense',
spa: 'spAtk', spd: 'spDef', spe: 'speed',
hp: 'hp', atk: 'attack', def: 'defense',
spa: 'spAtk', spd: 'spDef', spe: 'speed',
}
// Stat name mapping: our StatName → @pkmn/sim abbreviation
export const TO_DEX_STAT: Record<StatName, string> = {
hp: 'hp', attack: 'atk', defense: 'def',
spAtk: 'spa', spDef: 'spd', speed: 'spe',
hp: 'hp', attack: 'atk', defense: 'def',
spAtk: 'spa', spDef: 'spd', speed: 'spe',
}
/** Query species from Dex */
export function getSpecies(id: string) {
return gen.species.get(id)
return gen.species.get(id)
}
/** Map Dex baseStats to our StatName format */
export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record<StatName, number> {
const result = {} as Record<StatName, number>
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
}
return result
const result = {} as Record<StatName, number>
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
}
return result
}
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
return Math.round(genderRatio.F * 8)
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
return Math.round(genderRatio.F * 8)
}

View File

@@ -0,0 +1,191 @@
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
import { getNextEvolution } from './evolution'
import { SPECIES_I18N, SPECIES_PERSONALITY } from './names'
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
const SUPPLEMENT: Record<SpeciesId, {
growthRate: GrowthRate
captureRate: number
baseHappiness: number
flavorText: string
}> = {
bulbasaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.',
},
ivysaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.',
},
venusaur: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.',
},
charmander: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.',
},
charmeleon: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.',
},
charizard: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.',
},
squirtle: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.',
},
wartortle: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.',
},
blastoise: {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.',
},
pikachu: {
growthRate: 'medium-fast',
captureRate: 190,
baseHappiness: 70,
flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.',
},
}
// ─── Evolution chain builder (from Dex evos field) ───
function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] {
const evo = getNextEvolution(speciesId)
if (!evo) return undefined
return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }]
}
// ─── Build SpeciesData from Dex + supplement ───
function buildSpeciesData(id: SpeciesId): SpeciesData {
const dex = getSpecies(id)
const sup = SUPPLEMENT[id]
const i18n = SPECIES_I18N[id]
const personality = SPECIES_PERSONALITY[id]
if (!dex) {
// Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP)
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
}
return {
id,
name: dex.name,
names: i18n ?? { en: dex.name },
dexNumber: dex.num,
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
baseStats: mapBaseStats(dex.baseStats),
types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?],
baseHappiness: sup.baseHappiness,
growthRate: sup.growthRate,
captureRate: sup.captureRate,
personality: personality ?? '',
evolutionChain: buildEvolutionChain(id),
shinyChance: 1 / 4096,
flavorText: sup.flavorText,
}
}
// ─── In-memory cache (built once, immutable) ───
const speciesCache = new Map<SpeciesId, SpeciesData>()
function getCached(id: SpeciesId): SpeciesData {
let data = speciesCache.get(id)
if (!data) {
data = buildSpeciesData(id)
speciesCache.set(id, data)
}
return data
}
// ─── Sync getters (used by all consumers) ───
/** Get species data by ID. */
export function getSpeciesData(id: SpeciesId): SpeciesData {
return getCached(id)
}
/** Get all species data as a Record. */
export function getAllSpeciesData(): Record<SpeciesId, SpeciesData> {
const result = {} as Record<SpeciesId, SpeciesData>
for (const id of ALL_SPECIES_IDS) {
result[id] = getCached(id)
}
return result
}
/**
* Synchronous getter that returns the full map.
* @deprecated Use getSpeciesData / getAllSpeciesData
*/
export const SPECIES_DATA: Record<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
get(_, prop: string) {
return getSpeciesData(prop as SpeciesId)
},
ownKeys() {
return ALL_SPECIES_IDS as unknown as string[]
},
has(_, prop) {
return ALL_SPECIES_IDS.includes(prop as SpeciesId)
},
getOwnPropertyDescriptor(_, prop) {
if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) {
return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) }
}
return undefined
},
})
/** No-op — data is now built-in from @pkmn/sim */
export function ensureSpeciesData(): Promise<void> {
return Promise.resolve()
}
/** No-op — data is now built-in from @pkmn/sim */
export async function refreshAllSpeciesData(): Promise<void> {
// Clear cache to force rebuild
speciesCache.clear()
}
// ─── Dex number mapping ───
export const DEX_TO_SPECIES: Record<number, SpeciesId> = {
1: 'bulbasaur',
2: 'ivysaur',
3: 'venusaur',
4: 'charmander',
5: 'charmeleon',
6: 'charizard',
7: 'squirtle',
8: 'wartortle',
9: 'blastoise',
25: 'pikachu',
}

View File

@@ -0,0 +1,81 @@
import type { GrowthRate } from '../types'
/**
* Calculate total XP required to reach a given level for a growth rate type.
* Follows original Pokémon XP curve formulas.
*/
export function xpForLevel(level: number, growthRate: GrowthRate): number {
if (level <= 1) return 0
const n = level
switch (growthRate) {
case 'erratic':
return xpErratic(n)
case 'fast':
return Math.floor((n * n * n * 4) / 5)
case 'medium-fast':
return n * n * n
case 'medium-slow':
return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140)
case 'slow':
return Math.floor((5 * n * n * n) / 4)
case 'fluctuating':
return xpFluctuating(n)
default:
return n * n * n
}
}
/**
* Calculate level from total XP for a given growth rate.
*/
export function levelFromXp(totalXp: number, growthRate: GrowthRate): number {
// Binary search for level
let lo = 1
let hi = 100
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (xpForLevel(mid, growthRate) <= totalXp) {
lo = mid
} else {
hi = mid - 1
}
}
return Math.min(lo, 100)
}
/**
* XP needed to go from current level to next level.
*/
export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number {
if (currentLevel >= 100) return 0
const nextLevelXp = xpForLevel(currentLevel + 1, growthRate)
return nextLevelXp - totalXp
}
// Erratic growth rate (complex piecewise)
function xpErratic(n: number): number {
if (n <= 1) return 0
if (n <= 50) {
return Math.floor((n * n * n * (100 - n)) / 50)
}
if (n <= 68) {
return Math.floor((n * n * n * (150 - n)) / 100)
}
if (n <= 98) {
return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500)
}
// n 99-100
return Math.floor((n * n * n * (160 - n)) / 100)
}
// Fluctuating growth rate (complex piecewise)
function xpFluctuating(n: number): number {
if (n <= 1) return 0
if (n <= 15) {
return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50)
}
if (n <= 36) {
return Math.floor((n * n * n * (n + 14)) / 50)
}
return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50)
}

View File

@@ -1,40 +1,40 @@
// Types
export type {
StatName,
NatureName,
NatureStat,
NatureEffect,
MoveSlot,
ItemId,
PCBox,
BagEntry,
Bag,
SpeciesId,
Gender,
EvolutionTrigger,
EvolutionCondition,
GrowthRate,
SpeciesData,
Creature,
Egg,
DexEntry,
BuddyData,
StatsResult,
EvolutionResult,
SpriteCache,
AnimMode,
StatName,
NatureName,
NatureStat,
NatureEffect,
MoveSlot,
ItemId,
PCBox,
BagEntry,
Bag,
SpeciesId,
Gender,
EvolutionTrigger,
EvolutionCondition,
GrowthRate,
SpeciesData,
Creature,
Egg,
DexEntry,
BuddyData,
StatsResult,
EvolutionResult,
SpriteCache,
AnimMode,
} from './types'
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
// Data
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './data/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names'
export { getAllNatureNames, randomNature, getNatureEffect } from './data/nature'
export { getNextEvolution } from './data/evolution'
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './data/learnsets'
export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn'
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
export { getNextEvolution } from './dex/evolution'
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
// Battle
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
@@ -50,12 +50,12 @@ export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/eff
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
export {
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
updateDailyStats, incrementTurns,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
updateDailyStats, incrementTurns,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
} from './core/storage'
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
@@ -77,3 +77,4 @@ export { SwitchPanel } from './ui/SwitchPanel'
export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
export { MoveLearnPanel } from './ui/MoveLearnPanel'
export { BattleFlow } from './ui/BattleFlow'

View File

@@ -5,81 +5,81 @@ import type { SpeciesId } from '../types'
* Simple 5-line representations of each species.
*/
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
bulbasaur: [
' _,,--.,,_ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `--,,__,,--\' ',
],
ivysaur: [
' _,--..,_ ',
' ,\' (o)(o) `, ',
' ; ~~~~~~ ; ',
' ; \\====/ ; ',
' `--,,__,,--\' ',
],
venusaur: [
' _,,,---.,,_ ',
' ,\' (o) (o) `, ',
' ; ~~~~~~~~ ; ',
' ; /========\\ ; ',
' `-,,,____,,,-\' ',
],
charmander: [
' ,^., ',
' ( o o) ',
' / ~~~ \\ ',
' / \\___/ \\ ',
' ^^^ ^^^ ',
],
charmeleon: [
' ,--^. ',
' ( o o) ',
' / ~~~~~ \\ ',
' / \\___/ \\ ',
' ^^ ^^ ',
],
charizard: [
' /\\ /\\ ',
' / \\/ \\ ',
' | o o | ',
' | ~~~~~~ | ',
' \\______/ ',
],
squirtle: [
' _____ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~ ; ',
' `-.,__,\' ',
],
wartortle: [
' _______ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `-.,__,\' ',
],
blastoise: [
' .________. ',
' | o o | ',
' | ~~~~~~~~ | ',
' | [====] | ',
' `-.,__,\' ',
],
pikachu: [
' /\\ /\\ ',
' ( o o ) ',
' \\ ~~~ / ',
' /`-...-\'\\ ',
' ^^ ^^ ',
],
bulbasaur: [
' _,,--.,,_ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `--,,__,,--\' ',
],
ivysaur: [
' _,--..,_ ',
' ,\' (o)(o) `, ',
' ; ~~~~~~ ; ',
' ; \\====/ ; ',
' `--,,__,,--\' ',
],
venusaur: [
' _,,,---.,,_ ',
' ,\' (o) (o) `, ',
' ; ~~~~~~~~ ; ',
' ; /========\\ ; ',
' `-,,,____,,,-\' ',
],
charmander: [
' ,^., ',
' ( o o) ',
' / ~~~ \\ ',
' / \\___/ \\ ',
' ^^^ ^^^ ',
],
charmeleon: [
' ,--^. ',
' ( o o) ',
' / ~~~~~ \\ ',
' / \\___/ \\ ',
' ^^ ^^ ',
],
charizard: [
' /\\ /\\ ',
' / \\/ \\ ',
' | o o | ',
' | ~~~~~~ | ',
' \\______/ ',
],
squirtle: [
' _____ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~ ; ',
' `-.,__,\' ',
],
wartortle: [
' _______ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `-.,__,\' ',
],
blastoise: [
' .________. ',
' | o o | ',
' | ~~~~~~~~ | ',
' | [====] | ',
' `-.,__,\' ',
],
pikachu: [
' /\\ /\\ ',
' ( o o ) ',
' \\ ~~~ / ',
' /`-...-\'\\ ',
' ^^ ^^ ',
],
}
/**
* Get fallback ASCII sprite lines for a species.
*/
export function getFallbackSprite(speciesId: SpeciesId): string[] {
return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu
return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu
}

View File

@@ -14,9 +14,9 @@ import type { AnimMode } from '../types'
// After transform, render each row back: reset → style → char → reset
interface Pixel {
char: string
/** Full ANSI state needed to render this pixel */
style: string
char: string
/** Full ANSI state needed to render this pixel */
style: string
}
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
@@ -26,53 +26,53 @@ const EMPTY_ROW: Pixel[] = []
/** Parse a raw ANSI string line into a Pixel row */
function parseLine(line: string): Pixel[] {
const pixels: Pixel[] = []
let style = ''
let i = 0
while (i < line.length) {
if (line[i] === '\x1b') {
// Collect full ANSI escape sequence: \x1b[ ... m
const start = i
i++ // skip \x1b
if (i < line.length && line[i] === '[') {
i++ // skip [
while (i < line.length && line[i] !== 'm') i++
if (i < line.length) i++ // skip m
}
style += line.slice(start, i)
} else {
// Visible character (handle multi-byte Unicode)
const cp = line.codePointAt(i)!
const ch = String.fromCodePoint(cp)
pixels.push({ char: ch, style })
i += ch.length
}
}
return pixels
const pixels: Pixel[] = []
let style = ''
let i = 0
while (i < line.length) {
if (line[i] === '\x1b') {
// Collect full ANSI escape sequence: \x1b[ ... m
const start = i
i++ // skip \x1b
if (i < line.length && line[i] === '[') {
i++ // skip [
while (i < line.length && line[i] !== 'm') i++
if (i < line.length) i++ // skip m
}
style += line.slice(start, i)
} else {
// Visible character (handle multi-byte Unicode)
const cp = line.codePointAt(i)!
const ch = String.fromCodePoint(cp)
pixels.push({ char: ch, style })
i += ch.length
}
}
return pixels
}
/** Render a Pixel row back to an ANSI string */
function renderRow(pixels: Pixel[]): string {
if (pixels.length === 0) return ''
let out = ''
let lastStyle: string | null = null
for (const p of pixels) {
if (p.style !== lastStyle) {
out += '\x1b[0m' + p.style // reset then apply
lastStyle = p.style
}
out += p.char
}
out += '\x1b[0m' // final reset
return out
if (pixels.length === 0) return ''
let out = ''
let lastStyle: string | null = null
for (const p of pixels) {
if (p.style !== lastStyle) {
out += '\x1b[0m' + p.style // reset then apply
lastStyle = p.style
}
out += p.char
}
out += '\x1b[0m' // final reset
return out
}
function parseSprite(lines: string[]): Pixel[][] {
return lines.map(parseLine)
return lines.map(parseLine)
}
function renderSprite(grid: Pixel[][]): string[] {
return grid.map(renderRow)
return grid.map(renderRow)
}
// ─── Grid Transforms ──────────────────────────────────
@@ -80,37 +80,37 @@ function renderSprite(grid: Pixel[][]): string[] {
/** Horizontal shift — positive = right, negative = left */
function shiftH(grid: Pixel[][], n: number): Pixel[][] {
if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row])
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
return grid
if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row])
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
return grid
}
/** Vertical shift up — removes rows from top, pads empty at bottom */
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
if (n <= 0) return grid
const height = grid.length
const shifted = grid.slice(n)
while (shifted.length < height) shifted.push(EMPTY_ROW)
return shifted
if (n <= 0) return grid
const height = grid.length
const shifted = grid.slice(n)
while (shifted.length < height) shifted.push(EMPTY_ROW)
return shifted
}
/** Mirror map — characters that change when flipped horizontally */
const MIRROR: Record<string, string> = {
'/': '\\', '\\': '/',
'(': ')', ')': '(',
'<': '>', '>': '<',
'{': '}', '}': '{',
'[': ']', ']': '[',
'': '╲', '╲': '',
'▌': '▐', '▐': '▌',
'▎': '▏', '▏': '▎',
'◀': '▶', '▶': '◀',
'◄': '►', '►': '◄',
'→': '←', '←': '→',
'↗': '↙', '↙': '↗',
'↘': '↖', '↖': '↘',
'`': "'", "'": '`',
',': '´', '´': ',',
'/': '\\', '\\': '/',
'(': ')', ')': '(',
'<': '>', '>': '<',
'{': '}', '}': '{',
'[': ']', ']': '[',
'': '╲', '╲': '',
'▌': '▐', '▐': '▌',
'▎': '▏', '▏': '▎',
'◀': '▶', '▶': '◀',
'◄': '►', '►': '◄',
'→': '←', '←': '→',
'↗': '↙', '↙': '↗',
'↘': '↖', '↖': '↘',
'`': "'", "'": '`',
',': '´', '´': ',',
}
/**
@@ -119,24 +119,24 @@ const MIRROR: Record<string, string> = {
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
*/
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
const width = Math.max(0, ...grid.map(row => row.length))
return grid.map(row =>
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
.reverse()
.map(p => ({
...p,
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
})),
)
const width = Math.max(0, ...grid.map(row => row.length))
return grid.map(row =>
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
.reverse()
.map(p => ({
...p,
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
})),
)
}
/** Replace eye-like characters with dash */
function blinkEyes(grid: Pixel[][]): Pixel[][] {
return grid.map(row =>
row.map(p =>
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
),
)
return grid.map(row =>
row.map(p =>
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
),
)
}
// ═══════════════════════════════════════════════════════
@@ -144,29 +144,29 @@ function blinkEyes(grid: Pixel[][]): Pixel[][] {
// ═══════════════════════════════════════════════════════
const IDLE_SEQUENCE: AnimMode[] = [
'idle', 'idle',
'breathe', 'breathe',
'idle',
'blink',
'idle',
'bounce',
'idle',
'fidget', 'fidget',
'idle',
'breathe', 'breathe',
'idle',
'flip', 'flip', 'flip',
'idle', 'idle',
'bounce',
'idle',
'blink',
'idle',
'excited', 'excited',
'idle',
'idle', 'idle',
'breathe', 'breathe',
'idle',
'blink',
'idle',
'bounce',
'idle',
'fidget', 'fidget',
'idle',
'breathe', 'breathe',
'idle',
'flip', 'flip', 'flip',
'idle', 'idle',
'bounce',
'idle',
'blink',
'idle',
'excited', 'excited',
'idle',
]
export function getIdleAnimMode(tick: number): AnimMode {
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
}
// ═══════════════════════════════════════════════════════
@@ -178,64 +178,64 @@ export function getIdleAnimMode(tick: number): AnimMode {
* Internally: parse ANSI → Pixel grid → transform → render back.
*/
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
const grid = parseSprite(lines)
const grid = parseSprite(lines)
let result: Pixel[][] = grid
let result: Pixel[][] = grid
switch (mode) {
case 'idle':
break
case 'breathe':
// Right sway → center
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
break
case 'blink':
result = blinkEyes(result)
break
case 'fidget':
// Big right sway → center
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
break
case 'bounce': {
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
const h = PATTERN[tick % PATTERN.length]
result = shiftUp(result, h)
break
}
case 'walkLeft':
// Step right → center (mimics bounce-back from left step)
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
break
case 'walkRight':
// Step right → further right → center
result = shiftH(result, (tick % 4) * 2)
break
case 'flip':
// Pure position reversal — do NOT mirror chars so / \ ( )
// visibly swap, making the flip obvious.
result = reverseH(result, false)
break
case 'excited':
// Jitter right ↔ further right (never crop)
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
break
case 'pet':
break // overlay handled by SpriteAnimator
}
switch (mode) {
case 'idle':
break
case 'breathe':
// Right sway → center
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
break
case 'blink':
result = blinkEyes(result)
break
case 'fidget':
// Big right sway → center
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
break
case 'bounce': {
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
const h = PATTERN[tick % PATTERN.length]
result = shiftUp(result, h)
break
}
case 'walkLeft':
// Step right → center (mimics bounce-back from left step)
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
break
case 'walkRight':
// Step right → further right → center
result = shiftH(result, (tick % 4) * 2)
break
case 'flip':
// Pure position reversal — do NOT mirror chars so / \ ( )
// visibly swap, making the flip obvious.
result = reverseH(result, false)
break
case 'excited':
// Jitter right ↔ further right (never crop)
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
break
case 'pet':
break // overlay handled by SpriteAnimator
}
return renderSprite(result)
return renderSprite(result)
}
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
const PET_HEARTS = [
[' ♥ ', ' '],
[' ♥ ♥ ', ' ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ', ' ♥ ♥ '],
[' ♥ ', ' '],
[' ♥ ♥ ', ' ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ', ' ♥ ♥ '],
]
export function getPetOverlay(tick: number): string[] {
return PET_HEARTS[tick % PET_HEARTS.length]
return PET_HEARTS[tick % PET_HEARTS.length]
}

View File

@@ -2,38 +2,38 @@
export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed']
export const STAT_LABELS: Record<StatName, string> = {
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
}
// Species IDs (MVP 10 species)
export type SpeciesId =
| 'bulbasaur'
| 'ivysaur'
| 'venusaur'
| 'charmander'
| 'charmeleon'
| 'charizard'
| 'squirtle'
| 'wartortle'
| 'blastoise'
| 'pikachu'
| 'bulbasaur'
| 'ivysaur'
| 'venusaur'
| 'charmander'
| 'charmeleon'
| 'charizard'
| 'squirtle'
| 'wartortle'
| 'blastoise'
| 'pikachu'
export const ALL_SPECIES_IDS: SpeciesId[] = [
'bulbasaur',
'ivysaur',
'venusaur',
'charmander',
'charmeleon',
'charizard',
'squirtle',
'wartortle',
'blastoise',
'pikachu',
'bulbasaur',
'ivysaur',
'venusaur',
'charmander',
'charmeleon',
'charizard',
'squirtle',
'wartortle',
'blastoise',
'pikachu',
]
// Nature (delegated to @pkmn/sim Dex.natures)
@@ -62,11 +62,11 @@ export type Gender = 'male' | 'female' | 'genderless'
export type EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship'
export type EvolutionCondition = {
trigger: EvolutionTrigger
level?: number // Level evolution: target level
minFriendship?: number // Friendship evolution
item?: string // Item evolution
into: SpeciesId // Evolves into
trigger: EvolutionTrigger
level?: number // Level evolution: target level
minFriendship?: number // Friendship evolution
item?: string // Item evolution
into: SpeciesId // Evolves into
}
// Growth rate types (from PokeAPI)
@@ -74,78 +74,78 @@ export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erra
// Species base data
export type SpeciesData = {
id: SpeciesId
name: string // English name
names: Record<string, string> // Multilingual names { ja, en, zh }
dexNumber: number // Pokédex number (1-10 MVP)
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
baseStats: Record<StatName, number>
types: [string, string?] // Types (grass/poison, fire, water etc.)
baseHappiness: number // Base friendship
growthRate: GrowthRate
captureRate: number
personality: string // Default personality description
evolutionChain?: EvolutionCondition[]
shinyChance: number // Shiny probability (default 1/4096)
flavorText?: string // Pokédex description
id: SpeciesId
name: string // English name
names: Record<string, string> // Multilingual names { ja, en, zh }
dexNumber: number // Pokédex number (1-10 MVP)
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
baseStats: Record<StatName, number>
types: [string, string?] // Types (grass/poison, fire, water etc.)
baseHappiness: number // Base friendship
growthRate: GrowthRate
captureRate: number
personality: string // Default personality description
evolutionChain?: EvolutionCondition[]
shinyChance: number // Shiny probability (default 1/4096)
flavorText?: string // Pokédex description
}
// Instantiated creature (stored in buddy-data.json)
export type Creature = {
id: string // UUID
speciesId: SpeciesId
nickname?: string // User-defined name
gender: Gender
level: number
xp: number // Current level progress XP
totalXp: number // Total accumulated XP
nature: NatureName // Character nature
ev: Record<StatName, number> // Effort values
iv: Record<StatName, number> // Individual values (0-31)
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
ability: string // Showdown ability ID
heldItem: ItemId | null // Held item
friendship: number // Friendship (0-255)
isShiny: boolean
hatchedAt: number // Timestamp when obtained
pokeball: string // Pokeball type
id: string // UUID
speciesId: SpeciesId
nickname?: string // User-defined name
gender: Gender
level: number
xp: number // Current level progress XP
totalXp: number // Total accumulated XP
nature: NatureName // Character nature
ev: Record<StatName, number> // Effort values
iv: Record<StatName, number> // Individual values (0-31)
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
ability: string // Showdown ability ID
heldItem: ItemId | null // Held item
friendship: number // Friendship (0-255)
isShiny: boolean
hatchedAt: number // Timestamp when obtained
pokeball: string // Pokeball type
}
// Egg
export type Egg = {
id: string
obtainedAt: number
stepsRemaining: number // Remaining hatch steps
totalSteps: number // Original total steps (for progress calc)
speciesId: SpeciesId // Pre-determined species
id: string
obtainedAt: number
stepsRemaining: number // Remaining hatch steps
totalSteps: number // Original total steps (for progress calc)
speciesId: SpeciesId // Pre-determined species
}
// Pokédex entry
export type DexEntry = {
speciesId: SpeciesId
discoveredAt: number
caughtCount: number // Number caught
bestLevel: number // Highest level record
speciesId: SpeciesId
discoveredAt: number
caughtCount: number // Number caught
bestLevel: number // Highest level record
}
// buddy-data.json complete structure
export type BuddyData = {
version: 2
party: (string | null)[] // Always length 6, party[0] = active buddy
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
creatures: Creature[]
eggs: Egg[]
dex: DexEntry[]
bag: Bag
stats: {
totalTurns: number
consecutiveDays: number
lastActiveDate: string // ISO date
totalEggsObtained: number
totalEvolutions: number
battlesWon: number
battlesLost: number
}
version: 2
party: (string | null)[] // Always length 6, party[0] = active buddy
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
creatures: Creature[]
eggs: Egg[]
dex: DexEntry[]
bag: Bag
stats: {
totalTurns: number
consecutiveDays: number
lastActiveDate: string // ISO date
totalEggsObtained: number
totalEvolutions: number
battlesWon: number
battlesLost: number
}
}
// Calculated stats result
@@ -153,29 +153,29 @@ export type StatsResult = Record<StatName, number>
// Evolution result
export type EvolutionResult = {
from: SpeciesId
to: SpeciesId
newLevel: number
from: SpeciesId
to: SpeciesId
newLevel: number
}
// Sprite cache entry
export type SpriteCache = {
speciesId: SpeciesId
lines: string[]
width: number
height: number
fetchedAt: number
speciesId: SpeciesId
lines: string[]
width: number
height: number
fetchedAt: number
}
// Animation mode
export type AnimMode =
| 'idle'
| 'breathe'
| 'blink'
| 'fidget'
| 'bounce'
| 'walkLeft'
| 'walkRight'
| 'flip'
| 'excited'
| 'pet'
| 'idle'
| 'breathe'
| 'blink'
| 'fidget'
| 'bounce'
| 'walkLeft'
| 'walkRight'
| 'flip'
| 'excited'
| 'pet'

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { calculateStats, getCreatureName } from '../core/creature'
const CYAN = 'ansi:cyan'
@@ -11,56 +11,56 @@ const GRAY = 'ansi:white'
const YELLOW = 'ansi:yellow'
interface BattleConfigPanelProps {
party: (Creature | null)[]
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
onCancel: () => void
party: (Creature | null)[]
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
onCancel: () => void
}
export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
const activeCreature = party[0]
const activeCreature = party[0]
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{/* Party display */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
{party.map((creature, i) => {
if (!creature) return (
<Box key={i}>
<Text color={GRAY}> [{i + 1}] []</Text>
</Box>
)
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const hpPercent = 100
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
const isLead = i === 0
return (
<Box key={creature.id}>
<Text>{isLead ? ' ▶ ' : ' '}</Text>
<Text bold={isLead}>{getCreatureName(creature)}</Text>
<Text> Lv.{creature.level} </Text>
<Text color={GREEN}>{hpBar}</Text>
<Text color={GRAY}>{hpEmpty}</Text>
<Text> {hpPercent}%</Text>
</Box>
)
})}
</Box>
{/* Party display */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
{party.map((creature, i) => {
if (!creature) return (
<Box key={i}>
<Text color={GRAY}> [{i + 1}] []</Text>
</Box>
)
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const hpPercent = 100
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
const isLead = i === 0
return (
<Box key={creature.id}>
<Text>{isLead ? ' ▶ ' : ' '}</Text>
<Text bold={isLead}>{getCreatureName(creature)}</Text>
<Text> Lv.{creature.level} </Text>
<Text color={GREEN}>{hpBar}</Text>
<Text color={GRAY}>{hpEmpty}</Text>
<Text> {hpPercent}%</Text>
</Box>
)
})}
</Box>
{/* Opponent selection */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
<Text color={YELLOW}> [1] </Text>
<Text color={GRAY}> [2] </Text>
</Box>
{/* Opponent selection */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
<Text color={YELLOW}> [1] </Text>
<Text color={GRAY}> [2] </Text>
</Box>
<Box marginTop={1}>
<Text color={GRAY}>[Enter] [ESC] </Text>
</Box>
</Box>
)
<Box marginTop={1}>
<Text color={GRAY}>[Enter] [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react'
import { Box, Text, useInput } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
import { saveBuddyData } from '../core/storage'
import { createBattle, executeTurn, type BattleInit } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
@@ -15,356 +16,386 @@ import { chooseAIMove } from '../battle/ai'
import type { BattleState, PlayerAction } from '../battle/types'
type Phase =
| 'config'
| 'configSelect'
| 'battle'
| 'switch'
| 'item'
| 'result'
| 'learnMoves'
| 'evolution'
| 'done'
| 'config'
| 'configSelect'
| 'battle'
| 'switch'
| 'item'
| 'result'
| 'learnMoves'
| 'evolution'
| 'done'
interface BattleFlowProps {
buddyData: BuddyData
onClose: () => void
buddyData: BuddyData
onClose: () => void
isActive?: boolean
}
export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
const [battleState, setBattleState] = useState<BattleState | null>(null)
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
const [opponentLevel, setOpponentLevel] = useState(5)
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
const [replaceIndex, setReplaceIndex] = useState(0)
export function BattleFlow({ buddyData: initialData, onClose, isActive = true }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
const [battleState, setBattleState] = useState<BattleState | null>(null)
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
const [opponentLevel, setOpponentLevel] = useState(5)
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
const [replaceIndex, setReplaceIndex] = useState(0)
const [speciesIndex, setSpeciesIndex] = useState(0)
// ─── Input handling ───
// ─── Input handling ───
useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
// Config phase: Enter = random battle, ESC = cancel
if (phase === 'config') {
if (key.escape) {
onClose()
} else if (key.return || input === '1') {
handleRandomBattle()
} else if (input === '2') {
setPhase('configSelect')
}
return
}
useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
// Config phase: Enter = random battle, ESC = cancel
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
} else if (key.return || input === '1') {
handleRandomBattle()
} else if (input === '2') {
setPhase('configSelect')
}
return
}
// Config select: pick species by number
if (phase === 'configSelect') {
if (key.escape) {
setPhase('config')
} else if (key.return) {
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
}
return
}
// Config select: pick species by number
if (phase === 'configSelect') {
if (key.escape) {
setPhase('config')
} else if (key.return) {
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
}
return
}
// Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel
if (phase === 'battle') {
if (key.escape) {
// Can't flee from wild battle - do nothing
return
}
if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
if (battleState && idx < battleState.playerPokemon.moves.length) {
handleAction({ type: 'move', moveIndex: idx })
}
} else if (input.toLowerCase() === 's') {
setPhase('switch')
} else if (input.toLowerCase() === 'i') {
setPhase('item')
}
return
}
// Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel
if (phase === 'battle') {
if (key.escape) {
// Can't flee from wild battle - do nothing
return
}
if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
if (battleState && idx < battleState.playerPokemon.moves.length) {
handleAction({ type: 'move', moveIndex: idx })
}
} else if (input.toLowerCase() === 's') {
setPhase('switch')
} else if (input.toLowerCase() === 'i') {
setPhase('item')
}
return
}
// Switch phase: 1-6 = select, ESC = cancel
if (phase === 'switch') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '6') {
const idx = parseInt(input) - 1
const partyCreatures = getPartyCreatures()
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
setPhase('battle')
}
}
return
}
// Switch phase: 1-6 = select, ESC = cancel
if (phase === 'switch') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '6') {
const idx = parseInt(input) - 1
const partyCreatures = getPartyCreatures()
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
setPhase('battle')
}
}
return
}
// Item phase: 1-9 = select item, ESC = cancel
if (phase === 'item') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '9') {
if (battleState) {
const idx = parseInt(input) - 1
const items = battleState.usableItems
if (items[idx]) {
handleAction({ type: 'item', itemId: items[idx]!.id })
setPhase('battle')
}
}
}
return
}
// Item phase: 1-9 = select item, ESC = cancel
if (phase === 'item') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '9') {
if (battleState) {
const idx = parseInt(input) - 1
const items = battleState.usableItems
if (items[idx]) {
handleAction({ type: 'item', itemId: items[idx]!.id })
setPhase('battle')
}
}
}
return
}
// Result phase: Enter = continue
if (phase === 'result') {
if (key.return) {
handleResultContinue()
}
return
}
// Result phase: Enter = continue
if (phase === 'result') {
if (key.return) {
handleResultContinue()
}
return
}
// Move learn phase: 1-4 = replace, S = skip
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
} else if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
setReplaceIndex(idx)
handleMoveLearn(idx)
}
return
}
// Move learn phase: 1-4 = replace, S = skip
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
} else if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
setReplaceIndex(idx)
handleMoveLearn(idx)
}
return
}
// Evolution phase: Enter = confirm
if (phase === 'evolution') {
if (key.return) {
handleEvolutionConfirm()
}
return
}
})
// Evolution phase: Enter = confirm
if (phase === 'evolution') {
if (key.return) {
handleEvolutionConfirm()
}
return
}
})
// ─── Helpers ───
// ─── Helpers ───
function getActiveCreatureLevel(): number {
const id = buddyData.party[0]
if (!id) return 5
const c = buddyData.creatures.find(cr => cr.id === id)
return c?.level ?? 5
}
function getActiveCreatureLevel(): number {
const id = buddyData.party[0]
if (!id) return 5
const c = buddyData.creatures.find(cr => cr.id === id)
return c?.level ?? 5
}
function getPartyCreatures(): Creature[] {
return buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
}
function getPartyCreatures(): Creature[] {
return buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
}
// ─── Actions ───
// ─── Actions ───
const handleRandomBattle = useCallback(() => {
const opponentLevel = getActiveCreatureLevel()
const speciesList = ALL_SPECIES_IDS
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
handleStartBattle(randomSpecies, opponentLevel)
}, [buddyData])
const handleRandomBattle = useCallback(() => {
const opponentLevel = getActiveCreatureLevel()
const speciesList = ALL_SPECIES_IDS
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
handleStartBattle(randomSpecies, opponentLevel)
}, [buddyData])
// Config phase: start battle
const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
setOpponentSpeciesId(speciesId)
setOpponentLevel(level)
// Config phase: start battle
const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
setOpponentSpeciesId(speciesId)
setOpponentLevel(level)
const creatures = buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
const creatures = buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
if (creatures.length === 0) return
if (creatures.length === 0) return
const bagItems = buddyData.bag.items
const init = createBattle(creatures, speciesId, level, bagItems)
setBattleInit(init)
setBattleState(init.state)
setPhase('battle')
}, [buddyData])
const bagItems = buddyData.bag.items
const init = createBattle(creatures, speciesId, level, bagItems)
setBattleInit(init)
setBattleState(init.state)
setPhase('battle')
}, [buddyData])
// Battle phase: handle action
const handleAction = useCallback(async (action: PlayerAction) => {
if (!battleInit) return
const state = executeTurn(battleInit, action)
setBattleState(state)
// Battle phase: handle action
const handleAction = useCallback(async (action: PlayerAction) => {
if (!battleInit) return
const state = executeTurn(battleInit, action)
setBattleState(state)
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
const result = { ...state.result, participantIds: participants }
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
const result = { ...state.result, participantIds: participants }
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
setBuddyData(settled.data)
setPendingMoves(settled.learnableMoves)
setPendingEvos(settled.pendingEvolutions)
setBattleState({ ...state, result })
setPhase('result')
}
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
setBuddyData(settled.data)
setPendingMoves(settled.learnableMoves)
setPendingEvos(settled.pendingEvolutions)
setBattleState({ ...state, result })
setPhase('result')
}
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
// Result phase: continue to move learning
const handleResultContinue = useCallback(() => {
if (pendingMoves.length > 0) {
setPhase('learnMoves')
} else if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
// Result phase: continue to move learning
const handleResultContinue = useCallback(() => {
if (pendingMoves.length > 0) {
setPhase('learnMoves')
} else if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
// Move learning
const handleMoveLearn = useCallback((idx: number) => {
if (pendingMoves.length === 0) return
const move = pendingMoves[0]!
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
setBuddyData(updated)
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
// Move learning
const handleMoveLearn = useCallback((idx: number) => {
if (pendingMoves.length === 0) return
const move = pendingMoves[0]!
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
setBuddyData(updated)
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
const handleMoveSkip = useCallback(() => {
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
const handleMoveSkip = useCallback(() => {
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
// Evolution
const handleEvolutionConfirm = useCallback(() => {
if (pendingEvos.length === 0) return
const evo = pendingEvos[0]!
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
setBuddyData(updated)
const remaining = pendingEvos.slice(1)
setPendingEvos(remaining)
if (remaining.length === 0) {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}, [pendingEvos, buddyData, onClose])
// Evolution
const handleEvolutionConfirm = useCallback(() => {
if (pendingEvos.length === 0) return
const evo = pendingEvos[0]!
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
setBuddyData(updated)
const remaining = pendingEvos.slice(1)
setPendingEvos(remaining)
if (remaining.length === 0) {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}, [pendingEvos, buddyData, onClose])
// Render by phase
switch (phase) {
case 'config':
case 'configSelect':
return (
<BattleConfigPanel
party={getPartyCreatures()}
onSubmit={handleStartBattle}
onCancel={onClose}
/>
)
// Render by phase
switch (phase) {
case 'config':
return (
<BattleConfigPanel
party={getPartyCreatures()}
onSubmit={handleStartBattle}
onCancel={onClose}
/>
)
case 'battle': {
if (!battleState) return null
return (
<BattleView
state={battleState}
onAction={handleAction}
/>
)
}
case 'configSelect': {
const species = getSpeciesData(opponentSpeciesId)
const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color="ansi:cyan"> </Text>
{visibleSpecies.map((sid, i) => {
const s = getSpeciesData(sid)
const isSelected = sid === opponentSpeciesId
return (
<Box key={sid}>
<Text color={isSelected ? 'ansi:yellow' : 'ansi:white'}>
{isSelected ? ' ▶ ' : ' '}
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
</Text>
{isSelected && <Text color="ansi:cyan"> Lv.{getActiveCreatureLevel()}</Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color="ansi:white"> [] [Enter] [ESC] </Text>
</Box>
</Box>
)
}
case 'switch': {
if (!battleState) return null
return (
<SwitchPanel
party={getPartyCreatures()}
activeId={battleState.playerPokemon.id}
onSelect={(creatureId) => {
handleAction({ type: 'switch', creatureId })
setPhase('battle')
}}
onCancel={() => setPhase('battle')}
/>
)
}
case 'battle': {
if (!battleState) return null
return (
<BattleView
state={battleState}
onAction={handleAction}
/>
)
}
case 'item': {
if (!battleState) return null
return (
<ItemPanel
items={battleState.usableItems}
onSelect={(itemId) => {
handleAction({ type: 'item', itemId })
setPhase('battle')
}}
onCancel={() => setPhase('battle')}
/>
)
}
case 'switch': {
if (!battleState) return null
return (
<SwitchPanel
party={getPartyCreatures()}
activeId={battleState.playerPokemon.id}
onSelect={(creatureId) => {
handleAction({ type: 'switch', creatureId })
setPhase('battle')
}}
onCancel={() => setPhase('battle')}
/>
)
}
case 'result': {
if (!battleState?.result) return null
return (
<BattleResultPanel
result={battleState.result}
playerPokemon={battleState.playerPokemon}
onContinue={handleResultContinue}
/>
)
}
case 'item': {
if (!battleState) return null
return (
<ItemPanel
items={battleState.usableItems}
onSelect={(itemId) => {
handleAction({ type: 'item', itemId })
setPhase('battle')
}}
onCancel={() => setPhase('battle')}
/>
)
}
case 'learnMoves': {
if (pendingMoves.length === 0) return null
const move = pendingMoves[0]!
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
if (!creature) return null
return (
<MoveLearnPanel
creature={creature}
newMoveId={move.moveId}
replaceIndex={replaceIndex}
onLearn={handleMoveLearn}
onSkip={handleMoveSkip}
onSelectReplace={setReplaceIndex}
/>
)
}
case 'result': {
if (!battleState?.result) return null
return (
<BattleResultPanel
result={battleState.result}
playerPokemon={battleState.playerPokemon}
onContinue={handleResultContinue}
/>
)
}
case 'evolution': {
if (pendingEvos.length === 0) return null
const evo = pendingEvos[0]!
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color="ansi:yellow"> </Text>
<Text> {evo.from} {evo.to}</Text>
<Text color="ansi:white"> [Enter] </Text>
</Box>
)
}
case 'learnMoves': {
if (pendingMoves.length === 0) return null
const move = pendingMoves[0]!
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
if (!creature) return null
return (
<MoveLearnPanel
creature={creature}
newMoveId={move.moveId}
replaceIndex={replaceIndex}
onLearn={handleMoveLearn}
onSkip={handleMoveSkip}
onSelectReplace={setReplaceIndex}
/>
)
}
case 'done':
return null
case 'evolution': {
if (pendingEvos.length === 0) return null
const evo = pendingEvos[0]!
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color="ansi:yellow"> </Text>
<Text> {evo.from} {evo.to}</Text>
<Text color="ansi:white"> [Enter] </Text>
</Box>
)
}
default:
return null
}
case 'done':
return null
default:
return null
}
}

View File

@@ -9,40 +9,40 @@ const CYAN = 'ansi:cyan'
const WHITE = 'ansi:whiteBright'
interface BattleResultPanelProps {
result: BattleResult
playerPokemon: BattlePokemon
onContinue: () => void
result: BattleResult
playerPokemon: BattlePokemon
onContinue: () => void
}
export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
const isWin = result.winner === 'player'
const isWin = result.winner === 'player'
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Box>
<Text bold color={isWin ? GREEN : RED}>
{' '}{isWin ? '胜利!' : '失败...'}
</Text>
</Box>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Box>
<Text bold color={isWin ? GREEN : RED}>
{' '}{isWin ? '胜利!' : '失败...'}
</Text>
</Box>
{isWin && (
<Box flexDirection="column">
<Text> {playerPokemon.name} {result.xpGained} </Text>
{isWin && (
<Box flexDirection="column">
<Text> {playerPokemon.name} {result.xpGained} </Text>
{Object.keys(result.evGained).length > 0 && (
<Box>
<Text> : </Text>
{Object.entries(result.evGained).map(([stat, value]) => (
<Text key={stat}> {stat.toUpperCase()}+{value} </Text>
))}
</Box>
)}
</Box>
)}
{Object.keys(result.evGained).length > 0 && (
<Box>
<Text> : </Text>
{Object.entries(result.evGained).map(([stat, value]) => (
<Text key={stat}> {stat.toUpperCase()}+{value} </Text>
))}
</Box>
)}
</Box>
)}
<Box marginTop={1}>
<Text color={CYAN}> [Enter] </Text>
</Box>
</Box>
)
<Box marginTop={1}>
<Text color={CYAN}> [Enter] </Text>
</Box>
</Box>
)
}

View File

@@ -10,117 +10,117 @@ const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
function hpColor(pct: number): Color {
if (pct > 50) return GREEN
if (pct > 25) return YELLOW
return RED
if (pct > 50) return GREEN
if (pct > 25) return YELLOW
return RED
}
function hpBar(current: number, max: number): { bar: string; pct: number } {
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
const pct = Math.round((current / max) * 100)
const filled = Math.round((current / max) * 10)
return {
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
pct,
}
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
const pct = Math.round((current / max) * 100)
const filled = Math.round((current / max) * 10)
return {
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
pct,
}
}
interface BattleViewProps {
state: BattleState
onAction: (action: import('../battle/types').PlayerAction) => void
state: BattleState
onAction: (action: import('../battle/types').PlayerAction) => void
}
export function BattleView({ state, onAction }: BattleViewProps) {
const opp = state.opponentPokemon
const player = state.playerPokemon
const oppHp = hpBar(opp.hp, opp.maxHp)
const playerHp = hpBar(player.hp, player.maxHp)
const opp = state.opponentPokemon
const player = state.playerPokemon
const oppHp = hpBar(opp.hp, opp.maxHp)
const playerHp = hpBar(player.hp, player.maxHp)
// Show last 5 events
const recentEvents = state.events.slice(-5)
// Show last 5 events
const recentEvents = state.events.slice(-5)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Opponent */}
<Box flexDirection="column">
<Box>
<Text bold> {opp.name} </Text>
<Text>(Lv.{opp.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
<Text> {oppHp.pct}%</Text>
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
</Box>
</Box>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Opponent */}
<Box flexDirection="column">
<Box>
<Text bold> {opp.name} </Text>
<Text>(Lv.{opp.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
<Text> {oppHp.pct}%</Text>
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
</Box>
</Box>
<Text color={GRAY}> vs </Text>
<Text color={GRAY}> vs </Text>
{/* Player */}
<Box flexDirection="column">
<Box>
<Text bold> {player.name} </Text>
<Text>(Lv.{player.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
<Text> {playerHp.pct}%</Text>
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
</Box>
</Box>
{/* Player */}
<Box flexDirection="column">
<Box>
<Text bold> {player.name} </Text>
<Text>(Lv.{player.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
<Text> {playerHp.pct}%</Text>
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
</Box>
</Box>
{/* Move selection */}
{!state.finished && (
<Box flexDirection="column" marginTop={1}>
<Text bold> :</Text>
{player.moves.map((move, i) => (
<Box key={move.id || i}>
<Text color={move.pp > 0 ? WHITE : GRAY}>
{' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
</Text>
</Box>
))}
<Text color={CYAN}> [S] [I] </Text>
</Box>
)}
{/* Move selection */}
{!state.finished && (
<Box flexDirection="column" marginTop={1}>
<Text bold> :</Text>
{player.moves.map((move, i) => (
<Box key={move.id || i}>
<Text color={move.pp > 0 ? WHITE : GRAY}>
{' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
</Text>
</Box>
))}
<Text color={CYAN}> [S] [I] </Text>
</Box>
)}
{/* Event log */}
{recentEvents.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{recentEvents.map((event, i) => (
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
))}
</Box>
)}
</Box>
)
{/* Event log */}
{recentEvents.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{recentEvents.map((event, i) => (
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
))}
</Box>
)}
</Box>
)
}
function eventColor(event: BattleEvent): Color {
switch (event.type) {
case 'damage': return RED
case 'heal': return GREEN
case 'faint': return RED
case 'crit': return YELLOW
case 'miss': return GRAY
case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
default: return WHITE
}
switch (event.type) {
case 'damage': return RED
case 'heal': return GREEN
case 'faint': return RED
case 'crit': return YELLOW
case 'miss': return GRAY
case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
default: return WHITE
}
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
case 'turn': return `── 回合 ${event.number} ──`
default: return ''
}
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
case 'turn': return `── 回合 ${event.number} ──`
default: return ''
}
}

View File

@@ -2,20 +2,20 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types'
import { getSpeciesData } from '../data/species'
import { SPECIES_PERSONALITY } from '../data/names'
import { getSpeciesData } from '../dex/species'
import { SPECIES_PERSONALITY } from '../dex/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience'
import { getEVSummary } from '../core/effort'
import { getGenderSymbol } from '../core/gender'
import { getStatColor } from './shared'
import { getNextEvolution } from '../data/evolution'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
interface CompanionCardProps {
creature: Creature
buddyData: BuddyData
spriteLines?: string[]
creature: Creature
buddyData: BuddyData
spriteLines?: string[]
}
// ANSI color constants
@@ -30,130 +30,130 @@ const GRAY: Color = 'ansi:white'
/** Type → display color mapping */
const TYPE_COLORS: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
/**
* Redesigned companion card with Pokémon-style stats display.
*/
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const xp = getXpProgress(creature)
const genderSymbol = getGenderSymbol(creature.gender)
const name = getCreatureName(creature)
const evSummary = getEVSummary(creature)
const totalEV = getTotalEV(creature)
const nextEvo = getNextEvolution(creature.speciesId)
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const xp = getXpProgress(creature)
const genderSymbol = getGenderSymbol(creature.gender)
const name = getCreatureName(creature)
const evSummary = getEVSummary(creature)
const totalEV = getTotalEV(creature)
const nextEvo = getNextEvolution(creature.speciesId)
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? '/' : ''}{t.toUpperCase()}
</Text>
))
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? '/' : ''}{t.toUpperCase()}
</Text>
))
// Friendship color
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
// Friendship color
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
// Shiny badge
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> SHINY</Text> : null
// Shiny badge
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> SHINY</Text> : null
// Evolution hint
const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null
// Evolution hint
const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header row */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>{name}</Text>
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge}
</Box>
<Text bold>Lv.{creature.level}</Text>
</Box>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header row */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>{name}</Text>
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge}
</Box>
<Text bold>Lv.{creature.level}</Text>
</Box>
{/* Species + type + gender */}
<Box>
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
</Box>
{/* Species + type + gender */}
<Box>
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
</Box>
{/* Sprite */}
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines ? (
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
) : (
<Text color={GRAY}>[Loading sprite...]</Text>
)}
</Box>
{/* Sprite */}
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines ? (
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
) : (
<Text color={GRAY}>[Loading sprite...]</Text>
)}
</Box>
{/* Personality */}
<Box>
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
</Box>
{/* Personality */}
<Box>
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
</Box>
{/* Stats section */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<StatBar
key={stat}
label={STAT_LABELS[stat]}
value={stats[stat]}
maxValue={255}
color={getStatColor(stat)}
/>
))}
</Box>
{/* Stats section */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<StatBar
key={stat}
label={STAT_LABELS[stat]}
value={stats[stat]}
maxValue={255}
color={getStatColor(stat)}
/>
))}
</Box>
{/* XP progress */}
<Box marginTop={0}>
<Text color={GRAY}>XP </Text>
<Text color={BLUE}>
{'█'.repeat(Math.round(xp.percentage / 10))}
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
</Text>
<Text> {xp.current}/{xp.needed}</Text>
</Box>
{/* XP progress */}
<Box marginTop={0}>
<Text color={GRAY}>XP </Text>
<Text color={BLUE}>
{'█'.repeat(Math.round(xp.percentage / 10))}
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
</Text>
<Text> {xp.current}/{xp.needed}</Text>
</Box>
{/* EV + Friendship */}
<Box flexDirection="column">
<Box>
<Text color={GRAY}>EV </Text>
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
<Text color={GRAY}> ({totalEV}/510)</Text>
</Box>
<Box>
<Text color={GRAY}> </Text>
<Text color={friendshipColor}>
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
</Text>
<Text> {creature.friendship}/255</Text>
</Box>
</Box>
{/* EV + Friendship */}
<Box flexDirection="column">
<Box>
<Text color={GRAY}>EV </Text>
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
<Text color={GRAY}> ({totalEV}/510)</Text>
</Box>
<Box>
<Text color={GRAY}> </Text>
<Text color={friendshipColor}>
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
</Text>
<Text> {creature.friendship}/255</Text>
</Box>
</Box>
{/* Evolution hint */}
{evoHint && (
<Box marginTop={0}>
<Text color={GRAY}>Next: </Text>
{evoHint}
</Box>
)}
{/* Evolution hint */}
{evoHint && (
<Box marginTop={0}>
<Text color={GRAY}>Next: </Text>
{evoHint}
</Box>
)}
</Box>
)
</Box>
)
}

View File

@@ -7,48 +7,48 @@ const YELLOW: Color = 'ansi:yellow'
const GRAY: Color = 'ansi:white'
interface EggViewProps {
egg: Egg
egg: Egg
}
/**
* Egg status view showing hatch progress.
*/
export function EggView({ egg }: EggViewProps) {
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
const filled = Math.round(percentage / 10)
const empty = 10 - filled
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
const filled = Math.round(percentage / 10)
const empty = 10 - filled
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={CYAN}>
Egg Status
</Text>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={CYAN}>
Egg Status
</Text>
{/* ASCII egg */}
<Box flexDirection="column" alignItems="center" marginY={1}>
<Text> . </Text>
<Text> / \ </Text>
<Text> | | </Text>
<Text> \_/ </Text>
</Box>
{/* ASCII egg */}
<Box flexDirection="column" alignItems="center" marginY={1}>
<Text> . </Text>
<Text> / \ </Text>
<Text> | | </Text>
<Text> \_/ </Text>
</Box>
{/* Progress */}
<Box flexDirection="column" alignItems="center">
<Text>
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
</Text>
<Text color={YELLOW}>
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</Text>
<Text>{percentage}%</Text>
</Box>
{/* Progress */}
<Box flexDirection="column" alignItems="center">
<Text>
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
</Text>
<Text color={YELLOW}>
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</Text>
<Text>{percentage}%</Text>
</Box>
{/* Tips */}
<Box marginTop={1} flexDirection="column" alignItems="center">
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
</Box>
</Box>
)
{/* Tips */}
<Box marginTop={1} flexDirection="column" alignItems="center">
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
</Box>
</Box>
)
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { loadSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
@@ -10,9 +10,9 @@ const GREEN: Color = 'ansi:green'
const GRAY: Color = 'ansi:white'
interface EvolutionAnimProps {
fromSpecies: SpeciesId
toSpecies: SpeciesId
onComplete: () => void
fromSpecies: SpeciesId
toSpecies: SpeciesId
onComplete: () => void
}
/**
@@ -21,70 +21,70 @@ interface EvolutionAnimProps {
* 8 frames × 500ms = ~4 seconds total.
*/
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
const [tick, setTick] = useState(0)
const totalFrames = 8
const [tick, setTick] = useState(0)
const totalFrames = 8
useEffect(() => {
if (tick >= totalFrames) {
onComplete()
return
}
const timer = setTimeout(() => setTick((t) => t + 1), 500)
return () => clearTimeout(timer)
}, [tick, onComplete])
useEffect(() => {
if (tick >= totalFrames) {
onComplete()
return
}
const timer = setTimeout(() => setTick((t) => t + 1), 500)
return () => clearTimeout(timer)
}, [tick, onComplete])
const fromSprite = getSpriteLines(fromSpecies)
const toSprite = getSpriteLines(toSpecies)
const fromName = getSpeciesData(fromSpecies).name
const toName = getSpeciesData(toSpecies).name
const fromSprite = getSpriteLines(fromSpecies)
const toSprite = getSpriteLines(toSpecies)
const fromName = getSpeciesData(fromSpecies).name
const toName = getSpeciesData(toSpecies).name
// Frame logic:
// 0-3: old sprite with flash (alternate blank)
// 4-7: alternate old/new, settle on new
let displayLines: string[]
if (tick < 3) {
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
} else if (tick < 6) {
displayLines = tick % 2 === 0 ? fromSprite : toSprite
} else {
displayLines = toSprite
}
// Frame logic:
// 0-3: old sprite with flash (alternate blank)
// 4-7: alternate old/new, settle on new
let displayLines: string[]
if (tick < 3) {
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
} else if (tick < 6) {
displayLines = tick % 2 === 0 ? fromSprite : toSprite
} else {
displayLines = toSprite
}
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={YELLOW}>
Evolution!
</Text>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={YELLOW}>
Evolution!
</Text>
<Box flexDirection="column" alignItems="center" marginY={1}>
{displayLines.map((line, i) => (
<Text key={i}>
{tick >= 6 ? '✨ ' : ''}
{line}
{tick >= 6 ? ' ✨' : ''}
</Text>
))}
</Box>
<Box flexDirection="column" alignItems="center" marginY={1}>
{displayLines.map((line, i) => (
<Text key={i}>
{tick >= 6 ? '✨ ' : ''}
{line}
{tick >= 6 ? ' ✨' : ''}
</Text>
))}
</Box>
<Text>
<Text color={GRAY}>{fromName}</Text>
<Text color={YELLOW}> </Text>
<Text bold color={GREEN}>
{toName}
</Text>
</Text>
<Text>
<Text color={GRAY}>{fromName}</Text>
<Text color={YELLOW}> </Text>
<Text bold color={GREEN}>
{toName}
</Text>
</Text>
{tick >= totalFrames - 1 && (
<Text bold color={GREEN}>
</Text>
)}
</Box>
)
{tick >= totalFrames - 1 && (
<Text bold color={GREEN}>
</Text>
)}
</Box>
)
}
function getSpriteLines(speciesId: SpeciesId): string[] {
const cached = loadSprite(speciesId)
if (cached) return cached.lines
return getFallbackSprite(speciesId)
const cached = loadSprite(speciesId)
if (cached) return cached.lines
return getFallbackSprite(speciesId)
}

View File

@@ -5,28 +5,28 @@ const CYAN = 'ansi:cyan'
const GRAY = 'ansi:white'
interface ItemPanelProps {
items: { id: string; name: string; count: number; description?: string }[]
onSelect: (itemId: string) => void
onCancel: () => void
items: { id: string; name: string; count: number; description?: string }[]
onSelect: (itemId: string) => void
onCancel: () => void
}
export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{items.length === 0 ? (
<Text color={GRAY}> </Text>
) : (
items.map((item, i) => (
<Box key={item.id}>
<Text> [{i + 1}] {item.name} ×{item.count}</Text>
{item.description && <Text color={GRAY}> {item.description}</Text>}
</Box>
))
)}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{items.length === 0 ? (
<Text color={GRAY}> </Text>
) : (
items.map((item, i) => (
<Box key={item.id}>
<Text> [{i + 1}] {item.name} ×{item.count}</Text>
{item.description && <Text color={GRAY}> {item.description}</Text>}
</Box>
))
)}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -9,41 +9,41 @@ const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
interface MoveLearnPanelProps {
creature: Creature
newMoveId: string
replaceIndex: number
onLearn: (replaceIndex: number) => void
onSkip: () => void
onSelectReplace: (index: number) => void
creature: Creature
newMoveId: string
replaceIndex: number
onLearn: (replaceIndex: number) => void
onSkip: () => void
onSelectReplace: (index: number) => void
}
export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
const dexMove = Dex.moves.get(newMoveId)
const moveName = dexMove?.name ?? newMoveId
const moveType = dexMove?.type ?? 'Normal'
const dexMove = Dex.moves.get(newMoveId)
const moveName = dexMove?.name ?? newMoveId
const moveType = dexMove?.type ?? 'Normal'
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
<Text> {creature.speciesId} : <Text bold>{moveName}</Text> ({moveType})</Text>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
<Text> {creature.speciesId} : <Text bold>{moveName}</Text> ({moveType})</Text>
<Box marginTop={1}><Text bold> :</Text></Box>
{creature.moves.map((move, i) => {
const isReplaceTarget = i === replaceIndex
const moveInfo = move.id ? Dex.moves.get(move.id) : null
return (
<Box key={i}>
<Text color={isReplaceTarget ? YELLOW : WHITE}>
{' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
</Text>
{isReplaceTarget && <Text color={YELLOW}> </Text>}
</Box>
)
})}
<Box marginTop={1}><Text bold> :</Text></Box>
{creature.moves.map((move, i) => {
const isReplaceTarget = i === replaceIndex
const moveInfo = move.id ? Dex.moves.get(move.id) : null
return (
<Box key={i}>
<Text color={isReplaceTarget ? YELLOW : WHITE}>
{' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
</Text>
{isReplaceTarget && <Text color={YELLOW}> </Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color={GRAY}> [1-4] [S] </Text>
</Box>
</Box>
)
<Box marginTop={1}>
<Text color={GRAY}> [1-4] [S] </Text>
</Box>
</Box>
)
}

View File

@@ -2,8 +2,8 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
const CYAN: Color = 'ansi:cyan'
const GREEN: Color = 'ansi:green'
@@ -14,7 +14,7 @@ const RED: Color = 'ansi:red'
const BLUE: Color = 'ansi:blue'
interface PokedexViewProps {
buddyData: BuddyData
buddyData: BuddyData
}
/**
@@ -22,165 +22,165 @@ interface PokedexViewProps {
* evolution chains, and active creature indicator.
*/
export function PokedexView({ buddyData }: PokedexViewProps) {
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
const collected = buddyData.dex.length
const total = ALL_SPECIES_IDS.length
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
const collected = buddyData.dex.length
const total = ALL_SPECIES_IDS.length
// Group species by evolution chain
const chains = groupByChain()
// Group species by evolution chain
const chains = groupByChain()
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between" marginBottom={0}>
<Text bold color={CYAN}>Pokédex</Text>
<Text>
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
<Text color={GRAY}>/{total} </Text>
<Text color={GRAY}>collected</Text>
</Text>
</Box>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between" marginBottom={0}>
<Text bold color={CYAN}>Pokédex</Text>
<Text>
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
<Text color={GRAY}>/{total} </Text>
<Text color={GRAY}>collected</Text>
</Text>
</Box>
{/* Progress bar */}
<Box>
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
<Text> {Math.floor((collected / total) * 100)}%</Text>
</Box>
{/* Progress bar */}
<Box>
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
<Text> {Math.floor((collected / total) * 100)}%</Text>
</Box>
{/* Species list grouped by evolution chains */}
{chains.map((chain, ci) => (
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
{chain.map((speciesId, si) => {
const species = getSpeciesData(speciesId)
const entry = dexMap.get(speciesId)
const discovered = !!entry
const isActive = buddyData.party[0]
? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId)
: false
const nextEvo = getNextEvolution(speciesId)
{/* Species list grouped by evolution chains */}
{chains.map((chain, ci) => (
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
{chain.map((speciesId, si) => {
const species = getSpeciesData(speciesId)
const entry = dexMap.get(speciesId)
const discovered = !!entry
const isActive = buddyData.party[0]
? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId)
: false
const nextEvo = getNextEvolution(speciesId)
return (
<Box key={speciesId} flexDirection="column">
<Box>
{/* Chain connector */}
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
{/* Active indicator */}
<Text>{isActive ? <Text color={YELLOW}></Text> : ' '}</Text>
{/* Dex number */}
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
{/* Name */}
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
{discovered
? (species.names.zh ?? species.name)
: '???'}
</Text>
{/* Type badges */}
{discovered && (
<Text>
{' '}
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
<Text key={t} color={getTypeColor(t)}>
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
</Text>
))}
</Text>
)}
{/* Level / unknown indicator */}
{discovered && entry ? (
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
) : (
<Text color={GRAY}> </Text>
)}
{/* Evolution arrow */}
{nextEvo && (
<Text color={GRAY}> <Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
)}
</Box>
</Box>
)
})}
</Box>
))}
return (
<Box key={speciesId} flexDirection="column">
<Box>
{/* Chain connector */}
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
{/* Active indicator */}
<Text>{isActive ? <Text color={YELLOW}></Text> : ' '}</Text>
{/* Dex number */}
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
{/* Name */}
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
{discovered
? (species.names.zh ?? species.name)
: '???'}
</Text>
{/* Type badges */}
{discovered && (
<Text>
{' '}
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
<Text key={t} color={getTypeColor(t)}>
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
</Text>
))}
</Text>
)}
{/* Level / unknown indicator */}
{discovered && entry ? (
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
) : (
<Text color={GRAY}> </Text>
)}
{/* Evolution arrow */}
{nextEvo && (
<Text color={GRAY}> <Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
)}
</Box>
</Box>
)
})}
</Box>
))}
{/* Stats row */}
<Box marginTop={0} flexDirection="column">
<Text color={GRAY}> Stats </Text>
<Box>
<Text color={GRAY}>Turns: </Text>
<Text>{buddyData.stats.totalTurns}</Text>
<Text color={GRAY}> Days: </Text>
<Text>{buddyData.stats.consecutiveDays}</Text>
</Box>
<Box>
<Text color={GRAY}>Eggs: </Text>
<Text>{buddyData.stats.totalEggsObtained}</Text>
<Text color={GRAY}> Evolutions: </Text>
<Text>{buddyData.stats.totalEvolutions}</Text>
</Box>
</Box>
{/* Stats row */}
<Box marginTop={0} flexDirection="column">
<Text color={GRAY}> Stats </Text>
<Box>
<Text color={GRAY}>Turns: </Text>
<Text>{buddyData.stats.totalTurns}</Text>
<Text color={GRAY}> Days: </Text>
<Text>{buddyData.stats.consecutiveDays}</Text>
</Box>
<Box>
<Text color={GRAY}>Eggs: </Text>
<Text>{buddyData.stats.totalEggsObtained}</Text>
<Text color={GRAY}> Evolutions: </Text>
<Text>{buddyData.stats.totalEvolutions}</Text>
</Box>
</Box>
{/* Egg info */}
{buddyData.eggs.length > 0 && (
<Box marginTop={0}>
<Text color={YELLOW}>🥚 Egg: </Text>
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
<Text color={GRAY}> steps</Text>
</Box>
)}
{/* Egg info */}
{buddyData.eggs.length > 0 && (
<Box marginTop={0}>
<Text color={YELLOW}>🥚 Egg: </Text>
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
<Text color={GRAY}> steps</Text>
</Box>
)}
{buddyData.stats.consecutiveDays < 7 && (
<Box>
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
</Box>
)}
</Box>
)
{buddyData.stats.consecutiveDays < 7 && (
<Box>
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
</Box>
)}
</Box>
)
}
/** Type → color mapping */
function getTypeColor(type: string): Color {
const colors: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
return colors[type] ?? 'ansi:white'
const colors: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
return colors[type] ?? 'ansi:white'
}
/** Group species by evolution chain for visual display */
function groupByChain(): SpeciesId[][] {
const visited = new Set<SpeciesId>()
const chains: SpeciesId[][] = []
const visited = new Set<SpeciesId>()
const chains: SpeciesId[][] = []
for (const id of ALL_SPECIES_IDS) {
if (visited.has(id)) continue
for (const id of ALL_SPECIES_IDS) {
if (visited.has(id)) continue
// Walk back to find chain head
let head: SpeciesId = id
for (const candidate of ALL_SPECIES_IDS) {
const evo = getNextEvolution(candidate)
if (evo?.to === head) {
head = candidate
break
}
}
// Walk back to find chain head
let head: SpeciesId = id
for (const candidate of ALL_SPECIES_IDS) {
const evo = getNextEvolution(candidate)
if (evo?.to === head) {
head = candidate
break
}
}
// Walk forward to build chain
const chain: SpeciesId[] = []
let current: SpeciesId | undefined = head
while (current && !visited.has(current)) {
chain.push(current)
visited.add(current)
current = getNextEvolution(current)?.to
}
// Walk forward to build chain
const chain: SpeciesId[] = []
let current: SpeciesId | undefined = head
while (current && !visited.has(current)) {
chain.push(current)
visited.add(current)
current = getNextEvolution(current)?.to
}
if (chain.length > 0) chains.push(chain)
}
if (chain.length > 0) chains.push(chain)
}
return chains
return chains
}

View File

@@ -2,8 +2,8 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId, StatName } from '../types'
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
import { getStatColor } from './shared'
@@ -17,160 +17,160 @@ const BLUE: Color = 'ansi:blue'
/** Type → color */
const TYPE_COLORS: Record<string, Color> = {
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
normal: 'ansi:white',
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
normal: 'ansi:white',
}
interface SpeciesDetailProps {
speciesId: SpeciesId
caughtLevel?: number
spriteLines?: string[]
speciesId: SpeciesId
caughtLevel?: number
spriteLines?: string[]
}
/**
* Detailed species info page — base stats, evolution chain, flavor text.
*/
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
const species = getSpeciesData(speciesId)
const nextEvo = getNextEvolution(speciesId)
const species = getSpeciesData(speciesId)
const nextEvo = getNextEvolution(speciesId)
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
</Text>
))
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
</Text>
))
// Gender info
const genderInfo = species.genderRate === -1
? 'Genderless'
: species.genderRate === 0
? '♂ 100%'
: species.genderRate === 8
? '♀ 100%'
: `${(species.genderRate / 8 * 100).toFixed(1)}%`
// Gender info
const genderInfo = species.genderRate === -1
? 'Genderless'
: species.genderRate === 0
? '♂ 100%'
: species.genderRate === 8
? '♀ 100%'
: `${(species.genderRate / 8 * 100).toFixed(1)}%`
// Max base stat for bar scaling
const maxBase = 130
// Max base stat for bar scaling
const maxBase = 130
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
</Box>
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
</Box>
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
</Box>
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
</Box>
{/* Type + gender */}
<Box>
{typeBadges}
<Text color={GRAY}> {genderInfo}</Text>
</Box>
{/* Type + gender */}
<Box>
{typeBadges}
<Text color={GRAY}> {genderInfo}</Text>
</Box>
{/* Sprite */}
{spriteLines && (
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
</Box>
)}
{/* Sprite */}
{spriteLines && (
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
</Box>
)}
{/* Flavor text */}
{species.flavorText && (
<Box marginTop={0}>
<Text color={GRAY} italic>{species.flavorText}</Text>
</Box>
)}
{/* Flavor text */}
{species.flavorText && (
<Box marginTop={0}>
<Text color={GRAY} italic>{species.flavorText}</Text>
</Box>
)}
{/* Base Stats */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<Box key={stat}>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
</Text>
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
</Box>
))}
{/* Total */}
<Box>
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
<Text color={GRAY}>
{'─'.repeat(15)}
</Text>
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
</Box>
</Box>
{/* Base Stats */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<Box key={stat}>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
</Text>
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
</Box>
))}
{/* Total */}
<Box>
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
<Text color={GRAY}>
{'─'.repeat(15)}
</Text>
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
</Box>
</Box>
{/* Evolution chain */}
{(nextEvo || species.dexNumber > 1) && (
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Evolution </Text>
<EvolutionChain speciesId={speciesId} />
</Box>
)}
{/* Evolution chain */}
{(nextEvo || species.dexNumber > 1) && (
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Evolution </Text>
<EvolutionChain speciesId={speciesId} />
</Box>
)}
{/* Info */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Info </Text>
<Box>
<Text color={GRAY}>Growth: </Text>
<Text>{species.growthRate}</Text>
</Box>
<Box>
<Text color={GRAY}>Capture: </Text>
<Text>{species.captureRate}</Text>
<Text color={GRAY}> Happiness: </Text>
<Text>{species.baseHappiness}</Text>
</Box>
</Box>
</Box>
)
{/* Info */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Info </Text>
<Box>
<Text color={GRAY}>Growth: </Text>
<Text>{species.growthRate}</Text>
</Box>
<Box>
<Text color={GRAY}>Capture: </Text>
<Text>{species.captureRate}</Text>
<Text color={GRAY}> Happiness: </Text>
<Text>{species.baseHappiness}</Text>
</Box>
</Box>
</Box>
)
}
/** Render evolution chain arrow */
function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
// Walk back to find chain head
let head: SpeciesId = speciesId
for (const candidate of ALL_SPECIES_IDS) {
const evo = getNextEvolution(candidate)
if (evo?.to === head) {
head = candidate
break
}
}
// Walk back to find chain head
let head: SpeciesId = speciesId
for (const candidate of ALL_SPECIES_IDS) {
const evo = getNextEvolution(candidate)
if (evo?.to === head) {
head = candidate
break
}
}
const chain: SpeciesId[] = [head]
let current: SpeciesId | undefined = head
while (current) {
const next = getNextEvolution(current)
if (next) {
chain.push(next.to)
current = next.to
} else {
current = undefined
}
}
const chain: SpeciesId[] = [head]
let current: SpeciesId | undefined = head
while (current) {
const next = getNextEvolution(current)
if (next) {
chain.push(next.to)
current = next.to
} else {
current = undefined
}
}
return (
<Box>
{chain.map((sid, i) => (
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
{getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
</Text>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
)}
</React.Fragment>
))}
</Box>
)
return (
<Box>
{chain.map((sid, i) => (
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
{getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
</Text>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
)}
</React.Fragment>
))}
</Box>
)
}

View File

@@ -7,18 +7,18 @@ import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites
const V_PAD = 4
interface SpriteAnimatorProps {
/** Base sprite lines (ANSI is preserved) */
lines: string[]
/** Text color for the sprite */
color?: Color
/** Tick interval in ms (default 250) */
tickMs?: number
/** Single mode; omit for idle auto-play */
mode?: AnimMode
/** Center horizontally (default true) */
centered?: boolean
/** Show pet hearts overlay */
petting?: boolean
/** Base sprite lines (ANSI is preserved) */
lines: string[]
/** Text color for the sprite */
color?: Color
/** Tick interval in ms (default 250) */
tickMs?: number
/** Single mode; omit for idle auto-play */
mode?: AnimMode
/** Center horizontally (default true) */
centered?: boolean
/** Show pet hearts overlay */
petting?: boolean
}
/**
@@ -29,44 +29,44 @@ interface SpriteAnimatorProps {
* - Grid transforms guarantee fixed output height
*/
export function SpriteAnimator({
lines,
color,
tickMs = 100,
mode,
centered = true,
petting,
lines,
color,
tickMs = 100,
mode,
centered = true,
petting,
}: SpriteAnimatorProps) {
const [tick, setTick] = useState(0)
const [tick, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), tickMs)
return () => clearInterval(timer)
}, [tickMs])
useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), tickMs)
return () => clearInterval(timer)
}, [tickMs])
// Add vertical padding — bounce shifts within this space
const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
// Add vertical padding — bounce shifts within this space
const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
// Apply animation (renderer parses to pixels, transforms, renders back)
const currentMode = mode ?? getIdleAnimMode(tick)
const animated = renderAnimatedSprite(padded, tick, currentMode)
// Apply animation (renderer parses to pixels, transforms, renders back)
const currentMode = mode ?? getIdleAnimMode(tick)
const animated = renderAnimatedSprite(padded, tick, currentMode)
// Pet hearts overlay
const overlay = petting ? getPetOverlay(tick) : null
const displayLines = overlay ? [...overlay, ...animated] : animated
// Pet hearts overlay
const overlay = petting ? getPetOverlay(tick) : null
const displayLines = overlay ? [...overlay, ...animated] : animated
const spriteBlock = (
<Box flexDirection="column">
{displayLines.map((line, i) => (
<Text key={i} color={color}>{line || ' '}</Text>
))}
</Box>
)
const spriteBlock = (
<Box flexDirection="column">
{displayLines.map((line, i) => (
<Text key={i} color={color}>{line || ' '}</Text>
))}
</Box>
)
if (!centered) return spriteBlock
if (!centered) return spriteBlock
return (
<Box flexDirection="row" justifyContent="center" width="100%">
{spriteBlock}
</Box>
)
return (
<Box flexDirection="row" justifyContent="center" width="100%">
{spriteBlock}
</Box>
)
}

View File

@@ -2,27 +2,27 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
interface StatBarProps {
label: string
value: number
maxValue: number
color?: Color
width?: number
label: string
value: number
maxValue: number
color?: Color
width?: number
}
/**
* Compact horizontal stat bar for Pokémon stats.
*/
export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) {
const filled = Math.round((value / maxValue) * width)
const empty = width - filled
const bar = '█'.repeat(filled) + '░'.repeat(empty)
const valueStr = String(value).padStart(3)
const filled = Math.round((value / maxValue) * width)
const empty = width - filled
const bar = '█'.repeat(filled) + '░'.repeat(empty)
const valueStr = String(value).padStart(3)
return (
<Box>
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
<Text color={color}>{bar}</Text>
<Text> {valueStr}</Text>
</Box>
)
return (
<Box>
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
<Text color={color}>{bar}</Text>
<Text> {valueStr}</Text>
</Box>
)
}

View File

@@ -8,31 +8,31 @@ const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
interface SwitchPanelProps {
party: Creature[]
activeId: string
onSelect: (creatureId: string) => void
onCancel: () => void
party: Creature[]
activeId: string
onSelect: (creatureId: string) => void
onCancel: () => void
}
export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{party.map((creature, i) => {
const isActive = creature.id === activeId
return (
<Box key={creature.id}>
<Text>{isActive ? ' ▶ ' : ' '}</Text>
<Text color={isActive ? GRAY : WHITE}>
[{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
</Text>
{isActive && <Text color={GRAY}> </Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{party.map((creature, i) => {
const isActive = creature.id === activeId
return (
<Box key={creature.id}>
<Text>{isActive ? ' ▶ ' : ' '}</Text>
<Text color={isActive ? GRAY : WHITE}>
[{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
</Text>
{isActive && <Text color={GRAY}> </Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -1,14 +1,14 @@
import type { Color } from '@anthropic/ink'
const STAT_COLORS: Record<string, Color> = {
hp: 'ansi:green',
attack: 'ansi:red',
defense: 'ansi:yellow',
spAtk: 'ansi:blue',
spDef: 'ansi:magenta',
speed: 'ansi:cyan',
hp: 'ansi:green',
attack: 'ansi:red',
defense: 'ansi:yellow',
spAtk: 'ansi:blue',
spDef: 'ansi:magenta',
speed: 'ansi:cyan',
}
export function getStatColor(stat: string): Color {
return STAT_COLORS[stat] ?? 'ansi:white'
return STAT_COLORS[stat] ?? 'ansi:white'
}

View File

@@ -21,6 +21,7 @@ import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon';
import { BattleFlow, loadBuddyData } from '@claude-code-best/pokemon';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -91,6 +92,13 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
onClose={() => onClose('buddy panel closed')}
/>
</Tab>,
<Tab key="battle" title="Battle">
<BattleTab
buddyData={data}
isActive={selectedTab === 'Battle'}
onUpdate={updateData}
/>
</Tab>,
<Tab key="egg" title="Egg">
<EggTab buddyData={data} />
</Tab>,
@@ -613,6 +621,35 @@ function DexTab({
);
}
// ─── Battle Tab ──────────────────────────────────────────
function BattleTab({
buddyData,
isActive,
onUpdate,
}: {
buddyData: BuddyData;
isActive: boolean;
onUpdate: (data: BuddyData) => void;
}) {
const [battleKey, setBattleKey] = useState(0);
const handleClose = async () => {
const updated = await loadBuddyData();
onUpdate(updated);
setBattleKey(k => k + 1);
};
return (
<BattleFlow
key={battleKey}
buddyData={buddyData}
onClose={handleClose}
isActive={isActive}
/>
);
}
// ─── Egg Tab ──────────────────────────────────────────
function EggTab({ buddyData }: { buddyData: BuddyData }) {