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
- 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>
687 lines
28 KiB
TypeScript
687 lines
28 KiB
TypeScript
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<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ě (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<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));
|
||
}
|