feat: anti-bot inspekce a vězení + přepínač hraní/objednávání
CI / Generate TypeScript types (push) Successful in 15s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 1m0s
CI / Playwright E2E tests (push) Successful in 1m30s
CI / Build and push Docker image (push) Successful in 1m6s
CI / Notify (push) Successful in 5s

- přepínač Hraní/Objednávání; v objednávání hra nic neruší, v hraní herní vrstva odstíní kliknutí
- scéna žije trvale (přepnutí režimu neobejde pavučinu, omráčení, zabavenou síťku ani ban)
- anti-bot: ovládání jen reálnou myší (isTrusted/webdriver), občasná motýlí inspekce (chyť korunovaného)
- neúspěch inspekce = zabavená síťka 45 s (opticky skrytá); opakovaně = serverový ban s mříží a odpočtem
- během banu server nekredituje úlovky (F5/přepnutí-proof)
- větší síťka se teď viditelně zvětší hned po koupi
- changelog route přeskočí vadný JSON (nespadne celý endpoint) + oprava changelogu

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-27 12:56:20 +02:00
co-authored by Claude Opus 4.8
parent 252d105408
commit 377a350211
11 changed files with 502 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
[
"Přepínač Hraní / Objednávání: ve výchozím režimu motýli jen poletují a nic neruší objednávání; hru zapneš tlačítkem vpravo dole a herní vrstva pak odstíní kliknutí, aby ses při hraní neproklikl do objednávky",
"Vylepšení „větší síťka“ teď síťku i viditelně zvětší (a projeví se hned po koupi)",
"Občas proběhne rychlá motýlí inspekce chyť označeného zářícího motýla; férových hráčů se to skoro nedotkne, na roboty ale platí (a recidivisty čeká i chvilka ve vězení)"
]
+46
View File
@@ -41,6 +41,14 @@ interface StoredButterflyStats {
/** Počet započtených úlovků v aktuálním okně */
catchWindowCount: number;
// --- Anti-bot ban (opakované selhání inspekce) ---
/** Počet selhání inspekce v aktuálním okně */
inspectionFails: number;
/** Začátek okna pro počítání selhání (ms epoch) */
inspectionWindowStart: number;
/** Do kdy platí ban za podvádění (ms epoch); 0 = bez banu */
banUntil: number;
/** Kolikrát hráč omylem chytil černou můru */
mothsHit: number;
/** Trvalá vylepšení z obchodu */
@@ -156,6 +164,13 @@ export const RATE_CAP_PER_MIN = 90;
/** Délka okna pro rate-cap (ms) */
const RATE_WINDOW_MS = 60_000;
/** Kolik selhání inspekce v okně vede k banu */
export const BAN_FAIL_THRESHOLD = 3;
/** Okno, ve kterém se selhání inspekce počítají (ms) */
export const BAN_WINDOW_MS = 10 * 60_000;
/** Jak dlouho trvá ban za podvádění (ms) */
export const BAN_DURATION_MS = 90 * 60_000;
/**
* Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
* (prestiž), takže postup nikdy „nedojde".
@@ -241,6 +256,7 @@ function defaultStats(now: number): StoredButterflyStats {
repellentUntil: 0, netTornUntil: 0,
lastWaspAt: now, lastBirdAt: now,
catchWindowStart: now, catchWindowCount: 0,
inspectionFails: 0, inspectionWindowStart: now, banUntil: 0,
mothsHit: 0,
upgrades: { net: 0, scarecrow: 0, reinforced: 0 },
insuranceUntil: 0,
@@ -267,6 +283,9 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
lastBirdAt: s.lastBirdAt ?? now,
catchWindowStart: s.catchWindowStart ?? now,
catchWindowCount: s.catchWindowCount ?? 0,
inspectionFails: s.inspectionFails ?? 0,
inspectionWindowStart: s.inspectionWindowStart ?? now,
banUntil: s.banUntil ?? 0,
mothsHit: s.mothsHit ?? 0,
upgrades: {
net: s.upgrades?.net ?? 0,
@@ -315,6 +334,8 @@ function bumpDaily(s: StoredButterflyStats, kind: string, amount: number): void
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;
// Vypršelý ban vyčistíme
if (s.banUntil && s.banUntil <= now) s.banUntil = 0;
// Vosy
if (s.wasps < WASP_MAX) {
@@ -365,6 +386,7 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
repellentUntil: s.repellentUntil,
netTornUntil: s.netTornUntil,
insuranceUntil: s.insuranceUntil,
banUntil: s.banUntil,
upgrades: { ...s.upgrades },
daily: {
taskId: s.daily.taskId,
@@ -434,6 +456,9 @@ export async function recordCatches(login: string, normal: number, golden: numbe
let premiumUnlocked = false;
const mine = await mutateUser(login, now, (s) => {
// Ban za podvádění: během banu se nic nezapočítává
if (s.banUntil > now) return;
const oldCaught = s.caught;
const oldLevel = levelForCaught(oldCaught);
@@ -529,6 +554,27 @@ export async function reportNetTorn(login: string, now: number = Date.now()): Pr
return toDto(mine);
}
/**
* Zaznamená selhání anti-bot inspekce. Po BAN_FAIL_THRESHOLD selháních v okně
* BAN_WINDOW_MS udělí ban na BAN_DURATION_MS (perzistentní nejde obejít
* obnovením stránky ani přepnutím režimu).
*/
export async function reportInspectionFail(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => {
if (s.banUntil > now) return; // už zabanovaný
if (now - s.inspectionWindowStart > BAN_WINDOW_MS) {
s.inspectionWindowStart = now;
s.inspectionFails = 0;
}
s.inspectionFails += 1;
if (s.inspectionFails >= BAN_FAIL_THRESHOLD) {
s.banUntil = now + BAN_DURATION_MS;
s.inspectionFails = 0;
}
});
return toDto(mine);
}
/** 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) => {
+9
View File
@@ -13,6 +13,7 @@ import {
caterpillarAte,
defeatCaterpillar,
mothHit,
reportInspectionFail,
buyPremium,
waspSpray,
buyInsurance,
@@ -128,6 +129,14 @@ router.post("/moth", async (req: Request, res, next) => {
} catch (e: any) { next(e) }
});
router.post("/inspectionFailed", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await reportInspectionFail(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/buyPremium", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
+13 -2
View File
@@ -20,8 +20,19 @@ function loadAllChangelogs(): Record<string, string[]> {
for (const file of files) {
const date = file.replace(".json", "");
if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
cache[date] = JSON.parse(content);
try {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
const parsed = JSON.parse(content);
// Očekáváme pole řetězců; jiný obsah přeskočíme
if (Array.isArray(parsed)) {
cache[date] = parsed;
} else {
console.warn(`Changelog ${file} není pole přeskočeno.`);
}
} catch (e) {
// Vadný JSON nesmí shodit celý changelog endpoint přeskočíme ho
console.warn(`Changelog ${file} má neplatný JSON přeskočeno:`, (e as Error).message);
}
}
}
+35
View File
@@ -12,6 +12,7 @@ import {
caterpillarAte,
defeatCaterpillar,
mothHit,
reportInspectionFail,
buyPremium,
waspSpray,
buyInsurance,
@@ -44,6 +45,9 @@ import {
CATERPILLAR_EATS,
CATERPILLAR_REWARD,
AUTO_REPAIR_MS,
BAN_FAIL_THRESHOLD,
BAN_DURATION_MS,
BAN_WINDOW_MS,
MOTH_PENALTY,
PREMIUM_BUY_COST,
WASP_SPRAY_COST,
@@ -470,6 +474,37 @@ describe('achievementy', () => {
});
});
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 });