-
-
{caught}
+
+ {flash && (
+
setFlash(null)} title="Klikni pro zavření">
+ {flash}
+
+ )}
+ {combo > 1 &&
Kombo ×{combo}!
}
+
+
setLeaderboardOpen(true)}
+ onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
+ >
+
+
+ {caught}
+
+ {coins}
+
+
+
+ {stats ? `${stats.level}. ${stats.title}` : '…'}
+
+
+
+
+
+ 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}
+ 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}
+ {repellentActive && (
+
+ 🚫 plašič {formatRemaining(repellentRemainingMs)}
+
+ )}
+
+
+ {torn && (
+
+ )}
+
+ {birds > 0 && !repellentActive && (
+
+ )}
+
+
+
setLeaderboardOpen(false)}
+ myStats={stats}
+ />
)}
>
);
};
-// Přednastavení množství motýlů pro různé účely
export const BUTTERFLY_PRESETS = {
- LIGHT: 5, // Pár motýlů
- NORMAL: 9, // Standardní množství
- HEAVY: 16, // Rušná letní louka
+ LIGHT: 5,
+ NORMAL: 9,
+ HEAVY: 16,
} as const;
-// Přednastavené barevné kombinace
export const BUTTERFLY_COLOR_THEMES = {
ALL: BUTTERFLY_VARIANTS,
- WARM: ['butterfly-orange.svg', 'butterfly-yellow.svg', 'butterfly-pink.svg'] as const,
- COOL: ['butterfly-blue.svg'] as const,
+ WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
+ COOL: ['/butterfly-blue.svg'] as const,
} as const;
export default FlyingButterflies;
diff --git a/client/src/components/modals/ButterflyLeaderboardModal.scss b/client/src/components/modals/ButterflyLeaderboardModal.scss
new file mode 100644
index 0000000..2697a8d
--- /dev/null
+++ b/client/src/components/modals/ButterflyLeaderboardModal.scss
@@ -0,0 +1,111 @@
+.butterfly-mystats {
+ margin-bottom: 16px;
+ padding: 12px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
+ border: 1px solid rgba(224, 162, 0, 0.35);
+}
+
+.butterfly-mystats-title {
+ font-weight: 700;
+ color: #6b5b2e;
+ margin-bottom: 8px;
+}
+
+.butterfly-mystats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
+ gap: 8px;
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .v {
+ font-size: 1.3rem;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+
+ &.gold {
+ color: #e0a200;
+ }
+ }
+
+ .l {
+ font-size: 0.72rem;
+ color: #6c757d;
+ }
+}
+
+.butterfly-leaderboard-heading {
+ font-weight: 700;
+ margin: 4px 0 8px;
+}
+
+.butterfly-leaderboard {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.butterfly-leaderboard-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.03);
+
+ &.me {
+ background: rgba(255, 203, 61, 0.22);
+ border: 1px solid rgba(224, 162, 0, 0.5);
+ font-weight: 700;
+ }
+}
+
+.butterfly-leaderboard-rank {
+ flex: 0 0 auto;
+ width: 28px;
+ text-align: center;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.butterfly-leaderboard-name {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.butterfly-leaderboard-title {
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: #6b5b2e;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.butterfly-leaderboard-stats {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-variant-numeric: tabular-nums;
+}
+
+.butterfly-leaderboard-golden {
+ color: #e0a200;
+ font-weight: 700;
+}
diff --git a/client/src/components/modals/ButterflyLeaderboardModal.tsx b/client/src/components/modals/ButterflyLeaderboardModal.tsx
new file mode 100644
index 0000000..86b7175
--- /dev/null
+++ b/client/src/components/modals/ButterflyLeaderboardModal.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from "react";
+import { Modal } from "react-bootstrap";
+import { ButterflyLeaderboardEntry, ButterflyStats, getButterflyLeaderboard } from "../../../../types";
+import { useAuth } from "../../context/auth";
+import "./ButterflyLeaderboardModal.scss";
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ myStats?: ButterflyStats;
+};
+
+const MEDALS = ['🥇', '🥈', '🥉'];
+
+/** Modální dialog s osobními statistikami a týmovým žebříčkem chytačů motýlů. */
+export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly
) {
+ const auth = useAuth();
+ const [entries, setEntries] = useState();
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ let cancelled = false;
+ setLoading(true);
+ getButterflyLeaderboard({ query: { limit: 20 } })
+ .then(res => { if (!cancelled) setEntries(res.data ?? []); })
+ .catch(() => { if (!cancelled) setEntries([]); })
+ .finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [isOpen]);
+
+ return (
+
+
+ 🦋 Chytání motýlků
+
+
+ {myStats && (
+
+
+ {myStats.level}. {myStats.title}
+
+
+
{myStats.caught}🦋 chyceno
+
{myStats.goldenCaught}✨ zlatých
+
{myStats.coins}🪙 mincí
+
{myStats.waspsKilled}🪰 vos zabito
+
{myStats.birdsScared}🦅 vyplašeno
+
+
+ )}
+
+ Žebříček týmu
+ {loading && Načítám žebříček…
}
+ {!loading && entries && entries.length === 0 && (
+ Zatím nikdo nic nechytil. Buď první!
+ )}
+ {!loading && entries && entries.length > 0 && (
+
+ {entries.map((e, i) => (
+ -
+ {MEDALS[i] ?? `${i + 1}.`}
+
+ {e.login}
+ {e.level}. {e.title}
+
+
+ {e.caught} 🦋
+ {e.goldenCaught > 0 && (
+ {e.goldenCaught} ✨
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/hooks/useButterflyStats.ts b/client/src/hooks/useButterflyStats.ts
new file mode 100644
index 0000000..196216b
--- /dev/null
+++ b/client/src/hooks/useButterflyStats.ts
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ButterflyStats,
+ getButterflyStats,
+ catchButterflies,
+ repairNet as repairNetApi,
+ killWasp as killWaspApi,
+ buyRepellent as buyRepellentApi,
+ reportNetTorn as reportTearApi,
+} from '../../../types';
+
+/** Starý klíč, pod kterým se počet chycených ukládal jen do localStorage. */
+const LEGACY_KEY = 'flyingButterfliesCaught';
+/** Příznak, že už proběhla jednorázová migrace localStorage → server. */
+const MIGRATED_KEY = 'flyingButterfliesMigrated';
+/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
+const FLUSH_DEBOUNCE_MS = 1500;
+/** Server přijme max 100 motýlů na dávku – migraci proto posíláme po částech. */
+const MIGRATION_CHUNK = 100;
+/** Bezpečnostní strop migrovaných úlovků (proti nesmyslným hodnotám v localStorage). */
+const MIGRATION_MAX = 1000;
+/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
+const POLL_INTERVAL_MS = 30_000;
+
+/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
+export interface RewardEvent {
+ coinsAwarded: number;
+ dailyBonusApplied: boolean;
+ leveledUp: boolean;
+ premiumUnlocked: boolean;
+ newLevel: number;
+ newTitle: string;
+}
+
+/**
+ * Hook spravující serverovou perzistenci chytání motýlků: načtení stavu,
+ * jednorázovou migraci z localStorage, dávkové odesílání úlovků, hubení vos,
+ * plašič ptáků, protržení/opravu síťky a periodické přenačítání (růst škůdců).
+ */
+export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
+ const [stats, setStats] = useState();
+ /** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
+ const coinsRef = useRef(0);
+ const pending = useRef({ normal: 0, golden: 0 });
+ const flushTimer = useRef(null);
+ const onRewardRef = useRef(onReward);
+ onRewardRef.current = onReward;
+
+ /** Zapíše statistiky ze serveru, ale nedovolí „couvnout" počtu chycených
+ * (kvůli optimistickému počítadlu, které může být napřed před flushem). */
+ const applyStats = useCallback((incoming: ButterflyStats) => {
+ setStats(prev => {
+ const merged: ButterflyStats = { ...incoming };
+ if (prev && prev.caught > incoming.caught) {
+ merged.caught = prev.caught;
+ merged.goldenCaught = Math.max(prev.goldenCaught, incoming.goldenCaught);
+ }
+ coinsRef.current = merged.coins;
+ return merged;
+ });
+ }, []);
+
+ const flush = useCallback(async () => {
+ if (flushTimer.current) {
+ clearTimeout(flushTimer.current);
+ flushTimer.current = null;
+ }
+ const batch = pending.current;
+ if (batch.normal === 0 && batch.golden === 0) return;
+ pending.current = { normal: 0, golden: 0 };
+ try {
+ const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
+ if (res.data) {
+ applyStats(res.data.stats);
+ onRewardRef.current?.({
+ coinsAwarded: res.data.coinsAwarded,
+ dailyBonusApplied: res.data.dailyBonusApplied,
+ leveledUp: res.data.leveledUp,
+ premiumUnlocked: res.data.premiumUnlocked,
+ newLevel: res.data.stats.level,
+ newTitle: res.data.stats.title,
+ });
+ }
+ } catch {
+ pending.current.normal += batch.normal;
+ pending.current.golden += batch.golden;
+ }
+ }, [applyStats]);
+
+ /** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
+ const reportCatch = useCallback((golden: boolean) => {
+ if (golden) pending.current.golden += 1;
+ else pending.current.normal += 1;
+ setStats(prev => prev
+ ? { ...prev, caught: prev.caught + 1, goldenCaught: prev.goldenCaught + (golden ? 1 : 0) }
+ : prev);
+ if (!flushTimer.current) {
+ flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
+ }
+ }, [flush]);
+
+ /** Zaplatí opravu síťky. Vrátí true při úspěchu, false při nedostatku mincí/chybě. */
+ const repair = useCallback(async (): Promise => {
+ try {
+ const res = await repairNetApi();
+ if (res.data) { applyStats(res.data); return true; }
+ return false;
+ } catch { return false; }
+ }, [applyStats]);
+
+ /** Zahubí jednu vosu (plácačka). */
+ const killWasp = useCallback(async () => {
+ try {
+ const res = await killWaspApi();
+ if (res.data) applyStats(res.data);
+ } catch { /* ignore */ }
+ }, [applyStats]);
+
+ /** Koupí plašič ptáků. Vrátí true při úspěchu, false při nedostatku mincí. */
+ const buyRepellent = useCallback(async (): Promise => {
+ try {
+ const res = await buyRepellentApi();
+ if (res.data) { applyStats(res.data); return true; }
+ return false;
+ } catch { return false; }
+ }, [applyStats]);
+
+ /** Nahlásí serveru, že pták protrhl síťku (perzistentně). */
+ const reportTear = useCallback(async () => {
+ try {
+ const res = await reportTearApi();
+ if (res.data) applyStats(res.data);
+ } catch { /* ignore */ }
+ }, [applyStats]);
+
+ /** Přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
+ const refresh = useCallback(async () => {
+ try {
+ const res = await getButterflyStats();
+ if (res.data) applyStats(res.data);
+ } catch { /* ignore */ }
+ }, [applyStats]);
+
+ // Načtení stavu + jednorázová migrace z localStorage
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await getButterflyStats();
+ if (cancelled || !res.data) return;
+ let current = res.data;
+ try {
+ if (!localStorage.getItem(MIGRATED_KEY)) {
+ const legacy = Number(localStorage.getItem(LEGACY_KEY));
+ let diff = Number.isFinite(legacy) ? Math.min(legacy - current.caught, MIGRATION_MAX) : 0;
+ while (diff > 0 && !cancelled) {
+ const chunk = Math.min(diff, MIGRATION_CHUNK);
+ const migRes = await catchButterflies({ body: { normal: chunk, golden: 0 } });
+ if (migRes.data) current = migRes.data.stats;
+ diff -= chunk;
+ }
+ localStorage.setItem(MIGRATED_KEY, '1');
+ localStorage.removeItem(LEGACY_KEY);
+ }
+ } catch { /* localStorage nedostupné – migraci přeskočíme */ }
+ if (!cancelled) applyStats(current);
+ } catch { /* server nedostupný – hra jede dál bez perzistence */ }
+ })();
+ return () => { cancelled = true; };
+ }, [applyStats]);
+
+ // Periodické přenačítání (růst škůdců) + při návratu na záložku
+ useEffect(() => {
+ const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
+ const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
+ document.addEventListener('visibilitychange', onVisible);
+ return () => {
+ window.clearInterval(id);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ }, [refresh]);
+
+ // Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
+ useEffect(() => {
+ const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
+ const onPageHide = () => { void flush(); };
+ document.addEventListener('visibilitychange', onVisibility);
+ window.addEventListener('pagehide', onPageHide);
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibility);
+ window.removeEventListener('pagehide', onPageHide);
+ void flush();
+ };
+ }, [flush]);
+
+ return { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear };
+}
diff --git a/server/changelogs/2026-07-22.json b/server/changelogs/2026-07-22.json
new file mode 100644
index 0000000..da8debc
--- /dev/null
+++ b/server/changelogs/2026-07-22.json
@@ -0,0 +1,10 @@
+[
+ "Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
+ "Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
+ "Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
+ "Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
+ "Ptáci přibývají a trhají síťku (oprava za mince); můžeš si koupit plašič, který je na chvíli vyžene",
+ "Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
+ "Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
+ "Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
+]
diff --git a/server/src/butterflies.ts b/server/src/butterflies.ts
new file mode 100644
index 0000000..e9a0a9d
--- /dev/null
+++ b/server/src/butterflies.ts
@@ -0,0 +1,338 @@
+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;
+ /** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
+ repellentUntil: number;
+ /** Zda je síťka protržená */
+ netTorn: boolean;
+ /** Kdy naposledy „dorostla" vosa (ms epoch) */
+ lastWaspAt: number;
+ /** Kdy naposledy „dorostl" pták (ms epoch) */
+ lastBirdAt: number;
+}
+
+const storage = getStorage();
+const STORAGE_KEY = 'butterflyStats';
+
+// --- 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 = 10;
+/** Bonus mincí za první úlovek dne */
+export const DAILY_BONUS = 10;
+/** Po každých kolika chycených se odemkne prémiová síťka */
+export const PREMIUM_MILESTONE = 50;
+
+/** Maximální počet současně poletujících vos */
+export const WASP_MAX = 5;
+/** Maximální počet současně poletujících ptáků */
+export const BIRD_MAX = 4;
+/** Jak často (ms) přibude jedna vosa */
+export const WASP_GROWTH_MS = 30_000;
+/** Jak často (ms) přibude jeden pták */
+export const BIRD_GROWTH_MS = 120_000;
+/** Cena plašiče ptáků v mincích */
+export const REPELLENT_COST = 25;
+/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
+export const REPELLENT_DURATION_MS = 180_000;
+
+/** Od jaké velikosti dávky se začíná počítat kombo bonus */
+const COMBO_THRESHOLD = 3;
+/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
+const COMBO_CAP = 20;
+/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
+const MAX_BATCH = 100;
+
+/**
+ * Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených,
+ * od které úroveň platí. Pole je vzestupně dle `min`.
+ */
+const LEVELS: { min: number; title: string }[] = [
+ { min: 0, title: 'Začátečník se síťkou' },
+ { min: 10, title: 'Nedělní chytač' },
+ { min: 30, title: 'Lovec luk' },
+ { min: 60, title: 'Sběratel křídel' },
+ { min: 120, title: 'Mistr síťky' },
+ { min: 200, title: 'Motýlí šeptač' },
+ { min: 350, title: 'Legendární entomolog' },
+ { min: 600, title: 'Vládce louky' },
+];
+
+// --- Čisté pomocné funkce ----------------------------------------------------
+
+/** 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;
+ for (let i = 0; i < LEVELS.length; i++) {
+ if (caught >= LEVELS[i].min) {
+ level = i + 1;
+ }
+ }
+ return level;
+}
+
+/** Vrátí titul odpovídající dané úrovni (1-based). */
+export function titleForLevel(level: number): string {
+ const idx = Math.min(Math.max(level - 1, 0), LEVELS.length - 1);
+ return LEVELS[idx].title;
+}
+
+/** 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,
+ repellentUntil: 0, netTorn: false,
+ lastWaspAt: now, lastBirdAt: now,
+ };
+}
+
+/** 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,
+ repellentUntil: s.repellentUntil ?? 0,
+ netTorn: s.netTorn ?? false,
+ lastWaspAt: s.lastWaspAt ?? now,
+ lastBirdAt: s.lastBirdAt ?? now,
+ };
+}
+
+/**
+ * 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 {
+ // 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č
+ const repellentActive = s.repellentUntil > now;
+ if (repellentActive) {
+ s.lastBirdAt = now;
+ } else if (s.birds < BIRD_MAX) {
+ const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
+ if (add > 0) {
+ s.birds = Math.min(s.birds + add, BIRD_MAX);
+ s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS;
+ }
+ } 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),
+ lastCatchDay: s.lastCatchDay,
+ wasps: s.wasps,
+ birds: s.birds,
+ waspsKilled: s.waspsKilled,
+ birdsScared: s.birdsScared,
+ repellentUntil: s.repellentUntil,
+ netTorn: s.netTorn,
+ };
+}
+
+/** 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);
+ 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);
+
+ dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today;
+ const daily = dailyBonusApplied ? DAILY_BONUS : 0;
+
+ coinsAwarded = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily;
+
+ const newCaught = oldCaught + batchTotal;
+ s.caught = newCaught;
+ s.coins += coinsAwarded;
+ s.goldenCaught += g;
+ if (batchTotal > 0) s.lastCatchDay = today;
+
+ leveledUp = levelForCaught(newCaught) > oldLevel;
+ premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
+ });
+
+ return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked };
+}
+
+/**
+ * Zašije protrženou síťku za mince. Vyhodí {@link InsufficientCoinsError}, pokud
+ * uživatel nemá dost 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.netTorn) return;
+ if (s.coins < REPAIR_COST) { failed = true; return; }
+ s.coins -= REPAIR_COST;
+ s.netTorn = false;
+ });
+ if (failed) throw new InsufficientCoinsError('Nedostatek mincí na 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;
+ }
+ });
+ 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;
+ s.lastBirdAt = now;
+ });
+ if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
+ return toDto(mine);
+}
+
+/** Zaznamená, že pták protrhl síťku (perzistentně). */
+export async function reportNetTorn(login: string, now: number = Date.now()): Promise {
+ const mine = await mutateUser(login, now, (s) => { s.netTorn = true; });
+ return toDto(mine);
+}
+
+/**
+ * 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));
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 754822d..3b557e1 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -28,6 +28,7 @@ import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes";
+import butterflyRoutes from "./routes/butterflyRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -273,6 +274,7 @@ app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use("/api/groups", groupRoutes);
app.use("/api/stores", storeRoutes);
+app.use("/api/butterflies", butterflyRoutes);
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('*splat', (_req, res) => {
diff --git a/server/src/routes/butterflyRoutes.ts b/server/src/routes/butterflyRoutes.ts
new file mode 100644
index 0000000..96d6d40
--- /dev/null
+++ b/server/src/routes/butterflyRoutes.ts
@@ -0,0 +1,88 @@
+import express, { Request } from "express";
+import { getLogin } from "../auth";
+import { parseToken } from "../utils";
+import {
+ getStats,
+ recordCatches,
+ repairNet,
+ killWasp,
+ buyRepellent,
+ reportNetTorn,
+ getLeaderboard,
+ InsufficientCoinsError,
+} from "../butterflies";
+import { CatchButterfliesData } from "../../../types";
+
+const router = express.Router();
+
+router.get("/", async (req: Request, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const data = await getStats(login);
+ res.status(200).json(data);
+ } catch (e: any) { next(e) }
+});
+
+router.post("/catch", async (req: Request<{}, any, CatchButterfliesData["body"]>, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ if (typeof req.body?.normal !== 'number' || typeof req.body?.golden !== 'number') {
+ return res.status(400).json({ error: "Chybné parametry volání" });
+ }
+ const data = await recordCatches(login, req.body.normal, req.body.golden);
+ res.status(200).json(data);
+ } catch (e: any) { next(e) }
+});
+
+router.post("/repair", async (req: Request, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const data = await repairNet(login);
+ res.status(200).json(data);
+ } catch (e: any) {
+ if (e instanceof InsufficientCoinsError) {
+ return res.status(402).json({ error: e.message });
+ }
+ next(e);
+ }
+});
+
+router.post("/killWasp", async (req: Request, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const data = await killWasp(login);
+ res.status(200).json(data);
+ } catch (e: any) { next(e) }
+});
+
+router.post("/repellent", async (req: Request, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const data = await buyRepellent(login);
+ res.status(200).json(data);
+ } catch (e: any) {
+ if (e instanceof InsufficientCoinsError) {
+ return res.status(402).json({ error: e.message });
+ }
+ next(e);
+ }
+});
+
+router.post("/tear", async (req: Request, res, next) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const data = await reportNetTorn(login);
+ res.status(200).json(data);
+ } catch (e: any) { next(e) }
+});
+
+router.get("/leaderboard", async (req: Request, res, next) => {
+ try {
+ getLogin(parseToken(req));
+ const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : undefined;
+ const data = await getLeaderboard(Number.isFinite(limit) ? limit : undefined);
+ res.status(200).json(data);
+ } catch (e: any) { next(e) }
+});
+
+export default router;
diff --git a/server/src/tests/butterflies.test.ts b/server/src/tests/butterflies.test.ts
new file mode 100644
index 0000000..775bc98
--- /dev/null
+++ b/server/src/tests/butterflies.test.ts
@@ -0,0 +1,263 @@
+import { resetMemoryStorage } from '../storage/memory';
+import getStorage from '../storage';
+import {
+ getStats,
+ recordCatches,
+ repairNet,
+ killWasp,
+ buyRepellent,
+ reportNetTorn,
+ getLeaderboard,
+ growPests,
+ levelForCaught,
+ titleForLevel,
+ comboBonus,
+ InsufficientCoinsError,
+ COIN_BASE,
+ GOLD_VALUE,
+ REPAIR_COST,
+ DAILY_BONUS,
+ PREMIUM_MILESTONE,
+ WASP_MAX,
+ BIRD_MAX,
+ WASP_GROWTH_MS,
+ BIRD_GROWTH_MS,
+ REPELLENT_COST,
+ REPELLENT_DURATION_MS,
+} from '../butterflies';
+import { formatDate } from '../utils';
+
+const USER = 'tomas';
+const OTHER = 'petr';
+
+beforeEach(() => {
+ resetMemoryStorage();
+});
+
+/** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */
+async function seed(login: string, data: any) {
+ const storage = getStorage();
+ const all = (await storage.getData('butterflyStats')) ?? {};
+ all[login] = data;
+ await storage.setData('butterflyStats', all);
+}
+
+describe('levelForCaught / titleForLevel', () => {
+ test('začíná na úrovni 1', () => {
+ expect(levelForCaught(0)).toBe(1);
+ expect(titleForLevel(1)).toBe('Začátečník se síťkou');
+ });
+
+ test('roste s počtem chycených', () => {
+ expect(levelForCaught(9)).toBe(1);
+ expect(levelForCaught(10)).toBe(2);
+ expect(levelForCaught(30)).toBe(3);
+ expect(levelForCaught(10000)).toBe(8);
+ });
+});
+
+describe('comboBonus', () => {
+ test('malá dávka nedává kombo', () => {
+ expect(comboBonus(1)).toBe(0);
+ expect(comboBonus(2)).toBe(0);
+ });
+
+ test('větší dávka dává rostoucí bonus se stropem', () => {
+ expect(comboBonus(3)).toBe(1);
+ expect(comboBonus(4)).toBe(2);
+ expect(comboBonus(1000)).toBe(20); // COMBO_CAP
+ });
+});
+
+describe('getStats', () => {
+ test('pro nového uživatele vrací výchozí prázdné statistiky', async () => {
+ const stats = await getStats(USER);
+ expect(stats.caught).toBe(0);
+ expect(stats.coins).toBe(0);
+ expect(stats.goldenCaught).toBe(0);
+ expect(stats.level).toBe(1);
+ expect(stats.title).toBe('Začátečník se síťkou');
+ });
+});
+
+describe('recordCatches – mince', () => {
+ test('běžný motýl dá základní minci', async () => {
+ const result = await recordCatches(USER, 1, 0);
+ // 1 běžný + první úlovek dne => denní bonus
+ expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS);
+ expect(result.dailyBonusApplied).toBe(true);
+ expect(result.stats.caught).toBe(1);
+ expect(result.stats.coins).toBe(COIN_BASE + DAILY_BONUS);
+ });
+
+ test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => {
+ // předvyplníme dnešní den, aby nezasáhl denní bonus
+ await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
+ const result = await recordCatches(USER, 0, 1);
+ expect(result.coinsAwarded).toBe(GOLD_VALUE);
+ expect(result.dailyBonusApplied).toBe(false);
+ expect(result.stats.goldenCaught).toBe(1);
+ expect(result.stats.caught).toBe(6);
+ });
+
+ test('kombo se připočítá u větší dávky', async () => {
+ await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
+ const result = await recordCatches(USER, 4, 0);
+ // 4 * COIN_BASE + comboBonus(4)=2
+ expect(result.coinsAwarded).toBe(4 * COIN_BASE + 2);
+ });
+
+ test('sanity limit ořízne absurdní dávku a odmítne záporné', async () => {
+ const result = await recordCatches(USER, 100000, -5);
+ expect(result.stats.caught).toBe(100); // MAX_BATCH
+ });
+});
+
+describe('recordCatches – denní bonus', () => {
+ test('denní bonus se připíše jen jednou za den', async () => {
+ const first = await recordCatches(USER, 1, 0);
+ expect(first.dailyBonusApplied).toBe(true);
+ const second = await recordCatches(USER, 1, 0);
+ expect(second.dailyBonusApplied).toBe(false);
+ expect(second.coinsAwarded).toBe(COIN_BASE);
+ });
+
+ test('bonus se znovu připíše po změně dne', async () => {
+ await seed(USER, { caught: 3, coins: 3, goldenCaught: 0, lastCatchDay: '2000-01-01' });
+ const result = await recordCatches(USER, 1, 0);
+ expect(result.dailyBonusApplied).toBe(true);
+ });
+});
+
+describe('recordCatches – progres a milníky', () => {
+ test('leveledUp se nastaví při překročení hranice úrovně', async () => {
+ await seed(USER, { caught: 9, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
+ const result = await recordCatches(USER, 1, 0); // 9 -> 10 => úroveň 2
+ expect(result.leveledUp).toBe(true);
+ expect(result.stats.level).toBe(2);
+ });
+
+ test('premiumUnlocked se nastaví při překročení milníku', async () => {
+ await seed(USER, {
+ caught: PREMIUM_MILESTONE - 1,
+ coins: 0,
+ goldenCaught: 0,
+ lastCatchDay: formatDate(new Date()),
+ });
+ const result = await recordCatches(USER, 1, 0);
+ expect(result.premiumUnlocked).toBe(true);
+ });
+
+ test('bez překročení milníku premiumUnlocked zůstane false', async () => {
+ await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
+ const result = await recordCatches(USER, 1, 0);
+ expect(result.premiumUnlocked).toBe(false);
+ });
+});
+
+describe('repairNet', () => {
+ test('odečte cenu opravy při dostatku mincí a zruší protržení', async () => {
+ await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTorn: true });
+ const stats = await repairNet(USER);
+ expect(stats.coins).toBe(5);
+ expect(stats.netTorn).toBe(false);
+ });
+
+ test('vyhodí chybu při nedostatku mincí', async () => {
+ await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTorn: true });
+ await expect(repairNet(USER)).rejects.toThrow(InsufficientCoinsError);
+ // mince zůstanou nezměněné a síťka stále protržená
+ const stats = await getStats(USER);
+ expect(stats.coins).toBe(REPAIR_COST - 1);
+ expect(stats.netTorn).toBe(true);
+ });
+
+ test('neúčtuje, když síťka není protržená', async () => {
+ await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTorn: false });
+ const stats = await repairNet(USER);
+ expect(stats.coins).toBe(50);
+ });
+});
+
+describe('growPests', () => {
+ test('vosy přibývají v čase do maxima', () => {
+ const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
+ growPests(s, 2 * WASP_GROWTH_MS + 100);
+ expect(s.wasps).toBe(2);
+ growPests(s, 1_000_000 * WASP_GROWTH_MS);
+ expect(s.wasps).toBe(WASP_MAX);
+ });
+
+ test('zachovává rozpracovaný čas (dvě poloviční okna = jedna vosa)', () => {
+ const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
+ growPests(s, Math.floor(WASP_GROWTH_MS * 0.6));
+ expect(s.wasps).toBe(0);
+ growPests(s, Math.floor(WASP_GROWTH_MS * 1.2));
+ expect(s.wasps).toBe(1);
+ });
+
+ test('ptáci nepřibývají při aktivním plašiči', () => {
+ const now = 1_000_000;
+ const s: any = { wasps: 0, birds: 0, repellentUntil: now + 10 * BIRD_GROWTH_MS, lastWaspAt: now, lastBirdAt: now };
+ growPests(s, now + 3 * BIRD_GROWTH_MS);
+ expect(s.birds).toBe(0);
+ });
+});
+
+describe('killWasp', () => {
+ test('sníží počet vos a zvýší statistiku zahubených', async () => {
+ await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 3, lastWaspAt: Date.now() });
+ const stats = await killWasp(USER);
+ expect(stats.wasps).toBe(2);
+ expect(stats.waspsKilled).toBe(1);
+ });
+
+ test('při nula vosách nic nepokazí', async () => {
+ await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 0, lastWaspAt: Date.now() });
+ const stats = await killWasp(USER);
+ expect(stats.wasps).toBe(0);
+ expect(stats.waspsKilled).toBe(0);
+ });
+});
+
+describe('buyRepellent', () => {
+ test('vyžene ptáky, aktivuje plašič a odečte mince', async () => {
+ const now = 5_000_000;
+ await seed(USER, { caught: 0, coins: REPELLENT_COST + 3, goldenCaught: 0, birds: 3, lastBirdAt: now });
+ const stats = await buyRepellent(USER, now);
+ expect(stats.coins).toBe(3);
+ expect(stats.birds).toBe(0);
+ expect(stats.birdsScared).toBe(3);
+ expect(stats.repellentUntil).toBe(now + REPELLENT_DURATION_MS);
+ });
+
+ test('vyhodí chybu při nedostatku mincí', async () => {
+ await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
+ await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
+ });
+});
+
+describe('reportNetTorn', () => {
+ test('nastaví protrženou síťku (perzistentně)', async () => {
+ await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 });
+ const stats = await reportNetTorn(USER);
+ expect(stats.netTorn).toBe(true);
+ // stav přežije další čtení (F5)
+ expect((await getStats(USER)).netTorn).toBe(true);
+ });
+});
+
+describe('getLeaderboard', () => {
+ test('řadí sestupně dle počtu chycených a respektuje limit', async () => {
+ await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 });
+ await seed(OTHER, { caught: 200, coins: 0, goldenCaught: 0 });
+ await seed('jana', { caught: 50, coins: 0, goldenCaught: 10 });
+
+ const all = await getLeaderboard(10);
+ expect(all.map(e => e.login)).toEqual([OTHER, USER, 'jana']);
+
+ const top = await getLeaderboard(2);
+ expect(top).toHaveLength(2);
+ expect(top[0].login).toBe(OTHER);
+ });
+});
diff --git a/types/api.yml b/types/api.yml
index 835d343..adced24 100644
--- a/types/api.yml
+++ b/types/api.yml
@@ -81,6 +81,22 @@ paths:
/suggestions/delete:
$ref: "./paths/suggestions/delete.yml"
+ # Chytání motýlků (/api/butterflies)
+ /butterflies:
+ $ref: "./paths/butterflies/getStats.yml"
+ /butterflies/catch:
+ $ref: "./paths/butterflies/catch.yml"
+ /butterflies/repair:
+ $ref: "./paths/butterflies/repair.yml"
+ /butterflies/killWasp:
+ $ref: "./paths/butterflies/killWasp.yml"
+ /butterflies/repellent:
+ $ref: "./paths/butterflies/repellent.yml"
+ /butterflies/tear:
+ $ref: "./paths/butterflies/tear.yml"
+ /butterflies/leaderboard:
+ $ref: "./paths/butterflies/leaderboard.yml"
+
# Changelog (/api/changelogs)
/changelogs:
$ref: "./paths/changelogs/getChangelogs.yml"
diff --git a/types/paths/butterflies/catch.yml b/types/paths/butterflies/catch.yml
new file mode 100644
index 0000000..3b555bb
--- /dev/null
+++ b/types/paths/butterflies/catch.yml
@@ -0,0 +1,18 @@
+post:
+ operationId: catchButterflies
+ summary: >-
+ Zaznamená dávku nachytaných motýlů, připíše mince (základ, kombo a případný
+ denní bonus) a vrátí aktualizované statistiky.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyCatchRequest"
+ responses:
+ "200":
+ description: Aktualizované statistiky a přehled připsaných odměn.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyCatchResult"
diff --git a/types/paths/butterflies/getStats.yml b/types/paths/butterflies/getStats.yml
new file mode 100644
index 0000000..128d546
--- /dev/null
+++ b/types/paths/butterflies/getStats.yml
@@ -0,0 +1,10 @@
+get:
+ operationId: getButterflyStats
+ summary: Vrátí statistiky chytání motýlků přihlášeného uživatele.
+ responses:
+ "200":
+ description: Statistiky chytání motýlků přihlášeného uživatele.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyStats"
diff --git a/types/paths/butterflies/killWasp.yml b/types/paths/butterflies/killWasp.yml
new file mode 100644
index 0000000..c0e2379
--- /dev/null
+++ b/types/paths/butterflies/killWasp.yml
@@ -0,0 +1,10 @@
+post:
+ operationId: killWasp
+ summary: Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos.
+ responses:
+ "200":
+ description: Aktualizované statistiky po zahubení vosy.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyStats"
diff --git a/types/paths/butterflies/leaderboard.yml b/types/paths/butterflies/leaderboard.yml
new file mode 100644
index 0000000..e26b4ab
--- /dev/null
+++ b/types/paths/butterflies/leaderboard.yml
@@ -0,0 +1,17 @@
+get:
+ operationId: getButterflyLeaderboard
+ summary: Vrátí žebříček nejlepších chytačů motýlů seřazený dle počtu chycených.
+ parameters:
+ - in: query
+ name: limit
+ required: false
+ schema:
+ type: integer
+ description: Maximální počet záznamů žebříčku (výchozí 10)
+ responses:
+ "200":
+ description: Žebříček chytačů motýlů.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyLeaderboard"
diff --git a/types/paths/butterflies/repair.yml b/types/paths/butterflies/repair.yml
new file mode 100644
index 0000000..d552a3d
--- /dev/null
+++ b/types/paths/butterflies/repair.yml
@@ -0,0 +1,14 @@
+post:
+ operationId: repairNet
+ summary: >-
+ Zašije protrženou síťku za mince. Vrátí aktualizované statistiky, nebo 402
+ pokud uživatel nemá dostatek mincí.
+ responses:
+ "200":
+ description: Aktualizované statistiky po zaplacení opravy.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyStats"
+ "402":
+ description: Uživatel nemá dostatek mincí na opravu síťky.
diff --git a/types/paths/butterflies/repellent.yml b/types/paths/butterflies/repellent.yml
new file mode 100644
index 0000000..d73fa02
--- /dev/null
+++ b/types/paths/butterflies/repellent.yml
@@ -0,0 +1,14 @@
+post:
+ operationId: buyRepellent
+ summary: >-
+ Koupí plašič ptáků za mince – vyžene všechny ptáky a po dobu platnosti brání
+ příletu nových. Vrátí 402 při nedostatku mincí.
+ responses:
+ "200":
+ description: Aktualizované statistiky po koupi plašiče.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyStats"
+ "402":
+ description: Uživatel nemá dostatek mincí na plašič.
diff --git a/types/paths/butterflies/tear.yml b/types/paths/butterflies/tear.yml
new file mode 100644
index 0000000..9c94313
--- /dev/null
+++ b/types/paths/butterflies/tear.yml
@@ -0,0 +1,12 @@
+post:
+ operationId: reportNetTorn
+ summary: >-
+ Zaznamená, že pták protrhl síťku (perzistentně, aby se stav neobešel
+ obnovením stránky).
+ responses:
+ "200":
+ description: Aktualizované statistiky s protrženou síťkou.
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/ButterflyStats"
diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml
index 0421636..464753c 100644
--- a/types/schemas/_index.yml
+++ b/types/schemas/_index.yml
@@ -926,3 +926,128 @@ PendingQr:
groupId:
description: ID skupiny objednávky, ke které QR patří
type: string
+
+# --- CHYTÁNÍ MOTÝLKŮ ---
+ButterflyStats:
+ description: Statistiky chytání motýlků pro jednoho uživatele
+ type: object
+ additionalProperties: false
+ required:
+ - caught
+ - coins
+ - goldenCaught
+ - level
+ - title
+ - wasps
+ - birds
+ - waspsKilled
+ - birdsScared
+ - repellentUntil
+ - netTorn
+ properties:
+ caught:
+ description: Celkový počet chycených motýlů
+ type: integer
+ coins:
+ description: Aktuální počet mincí k utracení
+ type: integer
+ goldenCaught:
+ description: Počet chycených vzácných zlatých motýlů
+ type: integer
+ level:
+ description: Aktuální úroveň sběratele (odvozená z počtu chycených)
+ type: integer
+ title:
+ description: Titul odpovídající aktuální úrovni
+ type: string
+ lastCatchDay:
+ description: Datum posledního úlovku (YYYY-MM-DD) pro denní bonus
+ type: string
+ wasps:
+ description: Aktuální počet poletujících vos (perzistentní, přibývají v čase)
+ type: integer
+ birds:
+ description: Aktuální počet poletujících ptáků (perzistentní, přibývají v čase)
+ type: integer
+ waspsKilled:
+ description: Celkový počet zahubených vos
+ type: integer
+ birdsScared:
+ description: Celkový počet vyplašených ptáků (plašičem)
+ type: integer
+ repellentUntil:
+ description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní
+ type: integer
+ netTorn:
+ description: Zda je síťka aktuálně protržená (nutná placená oprava)
+ type: boolean
+ButterflyCatchRequest:
+ description: Dávka nachytaných motýlů k zaznamenání
+ type: object
+ additionalProperties: false
+ required:
+ - normal
+ - golden
+ properties:
+ normal:
+ description: Počet běžných motýlů chycených v této dávce
+ type: integer
+ golden:
+ description: Počet zlatých motýlů chycených v této dávce
+ type: integer
+ButterflyCatchResult:
+ description: Výsledek zaznamenání dávky úlovků včetně přehledu odměn
+ type: object
+ additionalProperties: false
+ required:
+ - stats
+ - coinsAwarded
+ - dailyBonusApplied
+ - leveledUp
+ - premiumUnlocked
+ properties:
+ stats:
+ $ref: "#/ButterflyStats"
+ coinsAwarded:
+ description: Počet mincí připsaných za tuto dávku (základ + kombo + denní bonus)
+ type: integer
+ dailyBonusApplied:
+ description: Zda byl v této dávce připsán denní bonus za první úlovek dne
+ type: boolean
+ leveledUp:
+ description: Zda uživatel touto dávkou postoupil alespoň o jednu úroveň
+ type: boolean
+ premiumUnlocked:
+ description: Zda uživatel touto dávkou dosáhl milníku pro prémiovou síťku
+ type: boolean
+ButterflyLeaderboardEntry:
+ description: Jeden záznam v žebříčku chytačů motýlů
+ type: object
+ additionalProperties: false
+ required:
+ - login
+ - caught
+ - goldenCaught
+ - level
+ - title
+ properties:
+ login:
+ description: Přihlašovací jméno chytače
+ type: string
+ caught:
+ description: Celkový počet chycených motýlů
+ type: integer
+ goldenCaught:
+ description: Počet chycených zlatých motýlů
+ type: integer
+ level:
+ description: Aktuální úroveň sběratele
+ type: integer
+ title:
+ description: Titul odpovídající aktuální úrovni
+ type: string
+ButterflyLeaderboard:
+ description: Žebříček chytačů motýlů seřazený sestupně dle počtu chycených
+ type: array
+ items:
+ $ref: "#/ButterflyLeaderboardEntry"