feat: obtížnější chytání motýlků + zloděj, housenka a agresivní škůdci
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s

- 60 úrovní se strmou křivkou, dražší vylepšení, vzácnější zlatý motýl
- ochrana proti podvádění (rate cap + cooldown chytání)
- perzistentní škůdci přibývají rychleji; vosy nalétávají do síťky, ptáci ji protrhnou vždy
- protržená síťka se sama zašije za minutu (nebo okamžitě za mince)
- zloděj krade mince a housenka žere úlovky – oba se plíží k počítadlu a jdou zaklikat
- plašič ptáků s odpočtem, level progress bar, nová sezóna (reset statistik)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-22 14:10:29 +02:00
co-authored by Claude Opus 4.8
parent c5ca6c9d41
commit e3f439bde0
14 changed files with 918 additions and 347 deletions
+186 -56
View File
@@ -22,18 +22,29 @@ interface StoredButterflyStats {
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;
/** Zda je síťka protržená */
netTorn: boolean;
/** 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;
}
const storage = getStorage();
const STORAGE_KEY = 'butterflyStats';
// Sezóna 2 nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry)
const STORAGE_KEY = 'butterflyStats_s2';
// --- Herní konstanty ---------------------------------------------------------
@@ -42,64 +53,117 @@ 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;
export const REPAIR_COST = 20;
/** Bonus mincí za první úlovek dne */
export const DAILY_BONUS = 10;
export const DAILY_BONUS = 5;
/** Po každých kolika chycených se odemkne prémiová síťka */
export const PREMIUM_MILESTONE = 50;
export const PREMIUM_MILESTONE = 75;
/** Maximální počet současně poletujících vos */
export const WASP_MAX = 5;
export const WASP_MAX = 6;
/** Maximální počet současně poletujících ptáků */
export const BIRD_MAX = 4;
export const BIRD_MAX = 5;
/** 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;
export const BIRD_GROWTH_MS = 60_000;
/** Cena plašiče ptáků v mincích */
export const REPELLENT_COST = 25;
export const REPELLENT_COST = 60;
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
export const REPELLENT_DURATION_MS = 180_000;
export const REPELLENT_DURATION_MS = 120_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;
/** Od jaké velikosti dávky se začíná počítat kombo bonus */
const COMBO_THRESHOLD = 3;
const COMBO_THRESHOLD = 4;
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
const COMBO_CAP = 20;
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;
/**
* Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených,
* od které úroveň platí. Pole je vzestupně dle `min`.
* Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
* (prestiž), takže postup nikdy „nedojde".
*/
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' },
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;
for (let i = 0; i < LEVELS.length; i++) {
if (caught >= LEVELS[i].min) {
level = i + 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 {
const idx = Math.min(Math.max(level - 1, 0), LEVELS.length - 1);
return LEVELS[idx].title;
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). */
@@ -115,8 +179,10 @@ function defaultStats(now: number): StoredButterflyStats {
return {
caught: 0, coins: 0, goldenCaught: 0,
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
repellentUntil: 0, netTorn: false,
thievesDefeated: 0, caterpillarsDefeated: 0,
repellentUntil: 0, netTornUntil: 0,
lastWaspAt: now, lastBirdAt: now,
catchWindowStart: now, catchWindowCount: 0,
};
}
@@ -131,10 +197,14 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
birds: s.birds ?? 0,
waspsKilled: s.waspsKilled ?? 0,
birdsScared: s.birdsScared ?? 0,
thievesDefeated: s.thievesDefeated ?? 0,
caterpillarsDefeated: s.caterpillarsDefeated ?? 0,
repellentUntil: s.repellentUntil ?? 0,
netTorn: s.netTorn ?? false,
netTornUntil: s.netTornUntil ?? 0,
lastWaspAt: s.lastWaspAt ?? now,
lastBirdAt: s.lastBirdAt ?? now,
catchWindowStart: s.catchWindowStart ?? now,
catchWindowCount: s.catchWindowCount ?? 0,
};
}
@@ -143,6 +213,9 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
* 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);
@@ -154,18 +227,19 @@ export function growPests(s: StoredButterflyStats, now: number): void {
s.lastWaspAt = now;
}
// Ptáci jen když neběží plašič
// 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) {
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;
if (!repellentActive) {
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;
}
} else {
s.lastBirdAt = now;
}
}
@@ -178,13 +252,16 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
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,
repellentUntil: s.repellentUntil,
netTorn: s.netTorn,
netTornUntil: s.netTornUntil,
};
}
@@ -247,16 +324,28 @@ export async function recordCatches(login: string, normal: number, golden: numbe
const oldCaught = s.caught;
const oldLevel = levelForCaught(oldCaught);
dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today;
// 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 = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily;
coinsAwarded = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily;
const newCaught = oldCaught + batchTotal;
const newCaught = oldCaught + credited;
s.caught = newCaught;
s.coins += coinsAwarded;
s.goldenCaught += g;
if (batchTotal > 0) s.lastCatchDay = today;
s.goldenCaught += creditGolden;
if (credited > 0) s.lastCatchDay = today;
leveledUp = levelForCaught(newCaught) > oldLevel;
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
@@ -266,18 +355,19 @@ export async function recordCatches(login: string, normal: number, golden: numbe
}
/**
* 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.
* 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.netTorn) return;
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.netTorn = false;
s.netTornUntil = 0;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na opravu síťky');
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky');
return toDto(mine);
}
@@ -304,15 +394,55 @@ export async function buyRepellent(login: string, now: number = Date.now()): Pro
s.birdsScared += s.birds;
s.birds = 0;
s.repellentUntil = now + REPELLENT_DURATION_MS;
s.lastBirdAt = now;
// 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ě). */
/**
* 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) => { s.netTorn = true; });
const mine = await mutateUser(login, now, (s) => {
if (s.netTornUntil <= now) s.netTornUntil = now + AUTO_REPAIR_MS;
});
return toDto(mine);
}
/** Zloděj se dostal k penězům a ukradl podíl mincí. */
export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => {
s.coins = Math.round(s.coins * (1 - ROBBERY_FRACTION));
});
return toDto(mine);
}
/** Hráč porazil zloděje malá odměna a statistika. */
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;
});
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);
}