Files
Luncher/server/src/tests/butterflies.test.ts
T
mates 92ea78a870
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Build and push Docker image (push) Successful in 3m2s
CI / Notify (push) Successful in 2s
test: oprava testu poražení zloděje
2026-09-09 09:03:40 +02:00

529 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<any>(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 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(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);
});
});