Files
Luncher/server/src/butterflies.ts
T
Stánek PavelandClaude Opus 4.8 252d105408
CI / Generate TypeScript types (push) Successful in 15s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 1m2s
CI / Playwright E2E tests (push) Successful in 1m33s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 1s
feat: obchod, černé můry, netopýr, pavouk a denní úkoly u chytání motýlků
- obchod s pomůckami a trvalými vylepšeními (větší síťka, strašák, zpevněná síťka) – mince mají trvalý smysl
- černé můry se míchají mezi motýly; chycení (i magnetem) ubere hodně úlovků
- ptáci silnější, plašič dražší a kratší, zloději chodí častěji podle bohatství
- denní úkol s odměnou + achievementy/odznaky
- netopýr v noci loví motýly, pavouk zamotá síťku pavučinou – oba na zaklikání
- nákupní hlášky rozlišují nedostatek mincí od jiné chyby; herní smyčka se pozastaví při skryté záložce

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:23:09 +02:00

687 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (01) */
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<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
/** Základní cena vylepšení (další úroveň je 2× dražší) */
const UPGRADE_BASE: Record<UpgradeId, number> = { 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<string, string> = {
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ě (0100 %). 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<Record<string, StoredButterflyStats>> {
return (await storage.getData<Record<string, StoredButterflyStats>>(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<StoredButterflyStats> {
const updated = await storage.updateData<Record<string, StoredButterflyStats>>(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<ButterflyStats> {
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<ButterflyCatchResult> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<ButterflyStats> {
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<Achievement[]> {
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<ButterflyLeaderboardEntry[]> {
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));
}