Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f439bde0 | ||
|
|
c5ca6c9d41 | ||
|
|
c8c5ecc60c | ||
|
|
146c31b775
|
||
|
|
0232e6ea26
|
||
|
|
87c12501bb
|
||
|
|
56ceb6e3cb
|
||
|
|
e1ada97184 | ||
|
|
9ea2916c7a | ||
|
|
f8c5d28f47 | ||
|
|
12fa87f3ad | ||
|
|
a708eda421
|
||
|
|
464b14d63a
|
||
|
|
9a03dd1e5e
|
||
|
|
c3a8900283
|
@@ -10,6 +10,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@sentry/react": "^10.65.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Vosa letící doprava (JS překlápí podle směru) -->
|
||||
<!-- křídla -->
|
||||
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
|
||||
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
|
||||
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
|
||||
</g>
|
||||
<!-- žihadlo -->
|
||||
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
|
||||
<!-- tělo -->
|
||||
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
|
||||
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
|
||||
<!-- pruhy -->
|
||||
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
|
||||
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
|
||||
<!-- hlava -->
|
||||
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
|
||||
</g>
|
||||
<!-- oko a tykadla -->
|
||||
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
|
||||
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
|
||||
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
|
||||
<path d="M78,42 C84,36 88,36 90,38"/>
|
||||
<path d="M80,44 C86,40 90,41 92,43"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Pták letící doprava (JS překlápí podle směru) -->
|
||||
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
|
||||
<!-- ocas -->
|
||||
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
|
||||
<!-- tělo -->
|
||||
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
|
||||
<!-- křídlo (nahoru) -->
|
||||
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
|
||||
<!-- hlava -->
|
||||
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
|
||||
</g>
|
||||
<!-- zobák -->
|
||||
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
|
||||
<!-- oko -->
|
||||
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
|
||||
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#BBD7FF" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#17233B" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,66 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
|
||||
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
|
||||
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
|
||||
<stop offset="0%" stop-color="#FFFBE6"/>
|
||||
<stop offset="35%" stop-color="#FFDE6A"/>
|
||||
<stop offset="70%" stop-color="#F5B617"/>
|
||||
<stop offset="100%" stop-color="#C97B00"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Zářivá aura -->
|
||||
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
|
||||
|
||||
<!-- Křídla -->
|
||||
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
|
||||
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
|
||||
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
|
||||
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
|
||||
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
|
||||
</g>
|
||||
|
||||
<!-- Drahokamové body na křídlech -->
|
||||
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
|
||||
<circle cx="20" cy="30" r="4.5"/>
|
||||
<circle cx="80" cy="30" r="4.5"/>
|
||||
<circle cx="29" cy="72" r="3.5"/>
|
||||
<circle cx="71" cy="72" r="3.5"/>
|
||||
</g>
|
||||
<g fill="#B8860B">
|
||||
<circle cx="20" cy="30" r="1.6"/>
|
||||
<circle cx="80" cy="30" r="1.6"/>
|
||||
</g>
|
||||
|
||||
<!-- Lesklý přeliv přes horní křídla -->
|
||||
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
|
||||
|
||||
<!-- Tykadla -->
|
||||
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,17 40,13 36,10"/>
|
||||
<path d="M51,28 C56,17 60,13 64,10"/>
|
||||
</g>
|
||||
|
||||
<!-- Tělo -->
|
||||
<g fill="#5A3E00" stroke="none">
|
||||
<circle cx="36" cy="10" r="2"/>
|
||||
<circle cx="64" cy="10" r="2"/>
|
||||
<rect x="47" y="24" width="6" height="52" rx="3"/>
|
||||
<circle cx="50" cy="24" r="5"/>
|
||||
</g>
|
||||
|
||||
<!-- Jiskry -->
|
||||
<g fill="#FFFFFF">
|
||||
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
|
||||
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
|
||||
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,79 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="netBagClipGold">
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak za obručí -->
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"
|
||||
fill="url(#netFillGold)"
|
||||
stroke="#E8B23A"
|
||||
stroke-width="1"
|
||||
opacity="0.85"/>
|
||||
|
||||
<!-- Výplet síťky -->
|
||||
<g clip-path="url(#netBagClipGold)"
|
||||
fill="none"
|
||||
stroke="#E8B23A"
|
||||
stroke-width="0.8"
|
||||
opacity="0.7"
|
||||
stroke-linecap="round">
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Zlatá obruč -->
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
|
||||
|
||||
<!-- Třpytky -->
|
||||
<g fill="#FFF7D6" stroke="none">
|
||||
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
|
||||
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
|
||||
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Vak s vyříznutou dírou uprostřed -->
|
||||
<clipPath id="netBagClipTorn">
|
||||
<path d="
|
||||
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||
clip-rule="evenodd"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak (s dírou) -->
|
||||
<path d="
|
||||
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||
fill="url(#netFillTorn)"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="1"
|
||||
opacity="0.7"
|
||||
fill-rule="evenodd"/>
|
||||
|
||||
<!-- Výplet síťky (oříznutý dírou) -->
|
||||
<g clip-path="url(#netBagClipTorn)"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="0.8"
|
||||
opacity="0.55"
|
||||
stroke-linecap="round">
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Roztřepená vlákna kolem díry -->
|
||||
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 27 40 l -3 -2"/>
|
||||
<path d="M 43 46 l 3 -1"/>
|
||||
<path d="M 30 58 l -1 3"/>
|
||||
<path d="M 38 52 l 3 2"/>
|
||||
<path d="M 24 49 l -3 1"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Obruč -->
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,106 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Celý tvar síťového vaku -->
|
||||
<clipPath id="netBagClip">
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak za obručí -->
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"
|
||||
fill="url(#netFill)"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="1"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Výplet síťky -->
|
||||
<g clip-path="url(#netBagClip)"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="0.8"
|
||||
opacity="0.6"
|
||||
stroke-linecap="round">
|
||||
|
||||
<!-- Vlákna začínající na horní a boční obruči -->
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
|
||||
<!-- Vlákna od levého okraje obruče -->
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
|
||||
<!-- Příčná oka -->
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57"
|
||||
x2="93" y2="93"
|
||||
stroke="#8B5A2B"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<line x1="57" y1="57"
|
||||
x2="93" y2="93"
|
||||
stroke="#A9713A"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55"
|
||||
x2="61" y2="61"
|
||||
stroke="#71808E"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<line x1="56" y1="55"
|
||||
x2="61" y2="61"
|
||||
stroke="#B8C4CF"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- Hlavní obruč – nezměněná -->
|
||||
<circle cx="36" cy="35" r="27"
|
||||
fill="none"
|
||||
stroke="#7A8896"
|
||||
stroke-width="4.5"/>
|
||||
|
||||
<circle cx="36" cy="35" r="27"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="2"/>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFE0A3" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#2B1A0B" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFD1E3" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#3B0E24" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFF3C4" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#3A2E00" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
|
||||
<stop offset="0%" stop-color="#FFE9A3"/>
|
||||
<stop offset="60%" stop-color="#FFCB3D"/>
|
||||
<stop offset="100%" stop-color="#E0A200"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
|
||||
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
|
||||
<!-- stylizovaný motýlek na minci -->
|
||||
<g fill="#B8860B" opacity="0.85">
|
||||
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
|
||||
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
|
||||
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
|
||||
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
|
||||
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
|
||||
</g>
|
||||
<!-- lesk -->
|
||||
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -513,7 +513,7 @@ function App() {
|
||||
}
|
||||
|
||||
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (foodChoiceList?.length && choiceRef.current?.value) {
|
||||
if (choiceRef.current?.value) {
|
||||
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,8 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayChange = async (dayIndex: number) => {
|
||||
const handleDayChange = async (requestedIndex: number) => {
|
||||
const dayIndex = Math.max(0, Math.min(4, requestedIndex));
|
||||
setDayIndex(dayIndex);
|
||||
dayIndexRef.current = dayIndex;
|
||||
if (choiceRef?.current?.value) {
|
||||
@@ -777,9 +778,11 @@ function App() {
|
||||
</div>
|
||||
{userChoices && userChoices.length > 0 && food && (
|
||||
<div className="food-choices">
|
||||
{userChoices.map(foodIndex => {
|
||||
{userChoices
|
||||
.filter(foodIndex => food[key as Restaurant]?.food?.[foodIndex] != null)
|
||||
.map(foodIndex => {
|
||||
const restaurantKey = key as Restaurant;
|
||||
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
|
||||
const foodName = food[restaurantKey]?.food?.[foodIndex]?.name;
|
||||
return <div key={foodIndex} className="food-choice-item">
|
||||
<span className="food-choice-name">{foodName}</span>
|
||||
{login === auth.login && canChangeChoice &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { ProvideSettings } from "./context/settings";
|
||||
// import Snowfall from "react-snowfall";
|
||||
import { SnowOverlay } from 'react-snow-overlay';
|
||||
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
||||
import "./FlyingButterflies.scss";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { SocketContext, socket } from "./context/socket";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
@@ -34,13 +34,7 @@ export default function AppRoutes() {
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
{/* <Snowfall style={{
|
||||
zIndex: 2,
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh'
|
||||
}} /> */}
|
||||
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
|
||||
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
.flying-butterflies {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.butterfly-scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
|
||||
// Vnější element – pozici a natočení nastavuje JS přes transform
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
// Vnitřní element se sprite motýla – máchá křídly (scaleX kolem svislé osy těla)
|
||||
.butterfly-sprite {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
animation: butterfly-flap 0.45s ease-in-out infinite;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||||
|
||||
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
|
||||
&.golden {
|
||||
animation:
|
||||
butterfly-flap 0.35s ease-in-out infinite,
|
||||
butterfly-golden-glow 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes butterfly-golden-glow {
|
||||
0%,
|
||||
100% {
|
||||
filter:
|
||||
saturate(1.15) hue-rotate(0deg)
|
||||
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
|
||||
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
50% {
|
||||
filter:
|
||||
saturate(1.4) hue-rotate(-16deg)
|
||||
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
|
||||
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes butterfly-flap {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(0.35);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Havěť (ptáci a vosy) ----------------------------------------------------
|
||||
|
||||
.butterfly-critter {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
|
||||
z-index: 3;
|
||||
|
||||
// Vosa jde zaklikat (plácačka)
|
||||
&.wasp {
|
||||
pointer-events: auto;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Síťka na chytání --------------------------------------------------------
|
||||
|
||||
.butterfly-net {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
z-index: 4;
|
||||
will-change: transform;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||
transition: filter 0.15s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&.grabbed {
|
||||
cursor: grabbing;
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
|
||||
&.premium {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||
animation: butterfly-net-glow 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Protržená síťka – nedá se chytat
|
||||
&.torn {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
|
||||
}
|
||||
|
||||
&.catch-pop {
|
||||
animation: butterfly-net-pop 0.3s ease-out;
|
||||
}
|
||||
|
||||
&.stung {
|
||||
animation: butterfly-net-stung 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
|
||||
// Má přednost před grabbed kurzorem.
|
||||
&.stunned {
|
||||
cursor: not-allowed !important;
|
||||
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
|
||||
// NEanimujeme transform (ten řídí JS inline) – jen filter.
|
||||
@keyframes butterfly-net-dizzy {
|
||||
0%,
|
||||
100% {
|
||||
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
|
||||
}
|
||||
50% {
|
||||
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes butterfly-net-pop {
|
||||
0% {
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||
}
|
||||
40% {
|
||||
filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes butterfly-net-glow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
}
|
||||
|
||||
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
|
||||
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
|
||||
@keyframes butterfly-net-stung {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||
}
|
||||
30% {
|
||||
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Prchavé texty (chycení, protržení, žihnutí)
|
||||
.butterfly-catch-fx,
|
||||
.butterfly-tear-fx,
|
||||
.butterfly-sting-fx {
|
||||
position: fixed;
|
||||
margin: -12px 0 0 10px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
||||
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.butterfly-catch-fx {
|
||||
color: #2f9e44;
|
||||
|
||||
&.golden {
|
||||
color: #e0a200;
|
||||
font-size: 1.35rem;
|
||||
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.butterfly-tear-fx {
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.butterfly-sting-fx {
|
||||
color: #d9480f;
|
||||
}
|
||||
|
||||
@keyframes butterfly-catch-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.9);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-6px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
|
||||
|
||||
.butterfly-hud {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.butterfly-counter {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
&.bump {
|
||||
animation: butterfly-counter-bump 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.butterfly-counter-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.butterfly-counter-icon,
|
||||
.butterfly-coin-icon {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.butterfly-counter-icon {
|
||||
background-image: url(/butterfly-orange.svg);
|
||||
}
|
||||
|
||||
.butterfly-coin-icon {
|
||||
background-image: url(/coin.svg);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.butterfly-counter-value {
|
||||
min-width: 1ch;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.butterfly-level {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.butterfly-level-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #6b5b2e;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.butterfly-progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.butterfly-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffcb3d, #e0a200);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.butterfly-pests {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.butterfly-pest {
|
||||
color: #868e96;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.warn {
|
||||
color: #d9480f;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #2f9e44;
|
||||
}
|
||||
}
|
||||
|
||||
.butterfly-torn-note {
|
||||
margin-top: 4px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.butterfly-action-btn {
|
||||
margin-top: 4px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #1c7ed6;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1971c2;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #adb5bd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: #c92a2a;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e03131;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
margin: -14px 0 0 -6px;
|
||||
font-size: 1.4rem;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.butterfly-combo {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(224, 162, 0, 0.92);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
animation: butterfly-counter-bump 0.3s ease-out;
|
||||
}
|
||||
|
||||
.butterfly-flash {
|
||||
max-width: 260px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(33, 37, 41, 0.92);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.25;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||
animation: butterfly-flash-in 0.25s ease-out;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes butterfly-flash-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes butterfly-counter-bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.butterfly-sprite,
|
||||
.butterfly-sprite.golden {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.butterfly-net.catch-pop,
|
||||
.butterfly-net.premium,
|
||||
.butterfly-net.stung,
|
||||
.butterfly-net.stunned,
|
||||
.butterfly-counter.bump,
|
||||
.butterfly-combo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
|
||||
.butterfly-net.stunned {
|
||||
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
|
||||
}
|
||||
|
||||
// Zlatý motýl bez animace aspoň staticky září
|
||||
.butterfly-sprite.golden {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
|
||||
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
|
||||
|
||||
// Barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
||||
const BUTTERFLY_VARIANTS = [
|
||||
'/butterfly-orange.svg',
|
||||
'/butterfly-blue.svg',
|
||||
'/butterfly-yellow.svg',
|
||||
'/butterfly-pink.svg',
|
||||
] as const;
|
||||
|
||||
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
||||
const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější)
|
||||
|
||||
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_DURATION_MS = 60_000;
|
||||
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);
|
||||
const m = Math.floor(s / 60);
|
||||
const ss = s % 60;
|
||||
return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`;
|
||||
}
|
||||
|
||||
interface ButterflyData {
|
||||
el: HTMLDivElement;
|
||||
sprite: HTMLDivElement;
|
||||
x: number; y: number;
|
||||
dir: number; speed: number; size: number;
|
||||
golden: boolean;
|
||||
bobFreq1: number; bobPhase1: number; bobAmp1: number;
|
||||
bobFreq2: number; bobPhase2: number; bobAmp2: number;
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
triggered: boolean;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
numButterflies?: number;
|
||||
className?: string;
|
||||
butterflyVariants?: readonly string[];
|
||||
enableNet?: boolean;
|
||||
}
|
||||
|
||||
class ButterflyScene {
|
||||
private viewport: HTMLElement;
|
||||
private world: HTMLDivElement;
|
||||
private butterflies: ButterflyData[] = [];
|
||||
private birds: CritterData[] = [];
|
||||
private wasps: CritterData[] = [];
|
||||
private stalker: StalkerData | null = null;
|
||||
private numButterflies: number;
|
||||
private variants: readonly string[];
|
||||
private width: number;
|
||||
private height: number;
|
||||
private timer: number = 0;
|
||||
private animationId: number | null = null;
|
||||
private handleResize: () => void;
|
||||
|
||||
private net: HTMLDivElement;
|
||||
private netEnabled: boolean;
|
||||
private netGrabbed: boolean = false;
|
||||
private netX: number = 0;
|
||||
private netY: number = 0;
|
||||
private cb: SceneCallbacks;
|
||||
|
||||
private torn: boolean = false;
|
||||
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;
|
||||
private comboTimer: number = 0;
|
||||
|
||||
private static readonly BASE_SIZE = 34;
|
||||
private static readonly NET_SIZE_NORMAL = 120;
|
||||
private static readonly NET_SIZE_PREMIUM = 168;
|
||||
private static readonly CATCH_RADIUS_NORMAL = 40;
|
||||
private static readonly CATCH_RADIUS_PREMIUM = 62;
|
||||
private static readonly HOOP_FRAC_X = 0.36;
|
||||
private static readonly HOOP_FRAC_Y = 0.35;
|
||||
|
||||
private static readonly BIRD_SIZE = 64;
|
||||
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 MAGNET_RADIUS = 150;
|
||||
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;
|
||||
|
||||
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;
|
||||
this.variants = variants;
|
||||
this.netEnabled = netEnabled;
|
||||
this.cb = cb;
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
this.net = document.createElement('div');
|
||||
this.handleResize = () => {
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
};
|
||||
}
|
||||
|
||||
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 catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; }
|
||||
private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
|
||||
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
|
||||
|
||||
// --- Motýli --------------------------------------------------------------
|
||||
|
||||
private resetButterfly = (b: ButterflyData): void => {
|
||||
b.dir = Math.random() > 0.5 ? 1 : -1;
|
||||
b.speed = Math.random() * 1.1 + 0.7;
|
||||
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;
|
||||
b.bobFreq2 = Math.random() * 0.015 + 0.01;
|
||||
b.bobPhase2 = Math.random() * Math.PI * 2;
|
||||
b.bobAmp2 = Math.random() * 0.6 + 0.4;
|
||||
|
||||
b.golden = Math.random() < GOLDEN_CHANCE;
|
||||
if (b.golden) {
|
||||
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
||||
b.sprite.classList.add('golden');
|
||||
b.size = Math.random() * 0.5 + 1.0;
|
||||
} else {
|
||||
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`;
|
||||
};
|
||||
|
||||
private updateButterfly = (b: ButterflyData): void => {
|
||||
const vx = b.dir * b.speed;
|
||||
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;
|
||||
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) {
|
||||
this.resetButterfly(b);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Síťka ---------------------------------------------------------------
|
||||
|
||||
private updateNetTransform = (): void => {
|
||||
this.net.style.transform = `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
||||
};
|
||||
|
||||
private applyNetAppearance = (): void => {
|
||||
const size = this.netSize;
|
||||
this.net.style.width = `${size}px`;
|
||||
this.net.style.height = `${size}px`;
|
||||
let image = NET_NORMAL;
|
||||
if (this.torn) image = NET_TORN;
|
||||
else if (this.premiumActive) image = NET_GOLDEN;
|
||||
this.net.style.backgroundImage = `url(${image})`;
|
||||
this.net.classList.toggle('premium', this.premiumActive && !this.torn);
|
||||
this.net.classList.toggle('torn', this.torn);
|
||||
this.updateNetTransform();
|
||||
};
|
||||
|
||||
private onPointerDown = (e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
this.netGrabbed = true;
|
||||
this.net.classList.add('grabbed');
|
||||
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.updateNetTransform();
|
||||
};
|
||||
private onPointerUp = (): void => {
|
||||
if (!this.netGrabbed) return;
|
||||
this.netGrabbed = false;
|
||||
this.net.classList.remove('grabbed');
|
||||
};
|
||||
|
||||
private spawnFx = (text: string, cssClass: string, x: number, y: number): void => {
|
||||
const fx = document.createElement('div');
|
||||
fx.className = cssClass;
|
||||
fx.textContent = text;
|
||||
fx.style.left = `${x}px`;
|
||||
fx.style.top = `${y}px`;
|
||||
this.viewport.appendChild(fx);
|
||||
window.setTimeout(() => { if (fx.parentNode) fx.parentNode.removeChild(fx); }, 800);
|
||||
};
|
||||
|
||||
private popNet = (): void => {
|
||||
this.net.classList.remove('catch-pop');
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('catch-pop');
|
||||
};
|
||||
|
||||
private registerCatch = (b: ButterflyData, atX: number, atY: number): void => {
|
||||
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;
|
||||
this.lastCatchTs = now;
|
||||
if (this.comboCount >= 2) {
|
||||
this.cb.onCombo(this.comboCount);
|
||||
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||
this.comboTimer = window.setTimeout(() => this.cb.onCombo(0), ButterflyScene.COMBO_WINDOW_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 || this.timer < this.nextCatchAt) return;
|
||||
const r2 = this.catchRadius * this.catchRadius;
|
||||
const half = ButterflyScene.BASE_SIZE / 2;
|
||||
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) {
|
||||
this.registerCatch(b, this.netX, this.netY);
|
||||
this.popNet();
|
||||
return; // jen jeden za cooldown
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private runMagnet = (): void => {
|
||||
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;
|
||||
let nearest: ButterflyData | null = null;
|
||||
let nearestD2 = ButterflyScene.MAGNET_RADIUS * ButterflyScene.MAGNET_RADIUS;
|
||||
for (const b of this.butterflies) {
|
||||
const dx = (b.x + half * b.size) - this.netX;
|
||||
const dy = (b.y + half * b.size) - this.netY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < nearestD2) { nearestD2 = d2; nearest = b; }
|
||||
}
|
||||
if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); }
|
||||
};
|
||||
|
||||
public activatePremiumNet = (): void => {
|
||||
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
public repairNetExternally = (): void => {
|
||||
if (!this.torn) return;
|
||||
this.torn = false;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
public setTornFromServer = (torn: boolean): void => {
|
||||
if (this.torn === torn) return;
|
||||
this.torn = torn;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
|
||||
private tearNet = (atX: number, atY: number): void => {
|
||||
if (this.torn) return;
|
||||
this.torn = true;
|
||||
this.applyNetAppearance();
|
||||
this.spawnFx('Ratata!', 'butterfly-tear-fx', atX, atY);
|
||||
this.cb.onTornChange(true);
|
||||
};
|
||||
|
||||
// --- Havěť (ptáci a vosy) -----------------------------------------------
|
||||
|
||||
private critterHitsHoop = (c: CritterData, radius: number): boolean => {
|
||||
const dx = (c.x + c.size / 2) - this.netX;
|
||||
const dy = (c.y + c.size / 2) - this.netY;
|
||||
return dx * dx + dy * dy < radius * radius;
|
||||
};
|
||||
|
||||
private spawnCritter = (type: 'wasp' | 'bird'): CritterData => {
|
||||
const size = type === 'bird' ? ButterflyScene.BIRD_SIZE : ButterflyScene.WASP_SIZE;
|
||||
const el = document.createElement('div');
|
||||
el.className = type === 'bird' ? 'butterfly-critter bird' : 'butterfly-critter wasp';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.backgroundImage = `url(${type === 'bird' ? '/bird.svg' : '/bee.svg'})`;
|
||||
|
||||
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||
const c: CritterData = {
|
||||
el,
|
||||
x: dir === 1 ? -size : this.width + size,
|
||||
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') {
|
||||
el.title = 'Plácni vosu!';
|
||||
const swat = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.spawnFx('💥', 'butterfly-swat-fx', c.x + c.size / 2, c.y + c.size / 2);
|
||||
const idx = this.wasps.indexOf(c);
|
||||
if (idx >= 0) this.wasps.splice(idx, 1);
|
||||
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||
this.cb.onSwatWasp();
|
||||
};
|
||||
el.addEventListener('pointerdown', swat);
|
||||
c.cleanup = () => el.removeEventListener('pointerdown', swat);
|
||||
}
|
||||
|
||||
this.world.appendChild(el);
|
||||
return c;
|
||||
};
|
||||
|
||||
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.6) + this.height * 0.1;
|
||||
c.triggered = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 removeLastCritter = (arr: CritterData[]): void => {
|
||||
const c = arr.pop();
|
||||
if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); }
|
||||
};
|
||||
|
||||
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.removeLastCritter(this.wasps);
|
||||
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
||||
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) {
|
||||
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) {
|
||||
// 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.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
||||
bird.triggered = true;
|
||||
this.tearNet(this.netX, this.netY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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();
|
||||
this.netX = this.width * 0.82;
|
||||
this.netY = this.height * 0.72;
|
||||
this.updateNetTransform();
|
||||
this.net.addEventListener('pointerdown', this.onPointerDown);
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
window.addEventListener('pointercancel', this.onPointerUp);
|
||||
this.viewport.appendChild(this.net);
|
||||
};
|
||||
|
||||
public init = (): void => {
|
||||
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');
|
||||
sprite.className = 'butterfly-sprite';
|
||||
el.appendChild(sprite);
|
||||
const b: ButterflyData = {
|
||||
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false,
|
||||
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
|
||||
};
|
||||
this.resetButterfly(b);
|
||||
this.butterflies.push(b);
|
||||
this.world.appendChild(el);
|
||||
}
|
||||
this.viewport.appendChild(this.world);
|
||||
if (this.netEnabled) this.initNet();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
};
|
||||
|
||||
private wasPremium: boolean = false;
|
||||
private wasStunned: boolean = false;
|
||||
|
||||
public render = (): void => {
|
||||
for (const b of this.butterflies) this.updateButterfly(b);
|
||||
|
||||
if (this.netEnabled) {
|
||||
const nowPremium = this.premiumActive;
|
||||
if (this.wasPremium && !nowPremium) this.applyNetAppearance();
|
||||
this.wasPremium = nowPremium;
|
||||
|
||||
this.updateWasps();
|
||||
this.updateBirds();
|
||||
this.trySpawnStalker();
|
||||
this.updateStalker();
|
||||
|
||||
const stunned = this.timer < this.stunUntil;
|
||||
if (stunned !== this.wasStunned) {
|
||||
this.net.classList.toggle('stunned', stunned);
|
||||
this.wasStunned = stunned;
|
||||
}
|
||||
|
||||
if (this.netGrabbed) this.checkCatches();
|
||||
if (nowPremium) this.runMagnet();
|
||||
}
|
||||
|
||||
this.timer++;
|
||||
this.animationId = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
public destroy = (): void => {
|
||||
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);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
window.removeEventListener('pointercancel', this.onPointerUp);
|
||||
if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FlyingButterfliesProps> = ({
|
||||
numButterflies = 9,
|
||||
className = 'flying-butterflies',
|
||||
butterflyVariants = BUTTERFLY_VARIANTS,
|
||||
enableNet = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ButterflyScene | null>(null);
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [combo, setCombo] = useState(0);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
const flashTimer = useRef<number | null>(null);
|
||||
|
||||
const showFlash = useCallback((msg: string) => {
|
||||
setFlash(msg);
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
flashTimer.current = window.setTimeout(() => setFlash(null), 6000);
|
||||
}, []);
|
||||
|
||||
const handleReward = useCallback((e: RewardEvent) => {
|
||||
if (e.premiumUnlocked) {
|
||||
sceneRef.current?.activatePremiumNet();
|
||||
showFlash('🪄 Zlatá síťka aktivní! Větší, magnetická a odolná proti ptákům.');
|
||||
} else if (e.leveledUp) {
|
||||
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
||||
} else if (e.dailyBonusApplied) {
|
||||
showFlash('☀️ Denní bonus mincí!');
|
||||
}
|
||||
}, [showFlash]);
|
||||
|
||||
const {
|
||||
stats, displayCaught, coinsRef, reportCatch, repair,
|
||||
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
|
||||
caterpillarAte, defeatCaterpillar,
|
||||
} = useButterflyStats(handleReward);
|
||||
|
||||
const callbacksRef = useRef<SceneCallbacks>({
|
||||
onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { },
|
||||
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
||||
onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { },
|
||||
onDefeatCaterpillar: () => { }, onStalkerAppear: () => { },
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onCatch: (golden) => reportCatch(golden),
|
||||
getCoins: () => coinsRef.current,
|
||||
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(),
|
||||
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();
|
||||
}
|
||||
}, [numButterflies, butterflyVariants, enableNet]);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
return () => {
|
||||
if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; }
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
};
|
||||
}, [initialize]);
|
||||
|
||||
const wasps = stats?.wasps ?? 0;
|
||||
const birds = stats?.birds ?? 0;
|
||||
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(torn); }, [torn]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = badgeRef.current;
|
||||
if (!node || displayCaught === 0) return;
|
||||
node.classList.remove('bump');
|
||||
void node.offsetWidth;
|
||||
node.classList.add('bump');
|
||||
}, [displayCaught]);
|
||||
|
||||
const coins = stats?.coins ?? 0;
|
||||
const canAffordRepair = coins >= REPAIR_COST;
|
||||
const canAffordRepellent = coins >= REPELLENT_COST;
|
||||
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čty plašiče i protržené síťky (jen dokud běží)
|
||||
useEffect(() => {
|
||||
const until = Math.max(repellentUntil, netTornUntil);
|
||||
if (until <= Date.now()) return;
|
||||
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [repellentUntil, netTornUntil]);
|
||||
|
||||
const onRepairClick = useCallback(async () => {
|
||||
const ok = await repair();
|
||||
if (ok) sceneRef.current?.repairNetExternally();
|
||||
}, [repair]);
|
||||
|
||||
const onRepellentClick = useCallback(async () => {
|
||||
const ok = await buyRepellent();
|
||||
if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.');
|
||||
}, [buyRepellent, showFlash]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className={className} />
|
||||
{enableNet && (
|
||||
<div className="butterfly-hud">
|
||||
{flash && (
|
||||
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
|
||||
{flash}
|
||||
</div>
|
||||
)}
|
||||
{combo > 1 && <div className="butterfly-combo">Kombo ×{combo}!</div>}
|
||||
|
||||
<div
|
||||
ref={badgeRef}
|
||||
className="butterfly-counter"
|
||||
title="Zobrazit žebříček a statistiky"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLeaderboardOpen(true)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
|
||||
>
|
||||
<div className="butterfly-counter-row">
|
||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{displayCaught}</span>
|
||||
<span className="butterfly-coin-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{coins}</span>
|
||||
</div>
|
||||
<div className="butterfly-level">
|
||||
<span className="butterfly-level-title">
|
||||
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
||||
</span>
|
||||
<div className="butterfly-progress" title="Postup do další úrovně">
|
||||
<div className="butterfly-progress-bar" style={{ width: `${levelProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="butterfly-pests" title="Škůdci u síťky">
|
||||
<span className={wasps > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}</span>
|
||||
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
||||
{repellentActive && (
|
||||
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
||||
🚫 {formatRemaining(repellentRemainingMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{torn && (
|
||||
<>
|
||||
<div className="butterfly-torn-note">
|
||||
🕳️ Protržená síťka – sama se zašije za {formatRemaining(tornRemainingMs)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn danger"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
||||
disabled={!canAffordRepair}
|
||||
title={canAffordRepair ? 'Zašít hned za mince' : 'Nemáš dost mincí – počkej, než se zašije sama'}
|
||||
>
|
||||
🪡 Zašít hned za {REPAIR_COST} 🪙
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{birds > 0 && !repellentActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepellentClick(); }}
|
||||
disabled={!canAffordRepellent}
|
||||
title={canAffordRepellent ? 'Vyžene všechny ptáky a chvíli brání novým' : 'Nemáš dost mincí na plašič'}
|
||||
>
|
||||
🦅🚫 Plašič za {REPELLENT_COST} 🪙
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButterflyLeaderboardModal isOpen={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} myStats={stats} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
COOL: ['/butterfly-blue.svg'] as const,
|
||||
} as const;
|
||||
|
||||
export default FlyingButterflies;
|
||||
@@ -14,6 +14,7 @@ const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doruče
|
||||
const ORDER_STATE_TO_STEP: Record<string, number> = {
|
||||
created: 0,
|
||||
pending: 0,
|
||||
waiting_acceptance: 0,
|
||||
accepted: 0,
|
||||
waiting_preparation: 0,
|
||||
preparing: 1,
|
||||
@@ -31,6 +32,8 @@ const ORDER_STATE_TO_STEP: Record<string, number> = {
|
||||
|
||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||
const COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||
matched: 0,
|
||||
accepted: 0,
|
||||
heading_to_provider: 1,
|
||||
arrived_to_provider: 1,
|
||||
picked_up: 3,
|
||||
@@ -49,6 +52,7 @@ function stepForOrderState(state: string): number | 'cancelled' {
|
||||
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
|
||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||
if (/delivered|finished/.test(s)) return 4;
|
||||
if (/accept|pending|created/.test(s)) return 0;
|
||||
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
|
||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.hue-picker-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-toggle {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
.butterfly-mystats {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
|
||||
border: 1px solid rgba(224, 162, 0, 0.35);
|
||||
}
|
||||
|
||||
.butterfly-mystats-title {
|
||||
font-weight: 700;
|
||||
color: #6b5b2e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.butterfly-mystats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
|
||||
&.gold {
|
||||
color: #e0a200;
|
||||
}
|
||||
}
|
||||
|
||||
.l {
|
||||
font-size: 0.72rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-heading {
|
||||
font-weight: 700;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
|
||||
&.me {
|
||||
background: rgba(255, 203, 61, 0.22);
|
||||
border: 1px solid rgba(224, 162, 0, 0.5);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-rank {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-name {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #6b5b2e;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-stats {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.butterfly-leaderboard-golden {
|
||||
color: #e0a200;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { ButterflyLeaderboardEntry, ButterflyStats, getButterflyLeaderboard } from "../../../../types";
|
||||
import { useAuth } from "../../context/auth";
|
||||
import "./ButterflyLeaderboardModal.scss";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
myStats?: ButterflyStats;
|
||||
};
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
/** Modální dialog s osobními statistikami a týmovým žebříčkem chytačů motýlů. */
|
||||
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
|
||||
const auth = useAuth();
|
||||
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getButterflyLeaderboard({ query: { limit: 20 } })
|
||||
.then(res => { if (!cancelled) setEntries(res.data ?? []); })
|
||||
.catch(() => { if (!cancelled) setEntries([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{myStats && (
|
||||
<div className="butterfly-mystats">
|
||||
<div className="butterfly-mystats-title">
|
||||
{myStats.level}. {myStats.title}
|
||||
</div>
|
||||
<div className="butterfly-mystats-grid">
|
||||
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
|
||||
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
|
||||
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
||||
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
||||
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
||||
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
|
||||
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
|
||||
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
|
||||
{!loading && entries && entries.length === 0 && (
|
||||
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
|
||||
)}
|
||||
{!loading && entries && entries.length > 0 && (
|
||||
<ol className="butterfly-leaderboard">
|
||||
{entries.map((e, i) => (
|
||||
<li
|
||||
key={e.login}
|
||||
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
|
||||
>
|
||||
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
|
||||
<span className="butterfly-leaderboard-name" title={e.title}>
|
||||
{e.login}
|
||||
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
|
||||
</span>
|
||||
<span className="butterfly-leaderboard-stats">
|
||||
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
|
||||
{e.goldenCaught > 0 && (
|
||||
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ButterflyStats,
|
||||
getButterflyStats,
|
||||
catchButterflies,
|
||||
repairNet as repairNetApi,
|
||||
killWasp as killWaspApi,
|
||||
buyRepellent as buyRepellentApi,
|
||||
reportNetTorn as reportTearApi,
|
||||
reportRobbery as reportRobberyApi,
|
||||
defeatThief as defeatThiefApi,
|
||||
reportCaterpillarAte as caterpillarAteApi,
|
||||
defeatCaterpillar as defeatCaterpillarApi,
|
||||
} from '../../../types';
|
||||
|
||||
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
|
||||
const FLUSH_DEBOUNCE_MS = 1500;
|
||||
/** 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;
|
||||
|
||||
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
|
||||
export interface RewardEvent {
|
||||
coinsAwarded: number;
|
||||
dailyBonusApplied: boolean;
|
||||
leveledUp: boolean;
|
||||
premiumUnlocked: boolean;
|
||||
newLevel: number;
|
||||
newTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ButterflyStats | undefined>();
|
||||
/** 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 });
|
||||
const flushTimer = useRef<number | null>(null);
|
||||
const onRewardRef = useRef(onReward);
|
||||
onRewardRef.current = onReward;
|
||||
|
||||
const applyStats = useCallback((incoming: ButterflyStats) => {
|
||||
coinsRef.current = incoming.coins;
|
||||
setStats(incoming);
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (flushTimer.current) {
|
||||
clearTimeout(flushTimer.current);
|
||||
flushTimer.current = null;
|
||||
}
|
||||
const batch = pending.current;
|
||||
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,
|
||||
leveledUp: res.data.leveledUp,
|
||||
premiumUnlocked: res.data.premiumUnlocked,
|
||||
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;
|
||||
}
|
||||
}, [applyStats]);
|
||||
|
||||
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
|
||||
const reportCatch = useCallback((golden: boolean) => {
|
||||
if (golden) pending.current.golden += 1;
|
||||
else pending.current.normal += 1;
|
||||
setPendingShown(p => p + 1);
|
||||
if (!flushTimer.current) {
|
||||
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
const repair = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await repairNetApi();
|
||||
if (res.data) { applyStats(res.data); return true; }
|
||||
return false;
|
||||
} catch { return false; }
|
||||
}, [applyStats]);
|
||||
|
||||
const killWasp = useCallback(async () => {
|
||||
try {
|
||||
const res = await killWaspApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
const buyRepellent = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await buyRepellentApi();
|
||||
if (res.data) { applyStats(res.data); return true; }
|
||||
return false;
|
||||
} catch { return false; }
|
||||
}, [applyStats]);
|
||||
|
||||
const reportTear = useCallback(async () => {
|
||||
try {
|
||||
const res = await reportTearApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
/** 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();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
// Načtení stavu
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getButterflyStats();
|
||||
if (!cancelled && res.data) applyStats(res.data);
|
||||
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [applyStats]);
|
||||
|
||||
// Periodické přenačítání (růst škůdců) + při návratu na záložku
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
|
||||
useEffect(() => {
|
||||
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
|
||||
const onPageHide = () => { void flush(); };
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
void flush();
|
||||
};
|
||||
}, [flush]);
|
||||
|
||||
const displayCaught = (stats?.caught ?? 0) + pendingShown;
|
||||
|
||||
return {
|
||||
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
|
||||
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
|
||||
};
|
||||
}
|
||||
@@ -6,20 +6,37 @@ import './index.css';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { client } from '../../types/gen/client.gen';
|
||||
import { getConfig } from '../../types/gen/sdk.gen';
|
||||
import { getToken } from './Utils';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
|
||||
});
|
||||
|
||||
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env.
|
||||
getConfig().then(({ data }) => {
|
||||
if (data?.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: data.sentry.dsn,
|
||||
environment: data.sentry.environment,
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* config endpoint nedostupný — běžíme bez Sentry */ });
|
||||
|
||||
// Interceptor na vyhození toasteru při chybě
|
||||
client.interceptors.response.use(async response => {
|
||||
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
|
||||
if (!response.ok && !response.url.includes("/login")) {
|
||||
const json = await response.json();
|
||||
toast.error(json.error, { theme: "colored" });
|
||||
// Serverové chyby hlásíme do Sentry; 4xx jsou očekávané (chyby uživatele)
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureMessage(`API ${response.status}: ${new URL(response.url).pathname} — ${json.error}`, 'error');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
});
|
||||
@@ -29,10 +46,18 @@ const root = ReactDOM.createRoot(
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Sentry.ErrorBoundary fallback={
|
||||
<div className="text-center p-5">
|
||||
<h4>Něco se pokazilo 😕</h4>
|
||||
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p>
|
||||
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button>
|
||||
</div>
|
||||
}>
|
||||
<BrowserRouter>
|
||||
<ProvideAuth>
|
||||
<AppRoutes />
|
||||
</ProvideAuth>
|
||||
</BrowserRouter>
|
||||
</Sentry.ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,10 @@ export default defineConfig({
|
||||
// depending on your application, base can also be "/"
|
||||
base: '',
|
||||
plugins: [react(), viteTsconfigPaths()],
|
||||
build: {
|
||||
// Veřejné sourcemapy, aby Sentry umělo přeložit minifikované stack traces
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
host: '0.0.0.0',
|
||||
|
||||
@@ -56,3 +56,11 @@
|
||||
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s).
|
||||
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
||||
# BOLT_POLL_INTERVAL_MS=3000
|
||||
|
||||
# Sentry (volitelné) — když není vyplněno, Sentry se vůbec neaktivuje.
|
||||
# Server hlásí chyby (5xx, pády, console.error/warn); console.log se k chybám přikládá jako breadcrumbs.
|
||||
# SENTRY_DSN=https://...@sentry.example.com/1
|
||||
# DSN pro prohlížeč (klient si ho vyzvedne z GET /api/config). Když není vyplněno, použije se SENTRY_DSN.
|
||||
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
||||
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
||||
# SENTRY_DEBUG=true
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
"Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu",
|
||||
"Oprava: načítání denního menu podniku TechTower po změně jejich webu",
|
||||
"Oprava: rychlé klikání na šipky přepínání dní už nepřeskočí mimo pracovní týden",
|
||||
"Letní vzhled: místo padajícího sněhu teď po stránce poletují motýli"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Oprava zobrazení stavu objednávky Bolt Food — objednávka čekající na přijetí podnikem se už nezobrazuje jako v přípravě"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Chytání motýlků: uchop myší síťku a lov poletující motýly – počet ulovených se ti počítá a ukládá"
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
"Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
|
||||
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
||||
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
||||
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
||||
"Ptáci přibývají a trhají síťku (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"
|
||||
]
|
||||
@@ -30,6 +30,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^10.65.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"axios": "^1.13.2",
|
||||
"cheerio": "^1.1.2",
|
||||
|
||||
@@ -44,8 +44,10 @@ interface Simulation {
|
||||
* (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno).
|
||||
*/
|
||||
export const DEFAULT_SCENARIO: SimStep[] = [
|
||||
{ order_state: 'waiting_acceptance', etaSeconds: 2100 },
|
||||
{ order_state: 'accepted', etaSeconds: 1800 },
|
||||
{ order_state: 'preparing', etaSeconds: 1500 },
|
||||
{ order_state: 'waiting_preparation', courier_state: 'accepted', etaSeconds: 1800 },
|
||||
{ order_state: 'preparing', courier_state: 'heading_to_provider', etaSeconds: 1500 },
|
||||
{ order_state: 'waiting_delivery', courier_state: 'arrived_to_provider', etaSeconds: 900 },
|
||||
{ order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 },
|
||||
{ order_state: 'delivered', courier_state: 'delivered', etaSeconds: 0 },
|
||||
|
||||
@@ -66,14 +66,14 @@ export async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
|
||||
}
|
||||
const res = await axios.post(BOLT_POLLING_URL, { token }, {
|
||||
params: {
|
||||
version: 'FW.1.111',
|
||||
version: 'FW.1.113',
|
||||
language: 'cs-CZ',
|
||||
country: 'cz',
|
||||
device_name: 'web',
|
||||
device_os_version: 'web',
|
||||
deviceType: 'web',
|
||||
session_id: DEVICE_ID,
|
||||
distinct_id: DEVICE_ID,
|
||||
distinct_id: `$device:${DEVICE_ID}`,
|
||||
deviceId: DEVICE_ID,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -143,6 +143,15 @@ export async function checkBoltTracking(): Promise<void> {
|
||||
const courierChanged = courierState !== group.boltCourierState && !clearToken;
|
||||
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
|
||||
|
||||
// Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle
|
||||
// průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx).
|
||||
if (stateChanged || courierChanged) {
|
||||
console.log(
|
||||
`Bolt tracking: skupina "${group.name}" stav ${group.boltOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` +
|
||||
`, kurýr ${group.boltCourierState ?? '(žádný)'} → ${courierState ?? '(žádný)'}`
|
||||
);
|
||||
}
|
||||
|
||||
updated = await storage.updateData<ClientData>(key, current => {
|
||||
const d = current ?? data!;
|
||||
const g = d.groups?.find(x => x.id === group.id);
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { ButterflyStats, ButterflyCatchResult, ButterflyLeaderboardEntry } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
/** Interní reprezentace statistik jednoho uživatele uložená ve storage. */
|
||||
interface StoredButterflyStats {
|
||||
/** Celkový počet chycených motýlů */
|
||||
caught: number;
|
||||
/** Aktuální počet mincí k utracení */
|
||||
coins: number;
|
||||
/** Počet chycených vzácných zlatých motýlů */
|
||||
goldenCaught: number;
|
||||
/** Datum posledního úlovku (YYYY-MM-DD) pro denní bonus */
|
||||
lastCatchDay?: string;
|
||||
|
||||
// --- Perzistentní škůdci (aby je nešlo obejít obnovením stránky) ---
|
||||
/** Aktuální počet vos */
|
||||
wasps: number;
|
||||
/** Aktuální počet ptáků */
|
||||
birds: number;
|
||||
/** Celkový počet zahubených vos */
|
||||
waspsKilled: number;
|
||||
/** Celkový počet vyplašených ptáků */
|
||||
birdsScared: number;
|
||||
/** 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;
|
||||
/** 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();
|
||||
// Sezóna 2 – nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry)
|
||||
const STORAGE_KEY = 'butterflyStats_s2';
|
||||
|
||||
// --- Herní konstanty ---------------------------------------------------------
|
||||
|
||||
/** Mince za jednoho běžného motýla */
|
||||
export const COIN_BASE = 1;
|
||||
/** Mince za jednoho vzácného zlatého motýla */
|
||||
export const GOLD_VALUE = 25;
|
||||
/** Cena zašití protržené síťky v mincích */
|
||||
export const REPAIR_COST = 20;
|
||||
/** Bonus mincí za první úlovek dne */
|
||||
export const DAILY_BONUS = 5;
|
||||
/** Po každých kolika chycených se odemkne prémiová síťka */
|
||||
export const PREMIUM_MILESTONE = 75;
|
||||
|
||||
/** Maximální počet současně poletujících vos */
|
||||
export const WASP_MAX = 6;
|
||||
/** Maximální počet současně poletujících ptáků */
|
||||
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 = 60_000;
|
||||
/** Cena plašiče ptáků v mincích */
|
||||
export const REPELLENT_COST = 60;
|
||||
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
|
||||
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 = 4;
|
||||
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
|
||||
* (prestiž), takže postup nikdy „nedojde".
|
||||
*/
|
||||
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;
|
||||
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 {
|
||||
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). */
|
||||
export function comboBonus(batchTotal: number): number {
|
||||
if (batchTotal < COMBO_THRESHOLD) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(batchTotal - (COMBO_THRESHOLD - 1), COMBO_CAP);
|
||||
}
|
||||
|
||||
/** Vytvoří výchozí (prázdné) statistiky. */
|
||||
function defaultStats(now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: 0, coins: 0, goldenCaught: 0,
|
||||
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
||||
thievesDefeated: 0, caterpillarsDefeated: 0,
|
||||
repellentUntil: 0, netTornUntil: 0,
|
||||
lastWaspAt: now, lastBirdAt: now,
|
||||
catchWindowStart: now, catchWindowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Doplní chybějící pole u starších uložených záznamů (migrace za běhu). */
|
||||
function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: s.caught ?? 0,
|
||||
coins: s.coins ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
lastCatchDay: s.lastCatchDay,
|
||||
wasps: s.wasps ?? 0,
|
||||
birds: s.birds ?? 0,
|
||||
waspsKilled: s.waspsKilled ?? 0,
|
||||
birdsScared: s.birdsScared ?? 0,
|
||||
thievesDefeated: s.thievesDefeated ?? 0,
|
||||
caterpillarsDefeated: s.caterpillarsDefeated ?? 0,
|
||||
repellentUntil: s.repellentUntil ?? 0,
|
||||
netTornUntil: s.netTornUntil ?? 0,
|
||||
lastWaspAt: s.lastWaspAt ?? now,
|
||||
lastBirdAt: s.lastBirdAt ?? now,
|
||||
catchWindowStart: s.catchWindowStart ?? now,
|
||||
catchWindowCount: s.catchWindowCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá
|
||||
* razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič.
|
||||
*/
|
||||
export function growPests(s: StoredButterflyStats, now: number): void {
|
||||
// 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);
|
||||
if (add > 0) {
|
||||
s.wasps = Math.min(s.wasps + add, WASP_MAX);
|
||||
s.lastWaspAt = s.wasps >= WASP_MAX ? now : s.lastWaspAt + add * WASP_GROWTH_MS;
|
||||
}
|
||||
} else {
|
||||
s.lastWaspAt = now;
|
||||
}
|
||||
|
||||
// Ptáci – jen když neběží plašič. 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) {
|
||||
if (s.birds < BIRD_MAX) {
|
||||
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
|
||||
if (add > 0) {
|
||||
s.birds = Math.min(s.birds + add, BIRD_MAX);
|
||||
s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS;
|
||||
}
|
||||
} else {
|
||||
s.lastBirdAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
||||
function toDto(s: StoredButterflyStats): ButterflyStats {
|
||||
const level = levelForCaught(s.caught);
|
||||
return {
|
||||
caught: s.caught,
|
||||
coins: s.coins,
|
||||
goldenCaught: s.goldenCaught,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
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,
|
||||
netTornUntil: s.netTornUntil,
|
||||
};
|
||||
}
|
||||
|
||||
/** Načte mapu všech uživatelských statistik ze storage. */
|
||||
async function loadAll(): Promise<Record<string, StoredButterflyStats>> {
|
||||
return (await storage.getData<Record<string, StoredButterflyStats>>(STORAGE_KEY)) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomicky zmutuje statistiky jednoho uživatele. Před zavoláním `mutator`
|
||||
* záznam znormalizuje a nechá dorůst škůdce.
|
||||
*/
|
||||
async function mutateUser(
|
||||
login: string,
|
||||
now: number,
|
||||
mutator: (mine: StoredButterflyStats) => void,
|
||||
): Promise<StoredButterflyStats> {
|
||||
const updated = await storage.updateData<Record<string, StoredButterflyStats>>(STORAGE_KEY, (current) => {
|
||||
const all = current ?? {};
|
||||
const mine = normalize(all[login] ?? defaultStats(now), now);
|
||||
growPests(mine, now);
|
||||
mutator(mine);
|
||||
all[login] = mine;
|
||||
return all;
|
||||
});
|
||||
return updated[login];
|
||||
}
|
||||
|
||||
// --- Chyby -------------------------------------------------------------------
|
||||
|
||||
/** Chyba vyhozená, když uživatel nemá dost mincí. */
|
||||
export class InsufficientCoinsError extends Error { }
|
||||
|
||||
// --- Veřejné API -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vrátí statistiky chytání motýlků daného uživatele (a nechá dorůst škůdce).
|
||||
*/
|
||||
export async function getStats(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, () => { /* jen dorůst škůdce */ });
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznamená dávku nachytaných motýlů, atomicky připíše mince (základ, kombo,
|
||||
* denní bonus) a aktualizuje statistiky. Vrátí i přehled odměn.
|
||||
*/
|
||||
export async function recordCatches(login: string, normal: number, golden: number, now: number = Date.now()): Promise<ButterflyCatchResult> {
|
||||
const n = Math.min(Math.max(Math.floor(normal) || 0, 0), MAX_BATCH);
|
||||
const g = Math.min(Math.max(Math.floor(golden) || 0, 0), MAX_BATCH);
|
||||
const batchTotal = n + g;
|
||||
const today = formatDate(new Date(now));
|
||||
|
||||
let coinsAwarded = 0;
|
||||
let dailyBonusApplied = false;
|
||||
let leveledUp = false;
|
||||
let premiumUnlocked = false;
|
||||
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
const oldCaught = s.caught;
|
||||
const oldLevel = levelForCaught(oldCaught);
|
||||
|
||||
// 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 = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily;
|
||||
|
||||
const newCaught = oldCaught + credited;
|
||||
s.caught = newCaught;
|
||||
s.coins += coinsAwarded;
|
||||
s.goldenCaught += creditGolden;
|
||||
if (credited > 0) s.lastCatchDay = today;
|
||||
|
||||
leveledUp = levelForCaught(newCaught) > oldLevel;
|
||||
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
|
||||
});
|
||||
|
||||
return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
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.netTornUntil = 0;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos. */
|
||||
export async function killWasp(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.wasps > 0) {
|
||||
s.wasps -= 1;
|
||||
s.waspsKilled += 1;
|
||||
}
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Koupí plašič ptáků: vyžene všechny ptáky a po dobu platnosti brání příletu
|
||||
* nových. Vyhodí {@link InsufficientCoinsError} při nedostatku mincí.
|
||||
*/
|
||||
export async function buyRepellent(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < REPELLENT_COST) { failed = true; return; }
|
||||
s.coins -= REPELLENT_COST;
|
||||
s.birdsScared += s.birds;
|
||||
s.birds = 0;
|
||||
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
||||
// 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ě). 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<ButterflyStats> {
|
||||
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<ButterflyStats> {
|
||||
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<ButterflyStats> {
|
||||
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<ButterflyStats> {
|
||||
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<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
s.caterpillarsDefeated += 1;
|
||||
s.coins += CATERPILLAR_REWARD;
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených
|
||||
* (při shodě dle počtu zlatých motýlů).
|
||||
*/
|
||||
export async function getLeaderboard(limit = 10): Promise<ButterflyLeaderboardEntry[]> {
|
||||
const all = await loadAll();
|
||||
return Object.entries(all)
|
||||
.map(([login, s]) => {
|
||||
const level = levelForCaught(s.caught ?? 0);
|
||||
return {
|
||||
login,
|
||||
caught: s.caught ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.caught - a.caught || b.goldenCaught - a.goldenCaught)
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './instrument'; // Sentry — musí být první import (auto-instrumentace)
|
||||
import * as Sentry from '@sentry/node';
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from 'cors';
|
||||
@@ -26,6 +28,7 @@ import devRoutes from "./routes/devRoutes";
|
||||
import changelogRoutes from "./routes/changelogRoutes";
|
||||
import groupRoutes from "./routes/groupRoutes";
|
||||
import storeRoutes from "./routes/storeRoutes";
|
||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -98,6 +101,9 @@ async function shutdown(signal: string) {
|
||||
await shutdownRedisStorage();
|
||||
}
|
||||
|
||||
// Flush zbývajících Sentry eventů/logů (no-op, když Sentry není inicializované)
|
||||
await Sentry.close(2000);
|
||||
|
||||
console.log('Graceful shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -122,6 +128,16 @@ app.get("/api/health/ready", async (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
/** Veřejná runtime konfigurace pro klienta (Sentry DSN apod.). */
|
||||
app.get("/api/config", (_req, res) => {
|
||||
res.status(200).json({
|
||||
sentry: {
|
||||
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
||||
environment: ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/whoami", (req, res) => {
|
||||
if (!HTTP_REMOTE_USER_ENABLED) {
|
||||
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
||||
@@ -258,12 +274,17 @@ app.use("/api/dev", devRoutes);
|
||||
app.use("/api/changelogs", changelogRoutes);
|
||||
app.use("/api/groups", groupRoutes);
|
||||
app.use("/api/stores", storeRoutes);
|
||||
app.use("/api/butterflies", butterflyRoutes);
|
||||
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
app.get('*splat', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Sentry error handler — hlásí jen 5xx (výchozí shouldHandleError), očekávané
|
||||
// chyby (403/409) do Sentry nepadají. Musí být před vlastním error middlewarem.
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: any, req: any, res: any, next: any) => {
|
||||
if (err instanceof InsufficientPermissions) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Sentry se musí inicializovat před načtením ostatních modulů (auto-instrumentace),
|
||||
// proto si tento soubor načítá .env sám — index.ts na to dojde až po importech.
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${process.env.NODE_ENV ?? 'production'}`) });
|
||||
|
||||
/** Klíče, jejichž hodnoty se před odesláním do Sentry redigují (hlavičky, tokeny, API klíče…). */
|
||||
const SENSITIVE_KEY = /authorization|cookie|token|secret|passw|api_?key|(^|[-_])key$/i;
|
||||
/** Tokeny/klíče schované uvnitř řetězců (query parametry, serializovaná JSON body). */
|
||||
const SENSITIVE_STRING = /((?:token|key|secret|password)"?\s*[=:]\s*"?)[^&\s"',}]+/gi;
|
||||
|
||||
/**
|
||||
* Rekurzivně rediguje citlivé hodnoty v datech eventu. Console eventy nesou celé
|
||||
* argumenty console.error — u axios chyb tedy i config s hlavičkami a request body
|
||||
* (Bolt token, Gotify/NTFY klíče), které nesmí odejít do Sentry.
|
||||
*/
|
||||
export function scrubEventData(value: unknown, depth = 0): unknown {
|
||||
if (depth > 8) return '[max depth]';
|
||||
if (typeof value === 'string') return value.replace(SENSITIVE_STRING, '$1[redacted]');
|
||||
if (Array.isArray(value)) return value.map(v => scrubEventData(v, depth + 1));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) ? '[redacted]' : scrubEventData(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Bez SENTRY_DSN (dev, Jest, E2E) zůstává Sentry úplně vypnuté.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV ?? 'production',
|
||||
// Interní logy SDK do konzole — pro ladění, když eventy nedorazí do Sentry
|
||||
debug: process.env.SENTRY_DEBUG === 'true',
|
||||
// Naše self-hosted instance má jen error monitoring (bez Sentry Logs):
|
||||
// console.error/warn se hlásí jako eventy, console.log (např. přechody stavů
|
||||
// Bolt trackingu) se k chybám přikládá jako breadcrumbs (výchozí integrace).
|
||||
integrations: [Sentry.captureConsoleIntegration({ levels: ['error', 'warn'] })],
|
||||
beforeSend(event) {
|
||||
if (event.extra) event.extra = scrubEventData(event.extra) as typeof event.extra;
|
||||
if (event.breadcrumbs) event.breadcrumbs = scrubEventData(event.breadcrumbs) as typeof event.breadcrumbs;
|
||||
if (event.contexts) event.contexts = scrubEventData(event.contexts) as typeof event.contexts;
|
||||
if (event.request) event.request = scrubEventData(event.request) as typeof event.request;
|
||||
if (event.exception) event.exception = scrubEventData(event.exception) as typeof event.exception;
|
||||
return event;
|
||||
},
|
||||
});
|
||||
console.log('Sentry: inicializováno');
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
let thirdTry = false;
|
||||
// První pokus - varianta "Obědy"
|
||||
let fonts = $('font.wsw-41');
|
||||
let font = undefined;
|
||||
let font: any = undefined;
|
||||
fonts.each((i, f) => {
|
||||
if ($(f).text().trim().startsWith('Obědy')) {
|
||||
font = f;
|
||||
@@ -309,12 +309,83 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
}
|
||||
})
|
||||
}
|
||||
// Pátý pokus - formát z 07/2026: hlavička "Jídelní lístek" je v libovolném <font>
|
||||
// (typicky wsw-31), jednotlivé položky jsou v samostatných <p> uvnitř vnořeného
|
||||
// <div><font class="wsw-31">, den a jeho první položka mohou být ve stejném <p>
|
||||
// oddělené <br>. Třída wsw-41 nyní obsahuje otevírací dobu, nikoli menu.
|
||||
let fifthTry = false;
|
||||
if (!font) {
|
||||
$('font').each((_, f) => {
|
||||
if (font) return;
|
||||
if ($(f).text().trim().startsWith('Jídelní lístek')) {
|
||||
font = f;
|
||||
fifthTry = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!font) {
|
||||
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
|
||||
}
|
||||
|
||||
const result: Food[][] = [];
|
||||
|
||||
// Z textu jedné položky odvodí název, cenu a alergeny (sdíleno napříč formáty).
|
||||
const buildFoodItem = (text: string): Food => {
|
||||
let price = 'na\xA0váhu';
|
||||
let nameRaw = text.replace('•', '');
|
||||
if (text.toLowerCase().endsWith('kč')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
|
||||
price = `${split.slice(1)[0]}\xA0Kč`;
|
||||
nameRaw = split[0].replace('•', '');
|
||||
} else if (text.toLowerCase().endsWith(',-')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
|
||||
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`;
|
||||
nameRaw = split[0].replace('•', '');
|
||||
}
|
||||
if (nameRaw.endsWith('–') || nameRaw.endsWith('—')) {
|
||||
nameRaw = nameRaw.slice(0, -1).trim();
|
||||
}
|
||||
const parsed = parseAllergens(nameRaw);
|
||||
return {
|
||||
amount: '-',
|
||||
name: parsed.cleanName,
|
||||
price,
|
||||
isSoup: isTextSoupName(parsed.cleanName),
|
||||
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Pátý pokus (parsing): projdeme všechny <p> ve společném kontejneru, každý rozdělíme
|
||||
// podle <br> na jednotlivé řádky. Řádek s názvem dne přepne aktuální den, ostatní řádky
|
||||
// jsou položky. Zvládne tak i den a první položku ve stejném <p>.
|
||||
if (fifthTry) {
|
||||
let parsing = false;
|
||||
let currentDayIndex = 0;
|
||||
const container = $(font).parent().parent();
|
||||
container.find('p').each((_, p) => {
|
||||
const pHtml = $(p).html() ?? '';
|
||||
const lines = pHtml.split(/<br\s*\/?>/i)
|
||||
.map(part => $(`<span>${part}</span>`).text().trim())
|
||||
.filter(line => line.length > 0);
|
||||
for (const text of lines) {
|
||||
const lower = text.toLocaleLowerCase();
|
||||
if (DAYS_IN_WEEK.includes(lower)) {
|
||||
currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
|
||||
parsing = true;
|
||||
result[currentDayIndex] ??= [];
|
||||
continue;
|
||||
}
|
||||
if (!parsing) {
|
||||
continue;
|
||||
}
|
||||
result[currentDayIndex].push(buildFoodItem(text));
|
||||
}
|
||||
});
|
||||
return finalizeTechTower(result, $(font).text().trim(), firstDayOfWeek);
|
||||
}
|
||||
|
||||
// Čtvrtý pokus (detekce): thirdTry našel <font>, ale nový formát má každý den v jednom <p>
|
||||
// s položkami oddělenými <br> místo separátních <p> pro každou položku
|
||||
const fourthTry = thirdTry && $(font).parent().siblings('p').toArray().some(el => {
|
||||
@@ -345,31 +416,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
for (const text of itemLines) {
|
||||
let price = 'na\xA0váhu';
|
||||
let nameRaw = text.replace('•', '');
|
||||
if (text.toLowerCase().endsWith('kč')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
|
||||
price = `${split.slice(1)[0]}\xA0Kč`;
|
||||
nameRaw = split[0].replace('•', '');
|
||||
} else if (text.toLowerCase().endsWith(',-')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
|
||||
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`;
|
||||
nameRaw = split[0].replace('•', '');
|
||||
}
|
||||
if (nameRaw.endsWith('–') || nameRaw.endsWith('—')) {
|
||||
nameRaw = nameRaw.slice(0, -1).trim();
|
||||
}
|
||||
|
||||
const parsed = parseAllergens(nameRaw);
|
||||
result[dayIndex].push({
|
||||
amount: '-',
|
||||
name: parsed.cleanName,
|
||||
price,
|
||||
isSoup: isTextSoupName(parsed.cleanName),
|
||||
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
||||
});
|
||||
result[dayIndex].push(buildFoodItem(text));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -389,47 +436,40 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
|
||||
continue;
|
||||
}
|
||||
let price = 'na\xA0váhu';
|
||||
let nameRaw = text.replace('•', '');
|
||||
if (text.toLowerCase().endsWith('kč')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
|
||||
price = `${split.slice(1)[0]}\xA0Kč`
|
||||
nameRaw = split[0].replace('•', '');
|
||||
} else if (text.toLowerCase().endsWith(',-')) {
|
||||
const tmp = text.replace('\xA0', ' ').split(' ');
|
||||
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
|
||||
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`
|
||||
nameRaw = split[0].replace('•', '');
|
||||
}
|
||||
if (nameRaw.endsWith('–')|| nameRaw.endsWith('—')) {
|
||||
nameRaw = nameRaw.slice(0, -1).trim();
|
||||
}
|
||||
|
||||
const parsed = parseAllergens(nameRaw);
|
||||
result[currentDayIndex] ??= [];
|
||||
result[currentDayIndex].push({
|
||||
amount: '-',
|
||||
name: parsed.cleanName,
|
||||
price,
|
||||
isSoup: isTextSoupName(parsed.cleanName),
|
||||
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
||||
})
|
||||
result[currentDayIndex].push(buildFoodItem(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
|
||||
const headerText = $(font).text().trim();
|
||||
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
|
||||
return finalizeTechTower(result, $(font).text().trim(), firstDayOfWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ověří, že datum v hlavičce TechTower spadá do požadovaného týdne, jinak vyhodí StaleWeekError.
|
||||
*
|
||||
* Datum v hlavičce nemusí být přesně pondělí (např. je-li pondělí svátek, začíná hlavička
|
||||
* úterým), proto stačí, aby první nalezené datum padlo do libovolného dne požadovaného týdne.
|
||||
* Toleruje i mezery za tečkami (formát "7. 7.-10. 7.").
|
||||
*/
|
||||
const finalizeTechTower = (result: Food[][], headerText: string, firstDayOfWeek: Date): Food[][] => {
|
||||
const dateMatch = headerText.match(/(\d{1,2})\.\s*(\d{1,2})\./);
|
||||
if (dateMatch) {
|
||||
const foundDay = parseInt(dateMatch[1]);
|
||||
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based
|
||||
if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) {
|
||||
let matchesWeek = false;
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const day = new Date(firstDayOfWeek);
|
||||
day.setDate(firstDayOfWeek.getDate() + d);
|
||||
if (day.getDate() === foundDay && day.getMonth() === foundMonth) {
|
||||
matchesWeek = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchesWeek) {
|
||||
throw new StaleWeekError(result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import {
|
||||
getStats,
|
||||
recordCatches,
|
||||
repairNet,
|
||||
killWasp,
|
||||
buyRepellent,
|
||||
reportNetTorn,
|
||||
robbery,
|
||||
defeatThief,
|
||||
caterpillarAte,
|
||||
defeatCaterpillar,
|
||||
getLeaderboard,
|
||||
InsufficientCoinsError,
|
||||
} from "../butterflies";
|
||||
import { CatchButterfliesData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await getStats(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/catch", async (req: Request<{}, any, CatchButterfliesData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (typeof req.body?.normal !== 'number' || typeof req.body?.golden !== 'number') {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await recordCatches(login, req.body.normal, req.body.golden);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/repair", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await repairNet(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) {
|
||||
if (e instanceof InsufficientCoinsError) {
|
||||
return res.status(402).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/killWasp", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await killWasp(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/repellent", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await buyRepellent(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) {
|
||||
if (e instanceof InsufficientCoinsError) {
|
||||
return res.status(402).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tear", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await reportNetTorn(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.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));
|
||||
const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : undefined;
|
||||
const data = await getLeaderboard(Number.isFinite(limit) ? limit : undefined);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,7 +4,7 @@ import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuS
|
||||
import { getTodayMock } from "./mock";
|
||||
import { removeAllUserPizzas } from "./pizza";
|
||||
import { getStores } from "./stores";
|
||||
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||
import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, WeekMenu } from "../../types/gen/types.gen";
|
||||
|
||||
const storage = getStorage();
|
||||
const MENU_PREFIX = 'menu';
|
||||
@@ -62,10 +62,54 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
|
||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||
}
|
||||
// Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují.
|
||||
// Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal).
|
||||
if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) {
|
||||
await storage.updateData<ClientData>(getDataKey(date ?? getToday(), slot), (current) => {
|
||||
const data = current ?? getEmptyData(date);
|
||||
pruneStaleFoodChoices(data.choices, clientData.menus!);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
}
|
||||
return clientData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Odstraní z voleb uživatelů indexy jídel, které v aktuálním menu daného podniku již neexistují.
|
||||
*
|
||||
* Nastává typicky poté, co podnik ze svého webu odebere menu předchozího týdne: aplikace pak
|
||||
* pro daný den vyparsuje méně jídel než dříve a dříve uložený index (např. z „budoucího“ týdne)
|
||||
* se stane neplatným. Bez očištění by klient spadl při přístupu na neexistující jídlo a data
|
||||
* by zůstala nekonzistentní.
|
||||
*
|
||||
* Očista se provádí pouze u podniků, které mají v menu alespoň jedno jídlo – prázdné menu
|
||||
* (zavřeno / neúspěšné načtení) volby záměrně nemaže, aby nedošlo ke ztrátě platných voleb.
|
||||
*
|
||||
* @param choices volby uživatelů
|
||||
* @param menus menu podniků pro daný den
|
||||
* @returns true, pokud došlo k odebrání alespoň jednoho indexu
|
||||
*/
|
||||
export function pruneStaleFoodChoices(choices: LunchChoices, menus: RestaurantDayMenuMap): boolean {
|
||||
let mutated = false;
|
||||
for (const restaurant of Object.keys(Restaurant) as Restaurant[]) {
|
||||
const foodCount = menus[restaurant]?.food?.length ?? 0;
|
||||
if (foodCount === 0) continue; // prázdné menu volby nemažeme
|
||||
const locationChoices = choices[restaurant];
|
||||
if (!locationChoices) continue;
|
||||
for (const userChoice of Object.values(locationChoices)) {
|
||||
const selected = userChoice.selectedFoods;
|
||||
if (!selected?.length) continue;
|
||||
const filtered = selected.filter(index => index < foodCount);
|
||||
if (filtered.length !== selected.length) {
|
||||
userChoice.selectedFoods = filtered;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí klíč, pod kterým je uloženo menu pro týden příslušící předanému datu.
|
||||
*
|
||||
|
||||
@@ -291,18 +291,18 @@ describe('DEV simulace (boltSimulator + checkBoltTracking)', () => {
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('první poll nastaví stav accepted a ETA', async () => {
|
||||
test('první poll nastaví stav waiting_acceptance a ETA', async () => {
|
||||
await checkBoltTracking();
|
||||
const g = await getGroup();
|
||||
expect(g.boltOrderState).toBe('accepted');
|
||||
expect(g.deliveryAt).toBe(computeDeliveryHHMM(1800));
|
||||
expect(g.boltOrderState).toBe('waiting_acceptance');
|
||||
expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100));
|
||||
});
|
||||
|
||||
test('advance posune sekvenci na preparing', async () => {
|
||||
test('advance posune sekvenci na accepted', async () => {
|
||||
await checkBoltTracking();
|
||||
advanceBoltSimulation(groupId);
|
||||
await checkBoltTracking();
|
||||
expect((await getGroup()).boltOrderState).toBe('preparing');
|
||||
expect((await getGroup()).boltOrderState).toBe('accepted');
|
||||
});
|
||||
|
||||
test('ruční nastavení stavu (override) se projeví při pollu', async () => {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import getStorage from '../storage';
|
||||
import {
|
||||
getStats,
|
||||
recordCatches,
|
||||
repairNet,
|
||||
killWasp,
|
||||
buyRepellent,
|
||||
reportNetTorn,
|
||||
robbery,
|
||||
defeatThief,
|
||||
caterpillarAte,
|
||||
defeatCaterpillar,
|
||||
getLeaderboard,
|
||||
growPests,
|
||||
levelForCaught,
|
||||
levelThreshold,
|
||||
levelProgress,
|
||||
titleForLevel,
|
||||
comboBonus,
|
||||
InsufficientCoinsError,
|
||||
COIN_BASE,
|
||||
GOLD_VALUE,
|
||||
REPAIR_COST,
|
||||
DAILY_BONUS,
|
||||
PREMIUM_MILESTONE,
|
||||
WASP_MAX,
|
||||
BIRD_MAX,
|
||||
WASP_GROWTH_MS,
|
||||
BIRD_GROWTH_MS,
|
||||
REPELLENT_COST,
|
||||
REPELLENT_DURATION_MS,
|
||||
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();
|
||||
});
|
||||
|
||||
/** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */
|
||||
async function seed(login: string, data: any) {
|
||||
const storage = getStorage();
|
||||
const all = (await storage.getData<any>(KEY)) ?? {};
|
||||
all[login] = data;
|
||||
await storage.setData(KEY, all);
|
||||
}
|
||||
|
||||
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('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(3)).toBe(0); // pod prahem 4
|
||||
});
|
||||
|
||||
test('větší dávka dává rostoucí bonus se stropem', () => {
|
||||
expect(comboBonus(4)).toBe(1);
|
||||
expect(comboBonus(5)).toBe(2);
|
||||
expect(comboBonus(1000)).toBe(8); // COMBO_CAP
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
test('pro nového uživatele vrací výchozí prázdné statistiky', async () => {
|
||||
const stats = await getStats(USER);
|
||||
expect(stats.caught).toBe(0);
|
||||
expect(stats.coins).toBe(0);
|
||||
expect(stats.goldenCaught).toBe(0);
|
||||
expect(stats.level).toBe(1);
|
||||
expect(stats.title).toBe('Začátečník se síťkou');
|
||||
expect(stats.wasps).toBe(0);
|
||||
expect(stats.birds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCatches – mince', () => {
|
||||
test('běžný motýl dá základní minci + denní bonus', async () => {
|
||||
const result = await recordCatches(USER, 1, 0);
|
||||
expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS);
|
||||
expect(result.dailyBonusApplied).toBe(true);
|
||||
expect(result.stats.caught).toBe(1);
|
||||
expect(result.stats.coins).toBe(COIN_BASE + DAILY_BONUS);
|
||||
});
|
||||
|
||||
test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => {
|
||||
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||
const result = await recordCatches(USER, 0, 1);
|
||||
expect(result.coinsAwarded).toBe(GOLD_VALUE);
|
||||
expect(result.dailyBonusApplied).toBe(false);
|
||||
expect(result.stats.goldenCaught).toBe(1);
|
||||
expect(result.stats.caught).toBe(6);
|
||||
});
|
||||
|
||||
test('kombo se připočítá u větší dávky', async () => {
|
||||
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||
const result = await recordCatches(USER, 5, 0);
|
||||
expect(result.coinsAwarded).toBe(5 * COIN_BASE + comboBonus(5));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCatches – denní bonus', () => {
|
||||
test('denní bonus se připíše jen jednou za den', async () => {
|
||||
const first = await recordCatches(USER, 1, 0);
|
||||
expect(first.dailyBonusApplied).toBe(true);
|
||||
const second = await recordCatches(USER, 1, 0);
|
||||
expect(second.dailyBonusApplied).toBe(false);
|
||||
expect(second.coinsAwarded).toBe(COIN_BASE);
|
||||
});
|
||||
|
||||
test('bonus se znovu připíše po změně dne', async () => {
|
||||
await seed(USER, { caught: 3, coins: 3, goldenCaught: 0, lastCatchDay: '2000-01-01' });
|
||||
const result = await recordCatches(USER, 1, 0);
|
||||
expect(result.dailyBonusApplied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCatches – progres a milníky', () => {
|
||||
test('leveledUp se nastaví při překročení hranice úrovně', async () => {
|
||||
await seed(USER, { caught: 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()) });
|
||||
const result = await recordCatches(USER, 1, 0);
|
||||
expect(result.premiumUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
test('bez překročení milníku premiumUnlocked zůstane false', async () => {
|
||||
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||
const result = await recordCatches(USER, 1, 0);
|
||||
expect(result.premiumUnlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('growPests', () => {
|
||||
test('vosy přibývají v čase do maxima', () => {
|
||||
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||
growPests(s, 2 * WASP_GROWTH_MS + 100);
|
||||
expect(s.wasps).toBe(2);
|
||||
growPests(s, 1_000_000 * WASP_GROWTH_MS);
|
||||
expect(s.wasps).toBe(WASP_MAX);
|
||||
});
|
||||
|
||||
test('zachovává rozpracovaný čas (dvě poloviční okna = jedna vosa)', () => {
|
||||
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||
growPests(s, Math.floor(WASP_GROWTH_MS * 0.6));
|
||||
expect(s.wasps).toBe(0);
|
||||
growPests(s, Math.floor(WASP_GROWTH_MS * 1.2));
|
||||
expect(s.wasps).toBe(1);
|
||||
});
|
||||
|
||||
test('ptáci nepřibývají při aktivním plašiči', () => {
|
||||
const now = 1_000_000;
|
||||
const s: any = { wasps: 0, birds: 0, repellentUntil: now + 10 * BIRD_GROWTH_MS, lastWaspAt: now, lastBirdAt: now };
|
||||
growPests(s, now + 3 * BIRD_GROWTH_MS);
|
||||
expect(s.birds).toBe(0);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('sníží počet vos a zvýší statistiku zahubených', async () => {
|
||||
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 3, lastWaspAt: Date.now() });
|
||||
const stats = await killWasp(USER);
|
||||
expect(stats.wasps).toBe(2);
|
||||
expect(stats.waspsKilled).toBe(1);
|
||||
});
|
||||
|
||||
test('při nula vosách nic nepokazí', async () => {
|
||||
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 0, lastWaspAt: Date.now() });
|
||||
const stats = await killWasp(USER);
|
||||
expect(stats.wasps).toBe(0);
|
||||
expect(stats.waspsKilled).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buyRepellent', () => {
|
||||
test('vyžene ptáky, aktivuje plašič a odečte mince', async () => {
|
||||
const now = 5_000_000;
|
||||
await seed(USER, { caught: 0, coins: REPELLENT_COST + 3, goldenCaught: 0, birds: 3, lastBirdAt: now });
|
||||
const stats = await buyRepellent(USER, now);
|
||||
expect(stats.coins).toBe(3);
|
||||
expect(stats.birds).toBe(0);
|
||||
expect(stats.birdsScared).toBe(3);
|
||||
expect(stats.repellentUntil).toBe(now + REPELLENT_DURATION_MS);
|
||||
});
|
||||
|
||||
test('vyhodí chybu při nedostatku mincí', async () => {
|
||||
await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
|
||||
await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
|
||||
});
|
||||
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeaderboard', () => {
|
||||
test('řadí sestupně dle počtu chycených a respektuje limit', async () => {
|
||||
await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 });
|
||||
await seed(OTHER, { caught: 200, coins: 0, goldenCaught: 0 });
|
||||
await seed('jana', { caught: 50, coins: 0, goldenCaught: 10 });
|
||||
|
||||
const all = await getLeaderboard(10);
|
||||
expect(all.map(e => e.login)).toEqual([OTHER, USER, 'jana']);
|
||||
|
||||
const top = await getLeaderboard(2);
|
||||
expect(top).toHaveLength(2);
|
||||
expect(top[0].login).toBe(OTHER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { scrubEventData } from '../instrument';
|
||||
|
||||
describe('scrubEventData', () => {
|
||||
test('rediguje citlivé klíče (hlavičky, tokeny, API klíče)', () => {
|
||||
const scrubbed = scrubEventData({
|
||||
headers: { Authorization: 'Bearer abc123', 'X-Gotify-Key': 'gk1', accept: 'application/json' },
|
||||
data: { token: 'deadbeef'.repeat(8) },
|
||||
apiKey: 'k',
|
||||
}) as any;
|
||||
expect(scrubbed.headers.Authorization).toBe('[redacted]');
|
||||
expect(scrubbed.headers['X-Gotify-Key']).toBe('[redacted]');
|
||||
expect(scrubbed.headers.accept).toBe('application/json');
|
||||
expect(scrubbed.data.token).toBe('[redacted]');
|
||||
expect(scrubbed.apiKey).toBe('[redacted]');
|
||||
});
|
||||
|
||||
test('rediguje tokeny uvnitř řetězců (query parametry, JSON body)', () => {
|
||||
expect(scrubEventData('https://ntfy.example.com/topic?token=tajny&x=1')).toBe(
|
||||
'https://ntfy.example.com/topic?token=[redacted]&x=1'
|
||||
);
|
||||
expect(scrubEventData('{"token":"deadbeef","other":1}')).toBe('{"token":"[redacted]","other":1}');
|
||||
});
|
||||
|
||||
test('nechává běžná data beze změny včetně vnořených polí', () => {
|
||||
const input = { breadcrumbs: [{ message: 'Bolt tracking: stav accepted → preparing', level: 'log' }], count: 2 };
|
||||
expect(scrubEventData(input)).toEqual(input);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,48 @@ describe('TechTower parser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TechTower parser – nový formát 07/2026', () => {
|
||||
// Pondělí 6.7.2026 (pondělí je svátek, hlavička začíná úterým "7. 7.-10. 7.")
|
||||
const MONDAY_2026 = new Date('2026-07-06');
|
||||
|
||||
beforeEach(() => {
|
||||
mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower-2026-07.html') });
|
||||
});
|
||||
|
||||
test('vrátí pole o délce 5', async () => {
|
||||
const menu = await getMenuTechTower(MONDAY_2026);
|
||||
expect(menu).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('středeční menu obsahuje polévku a hlavní jídla s cenou', async () => {
|
||||
const menu = await getMenuTechTower(MONDAY_2026);
|
||||
// index 2 = středa (Boršč + hlavní jídla)
|
||||
expect(menu[2].some(f => f.isSoup)).toBe(true);
|
||||
expect(menu[2].some(f => !f.isSoup && f.price.includes('Kč'))).toBe(true);
|
||||
});
|
||||
|
||||
test('cena i alergeny jsou správně odděleny od názvu jídla', async () => {
|
||||
const menu = await getMenuTechTower(MONDAY_2026);
|
||||
const polevka = menu[2].find(f => f.isSoup);
|
||||
expect(polevka).toBeDefined();
|
||||
// "Boršč 1,7,9 — 45 Kč" → název bez ceny a bez alergenů, cena a alergeny zvlášť
|
||||
expect(polevka!.name).not.toMatch(/Kč/);
|
||||
expect(polevka!.name).not.toMatch(/\d/);
|
||||
expect(polevka!.price).toContain('Kč');
|
||||
expect(Array.isArray(polevka!.allergens)).toBe(true);
|
||||
expect(polevka!.allergens!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('nehodí StaleWeekError pro aktuální týden, i když pondělí je svátek', async () => {
|
||||
await expect(getMenuTechTower(MONDAY_2026)).resolves.toHaveLength(5);
|
||||
});
|
||||
|
||||
test('hodí StaleWeekError pro jiný týden', async () => {
|
||||
const jinyTyden = new Date('2026-06-01');
|
||||
await expect(getMenuTechTower(jinyTyden)).rejects.toBeInstanceOf(StaleWeekError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SenkSerikova parser', () => {
|
||||
beforeEach(() => {
|
||||
// SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { pruneStaleFoodChoices } from '../service';
|
||||
import { LunchChoices, RestaurantDayMenuMap } from '../../../types/gen/types.gen';
|
||||
|
||||
// Pomocná fabrika menu s daným počtem jídel pro TECHTOWER.
|
||||
const menusWithTechTowerFoods = (count: number): RestaurantDayMenuMap => ({
|
||||
TECHTOWER: {
|
||||
lastUpdate: 0,
|
||||
closed: false,
|
||||
food: Array.from({ length: count }, (_, i) => ({ name: `Jídlo ${i}` })),
|
||||
},
|
||||
} as unknown as RestaurantDayMenuMap);
|
||||
|
||||
describe('pruneStaleFoodChoices', () => {
|
||||
test('odebere index jídla, který v aktuálním menu již neexistuje', () => {
|
||||
const choices: LunchChoices = {
|
||||
TECHTOWER: { alice: { selectedFoods: [0, 9] } },
|
||||
};
|
||||
const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5));
|
||||
expect(mutated).toBe(true);
|
||||
expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([0]);
|
||||
});
|
||||
|
||||
test('nechá platné indexy beze změny a vrátí false', () => {
|
||||
const choices: LunchChoices = {
|
||||
TECHTOWER: { alice: { selectedFoods: [0, 4] } },
|
||||
};
|
||||
const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5));
|
||||
expect(mutated).toBe(false);
|
||||
expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([0, 4]);
|
||||
});
|
||||
|
||||
test('u prázdného menu (zavřeno / neúspěšné načtení) volby nemaže', () => {
|
||||
const choices: LunchChoices = {
|
||||
TECHTOWER: { alice: { selectedFoods: [3] } },
|
||||
};
|
||||
const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(0));
|
||||
expect(mutated).toBe(false);
|
||||
expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([3]);
|
||||
});
|
||||
|
||||
test('očistí volby více uživatelů najednou', () => {
|
||||
const choices: LunchChoices = {
|
||||
TECHTOWER: {
|
||||
alice: { selectedFoods: [7] },
|
||||
bob: { selectedFoods: [1, 2] },
|
||||
},
|
||||
};
|
||||
const mutated = pruneStaleFoodChoices(choices, menusWithTechTowerFoods(5));
|
||||
expect(mutated).toBe(true);
|
||||
expect(choices.TECHTOWER!.alice.selectedFoods).toEqual([]);
|
||||
expect(choices.TECHTOWER!.bob.selectedFoods).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ export const initWebsocket = (server: any) => {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`New client connected: ${socket.id}`);
|
||||
// console.log(`New client connected: ${socket.id}`);
|
||||
|
||||
socket.on("join", (login: string) => {
|
||||
if (login && typeof login === "string") {
|
||||
@@ -25,7 +25,7 @@ export const initWebsocket = (server: any) => {
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
// console.log(`Client disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
return io;
|
||||
|
||||
@@ -14,6 +14,8 @@ paths:
|
||||
$ref: "./paths/qr/generate.yml"
|
||||
/data:
|
||||
$ref: "./paths/getData.yml"
|
||||
/config:
|
||||
$ref: "./paths/config/config.yml"
|
||||
|
||||
# Restaurace a jídla (/api/food)
|
||||
/food/addChoice:
|
||||
@@ -79,6 +81,30 @@ paths:
|
||||
/suggestions/delete:
|
||||
$ref: "./paths/suggestions/delete.yml"
|
||||
|
||||
# Chytání motýlků (/api/butterflies)
|
||||
/butterflies:
|
||||
$ref: "./paths/butterflies/getStats.yml"
|
||||
/butterflies/catch:
|
||||
$ref: "./paths/butterflies/catch.yml"
|
||||
/butterflies/repair:
|
||||
$ref: "./paths/butterflies/repair.yml"
|
||||
/butterflies/killWasp:
|
||||
$ref: "./paths/butterflies/killWasp.yml"
|
||||
/butterflies/repellent:
|
||||
$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"
|
||||
|
||||
# Changelog (/api/changelogs)
|
||||
/changelogs:
|
||||
$ref: "./paths/changelogs/getChangelogs.yml"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
post:
|
||||
operationId: catchButterflies
|
||||
summary: >-
|
||||
Zaznamená dávku nachytaných motýlů, připíše mince (základ, kombo a případný
|
||||
denní bonus) a vrátí aktualizované statistiky.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyCatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizované statistiky a přehled připsaných odměn.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyCatchResult"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,10 @@
|
||||
get:
|
||||
operationId: getButterflyStats
|
||||
summary: Vrátí statistiky chytání motýlků přihlášeného uživatele.
|
||||
responses:
|
||||
"200":
|
||||
description: Statistiky chytání motýlků přihlášeného uživatele.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||
@@ -0,0 +1,10 @@
|
||||
post:
|
||||
operationId: killWasp
|
||||
summary: Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos.
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizované statistiky po zahubení vosy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||
@@ -0,0 +1,17 @@
|
||||
get:
|
||||
operationId: getButterflyLeaderboard
|
||||
summary: Vrátí žebříček nejlepších chytačů motýlů seřazený dle počtu chycených.
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
description: Maximální počet záznamů žebříčku (výchozí 10)
|
||||
responses:
|
||||
"200":
|
||||
description: Žebříček chytačů motýlů.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyLeaderboard"
|
||||
@@ -0,0 +1,14 @@
|
||||
post:
|
||||
operationId: repairNet
|
||||
summary: >-
|
||||
Zašije protrženou síťku za mince. Vrátí aktualizované statistiky, nebo 402
|
||||
pokud uživatel nemá dostatek mincí.
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizované statistiky po zaplacení opravy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||
"402":
|
||||
description: Uživatel nemá dostatek mincí na opravu síťky.
|
||||
@@ -0,0 +1,14 @@
|
||||
post:
|
||||
operationId: buyRepellent
|
||||
summary: >-
|
||||
Koupí plašič ptáků za mince – vyžene všechny ptáky a po dobu platnosti brání
|
||||
příletu nových. Vrátí 402 při nedostatku mincí.
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizované statistiky po koupi plašiče.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||
"402":
|
||||
description: Uživatel nemá dostatek mincí na plašič.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,12 @@
|
||||
post:
|
||||
operationId: reportNetTorn
|
||||
summary: >-
|
||||
Zaznamená, že pták protrhl síťku (perzistentně, aby se stav neobešel
|
||||
obnovením stránky).
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizované statistiky s protrženou síťkou.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||
@@ -0,0 +1,24 @@
|
||||
get:
|
||||
operationId: getConfig
|
||||
summary: Vrátí veřejnou runtime konfiguraci pro klienta (např. Sentry DSN). Nevyžaduje autentizaci.
|
||||
security: [] # Nevyžaduje autentizaci
|
||||
responses:
|
||||
"200":
|
||||
description: Veřejná konfigurace klienta
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sentry]
|
||||
properties:
|
||||
sentry:
|
||||
type: object
|
||||
required: [dsn, environment]
|
||||
properties:
|
||||
dsn:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Sentry DSN pro prohlížeč (null = Sentry vypnuté)
|
||||
environment:
|
||||
type: string
|
||||
description: Prostředí serveru (development/production)
|
||||
@@ -926,3 +926,140 @@ PendingQr:
|
||||
groupId:
|
||||
description: ID skupiny objednávky, ke které QR patří
|
||||
type: string
|
||||
|
||||
# --- CHYTÁNÍ MOTÝLKŮ ---
|
||||
ButterflyStats:
|
||||
description: Statistiky chytání motýlků pro jednoho uživatele
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- caught
|
||||
- coins
|
||||
- goldenCaught
|
||||
- level
|
||||
- title
|
||||
- levelProgress
|
||||
- wasps
|
||||
- birds
|
||||
- waspsKilled
|
||||
- birdsScared
|
||||
- thievesDefeated
|
||||
- caterpillarsDefeated
|
||||
- repellentUntil
|
||||
- netTornUntil
|
||||
properties:
|
||||
caught:
|
||||
description: Celkový počet chycených motýlů
|
||||
type: integer
|
||||
coins:
|
||||
description: Aktuální počet mincí k utracení
|
||||
type: integer
|
||||
goldenCaught:
|
||||
description: Počet chycených vzácných zlatých motýlů
|
||||
type: integer
|
||||
level:
|
||||
description: Aktuální úroveň sběratele (odvozená z počtu chycených)
|
||||
type: integer
|
||||
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
|
||||
wasps:
|
||||
description: Aktuální počet poletujících vos (perzistentní, přibývají v čase)
|
||||
type: integer
|
||||
birds:
|
||||
description: Aktuální počet poletujících ptáků (perzistentní, přibývají v čase)
|
||||
type: integer
|
||||
waspsKilled:
|
||||
description: Celkový počet zahubených vos
|
||||
type: integer
|
||||
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
|
||||
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
|
||||
additionalProperties: false
|
||||
required:
|
||||
- normal
|
||||
- golden
|
||||
properties:
|
||||
normal:
|
||||
description: Počet běžných motýlů chycených v této dávce
|
||||
type: integer
|
||||
golden:
|
||||
description: Počet zlatých motýlů chycených v této dávce
|
||||
type: integer
|
||||
ButterflyCatchResult:
|
||||
description: Výsledek zaznamenání dávky úlovků včetně přehledu odměn
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- stats
|
||||
- coinsAwarded
|
||||
- dailyBonusApplied
|
||||
- leveledUp
|
||||
- premiumUnlocked
|
||||
properties:
|
||||
stats:
|
||||
$ref: "#/ButterflyStats"
|
||||
coinsAwarded:
|
||||
description: Počet mincí připsaných za tuto dávku (základ + kombo + denní bonus)
|
||||
type: integer
|
||||
dailyBonusApplied:
|
||||
description: Zda byl v této dávce připsán denní bonus za první úlovek dne
|
||||
type: boolean
|
||||
leveledUp:
|
||||
description: Zda uživatel touto dávkou postoupil alespoň o jednu úroveň
|
||||
type: boolean
|
||||
premiumUnlocked:
|
||||
description: Zda uživatel touto dávkou dosáhl milníku pro prémiovou síťku
|
||||
type: boolean
|
||||
ButterflyLeaderboardEntry:
|
||||
description: Jeden záznam v žebříčku chytačů motýlů
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- login
|
||||
- caught
|
||||
- goldenCaught
|
||||
- level
|
||||
- title
|
||||
properties:
|
||||
login:
|
||||
description: Přihlašovací jméno chytače
|
||||
type: string
|
||||
caught:
|
||||
description: Celkový počet chycených motýlů
|
||||
type: integer
|
||||
goldenCaught:
|
||||
description: Počet chycených zlatých motýlů
|
||||
type: integer
|
||||
level:
|
||||
description: Aktuální úroveň sběratele
|
||||
type: integer
|
||||
title:
|
||||
description: Titul odpovídající aktuální úrovni
|
||||
type: string
|
||||
ButterflyLeaderboard:
|
||||
description: Žebříček chytačů motýlů seřazený sestupně dle počtu chycených
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/ButterflyLeaderboardEntry"
|
||||
|
||||