feat: obchod, černé můry, netopýr, pavouk a denní úkoly u chytání motýlků
CI / Generate TypeScript types (push) Successful in 15s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 1m2s
CI / Playwright E2E tests (push) Successful in 1m33s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 1s

- obchod s pomůckami a trvalými vylepšeními (větší síťka, strašák, zpevněná síťka) – mince mají trvalý smysl
- černé můry se míchají mezi motýly; chycení (i magnetem) ubere hodně úlovků
- ptáci silnější, plašič dražší a kratší, zloději chodí častěji podle bohatství
- denní úkol s odměnou + achievementy/odznaky
- netopýr v noci loví motýly, pavouk zamotá síťku pavučinou – oba na zaklikání
- nákupní hlášky rozlišují nedostatek mincí od jiné chyby; herní smyčka se pozastaví při skryté záložce

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-27 10:23:09 +02:00
co-authored by Claude Opus 4.8
parent e3f439bde0
commit 252d105408
22 changed files with 1384 additions and 43 deletions
+229 -11
View File
@@ -40,6 +40,15 @@ interface StoredButterflyStats {
catchWindowStart: number;
/** Počet započtených úlovků v aktuálním okně */
catchWindowCount: number;
/** Kolikrát hráč omylem chytil černou můru */
mothsHit: number;
/** Trvalá vylepšení z obchodu */
upgrades: { net: number; scarecrow: number; reinforced: number };
/** Do kdy platí pojistka proti zloději (ms epoch), 0 = neaktivní */
insuranceUntil: number;
/** Denní úkol */
daily: { day: string; taskId: string; progress: number; target: number; done: boolean };
}
const storage = getStorage();
@@ -62,15 +71,15 @@ export const PREMIUM_MILESTONE = 75;
/** Maximální počet současně poletujících vos */
export const WASP_MAX = 6;
/** Maximální počet současně poletujících ptáků */
export const BIRD_MAX = 5;
export const BIRD_MAX = 7;
/** 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 = 60_000;
/** Jak často (ms) přibude jeden pták (základ; zpomaluje strašák) */
export const BIRD_GROWTH_MS = 40_000;
/** Cena plašiče ptáků v mincích */
export const REPELLENT_COST = 60;
export const REPELLENT_COST = 70;
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
export const REPELLENT_DURATION_MS = 120_000;
export const REPELLENT_DURATION_MS = 75_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) */
@@ -85,6 +94,55 @@ export const CATERPILLAR_EATS = 20;
export const CATERPILLAR_REWARD = 15;
/** Kolik zásahů (kliknutí) housenka vydrží */
export const CATERPILLAR_HP = 18;
/** O kolik úlovků přijdeš, když omylem chytíš černou můru */
export const MOTH_PENALTY = 15;
// --- Obchod: spotřební ---
/** Cena aktivace prémiové síťky na přání */
export const PREMIUM_BUY_COST = 40;
/** Cena vosího spreje (vyhubí všechny vosy) */
export const WASP_SPRAY_COST = 25;
/** Cena pojistky proti zloději */
export const INSURANCE_COST = 50;
/** Jak dlouho (ms) pojistka platí */
export const INSURANCE_DURATION_MS = 300_000;
/** Podíl mincí ukradený zlodějem, když je aktivní pojistka */
export const ROBBERY_FRACTION_INSURED = 0.2;
// --- Obchod: trvalá vylepšení ---
export type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
/** Maximální úrovně jednotlivých vylepšení */
export const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
/** Základní cena vylepšení (další úroveň je 2× dražší) */
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
/** Vrátí cenu příští úrovně daného vylepšení (level = aktuální úroveň). */
export function upgradeCost(item: UpgradeId, level: number): number {
return UPGRADE_BASE[item] * Math.pow(2, level);
}
// --- Denní úkoly ---
/** Odměna v mincích za splnění denního úkolu */
export const DAILY_TASK_REWARD = 30;
const DAILY_TASKS: { id: string; target: number }[] = [
{ id: 'catch', target: 60 },
{ id: 'golden', target: 3 },
{ id: 'thief', target: 1 },
{ id: 'wasp', target: 15 },
];
const DAILY_TITLES: Record<string, string> = {
catch: 'Nachytej {n} motýlů',
golden: 'Chyť {n} zlatých motýlů',
thief: 'Poraz {n} zloděje',
wasp: 'Zabij {n} vos',
};
/** Deterministický výběr úkolu podle data (aby byl pro všechny stejný a stabilní). */
function dailyTaskForDay(day: string): { id: string; target: number } {
let h = 0;
for (let i = 0; i < day.length; i++) h = (h * 31 + day.charCodeAt(i)) | 0;
return DAILY_TASKS[Math.abs(h) % DAILY_TASKS.length];
}
/** Od jaké velikosti dávky se začíná počítat kombo bonus */
const COMBO_THRESHOLD = 4;
@@ -183,6 +241,10 @@ function defaultStats(now: number): StoredButterflyStats {
repellentUntil: 0, netTornUntil: 0,
lastWaspAt: now, lastBirdAt: now,
catchWindowStart: now, catchWindowCount: 0,
mothsHit: 0,
upgrades: { net: 0, scarecrow: 0, reinforced: 0 },
insuranceUntil: 0,
daily: { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
};
}
@@ -205,9 +267,47 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
lastBirdAt: s.lastBirdAt ?? now,
catchWindowStart: s.catchWindowStart ?? now,
catchWindowCount: s.catchWindowCount ?? 0,
mothsHit: s.mothsHit ?? 0,
upgrades: {
net: s.upgrades?.net ?? 0,
scarecrow: s.upgrades?.scarecrow ?? 0,
reinforced: s.upgrades?.reinforced ?? 0,
},
insuranceUntil: s.insuranceUntil ?? 0,
daily: s.daily ?? { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
};
}
/** Efektivní interval růstu ptáků (zpomaluje strašák). */
function effectiveBirdGrowth(s: StoredButterflyStats): number {
return Math.round(BIRD_GROWTH_MS * (1 + 0.35 * (s.upgrades?.scarecrow ?? 0)));
}
/** Efektivní doba samo-zašití síťky (zkracuje zpevněná síťka). */
function effectiveAutoRepair(s: StoredButterflyStats): number {
return Math.round(AUTO_REPAIR_MS * (1 - 0.25 * (s.upgrades?.reinforced ?? 0)));
}
/** Zajistí, že denní úkol odpovídá dnešnímu dni (jinak vygeneruje nový). */
function ensureDaily(s: StoredButterflyStats, now: number): void {
const day = formatDate(new Date(now));
if (!s.daily || s.daily.day !== day) {
const def = dailyTaskForDay(day);
s.daily = { day, taskId: def.id, progress: 0, target: def.target, done: false };
}
}
/** Přičte progres dennímu úkolu daného druhu a případně vyplatí odměnu. */
function bumpDaily(s: StoredButterflyStats, kind: string, amount: number): void {
if (s.daily && !s.daily.done && s.daily.taskId === kind && amount > 0) {
s.daily.progress += amount;
if (s.daily.progress >= s.daily.target) {
s.daily.done = true;
s.coins += DAILY_TASK_REWARD;
}
}
}
/**
* Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá
* razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič.
@@ -232,10 +332,11 @@ export function growPests(s: StoredButterflyStats, now: number): void {
const repellentActive = s.repellentUntil > now;
if (!repellentActive) {
if (s.birds < BIRD_MAX) {
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
const birdGrowth = effectiveBirdGrowth(s);
const add = Math.floor((now - s.lastBirdAt) / birdGrowth);
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;
s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * birdGrowth;
}
} else {
s.lastBirdAt = now;
@@ -260,8 +361,19 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
birdsScared: s.birdsScared,
thievesDefeated: s.thievesDefeated,
caterpillarsDefeated: s.caterpillarsDefeated,
mothsHit: s.mothsHit,
repellentUntil: s.repellentUntil,
netTornUntil: s.netTornUntil,
insuranceUntil: s.insuranceUntil,
upgrades: { ...s.upgrades },
daily: {
taskId: s.daily.taskId,
title: (DAILY_TITLES[s.daily.taskId] ?? '{n}').replace('{n}', String(s.daily.target)),
progress: s.daily.progress,
target: s.daily.target,
done: s.daily.done,
reward: DAILY_TASK_REWARD,
},
};
}
@@ -283,6 +395,7 @@ async function mutateUser(
const all = current ?? {};
const mine = normalize(all[login] ?? defaultStats(now), now);
growPests(mine, now);
ensureDaily(mine, now);
mutator(mine);
all[login] = mine;
return all;
@@ -347,6 +460,9 @@ export async function recordCatches(login: string, normal: number, golden: numbe
s.goldenCaught += creditGolden;
if (credited > 0) s.lastCatchDay = today;
bumpDaily(s, 'catch', credited);
bumpDaily(s, 'golden', creditGolden);
leveledUp = levelForCaught(newCaught) > oldLevel;
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
});
@@ -377,6 +493,7 @@ export async function killWasp(login: string, now: number = Date.now()): Promise
if (s.wasps > 0) {
s.wasps -= 1;
s.waspsKilled += 1;
bumpDaily(s, 'wasp', 1);
}
});
return toDto(mine);
@@ -407,24 +524,28 @@ export async function buyRepellent(login: string, now: number = Date.now()): Pro
*/
export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => {
if (s.netTornUntil <= now) s.netTornUntil = now + AUTO_REPAIR_MS;
if (s.netTornUntil <= now) s.netTornUntil = now + effectiveAutoRepair(s);
});
return toDto(mine);
}
/** Zloděj se dostal k penězům a ukradl podíl mincí. */
/** Zloděj se dostal k penězům a ukradl podíl mincí (méně, když je aktivní pojistka). */
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));
const insured = s.insuranceUntil > now;
const fraction = insured ? ROBBERY_FRACTION_INSURED : ROBBERY_FRACTION;
s.coins = Math.round(s.coins * (1 - fraction));
if (insured) s.insuranceUntil = 0; // pojistka se spotřebuje
});
return toDto(mine);
}
/** Hráč porazil zloděje malá odměna a statistika. */
/** Hráč porazil zloděje malá odměna, statistika a progres denního úkolu. */
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;
bumpDaily(s, 'thief', 1);
});
return toDto(mine);
}
@@ -446,6 +567,103 @@ export async function defeatCaterpillar(login: string, now: number = Date.now())
return toDto(mine);
}
/** Hráč omylem chytil černou můru přijde o část úlovků. */
export async function mothHit(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => {
s.caught = Math.max(0, s.caught - MOTH_PENALTY);
s.mothsHit += 1;
});
return toDto(mine);
}
// --- Obchod ------------------------------------------------------------------
/** Koupí okamžitou aktivaci prémiové síťky (efekt řídí klient). */
export async function buyPremium(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < PREMIUM_BUY_COST) { failed = true; return; }
s.coins -= PREMIUM_BUY_COST;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na prémiovou síťku');
return toDto(mine);
}
/** Koupí vosí sprej vyhubí všechny vosy. */
export async function waspSpray(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < WASP_SPRAY_COST) { failed = true; return; }
s.coins -= WASP_SPRAY_COST;
s.waspsKilled += s.wasps;
s.wasps = 0;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vosí sprej');
return toDto(mine);
}
/** Koupí pojistku proti zloději (dočasně sníží ztrátu při okradení). */
export async function buyInsurance(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < INSURANCE_COST) { failed = true; return; }
s.coins -= INSURANCE_COST;
s.insuranceUntil = now + INSURANCE_DURATION_MS;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na pojistku');
return toDto(mine);
}
/** Chyba pro neplatnou/vyprodanou koupi vylepšení. */
export class UpgradeError extends Error { }
/** Koupí další úroveň trvalého vylepšení (cena se s úrovní zdvojnásobuje). */
export async function buyUpgrade(login: string, item: UpgradeId, now: number = Date.now()): Promise<ButterflyStats> {
if (!(item in UPGRADE_MAX)) throw new UpgradeError('Neznámé vylepšení');
let failed = false;
let maxed = false;
const mine = await mutateUser(login, now, (s) => {
const level = s.upgrades[item] ?? 0;
if (level >= UPGRADE_MAX[item]) { maxed = true; return; }
const cost = upgradeCost(item, level);
if (s.coins < cost) { failed = true; return; }
s.coins -= cost;
s.upgrades[item] = level + 1;
});
if (maxed) throw new UpgradeError('Vylepšení je na maximální úrovni');
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vylepšení');
return toDto(mine);
}
// --- Achievementy (odvozené z aktuálních statistik) --------------------------
export interface Achievement {
id: string;
title: string;
description: string;
unlocked: boolean;
}
/** Vrátí seznam achievementů odvozený z aktuálních statistik uživatele. */
export async function getAchievements(login: string, now: number = Date.now()): Promise<Achievement[]> {
const s = await mutateUser(login, now, () => { /* jen načíst/dorůst */ });
const level = levelForCaught(s.caught);
const def: Achievement[] = [
{ id: 'first-golden', title: 'Zlatý úlovek', description: 'Chyť prvního zlatého motýla', unlocked: s.goldenCaught >= 1 },
{ id: 'golden-10', title: 'Zlatokop', description: 'Chyť 10 zlatých motýlů', unlocked: s.goldenCaught >= 10 },
{ id: 'catch-500', title: 'Sběratel', description: 'Nachytej 500 motýlů', unlocked: s.caught >= 500 },
{ id: 'catch-2000', title: 'Motýlí magnát', description: 'Nachytej 2000 motýlů', unlocked: s.caught >= 2000 },
{ id: 'level-10', title: 'Zkušený chytač', description: 'Dosáhni úrovně 10', unlocked: level >= 10 },
{ id: 'level-20', title: 'Legenda louky', description: 'Dosáhni úrovně 20', unlocked: level >= 20 },
{ id: 'thief-10', title: 'Postrach zlodějů', description: 'Poraz 10 zlodějů', unlocked: s.thievesDefeated >= 10 },
{ id: 'caterpillar-10', title: 'Zahradník', description: 'Poraz 10 housenek', unlocked: s.caterpillarsDefeated >= 10 },
{ id: 'wasp-50', title: 'Plácačka', description: 'Zabij 50 vos', unlocked: s.waspsKilled >= 50 },
{ id: 'bird-25', title: 'Strašák', description: 'Vyplaš 25 ptáků', unlocked: s.birdsScared >= 25 },
{ id: 'net-max', title: 'Obří síť', description: 'Vylepši síťku na maximum', unlocked: (s.upgrades?.net ?? 0) >= UPGRADE_MAX.net },
];
return def;
}
/**
* Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených
* (při shodě dle počtu zlatých motýlů).