export type EquipmentCategory = 'straight' | 'curve' | 'crush'; export type ShopTab = 'all' | EquipmentCategory; export interface EquipmentDef { id: string; category: EquipmentCategory; /** UI에 보이는 영어 이름 */ name: string; /** 별 개수. 기본 장비는 0 */ price: number; isDefault: boolean; } const CATEGORIES: readonly EquipmentCategory[] = ['straight', 'curve', 'crush']; const def = (id: string, category: EquipmentCategory, name: string, price: number): EquipmentDef => ({ id, category, name, price, isDefault: price === 0 }); /** 스펙 표 순서이자 상점 정렬 순서(분류 → 기본 먼저 → 가격 오름차순 → 표 순서)다. */ export const EQUIPMENT: readonly EquipmentDef[] = [ def('kitchen-knife', 'straight', 'Kitchen Knife', 0), def('cleaver', 'straight', 'Cleaver', 30), def('carrot-knife', 'straight', 'Carrot Knife', 40), def('katana', 'straight', 'Katana', 50), def('fantasy-dagger', 'straight', 'Fantasy Dagger', 50), def('laser-sword', 'straight', 'Laser Sword', 80), def('scalpel', 'curve', 'Scalpel', 0), def('pizza-cutter', 'curve', 'Pizza Cutter', 30), def('fountain-pen', 'curve', 'Fountain Pen', 30), def('mini-ufo', 'curve', 'Mini UFO', 50), def('magic-wand', 'curve', 'Magic Wand', 50), def('piston-press', 'crush', 'Piston Press', 0), def('robot-fist', 'crush', 'Robot Fist', 30), def('marshmallow-hammer', 'crush', 'Marshmallow Hammer', 40), def('cat-paw', 'crush', 'Cat Paw', 50), ]; export const DEFAULT_EQUIPMENT: Record = { straight: 'kitchen-knife', curve: 'scalpel', crush: 'piston-press', }; /** 보유·장착을 단일 객체로 저장한다. 보유한 별 소비량은 보유 목록에서 계산한다. */ export interface EquipmentSave { version: 1; owned: string[]; equipped: Record; } export function equipmentDef(id: string): EquipmentDef | undefined { return EQUIPMENT.find(d => d.id === id); } export function sanitizeEquipment(value: unknown): EquipmentSave { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; const rawOwned = Array.isArray(raw.owned) ? raw.owned : []; const owned = [...new Set([ ...Object.values(DEFAULT_EQUIPMENT), ...rawOwned.filter((id): id is string => typeof id === 'string' && equipmentDef(id) !== undefined), ])]; const rawEquipped = raw.equipped && typeof raw.equipped === 'object' && !Array.isArray(raw.equipped) ? raw.equipped as Record : {}; const equipped = { ...DEFAULT_EQUIPMENT }; for (const category of CATEGORIES) { const id = rawEquipped[category]; if (typeof id === 'string' && owned.includes(id) && equipmentDef(id)?.category === category) equipped[category] = id; } return { version: 1, owned, equipped }; } export function spentStars(save: EquipmentSave): number { return save.owned.reduce((sum, id) => sum + (equipmentDef(id)?.price ?? 0), 0); } export function starBalance(earned: number, save: EquipmentSave): number { return Math.max(0, earned - spentStars(save)); } export type ShopState = 'equipped' | 'owned' | 'affordable' | 'locked'; export function shopState(save: EquipmentSave, balance: number, id: string): ShopState { if (Object.values(save.equipped).includes(id)) return 'equipped'; if (save.owned.includes(id)) return 'owned'; return balance >= (equipmentDef(id)?.price ?? Infinity) ? 'affordable' : 'locked'; } export function shopItems(tab: ShopTab): EquipmentDef[] { return EQUIPMENT.filter(d => tab === 'all' || d.category === tab); } /** 미보유이고 잔액이 충분하면 보유에 더하고 그 분류에 장착한 새 저장을, 아니면 null을 돌려준다. */ export function buyEquipment(save: EquipmentSave, earned: number, id: string): EquipmentSave | null { const item = equipmentDef(id); if (!item || item.isDefault || save.owned.includes(id)) return null; if (starBalance(earned, save) < item.price) return null; return { version: 1, owned: [...save.owned, id], equipped: { ...save.equipped, [item.category]: id }, }; } export function equipEquipment(save: EquipmentSave, id: string): EquipmentSave | null { const item = equipmentDef(id); if (!item || !save.owned.includes(id)) return null; return { version: 1, owned: [...save.owned], equipped: { ...save.equipped, [item.category]: id } }; }