import type { AptitudeKey, Building, Cat, GameState } from "./types"; import { APT_POINT_EVERY, APTITUDE_RATE, BASE_RATE, CAT_HOUSE_CAPACITY, ENERGY_DRAIN_PER_SEC, HOUSE_RECOVER_PER_SEC, IDLE_RECOVER_PER_SEC, REST_RECOVER_PER_SEC, STRIKE_RESUME, STRIKE_THRESHOLD, BOARD_COLS, BOARD_ROWS, BOARD_VERSION, BREEDS, BUILD_DEFS, CAT_NAMES, ADOPT_BASE_COST, ADOPT_COST_PER_CAT, ADOPT_FREE_CATS, BASE_STORAGE, BUILDING_STORE_CAP, DECO_DEFS, DEFAULT_BUILDING_POS, MARKET_RATE_PER_SEC, MEAL_PRICES, REROLL_COST, RUSH_COST_PER_10S, SELL_PRICES, WAREHOUSE_STORAGE, EAT_THRESHOLD, EXP_PER_LEVEL, FULLNESS_DECAY_PER_SEC, HALL_LEVELS, footprintOf, HALL_TILE, isWaterTile, KITCHEN_UPGRADE_COST, mealCap, PASSIVES, PLACEMENT_RULES, RECIPES, TREE_TILES, type BreedDef, type RecipeDef, } from "./data"; import type { BuildingType } from "./types"; const SAVE_KEY = "cat-village-save"; const RES_LABEL: Record = { fish: "생선", wood: "목재", stone: "돌" }; const APTITUDE_KEYS: AptitudeKey[] = [ "fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming", ]; const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; const pickWeighted = (items: T[]): T => { const total = items.reduce((s, i) => s + i.weight, 0); let roll = Math.random() * total; for (const item of items) { roll -= item.weight; if (roll <= 0) return item; } return items[items.length - 1]; }; let seq = 0; const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(seq++).toString(36)}`; // ---------- 고양이 생성 ---------- function generateCat(forceAptitude?: AptitudeKey): Cat { const breed: BreedDef = pickWeighted(BREEDS); const aptitude = {} as Record; for (const key of APTITUDE_KEYS) { let value = rand(0, 2); if (breed.bias.includes(key)) value += rand(1, 3); aptitude[key] = Math.min(value, breed.aptitudeCap); } if (forceAptitude && aptitude[forceAptitude] < 2) { aptitude[forceAptitude] = rand(2, 3); } const passives: string[] = []; const pool = [...PASSIVES]; const passiveCount = rand(0, 2); for (let i = 0; i < passiveCount; i++) { const idx = rand(0, pool.length - 1); passives.push(pool.splice(idx, 1)[0].key); } const stats = { diligence: rand(50, 150), stamina: rand(50, 150), appetite: Math.min(5, Math.max(1, rand(2, 4) + breed.appetiteMod)), }; return { id: nextId("cat"), name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)], breed: breed.key, breedName: breed.name, emoji: breed.emoji, level: 1, exp: 0, aptitudePoints: 0, aptitude, stats, status: { fullness: 100, happiness: 80, energy: stats.stamina }, passives, assignedTo: null, }; } // ---------- 초기 상태 ---------- function createInitialState(): GameState { // 새 게임: 회관만 있는 빈 마을에서 촌장 냥이 튜토리얼로 시작 return { version: 1, boardVersion: BOARD_VERSION, tutorialStep: 0, coins: 0, resources: { fish: 0, wood: 0, stone: 0 }, cats: [], buildings: [], decors: [], hall: { level: 1, capacity: 3 }, updatedAt: Date.now(), }; } // ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ---------- let state: GameState | null = null; let offlineMinutes: number | null = null; let loadedUpdatedAt = 0; // 디스크에서 읽은 시점의 updatedAt (동기화 비교 기준) function passiveSpeedMul(cat: Cat): number { return cat.passives.reduce((mul, key) => { const def = PASSIVES.find((p) => p.key === key); return mul * (def?.speedMul ?? 1); }, 1); } /** 초당 산출량 = 적성 테이블 × 근면 배율 × 패시브 배율 × BASE_RATE */ export function catRate(cat: Cat, aptitudeKey: AptitudeKey): number { const apt = APTITUDE_RATE[cat.aptitude[aptitudeKey]] ?? 0; return apt * (cat.stats.diligence / 100) * passiveSpeedMul(cat) * BASE_RATE; } /** 배고픔 페널티: 0이면 0.3배, 25 미만이면 0.7배 */ export function fullnessPenalty(cat: Cat): number { if (cat.status.fullness <= 0) return 0.3; if (cat.status.fullness < 25) return 0.7; return 1; } /** 행복도 배율: 80↑ +10%, 50 미만 -30% (시스템 해금 전엔 중립) */ export function happinessMul(cat: Cat): number { if (!state?.happinessUnlocked) return 1; const h = cat.status.happiness; if (h >= 80) return 1.1; if (h < 50) return 0.7; return 1; } /** 실효 산출량 (배고픔·행복도 반영) */ export function effectiveRate(cat: Cat, aptitudeKey: AptitudeKey): number { return catRate(cat, aptitudeKey) * fullnessPenalty(cat) * happinessMul(cat); } function passiveFoodMul(cat: Cat): number { let mul = 1; if (cat.passives.includes("small_eater")) mul *= 0.7; if (cat.passives.includes("big_eater")) mul *= 1.5; return mul; } /** 건설 중인가 */ export function isUnderConstruction(b: Building): boolean { return b.constructionUntil !== undefined && Date.now() < b.constructionUntil; } /** 사각 풋프린트 겹침 검사 */ function rectsOverlap( a: { i: number; j: number; w: number; h: number }, b: { i: number; j: number; w: number; h: number }, ): boolean { return ( a.i < b.i + b.w && b.i < a.i + a.w && a.j < b.j + b.h && b.j < a.j + a.h ); } /** 해당 앵커(좌상단)에 건물 풋프린트를 놓을 수 있는가 */ export function canPlaceAt( s: GameState, type: BuildingType, i: number, j: number, excludeId?: string, ): boolean { if (!Number.isInteger(i) || !Number.isInteger(j)) return false; const fp = footprintOf(type); const rule = PLACEMENT_RULES[type] ?? "land"; // 풋프린트 전 타일 검사 for (let di = 0; di < fp.w; di++) { for (let dj = 0; dj < fp.h; dj++) { const x = i + di; const y = j + dj; if (x < 0 || y < 0 || x >= BOARD_COLS || y >= BOARD_ROWS) return false; const water = isWaterTile(x); if (rule === "onWater" ? !water : water) return false; if (TREE_TILES.some((t) => t.i === x && t.j === y)) return false; } } const rect = { i, j, w: fp.w, h: fp.h }; const hallFp = footprintOf("hall"); if (rectsOverlap(rect, { ...HALL_TILE, w: hallFp.w, h: hallFp.h })) return false; for (const b of s.buildings) { if (b.id === excludeId) continue; const bfp = footprintOf(b.type); if (rectsOverlap(rect, { ...b.pos, w: bfp.w, h: bfp.h })) return false; } return true; } /** 자원별 보관 상한 (창고로 확장) */ export function storageCap(s: GameState): number { const warehouses = s.buildings.filter( (b) => b.type === "warehouse" && !isUnderConstruction(b), ).length; return BASE_STORAGE + warehouses * WAREHOUSE_STORAGE; } /** 다음 입양 비용 (처음 2마리 무료) */ export function adoptCost(s: GameState): number { const n = s.cats.length; if (n < ADOPT_FREE_CATS) return 0; return ADOPT_BASE_COST + (n - ADOPT_FREE_CATS) * ADOPT_COST_PER_CAT; } /** 데코를 놓을 수 있는 타일인가 (잔디 위, 건물·나무·물 제외) */ export function canPlaceDecoAt(s: GameState, i: number, j: number): boolean { if (!Number.isInteger(i) || !Number.isInteger(j)) return false; if (i < 0 || j < 0 || i >= BOARD_COLS || j >= BOARD_ROWS) return false; if (isWaterTile(i)) return false; if (TREE_TILES.some((t) => t.i === i && t.j === j)) return false; const hallFp = footprintOf("hall"); if ( i >= HALL_TILE.i && i < HALL_TILE.i + hallFp.w && j >= HALL_TILE.j && j < HALL_TILE.j + hallFp.h ) return false; for (const b of s.buildings) { const fp = footprintOf(b.type); if (i >= b.pos.i && i < b.pos.i + fp.w && j >= b.pos.j && j < b.pos.j + fp.h) return false; } return true; } /** 현재 해금된 레시피 중 가장 마지막 것 (부엌이 요리할 메뉴) */ export function activeRecipe(s: GameState): RecipeDef { const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1; const unlocked = RECIPES.filter( (r) => r.reqHallLv <= s.hall.level && r.reqKitchenLv <= kitchenLv, ); return unlocked[unlocked.length - 1] ?? RECIPES[0]; } function canConsume(s: GameState, input: RecipeDef["input"]): boolean { return Object.entries(input).every( ([key, amount]) => s.resources[key as keyof typeof s.resources] >= (amount ?? 0), ); } function consume(s: GameState, input: RecipeDef["input"]) { for (const [key, amount] of Object.entries(input)) { s.resources[key as keyof typeof s.resources] -= amount ?? 0; } } export const engine = { init(): GameState { if (state) return state; try { const raw = localStorage.getItem(SAVE_KEY); if (raw) { const saved = JSON.parse(raw) as GameState; if (saved.version === 1) { state = saved; loadedUpdatedAt = saved.updatedAt ?? 0; } } } catch { // 저장 데이터가 깨졌으면 새로 시작 } if (!state) state = createInitialState(); return this.finishLoad(); }, /** 마이그레이션 + 오프라인 정산 (로컬/원격 로드 공통) */ finishLoad(): GameState { if (!state) state = createInitialState(); // 구버전 세이브 마이그레이션 (필드 보강) state.resources.stone ??= 0; for (const cat of state.cats) { cat.aptitudePoints ??= 0; cat.status.energy ??= cat.stats.stamina; } for (const b of state.buildings) b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) }; state.decors ??= []; state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략 state.coins ??= 0; // 소급 지급: 이미 튜토리얼을 마쳤는데 정착 지원금을 못 받은 마을 if ((state.tutorialStep ?? 0) >= 99 && !state.settleGrantGiven) { state.coins += 200; state.settleGrantGiven = true; } if (state.happinessUnlocked === undefined) { const deckBuilt = state.buildings.some( (b) => b.type === "sun_deck" && b.constructionUntil === undefined, ); state.happinessUnlocked = state.hall.level >= 3 && deckBuilt; state.happinessIntroSeen = state.happinessUnlocked; // 기존 해금 유저는 안내 생략 if (!state.happinessUnlocked) { // 해금 전 소프트락 방지: 파업 해제 + 행복도 초기화 for (const c of state.cats) { if (c.state === "striking") c.state = null; c.status.happiness = 80; } } } // 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치 if (state.boardVersion !== BOARD_VERSION) { for (const b of state.buildings) b.pos = { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 6 }) }; state.boardVersion = BOARD_VERSION; } // 오프라인 정산: 최대 8시간, 60초 단위로 시뮬레이션 const offlineSec = Math.min( Math.max((Date.now() - state.updatedAt) / 1000, 0), 8 * 3600, ); if (offlineSec > 120) { const chunks = Math.floor(offlineSec / 60); for (let k = 0; k < chunks; k++) this.tick(60); offlineMinutes = Math.floor(offlineSec / 60); } return state; }, /** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */ replaceState(data: GameState) { state = data; loadedUpdatedAt = data.updatedAt ?? 0; this.finishLoad(); this.save(); }, /** 디스크에서 읽은 시점의 updatedAt */ loadedUpdatedAt(): number { return loadedUpdatedAt; }, /** 닉네임·토큰 설정 */ setNickname(nickname: string, token: string) { if (!state) return; state.nickname = nickname; state.syncToken = token; this.save(); }, /** 오프라인 정산 리포트 (1회성) */ consumeOfflineReport(): number | null { const r = offlineMinutes; offlineMinutes = null; return r; }, /** 행복도 안내 확인 처리 */ markHappinessIntroSeen() { if (!state) return; state.happinessIntroSeen = true; }, /** 튜토리얼 진행 갱신 (단계 진입 시 촌장 냥이의 지원 지급) */ setTutorialStep(step: number) { if (!state) return; state.tutorialStep = step; // 촌장 냥이의 자원 지원 — 대기 없이 다음 건설로 이어지도록 if (step === 5) state.resources.fish += 30; // 벌목장 재료 if (step === 7) state.resources.wood += 60; // 부엌 재료 if (step === 8) { state.resources.wood += 100; // 회관 증축 재료 state.resources.fish += 80; // 특훈: 가장 레벨 높은 친구를 Lv3으로 const best = [...state.cats].sort((a, b) => b.level - a.level)[0]; if (best && best.level < 3) { best.level = 3; best.exp = 0; } } if (step === 9 && !state.settleGrantGiven) { // 졸업 선물: 마을 정착 지원금 (장터 전까지 코인 수입이 없는 구간 해소) state.coins = (state.coins ?? 0) + 200; state.settleGrantGiven = true; } }, tick(dt: number) { if (!state) return; for (const building of state.buildings) { // 건설 완료 체크 if (building.constructionUntil !== undefined) { if (Date.now() >= building.constructionUntil) { building.constructionUntil = undefined; } else { continue; // 건설 중 — 생산 없음 } } let produced = 0; for (const catId of building.assigned) { const cat = state.cats.find((c) => c.id === catId); if (!cat) continue; produced += effectiveRate(cat, building.aptitude) * dt; // 배치 중 경험치: 초당 1 cat.exp += dt; const needed = cat.level * EXP_PER_LEVEL; if (cat.exp >= needed) { cat.exp -= needed; cat.level += 1; cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3)); if (cat.level % APT_POINT_EVERY === 0) cat.aptitudePoints += 1; } // 복덩이: 일하는 중 평균 100초에 1냥코인 if (cat.passives.includes("lucky") && Math.random() < dt / 100) state.coins = (state.coins ?? 0) + 1; } if (building.type === "kitchen") { // 요리: 재료가 있고 선반에 자리가 있을 때만 진행 const recipe = activeRecipe(state); building.progress = (building.progress ?? 0) + produced; while (building.progress >= recipe.work) { if (building.stored >= mealCap(building.level) || !canConsume(state, recipe.input)) { building.progress = recipe.work; // 재료·선반 대기 상태로 캡 break; } consume(state, recipe.input); building.stored += 1; building.progress -= recipe.work; } } else { // 건물 적재 한도 — 가득 차면 생산 정지 (회수 유도) building.stored = Math.min(BUILDING_STORE_CAP, building.stored + produced); } // 장터: 자동 판매 if ( building.type === "market" && building.sellKey && !isUnderConstruction(building) ) { building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt; while (building.progress >= 1) { let sold = false; if (building.sellKey === "meals") { const kitchen = state.buildings.find( (b) => b.type === "kitchen" && b.stored >= 1, ); if (kitchen) { kitchen.stored -= 1; state.coins = (state.coins ?? 0) + (MEAL_PRICES[activeRecipe(state).key] ?? 4); sold = true; } } else { const key = building.sellKey as keyof GameState["resources"]; if (state.resources[key] >= 1) { state.resources[key] -= 1; state.coins = (state.coins ?? 0) + (SELL_PRICES[building.sellKey] ?? 1); sold = true; } } if (!sold) { building.progress = 1; // 팔 것이 없으면 대기 break; } building.progress -= 1; } } } // 포만도 감소 (식욕 × 패시브) for (const cat of state.cats) { const decay = cat.stats.appetite * FULLNESS_DECAY_PER_SEC * passiveFoodMul(cat); cat.status.fullness = Math.max(0, cat.status.fullness - decay * dt); } // 배고픈 고양이가 부엌에서 자동 식사 (배고픈 순서대로) const kitchens = state.buildings.filter( (b) => b.type === "kitchen" && b.stored >= 1, ); if (kitchens.length > 0) { const recipe = activeRecipe(state); const hungry = [...state.cats] .filter((c) => c.status.fullness <= EAT_THRESHOLD) .sort((a, b) => a.status.fullness - b.status.fullness); for (const cat of hungry) { const kitchen = kitchens.find((k) => k.stored >= 1); if (!kitchen) break; kitchen.stored -= 1; cat.status.fullness = Math.min(100, cat.status.fullness + recipe.fullness); } } // 행복도 시스템 해금: 회관 Lv3 + 햇볕 데크 완공 if ( !state.happinessUnlocked && state.hall.level >= 3 && state.buildings.some( (b) => b.type === "sun_deck" && !isUnderConstruction(b), ) ) { state.happinessUnlocked = true; for (const c of state.cats) c.status.happiness = 80; // 산뜻하게 시작 } // ---------- 체력·휴식·행복도 (Phase B) ---------- const houses = state.buildings.filter( (b) => b.type === "cat_house" && !isUnderConstruction(b), ); const houseCapacity = houses.length * CAT_HOUSE_CAPACITY; const hasDeck = state.buildings.some( (b) => b.type === "sun_deck" && !isUnderConstruction(b), ); const gainMul = hasDeck ? 1.5 : 1; const restingCats = state.cats.filter((c) => c.state === "resting"); const tryReturn = (cat: Cat) => { // 휴식·파업 종료 → 이전 일터로 자동 복귀 (자리가 남아 있으면) const target = cat.returnTo ? state!.buildings.find((b) => b.id === cat.returnTo) : undefined; cat.state = null; cat.returnTo = null; if ( target && !isUnderConstruction(target) && target.assigned.length < target.maxSlots && cat.aptitude[target.aptitude] > 0 ) { target.assigned.push(cat.id); cat.assignedTo = target.id; } }; for (const cat of state.cats) { const st = cat.status; const decayMul = (cat.passives.includes("optimist") ? 0.6 : 1) * (cat.passives.includes("picky") ? 1.5 : 1) * (cat.passives.includes("workaholic") ? 1.4 : 1); const sturdyMul = cat.passives.includes("sturdy") ? 0.7 : 1; // 체력 if (cat.state === "resting") { const idx = restingCats.indexOf(cat); const inHouse = houses.length > 0 && idx >= 0 && idx < houseCapacity; const recover = inHouse ? HOUSE_RECOVER_PER_SEC : REST_RECOVER_PER_SEC; st.energy = Math.min(cat.stats.stamina, st.energy + recover * dt); if (state.happinessUnlocked) st.happiness = Math.min(100, st.happiness + (3 / 60) * gainMul * dt); if (st.energy >= cat.stats.stamina) tryReturn(cat); } else if (cat.assignedTo) { st.energy = Math.max(0, st.energy - ENERGY_DRAIN_PER_SEC * sturdyMul * dt); if (st.energy <= 0) { // 강제 휴식: 배치 해제 후 회복 cat.returnTo = cat.assignedTo; for (const b of state.buildings) b.assigned = b.assigned.filter((id) => id !== cat.id); cat.assignedTo = null; cat.state = "resting"; } } else { st.energy = Math.min( cat.stats.stamina, st.energy + IDLE_RECOVER_PER_SEC * dt, ); } // 행복도 증감 (시스템 해금 후에만) if (state.happinessUnlocked) { if (st.fullness >= 80) st.happiness = Math.min(100, st.happiness + (1 / 60) * gainMul * dt); if (st.fullness <= 0) st.happiness = Math.max(0, st.happiness - (4 / 60) * decayMul * dt); // 고양이 집 부족: 하락하되 이 요인만으로는 파업까지 가지 않도록 바닥 25 유지 if ( state.hall.level >= 3 && state.cats.length > houseCapacity && st.happiness > 25 ) st.happiness = Math.max(25, st.happiness - (2 / 60) * decayMul * dt); // 인기쟁이: 같은 건물 동료 행복도 상승 if (cat.assignedTo) { const mates = state.cats.filter( (c) => c.id !== cat.id && c.assignedTo === cat.assignedTo && c.passives.includes("popular"), ); if (mates.length > 0) st.happiness = Math.min(100, st.happiness + (0.5 / 60) * dt); } // 파업 if ( cat.state !== "striking" && cat.state !== "resting" && st.happiness < STRIKE_THRESHOLD ) { cat.returnTo = cat.assignedTo; for (const b of state.buildings) b.assigned = b.assigned.filter((id) => id !== cat.id); cat.assignedTo = null; cat.state = "striking"; } if (cat.state === "striking" && st.happiness >= STRIKE_RESUME) tryReturn(cat); } // happinessUnlocked } state.updatedAt = Date.now(); }, /** 입양 후보 생성 (아직 마을에 합류하지 않음). forceApt로 특정 적성 보장 */ generateCandidates(count = 3, forceApt?: AptitudeKey): Cat[] { return Array.from({ length: count }, () => generateCat(forceApt)); }, /** 후보 중 한 마리 입양 확정 (3마리째부터 냥코인 소모) */ adoptCandidate(cat: Cat): string | null { if (!state) return null; if (state.cats.length >= state.hall.capacity) return "수용 인원이 가득 찼습니다 (회관 증축 필요)"; const cost = adoptCost(state); if ((state.coins ?? 0) < cost) return `냥코인이 부족합니다 (필요: 🪙${cost})`; state.coins = (state.coins ?? 0) - cost; state.cats.push(cat); return null; }, /** 입양 재추첨 비용 지불 */ payReroll(): string | null { if (!state) return null; if ((state.coins ?? 0) < REROLL_COST) return `냥코인이 부족합니다 (필요: 🪙${REROLL_COST})`; state.coins = (state.coins ?? 0) - REROLL_COST; return null; }, /** 즉시 완공 (남은 10초당 1냥코인) */ rushConstruction(buildingId: string): string | null { if (!state) return null; const b = state.buildings.find((x) => x.id === buildingId); if (!b || b.constructionUntil === undefined) return null; const remain = Math.max(0, b.constructionUntil - Date.now()); const cost = Math.max(1, Math.ceil(remain / 10000) * RUSH_COST_PER_10S); if ((state.coins ?? 0) < cost) return `냥코인이 부족합니다 (필요: 🪙${cost})`; state.coins = (state.coins ?? 0) - cost; b.constructionUntil = Date.now() - 1; return null; }, /** 장터 판매 품목 설정 */ setSellKey(marketId: string, key: string | null): string | null { if (!state) return null; const m = state.buildings.find((b) => b.id === marketId); if (!m || m.type !== "market") return null; m.sellKey = key; m.progress = 0; return null; }, /** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */ build( type: BuildingType, pos: { i: number; j: number }, ): string | null { if (!state) return null; const def = BUILD_DEFS.find((d) => d.type === type); if (!def) return null; if (state.buildings.some((b) => b.type === type)) return `${def.name}은(는) 이미 지어져 있습니다`; if (state.hall.level < def.reqHallLv) return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`; if (!canPlaceAt(state, def.type, pos.i, pos.j)) return "그곳에는 지을 수 없습니다"; const missing = Object.entries(def.cost).filter( ([key, amount]) => state!.resources[key as keyof GameState["resources"]] < (amount ?? 0), ); if (missing.length > 0) return `자원이 부족합니다 (${missing .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`) .join(", ")})`; consume(state, def.cost); state.buildings.push({ id: def.id, type: def.type, name: def.name, emoji: def.emoji, pos: { i: pos.i, j: pos.j }, level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude, resource: def.resource, assigned: [], stored: 0, progress: 0, constructionUntil: Date.now() + def.buildTime * 1000, }); return null; }, /** 건물 이동 (자유 배치) */ moveBuilding(id: string, pos: { i: number; j: number }): string | null { if (!state) return null; const building = state.buildings.find((b) => b.id === id); if (!building) return null; if (!canPlaceAt(state, building.type, pos.i, pos.j, id)) return "그곳으로는 옮길 수 없습니다"; building.pos = { i: pos.i, j: pos.j }; return null; }, /** 꾸미기 배치 (같은 타일의 기존 장식은 교체) */ placeDeco(kind: string, pos: { i: number; j: number }): string | null { if (!state) return null; const def = DECO_DEFS.find((d) => d.key === kind); if (!def) return null; if (!canPlaceDecoAt(state, pos.i, pos.j)) return "그곳에는 놓을 수 없습니다"; const missing = Object.entries(def.cost).filter( ([key, amount]) => state!.resources[key as keyof GameState["resources"]] < (amount ?? 0), ); if (missing.length > 0) return `자원이 부족합니다 (${missing .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`) .join(", ")})`; consume(state, def.cost); state.decors ??= []; state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략 state.coins ??= 0; // 소급 지급: 이미 튜토리얼을 마쳤는데 정착 지원금을 못 받은 마을 if ((state.tutorialStep ?? 0) >= 99 && !state.settleGrantGiven) { state.coins += 200; state.settleGrantGiven = true; } if (state.happinessUnlocked === undefined) { const deckBuilt = state.buildings.some( (b) => b.type === "sun_deck" && b.constructionUntil === undefined, ); state.happinessUnlocked = state.hall.level >= 3 && deckBuilt; state.happinessIntroSeen = state.happinessUnlocked; // 기존 해금 유저는 안내 생략 if (!state.happinessUnlocked) { // 해금 전 소프트락 방지: 파업 해제 + 행복도 초기화 for (const c of state.cats) { if (c.state === "striking") c.state = null; c.status.happiness = 80; } } } // 같은 타일의 기존 장식 제거 후 배치 state.decors = state.decors.filter( (d) => !(d.pos.i === pos.i && d.pos.j === pos.j), ); state.decors.push({ id: nextId("deco"), kind, pos: { ...pos } }); return null; }, /** 꾸미기 제거 (환불 없음) */ removeDeco(id: string): string | null { if (!state?.decors) return null; state.decors = state.decors.filter((d) => d.id !== id); return null; }, /** 부엌 증축 Lv1→2: 훈제 생선 해금 조건 + 선반 확장 */ upgradeKitchen(): string | null { if (!state) return null; const kitchen = state.buildings.find((b) => b.type === "kitchen"); if (!kitchen) return "부엌이 없습니다"; if (kitchen.level >= 2) return "부엌이 최고 레벨입니다"; const missing = Object.entries(KITCHEN_UPGRADE_COST).filter( ([key, amount]) => state!.resources[key as keyof GameState["resources"]] < amount, ); if (missing.length > 0) return `자원이 부족합니다 (${missing .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`) .join(", ")})`; consume(state, KITCHEN_UPGRADE_COST); kitchen.level = 2; return null; }, /** 적성 포인트 투자 (품종 상한까지) */ investAptitude(catId: string, key: AptitudeKey): string | null { if (!state) return null; const cat = state.cats.find((c) => c.id === catId); if (!cat) return null; if (cat.aptitudePoints <= 0) return "적성 포인트가 없습니다"; const cap = BREEDS.find((b) => b.key === cat.breed)?.aptitudeCap ?? 5; if (cat.aptitude[key] >= cap) return `${cat.breedName}의 적성 상한(${cap})에 도달했습니다`; cat.aptitude[key] += 1; cat.aptitudePoints -= 1; return null; }, upgradeHall(): string | null { if (!state) return null; const next = HALL_LEVELS[state.hall.level + 1]; if (!next) return "회관이 최고 레벨입니다"; if (next.locked) return `회관 Lv${state.hall.level + 1}: ${next.locked}`; const missing: string[] = []; for (const [key, amount] of Object.entries(next.cost)) { const have = state.resources[key as keyof typeof state.resources]; if (have < (amount ?? 0)) missing.push(`${RES_LABEL[key] ?? key} ${Math.floor(have)}/${amount}`); } for (const req of next.reqBuildings ?? []) { const b = state.buildings.find((x) => x.type === req.type); if (!b || isUnderConstruction(b) || b.level < (req.level ?? 1)) missing.push(req.label); } if (next.reqCat) { const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length; if (count < next.reqCat.count) missing.push(`Lv${next.reqCat.level} 이상 고양이 ${count}/${next.reqCat.count}마리`); } if (missing.length > 0) return `증축 조건 부족 — ${missing.join(", ")}`; consume(state, next.cost); state.hall.level += 1; state.hall.capacity = next.capacity; return null; }, assign(catId: string, buildingId: string): string | null { if (!state) return null; const cat = state.cats.find((c) => c.id === catId); const building = state.buildings.find((b) => b.id === buildingId); if (!cat || !building) return null; if (isUnderConstruction(building)) return `${building.name}은(는) 아직 건설 중입니다`; if (cat.state === "striking") return `${cat.name}은(는) 파업 중이에요 (행복도를 올려주세요)`; if (building.maxSlots === 0) return `${building.name}에는 배치할 수 없습니다`; if (cat.state === "resting") { cat.state = null; cat.returnTo = null; } if (cat.aptitude[building.aptitude] === 0) return `${cat.name}은(는) ${building.name} 적성이 없습니다`; if (building.assigned.length >= building.maxSlots) return `${building.name} 자리가 가득 찼습니다`; // 기존 배치 해제 후 이동 this.unassign(catId); building.assigned.push(catId); cat.assignedTo = building.id; return null; }, unassign(catId: string) { if (!state) return; const cat = state.cats.find((c) => c.id === catId); if (!cat) return; for (const b of state.buildings) { b.assigned = b.assigned.filter((id) => id !== catId); } cat.assignedTo = null; }, collect(buildingId: string): string | null { if (!state) return null; const building = state.buildings.find((b) => b.id === buildingId); if (!building || !building.resource) return null; // 부엌은 회수 대상 아님 const cap = storageCap(state); const space = Math.max(0, cap - Math.floor(state.resources[building.resource])); const amount = Math.min(Math.floor(building.stored), space); if (amount <= 0) return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null; building.stored -= amount; state.resources[building.resource] += amount; return null; }, save() { if (!state) return; try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch { // 저장 실패는 치명적이지 않음 } }, snapshot(): GameState | null { return state ? (JSON.parse(JSON.stringify(state)) as GameState) : null; }, /** 렌더 루프용 라이브 참조 (읽기 전용으로만 사용할 것 — 매 프레임 클론 비용 회피) */ peek(): GameState | null { return state; }, };