import { resetMemoryStorage } from '../storage/memory'; import getStorage from '../storage'; import { getStats, recordCatches, repairNet, killWasp, buyRepellent, reportNetTorn, robbery, defeatThief, caterpillarAte, defeatCaterpillar, mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade, getAchievements, upgradeCost, getLeaderboard, growPests, levelForCaught, levelThreshold, levelProgress, 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, RATE_CAP_PER_MIN, MAX_LEVEL, ROBBERY_FRACTION, THIEF_REWARD, CATERPILLAR_EATS, CATERPILLAR_REWARD, AUTO_REPAIR_MS, BAN_FAIL_THRESHOLD, BAN_DURATION_MS, BAN_WINDOW_MS, MOTH_PENALTY, PREMIUM_BUY_COST, WASP_SPRAY_COST, INSURANCE_COST, INSURANCE_DURATION_MS, ROBBERY_FRACTION_INSURED, UPGRADE_MAX, DAILY_TASK_REWARD, } 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(); }); /** 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(KEY)) ?? {}; all[login] = data; await storage.setData(KEY, all); } 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('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 0–100 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(3)).toBe(0); // pod prahem 4 }); test('větší dávka dává rostoucí bonus se stropem', () => { expect(comboBonus(4)).toBe(1); expect(comboBonus(5)).toBe(2); expect(comboBonus(1000)).toBe(8); // 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'); expect(stats.wasps).toBe(0); expect(stats.birds).toBe(0); }); }); describe('recordCatches – mince', () => { test('běžný motýl dá základní minci + denní bonus', async () => { const result = await recordCatches(USER, 1, 0); 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 () => { 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, 5, 0); expect(result.coinsAwarded).toBe(5 * COIN_BASE + comboBonus(5)); }); 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); }); }); 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: 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()) }); 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('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); }); 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', () => { 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); }); 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('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 () => { const now = 4_000_000; const day = formatDate(new Date(now)); // Denní úkol pevně nastavíme na jiný druh – jinak by se v některé dny // (kdy je úkolem "poraž 1 zloděje") k odměně přičetla i jeho výplata. await seed(USER, { caught: 0, coins: 10, goldenCaught: 0, daily: { day, taskId: 'wasp', progress: 0, target: 15, done: false }, }); const stats = await defeatThief(USER, now); 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); }); }); describe('černé můry', () => { test('chycení můry ubere úlovky (nikdy pod nulu) a zvýší statistiku', async () => { await seed(USER, { caught: MOTH_PENALTY + 4, coins: 0, goldenCaught: 0 }); const stats = await mothHit(USER); expect(stats.caught).toBe(4); expect(stats.mothsHit).toBe(1); }); test('nesnese caught pod nulu', async () => { await seed(USER, { caught: 2, coins: 0, goldenCaught: 0 }); const stats = await mothHit(USER); expect(stats.caught).toBe(0); }); }); describe('obchod – spotřební', () => { test('buyPremium odečte cenu', async () => { await seed(USER, { caught: 0, coins: PREMIUM_BUY_COST + 5, goldenCaught: 0 }); const stats = await buyPremium(USER); expect(stats.coins).toBe(5); }); test('buyPremium bez mincí selže', async () => { await seed(USER, { caught: 0, coins: PREMIUM_BUY_COST - 1, goldenCaught: 0 }); await expect(buyPremium(USER)).rejects.toThrow(InsufficientCoinsError); }); test('waspSpray vyhubí všechny vosy a odečte cenu', async () => { await seed(USER, { caught: 0, coins: WASP_SPRAY_COST, goldenCaught: 0, wasps: 4, lastWaspAt: Date.now() }); const stats = await waspSpray(USER); expect(stats.coins).toBe(0); expect(stats.wasps).toBe(0); }); test('pojistka sníží ztrátu při okradení a spotřebuje se', async () => { const now = 3_000_000; await seed(USER, { caught: 0, coins: INSURANCE_COST + 100, goldenCaught: 0 }); await buyInsurance(USER, now); const robbed = await robbery(USER, now + 1000); // po koupi zbývá 100 mincí; pojistka → ztráta jen ROBBERY_FRACTION_INSURED expect(robbed.coins).toBe(Math.round(100 * (1 - ROBBERY_FRACTION_INSURED))); expect(robbed.insuranceUntil).toBe(0); // spotřebováno }); }); describe('obchod – trvalá vylepšení', () => { test('cena roste s úrovní a vylepšení se aplikuje', async () => { await seed(USER, { caught: 0, coins: upgradeCost('net', 0) + upgradeCost('net', 1) + 5, goldenCaught: 0 }); const a = await buyUpgrade(USER, 'net'); expect(a.upgrades.net).toBe(1); const b = await buyUpgrade(USER, 'net'); expect(b.upgrades.net).toBe(2); expect(b.coins).toBe(5); }); test('nelze koupit nad maximum', async () => { await seed(USER, { caught: 0, coins: 1_000_000, goldenCaught: 0, upgrades: { net: UPGRADE_MAX.net, scarecrow: 0, reinforced: 0 } }); await expect(buyUpgrade(USER, 'net')).rejects.toThrow(); }); test('bez mincí koupě selže', async () => { await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 }); await expect(buyUpgrade(USER, 'net')).rejects.toThrow(InsufficientCoinsError); }); }); describe('denní úkol', () => { test('progres se plní a po splnění se vyplatí odměna jednou', async () => { const now = 4_000_000; const day = formatDate(new Date(now)); await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 5, lastWaspAt: now, daily: { day, taskId: 'wasp', progress: 0, target: 2, done: false }, }); await killWasp(USER, now); let stats = await killWasp(USER, now); expect(stats.daily.done).toBe(true); expect(stats.coins).toBe(DAILY_TASK_REWARD); // další zabití už odměnu nepřidá stats = await killWasp(USER, now); expect(stats.coins).toBe(DAILY_TASK_REWARD); }); }); describe('achievementy', () => { test('odemykají se podle statistik', async () => { await seed(USER, { caught: 600, coins: 0, goldenCaught: 1 }); const list = await getAchievements(USER); const byId = Object.fromEntries(list.map(a => [a.id, a.unlocked])); expect(byId['first-golden']).toBe(true); expect(byId['catch-500']).toBe(true); expect(byId['catch-2000']).toBe(false); }); }); describe('anti-bot ban', () => { test('po prahu selhání inspekce udělí ban', async () => { const now = 6_000_000; await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, inspectionWindowStart: now }); let stats; for (let i = 0; i < BAN_FAIL_THRESHOLD - 1; i++) { stats = await reportInspectionFail(USER, now + i * 1000); expect(stats.banUntil).toBe(0); } stats = await reportInspectionFail(USER, now + BAN_FAIL_THRESHOLD * 1000); expect(stats.banUntil).toBe(now + BAN_FAIL_THRESHOLD * 1000 + BAN_DURATION_MS); }); test('selhání mimo okno se nesčítají do banu', async () => { const now = 7_000_000; await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, inspectionWindowStart: now }); await reportInspectionFail(USER, now); // daleko za oknem → počítadlo se resetuje const stats = await reportInspectionFail(USER, now + BAN_WINDOW_MS + 1000); expect(stats.banUntil).toBe(0); }); test('během banu se úlovky nezapočítávají', async () => { const now = 8_000_000; await seed(USER, { caught: 10, coins: 5, goldenCaught: 0, banUntil: now + BAN_DURATION_MS, lastCatchDay: formatDate(new Date(now)) }); const result = await recordCatches(USER, 20, 0, now); expect(result.stats.caught).toBe(10); // beze změny expect(result.coinsAwarded).toBe(0); }); }); 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); }); });