mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }[]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
31
packages/pokemon/src/dex/evMapping.ts
Normal file
31
packages/pokemon/src/dex/evMapping.ts
Normal 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]
|
||||
}
|
||||
37
packages/pokemon/src/dex/evolution.ts
Normal file
37
packages/pokemon/src/dex/evolution.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
59
packages/pokemon/src/dex/learnsets.ts
Normal file
59
packages/pokemon/src/dex/learnsets.ts
Normal 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
|
||||
}
|
||||
43
packages/pokemon/src/dex/names.ts
Normal file
43
packages/pokemon/src/dex/names.ts
Normal 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',
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
191
packages/pokemon/src/dex/species.ts
Normal file
191
packages/pokemon/src/dex/species.ts
Normal 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',
|
||||
}
|
||||
81
packages/pokemon/src/dex/xpTable.ts
Normal file
81
packages/pokemon/src/dex/xpTable.ts
Normal 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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user