feat: rozšíření chytání motýlků o progres, mince a škůdce
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s

- serverová perzistence statistik (mince, úrovně, zlatí motýli) + týmový žebříček
- prémiová zlatá síťka za milník (větší, magnetická, odolná proti ptákům)
- perzistentní škůdci: vosy na zaklikání a ptáci trhající síťku (F5-proof)
- placený plašič ptáků s odpočtem, denní bonus, kombo
- WOW zlatý motýl + osobní statistiky v modalu žebříčku
- oprava zobrazení ikonek (root-absolutní cesty k public assetům)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-22 11:59:17 +02:00
co-authored by Claude Opus 4.8
parent c8c5ecc60c
commit c5ca6c9d41
25 changed files with 2454 additions and 205 deletions
+338
View File
@@ -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<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);
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);
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<ButterflyStats> {
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<ButterflyStats> {
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<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;
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<ButterflyStats> {
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<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));
}