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
+178 -59
View File
@@ -7,9 +7,15 @@ import {
killWasp,
buyRepellent,
reportNetTorn,
robbery,
defeatThief,
caterpillarAte,
defeatCaterpillar,
getLeaderboard,
growPests,
levelForCaught,
levelThreshold,
levelProgress,
titleForLevel,
comboBonus,
InsufficientCoinsError,
@@ -24,11 +30,20 @@ import {
BIRD_GROWTH_MS,
REPELLENT_COST,
REPELLENT_DURATION_MS,
RATE_CAP_PER_MIN,
MAX_LEVEL,
ROBBERY_FRACTION,
THIEF_REWARD,
CATERPILLAR_EATS,
CATERPILLAR_REWARD,
AUTO_REPAIR_MS,
} from '../butterflies';
import { formatDate } from '../utils';
const USER = 'tomas';
const OTHER = 'petr';
/** Storage klíč sezóny 2 (musí odpovídat STORAGE_KEY v butterflies.ts). */
const KEY = 'butterflyStats_s2';
beforeEach(() => {
resetMemoryStorage();
@@ -37,35 +52,58 @@ beforeEach(() => {
/** 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<any>('butterflyStats')) ?? {};
const all = (await storage.getData<any>(KEY)) ?? {};
all[login] = data;
await storage.setData('butterflyStats', all);
await storage.setData(KEY, all);
}
describe('levelForCaught / titleForLevel', () => {
describe('úrovně', () => {
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);
test('křivka je rostoucí a stále strmější', () => {
expect(levelThreshold(1)).toBe(0);
expect(levelThreshold(2)).toBeGreaterThan(0);
// každý další skok je větší než ten předchozí (těžší postup)
const d1 = levelThreshold(3) - levelThreshold(2);
const d2 = levelThreshold(4) - levelThreshold(3);
const d3 = levelThreshold(20) - levelThreshold(19);
expect(d2).toBeGreaterThan(d1);
expect(d3).toBeGreaterThan(d2);
});
test('levelForCaught respektuje prahy a strop', () => {
expect(levelForCaught(levelThreshold(2) - 1)).toBe(1);
expect(levelForCaught(levelThreshold(2))).toBe(2);
expect(levelForCaught(levelThreshold(5))).toBe(5);
expect(levelForCaught(100_000_000)).toBe(MAX_LEVEL);
});
test('titul nad rámec seznamu dostane hvězdičky (prestiž)', () => {
expect(titleForLevel(MAX_LEVEL)).toContain('★');
});
test('levelProgress je 0100 a na maximu 100', () => {
expect(levelProgress(0)).toBe(0);
const p = levelProgress(Math.round((levelThreshold(2) + levelThreshold(3)) / 2));
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(100);
expect(levelProgress(100_000_000)).toBe(100);
});
});
describe('comboBonus', () => {
test('malá dávka nedává kombo', () => {
expect(comboBonus(1)).toBe(0);
expect(comboBonus(2)).toBe(0);
expect(comboBonus(3)).toBe(0); // pod prahem 4
});
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
expect(comboBonus(4)).toBe(1);
expect(comboBonus(5)).toBe(2);
expect(comboBonus(1000)).toBe(8); // COMBO_CAP
});
});
@@ -77,13 +115,14 @@ describe('getStats', () => {
expect(stats.goldenCaught).toBe(0);
expect(stats.level).toBe(1);
expect(stats.title).toBe('Začátečník se síťkou');
expect(stats.wasps).toBe(0);
expect(stats.birds).toBe(0);
});
});
describe('recordCatches mince', () => {
test('běžný motýl dá základní minci', async () => {
test('běžný motýl dá základní minci + denní bonus', 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);
@@ -91,7 +130,6 @@ describe('recordCatches mince', () => {
});
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);
@@ -102,14 +140,34 @@ describe('recordCatches mince', () => {
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);
const result = await recordCatches(USER, 5, 0);
expect(result.coinsAwarded).toBe(5 * COIN_BASE + comboBonus(5));
});
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
test('sanity limit + rate cap ořízne absurdní dávku', async () => {
const result = await recordCatches(USER, 100000, 0);
// MAX_BATCH=100, ale rate cap povolí jen RATE_CAP_PER_MIN za minutu
expect(result.stats.caught).toBe(RATE_CAP_PER_MIN);
});
});
describe('recordCatches rate cap (ochrana proti podvádění)', () => {
test('v jednom okně se započte max RATE_CAP_PER_MIN, zbytek se zahodí', async () => {
const t0 = 1_000_000;
const a = await recordCatches(USER, 60, 0, t0);
expect(a.stats.caught).toBe(60);
const b = await recordCatches(USER, 60, 0, t0 + 1000);
expect(b.stats.caught).toBe(RATE_CAP_PER_MIN); // 60 + 30
const c = await recordCatches(USER, 20, 0, t0 + 2000);
expect(c.stats.caught).toBe(RATE_CAP_PER_MIN); // už nic nepřibude
expect(c.coinsAwarded).toBe(0);
});
test('po uplynutí okna se limit resetuje', async () => {
const t0 = 2_000_000;
await recordCatches(USER, RATE_CAP_PER_MIN, 0, t0);
const later = await recordCatches(USER, 10, 0, t0 + 61_000);
expect(later.stats.caught).toBe(RATE_CAP_PER_MIN + 10);
});
});
@@ -131,19 +189,14 @@ describe('recordCatches denní bonus', () => {
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
await seed(USER, { caught: levelThreshold(2) - 1, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
const result = await recordCatches(USER, 1, 0);
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()),
});
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);
});
@@ -155,30 +208,6 @@ describe('recordCatches progres a milníky', () => {
});
});
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 };
@@ -202,6 +231,12 @@ describe('growPests', () => {
growPests(s, now + 3 * BIRD_GROWTH_MS);
expect(s.birds).toBe(0);
});
test('ptáci přibývají do maxima', () => {
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
growPests(s, 1_000_000 * BIRD_GROWTH_MS);
expect(s.birds).toBe(BIRD_MAX);
});
});
describe('killWasp', () => {
@@ -235,15 +270,99 @@ describe('buyRepellent', () => {
await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
});
test('po vypršení plašiče ptáci zase přibývají', async () => {
const t0 = 10_000_000;
await seed(USER, { caught: 0, coins: REPELLENT_COST, goldenCaught: 0, birds: 0, lastBirdAt: t0 });
await buyRepellent(USER, t0);
// dost dlouho po vypršení plašiče
const stats = await getStats(USER, t0 + REPELLENT_DURATION_MS + 2 * BIRD_GROWTH_MS + 1);
expect(stats.birds).toBeGreaterThanOrEqual(2);
});
});
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);
test('protrhne síťku na dobu auto-opravy', async () => {
const now = 1_000_000;
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: 0 });
const stats = await reportNetTorn(USER, now);
expect(stats.netTornUntil).toBe(now + AUTO_REPAIR_MS);
});
test('opakované protržení neprodlouží běžící odpočet', async () => {
const now = 1_000_000;
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 40_000 });
const stats = await reportNetTorn(USER, now);
expect(stats.netTornUntil).toBe(now + 40_000);
});
test('sama se zašije po uplynutí odpočtu', async () => {
const now = 1_000_000;
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 1000 });
const stats = await getStats(USER, now + 2000);
expect(stats.netTornUntil).toBe(0);
});
});
describe('repairNet', () => {
test('okamžitá oprava odečte cenu a zašije síťku', async () => {
const now = 1_000_000;
await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTornUntil: now + 30_000 });
const stats = await repairNet(USER, now);
expect(stats.coins).toBe(5);
expect(stats.netTornUntil).toBe(0);
});
test('bez mincí okamžitá oprava selže (ale síťka se sama zašije)', async () => {
const now = 1_000_000;
await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTornUntil: now + 30_000 });
await expect(repairNet(USER, now)).rejects.toThrow(InsufficientCoinsError);
const stats = await getStats(USER, now);
expect(stats.coins).toBe(REPAIR_COST - 1);
expect(stats.netTornUntil).toBeGreaterThan(now);
});
test('neúčtuje, když síťka není protržená', async () => {
const now = 1_000_000;
await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTornUntil: 0 });
const stats = await repairNet(USER, now);
expect(stats.coins).toBe(50);
});
});
describe('zloděj', () => {
test('okradení sebere podíl mincí', async () => {
await seed(USER, { caught: 0, coins: 100, goldenCaught: 0 });
const stats = await robbery(USER);
expect(stats.coins).toBe(Math.round(100 * (1 - ROBBERY_FRACTION)));
});
test('poražení zloděje dá odměnu a zvýší statistiku', async () => {
await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 });
const stats = await defeatThief(USER);
expect(stats.coins).toBe(10 + THIEF_REWARD);
expect(stats.thievesDefeated).toBe(1);
});
});
describe('housenka', () => {
test('sežere část úlovků (nikdy pod nulu) a může snížit úroveň', async () => {
await seed(USER, { caught: CATERPILLAR_EATS + 5, coins: 0, goldenCaught: 0 });
const stats = await caterpillarAte(USER);
expect(stats.caught).toBe(5);
});
test('nesnese caught pod nulu', async () => {
await seed(USER, { caught: 3, coins: 0, goldenCaught: 0 });
const stats = await caterpillarAte(USER);
expect(stats.caught).toBe(0);
});
test('poražení housenky dá odměnu a zvýší statistiku', async () => {
await seed(USER, { caught: 0, coins: 5, goldenCaught: 0 });
const stats = await defeatCaterpillar(USER);
expect(stats.coins).toBe(5 + CATERPILLAR_REWARD);
expect(stats.caterpillarsDefeated).toBe(1);
});
});