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
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:
co-authored by
Claude Opus 4.8
parent
c5ca6c9d41
commit
e3f439bde0
+186
-56
@@ -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 (0–1) */
|
||||
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ě (0–100 %). 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
killWasp,
|
||||
buyRepellent,
|
||||
reportNetTorn,
|
||||
robbery,
|
||||
defeatThief,
|
||||
caterpillarAte,
|
||||
defeatCaterpillar,
|
||||
getLeaderboard,
|
||||
InsufficientCoinsError,
|
||||
} from "../butterflies";
|
||||
@@ -76,6 +80,38 @@ router.post("/tear", async (req: Request, res, next) => {
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/robbery", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await robbery(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/defeatThief", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await defeatThief(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/caterpillarAte", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await caterpillarAte(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/defeatCaterpillar", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await defeatCaterpillar(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.get("/leaderboard", async (req: Request, res, next) => {
|
||||
try {
|
||||
getLogin(parseToken(req));
|
||||
|
||||
@@ -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 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(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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user