mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +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",
|
"types": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pkmn/client": "^0.7.2",
|
"@pkmn/client": "^0.7.2",
|
||||||
"@pkmn/protocol": "^0.7.2",
|
"@pkmn/protocol": "^0.7.2"
|
||||||
"@pkmn/view": "^0.7.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,465 +5,465 @@ import { chooseAIMove } from '../battle/ai'
|
|||||||
import type { Creature, BuddyData } from '../types'
|
import type { Creature, BuddyData } from '../types'
|
||||||
|
|
||||||
function makeTestCreature(overrides: Partial<Creature> = {}): Creature {
|
function makeTestCreature(overrides: Partial<Creature> = {}): Creature {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? 'test-1',
|
id: overrides.id ?? 'test-1',
|
||||||
speciesId: overrides.speciesId ?? 'charmander',
|
speciesId: overrides.speciesId ?? 'charmander',
|
||||||
gender: overrides.gender ?? 'male',
|
gender: overrides.gender ?? 'male',
|
||||||
level: overrides.level ?? 50,
|
level: overrides.level ?? 50,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
totalXp: 0,
|
totalXp: 0,
|
||||||
nature: overrides.nature ?? 'adamant',
|
nature: overrides.nature ?? 'adamant',
|
||||||
ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
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 },
|
iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
|
||||||
moves: overrides.moves ?? [
|
moves: overrides.moves ?? [
|
||||||
{ id: 'flamethrower', pp: 15, maxPp: 15 },
|
{ id: 'flamethrower', pp: 15, maxPp: 15 },
|
||||||
{ id: 'airslash', pp: 15, maxPp: 15 },
|
{ id: 'airslash', pp: 15, maxPp: 15 },
|
||||||
{ id: 'dragontail', pp: 10, maxPp: 10 },
|
{ id: 'dragontail', pp: 10, maxPp: 10 },
|
||||||
{ id: 'slash', pp: 20, maxPp: 20 },
|
{ id: 'slash', pp: 20, maxPp: 20 },
|
||||||
],
|
],
|
||||||
ability: overrides.ability ?? 'blaze',
|
ability: overrides.ability ?? 'blaze',
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
friendship: overrides.friendship ?? 70,
|
friendship: overrides.friendship ?? 70,
|
||||||
isShiny: false,
|
isShiny: false,
|
||||||
hatchedAt: Date.now(),
|
hatchedAt: Date.now(),
|
||||||
pokeball: 'pokeball',
|
pokeball: 'pokeball',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData {
|
function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creatures[0]!.id, null, null, null, null, null],
|
party: [creatures[0]!.id, null, null, null, null, null],
|
||||||
boxes: [],
|
boxes: [],
|
||||||
creatures: creatures,
|
creatures: creatures,
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: '',
|
lastActiveDate: '',
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createBattle', () => {
|
describe('createBattle', () => {
|
||||||
test('creates battle with valid initial state', () => {
|
test('creates battle with valid initial state', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
expect(init.state).toBeDefined()
|
expect(init.state).toBeDefined()
|
||||||
expect(init.state.playerPokemon).toBeDefined()
|
expect(init.state.playerPokemon).toBeDefined()
|
||||||
expect(init.state.opponentPokemon).toBeDefined()
|
expect(init.state.opponentPokemon).toBeDefined()
|
||||||
expect(init.state.finished).toBe(false)
|
expect(init.state.finished).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player pokemon has correct species', () => {
|
test('player pokemon has correct species', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'bulbasaur', 30)
|
const init = createBattle([creature], 'bulbasaur', 30)
|
||||||
expect(init.state.playerPokemon.speciesId).toBe('charmander')
|
expect(init.state.playerPokemon.speciesId).toBe('charmander')
|
||||||
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
|
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player pokemon has moves', () => {
|
test('player pokemon has moves', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
|
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('executeTurn', () => {
|
describe('executeTurn', () => {
|
||||||
test('move action generates events', () => {
|
test('move action generates events', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
const initialEventCount = init.state.events.length
|
const initialEventCount = init.state.events.length
|
||||||
|
|
||||||
const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
|
const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||||
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
|
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('battle eventually ends within 50 turns', () => {
|
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 creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
|
||||||
const init = createBattle([creature], 'squirtle', 5)
|
const init = createBattle([creature], 'squirtle', 5)
|
||||||
|
|
||||||
let state = init.state
|
let state = init.state
|
||||||
for (let i = 0; i < 50 && !state.finished; i++) {
|
for (let i = 0; i < 50 && !state.finished; i++) {
|
||||||
state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(state.finished).toBe(true)
|
expect(state.finished).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('settleBattle', () => {
|
describe('settleBattle', () => {
|
||||||
test('player win increments battlesWon', async () => {
|
test('player win increments battlesWon', async () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const data: BuddyData = {
|
const data: BuddyData = {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: [],
|
boxes: [],
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: '',
|
lastActiveDate: '',
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 5,
|
turns: 5,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
|
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
expect(settlement.data.stats.battlesWon).toBe(1)
|
expect(settlement.data.stats.battlesWon).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player loss returns unchanged data', async () => {
|
test('player loss returns unchanged data', async () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const data: BuddyData = {
|
const data: BuddyData = {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: [],
|
boxes: [],
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: '',
|
lastActiveDate: '',
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'opponent' as const,
|
winner: 'opponent' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
|
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
// Loss early-returns unchanged data
|
// Loss early-returns unchanged data
|
||||||
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
|
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
|
||||||
expect(settlement.learnableMoves).toEqual([])
|
expect(settlement.learnableMoves).toEqual([])
|
||||||
expect(settlement.pendingEvolutions).toEqual([])
|
expect(settlement.pendingEvolutions).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyMoveLearn', () => {
|
describe('applyMoveLearn', () => {
|
||||||
test('replaces move at given index', () => {
|
test('replaces move at given index', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const data: BuddyData = {
|
const data: BuddyData = {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: [],
|
boxes: [],
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: '',
|
lastActiveDate: '',
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
|
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
|
||||||
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
|
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyEvolution', () => {
|
describe('applyEvolution', () => {
|
||||||
test('evolves charmander to charmeleon and increments counter', () => {
|
test('evolves charmander to charmeleon and increments counter', () => {
|
||||||
const creature = makeTestCreature({ speciesId: 'charmander' })
|
const creature = makeTestCreature({ speciesId: 'charmander' })
|
||||||
const data: BuddyData = {
|
const data: BuddyData = {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: [],
|
boxes: [],
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: '',
|
lastActiveDate: '',
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||||
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
|
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
|
||||||
expect(updated.stats.totalEvolutions).toBe(1)
|
expect(updated.stats.totalEvolutions).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('chooseAIMove', () => {
|
describe('chooseAIMove', () => {
|
||||||
test('returns a valid move index', () => {
|
test('returns a valid move index', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
const aiPokemon = init.state.opponentPokemon
|
const aiPokemon = init.state.opponentPokemon
|
||||||
const idx = chooseAIMove(aiPokemon)
|
const idx = chooseAIMove(aiPokemon)
|
||||||
expect(idx).toBeGreaterThanOrEqual(0)
|
expect(idx).toBeGreaterThanOrEqual(0)
|
||||||
expect(idx).toBeLessThan(aiPokemon.moves.length)
|
expect(idx).toBeLessThan(aiPokemon.moves.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 0 when all moves have 0 PP', () => {
|
test('returns 0 when all moves have 0 PP', () => {
|
||||||
const pokemon = {
|
const pokemon = {
|
||||||
...makeTestCreature(),
|
...makeTestCreature(),
|
||||||
moves: [
|
moves: [
|
||||||
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
|
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const idx = chooseAIMove(pokemon as any)
|
const idx = chooseAIMove(pokemon as any)
|
||||||
expect(idx).toBe(0) // Struggle fallback
|
expect(idx).toBe(0) // Struggle fallback
|
||||||
})
|
})
|
||||||
|
|
||||||
test('skips disabled moves', () => {
|
test('skips disabled moves', () => {
|
||||||
const pokemon = {
|
const pokemon = {
|
||||||
...makeTestCreature(),
|
...makeTestCreature(),
|
||||||
moves: [
|
moves: [
|
||||||
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
|
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
|
||||||
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
|
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const idx = chooseAIMove(pokemon as any)
|
const idx = chooseAIMove(pokemon as any)
|
||||||
expect(idx).toBe(1) // Only non-disabled move
|
expect(idx).toBe(1) // Only non-disabled move
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('settleBattle - advanced', () => {
|
describe('settleBattle - advanced', () => {
|
||||||
test('player win awards XP to creature', async () => {
|
test('player win awards XP to creature', async () => {
|
||||||
const creature = makeTestCreature({ level: 5 })
|
const creature = makeTestCreature({ level: 5 })
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
|
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player win awards EVs (capped at 252 per stat)', async () => {
|
test('player win awards EVs (capped at 252 per stat)', async () => {
|
||||||
const creature = makeTestCreature({
|
const creature = makeTestCreature({
|
||||||
level: 5,
|
level: 5,
|
||||||
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
|
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
|
||||||
})
|
})
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
|
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
|
||||||
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
|
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player loss does not increment battlesWon', async () => {
|
test('player loss does not increment battlesWon', async () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'opponent' as const,
|
winner: 'opponent' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
expect(settlement.data.stats.battlesWon).toBe(0)
|
expect(settlement.data.stats.battlesWon).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('createBattle - extended', () => {
|
describe('createBattle - extended', () => {
|
||||||
test('battle state has turn initialized', () => {
|
test('battle state has turn initialized', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
expect(init.state.turn).toBeGreaterThanOrEqual(1)
|
expect(init.state.turn).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player pokemon has correct level', () => {
|
test('player pokemon has correct level', () => {
|
||||||
const creature = makeTestCreature({ level: 25 })
|
const creature = makeTestCreature({ level: 25 })
|
||||||
const init = createBattle([creature], 'bulbasaur', 10)
|
const init = createBattle([creature], 'bulbasaur', 10)
|
||||||
expect(init.state.playerPokemon.level).toBe(25)
|
expect(init.state.playerPokemon.level).toBe(25)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('opponent pokemon has correct level', () => {
|
test('opponent pokemon has correct level', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 15)
|
const init = createBattle([creature], 'squirtle', 15)
|
||||||
expect(init.state.opponentPokemon.level).toBe(15)
|
expect(init.state.opponentPokemon.level).toBe(15)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('battle state has player party', () => {
|
test('battle state has player party', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
expect(init.state.playerParty.length).toBeGreaterThan(0)
|
expect(init.state.playerParty.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('battle state has usable items (empty bag)', () => {
|
test('battle state has usable items (empty bag)', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
expect(init.state.usableItems).toEqual([])
|
expect(init.state.usableItems).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('executeTurn - extended', () => {
|
describe('executeTurn - extended', () => {
|
||||||
test('item action defaults to move 1', () => {
|
test('item action defaults to move 1', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const init = createBattle([creature], 'squirtle', 50)
|
const init = createBattle([creature], 'squirtle', 50)
|
||||||
const state = executeTurn(init, { type: 'item', itemId: 'potion' })
|
const state = executeTurn(init, { type: 'item', itemId: 'potion' })
|
||||||
expect(state).toBeDefined()
|
expect(state).toBeDefined()
|
||||||
expect(state.events.length).toBeGreaterThan(0)
|
expect(state.events.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('battle produces damage or heal events', () => {
|
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 creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
|
||||||
const init = createBattle([creature], 'squirtle', 5)
|
const init = createBattle([creature], 'squirtle', 5)
|
||||||
const state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
const state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||||
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
|
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
|
||||||
expect(hasDamageOrHeal).toBe(true)
|
expect(hasDamageOrHeal).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('settleBattle - EV limits', () => {
|
describe('settleBattle - EV limits', () => {
|
||||||
test('EV total cannot exceed 510', async () => {
|
test('EV total cannot exceed 510', async () => {
|
||||||
const creature = makeTestCreature({
|
const creature = makeTestCreature({
|
||||||
level: 5,
|
level: 5,
|
||||||
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
|
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
})
|
})
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
|
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
|
||||||
expect(totalEV).toBeLessThanOrEqual(510)
|
expect(totalEV).toBeLessThanOrEqual(510)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('non-participant creatures are unchanged', async () => {
|
test('non-participant creatures are unchanged', async () => {
|
||||||
const participant = makeTestCreature({ id: 'p1', level: 5 })
|
const participant = makeTestCreature({ id: 'p1', level: 5 })
|
||||||
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
||||||
const data = makeTestBuddyData([participant, bystander])
|
const data = makeTestBuddyData([participant, bystander])
|
||||||
data.party = [participant.id, bystander.id, null, null, null, null]
|
data.party = [participant.id, bystander.id, null, null, null, null]
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [participant.id],
|
participantIds: [participant.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
|
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
|
||||||
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
|
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('uses all party members as participants when participantIds is empty', async () => {
|
test('uses all party members as participants when participantIds is empty', async () => {
|
||||||
const c1 = makeTestCreature({ id: 'p1', level: 5 })
|
const c1 = makeTestCreature({ id: 'p1', level: 5 })
|
||||||
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
||||||
const data = makeTestBuddyData([c1, c2])
|
const data = makeTestBuddyData([c1, c2])
|
||||||
data.party = [c1.id, c2.id, null, null, null, null]
|
data.party = [c1.id, c2.id, null, null, null, null]
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [] as string[],
|
participantIds: [] as string[],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
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 === 'p1')!.totalXp).toBeGreaterThan(0)
|
||||||
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
|
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('player win increments battlesWon but not battlesLost', async () => {
|
test('player win increments battlesWon but not battlesLost', async () => {
|
||||||
const creature = makeTestCreature({ level: 5 })
|
const creature = makeTestCreature({ level: 5 })
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const result = {
|
const result = {
|
||||||
winner: 'player' as const,
|
winner: 'player' as const,
|
||||||
turns: 3,
|
turns: 3,
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [creature.id],
|
participantIds: [creature.id],
|
||||||
}
|
}
|
||||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||||
expect(settlement.data.stats.battlesWon).toBe(1)
|
expect(settlement.data.stats.battlesWon).toBe(1)
|
||||||
expect(settlement.data.stats.battlesLost).toBe(0)
|
expect(settlement.data.stats.battlesLost).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyMoveLearn - extended', () => {
|
describe('applyMoveLearn - extended', () => {
|
||||||
test('new move has correct PP from Dex', () => {
|
test('new move has correct PP from Dex', () => {
|
||||||
const creature = makeTestCreature()
|
const creature = makeTestCreature()
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
|
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
|
||||||
const move = updated.creatures[0]!.moves[0]!
|
const move = updated.creatures[0]!.moves[0]!
|
||||||
expect(move.id).toBe('fireblast')
|
expect(move.id).toBe('fireblast')
|
||||||
expect(move.pp).toBeGreaterThan(0)
|
expect(move.pp).toBeGreaterThan(0)
|
||||||
expect(move.maxPp).toBeGreaterThan(0)
|
expect(move.maxPp).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('non-target creatures are unchanged', () => {
|
test('non-target creatures are unchanged', () => {
|
||||||
const c1 = makeTestCreature({ id: 't1' })
|
const c1 = makeTestCreature({ id: 't1' })
|
||||||
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
||||||
const data = makeTestBuddyData([c1, c2])
|
const data = makeTestBuddyData([c1, c2])
|
||||||
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
|
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
|
||||||
const unchanged = updated.creatures.find(c => c.id === 't2')!
|
const unchanged = updated.creatures.find(c => c.id === 't2')!
|
||||||
expect(unchanged.moves[0]!.id).toBe('flamethrower')
|
expect(unchanged.moves[0]!.id).toBe('flamethrower')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyEvolution - extended', () => {
|
describe('applyEvolution - extended', () => {
|
||||||
test('friendship increases by 10', () => {
|
test('friendship increases by 10', () => {
|
||||||
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
|
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||||
expect(updated.creatures[0]!.friendship).toBe(80)
|
expect(updated.creatures[0]!.friendship).toBe(80)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('friendship capped at 255', () => {
|
test('friendship capped at 255', () => {
|
||||||
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
|
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
|
||||||
const data = makeTestBuddyData([creature])
|
const data = makeTestBuddyData([creature])
|
||||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||||
expect(updated.creatures[0]!.friendship).toBe(255)
|
expect(updated.creatures[0]!.friendship).toBe(255)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('multiple evolutions increment counter correctly', () => {
|
test('multiple evolutions increment counter correctly', () => {
|
||||||
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
|
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
|
||||||
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
||||||
const data = makeTestBuddyData([c1, c2])
|
const data = makeTestBuddyData([c1, c2])
|
||||||
let updated = applyEvolution(data, 't1', 'charmeleon')
|
let updated = applyEvolution(data, 't1', 'charmeleon')
|
||||||
updated = applyEvolution(updated, 't2', 'ivysaur')
|
updated = applyEvolution(updated, 't2', 'ivysaur')
|
||||||
expect(updated.stats.totalEvolutions).toBe(2)
|
expect(updated.stats.totalEvolutions).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,184 +1,184 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import type { SpeciesId, Creature } from '../types'
|
import type { SpeciesId, Creature } from '../types'
|
||||||
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
|
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
|
|
||||||
describe('generateCreature', () => {
|
describe('generateCreature', () => {
|
||||||
test('creates a creature with correct defaults', async () => {
|
test('creates a creature with correct defaults', async () => {
|
||||||
const c = await generateCreature('bulbasaur', 42)
|
const c = await generateCreature('bulbasaur', 42)
|
||||||
expect(c.speciesId).toBe('bulbasaur')
|
expect(c.speciesId).toBe('bulbasaur')
|
||||||
expect(c.level).toBe(1)
|
expect(c.level).toBe(1)
|
||||||
expect(c.xp).toBe(0)
|
expect(c.xp).toBe(0)
|
||||||
expect(c.totalXp).toBe(0)
|
expect(c.totalXp).toBe(0)
|
||||||
expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
|
expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
|
||||||
expect(c.isShiny).toBeDefined()
|
expect(c.isShiny).toBeDefined()
|
||||||
expect(c.id).toBeTruthy()
|
expect(c.id).toBeTruthy()
|
||||||
expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true)
|
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)
|
expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deterministic IV generation from seed', async () => {
|
test('deterministic IV generation from seed', async () => {
|
||||||
const c1 = await generateCreature('charmander', 12345)
|
const c1 = await generateCreature('charmander', 12345)
|
||||||
const c2 = await generateCreature('charmander', 12345)
|
const c2 = await generateCreature('charmander', 12345)
|
||||||
expect(c1.iv).toEqual(c2.iv)
|
expect(c1.iv).toEqual(c2.iv)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('different seeds produce different IVs', async () => {
|
test('different seeds produce different IVs', async () => {
|
||||||
const c1 = await generateCreature('squirtle', 100)
|
const c1 = await generateCreature('squirtle', 100)
|
||||||
const c2 = await generateCreature('squirtle', 200)
|
const c2 = await generateCreature('squirtle', 200)
|
||||||
expect(c1.iv).not.toEqual(c2.iv)
|
expect(c1.iv).not.toEqual(c2.iv)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('all MVP species can be generated', async () => {
|
test('all MVP species can be generated', async () => {
|
||||||
const species: SpeciesId[] = [
|
const species: SpeciesId[] = [
|
||||||
'bulbasaur', 'ivysaur', 'venusaur',
|
'bulbasaur', 'ivysaur', 'venusaur',
|
||||||
'charmander', 'charmeleon', 'charizard',
|
'charmander', 'charmeleon', 'charizard',
|
||||||
'squirtle', 'wartortle', 'blastoise',
|
'squirtle', 'wartortle', 'blastoise',
|
||||||
'pikachu',
|
'pikachu',
|
||||||
]
|
]
|
||||||
for (const s of species) {
|
for (const s of species) {
|
||||||
const c = await generateCreature(s)
|
const c = await generateCreature(s)
|
||||||
expect(c.speciesId).toBe(s)
|
expect(c.speciesId).toBe(s)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('calculateStats', () => {
|
describe('calculateStats', () => {
|
||||||
test('level 1 stats are reasonable', async () => {
|
test('level 1 stats are reasonable', async () => {
|
||||||
const c = await generateCreature('bulbasaur', 0)
|
const c = await generateCreature('bulbasaur', 0)
|
||||||
const stats = calculateStats(c)
|
const stats = calculateStats(c)
|
||||||
// HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10
|
// 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
|
// With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11
|
||||||
expect(stats.hp).toBeGreaterThanOrEqual(11)
|
expect(stats.hp).toBeGreaterThanOrEqual(11)
|
||||||
expect(stats.hp).toBeLessThanOrEqual(12)
|
expect(stats.hp).toBeLessThanOrEqual(12)
|
||||||
// Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5
|
// Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5
|
||||||
expect(stats.attack).toBeGreaterThanOrEqual(5)
|
expect(stats.attack).toBeGreaterThanOrEqual(5)
|
||||||
expect(stats.attack).toBeLessThanOrEqual(6)
|
expect(stats.attack).toBeLessThanOrEqual(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('stats increase with level', async () => {
|
test('stats increase with level', async () => {
|
||||||
const c1 = await generateCreature('charmander', 0)
|
const c1 = await generateCreature('charmander', 0)
|
||||||
c1.level = 1
|
c1.level = 1
|
||||||
const stats1 = calculateStats(c1)
|
const stats1 = calculateStats(c1)
|
||||||
|
|
||||||
const c50 = { ...c1, level: 50 }
|
const c50 = { ...c1, level: 50 }
|
||||||
const stats50 = calculateStats(c50)
|
const stats50 = calculateStats(c50)
|
||||||
// All stats should be higher at level 50
|
// All stats should be higher at level 50
|
||||||
expect(stats50.hp).toBeGreaterThan(stats1.hp)
|
expect(stats50.hp).toBeGreaterThan(stats1.hp)
|
||||||
expect(stats50.attack).toBeGreaterThan(stats1.attack)
|
expect(stats50.attack).toBeGreaterThan(stats1.attack)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('EVs affect stats', async () => {
|
test('EVs affect stats', async () => {
|
||||||
const c = await generateCreature('pikachu', 0)
|
const c = await generateCreature('pikachu', 0)
|
||||||
const statsNoEV = calculateStats(c)
|
const statsNoEV = calculateStats(c)
|
||||||
|
|
||||||
const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } }
|
const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } }
|
||||||
const statsWithEV = calculateStats(cWithEV)
|
const statsWithEV = calculateStats(cWithEV)
|
||||||
|
|
||||||
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
|
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getCreatureName', () => {
|
describe('getCreatureName', () => {
|
||||||
test('returns species name when no nickname', async () => {
|
test('returns species name when no nickname', async () => {
|
||||||
const c = await generateCreature('pikachu')
|
const c = await generateCreature('pikachu')
|
||||||
c.nickname = undefined
|
c.nickname = undefined
|
||||||
expect(getCreatureName(c)).toBe('Pikachu')
|
expect(getCreatureName(c)).toBe('Pikachu')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns nickname when set', async () => {
|
test('returns nickname when set', async () => {
|
||||||
const c = await generateCreature('pikachu')
|
const c = await generateCreature('pikachu')
|
||||||
c.nickname = 'Sparky'
|
c.nickname = 'Sparky'
|
||||||
expect(getCreatureName(c)).toBe('Sparky')
|
expect(getCreatureName(c)).toBe('Sparky')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getTotalEV', () => {
|
describe('getTotalEV', () => {
|
||||||
test('returns 0 for new creature', async () => {
|
test('returns 0 for new creature', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
expect(getTotalEV(c)).toBe(0)
|
expect(getTotalEV(c)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sums all EV values', async () => {
|
test('sums all EV values', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
|
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
|
||||||
expect(getTotalEV(c)).toBe(210)
|
expect(getTotalEV(c)).toBe(210)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('recalculateLevel', () => {
|
describe('recalculateLevel', () => {
|
||||||
test('returns same creature if level unchanged', async () => {
|
test('returns same creature if level unchanged', async () => {
|
||||||
const c = await generateCreature('bulbasaur', 42)
|
const c = await generateCreature('bulbasaur', 42)
|
||||||
const result = recalculateLevel(c)
|
const result = recalculateLevel(c)
|
||||||
expect(result.level).toBe(c.level)
|
expect(result.level).toBe(c.level)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('updates level based on totalXp', async () => {
|
test('updates level based on totalXp', async () => {
|
||||||
const c = await generateCreature('charmander', 42)
|
const c = await generateCreature('charmander', 42)
|
||||||
c.totalXp = 8000
|
c.totalXp = 8000
|
||||||
const result = recalculateLevel(c)
|
const result = recalculateLevel(c)
|
||||||
expect(result.level).toBeGreaterThan(1)
|
expect(result.level).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getActiveCreature', () => {
|
describe('getActiveCreature', () => {
|
||||||
test('returns null when party is empty', async () => {
|
test('returns null when party is empty', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
|
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns creature from party[0]', async () => {
|
test('returns creature from party[0]', async () => {
|
||||||
const c = await generateCreature('pikachu')
|
const c = await generateCreature('pikachu')
|
||||||
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
|
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.id).toBe(c.id)
|
expect(result!.id).toBe(c.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns creature from activeCreatureId (legacy)', async () => {
|
test('returns creature from activeCreatureId (legacy)', async () => {
|
||||||
const c = await generateCreature('squirtle')
|
const c = await generateCreature('squirtle')
|
||||||
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
|
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.id).toBe(c.id)
|
expect(result!.id).toBe(c.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('prefers party[0] over activeCreatureId', async () => {
|
test('prefers party[0] over activeCreatureId', async () => {
|
||||||
const c1 = await generateCreature('bulbasaur')
|
const c1 = await generateCreature('bulbasaur')
|
||||||
const c2 = await generateCreature('charmander')
|
const c2 = await generateCreature('charmander')
|
||||||
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
|
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
|
||||||
expect(result!.id).toBe(c1.id)
|
expect(result!.id).toBe(c1.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns null when creature ID not found', () => {
|
test('returns null when creature ID not found', () => {
|
||||||
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
|
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('calculateStats - nature effects', () => {
|
describe('calculateStats - nature effects', () => {
|
||||||
test('adamant nature boosts attack and lowers spAtk', async () => {
|
test('adamant nature boosts attack and lowers spAtk', async () => {
|
||||||
const c = await generateCreature('charmander', 42)
|
const c = await generateCreature('charmander', 42)
|
||||||
c.level = 50
|
c.level = 50
|
||||||
c.nature = 'adamant'
|
c.nature = 'adamant'
|
||||||
const adamantStats = calculateStats(c)
|
const adamantStats = calculateStats(c)
|
||||||
|
|
||||||
c.nature = 'hardy'
|
c.nature = 'hardy'
|
||||||
const hardyStats = calculateStats(c)
|
const hardyStats = calculateStats(c)
|
||||||
|
|
||||||
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
|
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
|
||||||
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
|
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('timid nature boosts speed and lowers attack', async () => {
|
test('timid nature boosts speed and lowers attack', async () => {
|
||||||
const c = await generateCreature('pikachu', 42)
|
const c = await generateCreature('pikachu', 42)
|
||||||
c.level = 50
|
c.level = 50
|
||||||
c.nature = 'timid'
|
c.nature = 'timid'
|
||||||
const timidStats = calculateStats(c)
|
const timidStats = calculateStats(c)
|
||||||
|
|
||||||
c.nature = 'hardy'
|
c.nature = 'hardy'
|
||||||
const hardyStats = calculateStats(c)
|
const hardyStats = calculateStats(c)
|
||||||
|
|
||||||
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
|
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
|
||||||
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
|
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,79 +1,79 @@
|
|||||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
import { generateCreature } from '../core/creature'
|
import { generateCreature } from '../core/creature'
|
||||||
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
|
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(() => {
|
beforeEach(() => {
|
||||||
resetEVCooldowns()
|
resetEVCooldowns()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('awardEV', () => {
|
describe('awardEV', () => {
|
||||||
test('mapped tool awards correct EV', async () => {
|
test('mapped tool awards correct EV', async () => {
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
// Clear cooldown by using old timestamp
|
// Clear cooldown by using old timestamp
|
||||||
c = awardEV(c, 'Bash', 0)
|
c = awardEV(c, 'Bash', 0)
|
||||||
expect(c.ev.attack).toBeGreaterThan(0)
|
expect(c.ev.attack).toBeGreaterThan(0)
|
||||||
expect(c.ev.speed).toBeGreaterThan(0)
|
expect(c.ev.speed).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unmapped tool awards random EV', async () => {
|
test('unmapped tool awards random EV', async () => {
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
c = awardEV(c, 'UnknownTool', 0)
|
c = awardEV(c, 'UnknownTool', 0)
|
||||||
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||||
expect(totalEV).toBeGreaterThan(0)
|
expect(totalEV).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cooldown prevents repeated awards', async () => {
|
test('cooldown prevents repeated awards', async () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
c = awardEV(c, 'Bash', now)
|
c = awardEV(c, 'Bash', now)
|
||||||
const ev1 = { ...c.ev }
|
const ev1 = { ...c.ev }
|
||||||
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
|
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
|
||||||
expect(c.ev).toEqual(ev1) // No change
|
expect(c.ev).toEqual(ev1) // No change
|
||||||
})
|
})
|
||||||
|
|
||||||
test('respects per-stat EV cap', async () => {
|
test('respects per-stat EV cap', async () => {
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
// Bash gives attack:2 + speed:1
|
// Bash gives attack:2 + speed:1
|
||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
|
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
|
||||||
}
|
}
|
||||||
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
|
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('respects total EV cap', async () => {
|
test('respects total EV cap', async () => {
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
|
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
|
||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000)
|
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)
|
const total = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||||
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
|
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('awardTurnEV', () => {
|
describe('awardTurnEV', () => {
|
||||||
test('awards EV for multiple tools', async () => {
|
test('awards EV for multiple tools', async () => {
|
||||||
let c = await generateCreature('bulbasaur')
|
let c = await generateCreature('bulbasaur')
|
||||||
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
|
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
|
||||||
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||||
expect(totalEV).toBeGreaterThan(0)
|
expect(totalEV).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getEVSummary', () => {
|
describe('getEVSummary', () => {
|
||||||
test('returns "None" for new creature', async () => {
|
test('returns "None" for new creature', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
expect(getEVSummary(c)).toBe('None')
|
expect(getEVSummary(c)).toBe('None')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows stat breakdown', async () => {
|
test('shows stat breakdown', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
|
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
|
||||||
const summary = getEVSummary(c)
|
const summary = getEVSummary(c)
|
||||||
expect(summary).toContain('ATK+5')
|
expect(summary).toContain('ATK+5')
|
||||||
expect(summary).toContain('SPA+3')
|
expect(summary).toContain('SPA+3')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,157 +4,157 @@ import type { BuddyData } from '../types'
|
|||||||
import { generateCreature } from '../core/creature'
|
import { generateCreature } from '../core/creature'
|
||||||
|
|
||||||
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
|
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
|
||||||
const creature = generateCreature('bulbasaur')
|
const creature = generateCreature('bulbasaur')
|
||||||
// Sync mock — generateCreature is async but for test setup we use the resolved structure
|
// Sync mock — generateCreature is async but for test setup we use the resolved structure
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: ['test-creature-id', null, null, null, null, null],
|
party: ['test-creature-id', null, null, null, null, null],
|
||||||
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
|
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
|
||||||
creatures: [{
|
creatures: [{
|
||||||
id: 'test-creature-id',
|
id: 'test-creature-id',
|
||||||
speciesId: 'bulbasaur',
|
speciesId: 'bulbasaur',
|
||||||
gender: 'male' as const,
|
gender: 'male' as const,
|
||||||
level: 5,
|
level: 5,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
totalXp: 100,
|
totalXp: 100,
|
||||||
nature: 'hardy',
|
nature: 'hardy',
|
||||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
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 },
|
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
|
||||||
moves: [
|
moves: [
|
||||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
],
|
],
|
||||||
ability: 'overgrow',
|
ability: 'overgrow',
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
friendship: 70,
|
friendship: 70,
|
||||||
isShiny: false,
|
isShiny: false,
|
||||||
hatchedAt: Date.now(),
|
hatchedAt: Date.now(),
|
||||||
pokeball: 'pokeball',
|
pokeball: 'pokeball',
|
||||||
}],
|
}],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
|
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 50,
|
totalTurns: 50,
|
||||||
consecutiveDays: 7,
|
consecutiveDays: 7,
|
||||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('checkEggEligibility', () => {
|
describe('checkEggEligibility', () => {
|
||||||
test('eligible when conditions met', () => {
|
test('eligible when conditions met', () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
expect(checkEggEligibility(data)).toBe(true)
|
expect(checkEggEligibility(data)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('not eligible with existing egg', () => {
|
test('not eligible with existing egg', () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
|
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
|
||||||
expect(checkEggEligibility(data)).toBe(false)
|
expect(checkEggEligibility(data)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('not eligible with low consecutive days', () => {
|
test('not eligible with low consecutive days', () => {
|
||||||
const data = makeBuddyData({ consecutiveDays: 2 })
|
const data = makeBuddyData({ consecutiveDays: 2 })
|
||||||
expect(checkEggEligibility(data)).toBe(false)
|
expect(checkEggEligibility(data)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('not eligible when turns not multiple of 50', () => {
|
test('not eligible when turns not multiple of 50', () => {
|
||||||
const data = makeBuddyData({ totalTurns: 51 })
|
const data = makeBuddyData({ totalTurns: 51 })
|
||||||
expect(checkEggEligibility(data)).toBe(false)
|
expect(checkEggEligibility(data)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('generateEgg', () => {
|
describe('generateEgg', () => {
|
||||||
test('prefers uncollected species', () => {
|
test('prefers uncollected species', () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
// Already have bulbasaur, so egg should prefer others
|
// Already have bulbasaur, so egg should prefer others
|
||||||
const egg = generateEgg(data)
|
const egg = generateEgg(data)
|
||||||
expect(egg.speciesId).not.toBe('bulbasaur')
|
expect(egg.speciesId).not.toBe('bulbasaur')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('egg has valid steps', () => {
|
test('egg has valid steps', () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = generateEgg(data)
|
const egg = generateEgg(data)
|
||||||
expect(egg.stepsRemaining).toBeGreaterThan(0)
|
expect(egg.stepsRemaining).toBeGreaterThan(0)
|
||||||
expect(egg.totalSteps).toBe(egg.stepsRemaining)
|
expect(egg.totalSteps).toBe(egg.stepsRemaining)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('advanceEggSteps', () => {
|
describe('advanceEggSteps', () => {
|
||||||
test('reduces steps remaining', () => {
|
test('reduces steps remaining', () => {
|
||||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
|
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||||
const advanced = advanceEggSteps(egg, 30)
|
const advanced = advanceEggSteps(egg, 30)
|
||||||
expect(advanced.stepsRemaining).toBe(70)
|
expect(advanced.stepsRemaining).toBe(70)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('steps do not go below 0', () => {
|
test('steps do not go below 0', () => {
|
||||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
|
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||||
const advanced = advanceEggSteps(egg, 50)
|
const advanced = advanceEggSteps(egg, 50)
|
||||||
expect(advanced.stepsRemaining).toBe(0)
|
expect(advanced.stepsRemaining).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isEggReadyToHatch', () => {
|
describe('isEggReadyToHatch', () => {
|
||||||
test('ready when steps = 0', () => {
|
test('ready when steps = 0', () => {
|
||||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
|
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||||
expect(isEggReadyToHatch(egg)).toBe(true)
|
expect(isEggReadyToHatch(egg)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('not ready when steps > 0', () => {
|
test('not ready when steps > 0', () => {
|
||||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
|
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||||
expect(isEggReadyToHatch(egg)).toBe(false)
|
expect(isEggReadyToHatch(egg)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('hatchEgg', () => {
|
describe('hatchEgg', () => {
|
||||||
test('creates a creature and removes egg', async () => {
|
test('creates a creature and removes egg', async () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
||||||
const result = await hatchEgg(data, egg)
|
const result = await hatchEgg(data, egg)
|
||||||
|
|
||||||
expect(result.creature.speciesId).toBe('charmander')
|
expect(result.creature.speciesId).toBe('charmander')
|
||||||
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
|
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
|
||||||
expect(result.buddyData.eggs.length).toBe(0)
|
expect(result.buddyData.eggs.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('adds creature to party when slot available', async () => {
|
test('adds creature to party when slot available', async () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
|
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
|
||||||
const result = await hatchEgg(data, egg)
|
const result = await hatchEgg(data, egg)
|
||||||
const newCreature = result.creature
|
const newCreature = result.creature
|
||||||
const inParty = result.buddyData.party.includes(newCreature.id)
|
const inParty = result.buddyData.party.includes(newCreature.id)
|
||||||
expect(inParty).toBe(true)
|
expect(inParty).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('increments totalEggsObtained', async () => {
|
test('increments totalEggsObtained', async () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
|
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
|
||||||
const result = await hatchEgg(data, egg)
|
const result = await hatchEgg(data, egg)
|
||||||
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
|
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('updates dex entry with new species', async () => {
|
test('updates dex entry with new species', async () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
||||||
const result = await hatchEgg(data, egg)
|
const result = await hatchEgg(data, egg)
|
||||||
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
|
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
|
||||||
expect(entry).toBeDefined()
|
expect(entry).toBeDefined()
|
||||||
expect(entry!.caughtCount).toBe(1)
|
expect(entry!.caughtCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('increments caughtCount for existing dex entry', async () => {
|
test('increments caughtCount for existing dex entry', async () => {
|
||||||
const data = makeBuddyData()
|
const data = makeBuddyData()
|
||||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
|
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
|
||||||
const result = await hatchEgg(data, egg)
|
const result = await hatchEgg(data, egg)
|
||||||
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
|
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
|
||||||
expect(entry!.caughtCount).toBe(2)
|
expect(entry!.caughtCount).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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', () => {
|
describe('getEVForTool', () => {
|
||||||
test('returns EV mapping for known tools', () => {
|
test('returns EV mapping for known tools', () => {
|
||||||
const bashEV = getEVForTool('Bash')
|
const bashEV = getEVForTool('Bash')
|
||||||
expect(bashEV).toBeDefined()
|
expect(bashEV).toBeDefined()
|
||||||
expect(bashEV!.attack).toBe(2)
|
expect(bashEV!.attack).toBe(2)
|
||||||
expect(bashEV!.speed).toBe(1)
|
expect(bashEV!.speed).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns undefined for unknown tools', () => {
|
test('returns undefined for unknown tools', () => {
|
||||||
expect(getEVForTool('UnknownTool')).toBeUndefined()
|
expect(getEVForTool('UnknownTool')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('all mapped tools have correct stat shape', () => {
|
test('all mapped tools have correct stat shape', () => {
|
||||||
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
|
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
|
||||||
expect(ev.hp).toBeDefined()
|
expect(ev.hp).toBeDefined()
|
||||||
expect(ev.attack).toBeDefined()
|
expect(ev.attack).toBeDefined()
|
||||||
expect(ev.defense).toBeDefined()
|
expect(ev.defense).toBeDefined()
|
||||||
expect(ev.spAtk).toBeDefined()
|
expect(ev.spAtk).toBeDefined()
|
||||||
expect(ev.spDef).toBeDefined()
|
expect(ev.spDef).toBeDefined()
|
||||||
expect(ev.speed).toBeDefined()
|
expect(ev.speed).toBeDefined()
|
||||||
// EVs should sum to > 0
|
// EVs should sum to > 0
|
||||||
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
|
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
|
||||||
expect(total).toBeGreaterThan(0)
|
expect(total).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('EV constants', () => {
|
describe('EV constants', () => {
|
||||||
test('MAX_EV_PER_STAT is 252', () => {
|
test('MAX_EV_PER_STAT is 252', () => {
|
||||||
expect(MAX_EV_PER_STAT).toBe(252)
|
expect(MAX_EV_PER_STAT).toBe(252)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('MAX_EV_TOTAL is 510', () => {
|
test('MAX_EV_TOTAL is 510', () => {
|
||||||
expect(MAX_EV_TOTAL).toBe(510)
|
expect(MAX_EV_TOTAL).toBe(510)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,122 +3,122 @@ import type { Creature } from '../types'
|
|||||||
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
|
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
|
||||||
|
|
||||||
function makeEvolutionCreature(overrides: Partial<Creature> = {}): Creature {
|
function makeEvolutionCreature(overrides: Partial<Creature> = {}): Creature {
|
||||||
return {
|
return {
|
||||||
id: 'test-evo',
|
id: 'test-evo',
|
||||||
speciesId: overrides.speciesId ?? 'bulbasaur',
|
speciesId: overrides.speciesId ?? 'bulbasaur',
|
||||||
gender: 'male',
|
gender: 'male',
|
||||||
level: overrides.level ?? 50,
|
level: overrides.level ?? 50,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
totalXp: 0,
|
totalXp: 0,
|
||||||
nature: 'hardy',
|
nature: 'hardy',
|
||||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
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 },
|
iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
|
||||||
moves: [
|
moves: [
|
||||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||||
{ id: 'growl', pp: 40, maxPp: 40 },
|
{ id: 'growl', pp: 40, maxPp: 40 },
|
||||||
{ id: 'vinewhip', pp: 15, maxPp: 15 },
|
{ id: 'vinewhip', pp: 15, maxPp: 15 },
|
||||||
{ id: 'razorleaf', pp: 10, maxPp: 10 },
|
{ id: 'razorleaf', pp: 10, maxPp: 10 },
|
||||||
],
|
],
|
||||||
ability: 'overgrow',
|
ability: 'overgrow',
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
friendship: overrides.friendship ?? 70,
|
friendship: overrides.friendship ?? 70,
|
||||||
isShiny: false,
|
isShiny: false,
|
||||||
hatchedAt: Date.now(),
|
hatchedAt: Date.now(),
|
||||||
pokeball: 'pokeball',
|
pokeball: 'pokeball',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('checkEvolution', () => {
|
describe('checkEvolution', () => {
|
||||||
test('bulbasaur at level 15 cannot evolve', () => {
|
test('bulbasaur at level 15 cannot evolve', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
|
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
|
||||||
expect(checkEvolution(creature)).toBeNull()
|
expect(checkEvolution(creature)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bulbasaur at level 16 can evolve into ivysaur', () => {
|
test('bulbasaur at level 16 can evolve into ivysaur', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
|
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.from).toBe('bulbasaur')
|
expect(result!.from).toBe('bulbasaur')
|
||||||
expect(result!.to).toBe('ivysaur')
|
expect(result!.to).toBe('ivysaur')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('charmander at level 16 evolves into charmeleon', () => {
|
test('charmander at level 16 evolves into charmeleon', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
|
const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result!.to).toBe('charmeleon')
|
expect(result!.to).toBe('charmeleon')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('charmeleon at level 36 evolves into charizard', () => {
|
test('charmeleon at level 36 evolves into charizard', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
|
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result!.to).toBe('charizard')
|
expect(result!.to).toBe('charizard')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('squirtle at level 16 evolves into wartortle', () => {
|
test('squirtle at level 16 evolves into wartortle', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
|
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result!.to).toBe('wartortle')
|
expect(result!.to).toBe('wartortle')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('wartortle at level 36 evolves into blastoise', () => {
|
test('wartortle at level 36 evolves into blastoise', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
|
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result!.to).toBe('blastoise')
|
expect(result!.to).toBe('blastoise')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('venusaur cannot evolve further', () => {
|
test('venusaur cannot evolve further', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
|
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
|
||||||
expect(checkEvolution(creature)).toBeNull()
|
expect(checkEvolution(creature)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pikachu cannot evolve in MVP', () => {
|
test('pikachu cannot evolve in MVP', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
|
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
|
||||||
expect(checkEvolution(creature)).toBeNull()
|
expect(checkEvolution(creature)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
|
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
|
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
|
||||||
const result = checkEvolution(creature)
|
const result = checkEvolution(creature)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.to).toBe('ivysaur')
|
expect(result!.to).toBe('ivysaur')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('evolve', () => {
|
describe('evolve', () => {
|
||||||
test('changes species and boosts friendship', () => {
|
test('changes species and boosts friendship', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
|
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
|
||||||
const evolved = evolve(creature, 'ivysaur')
|
const evolved = evolve(creature, 'ivysaur')
|
||||||
expect(evolved.speciesId).toBe('ivysaur')
|
expect(evolved.speciesId).toBe('ivysaur')
|
||||||
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
|
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
|
||||||
})
|
})
|
||||||
|
|
||||||
test('friendship is capped at 255', () => {
|
test('friendship is capped at 255', () => {
|
||||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
|
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
|
||||||
const evolved = evolve(creature, 'ivysaur')
|
const evolved = evolve(creature, 'ivysaur')
|
||||||
expect(evolved.friendship).toBe(255)
|
expect(evolved.friendship).toBe(255)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('canEvolveFurther', () => {
|
describe('canEvolveFurther', () => {
|
||||||
test('starter species can evolve', () => {
|
test('starter species can evolve', () => {
|
||||||
expect(canEvolveFurther('bulbasaur')).toBe(true)
|
expect(canEvolveFurther('bulbasaur')).toBe(true)
|
||||||
expect(canEvolveFurther('charmander')).toBe(true)
|
expect(canEvolveFurther('charmander')).toBe(true)
|
||||||
expect(canEvolveFurther('squirtle')).toBe(true)
|
expect(canEvolveFurther('squirtle')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('middle evolution can evolve', () => {
|
test('middle evolution can evolve', () => {
|
||||||
expect(canEvolveFurther('ivysaur')).toBe(true)
|
expect(canEvolveFurther('ivysaur')).toBe(true)
|
||||||
expect(canEvolveFurther('charmeleon')).toBe(true)
|
expect(canEvolveFurther('charmeleon')).toBe(true)
|
||||||
expect(canEvolveFurther('wartortle')).toBe(true)
|
expect(canEvolveFurther('wartortle')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('final evolution cannot evolve', () => {
|
test('final evolution cannot evolve', () => {
|
||||||
expect(canEvolveFurther('venusaur')).toBe(false)
|
expect(canEvolveFurther('venusaur')).toBe(false)
|
||||||
expect(canEvolveFurther('charizard')).toBe(false)
|
expect(canEvolveFurther('charizard')).toBe(false)
|
||||||
expect(canEvolveFurther('blastoise')).toBe(false)
|
expect(canEvolveFurther('blastoise')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pikachu cannot evolve in MVP', () => {
|
test('pikachu cannot evolve in MVP', () => {
|
||||||
expect(canEvolveFurther('pikachu')).toBe(false)
|
expect(canEvolveFurther('pikachu')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,153 +1,153 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { generateCreature } from '../core/creature'
|
import { generateCreature } from '../core/creature'
|
||||||
import { awardXP, getXpProgress } from '../core/experience'
|
import { awardXP, getXpProgress } from '../core/experience'
|
||||||
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
|
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
|
||||||
|
|
||||||
describe('xpForLevel', () => {
|
describe('xpForLevel', () => {
|
||||||
test('level 1 requires 0 XP', () => {
|
test('level 1 requires 0 XP', () => {
|
||||||
expect(xpForLevel(1, 'medium-slow')).toBe(0)
|
expect(xpForLevel(1, 'medium-slow')).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('medium-fast: level N requires N^3 XP', () => {
|
test('medium-fast: level N requires N^3 XP', () => {
|
||||||
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
||||||
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
|
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fast: level N requires floor(N^3 * 4/5)', () => {
|
test('fast: level N requires floor(N^3 * 4/5)', () => {
|
||||||
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
|
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
|
||||||
})
|
})
|
||||||
|
|
||||||
test('slow: level N requires floor(N^3 * 5/4)', () => {
|
test('slow: level N requires floor(N^3 * 5/4)', () => {
|
||||||
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
|
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('higher levels require more XP', () => {
|
test('higher levels require more XP', () => {
|
||||||
for (let i = 2; i < 99; i++) {
|
for (let i = 2; i < 99; i++) {
|
||||||
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
|
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('levelFromXp', () => {
|
describe('levelFromXp', () => {
|
||||||
test('0 XP = level 1', () => {
|
test('0 XP = level 1', () => {
|
||||||
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('roundtrip: level → XP → level', () => {
|
test('roundtrip: level → XP → level', () => {
|
||||||
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
|
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
|
||||||
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
|
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
|
||||||
const xp = xpForLevel(level, growth)
|
const xp = xpForLevel(level, growth)
|
||||||
expect(levelFromXp(xp, growth)).toBe(level)
|
expect(levelFromXp(xp, growth)).toBe(level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('XP slightly below threshold stays at lower level', () => {
|
test('XP slightly below threshold stays at lower level', () => {
|
||||||
const xp20 = xpForLevel(20, 'medium-fast')
|
const xp20 = xpForLevel(20, 'medium-fast')
|
||||||
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
|
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('awardXP', () => {
|
describe('awardXP', () => {
|
||||||
test('awards XP and returns updated creature', async () => {
|
test('awards XP and returns updated creature', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
const result = awardXP(c, 10)
|
const result = awardXP(c, 10)
|
||||||
expect(result.creature.totalXp).toBe(10)
|
expect(result.creature.totalXp).toBe(10)
|
||||||
expect(result.leveledUp).toBeDefined()
|
expect(result.leveledUp).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('large XP can cause level up', async () => {
|
test('large XP can cause level up', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
// Award enough XP for several levels
|
// Award enough XP for several levels
|
||||||
const result = awardXP(c, 10000)
|
const result = awardXP(c, 10000)
|
||||||
expect(result.creature.level).toBeGreaterThan(1)
|
expect(result.creature.level).toBeGreaterThan(1)
|
||||||
expect(result.leveledUp).toBe(true)
|
expect(result.leveledUp).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('level capped at 100', async () => {
|
test('level capped at 100', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
c.level = 100
|
c.level = 100
|
||||||
c.totalXp = 1000000
|
c.totalXp = 1000000
|
||||||
const result = awardXP(c, 999999)
|
const result = awardXP(c, 999999)
|
||||||
expect(result.creature.level).toBe(100)
|
expect(result.creature.level).toBe(100)
|
||||||
expect(result.leveledUp).toBe(false)
|
expect(result.leveledUp).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getXpProgress', () => {
|
describe('getXpProgress', () => {
|
||||||
test('new creature has 0 XP progress', async () => {
|
test('new creature has 0 XP progress', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
const progress = getXpProgress(c)
|
const progress = getXpProgress(c)
|
||||||
expect(progress.current).toBe(0)
|
expect(progress.current).toBe(0)
|
||||||
expect(progress.percentage).toBe(0)
|
expect(progress.percentage).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('level 100 creature has 100% progress', async () => {
|
test('level 100 creature has 100% progress', async () => {
|
||||||
const c = await generateCreature('charmander')
|
const c = await generateCreature('charmander')
|
||||||
c.level = 100
|
c.level = 100
|
||||||
c.totalXp = 1000000
|
c.totalXp = 1000000
|
||||||
const progress = getXpProgress(c)
|
const progress = getXpProgress(c)
|
||||||
expect(progress.percentage).toBe(100)
|
expect(progress.percentage).toBe(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('needed is positive for sub-100 creatures', async () => {
|
test('needed is positive for sub-100 creatures', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
c.level = 5
|
c.level = 5
|
||||||
c.totalXp = xpForLevel(5, 'medium-slow')
|
c.totalXp = xpForLevel(5, 'medium-slow')
|
||||||
const progress = getXpProgress(c)
|
const progress = getXpProgress(c)
|
||||||
expect(progress.needed).toBeGreaterThan(0)
|
expect(progress.needed).toBeGreaterThan(0)
|
||||||
expect(progress.current).toBe(0)
|
expect(progress.current).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('xpToNextLevel', () => {
|
describe('xpToNextLevel', () => {
|
||||||
test('returns XP needed from current to next level', () => {
|
test('returns XP needed from current to next level', () => {
|
||||||
const xp10 = xpForLevel(10, 'medium-fast')
|
const xp10 = xpForLevel(10, 'medium-fast')
|
||||||
const xp11 = xpForLevel(11, 'medium-fast')
|
const xp11 = xpForLevel(11, 'medium-fast')
|
||||||
const needed = xpToNextLevel(10, xp10, 'medium-fast')
|
const needed = xpToNextLevel(10, xp10, 'medium-fast')
|
||||||
expect(needed).toBe(xp11 - xp10)
|
expect(needed).toBe(xp11 - xp10)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 0 at level 100', () => {
|
test('returns 0 at level 100', () => {
|
||||||
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
|
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('accounts for partial XP already earned', () => {
|
test('accounts for partial XP already earned', () => {
|
||||||
const xp10 = xpForLevel(10, 'medium-fast')
|
const xp10 = xpForLevel(10, 'medium-fast')
|
||||||
const xp11 = xpForLevel(11, 'medium-fast')
|
const xp11 = xpForLevel(11, 'medium-fast')
|
||||||
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
|
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
|
||||||
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
|
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
|
||||||
expect(needed).toBe(xp11 - halfWay)
|
expect(needed).toBe(xp11 - halfWay)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('awardXP - extended', () => {
|
describe('awardXP - extended', () => {
|
||||||
test('awarding 0 XP returns unchanged creature', async () => {
|
test('awarding 0 XP returns unchanged creature', async () => {
|
||||||
const c = await generateCreature('bulbasaur')
|
const c = await generateCreature('bulbasaur')
|
||||||
const result = awardXP(c, 0)
|
const result = awardXP(c, 0)
|
||||||
expect(result.creature.totalXp).toBe(c.totalXp)
|
expect(result.creature.totalXp).toBe(c.totalXp)
|
||||||
expect(result.leveledUp).toBe(false)
|
expect(result.leveledUp).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('XP progress is correctly calculated after award', async () => {
|
test('XP progress is correctly calculated after award', async () => {
|
||||||
const c = await generateCreature('squirtle')
|
const c = await generateCreature('squirtle')
|
||||||
const xpNeeded = xpForLevel(2, 'medium-slow')
|
const xpNeeded = xpForLevel(2, 'medium-slow')
|
||||||
const result = awardXP(c, Math.floor(xpNeeded / 2))
|
const result = awardXP(c, Math.floor(xpNeeded / 2))
|
||||||
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
|
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('multiple small XP awards equal one large award', async () => {
|
test('multiple small XP awards equal one large award', async () => {
|
||||||
const c1 = await generateCreature('bulbasaur', 42)
|
const c1 = await generateCreature('bulbasaur', 42)
|
||||||
const c2 = await generateCreature('bulbasaur', 42)
|
const c2 = await generateCreature('bulbasaur', 42)
|
||||||
c2.totalXp = c1.totalXp
|
c2.totalXp = c1.totalXp
|
||||||
|
|
||||||
let current = c1
|
let current = c1
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
current = awardXP(current, 100).creature
|
current = awardXP(current, 100).creature
|
||||||
}
|
}
|
||||||
const bigResult = awardXP(c2, 1000)
|
const bigResult = awardXP(c2, 1000)
|
||||||
|
|
||||||
expect(current.totalXp).toBe(bigResult.creature.totalXp)
|
expect(current.totalXp).toBe(bigResult.creature.totalXp)
|
||||||
expect(current.level).toBe(bigResult.creature.level)
|
expect(current.level).toBe(bigResult.creature.level)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,26 +3,26 @@ import { getFallbackSprite } from '../sprites/fallback'
|
|||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
|
|
||||||
describe('getFallbackSprite', () => {
|
describe('getFallbackSprite', () => {
|
||||||
test('returns 5 lines for every species', () => {
|
test('returns 5 lines for every species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
const sprite = getFallbackSprite(id)
|
const sprite = getFallbackSprite(id)
|
||||||
expect(sprite.length).toBe(5)
|
expect(sprite.length).toBe(5)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns pikachu fallback for unknown species', () => {
|
test('returns pikachu fallback for unknown species', () => {
|
||||||
const sprite = getFallbackSprite('unknown' as any)
|
const sprite = getFallbackSprite('unknown' as any)
|
||||||
expect(sprite).toEqual(getFallbackSprite('pikachu'))
|
expect(sprite).toEqual(getFallbackSprite('pikachu'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('each line has consistent width', () => {
|
test('each line has consistent width', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
const sprite = getFallbackSprite(id)
|
const sprite = getFallbackSprite(id)
|
||||||
const widths = sprite.map(line => line.length)
|
const widths = sprite.map(line => line.length)
|
||||||
// All lines should be roughly the same width
|
// All lines should be roughly the same width
|
||||||
const maxWidth = Math.max(...widths)
|
const maxWidth = Math.max(...widths)
|
||||||
const minWidth = Math.min(...widths)
|
const minWidth = Math.min(...widths)
|
||||||
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { determineGender, getGenderSymbol } from '../core/gender'
|
import { determineGender, getGenderSymbol } from '../core/gender'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
|
|
||||||
describe('determineGender', () => {
|
describe('determineGender', () => {
|
||||||
test('genderless species', () => {
|
test('genderless species', () => {
|
||||||
// Pikachu has genderRate 4 (50% female)
|
// Pikachu has genderRate 4 (50% female)
|
||||||
// Venusaur has genderRate 1 (12.5% female)
|
// Venusaur has genderRate 1 (12.5% female)
|
||||||
// For testing genderless, we'd need a species with genderRate -1
|
// For testing genderless, we'd need a species with genderRate -1
|
||||||
// None in MVP are genderless, so test the basic logic
|
// None in MVP are genderless, so test the basic logic
|
||||||
const pikachu = getSpeciesData('pikachu')
|
const pikachu = getSpeciesData('pikachu')
|
||||||
expect(pikachu.genderRate).toBe(4)
|
expect(pikachu.genderRate).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pikachu 50% female ratio', () => {
|
test('pikachu 50% female ratio', () => {
|
||||||
const pikachu = getSpeciesData('pikachu')
|
const pikachu = getSpeciesData('pikachu')
|
||||||
let males = 0
|
let males = 0
|
||||||
let females = 0
|
let females = 0
|
||||||
for (let seed = 0; seed < 1000; seed++) {
|
for (let seed = 0; seed < 1000; seed++) {
|
||||||
const g = determineGender(pikachu, seed)
|
const g = determineGender(pikachu, seed)
|
||||||
if (g === 'male') males++
|
if (g === 'male') males++
|
||||||
else females++
|
else females++
|
||||||
}
|
}
|
||||||
// Should be roughly 50/50 with some tolerance
|
// Should be roughly 50/50 with some tolerance
|
||||||
expect(females).toBeGreaterThan(300)
|
expect(females).toBeGreaterThan(300)
|
||||||
expect(males).toBeGreaterThan(300)
|
expect(males).toBeGreaterThan(300)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('starters are ~12.5% female', () => {
|
test('starters are ~12.5% female', () => {
|
||||||
const bulbasaur = getSpeciesData('bulbasaur')
|
const bulbasaur = getSpeciesData('bulbasaur')
|
||||||
let females = 0
|
let females = 0
|
||||||
for (let seed = 0; seed < 1000; seed++) {
|
for (let seed = 0; seed < 1000; seed++) {
|
||||||
if (determineGender(bulbasaur, seed) === 'female') females++
|
if (determineGender(bulbasaur, seed) === 'female') females++
|
||||||
}
|
}
|
||||||
// ~12.5% female = ~125 out of 1000
|
// ~12.5% female = ~125 out of 1000
|
||||||
expect(females).toBeGreaterThan(50)
|
expect(females).toBeGreaterThan(50)
|
||||||
expect(females).toBeLessThan(250)
|
expect(females).toBeLessThan(250)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getGenderSymbol', () => {
|
describe('getGenderSymbol', () => {
|
||||||
test('male symbol', () => {
|
test('male symbol', () => {
|
||||||
expect(getGenderSymbol('male')).toBe('♂')
|
expect(getGenderSymbol('male')).toBe('♂')
|
||||||
})
|
})
|
||||||
test('female symbol', () => {
|
test('female symbol', () => {
|
||||||
expect(getGenderSymbol('female')).toBe('♀')
|
expect(getGenderSymbol('female')).toBe('♀')
|
||||||
})
|
})
|
||||||
test('genderless has no symbol', () => {
|
test('genderless has no symbol', () => {
|
||||||
expect(getGenderSymbol('genderless')).toBe('')
|
expect(getGenderSymbol('genderless')).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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'
|
import { EMPTY_MOVE } from '../types'
|
||||||
|
|
||||||
describe('getDefaultMoveset', () => {
|
describe('getDefaultMoveset', () => {
|
||||||
test('charmander at level 1 has at least one move', async () => {
|
test('charmander at level 1 has at least one move', async () => {
|
||||||
const moves = await getDefaultMoveset('charmander', 1)
|
const moves = await getDefaultMoveset('charmander', 1)
|
||||||
expect(moves.length).toBe(4)
|
expect(moves.length).toBe(4)
|
||||||
expect(moves[0]!.id).not.toBe('')
|
expect(moves[0]!.id).not.toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('charmander at level 10 has more moves', async () => {
|
test('charmander at level 10 has more moves', async () => {
|
||||||
const moves = await getDefaultMoveset('charmander', 10)
|
const moves = await getDefaultMoveset('charmander', 10)
|
||||||
const nonEmpty = moves.filter(m => m.id !== '')
|
const nonEmpty = moves.filter(m => m.id !== '')
|
||||||
expect(nonEmpty.length).toBeGreaterThan(1)
|
expect(nonEmpty.length).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('all moves have valid pp', async () => {
|
test('all moves have valid pp', async () => {
|
||||||
const moves = await getDefaultMoveset('bulbasaur', 20)
|
const moves = await getDefaultMoveset('bulbasaur', 20)
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
if (move.id) {
|
if (move.id) {
|
||||||
expect(move.pp).toBeGreaterThan(0)
|
expect(move.pp).toBeGreaterThan(0)
|
||||||
expect(move.maxPp).toBeGreaterThan(0)
|
expect(move.maxPp).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('invalid species returns empty moves', async () => {
|
test('invalid species returns empty moves', async () => {
|
||||||
const moves = await getDefaultMoveset('nonexistent' as any, 10)
|
const moves = await getDefaultMoveset('nonexistent' as any, 10)
|
||||||
expect(moves.every(m => m.id === '')).toBe(true)
|
expect(moves.every(m => m.id === '')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getDefaultAbility', () => {
|
describe('getDefaultAbility', () => {
|
||||||
test('charmander has blaze', () => {
|
test('charmander has blaze', () => {
|
||||||
expect(getDefaultAbility('charmander')).toBe('blaze')
|
expect(getDefaultAbility('charmander')).toBe('blaze')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bulbasaur has overgrow', () => {
|
test('bulbasaur has overgrow', () => {
|
||||||
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
|
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('squirtle has torrent', () => {
|
test('squirtle has torrent', () => {
|
||||||
expect(getDefaultAbility('squirtle')).toBe('torrent')
|
expect(getDefaultAbility('squirtle')).toBe('torrent')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNewLearnableMoves', () => {
|
describe('getNewLearnableMoves', () => {
|
||||||
test('charmander gains ember at level 4', async () => {
|
test('charmander gains ember at level 4', async () => {
|
||||||
const moves = await getNewLearnableMoves('charmander', 1, 4)
|
const moves = await getNewLearnableMoves('charmander', 1, 4)
|
||||||
expect(moves.length).toBeGreaterThan(0)
|
expect(moves.length).toBeGreaterThan(0)
|
||||||
expect(moves.some(m => m.id === 'ember')).toBe(true)
|
expect(moves.some(m => m.id === 'ember')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('no new moves when level stays same', async () => {
|
test('no new moves when level stays same', async () => {
|
||||||
const moves = await getNewLearnableMoves('charmander', 5, 5)
|
const moves = await getNewLearnableMoves('charmander', 5, 5)
|
||||||
expect(moves.length).toBe(0)
|
expect(moves.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
|
|
||||||
describe('SPECIES_NAMES', () => {
|
describe('SPECIES_NAMES', () => {
|
||||||
test('has name for every species', () => {
|
test('has name for every species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
expect(SPECIES_NAMES[id]).toBeTruthy()
|
expect(SPECIES_NAMES[id]).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Charmander name is correct', () => {
|
test('Charmander name is correct', () => {
|
||||||
expect(SPECIES_NAMES.charmander).toBe('Charmander')
|
expect(SPECIES_NAMES.charmander).toBe('Charmander')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SPECIES_I18N', () => {
|
describe('SPECIES_I18N', () => {
|
||||||
test('has i18n for every species', () => {
|
test('has i18n for every species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
expect(SPECIES_I18N[id]).toBeTruthy()
|
expect(SPECIES_I18N[id]).toBeTruthy()
|
||||||
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has Chinese translations', () => {
|
test('has Chinese translations', () => {
|
||||||
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
|
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
|
||||||
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
|
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SPECIES_PERSONALITY', () => {
|
describe('SPECIES_PERSONALITY', () => {
|
||||||
test('has personality for every species', () => {
|
test('has personality for every species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('personality is non-empty string', () => {
|
test('personality is non-empty string', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
||||||
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { getAllNatureNames, randomNature, getNatureEffect } from '../data/nature'
|
import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature'
|
||||||
|
|
||||||
describe('getAllNatureNames', () => {
|
describe('getAllNatureNames', () => {
|
||||||
test('returns 25 nature names', () => {
|
test('returns 25 nature names', () => {
|
||||||
const names = getAllNatureNames()
|
const names = getAllNatureNames()
|
||||||
expect(names.length).toBe(25)
|
expect(names.length).toBe(25)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('includes hardy and quirky', () => {
|
test('includes hardy and quirky', () => {
|
||||||
const names = getAllNatureNames()
|
const names = getAllNatureNames()
|
||||||
expect(names).toContain('hardy')
|
expect(names).toContain('hardy')
|
||||||
expect(names).toContain('quirky')
|
expect(names).toContain('quirky')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('randomNature', () => {
|
describe('randomNature', () => {
|
||||||
test('returns a valid nature name', () => {
|
test('returns a valid nature name', () => {
|
||||||
const nature = randomNature()
|
const nature = randomNature()
|
||||||
expect(getAllNatureNames()).toContain(nature)
|
expect(getAllNatureNames()).toContain(nature)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('produces different natures over multiple calls', () => {
|
test('produces different natures over multiple calls', () => {
|
||||||
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
|
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
|
||||||
expect(natures.size).toBeGreaterThan(1)
|
expect(natures.size).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNatureEffect', () => {
|
describe('getNatureEffect', () => {
|
||||||
test('hardy is neutral (no effect)', () => {
|
test('hardy is neutral (no effect)', () => {
|
||||||
const effect = getNatureEffect('hardy')
|
const effect = getNatureEffect('hardy')
|
||||||
expect(effect.plus).toBeNull()
|
expect(effect.plus).toBeNull()
|
||||||
expect(effect.minus).toBeNull()
|
expect(effect.minus).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('adamant boosts attack and lowers spAtk', () => {
|
test('adamant boosts attack and lowers spAtk', () => {
|
||||||
const effect = getNatureEffect('adamant')
|
const effect = getNatureEffect('adamant')
|
||||||
expect(effect.plus).toBe('attack')
|
expect(effect.plus).toBe('attack')
|
||||||
expect(effect.minus).toBe('spAtk')
|
expect(effect.minus).toBe('spAtk')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('timid boosts speed and lowers attack', () => {
|
test('timid boosts speed and lowers attack', () => {
|
||||||
const effect = getNatureEffect('timid')
|
const effect = getNatureEffect('timid')
|
||||||
expect(effect.plus).toBe('speed')
|
expect(effect.plus).toBe('speed')
|
||||||
expect(effect.minus).toBe('attack')
|
expect(effect.minus).toBe('attack')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('invalid nature returns neutral', () => {
|
test('invalid nature returns neutral', () => {
|
||||||
const effect = getNatureEffect('nonexistent')
|
const effect = getNatureEffect('nonexistent')
|
||||||
expect(effect.plus).toBeNull()
|
expect(effect.plus).toBeNull()
|
||||||
expect(effect.minus).toBeNull()
|
expect(effect.minus).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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', () => {
|
describe('FROM_DEX_STAT', () => {
|
||||||
test('maps all 6 stats', () => {
|
test('maps all 6 stats', () => {
|
||||||
expect(FROM_DEX_STAT.hp).toBe('hp')
|
expect(FROM_DEX_STAT.hp).toBe('hp')
|
||||||
expect(FROM_DEX_STAT.atk).toBe('attack')
|
expect(FROM_DEX_STAT.atk).toBe('attack')
|
||||||
expect(FROM_DEX_STAT.def).toBe('defense')
|
expect(FROM_DEX_STAT.def).toBe('defense')
|
||||||
expect(FROM_DEX_STAT.spa).toBe('spAtk')
|
expect(FROM_DEX_STAT.spa).toBe('spAtk')
|
||||||
expect(FROM_DEX_STAT.spd).toBe('spDef')
|
expect(FROM_DEX_STAT.spd).toBe('spDef')
|
||||||
expect(FROM_DEX_STAT.spe).toBe('speed')
|
expect(FROM_DEX_STAT.spe).toBe('speed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('TO_DEX_STAT', () => {
|
describe('TO_DEX_STAT', () => {
|
||||||
test('reverse maps all 6 stats', () => {
|
test('reverse maps all 6 stats', () => {
|
||||||
expect(TO_DEX_STAT.hp).toBe('hp')
|
expect(TO_DEX_STAT.hp).toBe('hp')
|
||||||
expect(TO_DEX_STAT.attack).toBe('atk')
|
expect(TO_DEX_STAT.attack).toBe('atk')
|
||||||
expect(TO_DEX_STAT.defense).toBe('def')
|
expect(TO_DEX_STAT.defense).toBe('def')
|
||||||
expect(TO_DEX_STAT.spAtk).toBe('spa')
|
expect(TO_DEX_STAT.spAtk).toBe('spa')
|
||||||
expect(TO_DEX_STAT.spDef).toBe('spd')
|
expect(TO_DEX_STAT.spDef).toBe('spd')
|
||||||
expect(TO_DEX_STAT.speed).toBe('spe')
|
expect(TO_DEX_STAT.speed).toBe('spe')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mapBaseStats', () => {
|
describe('mapBaseStats', () => {
|
||||||
test('converts Dex stat format to our format', () => {
|
test('converts Dex stat format to our format', () => {
|
||||||
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
|
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
hp: 45, attack: 49, defense: 49,
|
hp: 45, attack: 49, defense: 49,
|
||||||
spAtk: 65, spDef: 65, speed: 45,
|
spAtk: 65, spDef: 65, speed: 45,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mapGenderRatio', () => {
|
describe('mapGenderRatio', () => {
|
||||||
test('returns -1 for genderless', () => {
|
test('returns -1 for genderless', () => {
|
||||||
expect(mapGenderRatio(undefined)).toBe(-1)
|
expect(mapGenderRatio(undefined)).toBe(-1)
|
||||||
expect(mapGenderRatio('N')).toBe(-1)
|
expect(mapGenderRatio('N')).toBe(-1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('calculates female ratio', () => {
|
test('calculates female ratio', () => {
|
||||||
expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1
|
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
|
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'
|
import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
|
||||||
|
|
||||||
describe('renderAnimatedSprite', () => {
|
describe('renderAnimatedSprite', () => {
|
||||||
const testSprite = [
|
const testSprite = [
|
||||||
' AB',
|
' AB',
|
||||||
' C D',
|
' C D',
|
||||||
]
|
]
|
||||||
|
|
||||||
test('idle mode returns original sprite (with ANSI resets)', () => {
|
test('idle mode returns original sprite (with ANSI resets)', () => {
|
||||||
const result = renderAnimatedSprite(testSprite, 0, 'idle')
|
const result = renderAnimatedSprite(testSprite, 0, 'idle')
|
||||||
expect(result.length).toBe(2)
|
expect(result.length).toBe(2)
|
||||||
// Each row should contain the original characters
|
// Each row should contain the original characters
|
||||||
expect(result[0]).toContain('A')
|
expect(result[0]).toContain('A')
|
||||||
expect(result[0]).toContain('B')
|
expect(result[0]).toContain('B')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('flip reverses rows', () => {
|
test('flip reverses rows', () => {
|
||||||
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
|
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
|
||||||
expect(flipped[0]).toContain('B')
|
expect(flipped[0]).toContain('B')
|
||||||
expect(flipped[0]).toContain('A')
|
expect(flipped[0]).toContain('A')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('blink replaces eye characters with dash', () => {
|
test('blink replaces eye characters with dash', () => {
|
||||||
const sprite = [' O ', ' O ']
|
const sprite = [' O ', ' O ']
|
||||||
const result = renderAnimatedSprite(sprite, 0, 'blink')
|
const result = renderAnimatedSprite(sprite, 0, 'blink')
|
||||||
expect(result[0]).toContain('—')
|
expect(result[0]).toContain('—')
|
||||||
expect(result[1]).toContain('—')
|
expect(result[1]).toContain('—')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bounce shifts sprite up', () => {
|
test('bounce shifts sprite up', () => {
|
||||||
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
|
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
|
||||||
// Bounce at tick 2 should shift up by some amount
|
// Bounce at tick 2 should shift up by some amount
|
||||||
expect(result.length).toBe(2)
|
expect(result.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('excited mode shifts horizontally', () => {
|
test('excited mode shifts horizontally', () => {
|
||||||
const result = renderAnimatedSprite(testSprite, 0, 'excited')
|
const result = renderAnimatedSprite(testSprite, 0, 'excited')
|
||||||
expect(result.length).toBe(2)
|
expect(result.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('walkRight shifts progressively', () => {
|
test('walkRight shifts progressively', () => {
|
||||||
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
|
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
|
||||||
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
|
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
|
||||||
// Different ticks should produce different horizontal positions
|
// Different ticks should produce different horizontal positions
|
||||||
expect(r0).toBeDefined()
|
expect(r0).toBeDefined()
|
||||||
expect(r1).toBeDefined()
|
expect(r1).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('walkLeft mode shifts', () => {
|
test('walkLeft mode shifts', () => {
|
||||||
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
|
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
|
||||||
expect(result.length).toBe(2)
|
expect(result.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pet mode returns sprite unchanged', () => {
|
test('pet mode returns sprite unchanged', () => {
|
||||||
const result = renderAnimatedSprite(testSprite, 0, 'pet')
|
const result = renderAnimatedSprite(testSprite, 0, 'pet')
|
||||||
expect(result.length).toBe(2)
|
expect(result.length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getIdleAnimMode', () => {
|
describe('getIdleAnimMode', () => {
|
||||||
test('returns valid AnimMode for any tick', () => {
|
test('returns valid AnimMode for any tick', () => {
|
||||||
const modes = new Set<string>()
|
const modes = new Set<string>()
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
modes.add(getIdleAnimMode(i))
|
modes.add(getIdleAnimMode(i))
|
||||||
}
|
}
|
||||||
expect(modes.size).toBeGreaterThan(1)
|
expect(modes.size).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cycles through sequence', () => {
|
test('cycles through sequence', () => {
|
||||||
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
|
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
|
||||||
expect(getIdleAnimMode(0)).toBe('idle')
|
expect(getIdleAnimMode(0)).toBe('idle')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('wraps around after sequence length', () => {
|
test('wraps around after sequence length', () => {
|
||||||
const mode0 = getIdleAnimMode(0)
|
const mode0 = getIdleAnimMode(0)
|
||||||
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
|
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
|
||||||
expect(mode0).toBe(modeAfterFullCycle)
|
expect(mode0).toBe(modeAfterFullCycle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getPetOverlay', () => {
|
describe('getPetOverlay', () => {
|
||||||
test('returns two lines', () => {
|
test('returns two lines', () => {
|
||||||
const overlay = getPetOverlay(0)
|
const overlay = getPetOverlay(0)
|
||||||
expect(overlay.length).toBe(2)
|
expect(overlay.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('contains heart characters', () => {
|
test('contains heart characters', () => {
|
||||||
const overlay = getPetOverlay(0)
|
const overlay = getPetOverlay(0)
|
||||||
const combined = overlay.join('')
|
const combined = overlay.join('')
|
||||||
expect(combined).toContain('♥')
|
expect(combined).toContain('♥')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cycles through overlays', () => {
|
test('cycles through overlays', () => {
|
||||||
const o0 = getPetOverlay(0)
|
const o0 = getPetOverlay(0)
|
||||||
const o1 = getPetOverlay(1)
|
const o1 = getPetOverlay(1)
|
||||||
expect(o0).not.toEqual(o1)
|
expect(o0).not.toEqual(o1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('wraps around', () => {
|
test('wraps around', () => {
|
||||||
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
|
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,95 +1,95 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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 { ALL_SPECIES_IDS } from '../types'
|
||||||
import type { SpeciesId } from '../types'
|
import type { SpeciesId } from '../types'
|
||||||
|
|
||||||
describe('getSpeciesData', () => {
|
describe('getSpeciesData', () => {
|
||||||
test('returns valid data for charmander', () => {
|
test('returns valid data for charmander', () => {
|
||||||
const data = getSpeciesData('charmander')
|
const data = getSpeciesData('charmander')
|
||||||
expect(data.id).toBe('charmander')
|
expect(data.id).toBe('charmander')
|
||||||
expect(data.name).toBe('Charmander')
|
expect(data.name).toBe('Charmander')
|
||||||
expect(data.dexNumber).toBe(4)
|
expect(data.dexNumber).toBe(4)
|
||||||
expect(data.growthRate).toBe('medium-slow')
|
expect(data.growthRate).toBe('medium-slow')
|
||||||
expect(data.captureRate).toBe(45)
|
expect(data.captureRate).toBe(45)
|
||||||
expect(data.flavorText).toBeTruthy()
|
expect(data.flavorText).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns valid data for pikachu', () => {
|
test('returns valid data for pikachu', () => {
|
||||||
const data = getSpeciesData('pikachu')
|
const data = getSpeciesData('pikachu')
|
||||||
expect(data.id).toBe('pikachu')
|
expect(data.id).toBe('pikachu')
|
||||||
expect(data.dexNumber).toBe(25)
|
expect(data.dexNumber).toBe(25)
|
||||||
expect(data.growthRate).toBe('medium-fast')
|
expect(data.growthRate).toBe('medium-fast')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has baseStats with all 6 stats', () => {
|
test('has baseStats with all 6 stats', () => {
|
||||||
const data = getSpeciesData('bulbasaur')
|
const data = getSpeciesData('bulbasaur')
|
||||||
expect(data.baseStats).toHaveProperty('hp')
|
expect(data.baseStats).toHaveProperty('hp')
|
||||||
expect(data.baseStats).toHaveProperty('attack')
|
expect(data.baseStats).toHaveProperty('attack')
|
||||||
expect(data.baseStats).toHaveProperty('defense')
|
expect(data.baseStats).toHaveProperty('defense')
|
||||||
expect(data.baseStats).toHaveProperty('spAtk')
|
expect(data.baseStats).toHaveProperty('spAtk')
|
||||||
expect(data.baseStats).toHaveProperty('spDef')
|
expect(data.baseStats).toHaveProperty('spDef')
|
||||||
expect(data.baseStats).toHaveProperty('speed')
|
expect(data.baseStats).toHaveProperty('speed')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has types array', () => {
|
test('has types array', () => {
|
||||||
const data = getSpeciesData('squirtle')
|
const data = getSpeciesData('squirtle')
|
||||||
expect(data.types.length).toBeGreaterThan(0)
|
expect(data.types.length).toBeGreaterThan(0)
|
||||||
expect(data.types[0]).toBe('water')
|
expect(data.types[0]).toBe('water')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has evolutionChain for species with evolutions', () => {
|
test('has evolutionChain for species with evolutions', () => {
|
||||||
const data = getSpeciesData('charmander')
|
const data = getSpeciesData('charmander')
|
||||||
expect(data.evolutionChain).toBeDefined()
|
expect(data.evolutionChain).toBeDefined()
|
||||||
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
|
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has no evolutionChain for final evolutions', () => {
|
test('has no evolutionChain for final evolutions', () => {
|
||||||
const data = getSpeciesData('charizard')
|
const data = getSpeciesData('charizard')
|
||||||
expect(data.evolutionChain).toBeUndefined()
|
expect(data.evolutionChain).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getAllSpeciesData', () => {
|
describe('getAllSpeciesData', () => {
|
||||||
test('returns data for all species', () => {
|
test('returns data for all species', () => {
|
||||||
const all = getAllSpeciesData()
|
const all = getAllSpeciesData()
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
expect(all[id]).toBeDefined()
|
expect(all[id]).toBeDefined()
|
||||||
expect(all[id]!.id).toBe(id)
|
expect(all[id]!.id).toBe(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DEX_TO_SPECIES', () => {
|
describe('DEX_TO_SPECIES', () => {
|
||||||
test('maps dex numbers correctly', () => {
|
test('maps dex numbers correctly', () => {
|
||||||
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
|
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
|
||||||
expect(DEX_TO_SPECIES[4]).toBe('charmander')
|
expect(DEX_TO_SPECIES[4]).toBe('charmander')
|
||||||
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
|
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
|
||||||
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
|
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ensureSpeciesData', () => {
|
describe('ensureSpeciesData', () => {
|
||||||
test('resolves without error', async () => {
|
test('resolves without error', async () => {
|
||||||
await expect(ensureSpeciesData()).resolves.toBeUndefined()
|
await expect(ensureSpeciesData()).resolves.toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getSpeciesData - supplementary fields', () => {
|
describe('getSpeciesData - supplementary fields', () => {
|
||||||
test('has baseHappiness', () => {
|
test('has baseHappiness', () => {
|
||||||
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
|
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pikachu has higher captureRate', () => {
|
test('pikachu has higher captureRate', () => {
|
||||||
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
|
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has names with en key', () => {
|
test('has names with en key', () => {
|
||||||
const data = getSpeciesData('charmander')
|
const data = getSpeciesData('charmander')
|
||||||
expect(data.names).toBeDefined()
|
expect(data.names).toBeDefined()
|
||||||
expect(data.names.en).toBe('Charmander')
|
expect(data.names.en).toBe('Charmander')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shinyChance is 1/4096', () => {
|
test('shinyChance is 1/4096', () => {
|
||||||
expect(getSpeciesData('bulbasaur').shinyChance).toBe(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'
|
import { getSpeciesDisplay, loadSprite } from '../core/spriteCache'
|
||||||
|
|
||||||
describe('getSpeciesDisplay', () => {
|
describe('getSpeciesDisplay', () => {
|
||||||
test('formats charmander display', () => {
|
test('formats charmander display', () => {
|
||||||
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
|
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('formats pikachu display', () => {
|
test('formats pikachu display', () => {
|
||||||
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
|
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('formats bulbasaur display', () => {
|
test('formats bulbasaur display', () => {
|
||||||
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
|
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pads dex number to 3 digits', () => {
|
test('pads dex number to 3 digits', () => {
|
||||||
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
|
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loadSprite', () => {
|
describe('loadSprite', () => {
|
||||||
test('returns null when no cache exists', () => {
|
test('returns null when no cache exists', () => {
|
||||||
// Uses a temp directory via getSpritesDir, should return null for non-cached
|
// Uses a temp directory via getSpritesDir, should return null for non-cached
|
||||||
const result = loadSprite('nonexistent_pokemon' as any)
|
const result = loadSprite('nonexistent_pokemon' as any)
|
||||||
// Will be null since the file doesn't exist
|
// Will be null since the file doesn't exist
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,380 +1,380 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
getDefaultBuddyData,
|
getDefaultBuddyData,
|
||||||
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
|
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
|
||||||
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
||||||
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
||||||
addItemToBag, removeItemFromBag, getItemCount,
|
addItemToBag, removeItemFromBag, getItemCount,
|
||||||
updateDailyStats, incrementTurns,
|
updateDailyStats, incrementTurns,
|
||||||
} from '../core/storage'
|
} from '../core/storage'
|
||||||
import type { BuddyData } from '../types'
|
import type { BuddyData } from '../types'
|
||||||
|
|
||||||
function makeData(creatureCount = 1): BuddyData {
|
function makeData(creatureCount = 1): BuddyData {
|
||||||
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
|
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
|
||||||
id: `creature-${i}`,
|
id: `creature-${i}`,
|
||||||
speciesId: 'bulbasaur' as const,
|
speciesId: 'bulbasaur' as const,
|
||||||
gender: 'male' as const,
|
gender: 'male' as const,
|
||||||
level: 5,
|
level: 5,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
totalXp: 100,
|
totalXp: 100,
|
||||||
nature: 'hardy',
|
nature: 'hardy',
|
||||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
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 },
|
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
|
||||||
moves: [
|
moves: [
|
||||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
{ id: '', pp: 0, maxPp: 0 },
|
{ id: '', pp: 0, maxPp: 0 },
|
||||||
] as [any, any, any, any],
|
] as [any, any, any, any],
|
||||||
ability: 'overgrow',
|
ability: 'overgrow',
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
friendship: 70,
|
friendship: 70,
|
||||||
isShiny: false,
|
isShiny: false,
|
||||||
hatchedAt: Date.now(),
|
hatchedAt: Date.now(),
|
||||||
pokeball: 'pokeball',
|
pokeball: 'pokeball',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
|
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
|
||||||
if (creatureCount > 1) party[1] = creatures[1]!.id
|
if (creatureCount > 1) party[1] = creatures[1]!.id
|
||||||
if (creatureCount > 2) party[2] = creatures[2]!.id
|
if (creatureCount > 2) party[2] = creatures[2]!.id
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party,
|
party,
|
||||||
boxes: [
|
boxes: [
|
||||||
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
|
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
|
||||||
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
|
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
|
||||||
],
|
],
|
||||||
creatures,
|
creatures,
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [],
|
dex: [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 10,
|
totalTurns: 10,
|
||||||
consecutiveDays: 5,
|
consecutiveDays: 5,
|
||||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 3,
|
battlesWon: 3,
|
||||||
battlesLost: 1,
|
battlesLost: 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Default data ───
|
// ─── Default data ───
|
||||||
|
|
||||||
describe('getDefaultBuddyData', () => {
|
describe('getDefaultBuddyData', () => {
|
||||||
test('returns v2 data with correct structure', async () => {
|
test('returns v2 data with correct structure', async () => {
|
||||||
const data = await getDefaultBuddyData()
|
const data = await getDefaultBuddyData()
|
||||||
expect(data.version).toBe(2)
|
expect(data.version).toBe(2)
|
||||||
expect(data.party.length).toBe(6)
|
expect(data.party.length).toBe(6)
|
||||||
expect(data.party[0]).toBeTruthy()
|
expect(data.party[0]).toBeTruthy()
|
||||||
expect(data.boxes.length).toBe(8)
|
expect(data.boxes.length).toBe(8)
|
||||||
expect(data.boxes[0]!.slots.length).toBe(30)
|
expect(data.boxes[0]!.slots.length).toBe(30)
|
||||||
expect(data.bag.items).toEqual([])
|
expect(data.bag.items).toEqual([])
|
||||||
expect(data.stats.battlesWon).toBe(0)
|
expect(data.stats.battlesWon).toBe(0)
|
||||||
expect(data.stats.battlesLost).toBe(0)
|
expect(data.stats.battlesLost).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('has one creature matching party[0]', async () => {
|
test('has one creature matching party[0]', async () => {
|
||||||
const data = await getDefaultBuddyData()
|
const data = await getDefaultBuddyData()
|
||||||
expect(data.creatures.length).toBe(1)
|
expect(data.creatures.length).toBe(1)
|
||||||
expect(data.creatures[0]!.id).toBe(data.party[0]!)
|
expect(data.creatures[0]!.id).toBe(data.party[0]!)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creature has v2 fields', async () => {
|
test('creature has v2 fields', async () => {
|
||||||
const data = await getDefaultBuddyData()
|
const data = await getDefaultBuddyData()
|
||||||
const creature = data.creatures[0]!
|
const creature = data.creatures[0]!
|
||||||
expect(creature.nature).toBeTruthy()
|
expect(creature.nature).toBeTruthy()
|
||||||
expect(creature.moves.length).toBe(4)
|
expect(creature.moves.length).toBe(4)
|
||||||
expect(creature.ability).toBeTruthy()
|
expect(creature.ability).toBeTruthy()
|
||||||
expect(creature.heldItem).toBeNull()
|
expect(creature.heldItem).toBeNull()
|
||||||
expect(creature.pokeball).toBe('pokeball')
|
expect(creature.pokeball).toBe('pokeball')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Party operations ───
|
// ─── Party operations ───
|
||||||
|
|
||||||
describe('addToParty', () => {
|
describe('addToParty', () => {
|
||||||
test('adds creature to first empty slot', () => {
|
test('adds creature to first empty slot', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const result = addToParty(data, 'new-creature')
|
const result = addToParty(data, 'new-creature')
|
||||||
expect(result.added).toBe(true)
|
expect(result.added).toBe(true)
|
||||||
expect(result.data.party[1]).toBe('new-creature')
|
expect(result.data.party[1]).toBe('new-creature')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns false when party is full', () => {
|
test('returns false when party is full', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
|
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
|
||||||
const result = addToParty(data, 'new-creature')
|
const result = addToParty(data, 'new-creature')
|
||||||
expect(result.added).toBe(false)
|
expect(result.added).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeFromParty', () => {
|
describe('removeFromParty', () => {
|
||||||
test('removes creature at index', () => {
|
test('removes creature at index', () => {
|
||||||
const data = makeData(2)
|
const data = makeData(2)
|
||||||
const updated = removeFromParty(data, 1)
|
const updated = removeFromParty(data, 1)
|
||||||
expect(updated.party[1]).toBeNull()
|
expect(updated.party[1]).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does nothing for out-of-bounds index', () => {
|
test('does nothing for out-of-bounds index', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = removeFromParty(data, 10)
|
const updated = removeFromParty(data, 10)
|
||||||
expect(updated.party).toEqual(data.party)
|
expect(updated.party).toEqual(data.party)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('swapPartySlots', () => {
|
describe('swapPartySlots', () => {
|
||||||
test('swaps two party slots', () => {
|
test('swaps two party slots', () => {
|
||||||
const data = makeData(2)
|
const data = makeData(2)
|
||||||
const updated = swapPartySlots(data, 0, 1)
|
const updated = swapPartySlots(data, 0, 1)
|
||||||
expect(updated.party[0]).toBe('creature-1')
|
expect(updated.party[0]).toBe('creature-1')
|
||||||
expect(updated.party[1]).toBe('creature-0')
|
expect(updated.party[1]).toBe('creature-0')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setActivePartyMember', () => {
|
describe('setActivePartyMember', () => {
|
||||||
test('swaps creature to slot 0', () => {
|
test('swaps creature to slot 0', () => {
|
||||||
const data = makeData(2)
|
const data = makeData(2)
|
||||||
const updated = setActivePartyMember(data, 'creature-1')
|
const updated = setActivePartyMember(data, 'creature-1')
|
||||||
expect(updated.party[0]).toBe('creature-1')
|
expect(updated.party[0]).toBe('creature-1')
|
||||||
expect(updated.party[1]).toBe('creature-0')
|
expect(updated.party[1]).toBe('creature-0')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('no change if already active', () => {
|
test('no change if already active', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = setActivePartyMember(data, 'creature-0')
|
const updated = setActivePartyMember(data, 'creature-0')
|
||||||
expect(updated).toEqual(data)
|
expect(updated).toEqual(data)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── PC Box operations ───
|
// ─── PC Box operations ───
|
||||||
|
|
||||||
describe('depositToBox', () => {
|
describe('depositToBox', () => {
|
||||||
test('deposits creature to first empty box slot', () => {
|
test('deposits creature to first empty box slot', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const result = depositToBox(data, 'box-creature')
|
const result = depositToBox(data, 'box-creature')
|
||||||
expect(result.deposited).toBe(true)
|
expect(result.deposited).toBe(true)
|
||||||
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
|
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fills second box when first is full', () => {
|
test('fills second box when first is full', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
data.boxes[0]!.slots = Array(30).fill('x')
|
data.boxes[0]!.slots = Array(30).fill('x')
|
||||||
const result = depositToBox(data, 'box-creature')
|
const result = depositToBox(data, 'box-creature')
|
||||||
expect(result.deposited).toBe(true)
|
expect(result.deposited).toBe(true)
|
||||||
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
|
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('withdrawFromBox', () => {
|
describe('withdrawFromBox', () => {
|
||||||
test('withdraws creature from box', () => {
|
test('withdraws creature from box', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
data.boxes[0]!.slots[5] = 'box-creature'
|
data.boxes[0]!.slots[5] = 'box-creature'
|
||||||
const result = withdrawFromBox(data, 'box-creature')
|
const result = withdrawFromBox(data, 'box-creature')
|
||||||
expect(result.withdrawn).toBe(true)
|
expect(result.withdrawn).toBe(true)
|
||||||
expect(result.data.boxes[0]!.slots[5]).toBeNull()
|
expect(result.data.boxes[0]!.slots[5]).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns false when creature not in boxes', () => {
|
test('returns false when creature not in boxes', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const result = withdrawFromBox(data, 'nonexistent')
|
const result = withdrawFromBox(data, 'nonexistent')
|
||||||
expect(result.withdrawn).toBe(false)
|
expect(result.withdrawn).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('moveInBox', () => {
|
describe('moveInBox', () => {
|
||||||
test('moves creature between slots', () => {
|
test('moves creature between slots', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
data.boxes[0]!.slots[0] = 'moving-creature'
|
data.boxes[0]!.slots[0] = 'moving-creature'
|
||||||
const updated = moveInBox(data, 0, 0, 0, 5)
|
const updated = moveInBox(data, 0, 0, 0, 5)
|
||||||
expect(updated.boxes[0]!.slots[0]).toBeNull()
|
expect(updated.boxes[0]!.slots[0]).toBeNull()
|
||||||
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
|
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does nothing for empty source slot', () => {
|
test('does nothing for empty source slot', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = moveInBox(data, 0, 0, 0, 5)
|
const updated = moveInBox(data, 0, 0, 0, 5)
|
||||||
expect(updated).toEqual(data)
|
expect(updated).toEqual(data)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('renameBox', () => {
|
describe('renameBox', () => {
|
||||||
test('renames a box', () => {
|
test('renames a box', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = renameBox(data, 0, 'My Box')
|
const updated = renameBox(data, 0, 'My Box')
|
||||||
expect(updated.boxes[0]!.name).toBe('My Box')
|
expect(updated.boxes[0]!.name).toBe('My Box')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findCreatureLocation', () => {
|
describe('findCreatureLocation', () => {
|
||||||
test('finds creature in party', () => {
|
test('finds creature in party', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const loc = findCreatureLocation(data, 'creature-0')
|
const loc = findCreatureLocation(data, 'creature-0')
|
||||||
expect(loc).toEqual({ area: 'party', slot: 0 })
|
expect(loc).toEqual({ area: 'party', slot: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('finds creature in box', () => {
|
test('finds creature in box', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
data.boxes[0]!.slots[3] = 'box-creature'
|
data.boxes[0]!.slots[3] = 'box-creature'
|
||||||
const loc = findCreatureLocation(data, 'box-creature')
|
const loc = findCreatureLocation(data, 'box-creature')
|
||||||
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
|
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns null for nonexistent', () => {
|
test('returns null for nonexistent', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
|
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('releaseCreature', () => {
|
describe('releaseCreature', () => {
|
||||||
test('removes creature from party and creatures array', () => {
|
test('removes creature from party and creatures array', () => {
|
||||||
const data = makeData(2)
|
const data = makeData(2)
|
||||||
const updated = releaseCreature(data, 'creature-1')
|
const updated = releaseCreature(data, 'creature-1')
|
||||||
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
|
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getTotalCreatureCount', () => {
|
describe('getTotalCreatureCount', () => {
|
||||||
test('returns creature count', () => {
|
test('returns creature count', () => {
|
||||||
expect(getTotalCreatureCount(makeData(3))).toBe(3)
|
expect(getTotalCreatureCount(makeData(3))).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getAllCreatureIds', () => {
|
describe('getAllCreatureIds', () => {
|
||||||
test('returns all ids', () => {
|
test('returns all ids', () => {
|
||||||
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
|
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Bag operations ───
|
// ─── Bag operations ───
|
||||||
|
|
||||||
describe('addItemToBag', () => {
|
describe('addItemToBag', () => {
|
||||||
test('adds new item', () => {
|
test('adds new item', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = addItemToBag(data, 'potion', 3)
|
const updated = addItemToBag(data, 'potion', 3)
|
||||||
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
|
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('stacks existing item', () => {
|
test('stacks existing item', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const withItem = addItemToBag(data, 'potion', 2)
|
const withItem = addItemToBag(data, 'potion', 2)
|
||||||
const stacked = addItemToBag(withItem, 'potion', 3)
|
const stacked = addItemToBag(withItem, 'potion', 3)
|
||||||
expect(stacked.bag.items[0]!.count).toBe(5)
|
expect(stacked.bag.items[0]!.count).toBe(5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeItemFromBag', () => {
|
describe('removeItemFromBag', () => {
|
||||||
test('removes item quantity', () => {
|
test('removes item quantity', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const withItem = addItemToBag(data, 'potion', 5)
|
const withItem = addItemToBag(data, 'potion', 5)
|
||||||
const result = removeItemFromBag(withItem, 'potion', 3)
|
const result = removeItemFromBag(withItem, 'potion', 3)
|
||||||
expect(result.removed).toBe(true)
|
expect(result.removed).toBe(true)
|
||||||
expect(result.data.bag.items[0]!.count).toBe(2)
|
expect(result.data.bag.items[0]!.count).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('removes item entirely when count reaches 0', () => {
|
test('removes item entirely when count reaches 0', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const withItem = addItemToBag(data, 'potion', 2)
|
const withItem = addItemToBag(data, 'potion', 2)
|
||||||
const result = removeItemFromBag(withItem, 'potion', 2)
|
const result = removeItemFromBag(withItem, 'potion', 2)
|
||||||
expect(result.removed).toBe(true)
|
expect(result.removed).toBe(true)
|
||||||
expect(result.data.bag.items.length).toBe(0)
|
expect(result.data.bag.items.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns false when not enough items', () => {
|
test('returns false when not enough items', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const withItem = addItemToBag(data, 'potion', 1)
|
const withItem = addItemToBag(data, 'potion', 1)
|
||||||
const result = removeItemFromBag(withItem, 'potion', 5)
|
const result = removeItemFromBag(withItem, 'potion', 5)
|
||||||
expect(result.removed).toBe(false)
|
expect(result.removed).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns false for nonexistent item', () => {
|
test('returns false for nonexistent item', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const result = removeItemFromBag(data, 'potion', 1)
|
const result = removeItemFromBag(data, 'potion', 1)
|
||||||
expect(result.removed).toBe(false)
|
expect(result.removed).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getItemCount', () => {
|
describe('getItemCount', () => {
|
||||||
test('returns count for existing item', () => {
|
test('returns count for existing item', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const withItem = addItemToBag(data, 'potion', 3)
|
const withItem = addItemToBag(data, 'potion', 3)
|
||||||
expect(getItemCount(withItem, 'potion')).toBe(3)
|
expect(getItemCount(withItem, 'potion')).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 0 for nonexistent item', () => {
|
test('returns 0 for nonexistent item', () => {
|
||||||
expect(getItemCount(makeData(), 'potion')).toBe(0)
|
expect(getItemCount(makeData(), 'potion')).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Stats ───
|
// ─── Stats ───
|
||||||
|
|
||||||
describe('updateDailyStats', () => {
|
describe('updateDailyStats', () => {
|
||||||
test('same day does not increment consecutive', () => {
|
test('same day does not increment consecutive', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = updateDailyStats(data)
|
const updated = updateDailyStats(data)
|
||||||
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
|
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('incrementTurns', () => {
|
describe('incrementTurns', () => {
|
||||||
test('increments totalTurns by 1', () => {
|
test('increments totalTurns by 1', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const updated = incrementTurns(data)
|
const updated = incrementTurns(data)
|
||||||
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
|
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Extended coverage ───
|
// ─── Extended coverage ───
|
||||||
|
|
||||||
describe('depositToBox - full boxes', () => {
|
describe('depositToBox - full boxes', () => {
|
||||||
test('fails when all boxes are full', () => {
|
test('fails when all boxes are full', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
for (const box of data.boxes) {
|
for (const box of data.boxes) {
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
box.slots[i] = `filler-${i}`
|
box.slots[i] = `filler-${i}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = depositToBox(data, 'test-id')
|
const result = depositToBox(data, 'test-id')
|
||||||
expect(result.deposited).toBe(false)
|
expect(result.deposited).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('withdrawFromBox - roundtrip', () => {
|
describe('withdrawFromBox - roundtrip', () => {
|
||||||
test('deposit then withdraw leaves box empty', () => {
|
test('deposit then withdraw leaves box empty', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const deposited = depositToBox(data, 'test-id')
|
const deposited = depositToBox(data, 'test-id')
|
||||||
expect(deposited.deposited).toBe(true)
|
expect(deposited.deposited).toBe(true)
|
||||||
const result = withdrawFromBox(deposited.data, 'test-id')
|
const result = withdrawFromBox(deposited.data, 'test-id')
|
||||||
expect(result.withdrawn).toBe(true)
|
expect(result.withdrawn).toBe(true)
|
||||||
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
|
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
|
||||||
expect(slot).toBeUndefined()
|
expect(slot).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findCreatureLocation - deposit', () => {
|
describe('findCreatureLocation - deposit', () => {
|
||||||
test('finds creature after depositing to box', () => {
|
test('finds creature after depositing to box', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const deposited = depositToBox(data, 'box-mon')
|
const deposited = depositToBox(data, 'box-mon')
|
||||||
const loc = findCreatureLocation(deposited.data, 'box-mon')
|
const loc = findCreatureLocation(deposited.data, 'box-mon')
|
||||||
expect(loc).not.toBeNull()
|
expect(loc).not.toBeNull()
|
||||||
expect(loc!.area).toBe('box')
|
expect(loc!.area).toBe('box')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('releaseCreature - box', () => {
|
describe('releaseCreature - box', () => {
|
||||||
test('removes creature from box and creatures array', () => {
|
test('removes creature from box and creatures array', () => {
|
||||||
const data = makeData()
|
const data = makeData()
|
||||||
const deposited = depositToBox(data, 'box-mon')
|
const deposited = depositToBox(data, 'box-mon')
|
||||||
const released = releaseCreature(deposited.data, 'box-mon')
|
const released = releaseCreature(deposited.data, 'box-mon')
|
||||||
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
|
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('clears party slot when releasing party member', () => {
|
test('clears party slot when releasing party member', () => {
|
||||||
const data = makeData(2)
|
const data = makeData(2)
|
||||||
const updated = releaseCreature(data, 'creature-1')
|
const updated = releaseCreature(data, 'creature-1')
|
||||||
expect(updated.party[1]).toBeNull()
|
expect(updated.party[1]).toBeNull()
|
||||||
expect(updated.creatures.length).toBe(1)
|
expect(updated.creatures.length).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
|
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
|
||||||
|
|
||||||
describe('xpForLevel', () => {
|
describe('xpForLevel', () => {
|
||||||
test('returns 0 for level 1', () => {
|
test('returns 0 for level 1', () => {
|
||||||
expect(xpForLevel(1, 'medium-fast')).toBe(0)
|
expect(xpForLevel(1, 'medium-fast')).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 0 for level 0', () => {
|
test('returns 0 for level 0', () => {
|
||||||
expect(xpForLevel(0, 'medium-fast')).toBe(0)
|
expect(xpForLevel(0, 'medium-fast')).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('medium-fast: level 5 = 125 XP', () => {
|
test('medium-fast: level 5 = 125 XP', () => {
|
||||||
expect(xpForLevel(5, 'medium-fast')).toBe(125)
|
expect(xpForLevel(5, 'medium-fast')).toBe(125)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('medium-fast: level 10 = 1000 XP', () => {
|
test('medium-fast: level 10 = 1000 XP', () => {
|
||||||
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('slow: level 5 = 156 XP', () => {
|
test('slow: level 5 = 156 XP', () => {
|
||||||
expect(xpForLevel(5, 'slow')).toBe(156)
|
expect(xpForLevel(5, 'slow')).toBe(156)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fast: level 5 = 100 XP', () => {
|
test('fast: level 5 = 100 XP', () => {
|
||||||
expect(xpForLevel(5, 'fast')).toBe(100)
|
expect(xpForLevel(5, 'fast')).toBe(100)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('levelFromXp', () => {
|
describe('levelFromXp', () => {
|
||||||
test('returns 1 for 0 XP', () => {
|
test('returns 1 for 0 XP', () => {
|
||||||
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 5 for 125 XP medium-fast', () => {
|
test('returns 5 for 125 XP medium-fast', () => {
|
||||||
expect(levelFromXp(125, 'medium-fast')).toBe(5)
|
expect(levelFromXp(125, 'medium-fast')).toBe(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('caps at 100', () => {
|
test('caps at 100', () => {
|
||||||
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
|
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('roundtrip: xpForLevel then levelFromXp', () => {
|
test('roundtrip: xpForLevel then levelFromXp', () => {
|
||||||
for (let lv = 1; lv <= 100; lv += 10) {
|
for (let lv = 1; lv <= 100; lv += 10) {
|
||||||
const xp = xpForLevel(lv, 'medium-fast')
|
const xp = xpForLevel(lv, 'medium-fast')
|
||||||
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
|
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('xpToNextLevel', () => {
|
describe('xpToNextLevel', () => {
|
||||||
test('returns 0 at level 100', () => {
|
test('returns 0 at level 100', () => {
|
||||||
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
|
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns difference to next level', () => {
|
test('returns difference to next level', () => {
|
||||||
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
|
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
|
||||||
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
|
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns full next level XP from 0', () => {
|
test('returns full next level XP from 0', () => {
|
||||||
expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8
|
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.
|
* Simple AI: pick a random usable move.
|
||||||
*/
|
*/
|
||||||
export function chooseAIMove(pokemon: BattlePokemon): number {
|
export function chooseAIMove(pokemon: BattlePokemon): number {
|
||||||
const usable = pokemon.moves
|
const usable = pokemon.moves
|
||||||
.map((m, i) => ({ move: m, index: i }))
|
.map((m, i) => ({ move: m, index: i }))
|
||||||
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
||||||
|
|
||||||
if (usable.length === 0) return 0 // Struggle
|
if (usable.length === 0) return 0 // Struggle
|
||||||
return usable[Math.floor(Math.random() * usable.length)]!.index
|
return usable[Math.floor(Math.random() * usable.length)]!.index
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Battle, Teams, toID } from '@pkmn/sim'
|
import { Battle, Teams, toID } from '@pkmn/sim'
|
||||||
import { Dex } from '@pkmn/sim'
|
import { Dex } from '@pkmn/sim'
|
||||||
import type { Creature, SpeciesId } from '../types'
|
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 { STAT_NAMES } from '../types'
|
||||||
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
|
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
|
||||||
import { chooseAIMove } from './ai'
|
import { chooseAIMove } from './ai'
|
||||||
@@ -9,291 +9,291 @@ import { chooseAIMove } from './ai'
|
|||||||
// ─── Adapter: Creature → Showdown Set ───
|
// ─── Adapter: Creature → Showdown Set ───
|
||||||
|
|
||||||
function creatureToSetString(creature: Creature): string {
|
function creatureToSetString(creature: Creature): string {
|
||||||
const species = Dex.species.get(creature.speciesId)
|
const species = Dex.species.get(creature.speciesId)
|
||||||
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
||||||
|
|
||||||
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
|
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
|
||||||
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
|
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
|
||||||
|
|
||||||
const moves = creature.moves
|
const moves = creature.moves
|
||||||
.filter(m => m.id)
|
.filter(m => m.id)
|
||||||
.map(m => Dex.moves.get(m.id)?.name ?? 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 DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
|
||||||
const formatStatLine = (vals: Record<string, number>) =>
|
const formatStatLine = (vals: Record<string, number>) =>
|
||||||
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
|
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
|
||||||
const ivs = formatStatLine(creature.iv)
|
const ivs = formatStatLine(creature.iv)
|
||||||
const evs = formatStatLine(creature.ev)
|
const evs = formatStatLine(creature.ev)
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
species.name,
|
species.name,
|
||||||
`Level: ${creature.level}`,
|
`Level: ${creature.level}`,
|
||||||
`Ability: ${abilityName}`,
|
`Ability: ${abilityName}`,
|
||||||
`Nature: ${natureName}`,
|
`Nature: ${natureName}`,
|
||||||
`IVs: ${ivs}`,
|
`IVs: ${ivs}`,
|
||||||
`EVs: ${evs}`,
|
`EVs: ${evs}`,
|
||||||
]
|
]
|
||||||
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
|
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
|
||||||
for (const move of moves) lines.push(`- ${move}`)
|
for (const move of moves) lines.push(`- ${move}`)
|
||||||
|
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
||||||
const species = Dex.species.get(speciesId)
|
const species = Dex.species.get(speciesId)
|
||||||
if (!species) throw new Error(`Species ${speciesId} not found`)
|
if (!species) throw new Error(`Species ${speciesId} not found`)
|
||||||
const ability = species.abilities['0'] ?? ''
|
const ability = species.abilities['0'] ?? ''
|
||||||
// Get first 4 level-up moves (from species data)
|
// Get first 4 level-up moves (from species data)
|
||||||
const moves = getSpeciesMoves(speciesId, level)
|
const moves = getSpeciesMoves(speciesId, level)
|
||||||
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
|
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
||||||
// In @pkmn/sim, Dex.species doesn't expose learnsets directly.
|
// In @pkmn/sim, Dex.species doesn't expose learnsets directly.
|
||||||
// Use common moves that exist in the sim's data for basic battles.
|
// 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.
|
// The actual move pool is resolved by the Battle engine during construction.
|
||||||
const species = Dex.species.get(speciesId)
|
const species = Dex.species.get(speciesId)
|
||||||
if (!species) return ['Tackle']
|
if (!species) return ['Tackle']
|
||||||
// Use type-appropriate basic moves as fallback
|
// Use type-appropriate basic moves as fallback
|
||||||
const type = species.types[0]?.toLowerCase() ?? 'normal'
|
const type = species.types[0]?.toLowerCase() ?? 'normal'
|
||||||
const basicMoves: Record<string, string[]> = {
|
const basicMoves: Record<string, string[]> = {
|
||||||
normal: ['Tackle', 'Scratch'],
|
normal: ['Tackle', 'Scratch'],
|
||||||
fire: ['Ember', 'FireSpin'],
|
fire: ['Ember', 'FireSpin'],
|
||||||
water: ['WaterGun', 'Bubble'],
|
water: ['WaterGun', 'Bubble'],
|
||||||
grass: ['VineWhip', 'RazorLeaf'],
|
grass: ['VineWhip', 'RazorLeaf'],
|
||||||
electric: ['ThunderShock', 'Spark'],
|
electric: ['ThunderShock', 'Spark'],
|
||||||
poison: ['PoisonSting', 'Smog'],
|
poison: ['PoisonSting', 'Smog'],
|
||||||
ice: ['IceShard', 'PowderSnow'],
|
ice: ['IceShard', 'PowderSnow'],
|
||||||
fighting: ['KarateChop', 'LowKick'],
|
fighting: ['KarateChop', 'LowKick'],
|
||||||
ground: ['MudSlap', 'SandAttack'],
|
ground: ['MudSlap', 'SandAttack'],
|
||||||
flying: ['Gust', 'WingAttack'],
|
flying: ['Gust', 'WingAttack'],
|
||||||
psychic: ['Confusion', 'Psybeam'],
|
psychic: ['Confusion', 'Psybeam'],
|
||||||
bug: ['BugBite', 'StringShot'],
|
bug: ['BugBite', 'StringShot'],
|
||||||
rock: ['RockThrow', 'SandAttack'],
|
rock: ['RockThrow', 'SandAttack'],
|
||||||
ghost: ['Lick', 'ShadowSneak'],
|
ghost: ['Lick', 'ShadowSneak'],
|
||||||
dragon: ['DragonRage', 'Twister'],
|
dragon: ['DragonRage', 'Twister'],
|
||||||
dark: ['Bite', 'Pursuit'],
|
dark: ['Bite', 'Pursuit'],
|
||||||
steel: ['MetalClaw', 'IronTail'],
|
steel: ['MetalClaw', 'IronTail'],
|
||||||
fairy: ['FairyWind', 'DisarmingVoice'],
|
fairy: ['FairyWind', 'DisarmingVoice'],
|
||||||
}
|
}
|
||||||
return basicMoves[type] ?? ['Tackle', 'Scratch']
|
return basicMoves[type] ?? ['Tackle', 'Scratch']
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State Projection ───
|
// ─── State Projection ───
|
||||||
|
|
||||||
function projectPokemon(pkm: any): BattlePokemon {
|
function projectPokemon(pkm: any): BattlePokemon {
|
||||||
if (!pkm) throw new Error('No active pokemon')
|
if (!pkm) throw new Error('No active pokemon')
|
||||||
const species = pkm.species
|
const species = pkm.species
|
||||||
const hp = pkm.hp ?? 0
|
const hp = pkm.hp ?? 0
|
||||||
const maxHp = pkm.maxhp ?? 1
|
const maxHp = pkm.maxhp ?? 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pkm.name, // sim doesn't store our UUID, use name as temp id
|
id: pkm.name, // sim doesn't store our UUID, use name as temp id
|
||||||
speciesId: toID(species.name) as SpeciesId,
|
speciesId: toID(species.name) as SpeciesId,
|
||||||
name: species.name,
|
name: species.name,
|
||||||
level: pkm.level,
|
level: pkm.level,
|
||||||
hp,
|
hp,
|
||||||
maxHp,
|
maxHp,
|
||||||
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
|
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
|
||||||
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({
|
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({
|
||||||
id: toID(m.name ?? m),
|
id: toID(m.name ?? m),
|
||||||
name: m.name ?? m,
|
name: m.name ?? m,
|
||||||
type: m.type ?? 'Normal',
|
type: m.type ?? 'Normal',
|
||||||
pp: m.pp ?? 0,
|
pp: m.pp ?? 0,
|
||||||
maxPp: m.maxPp ?? m.pp ?? 0,
|
maxPp: m.maxPp ?? m.pp ?? 0,
|
||||||
disabled: m.disabled ?? false,
|
disabled: m.disabled ?? false,
|
||||||
})),
|
})),
|
||||||
ability: pkm.ability ?? '',
|
ability: pkm.ability ?? '',
|
||||||
heldItem: pkm.item ?? null,
|
heldItem: pkm.item ?? null,
|
||||||
status: mapStatus(pkm.status),
|
status: mapStatus(pkm.status),
|
||||||
statStages: projectBoosts(pkm.boosts),
|
statStages: projectBoosts(pkm.boosts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(status: string): StatusCondition {
|
function mapStatus(status: string): StatusCondition {
|
||||||
if (!status) return 'none'
|
if (!status) return 'none'
|
||||||
const s = status.toLowerCase()
|
const s = status.toLowerCase()
|
||||||
if (s === 'psn') return 'poison'
|
if (s === 'psn') return 'poison'
|
||||||
if (s === 'tox') return 'bad_poison'
|
if (s === 'tox') return 'bad_poison'
|
||||||
if (s === 'brn') return 'burn'
|
if (s === 'brn') return 'burn'
|
||||||
if (s === 'par') return 'paralysis'
|
if (s === 'par') return 'paralysis'
|
||||||
if (s === 'frz') return 'freeze'
|
if (s === 'frz') return 'freeze'
|
||||||
if (s === 'slp') return 'sleep'
|
if (s === 'slp') return 'sleep'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
|
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
|
||||||
if (!boosts) return {}
|
if (!boosts) return {}
|
||||||
const result: Record<string, number> = {}
|
const result: Record<string, number> = {}
|
||||||
for (const [k, v] of Object.entries(boosts)) {
|
for (const [k, v] of Object.entries(boosts)) {
|
||||||
const mapped = FROM_DEX_STAT[k]
|
const mapped = FROM_DEX_STAT[k]
|
||||||
if (mapped) result[mapped] = v
|
if (mapped) result[mapped] = v
|
||||||
else result[k] = v
|
else result[k] = v
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Log Parsing ───
|
// ─── Log Parsing ───
|
||||||
|
|
||||||
function parseLogToEvents(log: string[]): BattleEvent[] {
|
function parseLogToEvents(log: string[]): BattleEvent[] {
|
||||||
const events: BattleEvent[] = []
|
const events: BattleEvent[] = []
|
||||||
const parseSide = (s: string | undefined): 'player' | 'opponent' =>
|
const parseSide = (s: string | undefined): 'player' | 'opponent' =>
|
||||||
s?.startsWith('p1a') ? 'player' : 'opponent'
|
s?.startsWith('p1a') ? 'player' : 'opponent'
|
||||||
|
|
||||||
for (const line of log) {
|
for (const line of log) {
|
||||||
const parts = line.split('|')
|
const parts = line.split('|')
|
||||||
const side = parseSide(parts[2])
|
const side = parseSide(parts[2])
|
||||||
|
|
||||||
if (line.startsWith('|move|')) {
|
if (line.startsWith('|move|')) {
|
||||||
events.push({ type: 'move', side, move: parts[3], user: parts[2] })
|
events.push({ type: 'move', side, move: parts[3], user: parts[2] })
|
||||||
} else if (line.startsWith('|-damage|')) {
|
} else if (line.startsWith('|-damage|')) {
|
||||||
const [cur, max] = parseHpString(parts[3])
|
const [cur, max] = parseHpString(parts[3])
|
||||||
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
|
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
|
||||||
} else if (line.startsWith('|-heal|')) {
|
} else if (line.startsWith('|-heal|')) {
|
||||||
const [cur, max] = parseHpString(parts[3])
|
const [cur, max] = parseHpString(parts[3])
|
||||||
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
|
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
|
||||||
} else if (line.startsWith('|faint|')) {
|
} else if (line.startsWith('|faint|')) {
|
||||||
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
|
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
|
||||||
} else if (line.startsWith('|switch|')) {
|
} else if (line.startsWith('|switch|')) {
|
||||||
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
|
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
|
||||||
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
|
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
|
||||||
} else if (line.startsWith('|-supereffective|')) {
|
} else if (line.startsWith('|-supereffective|')) {
|
||||||
events.push({ type: 'effectiveness', multiplier: 2 })
|
events.push({ type: 'effectiveness', multiplier: 2 })
|
||||||
} else if (line.startsWith('|-resisted|')) {
|
} else if (line.startsWith('|-resisted|')) {
|
||||||
events.push({ type: 'effectiveness', multiplier: 0.5 })
|
events.push({ type: 'effectiveness', multiplier: 0.5 })
|
||||||
} else if (line.startsWith('|-crit|')) {
|
} else if (line.startsWith('|-crit|')) {
|
||||||
events.push({ type: 'crit' })
|
events.push({ type: 'crit' })
|
||||||
} else if (line.startsWith('|-miss|')) {
|
} else if (line.startsWith('|-miss|')) {
|
||||||
events.push({ type: 'miss', side })
|
events.push({ type: 'miss', side })
|
||||||
} else if (line.startsWith('|-status|')) {
|
} else if (line.startsWith('|-status|')) {
|
||||||
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
|
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
|
||||||
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
|
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
|
||||||
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
|
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
|
||||||
events.push({ type: 'statChange', side, stat: parts[3], stages })
|
events.push({ type: 'statChange', side, stat: parts[3], stages })
|
||||||
} else if (line.startsWith('|-ability|')) {
|
} else if (line.startsWith('|-ability|')) {
|
||||||
events.push({ type: 'ability', side, ability: parts[3] })
|
events.push({ type: 'ability', side, ability: parts[3] })
|
||||||
} else if (line.startsWith('|turn|')) {
|
} else if (line.startsWith('|turn|')) {
|
||||||
events.push({ type: 'turn', number: parseInt(parts[2]) })
|
events.push({ type: 'turn', number: parseInt(parts[2]) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHpString(hpStr: string): [number, number] {
|
function parseHpString(hpStr: string): [number, number] {
|
||||||
if (!hpStr) return [0, 1]
|
if (!hpStr) return [0, 1]
|
||||||
// Remove status suffix like "[1]"
|
// Remove status suffix like "[1]"
|
||||||
const clean = hpStr.replace(/\[.*\]/, '')
|
const clean = hpStr.replace(/\[.*\]/, '')
|
||||||
const parts = clean.split('/')
|
const parts = clean.split('/')
|
||||||
if (parts.length !== 2) return [0, 1]
|
if (parts.length !== 2) return [0, 1]
|
||||||
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
|
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Engine ───
|
// ─── Engine ───
|
||||||
|
|
||||||
export type BattleInit = {
|
export type BattleInit = {
|
||||||
battle: any // @pkmn/sim Battle instance
|
battle: any // @pkmn/sim Battle instance
|
||||||
state: BattleState
|
state: BattleState
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBattle(
|
export function createBattle(
|
||||||
partyCreatures: Creature[],
|
partyCreatures: Creature[],
|
||||||
opponentSpeciesId: SpeciesId,
|
opponentSpeciesId: SpeciesId,
|
||||||
opponentLevel: number,
|
opponentLevel: number,
|
||||||
_bagItems?: { id: string; count: number }[],
|
_bagItems?: { id: string; count: number }[],
|
||||||
): BattleInit {
|
): BattleInit {
|
||||||
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
||||||
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
|
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
|
||||||
|
|
||||||
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
||||||
const p2Team = Teams.import(p2Set)
|
const p2Team = Teams.import(p2Set)
|
||||||
|
|
||||||
// Create battle
|
// Create battle
|
||||||
const battle = new Battle({
|
const battle = new Battle({
|
||||||
formatid: 'gen9customgame' as any,
|
formatid: 'gen9customgame' as any,
|
||||||
p1: { name: 'Player', team: p1Team },
|
p1: { name: 'Player', team: p1Team },
|
||||||
p2: { name: 'Opponent', team: p2Team },
|
p2: { name: 'Opponent', team: p2Team },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle team preview → auto-select leads
|
// Handle team preview → auto-select leads
|
||||||
battle.makeChoices('team 1', 'team 1')
|
battle.makeChoices('team 1', 'team 1')
|
||||||
|
|
||||||
// Project initial state
|
// Project initial state
|
||||||
const state = projectState(battle, _bagItems)
|
const state = projectState(battle, _bagItems)
|
||||||
return { battle, state }
|
return { battle, state }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function executeTurn(
|
export function executeTurn(
|
||||||
battleInit: BattleInit,
|
battleInit: BattleInit,
|
||||||
action: PlayerAction,
|
action: PlayerAction,
|
||||||
): BattleState {
|
): BattleState {
|
||||||
const { battle } = battleInit
|
const { battle } = battleInit
|
||||||
const prevLogLen = battle.log.length
|
const prevLogLen = battle.log.length
|
||||||
|
|
||||||
// Build choice string
|
// Build choice string
|
||||||
let p1Choice: string
|
let p1Choice: string
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'move':
|
case 'move':
|
||||||
p1Choice = `move ${action.moveIndex + 1}`
|
p1Choice = `move ${action.moveIndex + 1}`
|
||||||
break
|
break
|
||||||
case 'switch': {
|
case 'switch': {
|
||||||
// Find the party slot number for this creature (sim uses 1-based index)
|
// Find the party slot number for this creature (sim uses 1-based index)
|
||||||
const p1Pokemon: any[] = battle.p1.pokemon
|
const p1Pokemon: any[] = battle.p1.pokemon
|
||||||
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
|
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
|
||||||
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
|
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'item':
|
case 'item':
|
||||||
p1Choice = 'move 1' // Items handled via settlement
|
p1Choice = 'move 1' // Items handled via settlement
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
p1Choice = 'move 1'
|
p1Choice = 'move 1'
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI choice
|
// AI choice
|
||||||
const aiPokemon = projectPokemon(battle.p2.active[0])
|
const aiPokemon = projectPokemon(battle.p2.active[0])
|
||||||
const aiMoveIndex = chooseAIMove(aiPokemon)
|
const aiMoveIndex = chooseAIMove(aiPokemon)
|
||||||
const p2Choice = `move ${aiMoveIndex + 1}`
|
const p2Choice = `move ${aiMoveIndex + 1}`
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
battle.makeChoices(p1Choice, p2Choice)
|
battle.makeChoices(p1Choice, p2Choice)
|
||||||
|
|
||||||
// Parse new log entries
|
// Parse new log entries
|
||||||
const newLog = battle.log.slice(prevLogLen)
|
const newLog = battle.log.slice(prevLogLen)
|
||||||
const newEvents = parseLogToEvents(newLog)
|
const newEvents = parseLogToEvents(newLog)
|
||||||
|
|
||||||
// Project new state
|
// Project new state
|
||||||
const state = projectState(battle, battleInit.state.usableItems)
|
const state = projectState(battle, battleInit.state.usableItems)
|
||||||
state.events = [...battleInit.state.events, ...newEvents]
|
state.events = [...battleInit.state.events, ...newEvents]
|
||||||
|
|
||||||
// Check for battle end
|
// Check for battle end
|
||||||
if (battle.ended) {
|
if (battle.ended) {
|
||||||
state.finished = true
|
state.finished = true
|
||||||
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
|
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
|
||||||
state.result = {
|
state.result = {
|
||||||
winner,
|
winner,
|
||||||
turns: state.turn,
|
turns: state.turn,
|
||||||
xpGained: 0, // calculated in settlement
|
xpGained: 0, // calculated in settlement
|
||||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
participantIds: [],
|
participantIds: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
battleInit.state = state
|
battleInit.state = state
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
|
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
|
||||||
const p1 = battle.p1
|
const p1 = battle.p1
|
||||||
const p2 = battle.p2
|
const p2 = battle.p2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerPokemon: projectPokemon(p1.active[0]),
|
playerPokemon: projectPokemon(p1.active[0]),
|
||||||
opponentPokemon: projectPokemon(p2.active[0]),
|
opponentPokemon: projectPokemon(p2.active[0]),
|
||||||
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
|
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
|
||||||
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
|
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
|
||||||
turn: battle.turn ?? 1,
|
turn: battle.turn ?? 1,
|
||||||
events: [],
|
events: [],
|
||||||
finished: battle.ended,
|
finished: battle.ended,
|
||||||
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
|
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 type { StatName, SpeciesId } from '../types'
|
||||||
import { STAT_NAMES } 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 { BattleResult } from './types'
|
||||||
import type { BuddyData } from '../types'
|
import type { BuddyData } from '../types'
|
||||||
import { levelFromXp } from '../data/xpTable'
|
import { levelFromXp } from '../dex/xpTable'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
|
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
|
||||||
import { Dex } from '@pkmn/sim'
|
import { Dex } from '@pkmn/sim'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
|
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
|
||||||
*/
|
*/
|
||||||
export async function settleBattle(
|
export async function settleBattle(
|
||||||
data: BuddyData,
|
data: BuddyData,
|
||||||
result: BattleResult,
|
result: BattleResult,
|
||||||
opponentSpeciesId: SpeciesId,
|
opponentSpeciesId: SpeciesId,
|
||||||
opponentLevel: number,
|
opponentLevel: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: BuddyData
|
data: BuddyData
|
||||||
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
|
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
|
||||||
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
|
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
|
||||||
}> {
|
}> {
|
||||||
if (result.winner !== 'player') {
|
if (result.winner !== 'player') {
|
||||||
return { data, learnableMoves: [], pendingEvolutions: [] }
|
return { data, learnableMoves: [], pendingEvolutions: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate XP reward (simplified: base XP from species)
|
// Calculate XP reward (simplified: base XP from species)
|
||||||
const oppSpecies = Dex.species.get(opponentSpeciesId)
|
const oppSpecies = Dex.species.get(opponentSpeciesId)
|
||||||
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
|
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
|
||||||
const xpGained = Math.max(1, Math.floor(baseXp))
|
const xpGained = Math.max(1, Math.floor(baseXp))
|
||||||
|
|
||||||
// Calculate EV reward
|
// Calculate EV reward
|
||||||
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
|
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
|
||||||
const evYield = getEvYield(opponentSpeciesId)
|
const evYield = getEvYield(opponentSpeciesId)
|
||||||
for (const stat of STAT_NAMES) {
|
for (const stat of STAT_NAMES) {
|
||||||
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
|
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Award XP/EV to participant creatures
|
// Award XP/EV to participant creatures
|
||||||
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
|
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
|
||||||
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
|
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 participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
|
||||||
|
|
||||||
const updatedCreatures: typeof data.creatures = []
|
const updatedCreatures: typeof data.creatures = []
|
||||||
for (const creature of data.creatures) {
|
for (const creature of data.creatures) {
|
||||||
if (!participantIds.has(creature.id)) {
|
if (!participantIds.has(creature.id)) {
|
||||||
updatedCreatures.push(creature)
|
updatedCreatures.push(creature)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Award EVs (capped)
|
// Award EVs (capped)
|
||||||
const newEv = { ...creature.ev }
|
const newEv = { ...creature.ev }
|
||||||
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
|
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
|
||||||
for (const stat of STAT_NAMES) {
|
for (const stat of STAT_NAMES) {
|
||||||
if (totalEV >= MAX_EV_TOTAL) break
|
if (totalEV >= MAX_EV_TOTAL) break
|
||||||
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
|
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
|
||||||
newEv[stat] += gain
|
newEv[stat] += gain
|
||||||
totalEV += gain
|
totalEV += gain
|
||||||
}
|
}
|
||||||
|
|
||||||
// Award XP
|
// Award XP
|
||||||
const oldLevel = creature.level
|
const oldLevel = creature.level
|
||||||
const newTotalXp = creature.totalXp + xpGained
|
const newTotalXp = creature.totalXp + xpGained
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
|
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
|
||||||
|
|
||||||
// Detect new learnable moves on level up
|
// Detect new learnable moves on level up
|
||||||
if (newLevel > oldLevel) {
|
if (newLevel > oldLevel) {
|
||||||
const learnset = await Dex.learnsets.get(creature.speciesId)
|
const learnset = await Dex.learnsets.get(creature.speciesId)
|
||||||
if (learnset?.learnset) {
|
if (learnset?.learnset) {
|
||||||
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
|
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
|
||||||
for (const src of sources as string[]) {
|
for (const src of sources as string[]) {
|
||||||
if (src.startsWith('9L')) {
|
if (src.startsWith('9L')) {
|
||||||
const moveLevel = parseInt(src.slice(2))
|
const moveLevel = parseInt(src.slice(2))
|
||||||
if (moveLevel > oldLevel && moveLevel <= newLevel) {
|
if (moveLevel > oldLevel && moveLevel <= newLevel) {
|
||||||
const dexMove = Dex.moves.get(moveId)
|
const dexMove = Dex.moves.get(moveId)
|
||||||
learnableMoves.push({
|
learnableMoves.push({
|
||||||
creatureId: creature.id,
|
creatureId: creature.id,
|
||||||
moveId,
|
moveId,
|
||||||
moveName: dexMove?.name ?? moveId,
|
moveName: dexMove?.name ?? moveId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect evolution
|
// Detect evolution
|
||||||
if (newLevel > oldLevel) {
|
if (newLevel > oldLevel) {
|
||||||
const species = Dex.species.get(creature.speciesId)
|
const species = Dex.species.get(creature.speciesId)
|
||||||
if (species?.evos?.length) {
|
if (species?.evos?.length) {
|
||||||
const targetId = species.evos[0]!.toLowerCase()
|
const targetId = species.evos[0]!.toLowerCase()
|
||||||
const target = Dex.species.get(targetId)
|
const target = Dex.species.get(targetId)
|
||||||
if (target?.evoLevel && newLevel >= target.evoLevel) {
|
if (target?.evoLevel && newLevel >= target.evoLevel) {
|
||||||
pendingEvolutions.push({
|
pendingEvolutions.push({
|
||||||
creatureId: creature.id,
|
creatureId: creature.id,
|
||||||
from: creature.speciesId,
|
from: creature.speciesId,
|
||||||
to: targetId as SpeciesId,
|
to: targetId as SpeciesId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedCreatures.push({
|
updatedCreatures.push({
|
||||||
...creature,
|
...creature,
|
||||||
level: newLevel,
|
level: newLevel,
|
||||||
totalXp: newTotalXp,
|
totalXp: newTotalXp,
|
||||||
ev: newEv,
|
ev: newEv,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update data
|
// Update data
|
||||||
const updatedData: BuddyData = {
|
const updatedData: BuddyData = {
|
||||||
...data,
|
...data,
|
||||||
creatures: updatedCreatures,
|
creatures: updatedCreatures,
|
||||||
stats: {
|
stats: {
|
||||||
...data.stats,
|
...data.stats,
|
||||||
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
|
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
|
||||||
battlesLost: data.stats.battlesLost + (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.
|
* Apply move learning - replace a move at the given index.
|
||||||
*/
|
*/
|
||||||
export function applyMoveLearn(
|
export function applyMoveLearn(
|
||||||
data: BuddyData,
|
data: BuddyData,
|
||||||
creatureId: string,
|
creatureId: string,
|
||||||
moveId: string,
|
moveId: string,
|
||||||
replaceIndex: number,
|
replaceIndex: number,
|
||||||
): BuddyData {
|
): BuddyData {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
creatures: data.creatures.map(c => {
|
creatures: data.creatures.map(c => {
|
||||||
if (c.id !== creatureId) return c
|
if (c.id !== creatureId) return c
|
||||||
const dexMove = Dex.moves.get(moveId)
|
const dexMove = Dex.moves.get(moveId)
|
||||||
const newMoves = [...c.moves] as typeof c.moves
|
const newMoves = [...c.moves] as typeof c.moves
|
||||||
newMoves[replaceIndex] = {
|
newMoves[replaceIndex] = {
|
||||||
id: moveId,
|
id: moveId,
|
||||||
pp: dexMove?.pp ?? 10,
|
pp: dexMove?.pp ?? 10,
|
||||||
maxPp: dexMove?.pp ?? 10,
|
maxPp: dexMove?.pp ?? 10,
|
||||||
}
|
}
|
||||||
return { ...c, moves: newMoves as typeof c.moves }
|
return { ...c, moves: newMoves as typeof c.moves }
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply evolution to a creature.
|
* Apply evolution to a creature.
|
||||||
*/
|
*/
|
||||||
export function applyEvolution(
|
export function applyEvolution(
|
||||||
data: BuddyData,
|
data: BuddyData,
|
||||||
creatureId: string,
|
creatureId: string,
|
||||||
newSpeciesId: SpeciesId,
|
newSpeciesId: SpeciesId,
|
||||||
): BuddyData {
|
): BuddyData {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
creatures: data.creatures.map(c =>
|
creatures: data.creatures.map(c =>
|
||||||
c.id === creatureId
|
c.id === creatureId
|
||||||
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
|
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
stats: {
|
stats: {
|
||||||
...data.stats,
|
...data.stats,
|
||||||
totalEvolutions: data.stats.totalEvolutions + 1,
|
totalEvolutions: data.stats.totalEvolutions + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEvYield(speciesId: string): Record<string, number> {
|
function getEvYield(speciesId: string): Record<string, number> {
|
||||||
// @pkmn/sim Dex.species doesn't have evs field
|
// @pkmn/sim Dex.species doesn't have evs field
|
||||||
// Use baseStats as proxy: highest base stat gets 1-2 EVs
|
// Use baseStats as proxy: highest base stat gets 1-2 EVs
|
||||||
const species = Dex.species.get(speciesId)
|
const species = Dex.species.get(speciesId)
|
||||||
if (!species?.baseStats) return {}
|
if (!species?.baseStats) return {}
|
||||||
const stats = species.baseStats as Record<string, number>
|
const stats = species.baseStats as Record<string, number>
|
||||||
const entries = Object.entries(stats)
|
const entries = Object.entries(stats)
|
||||||
if (entries.length === 0) return {}
|
if (entries.length === 0) return {}
|
||||||
// Sort by value descending, give 1-2 EV to top stats
|
// Sort by value descending, give 1-2 EV to top stats
|
||||||
entries.sort((a, b) => b[1] - a[1])
|
entries.sort((a, b) => b[1] - a[1])
|
||||||
const result: Record<string, number> = {}
|
const result: Record<string, number> = {}
|
||||||
// Top stat gets 2 EVs, second gets 1
|
// Top stat gets 2 EVs, second gets 1
|
||||||
if (entries[0]) result[entries[0][0]] = 2
|
if (entries[0]) result[entries[0][0]] = 2
|
||||||
if (entries[1]) result[entries[1][0]] = 1
|
if (entries[1]) result[entries[1][0]] = 1
|
||||||
return result
|
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 StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'none'
|
||||||
|
|
||||||
export type BattlePokemon = {
|
export type BattlePokemon = {
|
||||||
id: string // creature ID
|
id: string // creature ID
|
||||||
speciesId: SpeciesId
|
speciesId: SpeciesId
|
||||||
name: string
|
name: string
|
||||||
level: number
|
level: number
|
||||||
hp: number // current HP in battle
|
hp: number // current HP in battle
|
||||||
maxHp: number
|
maxHp: number
|
||||||
types: string[]
|
types: string[]
|
||||||
moves: MoveOption[]
|
moves: MoveOption[]
|
||||||
ability: string
|
ability: string
|
||||||
heldItem: string | null
|
heldItem: string | null
|
||||||
status: StatusCondition
|
status: StatusCondition
|
||||||
statStages: Record<string, number> // -6 to +6
|
statStages: Record<string, number> // -6 to +6
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MoveOption = {
|
export type MoveOption = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
pp: number
|
pp: number
|
||||||
maxPp: number
|
maxPp: number
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlayerAction =
|
export type PlayerAction =
|
||||||
| { type: 'move'; moveIndex: number }
|
| { type: 'move'; moveIndex: number }
|
||||||
| { type: 'switch'; creatureId: string }
|
| { type: 'switch'; creatureId: string }
|
||||||
| { type: 'item'; itemId: string }
|
| { type: 'item'; itemId: string }
|
||||||
|
|
||||||
export type BattleEvent =
|
export type BattleEvent =
|
||||||
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
|
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
|
||||||
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
||||||
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
||||||
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
|
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
|
||||||
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
|
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
|
||||||
| { type: 'effectiveness'; multiplier: number }
|
| { type: 'effectiveness'; multiplier: number }
|
||||||
| { type: 'crit' }
|
| { type: 'crit' }
|
||||||
| { type: 'miss'; side: 'player' | 'opponent' }
|
| { type: 'miss'; side: 'player' | 'opponent' }
|
||||||
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
|
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
|
||||||
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
|
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
|
||||||
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
|
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
|
||||||
| { type: 'item'; side: 'player' | 'opponent'; item: string }
|
| { type: 'item'; side: 'player' | 'opponent'; item: string }
|
||||||
| { type: 'fail'; reason: string }
|
| { type: 'fail'; reason: string }
|
||||||
| { type: 'turn'; number: number }
|
| { type: 'turn'; number: number }
|
||||||
|
|
||||||
export type BattleResult = {
|
export type BattleResult = {
|
||||||
winner: 'player' | 'opponent'
|
winner: 'player' | 'opponent'
|
||||||
turns: number
|
turns: number
|
||||||
xpGained: number
|
xpGained: number
|
||||||
evGained: Record<StatName, number>
|
evGained: Record<StatName, number>
|
||||||
participantIds: string[]
|
participantIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BattleState = {
|
export type BattleState = {
|
||||||
playerPokemon: BattlePokemon
|
playerPokemon: BattlePokemon
|
||||||
opponentPokemon: BattlePokemon
|
opponentPokemon: BattlePokemon
|
||||||
playerParty: BattlePokemon[]
|
playerParty: BattlePokemon[]
|
||||||
opponentParty: BattlePokemon[]
|
opponentParty: BattlePokemon[]
|
||||||
turn: number
|
turn: number
|
||||||
events: BattleEvent[]
|
events: BattleEvent[]
|
||||||
finished: boolean
|
finished: boolean
|
||||||
result?: BattleResult
|
result?: BattleResult
|
||||||
usableItems: { id: string; name: string; count: number }[]
|
usableItems: { id: string; name: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
|
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
|
||||||
import { STAT_NAMES } from '../types'
|
import { STAT_NAMES } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { determineGender } from './gender'
|
import { determineGender } from './gender'
|
||||||
import { levelFromXp } from '../data/xpTable'
|
import { levelFromXp } from '../dex/xpTable'
|
||||||
import { gen, TO_DEX_STAT } from '../data/pkmn'
|
import { gen, TO_DEX_STAT } from '../dex/pkmn'
|
||||||
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
|
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
|
||||||
import { randomNature } from '../data/nature'
|
import { randomNature } from '../dex/nature'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a new creature of the given species.
|
* Generate a new creature of the given species.
|
||||||
*/
|
*/
|
||||||
export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise<Creature> {
|
export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise<Creature> {
|
||||||
const species = getSpeciesData(speciesId)
|
const species = getSpeciesData(speciesId)
|
||||||
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
|
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
|
||||||
|
|
||||||
// Generate IVs (0-31) using simple hash from seed
|
// Generate IVs (0-31) using simple hash from seed
|
||||||
const iv = generateIVs(actualSeed)
|
const iv = generateIVs(actualSeed)
|
||||||
|
|
||||||
// Determine gender
|
// Determine gender
|
||||||
const gender = determineGender(species, actualSeed & 0xff)
|
const gender = determineGender(species, actualSeed & 0xff)
|
||||||
|
|
||||||
// Determine shiny status
|
// Determine shiny status
|
||||||
const isShiny = Math.random() < species.shinyChance
|
const isShiny = Math.random() < species.shinyChance
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
speciesId,
|
speciesId,
|
||||||
gender,
|
gender,
|
||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
totalXp: 0,
|
totalXp: 0,
|
||||||
nature: randomNature(),
|
nature: randomNature(),
|
||||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||||
iv,
|
iv,
|
||||||
moves: await getDefaultMoveset(speciesId, 1),
|
moves: await getDefaultMoveset(speciesId, 1),
|
||||||
ability: getDefaultAbility(speciesId),
|
ability: getDefaultAbility(speciesId),
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
friendship: species.baseHappiness,
|
friendship: species.baseHappiness,
|
||||||
isShiny,
|
isShiny,
|
||||||
hatchedAt: Date.now(),
|
hatchedAt: Date.now(),
|
||||||
pokeball: 'pokeball',
|
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.
|
* Handles base stats, IV, EV, level, and nature correction internally.
|
||||||
*/
|
*/
|
||||||
export function calculateStats(creature: Creature): StatsResult {
|
export function calculateStats(creature: Creature): StatsResult {
|
||||||
const species = gen.species.get(creature.speciesId)
|
const species = gen.species.get(creature.speciesId)
|
||||||
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
||||||
|
|
||||||
// Get nature if creature has one (Phase 1 adds nature field)
|
// Get nature if creature has one (Phase 1 adds nature field)
|
||||||
const nature = 'nature' in creature && creature.nature
|
const nature = 'nature' in creature && creature.nature
|
||||||
? gen.natures.get(creature.nature as string)
|
? gen.natures.get(creature.nature as string)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const result = {} as StatsResult
|
const result = {} as StatsResult
|
||||||
for (const stat of STAT_NAMES) {
|
for (const stat of STAT_NAMES) {
|
||||||
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
|
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
|
||||||
result[stat] = gen.stats.calc(
|
result[stat] = gen.stats.calc(
|
||||||
dexKey,
|
dexKey,
|
||||||
species.baseStats[dexKey],
|
species.baseStats[dexKey],
|
||||||
creature.iv[stat],
|
creature.iv[stat],
|
||||||
creature.ev[stat],
|
creature.ev[stat],
|
||||||
creature.level,
|
creature.level,
|
||||||
nature ?? undefined,
|
nature ?? undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get display name for a creature (nickname or species name).
|
* Get display name for a creature (nickname or species name).
|
||||||
*/
|
*/
|
||||||
export function getCreatureName(creature: Creature): string {
|
export function getCreatureName(creature: Creature): string {
|
||||||
if (creature.nickname) return creature.nickname
|
if (creature.nickname) return creature.nickname
|
||||||
return getSpeciesData(creature.speciesId).name
|
return getSpeciesData(creature.speciesId).name
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recalculate level from total XP (e.g. after XP gain).
|
* Recalculate level from total XP (e.g. after XP gain).
|
||||||
*/
|
*/
|
||||||
export function recalculateLevel(creature: Creature): Creature {
|
export function recalculateLevel(creature: Creature): Creature {
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
|
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
|
||||||
if (newLevel !== creature.level) {
|
if (newLevel !== creature.level) {
|
||||||
return { ...creature, level: newLevel }
|
return { ...creature, level: newLevel }
|
||||||
}
|
}
|
||||||
return creature
|
return creature
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,33 +97,33 @@ export function recalculateLevel(creature: Creature): Creature {
|
|||||||
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
|
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
|
||||||
*/
|
*/
|
||||||
export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
|
export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
|
||||||
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
|
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
|
||||||
if (!activeId) return null
|
if (!activeId) return null
|
||||||
return buddyData.creatures.find((c) => c.id === activeId) ?? null
|
return buddyData.creatures.find((c) => c.id === activeId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate IVs from a seed value. Each stat gets 0-31.
|
* Generate IVs from a seed value. Each stat gets 0-31.
|
||||||
*/
|
*/
|
||||||
function generateIVs(seed: number): Record<StatName, number> {
|
function generateIVs(seed: number): Record<StatName, number> {
|
||||||
let s = seed
|
let s = seed
|
||||||
const nextRand = () => {
|
const nextRand = () => {
|
||||||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
hp: nextRand() % 32,
|
hp: nextRand() % 32,
|
||||||
attack: nextRand() % 32,
|
attack: nextRand() % 32,
|
||||||
defense: nextRand() % 32,
|
defense: nextRand() % 32,
|
||||||
spAtk: nextRand() % 32,
|
spAtk: nextRand() % 32,
|
||||||
spDef: nextRand() % 32,
|
spDef: nextRand() % 32,
|
||||||
speed: nextRand() % 32,
|
speed: nextRand() % 32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total EV across all stats.
|
* Get total EV across all stats.
|
||||||
*/
|
*/
|
||||||
export function getTotalEV(creature: Creature): number {
|
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 type { Creature, StatName } from '../types'
|
||||||
import { STAT_NAMES } 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'
|
import { getTotalEV } from './creature'
|
||||||
|
|
||||||
// Track last EV award time per tool to enforce cooldown
|
// 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).
|
* Reset EV cooldown state (for testing).
|
||||||
*/
|
*/
|
||||||
export function resetEVCooldowns(): void {
|
export function resetEVCooldowns(): void {
|
||||||
evCooldowns.clear()
|
evCooldowns.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,35 +18,35 @@ export function resetEVCooldowns(): void {
|
|||||||
* Returns updated creature and actual EV awarded.
|
* Returns updated creature and actual EV awarded.
|
||||||
*/
|
*/
|
||||||
export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature {
|
export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature {
|
||||||
const now = timestamp ?? Date.now()
|
const now = timestamp ?? Date.now()
|
||||||
|
|
||||||
// Check cooldown
|
// Check cooldown
|
||||||
const lastTime = evCooldowns.get(toolName)
|
const lastTime = evCooldowns.get(toolName)
|
||||||
if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature
|
if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature
|
||||||
|
|
||||||
const currentTotal = getTotalEV(creature)
|
const currentTotal = getTotalEV(creature)
|
||||||
if (currentTotal >= MAX_EV_TOTAL) return creature
|
if (currentTotal >= MAX_EV_TOTAL) return creature
|
||||||
|
|
||||||
let evGains = getEVForTool(toolName)
|
let evGains = getEVForTool(toolName)
|
||||||
if (!evGains) {
|
if (!evGains) {
|
||||||
// Random EV for unmapped tools
|
// Random EV for unmapped tools
|
||||||
evGains = generateRandomEV()
|
evGains = generateRandomEV()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...creature, ev: { ...creature.ev } }
|
const updated = { ...creature, ev: { ...creature.ev } }
|
||||||
for (const stat of STAT_NAMES) {
|
for (const stat of STAT_NAMES) {
|
||||||
const gain = evGains[stat]
|
const gain = evGains[stat]
|
||||||
if (gain > 0) {
|
if (gain > 0) {
|
||||||
const current = updated.ev[stat]
|
const current = updated.ev[stat]
|
||||||
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
|
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
|
||||||
if (canAdd > 0) {
|
if (canAdd > 0) {
|
||||||
updated.ev[stat] = current + canAdd
|
updated.ev[stat] = current + canAdd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
evCooldowns.set(toolName, now)
|
evCooldowns.set(toolName, now)
|
||||||
return updated
|
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.
|
* Deduplicates tool names and spaces timestamps to avoid cooldown issues.
|
||||||
*/
|
*/
|
||||||
export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature {
|
export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature {
|
||||||
const uniqueTools = [...new Set(toolNames)]
|
const uniqueTools = [...new Set(toolNames)]
|
||||||
const baseTime = timestamp ?? Date.now()
|
const baseTime = timestamp ?? Date.now()
|
||||||
let current = creature
|
let current = creature
|
||||||
for (let i = 0; i < uniqueTools.length; i++) {
|
for (let i = 0; i < uniqueTools.length; i++) {
|
||||||
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
|
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate random 1-2 EV points in a random stat.
|
* Generate random 1-2 EV points in a random stat.
|
||||||
*/
|
*/
|
||||||
function generateRandomEV(): Record<StatName, number> {
|
function generateRandomEV(): Record<StatName, number> {
|
||||||
const stats = [...STAT_NAMES]
|
const stats = [...STAT_NAMES]
|
||||||
const stat = stats[Math.floor(Math.random() * stats.length)]
|
const stat = stats[Math.floor(Math.random() * stats.length)]
|
||||||
const amount = Math.random() < 0.5 ? 1 : 2
|
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 }
|
const result: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
|
||||||
result[stat] = amount
|
result[stat] = amount
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get formatted EV summary string.
|
* Get formatted EV summary string.
|
||||||
*/
|
*/
|
||||||
export function getEVSummary(creature: Creature): string {
|
export function getEVSummary(creature: Creature): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
for (const stat of STAT_NAMES) {
|
for (const stat of STAT_NAMES) {
|
||||||
const val = creature.ev[stat]
|
const val = creature.ev[stat]
|
||||||
if (val > 0) {
|
if (val > 0) {
|
||||||
const labels: Record<StatName, string> = {
|
const labels: Record<StatName, string> = {
|
||||||
hp: 'HP',
|
hp: 'HP',
|
||||||
attack: 'ATK',
|
attack: 'ATK',
|
||||||
defense: 'DEF',
|
defense: 'DEF',
|
||||||
spAtk: 'SPA',
|
spAtk: 'SPA',
|
||||||
spDef: 'SPD',
|
spDef: 'SPD',
|
||||||
speed: 'SPE',
|
speed: 'SPE',
|
||||||
}
|
}
|
||||||
parts.push(`${labels[stat]}+${val}`)
|
parts.push(`${labels[stat]}+${val}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parts.join(' ') || 'None'
|
return parts.join(' ') || 'None'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
|
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { generateCreature } from './creature'
|
import { generateCreature } from './creature'
|
||||||
import { addToParty, depositToBox } from './storage'
|
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
|
* Conditions: consecutiveDays >= EGG_REQUIRED_DAYS AND totalTurns % 50 === 0 AND eggs.length < 1
|
||||||
*/
|
*/
|
||||||
export function checkEggEligibility(buddyData: BuddyData): boolean {
|
export function checkEggEligibility(buddyData: BuddyData): boolean {
|
||||||
if (buddyData.eggs.length >= 1) return false
|
if (buddyData.eggs.length >= 1) return false
|
||||||
if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false
|
if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false
|
||||||
if (buddyData.stats.totalTurns % 50 !== 0) return false
|
if (buddyData.stats.totalTurns % 50 !== 0) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,27 +24,27 @@ export function checkEggEligibility(buddyData: BuddyData): boolean {
|
|||||||
* Priority: uncollected species > random from all species.
|
* Priority: uncollected species > random from all species.
|
||||||
*/
|
*/
|
||||||
export function generateEgg(buddyData: BuddyData): Egg {
|
export function generateEgg(buddyData: BuddyData): Egg {
|
||||||
// Find uncollected species
|
// Find uncollected species
|
||||||
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
|
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
|
||||||
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
|
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
|
||||||
|
|
||||||
// Pick species (prefer uncollected, fall back to random starter)
|
// Pick species (prefer uncollected, fall back to random starter)
|
||||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
|
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
|
||||||
const speciesId = uncollected.length > 0
|
const speciesId = uncollected.length > 0
|
||||||
? uncollected[Math.floor(Math.random() * uncollected.length)]
|
? uncollected[Math.floor(Math.random() * uncollected.length)]
|
||||||
: starters[Math.floor(Math.random() * starters.length)]
|
: starters[Math.floor(Math.random() * starters.length)]
|
||||||
|
|
||||||
// Steps based on rarity (capture rate: lower = rarer = more steps)
|
// Steps based on rarity (capture rate: lower = rarer = more steps)
|
||||||
const species = getSpeciesData(speciesId)
|
const species = getSpeciesData(speciesId)
|
||||||
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
|
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
obtainedAt: Date.now(),
|
obtainedAt: Date.now(),
|
||||||
stepsRemaining: baseSteps,
|
stepsRemaining: baseSteps,
|
||||||
totalSteps: baseSteps,
|
totalSteps: baseSteps,
|
||||||
speciesId,
|
speciesId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,15 +52,15 @@ export function generateEgg(buddyData: BuddyData): Egg {
|
|||||||
* Returns updated egg or null if egg hatched.
|
* Returns updated egg or null if egg hatched.
|
||||||
*/
|
*/
|
||||||
export function advanceEggSteps(egg: Egg, steps: number): Egg {
|
export function advanceEggSteps(egg: Egg, steps: number): Egg {
|
||||||
const newSteps = Math.max(0, egg.stepsRemaining - steps)
|
const newSteps = Math.max(0, egg.stepsRemaining - steps)
|
||||||
return { ...egg, stepsRemaining: newSteps }
|
return { ...egg, stepsRemaining: newSteps }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an egg is ready to hatch.
|
* Check if an egg is ready to hatch.
|
||||||
*/
|
*/
|
||||||
export function isEggReadyToHatch(egg: Egg): boolean {
|
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.
|
* Tries to add to party first, then deposits to PC box.
|
||||||
*/
|
*/
|
||||||
export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> {
|
export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> {
|
||||||
const creature = await generateCreature(egg.speciesId)
|
const creature = await generateCreature(egg.speciesId)
|
||||||
creature.hatchedAt = Date.now()
|
creature.hatchedAt = Date.now()
|
||||||
|
|
||||||
// Add creature to list
|
// Add creature to list
|
||||||
let updatedData: BuddyData = {
|
let updatedData: BuddyData = {
|
||||||
...buddyData,
|
...buddyData,
|
||||||
creatures: [...buddyData.creatures, creature],
|
creatures: [...buddyData.creatures, creature],
|
||||||
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
|
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
|
||||||
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
|
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
|
||||||
stats: {
|
stats: {
|
||||||
...buddyData.stats,
|
...buddyData.stats,
|
||||||
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
|
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place in party or PC box
|
// Place in party or PC box
|
||||||
const partyResult = addToParty(updatedData, creature.id)
|
const partyResult = addToParty(updatedData, creature.id)
|
||||||
if (partyResult.added) {
|
if (partyResult.added) {
|
||||||
updatedData = partyResult.data
|
updatedData = partyResult.data
|
||||||
} else {
|
} else {
|
||||||
const boxResult = depositToBox(updatedData, creature.id)
|
const boxResult = depositToBox(updatedData, creature.id)
|
||||||
if (boxResult.deposited) updatedData = boxResult.data
|
if (boxResult.deposited) updatedData = boxResult.data
|
||||||
}
|
}
|
||||||
|
|
||||||
return { buddyData: updatedData, creature }
|
return { buddyData: updatedData, creature }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update or create a dex entry for a species.
|
* Update or create a dex entry for a species.
|
||||||
*/
|
*/
|
||||||
function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] {
|
function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] {
|
||||||
const existing = dex.find((d) => d.speciesId === speciesId)
|
const existing = dex.find((d) => d.speciesId === speciesId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return dex.map((d) =>
|
return dex.map((d) =>
|
||||||
d.speciesId === speciesId
|
d.speciesId === speciesId
|
||||||
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
|
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
|
||||||
: d,
|
: d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
|
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import type { Creature, EvolutionResult, SpeciesId } from '../types'
|
import type { Creature, EvolutionResult, SpeciesId } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { getNextEvolution } from '../data/evolution'
|
import { getNextEvolution } from '../dex/evolution'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a creature meets evolution conditions.
|
* Check if a creature meets evolution conditions.
|
||||||
* Returns the evolution result if evolution should occur, null otherwise.
|
* Returns the evolution result if evolution should occur, null otherwise.
|
||||||
*/
|
*/
|
||||||
export function checkEvolution(creature: Creature): EvolutionResult | null {
|
export function checkEvolution(creature: Creature): EvolutionResult | null {
|
||||||
if (creature.level > 100) return null
|
if (creature.level > 100) return null
|
||||||
|
|
||||||
const nextEvo = getNextEvolution(creature.speciesId)
|
const nextEvo = getNextEvolution(creature.speciesId)
|
||||||
if (!nextEvo) return null
|
if (!nextEvo) return null
|
||||||
|
|
||||||
// Check level-up conditions
|
// Check level-up conditions
|
||||||
if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) {
|
if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) {
|
||||||
return {
|
return {
|
||||||
from: creature.speciesId,
|
from: creature.speciesId,
|
||||||
to: nextEvo.to,
|
to: nextEvo.to,
|
||||||
newLevel: creature.level,
|
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.
|
* Returns the updated creature with new species and recalculated data.
|
||||||
*/
|
*/
|
||||||
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
|
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
|
||||||
const newSpecies = getSpeciesData(targetSpeciesId)
|
const newSpecies = getSpeciesData(targetSpeciesId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...creature,
|
...creature,
|
||||||
speciesId: targetSpeciesId,
|
speciesId: targetSpeciesId,
|
||||||
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
|
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a species can evolve further.
|
* Check if a species can evolve further.
|
||||||
*/
|
*/
|
||||||
export function canEvolveFurther(speciesId: SpeciesId): boolean {
|
export function canEvolveFurther(speciesId: SpeciesId): boolean {
|
||||||
return getNextEvolution(speciesId) !== undefined
|
return getNextEvolution(speciesId) !== undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import type { Creature } from '../types'
|
import type { Creature } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { levelFromXp, xpForLevel } from '../data/xpTable'
|
import { levelFromXp, xpForLevel } from '../dex/xpTable'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Award XP to a creature. Returns updated creature and whether level up occurred.
|
* 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 } {
|
export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } {
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
if (creature.level >= 100) {
|
if (creature.level >= 100) {
|
||||||
return { creature, leveledUp: false, newLevel: creature.level }
|
return { creature, leveledUp: false, newLevel: creature.level }
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTotalXp = creature.totalXp + amount
|
const newTotalXp = creature.totalXp + amount
|
||||||
const oldLevel = creature.level
|
const oldLevel = creature.level
|
||||||
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
|
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
|
||||||
|
|
||||||
// XP progress within current level
|
// XP progress within current level
|
||||||
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
|
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
|
||||||
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
|
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
|
||||||
const xp = newTotalXp - currentLevelXp
|
const xp = newTotalXp - currentLevelXp
|
||||||
|
|
||||||
const updated: Creature = {
|
const updated: Creature = {
|
||||||
...creature,
|
...creature,
|
||||||
totalXp: newTotalXp,
|
totalXp: newTotalXp,
|
||||||
xp: Math.max(0, xp),
|
xp: Math.max(0, xp),
|
||||||
level: newLevel,
|
level: newLevel,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
creature: updated,
|
creature: updated,
|
||||||
leveledUp: newLevel > oldLevel,
|
leveledUp: newLevel > oldLevel,
|
||||||
newLevel,
|
newLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get XP needed to reach next level from current state.
|
* Get XP needed to reach next level from current state.
|
||||||
*/
|
*/
|
||||||
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
|
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
|
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
|
||||||
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
|
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
|
||||||
const needed = nextLevelXp - currentLevelXp
|
const needed = nextLevelXp - currentLevelXp
|
||||||
const current = creature.totalXp - currentLevelXp
|
const current = creature.totalXp - currentLevelXp
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: Math.max(0, current),
|
current: Math.max(0, current),
|
||||||
needed,
|
needed,
|
||||||
percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100,
|
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
|
* genderRate: -1 = genderless, 0 = always male, 1-7 = female chance = genderRate/8, 8 = always female
|
||||||
*/
|
*/
|
||||||
export function determineGender(speciesData: SpeciesData, seed: number): Gender {
|
export function determineGender(speciesData: SpeciesData, seed: number): Gender {
|
||||||
if (speciesData.genderRate === -1) return 'genderless'
|
if (speciesData.genderRate === -1) return 'genderless'
|
||||||
if (speciesData.genderRate === 0) return 'male'
|
if (speciesData.genderRate === 0) return 'male'
|
||||||
if (speciesData.genderRate === 8) return 'female'
|
if (speciesData.genderRate === 8) return 'female'
|
||||||
// Use seed value (0-255) to determine gender
|
// Use seed value (0-255) to determine gender
|
||||||
const threshold = (speciesData.genderRate / 8) * 256
|
const threshold = (speciesData.genderRate / 8) * 256
|
||||||
return (seed % 256) < threshold ? 'female' : 'male'
|
return (seed % 256) < threshold ? 'female' : 'male'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get gender symbol for display */
|
/** Get gender symbol for display */
|
||||||
export function getGenderSymbol(gender: Gender): string {
|
export function getGenderSymbol(gender: Gender): string {
|
||||||
switch (gender) {
|
switch (gender) {
|
||||||
case 'male':
|
case 'male':
|
||||||
return '♂'
|
return '♂'
|
||||||
case 'female':
|
case 'female':
|
||||||
return '♀'
|
return '♀'
|
||||||
case 'genderless':
|
case 'genderless':
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { SpeciesId, SpriteCache } from '../types'
|
import type { SpeciesId, SpriteCache } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { getSpritesDir } from './storage'
|
import { getSpritesDir } from './storage'
|
||||||
|
|
||||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
|
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
|
||||||
|
|
||||||
/** Mapping of speciesId to cow file prefix */
|
/** Mapping of speciesId to cow file prefix */
|
||||||
const COW_FILE_MAP: Record<SpeciesId, string> = {
|
const COW_FILE_MAP: Record<SpeciesId, string> = {
|
||||||
bulbasaur: '001_bulbasaur',
|
bulbasaur: '001_bulbasaur',
|
||||||
ivysaur: '002_ivysaur',
|
ivysaur: '002_ivysaur',
|
||||||
venusaur: '003_venusaur',
|
venusaur: '003_venusaur',
|
||||||
charmander: '004_charmander',
|
charmander: '004_charmander',
|
||||||
charmeleon: '005_charmeleon',
|
charmeleon: '005_charmeleon',
|
||||||
charizard: '006_charizard',
|
charizard: '006_charizard',
|
||||||
squirtle: '007_squirtle',
|
squirtle: '007_squirtle',
|
||||||
wartortle: '008_wartortle',
|
wartortle: '008_wartortle',
|
||||||
blastoise: '009_blastoise',
|
blastoise: '009_blastoise',
|
||||||
pikachu: '025_pikachu',
|
pikachu: '025_pikachu',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load sprite from local cache. Returns null if not cached.
|
* Load sprite from local cache. Returns null if not cached.
|
||||||
*/
|
*/
|
||||||
export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
|
export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
|
||||||
const spritesDir = getSpritesDir()
|
const spritesDir = getSpritesDir()
|
||||||
const filePath = join(spritesDir, `${speciesId}.json`)
|
const filePath = join(spritesDir, `${speciesId}.json`)
|
||||||
|
|
||||||
if (!existsSync(filePath)) return null
|
if (!existsSync(filePath)) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(filePath, 'utf-8')
|
const raw = readFileSync(filePath, 'utf-8')
|
||||||
return JSON.parse(raw) as SpriteCache
|
return JSON.parse(raw) as SpriteCache
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e)
|
console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,41 +43,41 @@ export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
|
|||||||
* Returns the cached sprite data, or null if fetch failed.
|
* Returns the cached sprite data, or null if fetch failed.
|
||||||
*/
|
*/
|
||||||
export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteCache | null> {
|
export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteCache | null> {
|
||||||
// Try local cache first
|
// Try local cache first
|
||||||
const cached = loadSprite(speciesId)
|
const cached = loadSprite(speciesId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const cowFileName = COW_FILE_MAP[speciesId]
|
const cowFileName = COW_FILE_MAP[speciesId]
|
||||||
if (!cowFileName) return null
|
if (!cowFileName) return null
|
||||||
|
|
||||||
const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow`
|
const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
if (!response.ok) return null
|
if (!response.ok) return null
|
||||||
|
|
||||||
const cowContent = await response.text()
|
const cowContent = await response.text()
|
||||||
const lines = convertCowToLines(cowContent)
|
const lines = convertCowToLines(cowContent)
|
||||||
if (lines.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
|
|
||||||
const sprite: SpriteCache = {
|
const sprite: SpriteCache = {
|
||||||
speciesId,
|
speciesId,
|
||||||
lines,
|
lines,
|
||||||
width: Math.max(...lines.map((l) => stripAnsi(l).length)),
|
width: Math.max(...lines.map((l) => stripAnsi(l).length)),
|
||||||
height: lines.length,
|
height: lines.length,
|
||||||
fetchedAt: Date.now(),
|
fetchedAt: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache to disk
|
// Cache to disk
|
||||||
const spritesDir = getSpritesDir()
|
const spritesDir = getSpritesDir()
|
||||||
const filePath = join(spritesDir, `${speciesId}.json`)
|
const filePath = join(spritesDir, `${speciesId}.json`)
|
||||||
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
|
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
|
||||||
|
|
||||||
return sprite
|
return sprite
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e)
|
console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,57 +85,57 @@ export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteC
|
|||||||
* Extracts heredoc content, converts Unicode escapes, strips thought lines.
|
* Extracts heredoc content, converts Unicode escapes, strips thought lines.
|
||||||
*/
|
*/
|
||||||
function convertCowToLines(cowContent: string): string[] {
|
function convertCowToLines(cowContent: string): string[] {
|
||||||
// Extract content between $the_cow =<<EOC; and EOC
|
// Extract content between $the_cow =<<EOC; and EOC
|
||||||
const startMarker = '$the_cow =<<EOC;'
|
const startMarker = '$the_cow =<<EOC;'
|
||||||
const endMarker = 'EOC'
|
const endMarker = 'EOC'
|
||||||
|
|
||||||
const startIdx = cowContent.indexOf(startMarker)
|
const startIdx = cowContent.indexOf(startMarker)
|
||||||
if (startIdx === -1) return []
|
if (startIdx === -1) return []
|
||||||
|
|
||||||
const contentStart = startIdx + startMarker.length
|
const contentStart = startIdx + startMarker.length
|
||||||
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||||
if (endIdx === -1) return []
|
if (endIdx === -1) return []
|
||||||
|
|
||||||
let content = cowContent.slice(contentStart, endIdx)
|
let content = cowContent.slice(contentStart, endIdx)
|
||||||
|
|
||||||
// Convert \N{U+XXXX} to actual Unicode characters
|
// Convert \N{U+XXXX} to actual Unicode characters
|
||||||
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||||
String.fromCodePoint(parseInt(hex, 16)),
|
String.fromCodePoint(parseInt(hex, 16)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert \e to actual escape character (for ANSI sequences)
|
// Convert \e to actual escape character (for ANSI sequences)
|
||||||
content = content.replace(/\\e/g, '\x1b')
|
content = content.replace(/\\e/g, '\x1b')
|
||||||
|
|
||||||
// Split into lines
|
// Split into lines
|
||||||
let lines = content.split('\n')
|
let lines = content.split('\n')
|
||||||
|
|
||||||
// Strip leading/trailing empty lines
|
// Strip leading/trailing empty lines
|
||||||
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
||||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
||||||
|
|
||||||
// Remove first 4 lines (cowsay thought bubble guide)
|
// Remove first 4 lines (cowsay thought bubble guide)
|
||||||
if (lines.length > 4) {
|
if (lines.length > 4) {
|
||||||
lines = lines.slice(4)
|
lines = lines.slice(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim trailing whitespace on each line (preserve leading for alignment)
|
// Trim trailing whitespace on each line (preserve leading for alignment)
|
||||||
lines = lines.map((line) => line.trimEnd())
|
lines = lines.map((line) => line.trimEnd())
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip ANSI escape sequences from a string.
|
* Strip ANSI escape sequences from a string.
|
||||||
*/
|
*/
|
||||||
function stripAnsi(str: string): string {
|
function stripAnsi(str: string): string {
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get species name with dex number for display.
|
* Get species name with dex number for display.
|
||||||
*/
|
*/
|
||||||
export function getSpeciesDisplay(speciesId: SpeciesId): string {
|
export function getSpeciesDisplay(speciesId: SpeciesId): string {
|
||||||
const data = getSpeciesData(speciesId)
|
const data = getSpeciesData(speciesId)
|
||||||
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
|
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 type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
import { generateCreature } from './creature'
|
import { generateCreature } from './creature'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
|
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
|
||||||
import { randomNature } from '../data/nature'
|
import { randomNature } from '../dex/nature'
|
||||||
|
|
||||||
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
|
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
|
||||||
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
|
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
|
||||||
@@ -16,10 +16,10 @@ const BOX_SIZE = 30
|
|||||||
|
|
||||||
/** Create empty boxes */
|
/** Create empty boxes */
|
||||||
function makeDefaultBoxes(): PCBox[] {
|
function makeDefaultBoxes(): PCBox[] {
|
||||||
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
|
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
|
||||||
name: `Box ${i + 1}`,
|
name: `Box ${i + 1}`,
|
||||||
slots: Array.from({ length: BOX_SIZE }, () => null),
|
slots: Array.from({ length: BOX_SIZE }, () => null),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,28 +27,28 @@ function makeDefaultBoxes(): PCBox[] {
|
|||||||
* Auto-migrates from any older version.
|
* Auto-migrates from any older version.
|
||||||
*/
|
*/
|
||||||
export async function loadBuddyData(): Promise<BuddyData> {
|
export async function loadBuddyData(): Promise<BuddyData> {
|
||||||
if (!existsSync(BUDDY_DATA_PATH)) {
|
if (!existsSync(BUDDY_DATA_PATH)) {
|
||||||
return getDefaultBuddyData()
|
return getDefaultBuddyData()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
|
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
|
||||||
const data = JSON.parse(raw)
|
const data = JSON.parse(raw)
|
||||||
return migrateToV2(data)
|
return migrateToV2(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[buddy] Failed to load buddy data:', e)
|
console.error('[buddy] Failed to load buddy data:', e)
|
||||||
return getDefaultBuddyData()
|
return getDefaultBuddyData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save buddy data to disk.
|
* Save buddy data to disk.
|
||||||
*/
|
*/
|
||||||
export function saveBuddyData(data: BuddyData): void {
|
export function saveBuddyData(data: BuddyData): void {
|
||||||
const dir = join(BUDDY_DATA_PATH, '..')
|
const dir = join(BUDDY_DATA_PATH, '..')
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
}
|
}
|
||||||
writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2))
|
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.
|
* Randomly assigns one of the three starters.
|
||||||
*/
|
*/
|
||||||
export async function getDefaultBuddyData(): Promise<BuddyData> {
|
export async function getDefaultBuddyData(): Promise<BuddyData> {
|
||||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
||||||
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
|
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
|
||||||
const creature = await generateCreature(randomStarter)
|
const creature = await generateCreature(randomStarter)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: makeDefaultBoxes(),
|
boxes: makeDefaultBoxes(),
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [
|
dex: [
|
||||||
{
|
{
|
||||||
speciesId: randomStarter,
|
speciesId: randomStarter,
|
||||||
discoveredAt: Date.now(),
|
discoveredAt: Date.now(),
|
||||||
caughtCount: 1,
|
caughtCount: 1,
|
||||||
bestLevel: 1,
|
bestLevel: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 0,
|
consecutiveDays: 0,
|
||||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the sprites cache directory path.
|
* Get the sprites cache directory path.
|
||||||
*/
|
*/
|
||||||
export function getSpritesDir(): string {
|
export function getSpritesDir(): string {
|
||||||
if (!existsSync(BUDDY_SPRITES_DIR)) {
|
if (!existsSync(BUDDY_SPRITES_DIR)) {
|
||||||
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
|
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
|
||||||
}
|
}
|
||||||
return BUDDY_SPRITES_DIR
|
return BUDDY_SPRITES_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate from legacy buddy system.
|
* Migrate from legacy buddy system.
|
||||||
*/
|
*/
|
||||||
export async function migrateFromLegacy(
|
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> {
|
): Promise<BuddyData> {
|
||||||
const speciesMap: Record<string, SpeciesId> = {
|
const speciesMap: Record<string, SpeciesId> = {
|
||||||
duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur',
|
duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur',
|
||||||
cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle',
|
cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle',
|
||||||
owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle',
|
owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle',
|
||||||
snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle',
|
snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle',
|
||||||
capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander',
|
capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander',
|
||||||
rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander',
|
rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander',
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
|
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
|
||||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
||||||
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
|
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
|
||||||
|
|
||||||
const creature = await generateCreature(speciesId)
|
const creature = await generateCreature(speciesId)
|
||||||
creature.level = 5
|
creature.level = 5
|
||||||
creature.totalXp = 100
|
creature.totalXp = 100
|
||||||
creature.friendship = 120
|
creature.friendship = 120
|
||||||
|
|
||||||
const speciesInfo = getSpeciesData(speciesId)
|
const speciesInfo = getSpeciesData(speciesId)
|
||||||
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
|
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
|
||||||
creature.nickname = storedCompanion.name
|
creature.nickname = storedCompanion.name
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party: [creature.id, null, null, null, null, null],
|
party: [creature.id, null, null, null, null, null],
|
||||||
boxes: makeDefaultBoxes(),
|
boxes: makeDefaultBoxes(),
|
||||||
creatures: [creature],
|
creatures: [creature],
|
||||||
eggs: [],
|
eggs: [],
|
||||||
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
|
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: 0,
|
totalTurns: 0,
|
||||||
consecutiveDays: 1,
|
consecutiveDays: 1,
|
||||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||||
totalEggsObtained: 0,
|
totalEggsObtained: 0,
|
||||||
totalEvolutions: 0,
|
totalEvolutions: 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Migration ───
|
// ─── Migration ───
|
||||||
|
|
||||||
/** Migrate any version to v2 */
|
/** Migrate any version to v2 */
|
||||||
async function migrateToV2(data: Record<string, unknown>): Promise<BuddyData> {
|
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
|
// v1 → v2
|
||||||
const v1 = data as Record<string, unknown>
|
const v1 = data as Record<string, unknown>
|
||||||
const party = ensureParty(v1)
|
const party = ensureParty(v1)
|
||||||
|
|
||||||
// Migrate creatures: add new fields
|
// Migrate creatures: add new fields
|
||||||
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
|
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
|
||||||
|
|
||||||
// Build boxes — put non-party creatures into Box 1
|
// Build boxes — put non-party creatures into Box 1
|
||||||
const partyIds = new Set(party.filter(Boolean))
|
const partyIds = new Set(party.filter(Boolean))
|
||||||
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
|
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
|
||||||
const boxes = makeDefaultBoxes()
|
const boxes = makeDefaultBoxes()
|
||||||
const box1Slots = [...boxes[0]!.slots]
|
const box1Slots = [...boxes[0]!.slots]
|
||||||
let boxIdx = 0
|
let boxIdx = 0
|
||||||
for (const c of nonPartyCreatures) {
|
for (const c of nonPartyCreatures) {
|
||||||
if (boxIdx < BOX_SIZE) {
|
if (boxIdx < BOX_SIZE) {
|
||||||
box1Slots[boxIdx] = c.id
|
box1Slots[boxIdx] = c.id
|
||||||
boxIdx++
|
boxIdx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boxes[0] = { name: 'Box 1', slots: box1Slots }
|
boxes[0] = { name: 'Box 1', slots: box1Slots }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
party,
|
party,
|
||||||
boxes,
|
boxes,
|
||||||
creatures,
|
creatures,
|
||||||
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
|
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
|
||||||
dex: (v1.dex as BuddyData['dex']) ?? [],
|
dex: (v1.dex as BuddyData['dex']) ?? [],
|
||||||
bag: { items: [] },
|
bag: { items: [] },
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
|
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
|
||||||
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
|
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
|
||||||
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
|
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
|
||||||
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
|
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
|
||||||
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
|
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
|
||||||
battlesWon: 0,
|
battlesWon: 0,
|
||||||
battlesLost: 0,
|
battlesLost: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure party field is valid */
|
/** Ensure party field is valid */
|
||||||
function ensureParty(data: Record<string, unknown>): (string | null)[] {
|
function ensureParty(data: Record<string, unknown>): (string | null)[] {
|
||||||
const existing = data.party as (string | null)[] | undefined
|
const existing = data.party as (string | null)[] | undefined
|
||||||
if (existing && existing.length === 6) return existing
|
if (existing && existing.length === 6) return existing
|
||||||
|
|
||||||
const party: (string | null)[] = new Array(6).fill(null)
|
const party: (string | null)[] = new Array(6).fill(null)
|
||||||
const activeId = data.activeCreatureId ?? existing?.[0]
|
const activeId = data.activeCreatureId ?? existing?.[0]
|
||||||
if (activeId) party[0] = activeId as string
|
if (activeId) party[0] = activeId as string
|
||||||
|
|
||||||
const creatures = data.creatures as Creature[] ?? []
|
const creatures = data.creatures as Creature[] ?? []
|
||||||
let slot = 1
|
let slot = 1
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
if (c.id === activeId) continue
|
if (c.id === activeId) continue
|
||||||
if (slot >= 6) break
|
if (slot >= 6) break
|
||||||
party[slot] = c.id
|
party[slot] = c.id
|
||||||
slot++
|
slot++
|
||||||
}
|
}
|
||||||
return party
|
return party
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Migrate creatures from v1 format to v2 */
|
/** Migrate creatures from v1 format to v2 */
|
||||||
async function migrateCreatures(creatures: Creature[]): Promise<Creature[]> {
|
async function migrateCreatures(creatures: Creature[]): Promise<Creature[]> {
|
||||||
const result: Creature[] = []
|
const result: Creature[] = []
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
// Already v2 (has nature field)
|
// Already v2 (has nature field)
|
||||||
if ('nature' in c && c.nature) {
|
if ('nature' in c && c.nature) {
|
||||||
result.push(c)
|
result.push(c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
...c,
|
...c,
|
||||||
nature: randomNature(),
|
nature: randomNature(),
|
||||||
moves: await getDefaultMoveset(c.speciesId, c.level),
|
moves: await getDefaultMoveset(c.speciesId, c.level),
|
||||||
ability: getDefaultAbility(c.speciesId),
|
ability: getDefaultAbility(c.speciesId),
|
||||||
heldItem: null,
|
heldItem: null,
|
||||||
pokeball: 'pokeball',
|
pokeball: 'pokeball',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Daily / Turn stats ───
|
// ─── Daily / Turn stats ───
|
||||||
|
|
||||||
export function updateDailyStats(data: BuddyData): BuddyData {
|
export function updateDailyStats(data: BuddyData): BuddyData {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const lastDate = data.stats.lastActiveDate
|
const lastDate = data.stats.lastActiveDate
|
||||||
|
|
||||||
let consecutiveDays = data.stats.consecutiveDays
|
let consecutiveDays = data.stats.consecutiveDays
|
||||||
if (lastDate !== today) {
|
if (lastDate !== today) {
|
||||||
const yesterday = new Date()
|
const yesterday = new Date()
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
||||||
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
|
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
|
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function incrementTurns(data: BuddyData): BuddyData {
|
export function incrementTurns(data: BuddyData): BuddyData {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
|
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Party operations ───
|
// ─── Party operations ───
|
||||||
|
|
||||||
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
|
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
|
||||||
const party = [...data.party]
|
const party = [...data.party]
|
||||||
const emptyIdx = party.findIndex(p => p === null)
|
const emptyIdx = party.findIndex(p => p === null)
|
||||||
if (emptyIdx === -1) return { data, added: false }
|
if (emptyIdx === -1) return { data, added: false }
|
||||||
party[emptyIdx] = creatureId
|
party[emptyIdx] = creatureId
|
||||||
return { data: { ...data, party }, added: true }
|
return { data: { ...data, party }, added: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
|
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
|
||||||
if (slotIndex < 0 || slotIndex >= 6) return data
|
if (slotIndex < 0 || slotIndex >= 6) return data
|
||||||
const party = [...data.party]
|
const party = [...data.party]
|
||||||
party[slotIndex] = null
|
party[slotIndex] = null
|
||||||
return { ...data, party }
|
return { ...data, party }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
|
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
|
||||||
const party = [...data.party]
|
const party = [...data.party]
|
||||||
const a = party[indexA]
|
const a = party[indexA]
|
||||||
const b = party[indexB]
|
const b = party[indexB]
|
||||||
party[indexA] = b
|
party[indexA] = b
|
||||||
party[indexB] = a
|
party[indexB] = a
|
||||||
return { ...data, party }
|
return { ...data, party }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
|
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
|
||||||
const party = [...data.party]
|
const party = [...data.party]
|
||||||
const existingIdx = party.findIndex(id => id === creatureId)
|
const existingIdx = party.findIndex(id => id === creatureId)
|
||||||
if (existingIdx === 0) return data
|
if (existingIdx === 0) return data
|
||||||
if (existingIdx > 0) {
|
if (existingIdx > 0) {
|
||||||
party[0] = creatureId
|
party[0] = creatureId
|
||||||
party[existingIdx] = data.party[0]
|
party[existingIdx] = data.party[0]
|
||||||
} else {
|
} else {
|
||||||
party[0] = creatureId
|
party[0] = creatureId
|
||||||
}
|
}
|
||||||
return { ...data, party }
|
return { ...data, party }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PC Box operations ───
|
// ─── PC Box operations ───
|
||||||
|
|
||||||
export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } {
|
export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } {
|
||||||
for (let b = 0; b < data.boxes.length; b++) {
|
for (let b = 0; b < data.boxes.length; b++) {
|
||||||
const slots = [...data.boxes[b]!.slots]
|
const slots = [...data.boxes[b]!.slots]
|
||||||
const emptyIdx = slots.findIndex(s => s === null)
|
const emptyIdx = slots.findIndex(s => s === null)
|
||||||
if (emptyIdx !== -1) {
|
if (emptyIdx !== -1) {
|
||||||
slots[emptyIdx] = creatureId
|
slots[emptyIdx] = creatureId
|
||||||
const boxes = [...data.boxes]
|
const boxes = [...data.boxes]
|
||||||
boxes[b] = { ...data.boxes[b]!, slots }
|
boxes[b] = { ...data.boxes[b]!, slots }
|
||||||
return { data: { ...data, boxes }, deposited: true }
|
return { data: { ...data, boxes }, deposited: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { data, deposited: false }
|
return { data, deposited: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } {
|
export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } {
|
||||||
for (let b = 0; b < data.boxes.length; b++) {
|
for (let b = 0; b < data.boxes.length; b++) {
|
||||||
const slots = [...data.boxes[b]!.slots]
|
const slots = [...data.boxes[b]!.slots]
|
||||||
const idx = slots.findIndex(s => s === creatureId)
|
const idx = slots.findIndex(s => s === creatureId)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
slots[idx] = null
|
slots[idx] = null
|
||||||
const boxes = [...data.boxes]
|
const boxes = [...data.boxes]
|
||||||
boxes[b] = { ...data.boxes[b]!, slots }
|
boxes[b] = { ...data.boxes[b]!, slots }
|
||||||
return { data: { ...data, boxes }, withdrawn: true }
|
return { data: { ...data, boxes }, withdrawn: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { data, withdrawn: false }
|
return { data, withdrawn: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveInBox(data: BuddyData, fromBox: number, fromSlot: number, toBox: number, toSlot: number): BuddyData {
|
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 boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] }))
|
||||||
const creatureId = boxes[fromBox]?.slots[fromSlot]
|
const creatureId = boxes[fromBox]?.slots[fromSlot]
|
||||||
if (!creatureId) return data
|
if (!creatureId) return data
|
||||||
boxes[fromBox]!.slots[fromSlot] = null
|
boxes[fromBox]!.slots[fromSlot] = null
|
||||||
boxes[toBox]!.slots[toSlot] = creatureId
|
boxes[toBox]!.slots[toSlot] = creatureId
|
||||||
return { ...data, boxes }
|
return { ...data, boxes }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData {
|
export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData {
|
||||||
const boxes = [...data.boxes]
|
const boxes = [...data.boxes]
|
||||||
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
|
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
|
||||||
return { ...data, boxes }
|
return { ...data, boxes }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null {
|
export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null {
|
||||||
const partyIdx = data.party.findIndex(id => id === creatureId)
|
const partyIdx = data.party.findIndex(id => id === creatureId)
|
||||||
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
|
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
|
||||||
|
|
||||||
for (let b = 0; b < data.boxes.length; b++) {
|
for (let b = 0; b < data.boxes.length; b++) {
|
||||||
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
|
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
|
||||||
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
|
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseCreature(data: BuddyData, creatureId: string): BuddyData {
|
export function releaseCreature(data: BuddyData, creatureId: string): BuddyData {
|
||||||
// Remove from party
|
// Remove from party
|
||||||
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
|
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
|
||||||
// Remove from boxes
|
// Remove from boxes
|
||||||
const withdrawResult = withdrawFromBox(updated, creatureId)
|
const withdrawResult = withdrawFromBox(updated, creatureId)
|
||||||
if (withdrawResult.withdrawn) updated = withdrawResult.data
|
if (withdrawResult.withdrawn) updated = withdrawResult.data
|
||||||
// Remove from creatures array
|
// Remove from creatures array
|
||||||
return {
|
return {
|
||||||
...updated,
|
...updated,
|
||||||
creatures: updated.creatures.filter(c => c.id !== creatureId),
|
creatures: updated.creatures.filter(c => c.id !== creatureId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTotalCreatureCount(data: BuddyData): number {
|
export function getTotalCreatureCount(data: BuddyData): number {
|
||||||
return data.creatures.length
|
return data.creatures.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllCreatureIds(data: BuddyData): string[] {
|
export function getAllCreatureIds(data: BuddyData): string[] {
|
||||||
return data.creatures.map(c => c.id)
|
return data.creatures.map(c => c.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Bag operations ───
|
// ─── Bag operations ───
|
||||||
|
|
||||||
export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData {
|
export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData {
|
||||||
const items = data.bag.items.map(e => ({ ...e }))
|
const items = data.bag.items.map(e => ({ ...e }))
|
||||||
const existing = items.find(e => e.id === itemId)
|
const existing = items.find(e => e.id === itemId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.count += count
|
existing.count += count
|
||||||
} else {
|
} else {
|
||||||
items.push({ id: itemId, count })
|
items.push({ id: itemId, count })
|
||||||
}
|
}
|
||||||
return { ...data, bag: { items } }
|
return { ...data, bag: { items } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } {
|
export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } {
|
||||||
const items = data.bag.items.map(e => ({ ...e }))
|
const items = data.bag.items.map(e => ({ ...e }))
|
||||||
const existing = items.find(e => e.id === itemId)
|
const existing = items.find(e => e.id === itemId)
|
||||||
if (!existing || existing.count < count) return { data, removed: false }
|
if (!existing || existing.count < count) return { data, removed: false }
|
||||||
|
|
||||||
existing.count -= count
|
existing.count -= count
|
||||||
if (existing.count <= 0) {
|
if (existing.count <= 0) {
|
||||||
const idx = items.indexOf(existing)
|
const idx = items.indexOf(existing)
|
||||||
items.splice(idx, 1)
|
items.splice(idx, 1)
|
||||||
}
|
}
|
||||||
return { data: { ...data, bag: { items } }, removed: true }
|
return { data: { ...data, bag: { items } }, removed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemCount(data: BuddyData, itemId: string): number {
|
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)
|
// All 25 canonical nature names (Dex.natures is not iterable, so we list them)
|
||||||
const NATURE_IDS: NatureName[] = [
|
const NATURE_IDS: NatureName[] = [
|
||||||
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
|
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
|
||||||
'bold', 'docile', 'relaxed', 'impish', 'lax',
|
'bold', 'docile', 'relaxed', 'impish', 'lax',
|
||||||
'timid', 'hasty', 'serious', 'jolly', 'naive',
|
'timid', 'hasty', 'serious', 'jolly', 'naive',
|
||||||
'modest', 'mild', 'quiet', 'bashful', 'rash',
|
'modest', 'mild', 'quiet', 'bashful', 'rash',
|
||||||
'calm', 'gentle', 'sassy', 'careful', 'quirky',
|
'calm', 'gentle', 'sassy', 'careful', 'quirky',
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Get all nature names */
|
/** Get all nature names */
|
||||||
export function getAllNatureNames(): NatureName[] {
|
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 */
|
/** Randomly assign a nature */
|
||||||
export function randomNature(): NatureName {
|
export function randomNature(): NatureName {
|
||||||
const names = getAllNatureNames()
|
const names = getAllNatureNames()
|
||||||
return names[Math.floor(Math.random() * names.length)]!
|
return names[Math.floor(Math.random() * names.length)]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
|
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
|
||||||
function mapDexStat(stat: string | undefined): NatureStat | null {
|
function mapDexStat(stat: string | undefined): NatureStat | null {
|
||||||
if (!stat) return null
|
if (!stat) return null
|
||||||
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
|
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
|
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
|
||||||
export function getNatureEffect(nature: NatureName): NatureEffect {
|
export function getNatureEffect(nature: NatureName): NatureEffect {
|
||||||
const n = Dex.natures.get(nature)
|
const n = Dex.natures.get(nature)
|
||||||
if (!n?.exists) return { plus: null, minus: null }
|
if (!n?.exists) return { plus: null, minus: null }
|
||||||
return {
|
return {
|
||||||
plus: mapDexStat(n.plus),
|
plus: mapDexStat(n.plus),
|
||||||
minus: mapDexStat(n.minus),
|
minus: mapDexStat(n.minus),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,32 +8,32 @@ export const gen = gens.get(9)
|
|||||||
|
|
||||||
// Stat name mapping: @pkmn/sim → our StatName
|
// Stat name mapping: @pkmn/sim → our StatName
|
||||||
export const FROM_DEX_STAT: Record<string, StatName> = {
|
export const FROM_DEX_STAT: Record<string, StatName> = {
|
||||||
hp: 'hp', atk: 'attack', def: 'defense',
|
hp: 'hp', atk: 'attack', def: 'defense',
|
||||||
spa: 'spAtk', spd: 'spDef', spe: 'speed',
|
spa: 'spAtk', spd: 'spDef', spe: 'speed',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat name mapping: our StatName → @pkmn/sim abbreviation
|
// Stat name mapping: our StatName → @pkmn/sim abbreviation
|
||||||
export const TO_DEX_STAT: Record<StatName, string> = {
|
export const TO_DEX_STAT: Record<StatName, string> = {
|
||||||
hp: 'hp', attack: 'atk', defense: 'def',
|
hp: 'hp', attack: 'atk', defense: 'def',
|
||||||
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query species from Dex */
|
/** Query species from Dex */
|
||||||
export function getSpecies(id: string) {
|
export function getSpecies(id: string) {
|
||||||
return gen.species.get(id)
|
return gen.species.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map Dex baseStats to our StatName format */
|
/** 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> {
|
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>
|
const result = {} as Record<StatName, number>
|
||||||
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
|
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
|
||||||
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
|
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
|
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
|
||||||
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
|
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
|
||||||
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
|
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
|
||||||
return Math.round(genderRatio.F * 8)
|
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
|
// Types
|
||||||
export type {
|
export type {
|
||||||
StatName,
|
StatName,
|
||||||
NatureName,
|
NatureName,
|
||||||
NatureStat,
|
NatureStat,
|
||||||
NatureEffect,
|
NatureEffect,
|
||||||
MoveSlot,
|
MoveSlot,
|
||||||
ItemId,
|
ItemId,
|
||||||
PCBox,
|
PCBox,
|
||||||
BagEntry,
|
BagEntry,
|
||||||
Bag,
|
Bag,
|
||||||
SpeciesId,
|
SpeciesId,
|
||||||
Gender,
|
Gender,
|
||||||
EvolutionTrigger,
|
EvolutionTrigger,
|
||||||
EvolutionCondition,
|
EvolutionCondition,
|
||||||
GrowthRate,
|
GrowthRate,
|
||||||
SpeciesData,
|
SpeciesData,
|
||||||
Creature,
|
Creature,
|
||||||
Egg,
|
Egg,
|
||||||
DexEntry,
|
DexEntry,
|
||||||
BuddyData,
|
BuddyData,
|
||||||
StatsResult,
|
StatsResult,
|
||||||
EvolutionResult,
|
EvolutionResult,
|
||||||
SpriteCache,
|
SpriteCache,
|
||||||
AnimMode,
|
AnimMode,
|
||||||
} from './types'
|
} from './types'
|
||||||
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
|
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './data/species'
|
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 './data/evMapping'
|
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
|
||||||
export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable'
|
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
|
||||||
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names'
|
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
|
||||||
export { getAllNatureNames, randomNature, getNatureEffect } from './data/nature'
|
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
|
||||||
export { getNextEvolution } from './data/evolution'
|
export { getNextEvolution } from './dex/evolution'
|
||||||
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './data/learnsets'
|
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
|
||||||
export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn'
|
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
|
||||||
|
|
||||||
// Battle
|
// Battle
|
||||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
|
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 { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
|
||||||
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
|
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
|
||||||
export {
|
export {
|
||||||
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
|
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
|
||||||
updateDailyStats, incrementTurns,
|
updateDailyStats, incrementTurns,
|
||||||
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
|
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
|
||||||
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
||||||
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
||||||
addItemToBag, removeItemFromBag, getItemCount,
|
addItemToBag, removeItemFromBag, getItemCount,
|
||||||
} from './core/storage'
|
} from './core/storage'
|
||||||
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
||||||
|
|
||||||
@@ -77,3 +77,4 @@ export { SwitchPanel } from './ui/SwitchPanel'
|
|||||||
export { ItemPanel } from './ui/ItemPanel'
|
export { ItemPanel } from './ui/ItemPanel'
|
||||||
export { BattleResultPanel } from './ui/BattleResultPanel'
|
export { BattleResultPanel } from './ui/BattleResultPanel'
|
||||||
export { MoveLearnPanel } from './ui/MoveLearnPanel'
|
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.
|
* Simple 5-line representations of each species.
|
||||||
*/
|
*/
|
||||||
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
|
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
|
||||||
bulbasaur: [
|
bulbasaur: [
|
||||||
' _,,--.,,_ ',
|
' _,,--.,,_ ',
|
||||||
' ,\' `, ',
|
' ,\' `, ',
|
||||||
' ; o o ; ',
|
' ; o o ; ',
|
||||||
' ; ~~~~~~~~ ; ',
|
' ; ~~~~~~~~ ; ',
|
||||||
' `--,,__,,--\' ',
|
' `--,,__,,--\' ',
|
||||||
],
|
],
|
||||||
ivysaur: [
|
ivysaur: [
|
||||||
' _,--..,_ ',
|
' _,--..,_ ',
|
||||||
' ,\' (o)(o) `, ',
|
' ,\' (o)(o) `, ',
|
||||||
' ; ~~~~~~ ; ',
|
' ; ~~~~~~ ; ',
|
||||||
' ; \\====/ ; ',
|
' ; \\====/ ; ',
|
||||||
' `--,,__,,--\' ',
|
' `--,,__,,--\' ',
|
||||||
],
|
],
|
||||||
venusaur: [
|
venusaur: [
|
||||||
' _,,,---.,,_ ',
|
' _,,,---.,,_ ',
|
||||||
' ,\' (o) (o) `, ',
|
' ,\' (o) (o) `, ',
|
||||||
' ; ~~~~~~~~ ; ',
|
' ; ~~~~~~~~ ; ',
|
||||||
' ; /========\\ ; ',
|
' ; /========\\ ; ',
|
||||||
' `-,,,____,,,-\' ',
|
' `-,,,____,,,-\' ',
|
||||||
],
|
],
|
||||||
charmander: [
|
charmander: [
|
||||||
' ,^., ',
|
' ,^., ',
|
||||||
' ( o o) ',
|
' ( o o) ',
|
||||||
' / ~~~ \\ ',
|
' / ~~~ \\ ',
|
||||||
' / \\___/ \\ ',
|
' / \\___/ \\ ',
|
||||||
' ^^^ ^^^ ',
|
' ^^^ ^^^ ',
|
||||||
],
|
],
|
||||||
charmeleon: [
|
charmeleon: [
|
||||||
' ,--^. ',
|
' ,--^. ',
|
||||||
' ( o o) ',
|
' ( o o) ',
|
||||||
' / ~~~~~ \\ ',
|
' / ~~~~~ \\ ',
|
||||||
' / \\___/ \\ ',
|
' / \\___/ \\ ',
|
||||||
' ^^ ^^ ',
|
' ^^ ^^ ',
|
||||||
],
|
],
|
||||||
charizard: [
|
charizard: [
|
||||||
' /\\ /\\ ',
|
' /\\ /\\ ',
|
||||||
' / \\/ \\ ',
|
' / \\/ \\ ',
|
||||||
' | o o | ',
|
' | o o | ',
|
||||||
' | ~~~~~~ | ',
|
' | ~~~~~~ | ',
|
||||||
' \\______/ ',
|
' \\______/ ',
|
||||||
],
|
],
|
||||||
squirtle: [
|
squirtle: [
|
||||||
' _____ ',
|
' _____ ',
|
||||||
' ,\' `, ',
|
' ,\' `, ',
|
||||||
' ; o o ; ',
|
' ; o o ; ',
|
||||||
' ; ~~~~~~~ ; ',
|
' ; ~~~~~~~ ; ',
|
||||||
' `-.,__,\' ',
|
' `-.,__,\' ',
|
||||||
],
|
],
|
||||||
wartortle: [
|
wartortle: [
|
||||||
' _______ ',
|
' _______ ',
|
||||||
' ,\' `, ',
|
' ,\' `, ',
|
||||||
' ; o o ; ',
|
' ; o o ; ',
|
||||||
' ; ~~~~~~~~ ; ',
|
' ; ~~~~~~~~ ; ',
|
||||||
' `-.,__,\' ',
|
' `-.,__,\' ',
|
||||||
],
|
],
|
||||||
blastoise: [
|
blastoise: [
|
||||||
' .________. ',
|
' .________. ',
|
||||||
' | o o | ',
|
' | o o | ',
|
||||||
' | ~~~~~~~~ | ',
|
' | ~~~~~~~~ | ',
|
||||||
' | [====] | ',
|
' | [====] | ',
|
||||||
' `-.,__,\' ',
|
' `-.,__,\' ',
|
||||||
],
|
],
|
||||||
pikachu: [
|
pikachu: [
|
||||||
' /\\ /\\ ',
|
' /\\ /\\ ',
|
||||||
' ( o o ) ',
|
' ( o o ) ',
|
||||||
' \\ ~~~ / ',
|
' \\ ~~~ / ',
|
||||||
' /`-...-\'\\ ',
|
' /`-...-\'\\ ',
|
||||||
' ^^ ^^ ',
|
' ^^ ^^ ',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get fallback ASCII sprite lines for a species.
|
* Get fallback ASCII sprite lines for a species.
|
||||||
*/
|
*/
|
||||||
export function getFallbackSprite(speciesId: SpeciesId): string[] {
|
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
|
// After transform, render each row back: reset → style → char → reset
|
||||||
|
|
||||||
interface Pixel {
|
interface Pixel {
|
||||||
char: string
|
char: string
|
||||||
/** Full ANSI state needed to render this pixel */
|
/** Full ANSI state needed to render this pixel */
|
||||||
style: string
|
style: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
|
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
|
||||||
@@ -26,53 +26,53 @@ const EMPTY_ROW: Pixel[] = []
|
|||||||
|
|
||||||
/** Parse a raw ANSI string line into a Pixel row */
|
/** Parse a raw ANSI string line into a Pixel row */
|
||||||
function parseLine(line: string): Pixel[] {
|
function parseLine(line: string): Pixel[] {
|
||||||
const pixels: Pixel[] = []
|
const pixels: Pixel[] = []
|
||||||
let style = ''
|
let style = ''
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i < line.length) {
|
while (i < line.length) {
|
||||||
if (line[i] === '\x1b') {
|
if (line[i] === '\x1b') {
|
||||||
// Collect full ANSI escape sequence: \x1b[ ... m
|
// Collect full ANSI escape sequence: \x1b[ ... m
|
||||||
const start = i
|
const start = i
|
||||||
i++ // skip \x1b
|
i++ // skip \x1b
|
||||||
if (i < line.length && line[i] === '[') {
|
if (i < line.length && line[i] === '[') {
|
||||||
i++ // skip [
|
i++ // skip [
|
||||||
while (i < line.length && line[i] !== 'm') i++
|
while (i < line.length && line[i] !== 'm') i++
|
||||||
if (i < line.length) i++ // skip m
|
if (i < line.length) i++ // skip m
|
||||||
}
|
}
|
||||||
style += line.slice(start, i)
|
style += line.slice(start, i)
|
||||||
} else {
|
} else {
|
||||||
// Visible character (handle multi-byte Unicode)
|
// Visible character (handle multi-byte Unicode)
|
||||||
const cp = line.codePointAt(i)!
|
const cp = line.codePointAt(i)!
|
||||||
const ch = String.fromCodePoint(cp)
|
const ch = String.fromCodePoint(cp)
|
||||||
pixels.push({ char: ch, style })
|
pixels.push({ char: ch, style })
|
||||||
i += ch.length
|
i += ch.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pixels
|
return pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render a Pixel row back to an ANSI string */
|
/** Render a Pixel row back to an ANSI string */
|
||||||
function renderRow(pixels: Pixel[]): string {
|
function renderRow(pixels: Pixel[]): string {
|
||||||
if (pixels.length === 0) return ''
|
if (pixels.length === 0) return ''
|
||||||
let out = ''
|
let out = ''
|
||||||
let lastStyle: string | null = null
|
let lastStyle: string | null = null
|
||||||
for (const p of pixels) {
|
for (const p of pixels) {
|
||||||
if (p.style !== lastStyle) {
|
if (p.style !== lastStyle) {
|
||||||
out += '\x1b[0m' + p.style // reset then apply
|
out += '\x1b[0m' + p.style // reset then apply
|
||||||
lastStyle = p.style
|
lastStyle = p.style
|
||||||
}
|
}
|
||||||
out += p.char
|
out += p.char
|
||||||
}
|
}
|
||||||
out += '\x1b[0m' // final reset
|
out += '\x1b[0m' // final reset
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSprite(lines: string[]): Pixel[][] {
|
function parseSprite(lines: string[]): Pixel[][] {
|
||||||
return lines.map(parseLine)
|
return lines.map(parseLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSprite(grid: Pixel[][]): string[] {
|
function renderSprite(grid: Pixel[][]): string[] {
|
||||||
return grid.map(renderRow)
|
return grid.map(renderRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Grid Transforms ──────────────────────────────────
|
// ─── Grid Transforms ──────────────────────────────────
|
||||||
@@ -80,37 +80,37 @@ function renderSprite(grid: Pixel[][]): string[] {
|
|||||||
|
|
||||||
/** Horizontal shift — positive = right, negative = left */
|
/** Horizontal shift — positive = right, negative = left */
|
||||||
function shiftH(grid: Pixel[][], n: number): Pixel[][] {
|
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 => [...Array(n).fill(EMPTY_PIXEL), ...row])
|
||||||
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
|
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
|
||||||
return grid
|
return grid
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vertical shift up — removes rows from top, pads empty at bottom */
|
/** Vertical shift up — removes rows from top, pads empty at bottom */
|
||||||
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
|
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
|
||||||
if (n <= 0) return grid
|
if (n <= 0) return grid
|
||||||
const height = grid.length
|
const height = grid.length
|
||||||
const shifted = grid.slice(n)
|
const shifted = grid.slice(n)
|
||||||
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
||||||
return shifted
|
return shifted
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirror map — characters that change when flipped horizontally */
|
/** Mirror map — characters that change when flipped horizontally */
|
||||||
const MIRROR: Record<string, string> = {
|
const MIRROR: Record<string, string> = {
|
||||||
'/': '\\', '\\': '/',
|
'/': '\\', '\\': '/',
|
||||||
'(': ')', ')': '(',
|
'(': ')', ')': '(',
|
||||||
'<': '>', '>': '<',
|
'<': '>', '>': '<',
|
||||||
'{': '}', '}': '{',
|
'{': '}', '}': '{',
|
||||||
'[': ']', ']': '[',
|
'[': ']', ']': '[',
|
||||||
'╱': '╲', '╲': '╱',
|
'╱': '╲', '╲': '╱',
|
||||||
'▌': '▐', '▐': '▌',
|
'▌': '▐', '▐': '▌',
|
||||||
'▎': '▏', '▏': '▎',
|
'▎': '▏', '▏': '▎',
|
||||||
'◀': '▶', '▶': '◀',
|
'◀': '▶', '▶': '◀',
|
||||||
'◄': '►', '►': '◄',
|
'◄': '►', '►': '◄',
|
||||||
'→': '←', '←': '→',
|
'→': '←', '←': '→',
|
||||||
'↗': '↙', '↙': '↗',
|
'↗': '↙', '↙': '↗',
|
||||||
'↘': '↖', '↖': '↘',
|
'↘': '↖', '↖': '↘',
|
||||||
'`': "'", "'": '`',
|
'`': "'", "'": '`',
|
||||||
',': '´', '´': ',',
|
',': '´', '´': ',',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,24 +119,24 @@ const MIRROR: Record<string, string> = {
|
|||||||
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
|
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
|
||||||
*/
|
*/
|
||||||
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
|
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
|
||||||
const width = Math.max(0, ...grid.map(row => row.length))
|
const width = Math.max(0, ...grid.map(row => row.length))
|
||||||
return grid.map(row =>
|
return grid.map(row =>
|
||||||
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
|
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
|
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace eye-like characters with dash */
|
/** Replace eye-like characters with dash */
|
||||||
function blinkEyes(grid: Pixel[][]): Pixel[][] {
|
function blinkEyes(grid: Pixel[][]): Pixel[][] {
|
||||||
return grid.map(row =>
|
return grid.map(row =>
|
||||||
row.map(p =>
|
row.map(p =>
|
||||||
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
|
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
@@ -144,29 +144,29 @@ function blinkEyes(grid: Pixel[][]): Pixel[][] {
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
const IDLE_SEQUENCE: AnimMode[] = [
|
const IDLE_SEQUENCE: AnimMode[] = [
|
||||||
'idle', 'idle',
|
'idle', 'idle',
|
||||||
'breathe', 'breathe',
|
'breathe', 'breathe',
|
||||||
'idle',
|
'idle',
|
||||||
'blink',
|
'blink',
|
||||||
'idle',
|
'idle',
|
||||||
'bounce',
|
'bounce',
|
||||||
'idle',
|
'idle',
|
||||||
'fidget', 'fidget',
|
'fidget', 'fidget',
|
||||||
'idle',
|
'idle',
|
||||||
'breathe', 'breathe',
|
'breathe', 'breathe',
|
||||||
'idle',
|
'idle',
|
||||||
'flip', 'flip', 'flip',
|
'flip', 'flip', 'flip',
|
||||||
'idle', 'idle',
|
'idle', 'idle',
|
||||||
'bounce',
|
'bounce',
|
||||||
'idle',
|
'idle',
|
||||||
'blink',
|
'blink',
|
||||||
'idle',
|
'idle',
|
||||||
'excited', 'excited',
|
'excited', 'excited',
|
||||||
'idle',
|
'idle',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getIdleAnimMode(tick: number): AnimMode {
|
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.
|
* Internally: parse ANSI → Pixel grid → transform → render back.
|
||||||
*/
|
*/
|
||||||
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
|
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) {
|
switch (mode) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
break
|
break
|
||||||
case 'breathe':
|
case 'breathe':
|
||||||
// Right sway → center
|
// Right sway → center
|
||||||
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
|
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
|
||||||
break
|
break
|
||||||
case 'blink':
|
case 'blink':
|
||||||
result = blinkEyes(result)
|
result = blinkEyes(result)
|
||||||
break
|
break
|
||||||
case 'fidget':
|
case 'fidget':
|
||||||
// Big right sway → center
|
// Big right sway → center
|
||||||
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
|
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
|
||||||
break
|
break
|
||||||
case 'bounce': {
|
case 'bounce': {
|
||||||
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
|
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
|
||||||
const h = PATTERN[tick % PATTERN.length]
|
const h = PATTERN[tick % PATTERN.length]
|
||||||
result = shiftUp(result, h)
|
result = shiftUp(result, h)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'walkLeft':
|
case 'walkLeft':
|
||||||
// Step right → center (mimics bounce-back from left step)
|
// Step right → center (mimics bounce-back from left step)
|
||||||
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
|
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
|
||||||
break
|
break
|
||||||
case 'walkRight':
|
case 'walkRight':
|
||||||
// Step right → further right → center
|
// Step right → further right → center
|
||||||
result = shiftH(result, (tick % 4) * 2)
|
result = shiftH(result, (tick % 4) * 2)
|
||||||
break
|
break
|
||||||
case 'flip':
|
case 'flip':
|
||||||
// Pure position reversal — do NOT mirror chars so / \ ( )
|
// Pure position reversal — do NOT mirror chars so / \ ( )
|
||||||
// visibly swap, making the flip obvious.
|
// visibly swap, making the flip obvious.
|
||||||
result = reverseH(result, false)
|
result = reverseH(result, false)
|
||||||
break
|
break
|
||||||
case 'excited':
|
case 'excited':
|
||||||
// Jitter right ↔ further right (never crop)
|
// Jitter right ↔ further right (never crop)
|
||||||
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
|
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
|
||||||
break
|
break
|
||||||
case 'pet':
|
case 'pet':
|
||||||
break // overlay handled by SpriteAnimator
|
break // overlay handled by SpriteAnimator
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderSprite(result)
|
return renderSprite(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
||||||
|
|
||||||
const PET_HEARTS = [
|
const PET_HEARTS = [
|
||||||
[' ♥ ', ' '],
|
[' ♥ ', ' '],
|
||||||
[' ♥ ♥ ', ' ♥ '],
|
[' ♥ ♥ ', ' ♥ '],
|
||||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||||
[' ♥ ', ' ♥ ♥ '],
|
[' ♥ ', ' ♥ ♥ '],
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getPetOverlay(tick: number): string[] {
|
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 type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
|
||||||
export const STAT_NAMES: 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> = {
|
export const STAT_LABELS: Record<StatName, string> = {
|
||||||
hp: 'HP',
|
hp: 'HP',
|
||||||
attack: 'ATK',
|
attack: 'ATK',
|
||||||
defense: 'DEF',
|
defense: 'DEF',
|
||||||
spAtk: 'SPA',
|
spAtk: 'SPA',
|
||||||
spDef: 'SPD',
|
spDef: 'SPD',
|
||||||
speed: 'SPE',
|
speed: 'SPE',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Species IDs (MVP 10 species)
|
// Species IDs (MVP 10 species)
|
||||||
export type SpeciesId =
|
export type SpeciesId =
|
||||||
| 'bulbasaur'
|
| 'bulbasaur'
|
||||||
| 'ivysaur'
|
| 'ivysaur'
|
||||||
| 'venusaur'
|
| 'venusaur'
|
||||||
| 'charmander'
|
| 'charmander'
|
||||||
| 'charmeleon'
|
| 'charmeleon'
|
||||||
| 'charizard'
|
| 'charizard'
|
||||||
| 'squirtle'
|
| 'squirtle'
|
||||||
| 'wartortle'
|
| 'wartortle'
|
||||||
| 'blastoise'
|
| 'blastoise'
|
||||||
| 'pikachu'
|
| 'pikachu'
|
||||||
|
|
||||||
export const ALL_SPECIES_IDS: SpeciesId[] = [
|
export const ALL_SPECIES_IDS: SpeciesId[] = [
|
||||||
'bulbasaur',
|
'bulbasaur',
|
||||||
'ivysaur',
|
'ivysaur',
|
||||||
'venusaur',
|
'venusaur',
|
||||||
'charmander',
|
'charmander',
|
||||||
'charmeleon',
|
'charmeleon',
|
||||||
'charizard',
|
'charizard',
|
||||||
'squirtle',
|
'squirtle',
|
||||||
'wartortle',
|
'wartortle',
|
||||||
'blastoise',
|
'blastoise',
|
||||||
'pikachu',
|
'pikachu',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Nature (delegated to @pkmn/sim Dex.natures)
|
// 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 EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship'
|
||||||
|
|
||||||
export type EvolutionCondition = {
|
export type EvolutionCondition = {
|
||||||
trigger: EvolutionTrigger
|
trigger: EvolutionTrigger
|
||||||
level?: number // Level evolution: target level
|
level?: number // Level evolution: target level
|
||||||
minFriendship?: number // Friendship evolution
|
minFriendship?: number // Friendship evolution
|
||||||
item?: string // Item evolution
|
item?: string // Item evolution
|
||||||
into: SpeciesId // Evolves into
|
into: SpeciesId // Evolves into
|
||||||
}
|
}
|
||||||
|
|
||||||
// Growth rate types (from PokeAPI)
|
// Growth rate types (from PokeAPI)
|
||||||
@@ -74,78 +74,78 @@ export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erra
|
|||||||
|
|
||||||
// Species base data
|
// Species base data
|
||||||
export type SpeciesData = {
|
export type SpeciesData = {
|
||||||
id: SpeciesId
|
id: SpeciesId
|
||||||
name: string // English name
|
name: string // English name
|
||||||
names: Record<string, string> // Multilingual names { ja, en, zh }
|
names: Record<string, string> // Multilingual names { ja, en, zh }
|
||||||
dexNumber: number // Pokédex number (1-10 MVP)
|
dexNumber: number // Pokédex number (1-10 MVP)
|
||||||
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
|
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
|
||||||
baseStats: Record<StatName, number>
|
baseStats: Record<StatName, number>
|
||||||
types: [string, string?] // Types (grass/poison, fire, water etc.)
|
types: [string, string?] // Types (grass/poison, fire, water etc.)
|
||||||
baseHappiness: number // Base friendship
|
baseHappiness: number // Base friendship
|
||||||
growthRate: GrowthRate
|
growthRate: GrowthRate
|
||||||
captureRate: number
|
captureRate: number
|
||||||
personality: string // Default personality description
|
personality: string // Default personality description
|
||||||
evolutionChain?: EvolutionCondition[]
|
evolutionChain?: EvolutionCondition[]
|
||||||
shinyChance: number // Shiny probability (default 1/4096)
|
shinyChance: number // Shiny probability (default 1/4096)
|
||||||
flavorText?: string // Pokédex description
|
flavorText?: string // Pokédex description
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantiated creature (stored in buddy-data.json)
|
// Instantiated creature (stored in buddy-data.json)
|
||||||
export type Creature = {
|
export type Creature = {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
speciesId: SpeciesId
|
speciesId: SpeciesId
|
||||||
nickname?: string // User-defined name
|
nickname?: string // User-defined name
|
||||||
gender: Gender
|
gender: Gender
|
||||||
level: number
|
level: number
|
||||||
xp: number // Current level progress XP
|
xp: number // Current level progress XP
|
||||||
totalXp: number // Total accumulated XP
|
totalXp: number // Total accumulated XP
|
||||||
nature: NatureName // Character nature
|
nature: NatureName // Character nature
|
||||||
ev: Record<StatName, number> // Effort values
|
ev: Record<StatName, number> // Effort values
|
||||||
iv: Record<StatName, number> // Individual values (0-31)
|
iv: Record<StatName, number> // Individual values (0-31)
|
||||||
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
|
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
|
||||||
ability: string // Showdown ability ID
|
ability: string // Showdown ability ID
|
||||||
heldItem: ItemId | null // Held item
|
heldItem: ItemId | null // Held item
|
||||||
friendship: number // Friendship (0-255)
|
friendship: number // Friendship (0-255)
|
||||||
isShiny: boolean
|
isShiny: boolean
|
||||||
hatchedAt: number // Timestamp when obtained
|
hatchedAt: number // Timestamp when obtained
|
||||||
pokeball: string // Pokeball type
|
pokeball: string // Pokeball type
|
||||||
}
|
}
|
||||||
|
|
||||||
// Egg
|
// Egg
|
||||||
export type Egg = {
|
export type Egg = {
|
||||||
id: string
|
id: string
|
||||||
obtainedAt: number
|
obtainedAt: number
|
||||||
stepsRemaining: number // Remaining hatch steps
|
stepsRemaining: number // Remaining hatch steps
|
||||||
totalSteps: number // Original total steps (for progress calc)
|
totalSteps: number // Original total steps (for progress calc)
|
||||||
speciesId: SpeciesId // Pre-determined species
|
speciesId: SpeciesId // Pre-determined species
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pokédex entry
|
// Pokédex entry
|
||||||
export type DexEntry = {
|
export type DexEntry = {
|
||||||
speciesId: SpeciesId
|
speciesId: SpeciesId
|
||||||
discoveredAt: number
|
discoveredAt: number
|
||||||
caughtCount: number // Number caught
|
caughtCount: number // Number caught
|
||||||
bestLevel: number // Highest level record
|
bestLevel: number // Highest level record
|
||||||
}
|
}
|
||||||
|
|
||||||
// buddy-data.json complete structure
|
// buddy-data.json complete structure
|
||||||
export type BuddyData = {
|
export type BuddyData = {
|
||||||
version: 2
|
version: 2
|
||||||
party: (string | null)[] // Always length 6, party[0] = active buddy
|
party: (string | null)[] // Always length 6, party[0] = active buddy
|
||||||
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
|
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
|
||||||
creatures: Creature[]
|
creatures: Creature[]
|
||||||
eggs: Egg[]
|
eggs: Egg[]
|
||||||
dex: DexEntry[]
|
dex: DexEntry[]
|
||||||
bag: Bag
|
bag: Bag
|
||||||
stats: {
|
stats: {
|
||||||
totalTurns: number
|
totalTurns: number
|
||||||
consecutiveDays: number
|
consecutiveDays: number
|
||||||
lastActiveDate: string // ISO date
|
lastActiveDate: string // ISO date
|
||||||
totalEggsObtained: number
|
totalEggsObtained: number
|
||||||
totalEvolutions: number
|
totalEvolutions: number
|
||||||
battlesWon: number
|
battlesWon: number
|
||||||
battlesLost: number
|
battlesLost: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculated stats result
|
// Calculated stats result
|
||||||
@@ -153,29 +153,29 @@ export type StatsResult = Record<StatName, number>
|
|||||||
|
|
||||||
// Evolution result
|
// Evolution result
|
||||||
export type EvolutionResult = {
|
export type EvolutionResult = {
|
||||||
from: SpeciesId
|
from: SpeciesId
|
||||||
to: SpeciesId
|
to: SpeciesId
|
||||||
newLevel: number
|
newLevel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprite cache entry
|
// Sprite cache entry
|
||||||
export type SpriteCache = {
|
export type SpriteCache = {
|
||||||
speciesId: SpeciesId
|
speciesId: SpeciesId
|
||||||
lines: string[]
|
lines: string[]
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
fetchedAt: number
|
fetchedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animation mode
|
// Animation mode
|
||||||
export type AnimMode =
|
export type AnimMode =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'breathe'
|
| 'breathe'
|
||||||
| 'blink'
|
| 'blink'
|
||||||
| 'fidget'
|
| 'fidget'
|
||||||
| 'bounce'
|
| 'bounce'
|
||||||
| 'walkLeft'
|
| 'walkLeft'
|
||||||
| 'walkRight'
|
| 'walkRight'
|
||||||
| 'flip'
|
| 'flip'
|
||||||
| 'excited'
|
| 'excited'
|
||||||
| 'pet'
|
| 'pet'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import type { Creature, SpeciesId } from '../types'
|
import type { Creature, SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { calculateStats, getCreatureName } from '../core/creature'
|
import { calculateStats, getCreatureName } from '../core/creature'
|
||||||
|
|
||||||
const CYAN = 'ansi:cyan'
|
const CYAN = 'ansi:cyan'
|
||||||
@@ -11,56 +11,56 @@ const GRAY = 'ansi:white'
|
|||||||
const YELLOW = 'ansi:yellow'
|
const YELLOW = 'ansi:yellow'
|
||||||
|
|
||||||
interface BattleConfigPanelProps {
|
interface BattleConfigPanelProps {
|
||||||
party: (Creature | null)[]
|
party: (Creature | null)[]
|
||||||
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
|
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
|
export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
|
||||||
const activeCreature = party[0]
|
const activeCreature = party[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
<Text bold color={CYAN}> 战斗配置 </Text>
|
<Text bold color={CYAN}> 战斗配置 </Text>
|
||||||
|
|
||||||
{/* Party display */}
|
{/* Party display */}
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold>队伍:</Text>
|
<Text bold>队伍:</Text>
|
||||||
{party.map((creature, i) => {
|
{party.map((creature, i) => {
|
||||||
if (!creature) return (
|
if (!creature) return (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Text color={GRAY}> [{i + 1}] [空]</Text>
|
<Text color={GRAY}> [{i + 1}] [空]</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
const stats = calculateStats(creature)
|
const stats = calculateStats(creature)
|
||||||
const hpPercent = 100
|
const hpPercent = 100
|
||||||
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
|
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
|
||||||
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
|
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
|
||||||
const isLead = i === 0
|
const isLead = i === 0
|
||||||
return (
|
return (
|
||||||
<Box key={creature.id}>
|
<Box key={creature.id}>
|
||||||
<Text>{isLead ? ' ▶ ' : ' '}</Text>
|
<Text>{isLead ? ' ▶ ' : ' '}</Text>
|
||||||
<Text bold={isLead}>{getCreatureName(creature)}</Text>
|
<Text bold={isLead}>{getCreatureName(creature)}</Text>
|
||||||
<Text> Lv.{creature.level} </Text>
|
<Text> Lv.{creature.level} </Text>
|
||||||
<Text color={GREEN}>{hpBar}</Text>
|
<Text color={GREEN}>{hpBar}</Text>
|
||||||
<Text color={GRAY}>{hpEmpty}</Text>
|
<Text color={GRAY}>{hpEmpty}</Text>
|
||||||
<Text> {hpPercent}%</Text>
|
<Text> {hpPercent}%</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Opponent selection */}
|
{/* Opponent selection */}
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold>对手:</Text>
|
<Text bold>对手:</Text>
|
||||||
<Text color={YELLOW}> [1] 随机遇战(等级自动匹配)</Text>
|
<Text color={YELLOW}> [1] 随机遇战(等级自动匹配)</Text>
|
||||||
<Text color={GRAY}> [2] 指定对手(输入物种名)</Text>
|
<Text color={GRAY}> [2] 指定对手(输入物种名)</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={GRAY}>[Enter] 开始战斗 [ESC] 取消</Text>
|
<Text color={GRAY}>[Enter] 开始战斗 [ESC] 取消</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react'
|
|||||||
import { Box, Text, useInput } from '@anthropic/ink'
|
import { Box, Text, useInput } from '@anthropic/ink'
|
||||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { saveBuddyData } from '../core/storage'
|
import { saveBuddyData } from '../core/storage'
|
||||||
import { createBattle, executeTurn, type BattleInit } from '../battle/engine'
|
import { createBattle, executeTurn, type BattleInit } from '../battle/engine'
|
||||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||||
@@ -15,356 +16,386 @@ import { chooseAIMove } from '../battle/ai'
|
|||||||
import type { BattleState, PlayerAction } from '../battle/types'
|
import type { BattleState, PlayerAction } from '../battle/types'
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| 'config'
|
| 'config'
|
||||||
| 'configSelect'
|
| 'configSelect'
|
||||||
| 'battle'
|
| 'battle'
|
||||||
| 'switch'
|
| 'switch'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'result'
|
| 'result'
|
||||||
| 'learnMoves'
|
| 'learnMoves'
|
||||||
| 'evolution'
|
| 'evolution'
|
||||||
| 'done'
|
| 'done'
|
||||||
|
|
||||||
interface BattleFlowProps {
|
interface BattleFlowProps {
|
||||||
buddyData: BuddyData
|
buddyData: BuddyData
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps) {
|
export function BattleFlow({ buddyData: initialData, onClose, isActive = true }: BattleFlowProps) {
|
||||||
const [phase, setPhase] = useState<Phase>('config')
|
const [phase, setPhase] = useState<Phase>('config')
|
||||||
const [buddyData, setBuddyData] = useState(initialData)
|
const [buddyData, setBuddyData] = useState(initialData)
|
||||||
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
|
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
|
||||||
const [battleState, setBattleState] = useState<BattleState | null>(null)
|
const [battleState, setBattleState] = useState<BattleState | null>(null)
|
||||||
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
|
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
|
||||||
const [opponentLevel, setOpponentLevel] = useState(5)
|
const [opponentLevel, setOpponentLevel] = useState(5)
|
||||||
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
||||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
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 }) => {
|
useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
|
||||||
// Config phase: Enter = random battle, ESC = cancel
|
// Config phase: Enter = random battle, ESC = cancel
|
||||||
if (phase === 'config') {
|
if (!isActive) return
|
||||||
if (key.escape) {
|
if (phase === 'config') {
|
||||||
onClose()
|
if (key.escape) {
|
||||||
} else if (key.return || input === '1') {
|
onClose()
|
||||||
handleRandomBattle()
|
} else if (key.return || input === '1') {
|
||||||
} else if (input === '2') {
|
handleRandomBattle()
|
||||||
setPhase('configSelect')
|
} else if (input === '2') {
|
||||||
}
|
setPhase('configSelect')
|
||||||
return
|
}
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Config select: pick species by number
|
// Config select: pick species by number
|
||||||
if (phase === 'configSelect') {
|
if (phase === 'configSelect') {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
setPhase('config')
|
setPhase('config')
|
||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
|
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel
|
// Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel
|
||||||
if (phase === 'battle') {
|
if (phase === 'battle') {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
// Can't flee from wild battle - do nothing
|
// Can't flee from wild battle - do nothing
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (input >= '1' && input <= '4') {
|
if (input >= '1' && input <= '4') {
|
||||||
const idx = parseInt(input) - 1
|
const idx = parseInt(input) - 1
|
||||||
if (battleState && idx < battleState.playerPokemon.moves.length) {
|
if (battleState && idx < battleState.playerPokemon.moves.length) {
|
||||||
handleAction({ type: 'move', moveIndex: idx })
|
handleAction({ type: 'move', moveIndex: idx })
|
||||||
}
|
}
|
||||||
} else if (input.toLowerCase() === 's') {
|
} else if (input.toLowerCase() === 's') {
|
||||||
setPhase('switch')
|
setPhase('switch')
|
||||||
} else if (input.toLowerCase() === 'i') {
|
} else if (input.toLowerCase() === 'i') {
|
||||||
setPhase('item')
|
setPhase('item')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch phase: 1-6 = select, ESC = cancel
|
// Switch phase: 1-6 = select, ESC = cancel
|
||||||
if (phase === 'switch') {
|
if (phase === 'switch') {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
setPhase('battle')
|
setPhase('battle')
|
||||||
} else if (input >= '1' && input <= '6') {
|
} else if (input >= '1' && input <= '6') {
|
||||||
const idx = parseInt(input) - 1
|
const idx = parseInt(input) - 1
|
||||||
const partyCreatures = getPartyCreatures()
|
const partyCreatures = getPartyCreatures()
|
||||||
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
|
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
|
||||||
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
|
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
|
||||||
setPhase('battle')
|
setPhase('battle')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item phase: 1-9 = select item, ESC = cancel
|
// Item phase: 1-9 = select item, ESC = cancel
|
||||||
if (phase === 'item') {
|
if (phase === 'item') {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
setPhase('battle')
|
setPhase('battle')
|
||||||
} else if (input >= '1' && input <= '9') {
|
} else if (input >= '1' && input <= '9') {
|
||||||
if (battleState) {
|
if (battleState) {
|
||||||
const idx = parseInt(input) - 1
|
const idx = parseInt(input) - 1
|
||||||
const items = battleState.usableItems
|
const items = battleState.usableItems
|
||||||
if (items[idx]) {
|
if (items[idx]) {
|
||||||
handleAction({ type: 'item', itemId: items[idx]!.id })
|
handleAction({ type: 'item', itemId: items[idx]!.id })
|
||||||
setPhase('battle')
|
setPhase('battle')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result phase: Enter = continue
|
// Result phase: Enter = continue
|
||||||
if (phase === 'result') {
|
if (phase === 'result') {
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
handleResultContinue()
|
handleResultContinue()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move learn phase: 1-4 = replace, S = skip
|
// Move learn phase: 1-4 = replace, S = skip
|
||||||
if (phase === 'learnMoves') {
|
if (phase === 'learnMoves') {
|
||||||
if (input.toLowerCase() === 's') {
|
if (input.toLowerCase() === 's') {
|
||||||
handleMoveSkip()
|
handleMoveSkip()
|
||||||
} else if (input >= '1' && input <= '4') {
|
} else if (input >= '1' && input <= '4') {
|
||||||
const idx = parseInt(input) - 1
|
const idx = parseInt(input) - 1
|
||||||
setReplaceIndex(idx)
|
setReplaceIndex(idx)
|
||||||
handleMoveLearn(idx)
|
handleMoveLearn(idx)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evolution phase: Enter = confirm
|
// Evolution phase: Enter = confirm
|
||||||
if (phase === 'evolution') {
|
if (phase === 'evolution') {
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
handleEvolutionConfirm()
|
handleEvolutionConfirm()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function getActiveCreatureLevel(): number {
|
function getActiveCreatureLevel(): number {
|
||||||
const id = buddyData.party[0]
|
const id = buddyData.party[0]
|
||||||
if (!id) return 5
|
if (!id) return 5
|
||||||
const c = buddyData.creatures.find(cr => cr.id === id)
|
const c = buddyData.creatures.find(cr => cr.id === id)
|
||||||
return c?.level ?? 5
|
return c?.level ?? 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPartyCreatures(): Creature[] {
|
function getPartyCreatures(): Creature[] {
|
||||||
return buddyData.party
|
return buddyData.party
|
||||||
.filter((id): id is string => id !== null)
|
.filter((id): id is string => id !== null)
|
||||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||||
.filter((c): c is Creature => c !== undefined)
|
.filter((c): c is Creature => c !== undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ───
|
// ─── Actions ───
|
||||||
|
|
||||||
const handleRandomBattle = useCallback(() => {
|
const handleRandomBattle = useCallback(() => {
|
||||||
const opponentLevel = getActiveCreatureLevel()
|
const opponentLevel = getActiveCreatureLevel()
|
||||||
const speciesList = ALL_SPECIES_IDS
|
const speciesList = ALL_SPECIES_IDS
|
||||||
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
|
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
|
||||||
handleStartBattle(randomSpecies, opponentLevel)
|
handleStartBattle(randomSpecies, opponentLevel)
|
||||||
}, [buddyData])
|
}, [buddyData])
|
||||||
|
|
||||||
// Config phase: start battle
|
// Config phase: start battle
|
||||||
const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
|
const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
|
||||||
setOpponentSpeciesId(speciesId)
|
setOpponentSpeciesId(speciesId)
|
||||||
setOpponentLevel(level)
|
setOpponentLevel(level)
|
||||||
|
|
||||||
const creatures = buddyData.party
|
const creatures = buddyData.party
|
||||||
.filter((id): id is string => id !== null)
|
.filter((id): id is string => id !== null)
|
||||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||||
.filter((c): c is Creature => c !== undefined)
|
.filter((c): c is Creature => c !== undefined)
|
||||||
|
|
||||||
if (creatures.length === 0) return
|
if (creatures.length === 0) return
|
||||||
|
|
||||||
const bagItems = buddyData.bag.items
|
const bagItems = buddyData.bag.items
|
||||||
const init = createBattle(creatures, speciesId, level, bagItems)
|
const init = createBattle(creatures, speciesId, level, bagItems)
|
||||||
setBattleInit(init)
|
setBattleInit(init)
|
||||||
setBattleState(init.state)
|
setBattleState(init.state)
|
||||||
setPhase('battle')
|
setPhase('battle')
|
||||||
}, [buddyData])
|
}, [buddyData])
|
||||||
|
|
||||||
// Battle phase: handle action
|
// Battle phase: handle action
|
||||||
const handleAction = useCallback(async (action: PlayerAction) => {
|
const handleAction = useCallback(async (action: PlayerAction) => {
|
||||||
if (!battleInit) return
|
if (!battleInit) return
|
||||||
const state = executeTurn(battleInit, action)
|
const state = executeTurn(battleInit, action)
|
||||||
setBattleState(state)
|
setBattleState(state)
|
||||||
|
|
||||||
if (state.finished && state.result) {
|
if (state.finished && state.result) {
|
||||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||||
const result = { ...state.result, participantIds: participants }
|
const result = { ...state.result, participantIds: participants }
|
||||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||||
|
|
||||||
setBuddyData(settled.data)
|
setBuddyData(settled.data)
|
||||||
setPendingMoves(settled.learnableMoves)
|
setPendingMoves(settled.learnableMoves)
|
||||||
setPendingEvos(settled.pendingEvolutions)
|
setPendingEvos(settled.pendingEvolutions)
|
||||||
setBattleState({ ...state, result })
|
setBattleState({ ...state, result })
|
||||||
setPhase('result')
|
setPhase('result')
|
||||||
}
|
}
|
||||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||||
|
|
||||||
// Result phase: continue to move learning
|
// Result phase: continue to move learning
|
||||||
const handleResultContinue = useCallback(() => {
|
const handleResultContinue = useCallback(() => {
|
||||||
if (pendingMoves.length > 0) {
|
if (pendingMoves.length > 0) {
|
||||||
setPhase('learnMoves')
|
setPhase('learnMoves')
|
||||||
} else if (pendingEvos.length > 0) {
|
} else if (pendingEvos.length > 0) {
|
||||||
setPhase('evolution')
|
setPhase('evolution')
|
||||||
} else {
|
} else {
|
||||||
saveBuddyData(buddyData)
|
saveBuddyData(buddyData)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||||
|
|
||||||
// Move learning
|
// Move learning
|
||||||
const handleMoveLearn = useCallback((idx: number) => {
|
const handleMoveLearn = useCallback((idx: number) => {
|
||||||
if (pendingMoves.length === 0) return
|
if (pendingMoves.length === 0) return
|
||||||
const move = pendingMoves[0]!
|
const move = pendingMoves[0]!
|
||||||
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
|
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
|
||||||
setBuddyData(updated)
|
setBuddyData(updated)
|
||||||
const remaining = pendingMoves.slice(1)
|
const remaining = pendingMoves.slice(1)
|
||||||
setPendingMoves(remaining)
|
setPendingMoves(remaining)
|
||||||
if (remaining.length === 0) {
|
if (remaining.length === 0) {
|
||||||
if (pendingEvos.length > 0) {
|
if (pendingEvos.length > 0) {
|
||||||
setPhase('evolution')
|
setPhase('evolution')
|
||||||
} else {
|
} else {
|
||||||
saveBuddyData(updated)
|
saveBuddyData(updated)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||||
|
|
||||||
const handleMoveSkip = useCallback(() => {
|
const handleMoveSkip = useCallback(() => {
|
||||||
const remaining = pendingMoves.slice(1)
|
const remaining = pendingMoves.slice(1)
|
||||||
setPendingMoves(remaining)
|
setPendingMoves(remaining)
|
||||||
if (remaining.length === 0) {
|
if (remaining.length === 0) {
|
||||||
if (pendingEvos.length > 0) {
|
if (pendingEvos.length > 0) {
|
||||||
setPhase('evolution')
|
setPhase('evolution')
|
||||||
} else {
|
} else {
|
||||||
saveBuddyData(buddyData)
|
saveBuddyData(buddyData)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||||
|
|
||||||
// Evolution
|
// Evolution
|
||||||
const handleEvolutionConfirm = useCallback(() => {
|
const handleEvolutionConfirm = useCallback(() => {
|
||||||
if (pendingEvos.length === 0) return
|
if (pendingEvos.length === 0) return
|
||||||
const evo = pendingEvos[0]!
|
const evo = pendingEvos[0]!
|
||||||
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
|
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
|
||||||
setBuddyData(updated)
|
setBuddyData(updated)
|
||||||
const remaining = pendingEvos.slice(1)
|
const remaining = pendingEvos.slice(1)
|
||||||
setPendingEvos(remaining)
|
setPendingEvos(remaining)
|
||||||
if (remaining.length === 0) {
|
if (remaining.length === 0) {
|
||||||
saveBuddyData(updated)
|
saveBuddyData(updated)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}, [pendingEvos, buddyData, onClose])
|
}, [pendingEvos, buddyData, onClose])
|
||||||
|
|
||||||
// Render by phase
|
// Render by phase
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case 'config':
|
case 'config':
|
||||||
case 'configSelect':
|
return (
|
||||||
return (
|
<BattleConfigPanel
|
||||||
<BattleConfigPanel
|
party={getPartyCreatures()}
|
||||||
party={getPartyCreatures()}
|
onSubmit={handleStartBattle}
|
||||||
onSubmit={handleStartBattle}
|
onCancel={onClose}
|
||||||
onCancel={onClose}
|
/>
|
||||||
/>
|
)
|
||||||
)
|
|
||||||
|
|
||||||
case 'battle': {
|
case 'configSelect': {
|
||||||
if (!battleState) return null
|
const species = getSpeciesData(opponentSpeciesId)
|
||||||
return (
|
const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
|
||||||
<BattleView
|
const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
|
||||||
state={battleState}
|
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
|
||||||
onAction={handleAction}
|
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': {
|
case 'battle': {
|
||||||
if (!battleState) return null
|
if (!battleState) return null
|
||||||
return (
|
return (
|
||||||
<SwitchPanel
|
<BattleView
|
||||||
party={getPartyCreatures()}
|
state={battleState}
|
||||||
activeId={battleState.playerPokemon.id}
|
onAction={handleAction}
|
||||||
onSelect={(creatureId) => {
|
/>
|
||||||
handleAction({ type: 'switch', creatureId })
|
)
|
||||||
setPhase('battle')
|
}
|
||||||
}}
|
|
||||||
onCancel={() => setPhase('battle')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'item': {
|
case 'switch': {
|
||||||
if (!battleState) return null
|
if (!battleState) return null
|
||||||
return (
|
return (
|
||||||
<ItemPanel
|
<SwitchPanel
|
||||||
items={battleState.usableItems}
|
party={getPartyCreatures()}
|
||||||
onSelect={(itemId) => {
|
activeId={battleState.playerPokemon.id}
|
||||||
handleAction({ type: 'item', itemId })
|
onSelect={(creatureId) => {
|
||||||
setPhase('battle')
|
handleAction({ type: 'switch', creatureId })
|
||||||
}}
|
setPhase('battle')
|
||||||
onCancel={() => setPhase('battle')}
|
}}
|
||||||
/>
|
onCancel={() => setPhase('battle')}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'result': {
|
case 'item': {
|
||||||
if (!battleState?.result) return null
|
if (!battleState) return null
|
||||||
return (
|
return (
|
||||||
<BattleResultPanel
|
<ItemPanel
|
||||||
result={battleState.result}
|
items={battleState.usableItems}
|
||||||
playerPokemon={battleState.playerPokemon}
|
onSelect={(itemId) => {
|
||||||
onContinue={handleResultContinue}
|
handleAction({ type: 'item', itemId })
|
||||||
/>
|
setPhase('battle')
|
||||||
)
|
}}
|
||||||
}
|
onCancel={() => setPhase('battle')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'learnMoves': {
|
case 'result': {
|
||||||
if (pendingMoves.length === 0) return null
|
if (!battleState?.result) return null
|
||||||
const move = pendingMoves[0]!
|
return (
|
||||||
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
|
<BattleResultPanel
|
||||||
if (!creature) return null
|
result={battleState.result}
|
||||||
return (
|
playerPokemon={battleState.playerPokemon}
|
||||||
<MoveLearnPanel
|
onContinue={handleResultContinue}
|
||||||
creature={creature}
|
/>
|
||||||
newMoveId={move.moveId}
|
)
|
||||||
replaceIndex={replaceIndex}
|
}
|
||||||
onLearn={handleMoveLearn}
|
|
||||||
onSkip={handleMoveSkip}
|
|
||||||
onSelectReplace={setReplaceIndex}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'evolution': {
|
case 'learnMoves': {
|
||||||
if (pendingEvos.length === 0) return null
|
if (pendingMoves.length === 0) return null
|
||||||
const evo = pendingEvos[0]!
|
const move = pendingMoves[0]!
|
||||||
return (
|
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
if (!creature) return null
|
||||||
<Text bold color="ansi:yellow"> 进化!</Text>
|
return (
|
||||||
<Text> {evo.from} 正在进化为 {evo.to}!</Text>
|
<MoveLearnPanel
|
||||||
<Text color="ansi:white"> [Enter] 继续</Text>
|
creature={creature}
|
||||||
</Box>
|
newMoveId={move.moveId}
|
||||||
)
|
replaceIndex={replaceIndex}
|
||||||
}
|
onLearn={handleMoveLearn}
|
||||||
|
onSkip={handleMoveSkip}
|
||||||
|
onSelectReplace={setReplaceIndex}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'done':
|
case 'evolution': {
|
||||||
return null
|
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:
|
case 'done':
|
||||||
return null
|
return null
|
||||||
}
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,40 +9,40 @@ const CYAN = 'ansi:cyan'
|
|||||||
const WHITE = 'ansi:whiteBright'
|
const WHITE = 'ansi:whiteBright'
|
||||||
|
|
||||||
interface BattleResultPanelProps {
|
interface BattleResultPanelProps {
|
||||||
result: BattleResult
|
result: BattleResult
|
||||||
playerPokemon: BattlePokemon
|
playerPokemon: BattlePokemon
|
||||||
onContinue: () => void
|
onContinue: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
|
export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
|
||||||
const isWin = result.winner === 'player'
|
const isWin = result.winner === 'player'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold color={isWin ? GREEN : RED}>
|
<Text bold color={isWin ? GREEN : RED}>
|
||||||
{' '}战斗结束!{isWin ? '胜利!' : '失败...'}
|
{' '}战斗结束!{isWin ? '胜利!' : '失败...'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isWin && (
|
{isWin && (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text> {playerPokemon.name} 获得了 {result.xpGained} 经验值!</Text>
|
<Text> {playerPokemon.name} 获得了 {result.xpGained} 经验值!</Text>
|
||||||
|
|
||||||
{Object.keys(result.evGained).length > 0 && (
|
{Object.keys(result.evGained).length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text> 努力值获得: </Text>
|
<Text> 努力值获得: </Text>
|
||||||
{Object.entries(result.evGained).map(([stat, value]) => (
|
{Object.entries(result.evGained).map(([stat, value]) => (
|
||||||
<Text key={stat}> {stat.toUpperCase()}+{value} </Text>
|
<Text key={stat}> {stat.toUpperCase()}+{value} </Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={CYAN}> [Enter] 继续</Text>
|
<Text color={CYAN}> [Enter] 继续</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,117 +10,117 @@ const GRAY = 'ansi:white'
|
|||||||
const WHITE = 'ansi:whiteBright'
|
const WHITE = 'ansi:whiteBright'
|
||||||
|
|
||||||
function hpColor(pct: number): Color {
|
function hpColor(pct: number): Color {
|
||||||
if (pct > 50) return GREEN
|
if (pct > 50) return GREEN
|
||||||
if (pct > 25) return YELLOW
|
if (pct > 25) return YELLOW
|
||||||
return RED
|
return RED
|
||||||
}
|
}
|
||||||
|
|
||||||
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
||||||
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
|
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
|
||||||
const pct = Math.round((current / max) * 100)
|
const pct = Math.round((current / max) * 100)
|
||||||
const filled = Math.round((current / max) * 10)
|
const filled = Math.round((current / max) * 10)
|
||||||
return {
|
return {
|
||||||
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
|
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
|
||||||
pct,
|
pct,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BattleViewProps {
|
interface BattleViewProps {
|
||||||
state: BattleState
|
state: BattleState
|
||||||
onAction: (action: import('../battle/types').PlayerAction) => void
|
onAction: (action: import('../battle/types').PlayerAction) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BattleView({ state, onAction }: BattleViewProps) {
|
export function BattleView({ state, onAction }: BattleViewProps) {
|
||||||
const opp = state.opponentPokemon
|
const opp = state.opponentPokemon
|
||||||
const player = state.playerPokemon
|
const player = state.playerPokemon
|
||||||
const oppHp = hpBar(opp.hp, opp.maxHp)
|
const oppHp = hpBar(opp.hp, opp.maxHp)
|
||||||
const playerHp = hpBar(player.hp, player.maxHp)
|
const playerHp = hpBar(player.hp, player.maxHp)
|
||||||
|
|
||||||
// Show last 5 events
|
// Show last 5 events
|
||||||
const recentEvents = state.events.slice(-5)
|
const recentEvents = state.events.slice(-5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
{/* Opponent */}
|
{/* Opponent */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold> 野生 {opp.name} </Text>
|
<Text bold> 野生 {opp.name} </Text>
|
||||||
<Text>(Lv.{opp.level})</Text>
|
<Text>(Lv.{opp.level})</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text> HP </Text>
|
<Text> HP </Text>
|
||||||
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
|
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
|
||||||
<Text> {oppHp.pct}%</Text>
|
<Text> {oppHp.pct}%</Text>
|
||||||
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
|
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={GRAY}> ── vs ──</Text>
|
<Text color={GRAY}> ── vs ──</Text>
|
||||||
|
|
||||||
{/* Player */}
|
{/* Player */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold> {player.name} </Text>
|
<Text bold> {player.name} </Text>
|
||||||
<Text>(Lv.{player.level})</Text>
|
<Text>(Lv.{player.level})</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text> HP </Text>
|
<Text> HP </Text>
|
||||||
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
|
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
|
||||||
<Text> {playerHp.pct}%</Text>
|
<Text> {playerHp.pct}%</Text>
|
||||||
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
|
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Move selection */}
|
{/* Move selection */}
|
||||||
{!state.finished && (
|
{!state.finished && (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold> 选择行动:</Text>
|
<Text bold> 选择行动:</Text>
|
||||||
{player.moves.map((move, i) => (
|
{player.moves.map((move, i) => (
|
||||||
<Box key={move.id || i}>
|
<Box key={move.id || i}>
|
||||||
<Text color={move.pp > 0 ? WHITE : GRAY}>
|
<Text color={move.pp > 0 ? WHITE : GRAY}>
|
||||||
{' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
|
{' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Text color={CYAN}> [S] 换人 [I] 道具</Text>
|
<Text color={CYAN}> [S] 换人 [I] 道具</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Event log */}
|
{/* Event log */}
|
||||||
{recentEvents.length > 0 && (
|
{recentEvents.length > 0 && (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
{recentEvents.map((event, i) => (
|
{recentEvents.map((event, i) => (
|
||||||
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
|
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventColor(event: BattleEvent): Color {
|
function eventColor(event: BattleEvent): Color {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'damage': return RED
|
case 'damage': return RED
|
||||||
case 'heal': return GREEN
|
case 'heal': return GREEN
|
||||||
case 'faint': return RED
|
case 'faint': return RED
|
||||||
case 'crit': return YELLOW
|
case 'crit': return YELLOW
|
||||||
case 'miss': return GRAY
|
case 'miss': return GRAY
|
||||||
case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
|
case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
|
||||||
default: return WHITE
|
default: return WHITE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEvent(event: BattleEvent): string {
|
function formatEvent(event: BattleEvent): string {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
|
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
|
||||||
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
|
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
|
||||||
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
|
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
|
||||||
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!`
|
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!`
|
||||||
case 'crit': return '击中要害!'
|
case 'crit': return '击中要害!'
|
||||||
case 'miss': return '攻击没有命中!'
|
case 'miss': return '攻击没有命中!'
|
||||||
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
|
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
|
||||||
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
|
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
|
||||||
case 'turn': return `── 回合 ${event.number} ──`
|
case 'turn': return `── 回合 ${event.number} ──`
|
||||||
default: return ''
|
default: return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@ import React from 'react'
|
|||||||
import { Box, Text, type Color } from '@anthropic/ink'
|
import { Box, Text, type Color } from '@anthropic/ink'
|
||||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||||
import { STAT_NAMES, STAT_LABELS } from '../types'
|
import { STAT_NAMES, STAT_LABELS } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { SPECIES_PERSONALITY } from '../data/names'
|
import { SPECIES_PERSONALITY } from '../dex/names'
|
||||||
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
|
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
|
||||||
import { getXpProgress } from '../core/experience'
|
import { getXpProgress } from '../core/experience'
|
||||||
import { getEVSummary } from '../core/effort'
|
import { getEVSummary } from '../core/effort'
|
||||||
import { getGenderSymbol } from '../core/gender'
|
import { getGenderSymbol } from '../core/gender'
|
||||||
import { getStatColor } from './shared'
|
import { getStatColor } from './shared'
|
||||||
import { getNextEvolution } from '../data/evolution'
|
import { getNextEvolution } from '../dex/evolution'
|
||||||
import { StatBar } from './StatBar'
|
import { StatBar } from './StatBar'
|
||||||
|
|
||||||
interface CompanionCardProps {
|
interface CompanionCardProps {
|
||||||
creature: Creature
|
creature: Creature
|
||||||
buddyData: BuddyData
|
buddyData: BuddyData
|
||||||
spriteLines?: string[]
|
spriteLines?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ANSI color constants
|
// ANSI color constants
|
||||||
@@ -30,130 +30,130 @@ const GRAY: Color = 'ansi:white'
|
|||||||
|
|
||||||
/** Type → display color mapping */
|
/** Type → display color mapping */
|
||||||
const TYPE_COLORS: Record<string, Color> = {
|
const TYPE_COLORS: Record<string, Color> = {
|
||||||
grass: 'ansi:green',
|
grass: 'ansi:green',
|
||||||
poison: 'ansi:magenta',
|
poison: 'ansi:magenta',
|
||||||
fire: 'ansi:red',
|
fire: 'ansi:red',
|
||||||
flying: 'ansi:cyan',
|
flying: 'ansi:cyan',
|
||||||
water: 'ansi:blue',
|
water: 'ansi:blue',
|
||||||
electric: 'ansi:yellow',
|
electric: 'ansi:yellow',
|
||||||
normal: 'ansi:white',
|
normal: 'ansi:white',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redesigned companion card with Pokémon-style stats display.
|
* Redesigned companion card with Pokémon-style stats display.
|
||||||
*/
|
*/
|
||||||
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
|
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
|
||||||
const species = getSpeciesData(creature.speciesId)
|
const species = getSpeciesData(creature.speciesId)
|
||||||
const stats = calculateStats(creature)
|
const stats = calculateStats(creature)
|
||||||
const xp = getXpProgress(creature)
|
const xp = getXpProgress(creature)
|
||||||
const genderSymbol = getGenderSymbol(creature.gender)
|
const genderSymbol = getGenderSymbol(creature.gender)
|
||||||
const name = getCreatureName(creature)
|
const name = getCreatureName(creature)
|
||||||
const evSummary = getEVSummary(creature)
|
const evSummary = getEVSummary(creature)
|
||||||
const totalEV = getTotalEV(creature)
|
const totalEV = getTotalEV(creature)
|
||||||
const nextEvo = getNextEvolution(creature.speciesId)
|
const nextEvo = getNextEvolution(creature.speciesId)
|
||||||
|
|
||||||
// Type badges
|
// Type badges
|
||||||
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
||||||
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||||
{i > 0 ? '/' : ''}{t.toUpperCase()}
|
{i > 0 ? '/' : ''}{t.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
))
|
||||||
|
|
||||||
// Friendship color
|
// Friendship color
|
||||||
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
|
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
|
||||||
|
|
||||||
// Shiny badge
|
// Shiny badge
|
||||||
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null
|
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null
|
||||||
|
|
||||||
// Evolution hint
|
// Evolution hint
|
||||||
const evoHint = nextEvo ? (
|
const evoHint = nextEvo ? (
|
||||||
<Text color={GRAY}> → <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
|
<Text color={GRAY}> → <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<Box justifyContent="space-between">
|
<Box justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold color={CYAN}>{name}</Text>
|
<Text bold color={CYAN}>{name}</Text>
|
||||||
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||||
{shinyBadge}
|
{shinyBadge}
|
||||||
</Box>
|
</Box>
|
||||||
<Text bold>Lv.{creature.level}</Text>
|
<Text bold>Lv.{creature.level}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Species + type + gender */}
|
{/* Species + type + gender */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
{typeBadges}
|
{typeBadges}
|
||||||
{genderSymbol && <Text> {genderSymbol}</Text>}
|
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sprite */}
|
{/* Sprite */}
|
||||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
{spriteLines ? (
|
{spriteLines ? (
|
||||||
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
|
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
|
||||||
) : (
|
) : (
|
||||||
<Text color={GRAY}>[Loading sprite...]</Text>
|
<Text color={GRAY}>[Loading sprite...]</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Personality */}
|
{/* Personality */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
|
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Stats section */}
|
{/* Stats section */}
|
||||||
<Box flexDirection="column" marginTop={0}>
|
<Box flexDirection="column" marginTop={0}>
|
||||||
<Text color={GRAY}>─── Base Stats ───</Text>
|
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||||
{STAT_NAMES.map((stat) => (
|
{STAT_NAMES.map((stat) => (
|
||||||
<StatBar
|
<StatBar
|
||||||
key={stat}
|
key={stat}
|
||||||
label={STAT_LABELS[stat]}
|
label={STAT_LABELS[stat]}
|
||||||
value={stats[stat]}
|
value={stats[stat]}
|
||||||
maxValue={255}
|
maxValue={255}
|
||||||
color={getStatColor(stat)}
|
color={getStatColor(stat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* XP progress */}
|
{/* XP progress */}
|
||||||
<Box marginTop={0}>
|
<Box marginTop={0}>
|
||||||
<Text color={GRAY}>XP </Text>
|
<Text color={GRAY}>XP </Text>
|
||||||
<Text color={BLUE}>
|
<Text color={BLUE}>
|
||||||
{'█'.repeat(Math.round(xp.percentage / 10))}
|
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||||
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||||
</Text>
|
</Text>
|
||||||
<Text> {xp.current}/{xp.needed}</Text>
|
<Text> {xp.current}/{xp.needed}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* EV + Friendship */}
|
{/* EV + Friendship */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>EV </Text>
|
<Text color={GRAY}>EV </Text>
|
||||||
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||||
<Text color={GRAY}> ({totalEV}/510)</Text>
|
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>♥ </Text>
|
<Text color={GRAY}>♥ </Text>
|
||||||
<Text color={friendshipColor}>
|
<Text color={friendshipColor}>
|
||||||
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||||
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||||
</Text>
|
</Text>
|
||||||
<Text> {creature.friendship}/255</Text>
|
<Text> {creature.friendship}/255</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Evolution hint */}
|
{/* Evolution hint */}
|
||||||
{evoHint && (
|
{evoHint && (
|
||||||
<Box marginTop={0}>
|
<Box marginTop={0}>
|
||||||
<Text color={GRAY}>Next: </Text>
|
<Text color={GRAY}>Next: </Text>
|
||||||
{evoHint}
|
{evoHint}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,48 +7,48 @@ const YELLOW: Color = 'ansi:yellow'
|
|||||||
const GRAY: Color = 'ansi:white'
|
const GRAY: Color = 'ansi:white'
|
||||||
|
|
||||||
interface EggViewProps {
|
interface EggViewProps {
|
||||||
egg: Egg
|
egg: Egg
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Egg status view showing hatch progress.
|
* Egg status view showing hatch progress.
|
||||||
*/
|
*/
|
||||||
export function EggView({ egg }: EggViewProps) {
|
export function EggView({ egg }: EggViewProps) {
|
||||||
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
|
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
|
||||||
const filled = Math.round(percentage / 10)
|
const filled = Math.round(percentage / 10)
|
||||||
const empty = 10 - filled
|
const empty = 10 - filled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||||
<Text bold color={CYAN}>
|
<Text bold color={CYAN}>
|
||||||
Egg Status
|
Egg Status
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* ASCII egg */}
|
{/* ASCII egg */}
|
||||||
<Box flexDirection="column" alignItems="center" marginY={1}>
|
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||||
<Text> . </Text>
|
<Text> . </Text>
|
||||||
<Text> / \ </Text>
|
<Text> / \ </Text>
|
||||||
<Text> | | </Text>
|
<Text> | | </Text>
|
||||||
<Text> \_/ </Text>
|
<Text> \_/ </Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<Box flexDirection="column" alignItems="center">
|
<Box flexDirection="column" alignItems="center">
|
||||||
<Text>
|
<Text>
|
||||||
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={YELLOW}>
|
<Text color={YELLOW}>
|
||||||
{'█'.repeat(filled)}
|
{'█'.repeat(filled)}
|
||||||
{'░'.repeat(empty)}
|
{'░'.repeat(empty)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>{percentage}%</Text>
|
<Text>{percentage}%</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Box marginTop={1} flexDirection="column" alignItems="center">
|
<Box marginTop={1} flexDirection="column" alignItems="center">
|
||||||
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||||
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Box, Text, type Color } from '@anthropic/ink'
|
import { Box, Text, type Color } from '@anthropic/ink'
|
||||||
import type { SpeciesId } from '../types'
|
import type { SpeciesId } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { loadSprite } from '../core/spriteCache'
|
import { loadSprite } from '../core/spriteCache'
|
||||||
import { getFallbackSprite } from '../sprites/fallback'
|
import { getFallbackSprite } from '../sprites/fallback'
|
||||||
|
|
||||||
@@ -10,9 +10,9 @@ const GREEN: Color = 'ansi:green'
|
|||||||
const GRAY: Color = 'ansi:white'
|
const GRAY: Color = 'ansi:white'
|
||||||
|
|
||||||
interface EvolutionAnimProps {
|
interface EvolutionAnimProps {
|
||||||
fromSpecies: SpeciesId
|
fromSpecies: SpeciesId
|
||||||
toSpecies: SpeciesId
|
toSpecies: SpeciesId
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,70 +21,70 @@ interface EvolutionAnimProps {
|
|||||||
* 8 frames × 500ms = ~4 seconds total.
|
* 8 frames × 500ms = ~4 seconds total.
|
||||||
*/
|
*/
|
||||||
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
|
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const totalFrames = 8
|
const totalFrames = 8
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tick >= totalFrames) {
|
if (tick >= totalFrames) {
|
||||||
onComplete()
|
onComplete()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => setTick((t) => t + 1), 500)
|
const timer = setTimeout(() => setTick((t) => t + 1), 500)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [tick, onComplete])
|
}, [tick, onComplete])
|
||||||
|
|
||||||
const fromSprite = getSpriteLines(fromSpecies)
|
const fromSprite = getSpriteLines(fromSpecies)
|
||||||
const toSprite = getSpriteLines(toSpecies)
|
const toSprite = getSpriteLines(toSpecies)
|
||||||
const fromName = getSpeciesData(fromSpecies).name
|
const fromName = getSpeciesData(fromSpecies).name
|
||||||
const toName = getSpeciesData(toSpecies).name
|
const toName = getSpeciesData(toSpecies).name
|
||||||
|
|
||||||
// Frame logic:
|
// Frame logic:
|
||||||
// 0-3: old sprite with flash (alternate blank)
|
// 0-3: old sprite with flash (alternate blank)
|
||||||
// 4-7: alternate old/new, settle on new
|
// 4-7: alternate old/new, settle on new
|
||||||
let displayLines: string[]
|
let displayLines: string[]
|
||||||
if (tick < 3) {
|
if (tick < 3) {
|
||||||
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
|
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
|
||||||
} else if (tick < 6) {
|
} else if (tick < 6) {
|
||||||
displayLines = tick % 2 === 0 ? fromSprite : toSprite
|
displayLines = tick % 2 === 0 ? fromSprite : toSprite
|
||||||
} else {
|
} else {
|
||||||
displayLines = toSprite
|
displayLines = toSprite
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||||
<Text bold color={YELLOW}>
|
<Text bold color={YELLOW}>
|
||||||
✨ Evolution! ✨
|
✨ Evolution! ✨
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box flexDirection="column" alignItems="center" marginY={1}>
|
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||||
{displayLines.map((line, i) => (
|
{displayLines.map((line, i) => (
|
||||||
<Text key={i}>
|
<Text key={i}>
|
||||||
{tick >= 6 ? '✨ ' : ''}
|
{tick >= 6 ? '✨ ' : ''}
|
||||||
{line}
|
{line}
|
||||||
{tick >= 6 ? ' ✨' : ''}
|
{tick >= 6 ? ' ✨' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={GRAY}>{fromName}</Text>
|
<Text color={GRAY}>{fromName}</Text>
|
||||||
<Text color={YELLOW}> → </Text>
|
<Text color={YELLOW}> → </Text>
|
||||||
<Text bold color={GREEN}>
|
<Text bold color={GREEN}>
|
||||||
{toName}
|
{toName}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{tick >= totalFrames - 1 && (
|
{tick >= totalFrames - 1 && (
|
||||||
<Text bold color={GREEN}>
|
<Text bold color={GREEN}>
|
||||||
进化成功!
|
进化成功!
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpriteLines(speciesId: SpeciesId): string[] {
|
function getSpriteLines(speciesId: SpeciesId): string[] {
|
||||||
const cached = loadSprite(speciesId)
|
const cached = loadSprite(speciesId)
|
||||||
if (cached) return cached.lines
|
if (cached) return cached.lines
|
||||||
return getFallbackSprite(speciesId)
|
return getFallbackSprite(speciesId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,28 @@ const CYAN = 'ansi:cyan'
|
|||||||
const GRAY = 'ansi:white'
|
const GRAY = 'ansi:white'
|
||||||
|
|
||||||
interface ItemPanelProps {
|
interface ItemPanelProps {
|
||||||
items: { id: string; name: string; count: number; description?: string }[]
|
items: { id: string; name: string; count: number; description?: string }[]
|
||||||
onSelect: (itemId: string) => void
|
onSelect: (itemId: string) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
|
export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
<Text bold color={CYAN}> 道具 </Text>
|
<Text bold color={CYAN}> 道具 </Text>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<Text color={GRAY}> 没有可用道具</Text>
|
<Text color={GRAY}> 没有可用道具</Text>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, i) => (
|
items.map((item, i) => (
|
||||||
<Box key={item.id}>
|
<Box key={item.id}>
|
||||||
<Text> [{i + 1}] {item.name} ×{item.count}</Text>
|
<Text> [{i + 1}] {item.name} ×{item.count}</Text>
|
||||||
{item.description && <Text color={GRAY}> {item.description}</Text>}
|
{item.description && <Text color={GRAY}> {item.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={GRAY}> [ESC] 取消</Text>
|
<Text color={GRAY}> [ESC] 取消</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,41 +9,41 @@ const GRAY = 'ansi:white'
|
|||||||
const WHITE = 'ansi:whiteBright'
|
const WHITE = 'ansi:whiteBright'
|
||||||
|
|
||||||
interface MoveLearnPanelProps {
|
interface MoveLearnPanelProps {
|
||||||
creature: Creature
|
creature: Creature
|
||||||
newMoveId: string
|
newMoveId: string
|
||||||
replaceIndex: number
|
replaceIndex: number
|
||||||
onLearn: (replaceIndex: number) => void
|
onLearn: (replaceIndex: number) => void
|
||||||
onSkip: () => void
|
onSkip: () => void
|
||||||
onSelectReplace: (index: number) => void
|
onSelectReplace: (index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
|
export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
|
||||||
const dexMove = Dex.moves.get(newMoveId)
|
const dexMove = Dex.moves.get(newMoveId)
|
||||||
const moveName = dexMove?.name ?? newMoveId
|
const moveName = dexMove?.name ?? newMoveId
|
||||||
const moveType = dexMove?.type ?? 'Normal'
|
const moveType = dexMove?.type ?? 'Normal'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
<Text bold color={CYAN}> 新招式!</Text>
|
<Text bold color={CYAN}> 新招式!</Text>
|
||||||
<Text> {creature.speciesId} 可以学习: <Text bold>{moveName}</Text> ({moveType})</Text>
|
<Text> {creature.speciesId} 可以学习: <Text bold>{moveName}</Text> ({moveType})</Text>
|
||||||
|
|
||||||
<Box marginTop={1}><Text bold> 当前招式:</Text></Box>
|
<Box marginTop={1}><Text bold> 当前招式:</Text></Box>
|
||||||
{creature.moves.map((move, i) => {
|
{creature.moves.map((move, i) => {
|
||||||
const isReplaceTarget = i === replaceIndex
|
const isReplaceTarget = i === replaceIndex
|
||||||
const moveInfo = move.id ? Dex.moves.get(move.id) : null
|
const moveInfo = move.id ? Dex.moves.get(move.id) : null
|
||||||
return (
|
return (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Text color={isReplaceTarget ? YELLOW : WHITE}>
|
<Text color={isReplaceTarget ? YELLOW : WHITE}>
|
||||||
{' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
|
{' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
|
||||||
</Text>
|
</Text>
|
||||||
{isReplaceTarget && <Text color={YELLOW}> ← 替换目标</Text>}
|
{isReplaceTarget && <Text color={YELLOW}> ← 替换目标</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={GRAY}> [1-4] 替换对应招式 [S] 跳过</Text>
|
<Text color={GRAY}> [1-4] 替换对应招式 [S] 跳过</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React from 'react'
|
|||||||
import { Box, Text, type Color } from '@anthropic/ink'
|
import { Box, Text, type Color } from '@anthropic/ink'
|
||||||
import type { BuddyData, SpeciesId } from '../types'
|
import type { BuddyData, SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { getNextEvolution } from '../data/evolution'
|
import { getNextEvolution } from '../dex/evolution'
|
||||||
|
|
||||||
const CYAN: Color = 'ansi:cyan'
|
const CYAN: Color = 'ansi:cyan'
|
||||||
const GREEN: Color = 'ansi:green'
|
const GREEN: Color = 'ansi:green'
|
||||||
@@ -14,7 +14,7 @@ const RED: Color = 'ansi:red'
|
|||||||
const BLUE: Color = 'ansi:blue'
|
const BLUE: Color = 'ansi:blue'
|
||||||
|
|
||||||
interface PokedexViewProps {
|
interface PokedexViewProps {
|
||||||
buddyData: BuddyData
|
buddyData: BuddyData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,165 +22,165 @@ interface PokedexViewProps {
|
|||||||
* evolution chains, and active creature indicator.
|
* evolution chains, and active creature indicator.
|
||||||
*/
|
*/
|
||||||
export function PokedexView({ buddyData }: PokedexViewProps) {
|
export function PokedexView({ buddyData }: PokedexViewProps) {
|
||||||
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
|
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
|
||||||
const collected = buddyData.dex.length
|
const collected = buddyData.dex.length
|
||||||
const total = ALL_SPECIES_IDS.length
|
const total = ALL_SPECIES_IDS.length
|
||||||
|
|
||||||
// Group species by evolution chain
|
// Group species by evolution chain
|
||||||
const chains = groupByChain()
|
const chains = groupByChain()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box justifyContent="space-between" marginBottom={0}>
|
<Box justifyContent="space-between" marginBottom={0}>
|
||||||
<Text bold color={CYAN}>Pokédex</Text>
|
<Text bold color={CYAN}>Pokédex</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
|
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
|
||||||
<Text color={GRAY}>/{total} </Text>
|
<Text color={GRAY}>/{total} </Text>
|
||||||
<Text color={GRAY}>collected</Text>
|
<Text color={GRAY}>collected</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||||
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||||
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Species list grouped by evolution chains */}
|
{/* Species list grouped by evolution chains */}
|
||||||
{chains.map((chain, ci) => (
|
{chains.map((chain, ci) => (
|
||||||
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
|
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
|
||||||
{chain.map((speciesId, si) => {
|
{chain.map((speciesId, si) => {
|
||||||
const species = getSpeciesData(speciesId)
|
const species = getSpeciesData(speciesId)
|
||||||
const entry = dexMap.get(speciesId)
|
const entry = dexMap.get(speciesId)
|
||||||
const discovered = !!entry
|
const discovered = !!entry
|
||||||
const isActive = buddyData.party[0]
|
const isActive = buddyData.party[0]
|
||||||
? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId)
|
? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId)
|
||||||
: false
|
: false
|
||||||
const nextEvo = getNextEvolution(speciesId)
|
const nextEvo = getNextEvolution(speciesId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={speciesId} flexDirection="column">
|
<Box key={speciesId} flexDirection="column">
|
||||||
<Box>
|
<Box>
|
||||||
{/* Chain connector */}
|
{/* Chain connector */}
|
||||||
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
||||||
{/* Active indicator */}
|
{/* Active indicator */}
|
||||||
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||||
{/* Dex number */}
|
{/* Dex number */}
|
||||||
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
||||||
{discovered
|
{discovered
|
||||||
? (species.names.zh ?? species.name)
|
? (species.names.zh ?? species.name)
|
||||||
: '???'}
|
: '???'}
|
||||||
</Text>
|
</Text>
|
||||||
{/* Type badges */}
|
{/* Type badges */}
|
||||||
{discovered && (
|
{discovered && (
|
||||||
<Text>
|
<Text>
|
||||||
{' '}
|
{' '}
|
||||||
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
|
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
|
||||||
<Text key={t} color={getTypeColor(t)}>
|
<Text key={t} color={getTypeColor(t)}>
|
||||||
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
|
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{/* Level / unknown indicator */}
|
{/* Level / unknown indicator */}
|
||||||
{discovered && entry ? (
|
{discovered && entry ? (
|
||||||
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={GRAY}> ───</Text>
|
<Text color={GRAY}> ───</Text>
|
||||||
)}
|
)}
|
||||||
{/* Evolution arrow */}
|
{/* Evolution arrow */}
|
||||||
{nextEvo && (
|
{nextEvo && (
|
||||||
<Text color={GRAY}> →<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
|
<Text color={GRAY}> →<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<Box marginTop={0} flexDirection="column">
|
<Box marginTop={0} flexDirection="column">
|
||||||
<Text color={GRAY}>─── Stats ───</Text>
|
<Text color={GRAY}>─── Stats ───</Text>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Turns: </Text>
|
<Text color={GRAY}>Turns: </Text>
|
||||||
<Text>{buddyData.stats.totalTurns}</Text>
|
<Text>{buddyData.stats.totalTurns}</Text>
|
||||||
<Text color={GRAY}> Days: </Text>
|
<Text color={GRAY}> Days: </Text>
|
||||||
<Text>{buddyData.stats.consecutiveDays}</Text>
|
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Eggs: </Text>
|
<Text color={GRAY}>Eggs: </Text>
|
||||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||||
<Text color={GRAY}> Evolutions: </Text>
|
<Text color={GRAY}> Evolutions: </Text>
|
||||||
<Text>{buddyData.stats.totalEvolutions}</Text>
|
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Egg info */}
|
{/* Egg info */}
|
||||||
{buddyData.eggs.length > 0 && (
|
{buddyData.eggs.length > 0 && (
|
||||||
<Box marginTop={0}>
|
<Box marginTop={0}>
|
||||||
<Text color={YELLOW}>🥚 Egg: </Text>
|
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||||
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
|
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
|
||||||
<Text color={GRAY}> steps</Text>
|
<Text color={GRAY}> steps</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{buddyData.stats.consecutiveDays < 7 && (
|
{buddyData.stats.consecutiveDays < 7 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type → color mapping */
|
/** Type → color mapping */
|
||||||
function getTypeColor(type: string): Color {
|
function getTypeColor(type: string): Color {
|
||||||
const colors: Record<string, Color> = {
|
const colors: Record<string, Color> = {
|
||||||
grass: 'ansi:green',
|
grass: 'ansi:green',
|
||||||
poison: 'ansi:magenta',
|
poison: 'ansi:magenta',
|
||||||
fire: 'ansi:red',
|
fire: 'ansi:red',
|
||||||
flying: 'ansi:cyan',
|
flying: 'ansi:cyan',
|
||||||
water: 'ansi:blue',
|
water: 'ansi:blue',
|
||||||
electric: 'ansi:yellow',
|
electric: 'ansi:yellow',
|
||||||
normal: 'ansi:white',
|
normal: 'ansi:white',
|
||||||
}
|
}
|
||||||
return colors[type] ?? 'ansi:white'
|
return colors[type] ?? 'ansi:white'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group species by evolution chain for visual display */
|
/** Group species by evolution chain for visual display */
|
||||||
function groupByChain(): SpeciesId[][] {
|
function groupByChain(): SpeciesId[][] {
|
||||||
const visited = new Set<SpeciesId>()
|
const visited = new Set<SpeciesId>()
|
||||||
const chains: SpeciesId[][] = []
|
const chains: SpeciesId[][] = []
|
||||||
|
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
if (visited.has(id)) continue
|
if (visited.has(id)) continue
|
||||||
|
|
||||||
// Walk back to find chain head
|
// Walk back to find chain head
|
||||||
let head: SpeciesId = id
|
let head: SpeciesId = id
|
||||||
for (const candidate of ALL_SPECIES_IDS) {
|
for (const candidate of ALL_SPECIES_IDS) {
|
||||||
const evo = getNextEvolution(candidate)
|
const evo = getNextEvolution(candidate)
|
||||||
if (evo?.to === head) {
|
if (evo?.to === head) {
|
||||||
head = candidate
|
head = candidate
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk forward to build chain
|
// Walk forward to build chain
|
||||||
const chain: SpeciesId[] = []
|
const chain: SpeciesId[] = []
|
||||||
let current: SpeciesId | undefined = head
|
let current: SpeciesId | undefined = head
|
||||||
while (current && !visited.has(current)) {
|
while (current && !visited.has(current)) {
|
||||||
chain.push(current)
|
chain.push(current)
|
||||||
visited.add(current)
|
visited.add(current)
|
||||||
current = getNextEvolution(current)?.to
|
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 { Box, Text, type Color } from '@anthropic/ink'
|
||||||
import type { SpeciesId, StatName } from '../types'
|
import type { SpeciesId, StatName } from '../types'
|
||||||
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
|
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
|
||||||
import { getSpeciesData } from '../data/species'
|
import { getSpeciesData } from '../dex/species'
|
||||||
import { getNextEvolution } from '../data/evolution'
|
import { getNextEvolution } from '../dex/evolution'
|
||||||
import { StatBar } from './StatBar'
|
import { StatBar } from './StatBar'
|
||||||
import { getStatColor } from './shared'
|
import { getStatColor } from './shared'
|
||||||
|
|
||||||
@@ -17,160 +17,160 @@ const BLUE: Color = 'ansi:blue'
|
|||||||
|
|
||||||
/** Type → color */
|
/** Type → color */
|
||||||
const TYPE_COLORS: Record<string, Color> = {
|
const TYPE_COLORS: Record<string, Color> = {
|
||||||
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
|
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
|
||||||
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
|
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
|
||||||
normal: 'ansi:white',
|
normal: 'ansi:white',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpeciesDetailProps {
|
interface SpeciesDetailProps {
|
||||||
speciesId: SpeciesId
|
speciesId: SpeciesId
|
||||||
caughtLevel?: number
|
caughtLevel?: number
|
||||||
spriteLines?: string[]
|
spriteLines?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailed species info page — base stats, evolution chain, flavor text.
|
* Detailed species info page — base stats, evolution chain, flavor text.
|
||||||
*/
|
*/
|
||||||
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
|
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
|
||||||
const species = getSpeciesData(speciesId)
|
const species = getSpeciesData(speciesId)
|
||||||
const nextEvo = getNextEvolution(speciesId)
|
const nextEvo = getNextEvolution(speciesId)
|
||||||
|
|
||||||
// Type badges
|
// Type badges
|
||||||
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
||||||
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||||
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
|
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
))
|
||||||
|
|
||||||
// Gender info
|
// Gender info
|
||||||
const genderInfo = species.genderRate === -1
|
const genderInfo = species.genderRate === -1
|
||||||
? 'Genderless'
|
? 'Genderless'
|
||||||
: species.genderRate === 0
|
: species.genderRate === 0
|
||||||
? '♂ 100%'
|
? '♂ 100%'
|
||||||
: species.genderRate === 8
|
: species.genderRate === 8
|
||||||
? '♀ 100%'
|
? '♀ 100%'
|
||||||
: `♀ ${(species.genderRate / 8 * 100).toFixed(1)}%`
|
: `♀ ${(species.genderRate / 8 * 100).toFixed(1)}%`
|
||||||
|
|
||||||
// Max base stat for bar scaling
|
// Max base stat for bar scaling
|
||||||
const maxBase = 130
|
const maxBase = 130
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box justifyContent="space-between">
|
<Box justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
|
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
|
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Type + gender */}
|
{/* Type + gender */}
|
||||||
<Box>
|
<Box>
|
||||||
{typeBadges}
|
{typeBadges}
|
||||||
<Text color={GRAY}> {genderInfo}</Text>
|
<Text color={GRAY}> {genderInfo}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sprite */}
|
{/* Sprite */}
|
||||||
{spriteLines && (
|
{spriteLines && (
|
||||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
|
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flavor text */}
|
{/* Flavor text */}
|
||||||
{species.flavorText && (
|
{species.flavorText && (
|
||||||
<Box marginTop={0}>
|
<Box marginTop={0}>
|
||||||
<Text color={GRAY} italic>{species.flavorText}</Text>
|
<Text color={GRAY} italic>{species.flavorText}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Base Stats */}
|
{/* Base Stats */}
|
||||||
<Box flexDirection="column" marginTop={0}>
|
<Box flexDirection="column" marginTop={0}>
|
||||||
<Text color={GRAY}>─── Base Stats ───</Text>
|
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||||
{STAT_NAMES.map((stat) => (
|
{STAT_NAMES.map((stat) => (
|
||||||
<Box key={stat}>
|
<Box key={stat}>
|
||||||
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
||||||
<Text color={getStatColor(stat)}>
|
<Text color={getStatColor(stat)}>
|
||||||
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
|
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||||
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
|
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||||
</Text>
|
</Text>
|
||||||
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
|
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
|
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
|
||||||
<Text color={GRAY}>
|
<Text color={GRAY}>
|
||||||
{'─'.repeat(15)}
|
{'─'.repeat(15)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
|
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Evolution chain */}
|
{/* Evolution chain */}
|
||||||
{(nextEvo || species.dexNumber > 1) && (
|
{(nextEvo || species.dexNumber > 1) && (
|
||||||
<Box flexDirection="column" marginTop={0}>
|
<Box flexDirection="column" marginTop={0}>
|
||||||
<Text color={GRAY}>─── Evolution ───</Text>
|
<Text color={GRAY}>─── Evolution ───</Text>
|
||||||
<EvolutionChain speciesId={speciesId} />
|
<EvolutionChain speciesId={speciesId} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<Box flexDirection="column" marginTop={0}>
|
<Box flexDirection="column" marginTop={0}>
|
||||||
<Text color={GRAY}>─── Info ───</Text>
|
<Text color={GRAY}>─── Info ───</Text>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Growth: </Text>
|
<Text color={GRAY}>Growth: </Text>
|
||||||
<Text>{species.growthRate}</Text>
|
<Text>{species.growthRate}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Capture: </Text>
|
<Text color={GRAY}>Capture: </Text>
|
||||||
<Text>{species.captureRate}</Text>
|
<Text>{species.captureRate}</Text>
|
||||||
<Text color={GRAY}> Happiness: </Text>
|
<Text color={GRAY}> Happiness: </Text>
|
||||||
<Text>{species.baseHappiness}</Text>
|
<Text>{species.baseHappiness}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render evolution chain arrow */
|
/** Render evolution chain arrow */
|
||||||
function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
|
function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
|
||||||
// Walk back to find chain head
|
// Walk back to find chain head
|
||||||
let head: SpeciesId = speciesId
|
let head: SpeciesId = speciesId
|
||||||
for (const candidate of ALL_SPECIES_IDS) {
|
for (const candidate of ALL_SPECIES_IDS) {
|
||||||
const evo = getNextEvolution(candidate)
|
const evo = getNextEvolution(candidate)
|
||||||
if (evo?.to === head) {
|
if (evo?.to === head) {
|
||||||
head = candidate
|
head = candidate
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chain: SpeciesId[] = [head]
|
const chain: SpeciesId[] = [head]
|
||||||
let current: SpeciesId | undefined = head
|
let current: SpeciesId | undefined = head
|
||||||
while (current) {
|
while (current) {
|
||||||
const next = getNextEvolution(current)
|
const next = getNextEvolution(current)
|
||||||
if (next) {
|
if (next) {
|
||||||
chain.push(next.to)
|
chain.push(next.to)
|
||||||
current = next.to
|
current = next.to
|
||||||
} else {
|
} else {
|
||||||
current = undefined
|
current = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{chain.map((sid, i) => (
|
{chain.map((sid, i) => (
|
||||||
<React.Fragment key={sid}>
|
<React.Fragment key={sid}>
|
||||||
{i > 0 && <Text color={GRAY}> → </Text>}
|
{i > 0 && <Text color={GRAY}> → </Text>}
|
||||||
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
|
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
|
||||||
{getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
|
{getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
|
||||||
</Text>
|
</Text>
|
||||||
{i < chain.length - 1 && getNextEvolution(sid) && (
|
{i < chain.length - 1 && getNextEvolution(sid) && (
|
||||||
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
|
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites
|
|||||||
const V_PAD = 4
|
const V_PAD = 4
|
||||||
|
|
||||||
interface SpriteAnimatorProps {
|
interface SpriteAnimatorProps {
|
||||||
/** Base sprite lines (ANSI is preserved) */
|
/** Base sprite lines (ANSI is preserved) */
|
||||||
lines: string[]
|
lines: string[]
|
||||||
/** Text color for the sprite */
|
/** Text color for the sprite */
|
||||||
color?: Color
|
color?: Color
|
||||||
/** Tick interval in ms (default 250) */
|
/** Tick interval in ms (default 250) */
|
||||||
tickMs?: number
|
tickMs?: number
|
||||||
/** Single mode; omit for idle auto-play */
|
/** Single mode; omit for idle auto-play */
|
||||||
mode?: AnimMode
|
mode?: AnimMode
|
||||||
/** Center horizontally (default true) */
|
/** Center horizontally (default true) */
|
||||||
centered?: boolean
|
centered?: boolean
|
||||||
/** Show pet hearts overlay */
|
/** Show pet hearts overlay */
|
||||||
petting?: boolean
|
petting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,44 +29,44 @@ interface SpriteAnimatorProps {
|
|||||||
* - Grid transforms guarantee fixed output height
|
* - Grid transforms guarantee fixed output height
|
||||||
*/
|
*/
|
||||||
export function SpriteAnimator({
|
export function SpriteAnimator({
|
||||||
lines,
|
lines,
|
||||||
color,
|
color,
|
||||||
tickMs = 100,
|
tickMs = 100,
|
||||||
mode,
|
mode,
|
||||||
centered = true,
|
centered = true,
|
||||||
petting,
|
petting,
|
||||||
}: SpriteAnimatorProps) {
|
}: SpriteAnimatorProps) {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setTick(t => t + 1), tickMs)
|
const timer = setInterval(() => setTick(t => t + 1), tickMs)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [tickMs])
|
}, [tickMs])
|
||||||
|
|
||||||
// Add vertical padding — bounce shifts within this space
|
// Add vertical padding — bounce shifts within this space
|
||||||
const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
|
const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
|
||||||
|
|
||||||
// Apply animation (renderer parses to pixels, transforms, renders back)
|
// Apply animation (renderer parses to pixels, transforms, renders back)
|
||||||
const currentMode = mode ?? getIdleAnimMode(tick)
|
const currentMode = mode ?? getIdleAnimMode(tick)
|
||||||
const animated = renderAnimatedSprite(padded, tick, currentMode)
|
const animated = renderAnimatedSprite(padded, tick, currentMode)
|
||||||
|
|
||||||
// Pet hearts overlay
|
// Pet hearts overlay
|
||||||
const overlay = petting ? getPetOverlay(tick) : null
|
const overlay = petting ? getPetOverlay(tick) : null
|
||||||
const displayLines = overlay ? [...overlay, ...animated] : animated
|
const displayLines = overlay ? [...overlay, ...animated] : animated
|
||||||
|
|
||||||
const spriteBlock = (
|
const spriteBlock = (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{displayLines.map((line, i) => (
|
{displayLines.map((line, i) => (
|
||||||
<Text key={i} color={color}>{line || ' '}</Text>
|
<Text key={i} color={color}>{line || ' '}</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!centered) return spriteBlock
|
if (!centered) return spriteBlock
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="center" width="100%">
|
<Box flexDirection="row" justifyContent="center" width="100%">
|
||||||
{spriteBlock}
|
{spriteBlock}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import React from 'react'
|
|||||||
import { Box, Text, type Color } from '@anthropic/ink'
|
import { Box, Text, type Color } from '@anthropic/ink'
|
||||||
|
|
||||||
interface StatBarProps {
|
interface StatBarProps {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
maxValue: number
|
maxValue: number
|
||||||
color?: Color
|
color?: Color
|
||||||
width?: number
|
width?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact horizontal stat bar for Pokémon stats.
|
* Compact horizontal stat bar for Pokémon stats.
|
||||||
*/
|
*/
|
||||||
export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) {
|
export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) {
|
||||||
const filled = Math.round((value / maxValue) * width)
|
const filled = Math.round((value / maxValue) * width)
|
||||||
const empty = width - filled
|
const empty = width - filled
|
||||||
const bar = '█'.repeat(filled) + '░'.repeat(empty)
|
const bar = '█'.repeat(filled) + '░'.repeat(empty)
|
||||||
const valueStr = String(value).padStart(3)
|
const valueStr = String(value).padStart(3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
|
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
|
||||||
<Text color={color}>{bar}</Text>
|
<Text color={color}>{bar}</Text>
|
||||||
<Text> {valueStr}</Text>
|
<Text> {valueStr}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,31 +8,31 @@ const GRAY = 'ansi:white'
|
|||||||
const WHITE = 'ansi:whiteBright'
|
const WHITE = 'ansi:whiteBright'
|
||||||
|
|
||||||
interface SwitchPanelProps {
|
interface SwitchPanelProps {
|
||||||
party: Creature[]
|
party: Creature[]
|
||||||
activeId: string
|
activeId: string
|
||||||
onSelect: (creatureId: string) => void
|
onSelect: (creatureId: string) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
|
export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
<Text bold color={CYAN}> 换人 </Text>
|
<Text bold color={CYAN}> 换人 </Text>
|
||||||
{party.map((creature, i) => {
|
{party.map((creature, i) => {
|
||||||
const isActive = creature.id === activeId
|
const isActive = creature.id === activeId
|
||||||
return (
|
return (
|
||||||
<Box key={creature.id}>
|
<Box key={creature.id}>
|
||||||
<Text>{isActive ? ' ▶ ' : ' '}</Text>
|
<Text>{isActive ? ' ▶ ' : ' '}</Text>
|
||||||
<Text color={isActive ? GRAY : WHITE}>
|
<Text color={isActive ? GRAY : WHITE}>
|
||||||
[{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
|
[{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
|
||||||
</Text>
|
</Text>
|
||||||
{isActive && <Text color={GRAY}> 当前场上</Text>}
|
{isActive && <Text color={GRAY}> 当前场上</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={GRAY}> [ESC] 取消</Text>
|
<Text color={GRAY}> [ESC] 取消</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Color } from '@anthropic/ink'
|
import type { Color } from '@anthropic/ink'
|
||||||
|
|
||||||
const STAT_COLORS: Record<string, Color> = {
|
const STAT_COLORS: Record<string, Color> = {
|
||||||
hp: 'ansi:green',
|
hp: 'ansi:green',
|
||||||
attack: 'ansi:red',
|
attack: 'ansi:red',
|
||||||
defense: 'ansi:yellow',
|
defense: 'ansi:yellow',
|
||||||
spAtk: 'ansi:blue',
|
spAtk: 'ansi:blue',
|
||||||
spDef: 'ansi:magenta',
|
spDef: 'ansi:magenta',
|
||||||
speed: 'ansi:cyan',
|
speed: 'ansi:cyan',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatColor(stat: string): Color {
|
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 { getGenderSymbol } from '@claude-code-best/pokemon';
|
||||||
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } 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';
|
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
|
|
||||||
const CYAN: Color = 'ansi:cyan';
|
const CYAN: Color = 'ansi:cyan';
|
||||||
@@ -91,6 +92,13 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
|
|||||||
onClose={() => onClose('buddy panel closed')}
|
onClose={() => onClose('buddy panel closed')}
|
||||||
/>
|
/>
|
||||||
</Tab>,
|
</Tab>,
|
||||||
|
<Tab key="battle" title="Battle">
|
||||||
|
<BattleTab
|
||||||
|
buddyData={data}
|
||||||
|
isActive={selectedTab === 'Battle'}
|
||||||
|
onUpdate={updateData}
|
||||||
|
/>
|
||||||
|
</Tab>,
|
||||||
<Tab key="egg" title="Egg">
|
<Tab key="egg" title="Egg">
|
||||||
<EggTab buddyData={data} />
|
<EggTab buddyData={data} />
|
||||||
</Tab>,
|
</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 ──────────────────────────────────────────
|
// ─── Egg Tab ──────────────────────────────────────────
|
||||||
|
|
||||||
function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user