feat: rozšíření chytání motýlků o progres, mince a škůdce
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
- serverová perzistence statistik (mince, úrovně, zlatí motýli) + týmový žebříček - prémiová zlatá síťka za milník (větší, magnetická, odolná proti ptákům) - perzistentní škůdci: vosy na zaklikání a ptáci trhající síťku (F5-proof) - placený plašič ptáků s odpočtem, denní bonus, kombo - WOW zlatý motýl + osobní statistiky v modalu žebříčku - oprava zobrazení ikonek (root-absolutní cesty k public assetům) 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
c8c5ecc60c
commit
c5ca6c9d41
@@ -0,0 +1,10 @@
|
||||
[
|
||||
"Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
|
||||
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
||||
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
||||
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
||||
"Ptáci přibývají a trhají síťku (oprava za mince); můžeš si koupit plašič, který je na chvíli vyžene",
|
||||
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
|
||||
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
|
||||
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
import { ButterflyStats, ButterflyCatchResult, ButterflyLeaderboardEntry } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
/** Interní reprezentace statistik jednoho uživatele uložená ve storage. */
|
||||
interface StoredButterflyStats {
|
||||
/** Celkový počet chycených motýlů */
|
||||
caught: number;
|
||||
/** Aktuální počet mincí k utracení */
|
||||
coins: number;
|
||||
/** Počet chycených vzácných zlatých motýlů */
|
||||
goldenCaught: number;
|
||||
/** Datum posledního úlovku (YYYY-MM-DD) pro denní bonus */
|
||||
lastCatchDay?: string;
|
||||
|
||||
// --- Perzistentní škůdci (aby je nešlo obejít obnovením stránky) ---
|
||||
/** Aktuální počet vos */
|
||||
wasps: number;
|
||||
/** Aktuální počet ptáků */
|
||||
birds: number;
|
||||
/** Celkový počet zahubených vos */
|
||||
waspsKilled: number;
|
||||
/** Celkový počet vyplašených ptáků */
|
||||
birdsScared: number;
|
||||
/** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
|
||||
repellentUntil: number;
|
||||
/** Zda je síťka protržená */
|
||||
netTorn: boolean;
|
||||
/** Kdy naposledy „dorostla" vosa (ms epoch) */
|
||||
lastWaspAt: number;
|
||||
/** Kdy naposledy „dorostl" pták (ms epoch) */
|
||||
lastBirdAt: number;
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const STORAGE_KEY = 'butterflyStats';
|
||||
|
||||
// --- Herní konstanty ---------------------------------------------------------
|
||||
|
||||
/** Mince za jednoho běžného motýla */
|
||||
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;
|
||||
/** Bonus mincí za první úlovek dne */
|
||||
export const DAILY_BONUS = 10;
|
||||
/** Po každých kolika chycených se odemkne prémiová síťka */
|
||||
export const PREMIUM_MILESTONE = 50;
|
||||
|
||||
/** Maximální počet současně poletujících vos */
|
||||
export const WASP_MAX = 5;
|
||||
/** Maximální počet současně poletujících ptáků */
|
||||
export const BIRD_MAX = 4;
|
||||
/** 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;
|
||||
/** Cena plašiče ptáků v mincích */
|
||||
export const REPELLENT_COST = 25;
|
||||
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
|
||||
export const REPELLENT_DURATION_MS = 180_000;
|
||||
|
||||
/** Od jaké velikosti dávky se začíná počítat kombo bonus */
|
||||
const COMBO_THRESHOLD = 3;
|
||||
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
||||
const COMBO_CAP = 20;
|
||||
/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
|
||||
const MAX_BATCH = 100;
|
||||
|
||||
/**
|
||||
* Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených,
|
||||
* od které úroveň platí. Pole je vzestupně dle `min`.
|
||||
*/
|
||||
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' },
|
||||
];
|
||||
|
||||
// --- Čisté pomocné funkce ----------------------------------------------------
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** Bonus mincí za kombo (dávku chycenou najednou). */
|
||||
export function comboBonus(batchTotal: number): number {
|
||||
if (batchTotal < COMBO_THRESHOLD) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(batchTotal - (COMBO_THRESHOLD - 1), COMBO_CAP);
|
||||
}
|
||||
|
||||
/** Vytvoří výchozí (prázdné) statistiky. */
|
||||
function defaultStats(now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: 0, coins: 0, goldenCaught: 0,
|
||||
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
||||
repellentUntil: 0, netTorn: false,
|
||||
lastWaspAt: now, lastBirdAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Doplní chybějící pole u starších uložených záznamů (migrace za běhu). */
|
||||
function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: s.caught ?? 0,
|
||||
coins: s.coins ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
lastCatchDay: s.lastCatchDay,
|
||||
wasps: s.wasps ?? 0,
|
||||
birds: s.birds ?? 0,
|
||||
waspsKilled: s.waspsKilled ?? 0,
|
||||
birdsScared: s.birdsScared ?? 0,
|
||||
repellentUntil: s.repellentUntil ?? 0,
|
||||
netTorn: s.netTorn ?? false,
|
||||
lastWaspAt: s.lastWaspAt ?? now,
|
||||
lastBirdAt: s.lastBirdAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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č.
|
||||
*/
|
||||
export function growPests(s: StoredButterflyStats, now: number): void {
|
||||
// Vosy
|
||||
if (s.wasps < WASP_MAX) {
|
||||
const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS);
|
||||
if (add > 0) {
|
||||
s.wasps = Math.min(s.wasps + add, WASP_MAX);
|
||||
s.lastWaspAt = s.wasps >= WASP_MAX ? now : s.lastWaspAt + add * WASP_GROWTH_MS;
|
||||
}
|
||||
} else {
|
||||
s.lastWaspAt = now;
|
||||
}
|
||||
|
||||
// Ptáci – jen když neběží plašič
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
s.lastBirdAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
||||
function toDto(s: StoredButterflyStats): ButterflyStats {
|
||||
const level = levelForCaught(s.caught);
|
||||
return {
|
||||
caught: s.caught,
|
||||
coins: s.coins,
|
||||
goldenCaught: s.goldenCaught,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
lastCatchDay: s.lastCatchDay,
|
||||
wasps: s.wasps,
|
||||
birds: s.birds,
|
||||
waspsKilled: s.waspsKilled,
|
||||
birdsScared: s.birdsScared,
|
||||
repellentUntil: s.repellentUntil,
|
||||
netTorn: s.netTorn,
|
||||
};
|
||||
}
|
||||
|
||||
/** Načte mapu všech uživatelských statistik ze storage. */
|
||||
async function loadAll(): Promise<Record<string, StoredButterflyStats>> {
|
||||
return (await storage.getData<Record<string, StoredButterflyStats>>(STORAGE_KEY)) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomicky zmutuje statistiky jednoho uživatele. Před zavoláním `mutator`
|
||||
* záznam znormalizuje a nechá dorůst škůdce.
|
||||
*/
|
||||
async function mutateUser(
|
||||
login: string,
|
||||
now: number,
|
||||
mutator: (mine: StoredButterflyStats) => void,
|
||||
): Promise<StoredButterflyStats> {
|
||||
const updated = await storage.updateData<Record<string, StoredButterflyStats>>(STORAGE_KEY, (current) => {
|
||||
const all = current ?? {};
|
||||
const mine = normalize(all[login] ?? defaultStats(now), now);
|
||||
growPests(mine, now);
|
||||
mutator(mine);
|
||||
all[login] = mine;
|
||||
return all;
|
||||
});
|
||||
return updated[login];
|
||||
}
|
||||
|
||||
// --- Chyby -------------------------------------------------------------------
|
||||
|
||||
/** Chyba vyhozená, když uživatel nemá dost mincí. */
|
||||
export class InsufficientCoinsError extends Error { }
|
||||
|
||||
// --- Veřejné API -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vrátí statistiky chytání motýlků daného uživatele (a nechá dorůst škůdce).
|
||||
*/
|
||||
export async function getStats(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, () => { /* jen dorůst škůdce */ });
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznamená dávku nachytaných motýlů, atomicky připíše mince (základ, kombo,
|
||||
* denní bonus) a aktualizuje statistiky. Vrátí i přehled odměn.
|
||||
*/
|
||||
export async function recordCatches(login: string, normal: number, golden: number, now: number = Date.now()): Promise<ButterflyCatchResult> {
|
||||
const n = Math.min(Math.max(Math.floor(normal) || 0, 0), MAX_BATCH);
|
||||
const g = Math.min(Math.max(Math.floor(golden) || 0, 0), MAX_BATCH);
|
||||
const batchTotal = n + g;
|
||||
const today = formatDate(new Date(now));
|
||||
|
||||
let coinsAwarded = 0;
|
||||
let dailyBonusApplied = false;
|
||||
let leveledUp = false;
|
||||
let premiumUnlocked = false;
|
||||
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
const oldCaught = s.caught;
|
||||
const oldLevel = levelForCaught(oldCaught);
|
||||
|
||||
dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today;
|
||||
const daily = dailyBonusApplied ? DAILY_BONUS : 0;
|
||||
|
||||
coinsAwarded = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily;
|
||||
|
||||
const newCaught = oldCaught + batchTotal;
|
||||
s.caught = newCaught;
|
||||
s.coins += coinsAwarded;
|
||||
s.goldenCaught += g;
|
||||
if (batchTotal > 0) s.lastCatchDay = today;
|
||||
|
||||
leveledUp = levelForCaught(newCaught) > oldLevel;
|
||||
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
|
||||
});
|
||||
|
||||
return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.coins < REPAIR_COST) { failed = true; return; }
|
||||
s.coins -= REPAIR_COST;
|
||||
s.netTorn = false;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na opravu síťky');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos. */
|
||||
export async function killWasp(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.wasps > 0) {
|
||||
s.wasps -= 1;
|
||||
s.waspsKilled += 1;
|
||||
}
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Koupí plašič ptáků: vyžene všechny ptáky a po dobu platnosti brání příletu
|
||||
* nových. Vyhodí {@link InsufficientCoinsError} při nedostatku mincí.
|
||||
*/
|
||||
export async function buyRepellent(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < REPELLENT_COST) { failed = true; return; }
|
||||
s.coins -= REPELLENT_COST;
|
||||
s.birdsScared += s.birds;
|
||||
s.birds = 0;
|
||||
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
||||
s.lastBirdAt = now;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Zaznamená, že pták protrhl síťku (perzistentně). */
|
||||
export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => { s.netTorn = true; });
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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ů).
|
||||
*/
|
||||
export async function getLeaderboard(limit = 10): Promise<ButterflyLeaderboardEntry[]> {
|
||||
const all = await loadAll();
|
||||
return Object.entries(all)
|
||||
.map(([login, s]) => {
|
||||
const level = levelForCaught(s.caught ?? 0);
|
||||
return {
|
||||
login,
|
||||
caught: s.caught ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.caught - a.caught || b.goldenCaught - a.goldenCaught)
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import devRoutes from "./routes/devRoutes";
|
||||
import changelogRoutes from "./routes/changelogRoutes";
|
||||
import groupRoutes from "./routes/groupRoutes";
|
||||
import storeRoutes from "./routes/storeRoutes";
|
||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -273,6 +274,7 @@ app.use("/api/dev", devRoutes);
|
||||
app.use("/api/changelogs", changelogRoutes);
|
||||
app.use("/api/groups", groupRoutes);
|
||||
app.use("/api/stores", storeRoutes);
|
||||
app.use("/api/butterflies", butterflyRoutes);
|
||||
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
app.get('*splat', (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import {
|
||||
getStats,
|
||||
recordCatches,
|
||||
repairNet,
|
||||
killWasp,
|
||||
buyRepellent,
|
||||
reportNetTorn,
|
||||
getLeaderboard,
|
||||
InsufficientCoinsError,
|
||||
} from "../butterflies";
|
||||
import { CatchButterfliesData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await getStats(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/catch", async (req: Request<{}, any, CatchButterfliesData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (typeof req.body?.normal !== 'number' || typeof req.body?.golden !== 'number') {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await recordCatches(login, req.body.normal, req.body.golden);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/repair", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await repairNet(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) {
|
||||
if (e instanceof InsufficientCoinsError) {
|
||||
return res.status(402).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/killWasp", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await killWasp(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/repellent", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await buyRepellent(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) {
|
||||
if (e instanceof InsufficientCoinsError) {
|
||||
return res.status(402).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tear", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await reportNetTorn(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.get("/leaderboard", async (req: Request, res, next) => {
|
||||
try {
|
||||
getLogin(parseToken(req));
|
||||
const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : undefined;
|
||||
const data = await getLeaderboard(Number.isFinite(limit) ? limit : undefined);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import getStorage from '../storage';
|
||||
import {
|
||||
getStats,
|
||||
recordCatches,
|
||||
repairNet,
|
||||
killWasp,
|
||||
buyRepellent,
|
||||
reportNetTorn,
|
||||
getLeaderboard,
|
||||
growPests,
|
||||
levelForCaught,
|
||||
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,
|
||||
} from '../butterflies';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
const USER = 'tomas';
|
||||
const OTHER = 'petr';
|
||||
|
||||
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>('butterflyStats')) ?? {};
|
||||
all[login] = data;
|
||||
await storage.setData('butterflyStats', all);
|
||||
}
|
||||
|
||||
describe('levelForCaught / titleForLevel', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comboBonus', () => {
|
||||
test('malá dávka nedává kombo', () => {
|
||||
expect(comboBonus(1)).toBe(0);
|
||||
expect(comboBonus(2)).toBe(0);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCatches – mince', () => {
|
||||
test('běžný motýl dá základní minci', 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);
|
||||
expect(result.stats.coins).toBe(COIN_BASE + DAILY_BONUS);
|
||||
});
|
||||
|
||||
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);
|
||||
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, 4, 0);
|
||||
// 4 * COIN_BASE + comboBonus(4)=2
|
||||
expect(result.coinsAwarded).toBe(4 * COIN_BASE + 2);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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: 9, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||
const result = await recordCatches(USER, 1, 0); // 9 -> 10 => úroveň 2
|
||||
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('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 };
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user