diff --git a/client/src/FlyingButterflies.scss b/client/src/FlyingButterflies.scss index bc05404..b7de14d 100644 --- a/client/src/FlyingButterflies.scss +++ b/client/src/FlyingButterflies.scss @@ -7,6 +7,49 @@ pointer-events: none; z-index: 2; 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 { @@ -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 .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)); @@ -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)); } } + +// --- 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; } +} diff --git a/client/src/FlyingButterflies.tsx b/client/src/FlyingButterflies.tsx index 413c655..b4ae62a 100644 --- a/client/src/FlyingButterflies.tsx +++ b/client/src/FlyingButterflies.tsx @@ -28,6 +28,14 @@ const GOLD_VALUE = 25; const MOTH_PENALTY = 15; /** Přírůstek dosahu chytání za úroveň vylepšení „větší síťka" */ 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) const THIEF_HP = 22; @@ -43,6 +51,17 @@ function formatRemaining(ms: number): string { 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 { el: HTMLDivElement; sprite: HTMLDivElement; @@ -51,6 +70,8 @@ interface ButterflyData { golden: boolean; /** Černá můra – chycení stojí úlovky (nepočítá se jako úlovek) */ moth: boolean; + /** Korunovaný motýl pro anti-bot inspekci */ + royal: boolean; bobFreq1: number; bobPhase1: number; bobAmp1: number; bobFreq2: number; bobPhase2: number; bobAmp2: number; } @@ -99,6 +120,8 @@ interface SceneCallbacks { getCoins: () => number; getCaught: () => number; getNetUpgrade: () => number; + /** Do kdy platí serverový ban (ms epoch); 0 = bez banu */ + getBanUntil: () => number; onTornChange: (torn: boolean) => void; onCombo: (count: number) => void; onSting: () => void; @@ -111,6 +134,8 @@ interface SceneCallbacks { onStalkerAppear: (type: 'thief' | 'caterpillar') => void; onBatShooed: () => 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 { @@ -123,10 +148,13 @@ interface FlyingButterfliesProps { class ButterflyScene { private viewport: HTMLElement; 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 birds: CritterData[] = []; private wasps: CritterData[] = []; private stalker: StalkerData | null = null; + private interactive: boolean = false; private numButterflies: number; private variants: readonly string[]; private width: number; @@ -157,6 +185,16 @@ class ButterflyScene { private webClicks: number = 0; 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 lastCatchTs: number = 0; private comboTimer: number = 0; @@ -195,6 +233,8 @@ class ButterflyScene { constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) { this.viewport = el; this.world = document.createElement('div'); + this.gameLayer = document.createElement('div'); + this.gameLayer.className = 'butterfly-game-layer'; this.numButterflies = numButterflies; this.variants = variants; this.netEnabled = netEnabled; @@ -202,6 +242,8 @@ class ButterflyScene { this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; 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.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; @@ -217,7 +259,10 @@ class ButterflyScene { } 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 { const base = this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; 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 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 -------------------------------------------------------------- private resetButterfly = (b: ButterflyData): void => { @@ -240,6 +300,19 @@ class ButterflyScene { b.bobPhase2 = Math.random() * Math.PI * 2; 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 b.moth = Math.random() < MOTH_CHANCE; b.golden = !b.moth && Math.random() < GOLDEN_CHANCE; @@ -295,6 +368,9 @@ class ButterflyScene { private onPointerDown = (e: PointerEvent): void => { 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.net.classList.add('grabbed'); this.netX = e.clientX; this.netY = e.clientY; @@ -302,6 +378,7 @@ class ButterflyScene { }; private onPointerMove = (e: PointerEvent): void => { if (!this.netGrabbed) return; + if (!e.isTrusted) { this.syntheticSeen = true; this.trustedControl = false; } this.netX = e.clientX; this.netY = e.clientY; this.updateNetTransform(); }; @@ -330,6 +407,13 @@ class ButterflyScene { /** 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 => { 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) { // Chytit černou můru je chyba – penalta, žádné kombo 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) 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 half = ButterflyScene.BASE_SIZE / 2; for (const b of this.butterflies) { @@ -377,7 +463,8 @@ class ButterflyScene { }; 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; this.lastMagnetAt = this.timer; const half = ButterflyScene.BASE_SIZE / 2; @@ -392,6 +479,54 @@ class ButterflyScene { 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 => { this.premiumUntil = Date.now() + PREMIUM_DURATION_MS; this.applyNetAppearance(); @@ -460,7 +595,7 @@ class ButterflyScene { c.cleanup = () => el.removeEventListener('pointerdown', swat); } - this.world.appendChild(el); + this.gameLayer.appendChild(el); return c; }; @@ -647,7 +782,7 @@ class ButterflyScene { el.addEventListener('pointerdown', hit); s.cleanup = () => el.removeEventListener('pointerdown', hit); - this.viewport.appendChild(el); + this.gameLayer.appendChild(el); this.stalker = s; this.cb.onStalkerAppear(type); }; @@ -732,7 +867,7 @@ class ButterflyScene { }; el.addEventListener('pointerdown', hit); bat.cleanup = () => el.removeEventListener('pointerdown', hit); - this.world.appendChild(el); + this.gameLayer.appendChild(el); this.bat = bat; }; @@ -799,7 +934,7 @@ class ButterflyScene { }; el.addEventListener('pointerdown', strip); (el as any)._cleanup = () => el.removeEventListener('pointerdown', strip); - this.viewport.appendChild(el); + this.gameLayer.appendChild(el); this.web = el; 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('pointerup', this.onPointerUp); window.addEventListener('pointercancel', this.onPointerUp); - this.viewport.appendChild(this.net); + this.gameLayer.appendChild(this.net); }; public init = (): void => { @@ -844,7 +979,7 @@ class ButterflyScene { sprite.className = 'butterfly-sprite'; el.appendChild(sprite); 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, }; this.resetButterfly(b); @@ -852,6 +987,8 @@ class ButterflyScene { this.world.appendChild(el); } this.viewport.appendChild(this.world); + this.viewport.appendChild(this.gameLayer); + this.gameLayer.style.display = this.interactive ? '' : 'none'; if (this.netEnabled) this.initNet(); window.addEventListener('resize', this.handleResize); document.addEventListener('visibilitychange', this.handleVisibility); @@ -859,13 +996,19 @@ class ButterflyScene { private wasPremium: boolean = false; private wasStunned: boolean = false; + private lastNetUpgrade: number = 0; public render = (): void => { for (const b of this.butterflies) this.updateButterfly(b); - if (this.netEnabled) { + if (this.netEnabled && this.interactive) { 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.updateWasps(); @@ -883,6 +1026,19 @@ class ButterflyScene { 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 (nowPremium) this.runMagnet(); } @@ -903,7 +1059,7 @@ class ButterflyScene { window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', 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); document.removeEventListener('visibilitychange', this.handleVisibility); }; @@ -932,6 +1088,20 @@ const FlyingButterflies: React.FC = ({ const [nowTs, setNowTs] = useState(() => Date.now()); const flashTimer = useRef(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) => { setFlash(msg); if (flashTimer.current) window.clearTimeout(flashTimer.current); @@ -949,27 +1119,33 @@ const FlyingButterflies: React.FC = ({ } }, [showFlash]); + const [inspecting, setInspecting] = useState(false); + const { stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, - caterpillarAte, defeatCaterpillar, mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade, + caterpillarAte, defeatCaterpillar, mothHit, reportInspectionFail, + buyPremium, waspSpray, buyInsurance, buyUpgrade, } = useButterflyStats(handleReward); const netUpgradeRef = useRef(0); netUpgradeRef.current = stats?.upgrades?.net ?? 0; + const banUntilRef = useRef(0); + banUntilRef.current = stats?.banUntil ?? 0; const noop = () => { }; const callbacksRef = useRef({ - 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, 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 = { onCatch: (golden) => reportCatch(golden), getCoins: () => coinsRef.current, getCaught: () => stats?.caught ?? 0, getNetUpgrade: () => netUpgradeRef.current, + getBanUntil: () => banUntilRef.current, onTornChange: (t) => { if (t) void reportTear(); }, onCombo: (c) => setCombo(c), onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'), @@ -984,8 +1160,17 @@ const FlyingButterflies: React.FC = ({ : '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'), onBatShooed: () => showFlash('🦇 Netopýr zahnán!'), 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(() => { if (containerRef.current) { sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, { @@ -993,6 +1178,7 @@ const FlyingButterflies: React.FC = ({ getCoins: () => callbacksRef.current.getCoins(), getCaught: () => callbacksRef.current.getCaught(), getNetUpgrade: () => callbacksRef.current.getNetUpgrade(), + getBanUntil: () => callbacksRef.current.getBanUntil(), onTornChange: (t) => callbacksRef.current.onTornChange(t), onCombo: (c) => callbacksRef.current.onCombo(c), onSting: () => callbacksRef.current.onSting(), @@ -1005,10 +1191,14 @@ const FlyingButterflies: React.FC = ({ onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), onBatShooed: () => callbacksRef.current.onBatShooed(), onWebCleared: () => callbacksRef.current.onWebCleared(), + onInspection: (s) => callbacksRef.current.onInspection(s), }); sceneRef.current.init(); + sceneRef.current.setInteractive(interactiveRef.current); 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]); useEffect(() => { @@ -1019,6 +1209,9 @@ const FlyingButterflies: React.FC = ({ }; }, [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 birds = stats?.birds ?? 0; const netTornUntil = stats?.netTornUntil ?? 0; @@ -1042,14 +1235,17 @@ const FlyingButterflies: React.FC = ({ const repellentUntil = stats?.repellentUntil ?? 0; const repellentRemainingMs = Math.max(0, repellentUntil - nowTs); 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(() => { - const until = Math.max(repellentUntil, netTornUntil); + const until = Math.max(repellentUntil, netTornUntil, banUntil); if (until <= Date.now()) return; const id = window.setInterval(() => setNowTs(Date.now()), 1000); return () => window.clearInterval(id); - }, [repellentUntil, netTornUntil]); + }, [repellentUntil, netTornUntil, banUntil]); const onRepairClick = useCallback(async () => { const ok = await repair(); @@ -1087,8 +1283,38 @@ const FlyingButterflies: React.FC = ({ return ( <> -
+
+ {enableNet && ( + + )} + + {interactive && inspecting && !banned && ( +
+ 🕵️ Inspekce! Chyť zářícího královského motýla 👑 — a jiného se nedotkni! +
+ )} + + {interactive && banned && ( +
+ + )} + + {interactive && (
{flash && (
setFlash(null)} title="Klikni pro zavření"> diff --git a/client/src/hooks/useButterflyStats.ts b/client/src/hooks/useButterflyStats.ts index 2b9c88c..ead7211 100644 --- a/client/src/hooks/useButterflyStats.ts +++ b/client/src/hooks/useButterflyStats.ts @@ -12,6 +12,7 @@ import { reportCaterpillarAte as caterpillarAteApi, defeatCaterpillar as defeatCaterpillarApi, reportMothHit as mothHitApi, + reportInspectionFail as inspectionFailApi, buyPremiumNet as buyPremiumApi, buyWaspSpray as waspSprayApi, buyInsurance as buyInsuranceApi, @@ -166,6 +167,14 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { /* ignore */ } }, [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). */ const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => { if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; } @@ -242,6 +251,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { return { stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar, - mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade, + mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade, }; } diff --git a/server/changelogs/2026-07-27.json b/server/changelogs/2026-07-27.json new file mode 100644 index 0000000..0ca3567 --- /dev/null +++ b/server/changelogs/2026-07-27.json @@ -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í)" +] diff --git a/server/src/butterflies.ts b/server/src/butterflies.ts index 0d62b5c..964df16 100644 --- a/server/src/butterflies.ts +++ b/server/src/butterflies.ts @@ -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 { + 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 { const mine = await mutateUser(login, now, (s) => { diff --git a/server/src/routes/butterflyRoutes.ts b/server/src/routes/butterflyRoutes.ts index 9279aed..05d2c8f 100644 --- a/server/src/routes/butterflyRoutes.ts +++ b/server/src/routes/butterflyRoutes.ts @@ -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)); diff --git a/server/src/routes/changelogRoutes.ts b/server/src/routes/changelogRoutes.ts index 20598fd..b9ca253 100644 --- a/server/src/routes/changelogRoutes.ts +++ b/server/src/routes/changelogRoutes.ts @@ -20,8 +20,19 @@ function loadAllChangelogs(): Record { 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); + } } } diff --git a/server/src/tests/butterflies.test.ts b/server/src/tests/butterflies.test.ts index 36ca634..6ed419e 100644 --- a/server/src/tests/butterflies.test.ts +++ b/server/src/tests/butterflies.test.ts @@ -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 }); diff --git a/types/api.yml b/types/api.yml index 05dab7b..1abcbe6 100644 --- a/types/api.yml +++ b/types/api.yml @@ -104,6 +104,8 @@ paths: $ref: "./paths/butterflies/defeatCaterpillar.yml" /butterflies/moth: $ref: "./paths/butterflies/moth.yml" + /butterflies/inspectionFailed: + $ref: "./paths/butterflies/inspectionFailed.yml" /butterflies/buyPremium: $ref: "./paths/butterflies/buyPremium.yml" /butterflies/waspSpray: diff --git a/types/paths/butterflies/inspectionFailed.yml b/types/paths/butterflies/inspectionFailed.yml new file mode 100644 index 0000000..104cc3b --- /dev/null +++ b/types/paths/butterflies/inspectionFailed.yml @@ -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" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index ebb412b..6e515d2 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -949,6 +949,7 @@ ButterflyStats: - repellentUntil - netTornUntil - insuranceUntil + - banUntil - upgrades - daily properties: @@ -1003,6 +1004,9 @@ ButterflyStats: insuranceUntil: description: Časové razítko (ms epoch), do kdy platí pojistka proti zloději; 0 = neaktivní type: integer + banUntil: + description: Časové razítko (ms epoch), do kdy platí ban za podvádění; 0 = bez banu + type: integer upgrades: $ref: "#/ButterflyUpgrades" daily: