diff --git a/client/src/FlyingButterflies.scss b/client/src/FlyingButterflies.scss index a4926e1..6282e06 100644 --- a/client/src/FlyingButterflies.scss +++ b/client/src/FlyingButterflies.scss @@ -365,6 +365,13 @@ } } +.butterfly-torn-note { + margin-top: 4px; + font-size: 0.76rem; + font-weight: 600; + color: #c92a2a; +} + .butterfly-action-btn { margin-top: 4px; padding: 5px 10px; @@ -401,6 +408,58 @@ } } +// --- Bossové (zloděj / housenka) --------------------------------------------- + +.butterfly-stalker { + position: fixed; + top: 0; + left: 0; + width: 56px; + display: flex; + flex-direction: column; + align-items: center; + pointer-events: auto; + cursor: crosshair; + user-select: none; + touch-action: none; + z-index: 6; + will-change: transform; + + &.hit .stalker-emoji { + animation: butterfly-stalker-hit 0.18s ease-out; + } +} + +.stalker-emoji { + font-size: 44px; + line-height: 1; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35)); +} + +.stalker-hp { + width: 46px; + height: 6px; + margin-bottom: 3px; + background: rgba(0, 0, 0, 0.25); + border-radius: 999px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.stalker-hp-inner { + height: 100%; + width: 100%; + background: linear-gradient(90deg, #ff6b6b, #c92a2a); + border-radius: 999px; + transition: width 0.1s linear; +} + +@keyframes butterfly-stalker-hit { + 0% { transform: scale(1) rotate(0); } + 50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); } + 100% { transform: scale(1) rotate(0); } +} + // Efekt při plácnutí vosy .butterfly-swat-fx { position: fixed; diff --git a/client/src/FlyingButterflies.tsx b/client/src/FlyingButterflies.tsx index ad3af0f..e1ab550 100644 --- a/client/src/FlyingButterflies.tsx +++ b/client/src/FlyingButterflies.tsx @@ -2,30 +2,33 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats'; import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal'; -// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně) +// Barevné varianty motýlů (soubory v public/, referencované root-absolutně) const BUTTERFLY_VARIANTS = [ - '/butterfly-orange.svg', // Oranžová (monarcha) - '/butterfly-blue.svg', // Modrá - '/butterfly-yellow.svg', // Žlutá (otakárek) - '/butterfly-pink.svg', // Růžová + '/butterfly-orange.svg', + '/butterfly-blue.svg', + '/butterfly-yellow.svg', + '/butterfly-pink.svg', ] as const; -// Vzácný zlatý motýl const GOLDEN_VARIANT = '/butterfly-golden.svg'; -const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých +const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější) -// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene) const NET_NORMAL = '/butterfly-net.svg'; const NET_GOLDEN = '/butterfly-net-golden.svg'; const NET_TORN = '/butterfly-net-torn.svg'; // Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts) -const PREMIUM_MILESTONE = 50; const PREMIUM_DURATION_MS = 60_000; -export const REPAIR_COST = 10; -export const REPELLENT_COST = 25; +export const REPAIR_COST = 20; +export const REPELLENT_COST = 60; const GOLD_VALUE = 25; +// „Bossové" (zloděj / housenka) +const THIEF_HP = 22; +const CATERPILLAR_HP = 18; +const THIEF_MIN_COINS = 20; // zloděj přijde, jen když je co ukrást +const CATERPILLAR_MIN_CAUGHT = 15; // housenka přijde, jen když je co sežrat + /** Naformátuje zbývající čas jako „m:ss" nebo „s s". */ function formatRemaining(ms: number): string { const s = Math.ceil(ms / 1000); @@ -37,44 +40,54 @@ function formatRemaining(ms: number): string { interface ButterflyData { el: HTMLDivElement; sprite: HTMLDivElement; - x: number; - y: number; - dir: number; - speed: number; - size: number; + x: number; y: number; + dir: number; speed: number; size: number; golden: boolean; - bobFreq1: number; - bobPhase1: number; - bobAmp1: number; - bobFreq2: number; - bobPhase2: number; - bobAmp2: number; + bobFreq1: number; bobPhase1: number; bobAmp1: number; + bobFreq2: number; bobPhase2: number; bobAmp2: number; } -/** Létající havěť (pták nebo vosa). */ +/** Létající havěť (pták nebo vosa) – nalétává na síťku. */ interface CritterData { el: HTMLDivElement; - x: number; - y: number; - dir: number; - speed: number; - size: number; - bobPhase: number; - bobAmp: number; - /** Pták: už protrhl síťku v tomto průletu (reset při obletu) */ + x: number; y: number; + dir: number; speed: number; size: number; + bobPhase: number; bobAmp: number; + /** Pták: už protrhl síťku v tomto průletu */ triggered: boolean; - /** Uklízeč posluchače kliknutí (jen u vos) */ + /** Vosa: fáze útoku – nálet do síťky, nebo stažení před dalším náletem */ + mode?: 'dive' | 'retreat'; + /** Vosa: cíl stažení */ + tx?: number; ty?: number; cleanup?: () => void; } -/** Callbacky, kterými scéna komunikuje s React obalem. */ +/** Boss plížící se k počítadlu (zloděj krade mince, housenka žere úlovky). */ +interface StalkerData { + el: HTMLDivElement; + hpInner: HTMLDivElement; + x: number; y: number; + vx: number; vy: number; + tx: number; ty: number; + hp: number; maxHp: number; + type: 'thief' | 'caterpillar'; + done: boolean; + cleanup: () => void; +} + interface SceneCallbacks { onCatch: (golden: boolean) => void; getCoins: () => number; + getCaught: () => number; onTornChange: (torn: boolean) => void; onCombo: (count: number) => void; onSting: () => void; onSwatWasp: () => void; + onRobbery: () => void; + onDefeatThief: () => void; + onCaterpillarAte: () => void; + onDefeatCaterpillar: () => void; + onStalkerAppear: (type: 'thief' | 'caterpillar') => void; } interface FlyingButterfliesProps { @@ -90,6 +103,7 @@ class ButterflyScene { private butterflies: ButterflyData[] = []; private birds: CritterData[] = []; private wasps: CritterData[] = []; + private stalker: StalkerData | null = null; private numButterflies: number; private variants: readonly string[]; private width: number; @@ -109,6 +123,8 @@ class ButterflyScene { private premiumUntil: number = 0; private stunUntil: number = 0; private lastMagnetAt: number = 0; + private nextCatchAt: number = 0; + private nextStalkerAt: number = 1800; // první možný boss ~30 s private comboCount: number = 0; private lastCatchTs: number = 0; @@ -126,18 +142,17 @@ class ButterflyScene { private static readonly WASP_SIZE = 44; private static readonly TEAR_RADIUS = 46; private static readonly STING_RADIUS = 46; - private static readonly STUN_FRAMES = 300; // ~5 s omráčení + private static readonly STUN_FRAMES = 300; // ~5 s omráčení private static readonly MAGNET_RADIUS = 150; - private static readonly MAGNET_INTERVAL = 42; + private static readonly MAGNET_INTERVAL = 60; // ~1 s + private static readonly CATCH_COOLDOWN = 8; // max ~7 chycení/s (proti zmenšení okna) private static readonly COMBO_WINDOW_MS = 1200; - constructor( - el: HTMLElement, - numButterflies: number, - variants: readonly string[], - netEnabled: boolean, - cb: SceneCallbacks, - ) { + private static readonly STALKER_TRAVEL_FRAMES = 1700; // ~28 s na doplížení + private static readonly STALKER_MIN_GAP = 3600; // 60 s + private static readonly STALKER_MAX_GAP = 9000; // 150 s + + constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) { this.viewport = el; this.world = document.createElement('div'); this.numButterflies = numButterflies; @@ -164,15 +179,9 @@ class ButterflyScene { private resetButterfly = (b: ButterflyData): void => { b.dir = Math.random() > 0.5 ? 1 : -1; b.speed = Math.random() * 1.1 + 0.7; - - if (this.timer === 0) { - b.x = Math.random() * this.width; - } else { - b.x = b.dir === 1 ? -40 : this.width + 40; - } + b.x = this.timer === 0 ? Math.random() * this.width : (b.dir === 1 ? -40 : this.width + 40); b.y = Math.random() * (this.height - 60) + 30; b.size = Math.random() * 0.6 + 0.6; - b.bobFreq1 = Math.random() * 0.02 + 0.03; b.bobPhase1 = Math.random() * Math.PI * 2; b.bobAmp1 = Math.random() * 0.8 + 0.6; @@ -184,10 +193,9 @@ class ButterflyScene { if (b.golden) { b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; b.sprite.classList.add('golden'); - b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší + b.size = Math.random() * 0.5 + 1.0; } else { - const variant = this.variants[Math.floor(Math.random() * this.variants.length)]; - b.sprite.style.backgroundImage = `url(${variant})`; + b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`; b.sprite.classList.remove('golden'); } b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; @@ -198,16 +206,10 @@ class ButterflyScene { const vy = Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 + Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2; - b.x += vx; - b.y += vy; + b.x += vx; b.y += vy; const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90; b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`; - - if ( - (b.dir === 1 && b.x > this.width + 50) || - (b.dir === -1 && b.x < -50) || - b.y < -60 || b.y > this.height + 60 - ) { + if ((b.dir === 1 && b.x > this.width + 50) || (b.dir === -1 && b.x < -50) || b.y < -60 || b.y > this.height + 60) { this.resetButterfly(b); } }; @@ -215,8 +217,7 @@ class ButterflyScene { // --- Síťka --------------------------------------------------------------- private updateNetTransform = (): void => { - this.net.style.transform = - `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`; + this.net.style.transform = `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`; }; private applyNetAppearance = (): void => { @@ -236,18 +237,14 @@ class ButterflyScene { e.preventDefault(); this.netGrabbed = true; this.net.classList.add('grabbed'); - this.netX = e.clientX; - this.netY = e.clientY; + this.netX = e.clientX; this.netY = e.clientY; this.updateNetTransform(); }; - private onPointerMove = (e: PointerEvent): void => { if (!this.netGrabbed) return; - this.netX = e.clientX; - this.netY = e.clientY; + this.netX = e.clientX; this.netY = e.clientY; this.updateNetTransform(); }; - private onPointerUp = (): void => { if (!this.netGrabbed) return; this.netGrabbed = false; @@ -274,6 +271,7 @@ class ButterflyScene { this.cb.onCatch(b.golden); this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY); this.resetButterfly(b); + this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN; const now = Date.now(); this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1; @@ -285,24 +283,24 @@ 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.timer < this.stunUntil) return; + if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; const r2 = this.catchRadius * this.catchRadius; const half = ButterflyScene.BASE_SIZE / 2; - let caughtAny = false; for (const b of this.butterflies) { const dx = (b.x + half * b.size) - this.netX; const dy = (b.y + half * b.size) - this.netY; if (dx * dx + dy * dy < r2) { - caughtAny = true; this.registerCatch(b, this.netX, this.netY); + this.popNet(); + return; // jen jeden za cooldown } } - if (caughtAny) this.popNet(); }; private runMagnet = (): void => { - if (this.torn || this.timer < this.stunUntil) return; + if (this.torn || 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; @@ -321,15 +319,11 @@ class ButterflyScene { this.premiumUntil = Date.now() + PREMIUM_DURATION_MS; this.applyNetAppearance(); }; - - /** Vnější (zaplacená) oprava protržené síťky. */ public repairNetExternally = (): void => { if (!this.torn) return; this.torn = false; this.applyNetAppearance(); }; - - /** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */ public setTornFromServer = (torn: boolean): void => { if (this.torn === torn) return; this.torn = torn; @@ -364,13 +358,14 @@ class ButterflyScene { const c: CritterData = { el, x: dir === 1 ? -size : this.width + size, - y: Math.random() * (this.height * 0.7) + this.height * 0.1, + y: Math.random() * (this.height * 0.6) + this.height * 0.1, dir, speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2, size, bobPhase: Math.random() * Math.PI * 2, bobAmp: Math.random() * 0.8 + 0.5, triggered: false, + mode: type === 'wasp' ? 'dive' : undefined, }; if (type === 'wasp') { @@ -395,70 +390,102 @@ class ButterflyScene { private wrapCritter = (c: CritterData): void => { c.dir = Math.random() > 0.5 ? 1 : -1; c.x = c.dir === 1 ? -c.size : this.width + c.size; - c.y = Math.random() * (this.height * 0.7) + this.height * 0.1; + c.y = Math.random() * (this.height * 0.6) + this.height * 0.1; c.triggered = false; }; - private updateWasp = (c: CritterData): void => { - c.x += c.dir * c.speed; - // mírné míření k obruči, aby vosy otravovaly zrovna síťku - const targetY = this.netY - c.size / 2; - c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp; - const flip = c.dir === -1 ? -1 : 1; - c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`; - if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) { + /** + * Pohyb havěti. `homing` 0 = letí rovně jedním směrem (ptáci), >0 = agresivně + * nalétává k síťce (vosy). Sprite se natáčí podle směru letu. + */ + private moveCritter = (c: CritterData, homingX: number, homingY: number): void => { + const cx = c.x + c.size / 2; + const cy = c.y + c.size / 2; + const bob = Math.sin(this.timer * 0.05 + c.bobPhase) * c.bobAmp * 0.5; + const vx = c.dir * c.speed + (homingX ? (this.netX - cx) * homingX : 0); + const vy = (homingY ? (this.netY - cy) * homingY : 0) + bob; + c.x += vx; + c.y += vy; + + // Natočení podle směru letu (sprite je nakreslený doprava); při letu doleva + // se svisle překlopí, aby nebyl vzhůru nohama. + const angle = Math.atan2(vy, vx) * (180 / Math.PI); + const flipY = Math.abs(angle) > 90 ? -1 : 1; + c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`; + + if (c.x > this.width + c.size * 2 || c.x < -c.size * 2 || c.y < -c.size * 2 || c.y > this.height + c.size * 2) { this.wrapCritter(c); } }; - private updateBird = (c: CritterData): void => { - c.x += c.dir * c.speed; - c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp; - const flip = c.dir === -1 ? -1 : 1; - c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`; - if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) { - this.wrapCritter(c); - } - }; - - private removeCritter = (arr: CritterData[]): void => { + private removeLastCritter = (arr: CritterData[]): void => { const c = arr.pop(); - if (c) { - c.cleanup?.(); - if (c.el.parentNode) c.el.parentNode.removeChild(c.el); - } + if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); } }; - /** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */ public syncPests = (wasps: number, birds: number): void => { if (!this.netEnabled) return; while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp')); - while (this.wasps.length > wasps) this.removeCritter(this.wasps); + while (this.wasps.length > wasps) this.removeLastCritter(this.wasps); while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird')); - while (this.birds.length > birds) this.removeCritter(this.birds); + while (this.birds.length > birds) this.removeLastCritter(this.birds); + }; + + /** Posune tvora plnou rychlostí k cíli a natočí ho po směru letu. */ + private steerTo = (c: CritterData, targetX: number, targetY: number): void => { + const cx = c.x + c.size / 2; + const cy = c.y + c.size / 2; + const dx = targetX - cx; + const dy = targetY - cy; + const dist = Math.hypot(dx, dy) || 1; + const vx = (dx / dist) * c.speed; + const vy = (dy / dist) * c.speed; + c.x += vx; + c.y += vy; + const angle = Math.atan2(vy, vx) * (180 / Math.PI); + const flipY = Math.abs(angle) > 90 ? -1 : 1; + c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`; }; private updateWasps = (): void => { for (const wasp of this.wasps) { - this.updateWasp(wasp); - // Vosa žihne síťku, jakmile pomine předchozí omráčení - if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) { - this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES; - this.net.classList.remove('stung'); - void this.net.offsetWidth; - this.net.classList.add('stung'); - this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY); - this.cb.onSting(); + if (wasp.mode === 'retreat') { + // Stáhnutí kousek od síťky před dalším náletem + this.steerTo(wasp, wasp.tx ?? this.netX, wasp.ty ?? this.netY); + const dx = (wasp.x + wasp.size / 2) - (wasp.tx ?? this.netX); + const dy = (wasp.y + wasp.size / 2) - (wasp.ty ?? this.netY); + if (dx * dx + dy * dy < 26 * 26) wasp.mode = 'dive'; + } else { + // Nálet přímo do síťky + this.steerTo(wasp, this.netX, this.netY); + if (this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) { + // Vytažení a příprava na další nálet + wasp.mode = 'retreat'; + wasp.tx = Math.max(30, Math.min(this.width - 30, this.netX + (Math.random() * 2 - 1) * 220)); + wasp.ty = Math.max(30, this.netY - (70 + Math.random() * 150)); + // Žihnutí (jen když už pominulo předchozí omráčení) + if (!this.torn && this.timer >= this.stunUntil) { + this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES; + this.net.classList.remove('stung'); + void this.net.offsetWidth; + this.net.classList.add('stung'); + this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY); + this.cb.onSting(); + } + } } } }; private updateBirds = (): void => { for (const bird of this.birds) { - this.updateBird(bird); - // Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu + // Svisle míří na výšku síťky jen ptáci, kteří k ní letí (mají ji před sebou); + // kdo síťku minul, dolétne rovně. + const cx = bird.x + bird.size / 2; + const towardNet = (bird.dir === 1 && this.netX > cx) || (bird.dir === -1 && this.netX < cx); + this.moveCritter(bird, 0, towardNet ? 0.02 : 0); + // Pták protrhne síťku vždy (i bez mincí); síťka se pak sama zašije po čase if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive - && this.cb.getCoins() >= REPAIR_COST && this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) { bird.triggered = true; this.tearNet(this.netX, this.netY); @@ -466,6 +493,117 @@ class ButterflyScene { } }; + // --- Bossové (zloděj / housenka) ---------------------------------------- + + private trySpawnStalker = (): void => { + if (this.timer < this.nextStalkerAt || this.stalker) return; + const canThief = this.cb.getCoins() >= THIEF_MIN_COINS; + const canCat = this.cb.getCaught() >= CATERPILLAR_MIN_CAUGHT; + const options: ('thief' | 'caterpillar')[] = []; + if (canThief) options.push('thief'); + if (canCat) options.push('caterpillar'); + if (options.length === 0) { + this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s + return; + } + const type = options[Math.floor(Math.random() * options.length)]; + this.spawnStalker(type); + this.nextStalkerAt = this.timer + ButterflyScene.STALKER_MIN_GAP + + Math.floor(Math.random() * (ButterflyScene.STALKER_MAX_GAP - ButterflyScene.STALKER_MIN_GAP)); + }; + + private spawnStalker = (type: 'thief' | 'caterpillar'): void => { + const maxHp = type === 'thief' ? THIEF_HP : CATERPILLAR_HP; + const el = document.createElement('div'); + el.className = `butterfly-stalker ${type}`; + el.title = type === 'thief' ? 'Zaklikej zloděje!' : 'Zaklikej housenku!'; + + const hp = document.createElement('div'); + hp.className = 'stalker-hp'; + const hpInner = document.createElement('div'); + hpInner.className = 'stalker-hp-inner'; + hp.appendChild(hpInner); + + const emoji = document.createElement('div'); + emoji.className = 'stalker-emoji'; + emoji.textContent = type === 'thief' ? '🦹' : '🐛'; + + el.appendChild(hp); + el.appendChild(emoji); + + // Start v horním rohu, cíl u počítadla (vlevo dole) + const fromLeft = Math.random() < 0.5; + const startX = fromLeft ? 10 : this.width - 70; + const startY = 10; + const tx = type === 'thief' ? 95 : 45; + const ty = this.height - 55; + + const s: StalkerData = { + el, hpInner, + x: startX, y: startY, + vx: (tx - startX) / ButterflyScene.STALKER_TRAVEL_FRAMES, + vy: (ty - startY) / ButterflyScene.STALKER_TRAVEL_FRAMES, + tx, ty, hp: maxHp, maxHp, type, done: false, + cleanup: () => { /* nahrazeno níže */ }, + }; + + const hit = (ev: Event) => { + ev.preventDefault(); + ev.stopPropagation(); + if (s.done) return; + s.hp -= 1; + s.hpInner.style.width = `${Math.max(0, (s.hp / s.maxHp) * 100)}%`; + el.classList.remove('hit'); + void el.offsetWidth; + el.classList.add('hit'); + if (s.hp <= 0) this.defeatStalker(s); + }; + el.addEventListener('pointerdown', hit); + s.cleanup = () => el.removeEventListener('pointerdown', hit); + + this.viewport.appendChild(el); + this.stalker = s; + this.cb.onStalkerAppear(type); + }; + + private removeStalker = (): void => { + const s = this.stalker; + if (!s) return; + s.cleanup(); + if (s.el.parentNode) s.el.parentNode.removeChild(s.el); + this.stalker = null; + }; + + private defeatStalker = (s: StalkerData): void => { + if (s.done) return; + s.done = true; + this.spawnFx(s.type === 'thief' ? '💰' : '🍃', 'butterfly-swat-fx', s.x + 24, s.y + 24); + if (s.type === 'thief') this.cb.onDefeatThief(); else this.cb.onDefeatCaterpillar(); + this.removeStalker(); + }; + + private updateStalker = (): void => { + const s = this.stalker; + if (!s || s.done) return; + s.x += s.vx; s.y += s.vy; + s.el.style.transform = `translate(${s.x}px, ${s.y}px)`; + const dx = s.x - s.tx; + const dy = s.y - s.ty; + if (dx * dx + dy * dy < 400) { + s.done = true; + if (s.type === 'thief') { + this.spawnFx('💸 Okradeno!', 'butterfly-tear-fx', s.tx, s.ty - 20); + this.cb.onRobbery(); + } else { + this.spawnFx('🐛 Sežráno!', 'butterfly-tear-fx', s.tx, s.ty - 20); + this.cb.onCaterpillarAte(); + } + this.removeStalker(); + } + }; + + // --- Init / render ------------------------------------------------------ + private initNet = (): void => { this.net.className = 'butterfly-net'; this.applyNetAppearance(); @@ -483,7 +621,6 @@ class ButterflyScene { this.butterflies = []; this.world.innerHTML = ''; this.world.className = 'butterfly-scene'; - for (let i = 0; i < this.numButterflies; i++) { const el = document.createElement('div'); const sprite = document.createElement('div'); @@ -497,7 +634,6 @@ class ButterflyScene { this.butterflies.push(b); this.world.appendChild(el); } - this.viewport.appendChild(this.world); if (this.netEnabled) this.initNet(); window.addEventListener('resize', this.handleResize); @@ -516,6 +652,8 @@ class ButterflyScene { this.updateWasps(); this.updateBirds(); + this.trySpawnStalker(); + this.updateStalker(); const stunned = this.timer < this.stunUntil; if (stunned !== this.wasStunned) { @@ -535,7 +673,7 @@ class ButterflyScene { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } if (this.comboTimer) window.clearTimeout(this.comboTimer); for (const w of this.wasps) w.cleanup?.(); - + this.removeStalker(); if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world); this.net.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('pointermove', this.onPointerMove); @@ -547,10 +685,10 @@ class ButterflyScene { } /** - * Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za - * milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci - * (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na - * server přes {@link useButterflyStats}. + * Minihra chytání motýlků: mince, úrovně (strmá křivka), prémiová zlatá síťka, + * vzácní zlatí motýli, kombo, perzistentní škůdci (vosy na zaklikání, nalétávající + * ptáci), zloděj mincí a housenka žeroucí úlovky. Stav na serveru přes + * {@link useButterflyStats}; rate-cap chrání proti podvádění. */ const FlyingButterflies: React.FC = ({ numButterflies = 9, @@ -562,7 +700,6 @@ const FlyingButterflies: React.FC = ({ const sceneRef = useRef(null); const badgeRef = useRef(null); - const [torn, setTorn] = useState(false); const [combo, setCombo] = useState(0); const [flash, setFlash] = useState(null); const [leaderboardOpen, setLeaderboardOpen] = useState(false); @@ -582,38 +719,55 @@ const FlyingButterflies: React.FC = ({ } else if (e.leveledUp) { showFlash(`⭐ Nová úroveň: ${e.newTitle}!`); } else if (e.dailyBonusApplied) { - showFlash('☀️ Denní bonus +10 mincí!'); + showFlash('☀️ Denní bonus mincí!'); } }, [showFlash]); - const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward); + const { + stats, displayCaught, coinsRef, reportCatch, repair, + killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, + caterpillarAte, defeatCaterpillar, + } = useButterflyStats(handleReward); const callbacksRef = useRef({ - onCatch: () => { }, getCoins: () => 0, onTornChange: () => { }, + onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { }, onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { }, + onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { }, + onDefeatCaterpillar: () => { }, onStalkerAppear: () => { }, }); callbacksRef.current = { onCatch: (golden) => reportCatch(golden), getCoins: () => coinsRef.current, - onTornChange: (t) => { setTorn(t); if (t) void reportTear(); }, + getCaught: () => stats?.caught ?? 0, + onTornChange: (t) => { if (t) void reportTear(); }, onCombo: (c) => setCombo(c), onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'), onSwatWasp: () => void killWasp(), + onRobbery: () => { void reportRobbery(); showFlash('💸 Zloděj ti ukradl většinu mincí! Příště ho zaklikej.'); }, + onDefeatThief: () => { void defeatThief(); showFlash('💰 Zloděj poražen! Malá odměna a mince v bezpečí.'); }, + onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); }, + onDefeatCaterpillar: () => { void defeatCaterpillar(); showFlash('🍃 Housenka poražena! Úlovky v bezpečí.'); }, + onStalkerAppear: (type) => showFlash(type === 'thief' + ? '🦹 Zloděj! Míří k tvým mincím – zaklikej ho (chce to hodně ran)!' + : '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'), }; const initialize = useCallback(() => { if (containerRef.current) { - sceneRef.current = new ButterflyScene( - containerRef.current, numButterflies, butterflyVariants, enableNet, - { - onCatch: (g) => callbacksRef.current.onCatch(g), - getCoins: () => callbacksRef.current.getCoins(), - onTornChange: (t) => callbacksRef.current.onTornChange(t), - onCombo: (c) => callbacksRef.current.onCombo(c), - onSting: () => callbacksRef.current.onSting(), - onSwatWasp: () => callbacksRef.current.onSwatWasp(), - }, - ); + sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, { + onCatch: (g) => callbacksRef.current.onCatch(g), + getCoins: () => callbacksRef.current.getCoins(), + getCaught: () => callbacksRef.current.getCaught(), + onTornChange: (t) => callbacksRef.current.onTornChange(t), + onCombo: (c) => callbacksRef.current.onCombo(c), + onSting: () => callbacksRef.current.onSting(), + onSwatWasp: () => callbacksRef.current.onSwatWasp(), + onRobbery: () => callbacksRef.current.onRobbery(), + onDefeatThief: () => callbacksRef.current.onDefeatThief(), + onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(), + onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(), + onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), + }); sceneRef.current.init(); sceneRef.current.render(); } @@ -627,39 +781,37 @@ const FlyingButterflies: React.FC = ({ }; }, [initialize]); - // Synchronizace škůdců a protržení ze serveru do scény const wasps = stats?.wasps ?? 0; const birds = stats?.birds ?? 0; - const netTorn = stats?.netTorn ?? false; + const netTornUntil = stats?.netTornUntil ?? 0; + const torn = netTornUntil > nowTs; + const tornRemainingMs = Math.max(0, netTornUntil - nowTs); useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]); - useEffect(() => { - sceneRef.current?.setTornFromServer(netTorn); - setTorn(netTorn); - }, [netTorn]); + useEffect(() => { sceneRef.current?.setTornFromServer(torn); }, [torn]); - const caught = stats?.caught ?? 0; useEffect(() => { const node = badgeRef.current; - if (!node || caught === 0) return; + if (!node || displayCaught === 0) return; node.classList.remove('bump'); void node.offsetWidth; node.classList.add('bump'); - }, [caught]); + }, [displayCaught]); const coins = stats?.coins ?? 0; const canAffordRepair = coins >= REPAIR_COST; const canAffordRepellent = coins >= REPELLENT_COST; - const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100); + const levelProgress = stats?.levelProgress ?? 0; const repellentUntil = stats?.repellentUntil ?? 0; const repellentRemainingMs = Math.max(0, repellentUntil - nowTs); const repellentActive = repellentRemainingMs > 0; - // Tik po sekundách pro odpočet plašiče (jen dokud běží) + // Tik po sekundách pro odpočty plašiče i protržené síťky (jen dokud běží) useEffect(() => { - if (repellentUntil <= Date.now()) return; + const until = Math.max(repellentUntil, netTornUntil); + if (until <= Date.now()) return; const id = window.setInterval(() => setNowTs(Date.now()), 1000); return () => window.clearInterval(id); - }, [repellentUntil]); + }, [repellentUntil, netTornUntil]); const onRepairClick = useCallback(async () => { const ok = await repair(); @@ -694,7 +846,7 @@ const FlyingButterflies: React.FC = ({ >
@@ -702,8 +854,8 @@ const FlyingButterflies: React.FC = ({ {stats ? `${stats.level}. ${stats.title}` : '…'} -
-
+
+
@@ -712,21 +864,26 @@ const FlyingButterflies: React.FC = ({ 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds} {repellentActive && ( - 🚫 plašič {formatRemaining(repellentRemainingMs)} + 🚫 {formatRemaining(repellentRemainingMs)} )}
{torn && ( - + <> +
+ 🕳️ Protržená síťka – sama se zašije za {formatRemaining(tornRemainingMs)} +
+ + )} {birds > 0 && !repellentActive && ( @@ -742,23 +899,14 @@ const FlyingButterflies: React.FC = ({ )}
- setLeaderboardOpen(false)} - myStats={stats} - /> + setLeaderboardOpen(false)} myStats={stats} /> )} ); }; -export const BUTTERFLY_PRESETS = { - LIGHT: 5, - NORMAL: 9, - HEAVY: 16, -} as const; - +export const BUTTERFLY_PRESETS = { LIGHT: 5, NORMAL: 9, HEAVY: 16 } as const; export const BUTTERFLY_COLOR_THEMES = { ALL: BUTTERFLY_VARIANTS, WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const, diff --git a/client/src/components/modals/ButterflyLeaderboardModal.tsx b/client/src/components/modals/ButterflyLeaderboardModal.tsx index 86b7175..de32bc6 100644 --- a/client/src/components/modals/ButterflyLeaderboardModal.tsx +++ b/client/src/components/modals/ButterflyLeaderboardModal.tsx @@ -46,6 +46,8 @@ export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }:
{myStats.coins}🪙 mincí
{myStats.waspsKilled}🪰 vos zabito
{myStats.birdsScared}🦅 vyplašeno
+
{myStats.thievesDefeated}🦹 zlodějů
+
{myStats.caterpillarsDefeated}🐛 housenek
)} diff --git a/client/src/hooks/useButterflyStats.ts b/client/src/hooks/useButterflyStats.ts index 196216b..742aeaa 100644 --- a/client/src/hooks/useButterflyStats.ts +++ b/client/src/hooks/useButterflyStats.ts @@ -7,18 +7,14 @@ import { killWasp as killWaspApi, buyRepellent as buyRepellentApi, reportNetTorn as reportTearApi, + reportRobbery as reportRobberyApi, + defeatThief as defeatThiefApi, + reportCaterpillarAte as caterpillarAteApi, + defeatCaterpillar as defeatCaterpillarApi, } from '../../../types'; -/** Starý klíč, pod kterým se počet chycených ukládal jen do localStorage. */ -const LEGACY_KEY = 'flyingButterfliesCaught'; -/** Příznak, že už proběhla jednorázová migrace localStorage → server. */ -const MIGRATED_KEY = 'flyingButterfliesMigrated'; /** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */ const FLUSH_DEBOUNCE_MS = 1500; -/** Server přijme max 100 motýlů na dávku – migraci proto posíláme po částech. */ -const MIGRATION_CHUNK = 100; -/** Bezpečnostní strop migrovaných úlovků (proti nesmyslným hodnotám v localStorage). */ -const MIGRATION_MAX = 1000; /** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */ const POLL_INTERVAL_MS = 30_000; @@ -33,12 +29,15 @@ export interface RewardEvent { } /** - * Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, - * jednorázovou migraci z localStorage, dávkové odesílání úlovků, hubení vos, - * plašič ptáků, protržení/opravu síťky a periodické přenačítání (růst škůdců). + * Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové + * odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a + * periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se + * zobrazuje jako serverová hodnota + zatím neodeslané úlovky. */ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { const [stats, setStats] = useState(); + /** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */ + const [pendingShown, setPendingShown] = useState(0); /** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */ const coinsRef = useRef(0); const pending = useRef({ normal: 0, golden: 0 }); @@ -46,18 +45,9 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { const onRewardRef = useRef(onReward); onRewardRef.current = onReward; - /** Zapíše statistiky ze serveru, ale nedovolí „couvnout" počtu chycených - * (kvůli optimistickému počítadlu, které může být napřed před flushem). */ const applyStats = useCallback((incoming: ButterflyStats) => { - setStats(prev => { - const merged: ButterflyStats = { ...incoming }; - if (prev && prev.caught > incoming.caught) { - merged.caught = prev.caught; - merged.goldenCaught = Math.max(prev.goldenCaught, incoming.goldenCaught); - } - coinsRef.current = merged.coins; - return merged; - }); + coinsRef.current = incoming.coins; + setStats(incoming); }, []); const flush = useCallback(async () => { @@ -66,12 +56,15 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { flushTimer.current = null; } const batch = pending.current; - if (batch.normal === 0 && batch.golden === 0) return; + const batchTotal = batch.normal + batch.golden; + if (batchTotal === 0) return; pending.current = { normal: 0, golden: 0 }; try { const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } }); if (res.data) { applyStats(res.data.stats); + // Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku + setPendingShown(p => Math.max(0, p - batchTotal)); onRewardRef.current?.({ coinsAwarded: res.data.coinsAwarded, dailyBonusApplied: res.data.dailyBonusApplied, @@ -80,8 +73,11 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { newLevel: res.data.stats.level, newTitle: res.data.stats.title, }); + } else { + setPendingShown(p => Math.max(0, p - batchTotal)); } } catch { + // Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený) pending.current.normal += batch.normal; pending.current.golden += batch.golden; } @@ -91,15 +87,12 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { const reportCatch = useCallback((golden: boolean) => { if (golden) pending.current.golden += 1; else pending.current.normal += 1; - setStats(prev => prev - ? { ...prev, caught: prev.caught + 1, goldenCaught: prev.goldenCaught + (golden ? 1 : 0) } - : prev); + setPendingShown(p => p + 1); if (!flushTimer.current) { flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS); } }, [flush]); - /** Zaplatí opravu síťky. Vrátí true při úspěchu, false při nedostatku mincí/chybě. */ const repair = useCallback(async (): Promise => { try { const res = await repairNetApi(); @@ -108,7 +101,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { return false; } }, [applyStats]); - /** Zahubí jednu vosu (plácačka). */ const killWasp = useCallback(async () => { try { const res = await killWaspApi(); @@ -116,7 +108,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { /* ignore */ } }, [applyStats]); - /** Koupí plašič ptáků. Vrátí true při úspěchu, false při nedostatku mincí. */ const buyRepellent = useCallback(async (): Promise => { try { const res = await buyRepellentApi(); @@ -125,7 +116,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { return false; } }, [applyStats]); - /** Nahlásí serveru, že pták protrhl síťku (perzistentně). */ const reportTear = useCallback(async () => { try { const res = await reportTearApi(); @@ -133,7 +123,38 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { /* ignore */ } }, [applyStats]); - /** Přenačte stav ze serveru (kvůli přibývajícím škůdcům). */ + /** Zloděj se dostal k penězům – server strhne podíl mincí. */ + const reportRobbery = useCallback(async () => { + try { + const res = await reportRobberyApi(); + if (res.data) applyStats(res.data); + } catch { /* ignore */ } + }, [applyStats]); + + /** Hráč porazil zloděje – odměna a statistika. */ + const defeatThief = useCallback(async () => { + try { + const res = await defeatThiefApi(); + if (res.data) applyStats(res.data); + } catch { /* ignore */ } + }, [applyStats]); + + /** Housenka se dostala k úlovkům – server sníží počet chycených. */ + const caterpillarAte = useCallback(async () => { + try { + const res = await caterpillarAteApi(); + if (res.data) applyStats(res.data); + } catch { /* ignore */ } + }, [applyStats]); + + /** Hráč porazil housenku – odměna a statistika. */ + const defeatCaterpillar = useCallback(async () => { + try { + const res = await defeatCaterpillarApi(); + if (res.data) applyStats(res.data); + } catch { /* ignore */ } + }, [applyStats]); + const refresh = useCallback(async () => { try { const res = await getButterflyStats(); @@ -141,29 +162,13 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { /* ignore */ } }, [applyStats]); - // Načtení stavu + jednorázová migrace z localStorage + // Načtení stavu useEffect(() => { let cancelled = false; (async () => { try { const res = await getButterflyStats(); - if (cancelled || !res.data) return; - let current = res.data; - try { - if (!localStorage.getItem(MIGRATED_KEY)) { - const legacy = Number(localStorage.getItem(LEGACY_KEY)); - let diff = Number.isFinite(legacy) ? Math.min(legacy - current.caught, MIGRATION_MAX) : 0; - while (diff > 0 && !cancelled) { - const chunk = Math.min(diff, MIGRATION_CHUNK); - const migRes = await catchButterflies({ body: { normal: chunk, golden: 0 } }); - if (migRes.data) current = migRes.data.stats; - diff -= chunk; - } - localStorage.setItem(MIGRATED_KEY, '1'); - localStorage.removeItem(LEGACY_KEY); - } - } catch { /* localStorage nedostupné – migraci přeskočíme */ } - if (!cancelled) applyStats(current); + if (!cancelled && res.data) applyStats(res.data); } catch { /* server nedostupný – hra jede dál bez perzistence */ } })(); return () => { cancelled = true; }; @@ -193,5 +198,10 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { }; }, [flush]); - return { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear }; + const displayCaught = (stats?.caught ?? 0) + pendingShown; + + return { + stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, + reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar, + }; } diff --git a/server/changelogs/2026-07-22.json b/server/changelogs/2026-07-22.json index da8debc..e2edb67 100644 --- a/server/changelogs/2026-07-22.json +++ b/server/changelogs/2026-07-22.json @@ -3,8 +3,15 @@ "Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům", "Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu", "Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat", - "Ptáci přibývají a trhají síťku (oprava za mince); můžeš si koupit plašič, který je na chvíli vyžene", + "Ptáci přibývají a trhají síťku (protrhnou ji vždy); protržená síťka se sama zašije za minutu, nebo si připlatíš za okamžitou opravu", + "Můžeš si koupit plašič, který ptáky na chvíli vyžene", "Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky", "Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo", + "Hra je celkově těžší: mnohem víc úrovní se strmější křivkou, dražší vylepšení a vzácnější zlatý motýl", + "Ptáci teď aktivně nalétávají na síťku a škůdců je víc", + "Občas přijde zloděj, který se plíží k tvým mincím – zaklikej ho, než ti je ukradne (chce to hodně ran)", + "Pozor i na housenku, která se plíží k úlovkům a umí ti sežrat nachytané motýly (i snížit úroveň)", + "Přidána ochrana proti podvádění (zmenšování okna) – rychlost chytání je omezená", + "Nová sezóna: statistiky začínají férově od nuly pro všechny", "Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka" ] diff --git a/server/src/butterflies.ts b/server/src/butterflies.ts index e9a0a9d..d70ebfd 100644 --- a/server/src/butterflies.ts +++ b/server/src/butterflies.ts @@ -22,18 +22,29 @@ interface StoredButterflyStats { waspsKilled: number; /** Celkový počet vyplašených ptáků */ birdsScared: number; + /** Celkový počet poražených zlodějů */ + thievesDefeated: number; + /** Celkový počet poražených housenek */ + caterpillarsDefeated: number; /** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */ repellentUntil: number; - /** Zda je síťka protržená */ - netTorn: boolean; + /** Do kdy je síťka protržená (ms epoch); po uplynutí se sama zašije. 0 = celá */ + netTornUntil: number; /** Kdy naposledy „dorostla" vosa (ms epoch) */ lastWaspAt: number; /** Kdy naposledy „dorostl" pták (ms epoch) */ lastBirdAt: number; + + // --- Ochrana proti podvádění (rychlost chytání) --- + /** Začátek aktuálního minutového okna pro počítadlo úlovků (ms epoch) */ + catchWindowStart: number; + /** Počet započtených úlovků v aktuálním okně */ + catchWindowCount: number; } const storage = getStorage(); -const STORAGE_KEY = 'butterflyStats'; +// Sezóna 2 – nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry) +const STORAGE_KEY = 'butterflyStats_s2'; // --- Herní konstanty --------------------------------------------------------- @@ -42,64 +53,117 @@ export const COIN_BASE = 1; /** Mince za jednoho vzácného zlatého motýla */ export const GOLD_VALUE = 25; /** Cena zašití protržené síťky v mincích */ -export const REPAIR_COST = 10; +export const REPAIR_COST = 20; /** Bonus mincí za první úlovek dne */ -export const DAILY_BONUS = 10; +export const DAILY_BONUS = 5; /** Po každých kolika chycených se odemkne prémiová síťka */ -export const PREMIUM_MILESTONE = 50; +export const PREMIUM_MILESTONE = 75; /** Maximální počet současně poletujících vos */ -export const WASP_MAX = 5; +export const WASP_MAX = 6; /** Maximální počet současně poletujících ptáků */ -export const BIRD_MAX = 4; +export const BIRD_MAX = 5; /** Jak často (ms) přibude jedna vosa */ export const WASP_GROWTH_MS = 30_000; /** Jak často (ms) přibude jeden pták */ -export const BIRD_GROWTH_MS = 120_000; +export const BIRD_GROWTH_MS = 60_000; /** Cena plašiče ptáků v mincích */ -export const REPELLENT_COST = 25; +export const REPELLENT_COST = 60; /** Jak dlouho (ms) plašič ptáků drží ptáky pryč */ -export const REPELLENT_DURATION_MS = 180_000; +export const REPELLENT_DURATION_MS = 120_000; +/** Za jak dlouho (ms) se protržená síťka sama zašije */ +export const AUTO_REPAIR_MS = 60_000; +/** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (0–1) */ +export const ROBBERY_FRACTION = 0.85; +/** Odměna v mincích za poražení zloděje */ +export const THIEF_REWARD = 20; +/** Kolik zásahů (kliknutí) zloděj vydrží */ +export const THIEF_HP = 22; +/** Kolik nachytaných motýlů sežere housenka, když se dostane k úlovkům */ +export const CATERPILLAR_EATS = 20; +/** Odměna v mincích za poražení housenky */ +export const CATERPILLAR_REWARD = 15; +/** Kolik zásahů (kliknutí) housenka vydrží */ +export const CATERPILLAR_HP = 18; /** Od jaké velikosti dávky se začíná počítat kombo bonus */ -const COMBO_THRESHOLD = 3; +const COMBO_THRESHOLD = 4; /** Horní strop komba, aby dávka nedala nesmyslně moc mincí */ -const COMBO_CAP = 20; +const COMBO_CAP = 8; /** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */ const MAX_BATCH = 100; +/** Ochrana proti podvádění: max započtených úlovků za minutu (nadbytek se zahodí) */ +export const RATE_CAP_PER_MIN = 90; +/** Délka okna pro rate-cap (ms) */ +const RATE_WINDOW_MS = 60_000; + /** - * Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených, - * od které úroveň platí. Pole je vzestupně dle `min`. + * Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami + * (prestiž), takže postup nikdy „nedojde". */ -const LEVELS: { min: number; title: string }[] = [ - { min: 0, title: 'Začátečník se síťkou' }, - { min: 10, title: 'Nedělní chytač' }, - { min: 30, title: 'Lovec luk' }, - { min: 60, title: 'Sběratel křídel' }, - { min: 120, title: 'Mistr síťky' }, - { min: 200, title: 'Motýlí šeptač' }, - { min: 350, title: 'Legendární entomolog' }, - { min: 600, title: 'Vládce louky' }, +const LEVEL_TITLES = [ + 'Začátečník se síťkou', // 1 + 'Nedělní chytač', // 2 + 'Lovec luk', // 3 + 'Sběratel křídel', // 4 + 'Průzkumník louky', // 5 + 'Mistr síťky', // 6 + 'Motýlí stopař', // 7 + 'Zaklínač křídel', // 8 + 'Motýlí šeptač', // 9 + 'Kurátor motýlů', // 10 + 'Amatérský entomolog', // 11 + 'Legendární entomolog', // 12 + 'Strážce louky', // 13 + 'Vládce louky', // 14 + 'Motýlí velmistr', // 15 + 'Duch luk', // 16 + 'Motýlí legenda', // 17 + 'Nebeský lovec', // 18 + 'Motýlí božstvo', // 19 + 'Pán všech křídel', // 20 ]; +/** Nejvyšší dosažitelná úroveň */ +export const MAX_LEVEL = 60; // --- Čisté pomocné funkce ---------------------------------------------------- +/** + * Kolik celkem chycených je potřeba k dosažení úrovně `n` (1-based). + * Úroveň 1 = 0. Křivka roste mocninně (vyšší úrovně jsou výrazně těžší). + */ +export function levelThreshold(n: number): number { + if (n <= 1) return 0; + return Math.round(6 * Math.pow(n - 1, 2.15)); +} + /** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */ export function levelForCaught(caught: number): number { let level = 1; - for (let i = 0; i < LEVELS.length; i++) { - if (caught >= LEVELS[i].min) { - level = i + 1; - } + while (level < MAX_LEVEL && caught >= levelThreshold(level + 1)) { + level++; } return level; } /** Vrátí titul odpovídající dané úrovni (1-based). */ export function titleForLevel(level: number): string { - const idx = Math.min(Math.max(level - 1, 0), LEVELS.length - 1); - return LEVELS[idx].title; + if (level <= LEVEL_TITLES.length) { + return LEVEL_TITLES[Math.max(level - 1, 0)]; + } + const stars = '★'.repeat(Math.min(level - LEVEL_TITLES.length, 10)); + return `${LEVEL_TITLES[LEVEL_TITLES.length - 1]} ${stars}`; +} + +/** Postup v rámci aktuální úrovně (0–100 %). Na maximální úrovni 100. */ +export function levelProgress(caught: number): number { + const level = levelForCaught(caught); + if (level >= MAX_LEVEL) return 100; + const start = levelThreshold(level); + const end = levelThreshold(level + 1); + if (end <= start) return 100; + return Math.max(0, Math.min(100, Math.round(((caught - start) / (end - start)) * 100))); } /** Bonus mincí za kombo (dávku chycenou najednou). */ @@ -115,8 +179,10 @@ function defaultStats(now: number): StoredButterflyStats { return { caught: 0, coins: 0, goldenCaught: 0, wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0, - repellentUntil: 0, netTorn: false, + thievesDefeated: 0, caterpillarsDefeated: 0, + repellentUntil: 0, netTornUntil: 0, lastWaspAt: now, lastBirdAt: now, + catchWindowStart: now, catchWindowCount: 0, }; } @@ -131,10 +197,14 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats { birds: s.birds ?? 0, waspsKilled: s.waspsKilled ?? 0, birdsScared: s.birdsScared ?? 0, + thievesDefeated: s.thievesDefeated ?? 0, + caterpillarsDefeated: s.caterpillarsDefeated ?? 0, repellentUntil: s.repellentUntil ?? 0, - netTorn: s.netTorn ?? false, + netTornUntil: s.netTornUntil ?? 0, lastWaspAt: s.lastWaspAt ?? now, lastBirdAt: s.lastBirdAt ?? now, + catchWindowStart: s.catchWindowStart ?? now, + catchWindowCount: s.catchWindowCount ?? 0, }; } @@ -143,6 +213,9 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats { * razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič. */ 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; + // Vosy if (s.wasps < WASP_MAX) { const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS); @@ -154,18 +227,19 @@ export function growPests(s: StoredButterflyStats, now: number): void { s.lastWaspAt = now; } - // Ptáci – jen když neběží plašič + // Ptáci – jen když neběží plašič. Během plašiče se lastBirdAt nemění (byl + // nastaven na konec plašiče při koupi), takže po vypršení růst začne od té chvíle. const repellentActive = s.repellentUntil > now; - if (repellentActive) { - s.lastBirdAt = now; - } else if (s.birds < BIRD_MAX) { - const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS); - if (add > 0) { - s.birds = Math.min(s.birds + add, BIRD_MAX); - s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS; + if (!repellentActive) { + if (s.birds < BIRD_MAX) { + const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS); + if (add > 0) { + s.birds = Math.min(s.birds + add, BIRD_MAX); + s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS; + } + } else { + s.lastBirdAt = now; } - } else { - s.lastBirdAt = now; } } @@ -178,13 +252,16 @@ function toDto(s: StoredButterflyStats): ButterflyStats { goldenCaught: s.goldenCaught, level, title: titleForLevel(level), + levelProgress: levelProgress(s.caught), lastCatchDay: s.lastCatchDay, wasps: s.wasps, birds: s.birds, waspsKilled: s.waspsKilled, birdsScared: s.birdsScared, + thievesDefeated: s.thievesDefeated, + caterpillarsDefeated: s.caterpillarsDefeated, repellentUntil: s.repellentUntil, - netTorn: s.netTorn, + netTornUntil: s.netTornUntil, }; } @@ -247,16 +324,28 @@ export async function recordCatches(login: string, normal: number, golden: numbe const oldCaught = s.caught; const oldLevel = levelForCaught(oldCaught); - dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today; + // Ochrana proti podvádění: v minutovém okně se započítá max RATE_CAP_PER_MIN + // úlovků, zbytek se tiše zahodí (nezapočte se ani mince). Přednost mají zlatí. + if (now - s.catchWindowStart >= RATE_WINDOW_MS) { + s.catchWindowStart = now; + s.catchWindowCount = 0; + } + const allowance = Math.max(0, RATE_CAP_PER_MIN - s.catchWindowCount); + const creditGolden = Math.min(g, allowance); + const creditNormal = Math.min(n, Math.max(0, allowance - creditGolden)); + const credited = creditGolden + creditNormal; + s.catchWindowCount += credited; + + dailyBonusApplied = credited > 0 && s.lastCatchDay !== today; const daily = dailyBonusApplied ? DAILY_BONUS : 0; - coinsAwarded = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily; + coinsAwarded = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily; - const newCaught = oldCaught + batchTotal; + const newCaught = oldCaught + credited; s.caught = newCaught; s.coins += coinsAwarded; - s.goldenCaught += g; - if (batchTotal > 0) s.lastCatchDay = today; + s.goldenCaught += creditGolden; + if (credited > 0) s.lastCatchDay = today; leveledUp = levelForCaught(newCaught) > oldLevel; premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE); @@ -266,18 +355,19 @@ export async function recordCatches(login: string, normal: number, golden: numbe } /** - * Zašije protrženou síťku za mince. Vyhodí {@link InsufficientCoinsError}, pokud - * uživatel nemá dost mincí. Když síťka není protržená, jen vrátí aktuální stav. + * Okamžitě zašije protrženou síťku za mince (volitelné – jinak se zašije sama). + * Vyhodí {@link InsufficientCoinsError} při nedostatku mincí. Když síťka není + * protržená, jen vrátí aktuální stav. */ export async function repairNet(login: string, now: number = Date.now()): Promise { let failed = false; const mine = await mutateUser(login, now, (s) => { - if (!s.netTorn) return; + if (s.netTornUntil <= now) return; // není protržená (nebo se už zašila) if (s.coins < REPAIR_COST) { failed = true; return; } s.coins -= REPAIR_COST; - s.netTorn = false; + s.netTornUntil = 0; }); - if (failed) throw new InsufficientCoinsError('Nedostatek mincí na opravu síťky'); + if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky'); return toDto(mine); } @@ -304,15 +394,55 @@ export async function buyRepellent(login: string, now: number = Date.now()): Pro s.birdsScared += s.birds; s.birds = 0; s.repellentUntil = now + REPELLENT_DURATION_MS; - s.lastBirdAt = now; + // Růst ptáků se rozběhne až od konce plašiče + s.lastBirdAt = s.repellentUntil; }); if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků'); return toDto(mine); } -/** Zaznamená, že pták protrhl síťku (perzistentně). */ +/** + * Zaznamená, že pták protrhl síťku (perzistentně). Síťka se sama zašije za + * AUTO_REPAIR_MS. Když už protržená je, běžící odpočet se neprodlužuje. + */ export async function reportNetTorn(login: string, now: number = Date.now()): Promise { - const mine = await mutateUser(login, now, (s) => { s.netTorn = true; }); + const mine = await mutateUser(login, now, (s) => { + if (s.netTornUntil <= now) s.netTornUntil = now + AUTO_REPAIR_MS; + }); + return toDto(mine); +} + +/** Zloděj se dostal k penězům a ukradl podíl mincí. */ +export async function robbery(login: string, now: number = Date.now()): Promise { + const mine = await mutateUser(login, now, (s) => { + s.coins = Math.round(s.coins * (1 - ROBBERY_FRACTION)); + }); + return toDto(mine); +} + +/** Hráč porazil zloděje – malá odměna a statistika. */ +export async function defeatThief(login: string, now: number = Date.now()): Promise { + const mine = await mutateUser(login, now, (s) => { + s.thievesDefeated += 1; + s.coins += THIEF_REWARD; + }); + return toDto(mine); +} + +/** Housenka se dostala k úlovkům a sežrala část nachytaných motýlů (může snížit úroveň). */ +export async function caterpillarAte(login: string, now: number = Date.now()): Promise { + const mine = await mutateUser(login, now, (s) => { + s.caught = Math.max(0, s.caught - CATERPILLAR_EATS); + }); + return toDto(mine); +} + +/** Hráč porazil housenku – malá odměna a statistika. */ +export async function defeatCaterpillar(login: string, now: number = Date.now()): Promise { + const mine = await mutateUser(login, now, (s) => { + s.caterpillarsDefeated += 1; + s.coins += CATERPILLAR_REWARD; + }); return toDto(mine); } diff --git a/server/src/routes/butterflyRoutes.ts b/server/src/routes/butterflyRoutes.ts index 96d6d40..3476693 100644 --- a/server/src/routes/butterflyRoutes.ts +++ b/server/src/routes/butterflyRoutes.ts @@ -8,6 +8,10 @@ import { killWasp, buyRepellent, reportNetTorn, + robbery, + defeatThief, + caterpillarAte, + defeatCaterpillar, getLeaderboard, InsufficientCoinsError, } from "../butterflies"; @@ -76,6 +80,38 @@ router.post("/tear", async (req: Request, res, next) => { } catch (e: any) { next(e) } }); +router.post("/robbery", async (req: Request, res, next) => { + try { + const login = getLogin(parseToken(req)); + const data = await robbery(login); + res.status(200).json(data); + } catch (e: any) { next(e) } +}); + +router.post("/defeatThief", async (req: Request, res, next) => { + try { + const login = getLogin(parseToken(req)); + const data = await defeatThief(login); + res.status(200).json(data); + } catch (e: any) { next(e) } +}); + +router.post("/caterpillarAte", async (req: Request, res, next) => { + try { + const login = getLogin(parseToken(req)); + const data = await caterpillarAte(login); + res.status(200).json(data); + } catch (e: any) { next(e) } +}); + +router.post("/defeatCaterpillar", async (req: Request, res, next) => { + try { + const login = getLogin(parseToken(req)); + const data = await defeatCaterpillar(login); + res.status(200).json(data); + } catch (e: any) { next(e) } +}); + router.get("/leaderboard", async (req: Request, res, next) => { try { getLogin(parseToken(req)); diff --git a/server/src/tests/butterflies.test.ts b/server/src/tests/butterflies.test.ts index 775bc98..647b86a 100644 --- a/server/src/tests/butterflies.test.ts +++ b/server/src/tests/butterflies.test.ts @@ -7,9 +7,15 @@ import { killWasp, buyRepellent, reportNetTorn, + robbery, + defeatThief, + caterpillarAte, + defeatCaterpillar, getLeaderboard, growPests, levelForCaught, + levelThreshold, + levelProgress, titleForLevel, comboBonus, InsufficientCoinsError, @@ -24,11 +30,20 @@ import { BIRD_GROWTH_MS, REPELLENT_COST, REPELLENT_DURATION_MS, + RATE_CAP_PER_MIN, + MAX_LEVEL, + ROBBERY_FRACTION, + THIEF_REWARD, + CATERPILLAR_EATS, + CATERPILLAR_REWARD, + AUTO_REPAIR_MS, } from '../butterflies'; import { formatDate } from '../utils'; const USER = 'tomas'; const OTHER = 'petr'; +/** Storage klíč sezóny 2 (musí odpovídat STORAGE_KEY v butterflies.ts). */ +const KEY = 'butterflyStats_s2'; beforeEach(() => { resetMemoryStorage(); @@ -37,35 +52,58 @@ beforeEach(() => { /** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */ async function seed(login: string, data: any) { const storage = getStorage(); - const all = (await storage.getData('butterflyStats')) ?? {}; + const all = (await storage.getData(KEY)) ?? {}; all[login] = data; - await storage.setData('butterflyStats', all); + await storage.setData(KEY, all); } -describe('levelForCaught / titleForLevel', () => { +describe('úrovně', () => { test('začíná na úrovni 1', () => { expect(levelForCaught(0)).toBe(1); expect(titleForLevel(1)).toBe('Začátečník se síťkou'); }); - test('roste s počtem chycených', () => { - expect(levelForCaught(9)).toBe(1); - expect(levelForCaught(10)).toBe(2); - expect(levelForCaught(30)).toBe(3); - expect(levelForCaught(10000)).toBe(8); + test('křivka je rostoucí a stále strmější', () => { + expect(levelThreshold(1)).toBe(0); + expect(levelThreshold(2)).toBeGreaterThan(0); + // každý další skok je větší než ten předchozí (těžší postup) + const d1 = levelThreshold(3) - levelThreshold(2); + const d2 = levelThreshold(4) - levelThreshold(3); + const d3 = levelThreshold(20) - levelThreshold(19); + expect(d2).toBeGreaterThan(d1); + expect(d3).toBeGreaterThan(d2); + }); + + test('levelForCaught respektuje prahy a strop', () => { + expect(levelForCaught(levelThreshold(2) - 1)).toBe(1); + expect(levelForCaught(levelThreshold(2))).toBe(2); + expect(levelForCaught(levelThreshold(5))).toBe(5); + expect(levelForCaught(100_000_000)).toBe(MAX_LEVEL); + }); + + test('titul nad rámec seznamu dostane hvězdičky (prestiž)', () => { + expect(titleForLevel(MAX_LEVEL)).toContain('★'); + }); + + test('levelProgress je 0–100 a na maximu 100', () => { + expect(levelProgress(0)).toBe(0); + const p = levelProgress(Math.round((levelThreshold(2) + levelThreshold(3)) / 2)); + expect(p).toBeGreaterThan(0); + expect(p).toBeLessThan(100); + expect(levelProgress(100_000_000)).toBe(100); }); }); describe('comboBonus', () => { test('malá dávka nedává kombo', () => { expect(comboBonus(1)).toBe(0); - expect(comboBonus(2)).toBe(0); + expect(comboBonus(3)).toBe(0); // pod prahem 4 }); test('větší dávka dává rostoucí bonus se stropem', () => { - expect(comboBonus(3)).toBe(1); - expect(comboBonus(4)).toBe(2); - expect(comboBonus(1000)).toBe(20); // COMBO_CAP + expect(comboBonus(4)).toBe(1); + expect(comboBonus(5)).toBe(2); + expect(comboBonus(1000)).toBe(8); // COMBO_CAP }); }); @@ -77,13 +115,14 @@ describe('getStats', () => { expect(stats.goldenCaught).toBe(0); expect(stats.level).toBe(1); expect(stats.title).toBe('Začátečník se síťkou'); + expect(stats.wasps).toBe(0); + expect(stats.birds).toBe(0); }); }); describe('recordCatches – mince', () => { - test('běžný motýl dá základní minci', async () => { + test('běžný motýl dá základní minci + denní bonus', async () => { const result = await recordCatches(USER, 1, 0); - // 1 běžný + první úlovek dne => denní bonus expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS); expect(result.dailyBonusApplied).toBe(true); expect(result.stats.caught).toBe(1); @@ -91,7 +130,6 @@ describe('recordCatches – mince', () => { }); test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => { - // předvyplníme dnešní den, aby nezasáhl denní bonus await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) }); const result = await recordCatches(USER, 0, 1); expect(result.coinsAwarded).toBe(GOLD_VALUE); @@ -102,14 +140,34 @@ describe('recordCatches – mince', () => { test('kombo se připočítá u větší dávky', async () => { await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) }); - const result = await recordCatches(USER, 4, 0); - // 4 * COIN_BASE + comboBonus(4)=2 - expect(result.coinsAwarded).toBe(4 * COIN_BASE + 2); + const result = await recordCatches(USER, 5, 0); + expect(result.coinsAwarded).toBe(5 * COIN_BASE + comboBonus(5)); }); - test('sanity limit ořízne absurdní dávku a odmítne záporné', async () => { - const result = await recordCatches(USER, 100000, -5); - expect(result.stats.caught).toBe(100); // MAX_BATCH + test('sanity limit + rate cap ořízne absurdní dávku', async () => { + const result = await recordCatches(USER, 100000, 0); + // MAX_BATCH=100, ale rate cap povolí jen RATE_CAP_PER_MIN za minutu + expect(result.stats.caught).toBe(RATE_CAP_PER_MIN); + }); +}); + +describe('recordCatches – rate cap (ochrana proti podvádění)', () => { + test('v jednom okně se započte max RATE_CAP_PER_MIN, zbytek se zahodí', async () => { + const t0 = 1_000_000; + const a = await recordCatches(USER, 60, 0, t0); + expect(a.stats.caught).toBe(60); + const b = await recordCatches(USER, 60, 0, t0 + 1000); + expect(b.stats.caught).toBe(RATE_CAP_PER_MIN); // 60 + 30 + const c = await recordCatches(USER, 20, 0, t0 + 2000); + expect(c.stats.caught).toBe(RATE_CAP_PER_MIN); // už nic nepřibude + expect(c.coinsAwarded).toBe(0); + }); + + test('po uplynutí okna se limit resetuje', async () => { + const t0 = 2_000_000; + await recordCatches(USER, RATE_CAP_PER_MIN, 0, t0); + const later = await recordCatches(USER, 10, 0, t0 + 61_000); + expect(later.stats.caught).toBe(RATE_CAP_PER_MIN + 10); }); }); @@ -131,19 +189,14 @@ describe('recordCatches – denní bonus', () => { describe('recordCatches – progres a milníky', () => { test('leveledUp se nastaví při překročení hranice úrovně', async () => { - await seed(USER, { caught: 9, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) }); - const result = await recordCatches(USER, 1, 0); // 9 -> 10 => úroveň 2 + await seed(USER, { caught: levelThreshold(2) - 1, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) }); + const result = await recordCatches(USER, 1, 0); expect(result.leveledUp).toBe(true); expect(result.stats.level).toBe(2); }); test('premiumUnlocked se nastaví při překročení milníku', async () => { - await seed(USER, { - caught: PREMIUM_MILESTONE - 1, - coins: 0, - goldenCaught: 0, - lastCatchDay: formatDate(new Date()), - }); + await seed(USER, { caught: PREMIUM_MILESTONE - 1, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) }); const result = await recordCatches(USER, 1, 0); expect(result.premiumUnlocked).toBe(true); }); @@ -155,30 +208,6 @@ describe('recordCatches – progres a milníky', () => { }); }); -describe('repairNet', () => { - test('odečte cenu opravy při dostatku mincí a zruší protržení', async () => { - await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTorn: true }); - const stats = await repairNet(USER); - expect(stats.coins).toBe(5); - expect(stats.netTorn).toBe(false); - }); - - test('vyhodí chybu při nedostatku mincí', async () => { - await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTorn: true }); - await expect(repairNet(USER)).rejects.toThrow(InsufficientCoinsError); - // mince zůstanou nezměněné a síťka stále protržená - const stats = await getStats(USER); - expect(stats.coins).toBe(REPAIR_COST - 1); - expect(stats.netTorn).toBe(true); - }); - - test('neúčtuje, když síťka není protržená', async () => { - await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTorn: false }); - const stats = await repairNet(USER); - expect(stats.coins).toBe(50); - }); -}); - describe('growPests', () => { test('vosy přibývají v čase do maxima', () => { const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 }; @@ -202,6 +231,12 @@ describe('growPests', () => { growPests(s, now + 3 * BIRD_GROWTH_MS); expect(s.birds).toBe(0); }); + + test('ptáci přibývají do maxima', () => { + const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 }; + growPests(s, 1_000_000 * BIRD_GROWTH_MS); + expect(s.birds).toBe(BIRD_MAX); + }); }); describe('killWasp', () => { @@ -235,15 +270,99 @@ describe('buyRepellent', () => { await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 }); await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError); }); + + test('po vypršení plašiče ptáci zase přibývají', async () => { + const t0 = 10_000_000; + await seed(USER, { caught: 0, coins: REPELLENT_COST, goldenCaught: 0, birds: 0, lastBirdAt: t0 }); + await buyRepellent(USER, t0); + // dost dlouho po vypršení plašiče + const stats = await getStats(USER, t0 + REPELLENT_DURATION_MS + 2 * BIRD_GROWTH_MS + 1); + expect(stats.birds).toBeGreaterThanOrEqual(2); + }); }); describe('reportNetTorn', () => { - test('nastaví protrženou síťku (perzistentně)', async () => { - await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 }); - const stats = await reportNetTorn(USER); - expect(stats.netTorn).toBe(true); - // stav přežije další čtení (F5) - expect((await getStats(USER)).netTorn).toBe(true); + test('protrhne síťku na dobu auto-opravy', async () => { + const now = 1_000_000; + await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: 0 }); + const stats = await reportNetTorn(USER, now); + expect(stats.netTornUntil).toBe(now + AUTO_REPAIR_MS); + }); + + test('opakované protržení neprodlouží běžící odpočet', async () => { + const now = 1_000_000; + await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 40_000 }); + const stats = await reportNetTorn(USER, now); + expect(stats.netTornUntil).toBe(now + 40_000); + }); + + test('sama se zašije po uplynutí odpočtu', async () => { + const now = 1_000_000; + await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 1000 }); + const stats = await getStats(USER, now + 2000); + expect(stats.netTornUntil).toBe(0); + }); +}); + +describe('repairNet', () => { + test('okamžitá oprava odečte cenu a zašije síťku', async () => { + const now = 1_000_000; + await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTornUntil: now + 30_000 }); + const stats = await repairNet(USER, now); + expect(stats.coins).toBe(5); + expect(stats.netTornUntil).toBe(0); + }); + + test('bez mincí okamžitá oprava selže (ale síťka se sama zašije)', async () => { + const now = 1_000_000; + await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTornUntil: now + 30_000 }); + await expect(repairNet(USER, now)).rejects.toThrow(InsufficientCoinsError); + const stats = await getStats(USER, now); + expect(stats.coins).toBe(REPAIR_COST - 1); + expect(stats.netTornUntil).toBeGreaterThan(now); + }); + + test('neúčtuje, když síťka není protržená', async () => { + const now = 1_000_000; + await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTornUntil: 0 }); + const stats = await repairNet(USER, now); + expect(stats.coins).toBe(50); + }); +}); + +describe('zloděj', () => { + test('okradení sebere podíl mincí', async () => { + await seed(USER, { caught: 0, coins: 100, goldenCaught: 0 }); + const stats = await robbery(USER); + expect(stats.coins).toBe(Math.round(100 * (1 - ROBBERY_FRACTION))); + }); + + test('poražení zloděje dá odměnu a zvýší statistiku', async () => { + await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 }); + const stats = await defeatThief(USER); + expect(stats.coins).toBe(10 + THIEF_REWARD); + expect(stats.thievesDefeated).toBe(1); + }); +}); + +describe('housenka', () => { + test('sežere část úlovků (nikdy pod nulu) a může snížit úroveň', async () => { + await seed(USER, { caught: CATERPILLAR_EATS + 5, coins: 0, goldenCaught: 0 }); + const stats = await caterpillarAte(USER); + expect(stats.caught).toBe(5); + }); + + test('nesnese caught pod nulu', async () => { + await seed(USER, { caught: 3, coins: 0, goldenCaught: 0 }); + const stats = await caterpillarAte(USER); + expect(stats.caught).toBe(0); + }); + + test('poražení housenky dá odměnu a zvýší statistiku', async () => { + await seed(USER, { caught: 0, coins: 5, goldenCaught: 0 }); + const stats = await defeatCaterpillar(USER); + expect(stats.coins).toBe(5 + CATERPILLAR_REWARD); + expect(stats.caterpillarsDefeated).toBe(1); }); }); diff --git a/types/api.yml b/types/api.yml index adced24..b27566b 100644 --- a/types/api.yml +++ b/types/api.yml @@ -94,6 +94,14 @@ paths: $ref: "./paths/butterflies/repellent.yml" /butterflies/tear: $ref: "./paths/butterflies/tear.yml" + /butterflies/robbery: + $ref: "./paths/butterflies/robbery.yml" + /butterflies/defeatThief: + $ref: "./paths/butterflies/defeatThief.yml" + /butterflies/caterpillarAte: + $ref: "./paths/butterflies/caterpillarAte.yml" + /butterflies/defeatCaterpillar: + $ref: "./paths/butterflies/defeatCaterpillar.yml" /butterflies/leaderboard: $ref: "./paths/butterflies/leaderboard.yml" diff --git a/types/paths/butterflies/caterpillarAte.yml b/types/paths/butterflies/caterpillarAte.yml new file mode 100644 index 0000000..56c2a60 --- /dev/null +++ b/types/paths/butterflies/caterpillarAte.yml @@ -0,0 +1,10 @@ +post: + operationId: reportCaterpillarAte + summary: Housenka se dostala k úlovkům a sežrala část nachytaných motýlů. + responses: + "200": + description: Aktualizované statistiky po sežrání úlovků. + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/ButterflyStats" diff --git a/types/paths/butterflies/defeatCaterpillar.yml b/types/paths/butterflies/defeatCaterpillar.yml new file mode 100644 index 0000000..2eeab00 --- /dev/null +++ b/types/paths/butterflies/defeatCaterpillar.yml @@ -0,0 +1,10 @@ +post: + operationId: defeatCaterpillar + summary: Hráč porazil housenku – malá odměna a statistika. + responses: + "200": + description: Aktualizované statistiky po poražení housenky. + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/ButterflyStats" diff --git a/types/paths/butterflies/defeatThief.yml b/types/paths/butterflies/defeatThief.yml new file mode 100644 index 0000000..cef2151 --- /dev/null +++ b/types/paths/butterflies/defeatThief.yml @@ -0,0 +1,10 @@ +post: + operationId: defeatThief + summary: Hráč porazil zloděje – malá odměna a statistika. + responses: + "200": + description: Aktualizované statistiky po poražení zloděje. + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/ButterflyStats" diff --git a/types/paths/butterflies/robbery.yml b/types/paths/butterflies/robbery.yml new file mode 100644 index 0000000..2099725 --- /dev/null +++ b/types/paths/butterflies/robbery.yml @@ -0,0 +1,10 @@ +post: + operationId: reportRobbery + summary: Zloděj se dostal k penězům a ukradl podíl mincí. + responses: + "200": + description: Aktualizované statistiky po okradení. + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/ButterflyStats" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 464753c..bb03607 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -938,12 +938,15 @@ ButterflyStats: - goldenCaught - level - title + - levelProgress - wasps - birds - waspsKilled - birdsScared + - thievesDefeated + - caterpillarsDefeated - repellentUntil - - netTorn + - netTornUntil properties: caught: description: Celkový počet chycených motýlů @@ -960,6 +963,9 @@ ButterflyStats: title: description: Titul odpovídající aktuální úrovni type: string + levelProgress: + description: Postup v rámci aktuální úrovně v procentech (0–100) + type: integer lastCatchDay: description: Datum posledního úlovku (YYYY-MM-DD) pro denní bonus type: string @@ -975,12 +981,18 @@ ButterflyStats: birdsScared: description: Celkový počet vyplašených ptáků (plašičem) type: integer + thievesDefeated: + description: Celkový počet poražených zlodějů + type: integer + caterpillarsDefeated: + description: Celkový počet poražených housenek + type: integer repellentUntil: description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní type: integer - netTorn: - description: Zda je síťka aktuálně protržená (nutná placená oprava) - type: boolean + netTornUntil: + description: Časové razítko (ms epoch), do kdy je síťka protržená; po uplynutí se sama zašije. 0 = celá + type: integer ButterflyCatchRequest: description: Dávka nachytaných motýlů k zaznamenání type: object