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
+121
View File
@@ -7,6 +7,49 @@
pointer-events: none; pointer-events: none;
z-index: 2; z-index: 2;
overflow: hidden; overflow: hidden;
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
&.playing {
pointer-events: auto;
}
}
// Přepínač režimu hraní / objednávání
.butterfly-mode-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 7;
pointer-events: auto;
padding: 8px 14px;
border: none;
border-radius: 999px;
background: rgba(33, 37, 41, 0.88);
color: #fff;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: rgba(33, 37, 41, 1); }
&:active { transform: scale(0.96); }
&.playing {
background: #2f9e44;
&:hover { background: #2b8a3e; }
}
}
// Vrstva herních prvků (síťka, škůdci, pavučina) skrývá se v režimu objednávání
.butterfly-game-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
} }
.butterfly-scene { .butterfly-scene {
@@ -73,6 +116,20 @@
} }
} }
// Korunovaný motýl (anti-bot inspekce) nepřehlédnutelná duhová záře
.butterfly-sprite.royal {
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
}
@keyframes butterfly-royal-glow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
}
50% {
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
}
}
// Černá můra zlověstný tmavý nádech // Černá můra zlověstný tmavý nádech
.butterfly-sprite.moth { .butterfly-sprite.moth {
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
@@ -626,3 +683,67 @@
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7)); filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
} }
} }
// --- Anti-bot inspekce a vězení ---------------------------------------------
.butterfly-inspection {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 8;
pointer-events: none;
max-width: 92vw;
padding: 10px 16px;
border-radius: 999px;
background: rgba(28, 126, 214, 0.95);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
animation: butterfly-flash-in 0.25s ease-out;
}
.butterfly-jail {
position: fixed;
inset: 0;
z-index: 6;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
// Svislé mříže přes celou obrazovku
.butterfly-jail-bars {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
90deg,
rgba(20, 22, 28, 0.92) 0px,
rgba(20, 22, 28, 0.92) 14px,
rgba(20, 22, 28, 0) 14px,
rgba(20, 22, 28, 0) 70px
);
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
}
.butterfly-jail-note {
position: relative;
text-align: center;
padding: 22px 26px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
max-width: 90vw;
.jail-emoji { font-size: 2.4rem; }
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
}
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite.royal { animation: none; }
}
+245 -19
View File
@@ -28,6 +28,14 @@ const GOLD_VALUE = 25;
const MOTH_PENALTY = 15; const MOTH_PENALTY = 15;
/** Přírůstek dosahu chytání za úroveň vylepšení „větší síťka" */ /** Přírůstek dosahu chytání za úroveň vylepšení „větší síťka" */
const NET_UPGRADE_RADIUS = 9; const NET_UPGRADE_RADIUS = 9;
/** Přírůstek vizuální velikosti síťky za úroveň vylepšení „větší síťka" (px) */
const NET_UPGRADE_SIZE = 20;
// Anti-bot „inspekce" kontrola, že hraje člověk
const INSPECT_WINDOW_FRAMES = 8 * 60; // ~8 s na splnění
const INSPECT_PAUSE_FRAMES = 45 * 60; // ~45 s zabavená síťka při selhání
const CHECK_EVERY_CLEAN = 220; // po kolika úlovcích kontrola u „čistého" hraní
const CHECK_EVERY_SUSPICIOUS = 25; // při botích signálech mnohem dřív
// „Bossové" (zloděj / housenka) // „Bossové" (zloděj / housenka)
const THIEF_HP = 22; const THIEF_HP = 22;
@@ -43,6 +51,17 @@ function formatRemaining(ms: number): string {
return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`; return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`;
} }
/** Delší čas (ban) jako „H:MM:SS" nebo „M:SS". */
function formatBan(ms: number): string {
const s = Math.ceil(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = s % 60;
const mm = String(m).padStart(2, '0');
const s2 = String(ss).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${s2}` : `${m}:${s2}`;
}
interface ButterflyData { interface ButterflyData {
el: HTMLDivElement; el: HTMLDivElement;
sprite: HTMLDivElement; sprite: HTMLDivElement;
@@ -51,6 +70,8 @@ interface ButterflyData {
golden: boolean; golden: boolean;
/** Černá můra chycení stojí úlovky (nepočítá se jako úlovek) */ /** Černá můra chycení stojí úlovky (nepočítá se jako úlovek) */
moth: boolean; moth: boolean;
/** Korunovaný motýl pro anti-bot inspekci */
royal: boolean;
bobFreq1: number; bobPhase1: number; bobAmp1: number; bobFreq1: number; bobPhase1: number; bobAmp1: number;
bobFreq2: number; bobPhase2: number; bobAmp2: number; bobFreq2: number; bobPhase2: number; bobAmp2: number;
} }
@@ -99,6 +120,8 @@ interface SceneCallbacks {
getCoins: () => number; getCoins: () => number;
getCaught: () => number; getCaught: () => number;
getNetUpgrade: () => number; getNetUpgrade: () => number;
/** Do kdy platí serverový ban (ms epoch); 0 = bez banu */
getBanUntil: () => number;
onTornChange: (torn: boolean) => void; onTornChange: (torn: boolean) => void;
onCombo: (count: number) => void; onCombo: (count: number) => void;
onSting: () => void; onSting: () => void;
@@ -111,6 +134,8 @@ interface SceneCallbacks {
onStalkerAppear: (type: 'thief' | 'caterpillar') => void; onStalkerAppear: (type: 'thief' | 'caterpillar') => void;
onBatShooed: () => void; onBatShooed: () => void;
onWebCleared: () => void; onWebCleared: () => void;
/** Stav anti-bot inspekce: začátek / úspěch / neúspěch (zabavená síťka) */
onInspection: (state: 'start' | 'pass' | 'fail') => void;
} }
interface FlyingButterfliesProps { interface FlyingButterfliesProps {
@@ -123,10 +148,13 @@ interface FlyingButterfliesProps {
class ButterflyScene { class ButterflyScene {
private viewport: HTMLElement; private viewport: HTMLElement;
private world: HTMLDivElement; private world: HTMLDivElement;
/** Vrstva se všemi herními prvky (síťka, škůdci, pavučina) jde skrýt v režimu objednávání */
private gameLayer: HTMLDivElement;
private butterflies: ButterflyData[] = []; private butterflies: ButterflyData[] = [];
private birds: CritterData[] = []; private birds: CritterData[] = [];
private wasps: CritterData[] = []; private wasps: CritterData[] = [];
private stalker: StalkerData | null = null; private stalker: StalkerData | null = null;
private interactive: boolean = false;
private numButterflies: number; private numButterflies: number;
private variants: readonly string[]; private variants: readonly string[];
private width: number; private width: number;
@@ -157,6 +185,16 @@ class ButterflyScene {
private webClicks: number = 0; private webClicks: number = 0;
private nextSpiderAt: number = 2400; private nextSpiderAt: number = 2400;
// Anti-bot inspekce
private trustedControl: boolean = false; // síťku ovládá reálná (isTrusted) myš
private syntheticSeen: boolean = false; // zaznamenán syntetický event / webdriver
private catchesSinceCheck: number = 0;
private nextCheckThreshold: number = CHECK_EVERY_CLEAN;
private inspecting: boolean = false;
private inspectUntil: number = 0;
private inspectPauseUntil: number = 0; // do kdy je síťka „zabavená"
private royal: ButterflyData | null = null;
private comboCount: number = 0; private comboCount: number = 0;
private lastCatchTs: number = 0; private lastCatchTs: number = 0;
private comboTimer: number = 0; private comboTimer: number = 0;
@@ -195,6 +233,8 @@ class ButterflyScene {
constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) { constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) {
this.viewport = el; this.viewport = el;
this.world = document.createElement('div'); this.world = document.createElement('div');
this.gameLayer = document.createElement('div');
this.gameLayer.className = 'butterfly-game-layer';
this.numButterflies = numButterflies; this.numButterflies = numButterflies;
this.variants = variants; this.variants = variants;
this.netEnabled = netEnabled; this.netEnabled = netEnabled;
@@ -202,6 +242,8 @@ class ButterflyScene {
this.width = this.viewport.offsetWidth; this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight; this.height = this.viewport.offsetHeight;
this.net = document.createElement('div'); this.net = document.createElement('div');
// Automatizované prohlížeče (Playwright/Puppeteer/Selenium) se prozradí
if (typeof navigator !== 'undefined' && (navigator as any).webdriver) this.syntheticSeen = true;
this.handleResize = () => { this.handleResize = () => {
this.width = this.viewport.offsetWidth; this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight; this.height = this.viewport.offsetHeight;
@@ -217,7 +259,10 @@ class ButterflyScene {
} }
private get premiumActive(): boolean { return Date.now() < this.premiumUntil; } private get premiumActive(): boolean { return Date.now() < this.premiumUntil; }
private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; } private get netSize(): number {
const base = this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL;
return base + NET_UPGRADE_SIZE * this.cb.getNetUpgrade();
}
private get catchRadius(): number { private get catchRadius(): number {
const base = this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; const base = this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL;
return base + NET_UPGRADE_RADIUS * this.cb.getNetUpgrade(); return base + NET_UPGRADE_RADIUS * this.cb.getNetUpgrade();
@@ -225,6 +270,21 @@ class ButterflyScene {
private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; } private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; } private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
/** Do kdy je síťka „zabavená" (po neúspěšné inspekci) nejde chytat ani ji vidět. */
private get confiscated(): boolean { return this.timer < this.inspectPauseUntil; }
/** Serverový ban (za opakované odhalení) blokuje chytání. */
private get banned(): boolean { return Date.now() < this.cb.getBanUntil(); }
/**
* Přepne interaktivní režim (hraní vs. jen dekorace). Herní vrstvu jen skryje/ukáže,
* takže aktivní penalizace (pavučina, omráčení, zabavená síťka) přepnutím neobejdeš.
*/
public setInteractive = (v: boolean): void => {
this.interactive = v;
this.gameLayer.style.display = v ? '' : 'none';
if (!v) { this.netGrabbed = false; this.net.classList.remove('grabbed'); }
};
// --- Motýli -------------------------------------------------------------- // --- Motýli --------------------------------------------------------------
private resetButterfly = (b: ButterflyData): void => { private resetButterfly = (b: ButterflyData): void => {
@@ -240,6 +300,19 @@ class ButterflyScene {
b.bobPhase2 = Math.random() * Math.PI * 2; b.bobPhase2 = Math.random() * Math.PI * 2;
b.bobAmp2 = Math.random() * 0.6 + 0.4; b.bobAmp2 = Math.random() * 0.6 + 0.4;
// Korunovaný motýl (inspekce) má přednost a zůstává jím, dokud inspekce trvá
if (b.royal) {
b.moth = false;
b.golden = false;
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
b.sprite.classList.remove('golden', 'moth');
b.sprite.classList.add('royal');
b.size = 1.25;
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
return;
}
b.sprite.classList.remove('royal');
// Nejdřív můra, jinak vzácně zlatý, jinak běžná varianta // Nejdřív můra, jinak vzácně zlatý, jinak běžná varianta
b.moth = Math.random() < MOTH_CHANCE; b.moth = Math.random() < MOTH_CHANCE;
b.golden = !b.moth && Math.random() < GOLDEN_CHANCE; b.golden = !b.moth && Math.random() < GOLDEN_CHANCE;
@@ -295,6 +368,9 @@ class ButterflyScene {
private onPointerDown = (e: PointerEvent): void => { private onPointerDown = (e: PointerEvent): void => {
e.preventDefault(); e.preventDefault();
// Síťku smí ovládat jen reálná myš (syntetické eventy mají isTrusted=false)
this.trustedControl = e.isTrusted && !(typeof navigator !== 'undefined' && (navigator as any).webdriver);
if (!e.isTrusted) this.syntheticSeen = true;
this.netGrabbed = true; this.netGrabbed = true;
this.net.classList.add('grabbed'); this.net.classList.add('grabbed');
this.netX = e.clientX; this.netY = e.clientY; this.netX = e.clientX; this.netY = e.clientY;
@@ -302,6 +378,7 @@ class ButterflyScene {
}; };
private onPointerMove = (e: PointerEvent): void => { private onPointerMove = (e: PointerEvent): void => {
if (!this.netGrabbed) return; if (!this.netGrabbed) return;
if (!e.isTrusted) { this.syntheticSeen = true; this.trustedControl = false; }
this.netX = e.clientX; this.netY = e.clientY; this.netX = e.clientX; this.netY = e.clientY;
this.updateNetTransform(); this.updateNetTransform();
}; };
@@ -330,6 +407,13 @@ class ButterflyScene {
/** Zpracuje kontakt síťky s letícím tvorem buď úlovek, nebo penalta za můru. */ /** Zpracuje kontakt síťky s letícím tvorem buď úlovek, nebo penalta za můru. */
private catchOne = (b: ButterflyData, atX: number, atY: number): void => { private catchOne = (b: ButterflyData, atX: number, atY: number): void => {
this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN; this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN;
// Během inspekce: chyť jen korunovaného, jinak neúspěch
if (this.inspecting) {
if (b.royal) this.passInspection();
else this.failInspection();
return;
}
this.catchesSinceCheck += 1;
if (b.moth) { if (b.moth) {
// Chytit černou můru je chyba penalta, žádné kombo // Chytit černou můru je chyba penalta, žádné kombo
this.cb.onMothCaught(); this.cb.onMothCaught();
@@ -362,7 +446,9 @@ class ButterflyScene {
// Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna) // Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna)
private checkCatches = (): void => { private checkCatches = (): void => {
if (this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; // Síťku musí ovládat reálná myš; při zabavení/banu/pavučině/omráčení se nechytá
if (!this.trustedControl || this.confiscated || this.banned || this.torn || this.webbed
|| this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
const r2 = this.catchRadius * this.catchRadius; const r2 = this.catchRadius * this.catchRadius;
const half = ButterflyScene.BASE_SIZE / 2; const half = ButterflyScene.BASE_SIZE / 2;
for (const b of this.butterflies) { for (const b of this.butterflies) {
@@ -377,7 +463,8 @@ class ButterflyScene {
}; };
private runMagnet = (): void => { private runMagnet = (): void => {
if (this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; if (this.inspecting || !this.trustedControl || this.confiscated || this.banned || this.torn || this.webbed
|| this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return; if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
this.lastMagnetAt = this.timer; this.lastMagnetAt = this.timer;
const half = ButterflyScene.BASE_SIZE / 2; const half = ButterflyScene.BASE_SIZE / 2;
@@ -392,6 +479,54 @@ class ButterflyScene {
if (nearest) { this.catchOne(nearest, this.netX, this.netY); this.popNet(); } if (nearest) { this.catchOne(nearest, this.netX, this.netY); this.popNet(); }
}; };
// --- Anti-bot inspekce ---------------------------------------------------
private scheduleNextCheck = (): void => {
this.catchesSinceCheck = 0;
const base = this.syntheticSeen ? CHECK_EVERY_SUSPICIOUS : CHECK_EVERY_CLEAN;
this.nextCheckThreshold = base + Math.floor(Math.random() * base * 0.4);
};
private clearRoyal = (): void => {
if (this.royal) {
this.royal.royal = false;
this.royal.sprite.classList.remove('royal');
this.resetButterfly(this.royal);
this.royal = null;
}
};
private startInspection = (): void => {
if (!this.butterflies.length) { this.scheduleNextCheck(); return; }
const b = this.butterflies[Math.floor(Math.random() * this.butterflies.length)];
b.royal = true;
b.moth = false;
b.golden = false;
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
b.sprite.classList.remove('moth', 'golden');
b.sprite.classList.add('royal');
this.royal = b;
this.inspecting = true;
this.inspectUntil = this.timer + INSPECT_WINDOW_FRAMES;
this.cb.onInspection('start');
};
private passInspection = (): void => {
this.inspecting = false;
this.clearRoyal();
this.scheduleNextCheck();
this.spawnFx('👍', 'butterfly-swat-fx', this.netX, this.netY);
this.cb.onInspection('pass');
};
private failInspection = (): void => {
this.inspecting = false;
this.clearRoyal();
this.inspectPauseUntil = this.timer + INSPECT_PAUSE_FRAMES;
this.scheduleNextCheck();
this.cb.onInspection('fail');
};
public activatePremiumNet = (): void => { public activatePremiumNet = (): void => {
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS; this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
this.applyNetAppearance(); this.applyNetAppearance();
@@ -460,7 +595,7 @@ class ButterflyScene {
c.cleanup = () => el.removeEventListener('pointerdown', swat); c.cleanup = () => el.removeEventListener('pointerdown', swat);
} }
this.world.appendChild(el); this.gameLayer.appendChild(el);
return c; return c;
}; };
@@ -647,7 +782,7 @@ class ButterflyScene {
el.addEventListener('pointerdown', hit); el.addEventListener('pointerdown', hit);
s.cleanup = () => el.removeEventListener('pointerdown', hit); s.cleanup = () => el.removeEventListener('pointerdown', hit);
this.viewport.appendChild(el); this.gameLayer.appendChild(el);
this.stalker = s; this.stalker = s;
this.cb.onStalkerAppear(type); this.cb.onStalkerAppear(type);
}; };
@@ -732,7 +867,7 @@ class ButterflyScene {
}; };
el.addEventListener('pointerdown', hit); el.addEventListener('pointerdown', hit);
bat.cleanup = () => el.removeEventListener('pointerdown', hit); bat.cleanup = () => el.removeEventListener('pointerdown', hit);
this.world.appendChild(el); this.gameLayer.appendChild(el);
this.bat = bat; this.bat = bat;
}; };
@@ -799,7 +934,7 @@ class ButterflyScene {
}; };
el.addEventListener('pointerdown', strip); el.addEventListener('pointerdown', strip);
(el as any)._cleanup = () => el.removeEventListener('pointerdown', strip); (el as any)._cleanup = () => el.removeEventListener('pointerdown', strip);
this.viewport.appendChild(el); this.gameLayer.appendChild(el);
this.web = el; this.web = el;
this.spawnFx('🕸️ Pavučina!', 'butterfly-tear-fx', this.netX, this.netY - 20); this.spawnFx('🕸️ Pavučina!', 'butterfly-tear-fx', this.netX, this.netY - 20);
}; };
@@ -831,7 +966,7 @@ class ButterflyScene {
window.addEventListener('pointermove', this.onPointerMove); window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp); window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('pointercancel', this.onPointerUp); window.addEventListener('pointercancel', this.onPointerUp);
this.viewport.appendChild(this.net); this.gameLayer.appendChild(this.net);
}; };
public init = (): void => { public init = (): void => {
@@ -844,7 +979,7 @@ class ButterflyScene {
sprite.className = 'butterfly-sprite'; sprite.className = 'butterfly-sprite';
el.appendChild(sprite); el.appendChild(sprite);
const b: ButterflyData = { const b: ButterflyData = {
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false, moth: false, el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false, moth: false, royal: false,
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0, bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
}; };
this.resetButterfly(b); this.resetButterfly(b);
@@ -852,6 +987,8 @@ class ButterflyScene {
this.world.appendChild(el); this.world.appendChild(el);
} }
this.viewport.appendChild(this.world); this.viewport.appendChild(this.world);
this.viewport.appendChild(this.gameLayer);
this.gameLayer.style.display = this.interactive ? '' : 'none';
if (this.netEnabled) this.initNet(); if (this.netEnabled) this.initNet();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
document.addEventListener('visibilitychange', this.handleVisibility); document.addEventListener('visibilitychange', this.handleVisibility);
@@ -859,13 +996,19 @@ class ButterflyScene {
private wasPremium: boolean = false; private wasPremium: boolean = false;
private wasStunned: boolean = false; private wasStunned: boolean = false;
private lastNetUpgrade: number = 0;
public render = (): void => { public render = (): void => {
for (const b of this.butterflies) this.updateButterfly(b); for (const b of this.butterflies) this.updateButterfly(b);
if (this.netEnabled) { if (this.netEnabled && this.interactive) {
const nowPremium = this.premiumActive; const nowPremium = this.premiumActive;
if (this.wasPremium && !nowPremium) this.applyNetAppearance(); const nu = this.cb.getNetUpgrade();
// Přepočítej vzhled síťky při změně prémiové síťky i po koupi vylepšení
if ((this.wasPremium && !nowPremium) || nu !== this.lastNetUpgrade) {
this.lastNetUpgrade = nu;
this.applyNetAppearance();
}
this.wasPremium = nowPremium; this.wasPremium = nowPremium;
this.updateWasps(); this.updateWasps();
@@ -883,6 +1026,19 @@ class ButterflyScene {
this.wasStunned = stunned; this.wasStunned = stunned;
} }
// Zabavená síťka (neúspěch inspekce) nebo ban → síťku opticky skryj, běží jen nápis
this.net.style.visibility = (this.confiscated || this.banned) ? 'hidden' : '';
// Anti-bot inspekce: konec časového okna = neúspěch
if (this.inspecting && this.timer >= this.inspectUntil) {
this.failInspection();
}
// Spuštění inspekce po dosažení prahu (jen při reálném hraní)
if (!this.inspecting && !this.confiscated && !this.banned && this.netGrabbed
&& this.trustedControl && this.catchesSinceCheck >= this.nextCheckThreshold) {
this.startInspection();
}
if (this.netGrabbed) this.checkCatches(); if (this.netGrabbed) this.checkCatches();
if (nowPremium) this.runMagnet(); if (nowPremium) this.runMagnet();
} }
@@ -903,7 +1059,7 @@ class ButterflyScene {
window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp); window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerUp); window.removeEventListener('pointercancel', this.onPointerUp);
if (this.net.parentNode) this.net.parentNode.removeChild(this.net); if (this.gameLayer.parentNode) this.gameLayer.parentNode.removeChild(this.gameLayer);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
document.removeEventListener('visibilitychange', this.handleVisibility); document.removeEventListener('visibilitychange', this.handleVisibility);
}; };
@@ -932,6 +1088,20 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
const [nowTs, setNowTs] = useState(() => Date.now()); const [nowTs, setNowTs] = useState(() => Date.now());
const flashTimer = useRef<number | null>(null); const flashTimer = useRef<number | null>(null);
// Režim: 'order' = jen dekorace (appka bez rušení), 'play' = plná hra
const [mode, setMode] = useState<'order' | 'play'>(() => {
try { return localStorage.getItem('butterflyMode') === 'play' ? 'play' : 'order'; }
catch { return 'order'; }
});
const interactive = enableNet && mode === 'play';
const toggleMode = useCallback(() => {
setMode(m => {
const next = m === 'play' ? 'order' : 'play';
try { localStorage.setItem('butterflyMode', next); } catch { /* ignore */ }
return next;
});
}, []);
const showFlash = useCallback((msg: string) => { const showFlash = useCallback((msg: string) => {
setFlash(msg); setFlash(msg);
if (flashTimer.current) window.clearTimeout(flashTimer.current); if (flashTimer.current) window.clearTimeout(flashTimer.current);
@@ -949,27 +1119,33 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
} }
}, [showFlash]); }, [showFlash]);
const [inspecting, setInspecting] = useState(false);
const { const {
stats, displayCaught, coinsRef, reportCatch, repair, stats, displayCaught, coinsRef, reportCatch, repair,
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
caterpillarAte, defeatCaterpillar, mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade, caterpillarAte, defeatCaterpillar, mothHit, reportInspectionFail,
buyPremium, waspSpray, buyInsurance, buyUpgrade,
} = useButterflyStats(handleReward); } = useButterflyStats(handleReward);
const netUpgradeRef = useRef(0); const netUpgradeRef = useRef(0);
netUpgradeRef.current = stats?.upgrades?.net ?? 0; netUpgradeRef.current = stats?.upgrades?.net ?? 0;
const banUntilRef = useRef(0);
banUntilRef.current = stats?.banUntil ?? 0;
const noop = () => { }; const noop = () => { };
const callbacksRef = useRef<SceneCallbacks>({ const callbacksRef = useRef<SceneCallbacks>({
onCatch: noop, getCoins: () => 0, getCaught: () => 0, getNetUpgrade: () => 0, onTornChange: noop, onCatch: noop, getCoins: () => 0, getCaught: () => 0, getNetUpgrade: () => 0, getBanUntil: () => 0, onTornChange: noop,
onCombo: noop, onSting: noop, onSwatWasp: noop, onMothCaught: noop, onCombo: noop, onSting: noop, onSwatWasp: noop, onMothCaught: noop,
onRobbery: noop, onDefeatThief: noop, onCaterpillarAte: noop, onRobbery: noop, onDefeatThief: noop, onCaterpillarAte: noop,
onDefeatCaterpillar: noop, onStalkerAppear: noop, onBatShooed: noop, onWebCleared: noop, onDefeatCaterpillar: noop, onStalkerAppear: noop, onBatShooed: noop, onWebCleared: noop, onInspection: noop,
}); });
callbacksRef.current = { callbacksRef.current = {
onCatch: (golden) => reportCatch(golden), onCatch: (golden) => reportCatch(golden),
getCoins: () => coinsRef.current, getCoins: () => coinsRef.current,
getCaught: () => stats?.caught ?? 0, getCaught: () => stats?.caught ?? 0,
getNetUpgrade: () => netUpgradeRef.current, getNetUpgrade: () => netUpgradeRef.current,
getBanUntil: () => banUntilRef.current,
onTornChange: (t) => { if (t) void reportTear(); }, onTornChange: (t) => { if (t) void reportTear(); },
onCombo: (c) => setCombo(c), onCombo: (c) => setCombo(c),
onSting: () => showFlash('🐝 Au! Vosa tě žihla síťka teď 5 s nechytá. Zaklikej vosy!'), onSting: () => showFlash('🐝 Au! Vosa tě žihla síťka teď 5 s nechytá. Zaklikej vosy!'),
@@ -984,8 +1160,17 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
: '🐛 Housenka! Plíží se k tvým úlovkům zaklikej ji!'), : '🐛 Housenka! Plíží se k tvým úlovkům zaklikej ji!'),
onBatShooed: () => showFlash('🦇 Netopýr zahnán!'), onBatShooed: () => showFlash('🦇 Netopýr zahnán!'),
onWebCleared: () => showFlash('🕸️ Pavučina stržena zase můžeš chytat.'), onWebCleared: () => showFlash('🕸️ Pavučina stržena zase můžeš chytat.'),
onInspection: (state) => {
if (state === 'start') { setInspecting(true); }
else if (state === 'pass') { setInspecting(false); showFlash('🕵️ Inspekce OK hraj dál!'); }
else { setInspecting(false); void reportInspectionFail(); showFlash('👮 Inspekce neúspěšná síťka zabavena na 45 s!'); }
},
}; };
// Aktuální interaktivita pro init (bez recreate scény při přepnutí režimu)
const interactiveRef = useRef(interactive);
interactiveRef.current = interactive;
const initialize = useCallback(() => { const initialize = useCallback(() => {
if (containerRef.current) { if (containerRef.current) {
sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, { sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, {
@@ -993,6 +1178,7 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
getCoins: () => callbacksRef.current.getCoins(), getCoins: () => callbacksRef.current.getCoins(),
getCaught: () => callbacksRef.current.getCaught(), getCaught: () => callbacksRef.current.getCaught(),
getNetUpgrade: () => callbacksRef.current.getNetUpgrade(), getNetUpgrade: () => callbacksRef.current.getNetUpgrade(),
getBanUntil: () => callbacksRef.current.getBanUntil(),
onTornChange: (t) => callbacksRef.current.onTornChange(t), onTornChange: (t) => callbacksRef.current.onTornChange(t),
onCombo: (c) => callbacksRef.current.onCombo(c), onCombo: (c) => callbacksRef.current.onCombo(c),
onSting: () => callbacksRef.current.onSting(), onSting: () => callbacksRef.current.onSting(),
@@ -1005,10 +1191,14 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t),
onBatShooed: () => callbacksRef.current.onBatShooed(), onBatShooed: () => callbacksRef.current.onBatShooed(),
onWebCleared: () => callbacksRef.current.onWebCleared(), onWebCleared: () => callbacksRef.current.onWebCleared(),
onInspection: (s) => callbacksRef.current.onInspection(s),
}); });
sceneRef.current.init(); sceneRef.current.init();
sceneRef.current.setInteractive(interactiveRef.current);
sceneRef.current.render(); sceneRef.current.render();
} }
// Scéna žije trvale; režim se přepíná přes setInteractive (viz efekt níže),
// aby přepnutí neobešlo aktivní penalizace.
}, [numButterflies, butterflyVariants, enableNet]); }, [numButterflies, butterflyVariants, enableNet]);
useEffect(() => { useEffect(() => {
@@ -1019,6 +1209,9 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
}; };
}, [initialize]); }, [initialize]);
// Přepnutí režimu hraní/objednávání jen přepne interaktivitu (bez recreate)
useEffect(() => { sceneRef.current?.setInteractive(interactive); }, [interactive]);
const wasps = stats?.wasps ?? 0; const wasps = stats?.wasps ?? 0;
const birds = stats?.birds ?? 0; const birds = stats?.birds ?? 0;
const netTornUntil = stats?.netTornUntil ?? 0; const netTornUntil = stats?.netTornUntil ?? 0;
@@ -1042,14 +1235,17 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
const repellentUntil = stats?.repellentUntil ?? 0; const repellentUntil = stats?.repellentUntil ?? 0;
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs); const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
const repellentActive = repellentRemainingMs > 0; const repellentActive = repellentRemainingMs > 0;
const banUntil = stats?.banUntil ?? 0;
const banned = banUntil > nowTs;
const banRemainingMs = Math.max(0, banUntil - nowTs);
// Tik po sekundách pro odpočty plašiče i protržené síťky (jen dokud běží) // Tik po sekundách pro odpočty plašiče, protržené síťky a banu (jen dokud běží)
useEffect(() => { useEffect(() => {
const until = Math.max(repellentUntil, netTornUntil); const until = Math.max(repellentUntil, netTornUntil, banUntil);
if (until <= Date.now()) return; if (until <= Date.now()) return;
const id = window.setInterval(() => setNowTs(Date.now()), 1000); const id = window.setInterval(() => setNowTs(Date.now()), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [repellentUntil, netTornUntil]); }, [repellentUntil, netTornUntil, banUntil]);
const onRepairClick = useCallback(async () => { const onRepairClick = useCallback(async () => {
const ok = await repair(); const ok = await repair();
@@ -1087,8 +1283,38 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
return ( return (
<> <>
<div ref={containerRef} className={className} /> <div ref={containerRef} className={interactive ? `${className} playing` : className} />
{enableNet && ( {enableNet && (
<button
type="button"
className={interactive ? 'butterfly-mode-toggle playing' : 'butterfly-mode-toggle'}
onClick={toggleMode}
title={interactive ? 'Přepnout na objednávání (vypne hru)' : 'Zapnout hraní s motýly'}
>
{interactive ? '🍽️ Objednávat' : '🎮 Hrát'}
</button>
)}
{interactive && inspecting && !banned && (
<div className="butterfly-inspection">
🕵 Inspekce! Chyť <b>zářícího královského motýla 👑</b> a jiného se nedotkni!
</div>
)}
{interactive && banned && (
<div className="butterfly-jail">
<div className="butterfly-jail-bars" aria-hidden="true" />
<div className="butterfly-jail-note">
<div className="jail-emoji">🚔👮</div>
<div className="jail-title">Ve vězení za podvádění!</div>
<div className="jail-sub">Inspektor přistihl. Pustí za <b>{formatBan(banRemainingMs)}</b>.</div>
<div className="jail-hint">Chytání je zastavené. Objednávat můžeš dál přepni vpravo dole.</div>
</div>
</div>
)}
{interactive && (
<div className="butterfly-hud"> <div className="butterfly-hud">
{flash && ( {flash && (
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření"> <div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
+10 -1
View File
@@ -12,6 +12,7 @@ import {
reportCaterpillarAte as caterpillarAteApi, reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi, defeatCaterpillar as defeatCaterpillarApi,
reportMothHit as mothHitApi, reportMothHit as mothHitApi,
reportInspectionFail as inspectionFailApi,
buyPremiumNet as buyPremiumApi, buyPremiumNet as buyPremiumApi,
buyWaspSpray as waspSprayApi, buyWaspSpray as waspSprayApi,
buyInsurance as buyInsuranceApi, buyInsurance as buyInsuranceApi,
@@ -166,6 +167,14 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ } } catch { /* ignore */ }
}, [applyStats]); }, [applyStats]);
/** Selhání anti-bot inspekce server počítá selhání a případně udělí ban. */
const reportInspectionFail = useCallback(async () => {
try {
const res = await inspectionFailApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */ /** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */
const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => { const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => {
if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; } if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; }
@@ -242,6 +251,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
return { return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar, reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade, mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade,
}; };
} }
+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ě */ /** Počet započtených úlovků v aktuálním okně */
catchWindowCount: number; 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 */ /** Kolikrát hráč omylem chytil černou můru */
mothsHit: number; mothsHit: number;
/** Trvalá vylepšení z obchodu */ /** Trvalá vylepšení z obchodu */
@@ -156,6 +164,13 @@ export const RATE_CAP_PER_MIN = 90;
/** Délka okna pro rate-cap (ms) */ /** Délka okna pro rate-cap (ms) */
const RATE_WINDOW_MS = 60_000; 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 * Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
* (prestiž), takže postup nikdy „nedojde". * (prestiž), takže postup nikdy „nedojde".
@@ -241,6 +256,7 @@ function defaultStats(now: number): StoredButterflyStats {
repellentUntil: 0, netTornUntil: 0, repellentUntil: 0, netTornUntil: 0,
lastWaspAt: now, lastBirdAt: now, lastWaspAt: now, lastBirdAt: now,
catchWindowStart: now, catchWindowCount: 0, catchWindowStart: now, catchWindowCount: 0,
inspectionFails: 0, inspectionWindowStart: now, banUntil: 0,
mothsHit: 0, mothsHit: 0,
upgrades: { net: 0, scarecrow: 0, reinforced: 0 }, upgrades: { net: 0, scarecrow: 0, reinforced: 0 },
insuranceUntil: 0, insuranceUntil: 0,
@@ -267,6 +283,9 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
lastBirdAt: s.lastBirdAt ?? now, lastBirdAt: s.lastBirdAt ?? now,
catchWindowStart: s.catchWindowStart ?? now, catchWindowStart: s.catchWindowStart ?? now,
catchWindowCount: s.catchWindowCount ?? 0, catchWindowCount: s.catchWindowCount ?? 0,
inspectionFails: s.inspectionFails ?? 0,
inspectionWindowStart: s.inspectionWindowStart ?? now,
banUntil: s.banUntil ?? 0,
mothsHit: s.mothsHit ?? 0, mothsHit: s.mothsHit ?? 0,
upgrades: { upgrades: {
net: s.upgrades?.net ?? 0, 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 { export function growPests(s: StoredButterflyStats, now: number): void {
// Síťka se sama zašije po uplynutí odpočtu // Síťka se sama zašije po uplynutí odpočtu
if (s.netTornUntil && s.netTornUntil <= now) s.netTornUntil = 0; 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 // Vosy
if (s.wasps < WASP_MAX) { if (s.wasps < WASP_MAX) {
@@ -365,6 +386,7 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
repellentUntil: s.repellentUntil, repellentUntil: s.repellentUntil,
netTornUntil: s.netTornUntil, netTornUntil: s.netTornUntil,
insuranceUntil: s.insuranceUntil, insuranceUntil: s.insuranceUntil,
banUntil: s.banUntil,
upgrades: { ...s.upgrades }, upgrades: { ...s.upgrades },
daily: { daily: {
taskId: s.daily.taskId, taskId: s.daily.taskId,
@@ -434,6 +456,9 @@ export async function recordCatches(login: string, normal: number, golden: numbe
let premiumUnlocked = false; let premiumUnlocked = false;
const mine = await mutateUser(login, now, (s) => { 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 oldCaught = s.caught;
const oldLevel = levelForCaught(oldCaught); const oldLevel = levelForCaught(oldCaught);
@@ -529,6 +554,27 @@ export async function reportNetTorn(login: string, now: number = Date.now()): Pr
return toDto(mine); 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). */ /** 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> { export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => { const mine = await mutateUser(login, now, (s) => {
+9
View File
@@ -13,6 +13,7 @@ import {
caterpillarAte, caterpillarAte,
defeatCaterpillar, defeatCaterpillar,
mothHit, mothHit,
reportInspectionFail,
buyPremium, buyPremium,
waspSpray, waspSpray,
buyInsurance, buyInsurance,
@@ -128,6 +129,14 @@ router.post("/moth", async (req: Request, res, next) => {
} catch (e: any) { next(e) } } 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) => { router.post("/buyPremium", async (req: Request, res, next) => {
try { try {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
+13 -2
View File
@@ -20,8 +20,19 @@ function loadAllChangelogs(): Record<string, string[]> {
for (const file of files) { for (const file of files) {
const date = file.replace(".json", ""); const date = file.replace(".json", "");
if (!cache[date]) { if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8"); try {
cache[date] = JSON.parse(content); 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, caterpillarAte,
defeatCaterpillar, defeatCaterpillar,
mothHit, mothHit,
reportInspectionFail,
buyPremium, buyPremium,
waspSpray, waspSpray,
buyInsurance, buyInsurance,
@@ -44,6 +45,9 @@ import {
CATERPILLAR_EATS, CATERPILLAR_EATS,
CATERPILLAR_REWARD, CATERPILLAR_REWARD,
AUTO_REPAIR_MS, AUTO_REPAIR_MS,
BAN_FAIL_THRESHOLD,
BAN_DURATION_MS,
BAN_WINDOW_MS,
MOTH_PENALTY, MOTH_PENALTY,
PREMIUM_BUY_COST, PREMIUM_BUY_COST,
WASP_SPRAY_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', () => { describe('getLeaderboard', () => {
test('řadí sestupně dle počtu chycených a respektuje limit', async () => { test('řadí sestupně dle počtu chycených a respektuje limit', async () => {
await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 }); await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 });
+2
View File
@@ -104,6 +104,8 @@ paths:
$ref: "./paths/butterflies/defeatCaterpillar.yml" $ref: "./paths/butterflies/defeatCaterpillar.yml"
/butterflies/moth: /butterflies/moth:
$ref: "./paths/butterflies/moth.yml" $ref: "./paths/butterflies/moth.yml"
/butterflies/inspectionFailed:
$ref: "./paths/butterflies/inspectionFailed.yml"
/butterflies/buyPremium: /butterflies/buyPremium:
$ref: "./paths/butterflies/buyPremium.yml" $ref: "./paths/butterflies/buyPremium.yml"
/butterflies/waspSpray: /butterflies/waspSpray:
@@ -0,0 +1,12 @@
post:
operationId: reportInspectionFail
summary: >-
Zaznamená selhání anti-bot inspekce. Po několika selháních v krátkém okně
udělí perzistentní ban za podvádění.
responses:
"200":
description: Aktualizované statistiky (případně s aktivním banem).
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
+4
View File
@@ -949,6 +949,7 @@ ButterflyStats:
- repellentUntil - repellentUntil
- netTornUntil - netTornUntil
- insuranceUntil - insuranceUntil
- banUntil
- upgrades - upgrades
- daily - daily
properties: properties:
@@ -1003,6 +1004,9 @@ ButterflyStats:
insuranceUntil: insuranceUntil:
description: Časové razítko (ms epoch), do kdy platí pojistka proti zloději; 0 = neaktivní description: Časové razítko (ms epoch), do kdy platí pojistka proti zloději; 0 = neaktivní
type: integer type: integer
banUntil:
description: Časové razítko (ms epoch), do kdy platí ban za podvádění; 0 = bez banu
type: integer
upgrades: upgrades:
$ref: "#/ButterflyUpgrades" $ref: "#/ButterflyUpgrades"
daily: daily: