import { ButterflyStats, ButterflyCatchResult, ButterflyLeaderboardEntry } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { formatDate } from "./utils"; /** Interní reprezentace statistik jednoho uživatele uložená ve storage. */ interface StoredButterflyStats { /** Celkový počet chycených motýlů */ caught: number; /** Aktuální počet mincí k utracení */ coins: number; /** Počet chycených vzácných zlatých motýlů */ goldenCaught: number; /** Datum posledního úlovku (YYYY-MM-DD) pro denní bonus */ lastCatchDay?: string; // --- Perzistentní škůdci (aby je nešlo obejít obnovením stránky) --- /** Aktuální počet vos */ wasps: number; /** Aktuální počet ptáků */ birds: number; /** Celkový počet zahubených vos */ waspsKilled: number; /** Celkový počet vyplašených ptáků */ birdsScared: number; /** Celkový počet poražených zlodějů */ thievesDefeated: number; /** Celkový počet poražených housenek */ caterpillarsDefeated: number; /** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */ repellentUntil: number; /** Do kdy je síťka protržená (ms epoch); po uplynutí se sama zašije. 0 = celá */ netTornUntil: number; /** Kdy naposledy „dorostla" vosa (ms epoch) */ lastWaspAt: number; /** Kdy naposledy „dorostl" pták (ms epoch) */ lastBirdAt: number; // --- Ochrana proti podvádění (rychlost chytání) --- /** Začátek aktuálního minutového okna pro počítadlo úlovků (ms epoch) */ catchWindowStart: number; /** Počet započtených úlovků v aktuálním okně */ catchWindowCount: number; /** Kolikrát hráč omylem chytil černou můru */ mothsHit: number; /** Trvalá vylepšení z obchodu */ upgrades: { net: number; scarecrow: number; reinforced: number }; /** Do kdy platí pojistka proti zloději (ms epoch), 0 = neaktivní */ insuranceUntil: number; /** Denní úkol */ daily: { day: string; taskId: string; progress: number; target: number; done: boolean }; } const storage = getStorage(); // Sezóna 2 – nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry) const STORAGE_KEY = 'butterflyStats_s2'; // --- Herní konstanty --------------------------------------------------------- /** Mince za jednoho běžného motýla */ export const COIN_BASE = 1; /** Mince za jednoho vzácného zlatého motýla */ export const GOLD_VALUE = 25; /** Cena zašití protržené síťky v mincích */ export const REPAIR_COST = 20; /** Bonus mincí za první úlovek dne */ export const DAILY_BONUS = 5; /** Po každých kolika chycených se odemkne prémiová síťka */ export const PREMIUM_MILESTONE = 75; /** Maximální počet současně poletujících vos */ export const WASP_MAX = 6; /** Maximální počet současně poletujících ptáků */ export const BIRD_MAX = 7; /** Jak často (ms) přibude jedna vosa */ export const WASP_GROWTH_MS = 30_000; /** Jak často (ms) přibude jeden pták (základ; zpomaluje strašák) */ export const BIRD_GROWTH_MS = 40_000; /** Cena plašiče ptáků v mincích */ export const REPELLENT_COST = 70; /** Jak dlouho (ms) plašič ptáků drží ptáky pryč */ export const REPELLENT_DURATION_MS = 75_000; /** Za jak dlouho (ms) se protržená síťka sama zašije */ export const AUTO_REPAIR_MS = 60_000; /** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (0–1) */ export const ROBBERY_FRACTION = 0.85; /** Odměna v mincích za poražení zloděje */ export const THIEF_REWARD = 20; /** Kolik zásahů (kliknutí) zloděj vydrží */ export const THIEF_HP = 22; /** Kolik nachytaných motýlů sežere housenka, když se dostane k úlovkům */ export const CATERPILLAR_EATS = 20; /** Odměna v mincích za poražení housenky */ export const CATERPILLAR_REWARD = 15; /** Kolik zásahů (kliknutí) housenka vydrží */ export const CATERPILLAR_HP = 18; /** O kolik úlovků přijdeš, když omylem chytíš černou můru */ export const MOTH_PENALTY = 15; // --- Obchod: spotřební --- /** Cena aktivace prémiové síťky na přání */ export const PREMIUM_BUY_COST = 40; /** Cena vosího spreje (vyhubí všechny vosy) */ export const WASP_SPRAY_COST = 25; /** Cena pojistky proti zloději */ export const INSURANCE_COST = 50; /** Jak dlouho (ms) pojistka platí */ export const INSURANCE_DURATION_MS = 300_000; /** Podíl mincí ukradený zlodějem, když je aktivní pojistka */ export const ROBBERY_FRACTION_INSURED = 0.2; // --- Obchod: trvalá vylepšení --- export type UpgradeId = 'net' | 'scarecrow' | 'reinforced'; /** Maximální úrovně jednotlivých vylepšení */ export const UPGRADE_MAX: Record = { net: 5, scarecrow: 4, reinforced: 3 }; /** Základní cena vylepšení (další úroveň je 2× dražší) */ const UPGRADE_BASE: Record = { net: 100, scarecrow: 120, reinforced: 150 }; /** Vrátí cenu příští úrovně daného vylepšení (level = aktuální úroveň). */ export function upgradeCost(item: UpgradeId, level: number): number { return UPGRADE_BASE[item] * Math.pow(2, level); } // --- Denní úkoly --- /** Odměna v mincích za splnění denního úkolu */ export const DAILY_TASK_REWARD = 30; const DAILY_TASKS: { id: string; target: number }[] = [ { id: 'catch', target: 60 }, { id: 'golden', target: 3 }, { id: 'thief', target: 1 }, { id: 'wasp', target: 15 }, ]; const DAILY_TITLES: Record = { catch: 'Nachytej {n} motýlů', golden: 'Chyť {n} zlatých motýlů', thief: 'Poraz {n} zloděje', wasp: 'Zabij {n} vos', }; /** Deterministický výběr úkolu podle data (aby byl pro všechny stejný a stabilní). */ function dailyTaskForDay(day: string): { id: string; target: number } { let h = 0; for (let i = 0; i < day.length; i++) h = (h * 31 + day.charCodeAt(i)) | 0; return DAILY_TASKS[Math.abs(h) % DAILY_TASKS.length]; } /** Od jaké velikosti dávky se začíná počítat kombo bonus */ const COMBO_THRESHOLD = 4; /** Horní strop komba, aby dávka nedala nesmyslně moc mincí */ const COMBO_CAP = 8; /** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */ const MAX_BATCH = 100; /** Ochrana proti podvádění: max započtených úlovků za minutu (nadbytek se zahodí) */ export const RATE_CAP_PER_MIN = 90; /** Délka okna pro rate-cap (ms) */ const RATE_WINDOW_MS = 60_000; /** * Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami * (prestiž), takže postup nikdy „nedojde". */ const LEVEL_TITLES = [ 'Začátečník se síťkou', // 1 'Nedělní chytač', // 2 'Lovec luk', // 3 'Sběratel křídel', // 4 'Průzkumník louky', // 5 'Mistr síťky', // 6 'Motýlí stopař', // 7 'Zaklínač křídel', // 8 'Motýlí šeptač', // 9 'Kurátor motýlů', // 10 'Amatérský entomolog', // 11 'Legendární entomolog', // 12 'Strážce louky', // 13 'Vládce louky', // 14 'Motýlí velmistr', // 15 'Duch luk', // 16 'Motýlí legenda', // 17 'Nebeský lovec', // 18 'Motýlí božstvo', // 19 'Pán všech křídel', // 20 ]; /** Nejvyšší dosažitelná úroveň */ export const MAX_LEVEL = 60; // --- Čisté pomocné funkce ---------------------------------------------------- /** * Kolik celkem chycených je potřeba k dosažení úrovně `n` (1-based). * Úroveň 1 = 0. Křivka roste mocninně (vyšší úrovně jsou výrazně těžší). */ export function levelThreshold(n: number): number { if (n <= 1) return 0; return Math.round(6 * Math.pow(n - 1, 2.15)); } /** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */ export function levelForCaught(caught: number): number { let level = 1; while (level < MAX_LEVEL && caught >= levelThreshold(level + 1)) { level++; } return level; } /** Vrátí titul odpovídající dané úrovni (1-based). */ export function titleForLevel(level: number): string { if (level <= LEVEL_TITLES.length) { return LEVEL_TITLES[Math.max(level - 1, 0)]; } const stars = '★'.repeat(Math.min(level - LEVEL_TITLES.length, 10)); return `${LEVEL_TITLES[LEVEL_TITLES.length - 1]} ${stars}`; } /** Postup v rámci aktuální úrovně (0–100 %). Na maximální úrovni 100. */ export function levelProgress(caught: number): number { const level = levelForCaught(caught); if (level >= MAX_LEVEL) return 100; const start = levelThreshold(level); const end = levelThreshold(level + 1); if (end <= start) return 100; return Math.max(0, Math.min(100, Math.round(((caught - start) / (end - start)) * 100))); } /** Bonus mincí za kombo (dávku chycenou najednou). */ export function comboBonus(batchTotal: number): number { if (batchTotal < COMBO_THRESHOLD) { return 0; } return Math.min(batchTotal - (COMBO_THRESHOLD - 1), COMBO_CAP); } /** Vytvoří výchozí (prázdné) statistiky. */ function defaultStats(now: number): StoredButterflyStats { return { caught: 0, coins: 0, goldenCaught: 0, wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0, thievesDefeated: 0, caterpillarsDefeated: 0, repellentUntil: 0, netTornUntil: 0, lastWaspAt: now, lastBirdAt: now, catchWindowStart: now, catchWindowCount: 0, mothsHit: 0, upgrades: { net: 0, scarecrow: 0, reinforced: 0 }, insuranceUntil: 0, daily: { day: '', taskId: 'catch', progress: 0, target: 0, done: false }, }; } /** Doplní chybějící pole u starších uložených záznamů (migrace za běhu). */ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats { return { caught: s.caught ?? 0, coins: s.coins ?? 0, goldenCaught: s.goldenCaught ?? 0, lastCatchDay: s.lastCatchDay, wasps: s.wasps ?? 0, birds: s.birds ?? 0, waspsKilled: s.waspsKilled ?? 0, birdsScared: s.birdsScared ?? 0, thievesDefeated: s.thievesDefeated ?? 0, caterpillarsDefeated: s.caterpillarsDefeated ?? 0, repellentUntil: s.repellentUntil ?? 0, netTornUntil: s.netTornUntil ?? 0, lastWaspAt: s.lastWaspAt ?? now, lastBirdAt: s.lastBirdAt ?? now, catchWindowStart: s.catchWindowStart ?? now, catchWindowCount: s.catchWindowCount ?? 0, mothsHit: s.mothsHit ?? 0, upgrades: { net: s.upgrades?.net ?? 0, scarecrow: s.upgrades?.scarecrow ?? 0, reinforced: s.upgrades?.reinforced ?? 0, }, insuranceUntil: s.insuranceUntil ?? 0, daily: s.daily ?? { day: '', taskId: 'catch', progress: 0, target: 0, done: false }, }; } /** Efektivní interval růstu ptáků (zpomaluje strašák). */ function effectiveBirdGrowth(s: StoredButterflyStats): number { return Math.round(BIRD_GROWTH_MS * (1 + 0.35 * (s.upgrades?.scarecrow ?? 0))); } /** Efektivní doba samo-zašití síťky (zkracuje zpevněná síťka). */ function effectiveAutoRepair(s: StoredButterflyStats): number { return Math.round(AUTO_REPAIR_MS * (1 - 0.25 * (s.upgrades?.reinforced ?? 0))); } /** Zajistí, že denní úkol odpovídá dnešnímu dni (jinak vygeneruje nový). */ function ensureDaily(s: StoredButterflyStats, now: number): void { const day = formatDate(new Date(now)); if (!s.daily || s.daily.day !== day) { const def = dailyTaskForDay(day); s.daily = { day, taskId: def.id, progress: 0, target: def.target, done: false }; } } /** Přičte progres dennímu úkolu daného druhu a případně vyplatí odměnu. */ function bumpDaily(s: StoredButterflyStats, kind: string, amount: number): void { if (s.daily && !s.daily.done && s.daily.taskId === kind && amount > 0) { s.daily.progress += amount; if (s.daily.progress >= s.daily.target) { s.daily.done = true; s.coins += DAILY_TASK_REWARD; } } } /** * Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá * razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič. */ export function growPests(s: StoredButterflyStats, now: number): void { // Síťka se sama zašije po uplynutí odpočtu if (s.netTornUntil && s.netTornUntil <= now) s.netTornUntil = 0; // Vosy if (s.wasps < WASP_MAX) { const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS); if (add > 0) { s.wasps = Math.min(s.wasps + add, WASP_MAX); s.lastWaspAt = s.wasps >= WASP_MAX ? now : s.lastWaspAt + add * WASP_GROWTH_MS; } } else { s.lastWaspAt = now; } // Ptáci – jen když neběží plašič. Během plašiče se lastBirdAt nemění (byl // nastaven na konec plašiče při koupi), takže po vypršení růst začne od té chvíle. const repellentActive = s.repellentUntil > now; if (!repellentActive) { if (s.birds < BIRD_MAX) { const birdGrowth = effectiveBirdGrowth(s); const add = Math.floor((now - s.lastBirdAt) / birdGrowth); if (add > 0) { s.birds = Math.min(s.birds + add, BIRD_MAX); s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * birdGrowth; } } else { s.lastBirdAt = now; } } } /** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */ function toDto(s: StoredButterflyStats): ButterflyStats { const level = levelForCaught(s.caught); return { caught: s.caught, coins: s.coins, goldenCaught: s.goldenCaught, level, title: titleForLevel(level), levelProgress: levelProgress(s.caught), lastCatchDay: s.lastCatchDay, wasps: s.wasps, birds: s.birds, waspsKilled: s.waspsKilled, birdsScared: s.birdsScared, thievesDefeated: s.thievesDefeated, caterpillarsDefeated: s.caterpillarsDefeated, mothsHit: s.mothsHit, repellentUntil: s.repellentUntil, netTornUntil: s.netTornUntil, insuranceUntil: s.insuranceUntil, upgrades: { ...s.upgrades }, daily: { taskId: s.daily.taskId, title: (DAILY_TITLES[s.daily.taskId] ?? '{n}').replace('{n}', String(s.daily.target)), progress: s.daily.progress, target: s.daily.target, done: s.daily.done, reward: DAILY_TASK_REWARD, }, }; } /** Načte mapu všech uživatelských statistik ze storage. */ async function loadAll(): Promise> { return (await storage.getData>(STORAGE_KEY)) ?? {}; } /** * Atomicky zmutuje statistiky jednoho uživatele. Před zavoláním `mutator` * záznam znormalizuje a nechá dorůst škůdce. */ async function mutateUser( login: string, now: number, mutator: (mine: StoredButterflyStats) => void, ): Promise { const updated = await storage.updateData>(STORAGE_KEY, (current) => { const all = current ?? {}; const mine = normalize(all[login] ?? defaultStats(now), now); growPests(mine, now); ensureDaily(mine, now); mutator(mine); all[login] = mine; return all; }); return updated[login]; } // --- Chyby ------------------------------------------------------------------- /** Chyba vyhozená, když uživatel nemá dost mincí. */ export class InsufficientCoinsError extends Error { } // --- Veřejné API ------------------------------------------------------------- /** * Vrátí statistiky chytání motýlků daného uživatele (a nechá dorůst škůdce). */ export async function getStats(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, () => { /* jen dorůst škůdce */ }); return toDto(mine); } /** * Zaznamená dávku nachytaných motýlů, atomicky připíše mince (základ, kombo, * denní bonus) a aktualizuje statistiky. Vrátí i přehled odměn. */ export async function recordCatches(login: string, normal: number, golden: number, now: number = Date.now()): Promise { const n = Math.min(Math.max(Math.floor(normal) || 0, 0), MAX_BATCH); const g = Math.min(Math.max(Math.floor(golden) || 0, 0), MAX_BATCH); const batchTotal = n + g; const today = formatDate(new Date(now)); let coinsAwarded = 0; let dailyBonusApplied = false; let leveledUp = false; let premiumUnlocked = false; const mine = await mutateUser(login, now, (s) => { const oldCaught = s.caught; const oldLevel = levelForCaught(oldCaught); // Ochrana proti podvádění: v minutovém okně se započítá max RATE_CAP_PER_MIN // úlovků, zbytek se tiše zahodí (nezapočte se ani mince). Přednost mají zlatí. if (now - s.catchWindowStart >= RATE_WINDOW_MS) { s.catchWindowStart = now; s.catchWindowCount = 0; } const allowance = Math.max(0, RATE_CAP_PER_MIN - s.catchWindowCount); const creditGolden = Math.min(g, allowance); const creditNormal = Math.min(n, Math.max(0, allowance - creditGolden)); const credited = creditGolden + creditNormal; s.catchWindowCount += credited; dailyBonusApplied = credited > 0 && s.lastCatchDay !== today; const daily = dailyBonusApplied ? DAILY_BONUS : 0; coinsAwarded = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily; const newCaught = oldCaught + credited; s.caught = newCaught; s.coins += coinsAwarded; s.goldenCaught += creditGolden; if (credited > 0) s.lastCatchDay = today; bumpDaily(s, 'catch', credited); bumpDaily(s, 'golden', creditGolden); leveledUp = levelForCaught(newCaught) > oldLevel; premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE); }); return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked }; } /** * Okamžitě zašije protrženou síťku za mince (volitelné – jinak se zašije sama). * Vyhodí {@link InsufficientCoinsError} při nedostatku mincí. Když síťka není * protržená, jen vrátí aktuální stav. */ export async function repairNet(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { if (s.netTornUntil <= now) return; // není protržená (nebo se už zašila) if (s.coins < REPAIR_COST) { failed = true; return; } s.coins -= REPAIR_COST; s.netTornUntil = 0; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky'); return toDto(mine); } /** Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos. */ export async function killWasp(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { if (s.wasps > 0) { s.wasps -= 1; s.waspsKilled += 1; bumpDaily(s, 'wasp', 1); } }); return toDto(mine); } /** * Koupí plašič ptáků: vyžene všechny ptáky a po dobu platnosti brání příletu * nových. Vyhodí {@link InsufficientCoinsError} při nedostatku mincí. */ export async function buyRepellent(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { if (s.coins < REPELLENT_COST) { failed = true; return; } s.coins -= REPELLENT_COST; s.birdsScared += s.birds; s.birds = 0; s.repellentUntil = now + REPELLENT_DURATION_MS; // Růst ptáků se rozběhne až od konce plašiče s.lastBirdAt = s.repellentUntil; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků'); return toDto(mine); } /** * Zaznamená, že pták protrhl síťku (perzistentně). Síťka se sama zašije za * AUTO_REPAIR_MS. Když už protržená je, běžící odpočet se neprodlužuje. */ export async function reportNetTorn(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { if (s.netTornUntil <= now) s.netTornUntil = now + effectiveAutoRepair(s); }); return toDto(mine); } /** Zloděj se dostal k penězům a ukradl podíl mincí (méně, když je aktivní pojistka). */ export async function robbery(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { const insured = s.insuranceUntil > now; const fraction = insured ? ROBBERY_FRACTION_INSURED : ROBBERY_FRACTION; s.coins = Math.round(s.coins * (1 - fraction)); if (insured) s.insuranceUntil = 0; // pojistka se spotřebuje }); return toDto(mine); } /** Hráč porazil zloděje – malá odměna, statistika a progres denního úkolu. */ export async function defeatThief(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { s.thievesDefeated += 1; s.coins += THIEF_REWARD; bumpDaily(s, 'thief', 1); }); return toDto(mine); } /** Housenka se dostala k úlovkům a sežrala část nachytaných motýlů (může snížit úroveň). */ export async function caterpillarAte(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { s.caught = Math.max(0, s.caught - CATERPILLAR_EATS); }); return toDto(mine); } /** Hráč porazil housenku – malá odměna a statistika. */ export async function defeatCaterpillar(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { s.caterpillarsDefeated += 1; s.coins += CATERPILLAR_REWARD; }); return toDto(mine); } /** Hráč omylem chytil černou můru – přijde o část úlovků. */ export async function mothHit(login: string, now: number = Date.now()): Promise { const mine = await mutateUser(login, now, (s) => { s.caught = Math.max(0, s.caught - MOTH_PENALTY); s.mothsHit += 1; }); return toDto(mine); } // --- Obchod ------------------------------------------------------------------ /** Koupí okamžitou aktivaci prémiové síťky (efekt řídí klient). */ export async function buyPremium(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { if (s.coins < PREMIUM_BUY_COST) { failed = true; return; } s.coins -= PREMIUM_BUY_COST; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na prémiovou síťku'); return toDto(mine); } /** Koupí vosí sprej – vyhubí všechny vosy. */ export async function waspSpray(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { if (s.coins < WASP_SPRAY_COST) { failed = true; return; } s.coins -= WASP_SPRAY_COST; s.waspsKilled += s.wasps; s.wasps = 0; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vosí sprej'); return toDto(mine); } /** Koupí pojistku proti zloději (dočasně sníží ztrátu při okradení). */ export async function buyInsurance(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { if (s.coins < INSURANCE_COST) { failed = true; return; } s.coins -= INSURANCE_COST; s.insuranceUntil = now + INSURANCE_DURATION_MS; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na pojistku'); return toDto(mine); } /** Chyba pro neplatnou/vyprodanou koupi vylepšení. */ export class UpgradeError extends Error { } /** Koupí další úroveň trvalého vylepšení (cena se s úrovní zdvojnásobuje). */ export async function buyUpgrade(login: string, item: UpgradeId, now: number = Date.now()): Promise { if (!(item in UPGRADE_MAX)) throw new UpgradeError('Neznámé vylepšení'); let failed = false; let maxed = false; const mine = await mutateUser(login, now, (s) => { const level = s.upgrades[item] ?? 0; if (level >= UPGRADE_MAX[item]) { maxed = true; return; } const cost = upgradeCost(item, level); if (s.coins < cost) { failed = true; return; } s.coins -= cost; s.upgrades[item] = level + 1; }); if (maxed) throw new UpgradeError('Vylepšení je na maximální úrovni'); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vylepšení'); return toDto(mine); } // --- Achievementy (odvozené z aktuálních statistik) -------------------------- export interface Achievement { id: string; title: string; description: string; unlocked: boolean; } /** Vrátí seznam achievementů odvozený z aktuálních statistik uživatele. */ export async function getAchievements(login: string, now: number = Date.now()): Promise { const s = await mutateUser(login, now, () => { /* jen načíst/dorůst */ }); const level = levelForCaught(s.caught); const def: Achievement[] = [ { id: 'first-golden', title: 'Zlatý úlovek', description: 'Chyť prvního zlatého motýla', unlocked: s.goldenCaught >= 1 }, { id: 'golden-10', title: 'Zlatokop', description: 'Chyť 10 zlatých motýlů', unlocked: s.goldenCaught >= 10 }, { id: 'catch-500', title: 'Sběratel', description: 'Nachytej 500 motýlů', unlocked: s.caught >= 500 }, { id: 'catch-2000', title: 'Motýlí magnát', description: 'Nachytej 2000 motýlů', unlocked: s.caught >= 2000 }, { id: 'level-10', title: 'Zkušený chytač', description: 'Dosáhni úrovně 10', unlocked: level >= 10 }, { id: 'level-20', title: 'Legenda louky', description: 'Dosáhni úrovně 20', unlocked: level >= 20 }, { id: 'thief-10', title: 'Postrach zlodějů', description: 'Poraz 10 zlodějů', unlocked: s.thievesDefeated >= 10 }, { id: 'caterpillar-10', title: 'Zahradník', description: 'Poraz 10 housenek', unlocked: s.caterpillarsDefeated >= 10 }, { id: 'wasp-50', title: 'Plácačka', description: 'Zabij 50 vos', unlocked: s.waspsKilled >= 50 }, { id: 'bird-25', title: 'Strašák', description: 'Vyplaš 25 ptáků', unlocked: s.birdsScared >= 25 }, { id: 'net-max', title: 'Obří síť', description: 'Vylepši síťku na maximum', unlocked: (s.upgrades?.net ?? 0) >= UPGRADE_MAX.net }, ]; return def; } /** * Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených * (při shodě dle počtu zlatých motýlů). */ export async function getLeaderboard(limit = 10): Promise { const all = await loadAll(); return Object.entries(all) .map(([login, s]) => { const level = levelForCaught(s.caught ?? 0); return { login, caught: s.caught ?? 0, goldenCaught: s.goldenCaught ?? 0, level, title: titleForLevel(level), }; }) .sort((a, b) => b.caught - a.caught || b.goldenCaught - a.goldenCaught) .slice(0, Math.max(1, limit)); }