@energy8platform/platform-core 0.30.5 → 0.30.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The "POWERED BY ENERGY8 ENGINE" pre-roll shown at the very start of every
3
+ * boot, above whatever preloader variant the game selected.
4
+ *
5
+ * This is deliberately NOT configurable: it is the platform's attribution and
6
+ * plays for every game, on every boot. `CSSPreloader` mounts it as a layer
7
+ * inside the same overlay it already owns, so there is still exactly one
8
+ * element to tear down and no gap where the page shows through.
9
+ */
10
+
11
+ /** Total splash length: logo fades in, holds, fades out, then the black backdrop lifts. */
12
+ export const SPLASH_DURATION_MS = 2000;
13
+
14
+ /**
15
+ * How long the game's own brand must remain readable once the splash has
16
+ * lifted. Without this a fast boot would swap straight from the splash into
17
+ * the game and the game's logo would never be seen.
18
+ */
19
+ export const BRAND_FLOOR_MS = 1000;
20
+
21
+ /** Class on the splash layer — also the hook tests assert against. */
22
+ export const SPLASH_CLASS = 'ge-splash';
23
+
24
+ /**
25
+ * The official artwork as vector outlines. Three groups, in the source's own
26
+ * order: the ENERGY8 wordmark, the "POWERED BY" line above it, and "ENGINE"
27
+ * below. Two deviations from the export:
28
+ *
29
+ * - Every shape is forced to #fff. The source paints `.st0 { fill: #02020a }`
30
+ * and leaves the two text groups with no fill at all (so they default to
31
+ * black) — on a black splash the whole logo would be invisible. Colours are
32
+ * set as attributes rather than a `<style>` block on purpose: inlining the
33
+ * export's `<style>` would leak a rule for the very generic `.st0` selector
34
+ * into the host page.
35
+ * - Ten zero-area `<path d="M…h0Z"/>` stubs are dropped (Illustrator artifacts).
36
+ */
37
+ const SPLASH_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 792.22 250.81" class="ge-splash-logo" role="img">
38
+ <title>Powered by Energy8 Engine</title>
39
+ <g fill="#fff" stroke="#fff" stroke-miterlimit="10">
40
+ <polygon points="126.9 47.45 33.24 47.45 .62 199.67 94.28 199.72 101.83 164.51 43.28 164.54 48.29 141.12 83.41 141.15 90.96 105.92 55.8 106.02 60.85 82.55 119.38 82.61 126.9 47.45"/>
41
+ <polygon points="337.67 47.45 243.99 47.45 211.39 199.65 305.06 199.63 312.59 164.42 254.06 164.43 259.05 141.1 294.18 141.08 301.73 105.87 266.58 105.98 271.61 82.48 330.14 82.59 337.67 47.45"/>
42
+ <path d="M431.33,47.45h-81.96c-10.92,50.92-21.7,101.3-32.62,152.21h35.13l12.53-58.53h11.71l-.83,58.54,35.14-.04.83-58.52,14.21-11.71,15.06-70.26-9.21-11.69h0ZM395.37,106l-23.41-.03c1.67-7.78,5.86-27.35,7.53-35.13l23.43-.06-7.55,35.22h0Z"/>
43
+ <polygon points="433.85 199.67 432.18 152.81 452.26 59.09 466.46 47.45 548.43 47.45 557.67 58.98 550.11 94.29 514.96 94.32 517.48 82.54 482.35 82.57 464.79 164.51 499.92 164.52 504.93 141.12 493.23 141.1 498.23 117.71 545.09 117.67 530.05 187.92 515.8 199.73 433.85 199.67"/>
44
+ <path d="M791.83,59.16l-9.23-11.71h-70.25l-14.21,11.7-12.52,58.52,15.05,6.34-17.56,5.4-12.54,58.53,9.2,11.71h70.25l14.22-11.73,12.53-58.51-17.56-5.45,20.07-6.25,12.54-58.56h0ZM731.61,176.28l-23.41-.04c1.67-7.78,5.86-27.34,7.53-35.11h23.42l-7.54,35.15ZM746.67,105.99l-23.41-.02c1.67-7.77,5.86-27.33,7.52-35.1l23.42-.02-7.53,35.14Z"/>
45
+ <polygon points="194.71 222.91 167.33 223.06 156.18 129.4 141.1 199.94 105.93 199.9 138.61 47.45 173.74 47.45 182.11 117.71 197.16 47.45 232.29 47.45 194.71 222.91"/>
46
+ <polygon points="591.92 117.71 571.85 47.45 606.98 47.45 620.38 94.19 653.81 47.45 688.94 47.45 622.04 141.1 609.51 199.64 569.37 223.06 591.92 117.71"/>
47
+ </g>
48
+ <g fill="#fff">
49
+ <path d="M154.59,6.63c3.65-.3,8.87-.56,16.66-.56,7.19,0,12.27.79,15.59,2.14,3.22,1.27,5.54,3.46,5.54,6.13,0,2.46-1.54,4.7-4.59,6.13-4.04,1.95-9.94,2.85-17.29,2.85-1.75,0-3.29-.07-4.41-.17v9.2h-11.5V6.63ZM166.09,18.44c1.06.13,2.42.19,4.39.19,6.26,0,10.4-1.5,10.4-4.21,0-2.44-3.54-3.74-9.53-3.74-2.62,0-4.38.1-5.26.2v7.57Z"/>
50
+ <path d="M251.53,19.13c0,8.12-10.78,13.68-26.82,13.68s-25.68-5.71-25.68-13.23c0-8.01,10.82-13.75,26.48-13.75s26.02,5.67,26.02,13.29ZM211.37,19.41c0,4.83,5.36,8.62,13.95,8.62s13.88-3.72,13.88-8.72c0-4.73-5.14-8.7-13.89-8.7s-13.94,3.91-13.94,8.8Z"/>
51
+ <path d="M268.79,32.35l-12.71-26.05h12.55l4.18,10.84c1.17,3.14,2.39,6.65,3.24,9.1h.15c.82-2.7,2.04-5.91,3.35-9.19l4.44-10.75h13.15l4.15,11.23c1.1,3.08,1.93,5.82,2.67,8.53h.15c.82-2.74,2-5.84,3.23-9l4.5-10.76h11.94l-14.01,26.05h-12.69l-4.61-12.11c-1.04-2.66-1.82-4.96-2.43-7.83h-.15c-.82,2.86-1.6,5.19-2.83,7.85l-5.16,12.1h-13.1Z"/>
52
+ <path d="M363.08,21.48h-20.37v5.91h22.73v4.97h-34.35V6.3h33.22v4.97h-21.61v5.24h20.37v4.97Z"/>
53
+ <path d="M374.75,6.65c3.75-.32,9.54-.58,16.2-.58,7.97,0,13.21.65,16.83,2.11,2.92,1.17,4.76,3.03,4.76,5.48,0,3.47-5.07,5.71-9.04,6.37v.14c3.43.73,5.42,2.49,6.76,4.75,1.71,2.97,3.25,6.42,4.24,7.45h-11.84c-.8-.76-2.07-2.68-3.68-6.02-1.55-3.26-3.77-4.12-8.81-4.16h-3.93v10.18h-11.5V6.65ZM386.25,17.8h5.22c5.96,0,9.57-1.39,9.57-3.65,0-2.44-3.55-3.54-8.9-3.54-3.26,0-5.03.11-5.89.21v6.98Z"/>
54
+ <path d="M454.32,21.48h-20.37v5.91h22.73v4.97h-34.35V6.3h33.22v4.97h-21.61v5.24h20.37v4.97Z"/>
55
+ <path d="M465.99,6.65c4.66-.37,10.58-.58,16.73-.58,10.36,0,17.04.96,22.27,2.96,5.4,2.02,8.95,5.11,8.95,9.57,0,4.9-3.69,8.25-8.56,10.39-5.77,2.48-14.71,3.66-25.2,3.66-6.69,0-11.17-.19-14.19-.42V6.65ZM477.6,27.78c1.12.1,2.96.11,4.59.11,11.88.06,19.36-3.07,19.36-8.98.03-5.43-7.06-8.14-17.86-8.14-3.01,0-4.97.14-6.09.26v16.76Z"/>
56
+ <path d="M540.25,6.65c2.93-.29,9.45-.58,15.49-.58,7.51,0,11.56.37,15.23,1.34,3.71.9,6.57,2.69,6.57,5.27,0,2.28-2.62,4.42-8.38,5.49v.09c5.58.75,10.14,2.95,10.14,6.44,0,2.46-2.32,4.38-5.61,5.67-3.83,1.48-10.07,2.28-20.37,2.28-6.09,0-10.46-.22-13.05-.42V6.65ZM551.75,16.51h4.12c6.69,0,9.91-1.21,9.91-3.09,0-2-3.21-2.9-8.6-2.9-2.89,0-4.42.09-5.43.19v5.8ZM551.75,28.02c1.22.1,2.74.11,4.97.11,5.43,0,10.28-.95,10.28-3.73,0-2.6-4.75-3.54-10.88-3.54h-4.37v7.17Z"/>
57
+ <path d="M598.63,32.35v-10.92l-17.08-15.13h13.56l5.55,6.29c1.7,1.93,2.83,3.15,4.19,4.81h.15c1.26-1.54,2.57-2.94,4.17-4.78l5.64-6.32h13.39l-17.95,14.94v11.12h-11.62Z"/>
58
+ </g>
59
+ <g fill="#fff">
60
+ <path d="M257.81,230.46l-25.46.08.02,5.72,28.41-.09v4.82s-42.91.13-42.91.13l-.08-25.25,41.52-.13.02,4.81-27,.08.02,5.08,25.46-.08v4.82Z"/>
61
+ <path d="M272.41,240.96l-.08-25.25,17.91-.05,13.87,9c4.29,2.87,8.09,5.99,11.21,9.06l.25-.02c-1.01-3.57-1.11-6.62-1.12-10.17l-.02-7.96,13.49-.04.08,25.25-15.73.05-14.47-9.72c-4.19-2.91-8.65-6.15-12.16-9.33l-.34.02c.47,3.61.57,7.08.58,10.88l.02,8.23-13.49.04Z"/>
62
+ <path d="M399.35,239.23c-4.86.69-13.79,1.64-23.09,1.67-12.61.04-21.62-1.2-27.82-3.6-5.76-2.2-8.94-5.33-8.95-8.83-.02-8.13,15.56-13.28,38.01-13.35,9.02-.03,16,.65,19.42,1.3l-3.17,4.7c-3.95-.67-8.69-1.21-16.7-1.19-12.73.04-22.3,2.87-22.29,8,.01,4.93,8.78,8.24,22.08,8.2,3.82-.01,6.95-.21,8.26-.45l-.02-5.05-10.82.03v-4.6s25.04-.08,25.04-.08l.04,13.24Z"/>
63
+ <path d="M427.02,215.23l.08,25.25-14.52.04-.08-25.25,14.52-.04Z"/>
64
+ <path d="M441.45,240.44l-.08-25.25,17.91-.06,13.87,9c4.29,2.87,8.09,5.99,11.21,9.06l.25-.02c-1.01-3.57-1.11-6.62-1.12-10.17l-.02-7.96,13.49-.04.08,25.25-15.73.05-14.47-9.72c-4.2-2.91-8.65-6.15-12.16-9.33l-.35.02c.47,3.61.57,7.08.58,10.88l.03,8.23-13.49.04Z"/>
65
+ <path d="M551.33,229.56l-25.46.08.02,5.72,28.41-.09v4.82s-42.91.13-42.91.13l-.08-25.25,41.52-.13v4.81s-26.99.08-26.99.08l.02,5.08,25.46-.08v4.82Z"/>
66
+ </g>
67
+ </svg>`;
68
+
69
+ /** Markup for the splash layer — a black backdrop over the overlay's content. */
70
+ export function buildSplashHTML(): string {
71
+ return `<div class="${SPLASH_CLASS}">${SPLASH_LOGO_SVG}</div>`;
72
+ }
73
+
74
+ /**
75
+ * The whole timeline is CSS. JS only removes the layer once it has played, so
76
+ * a stalled main thread can't strand the splash half-faded on screen.
77
+ *
78
+ * Percentages map onto SPLASH_DURATION_MS: logo in by 500ms, held to 1400ms,
79
+ * gone by 1700ms; the backdrop then lifts over the last 300ms, revealing the
80
+ * game's own background and brand from black.
81
+ */
82
+ export const SPLASH_CSS = `
83
+ .${SPLASH_CLASS} {
84
+ position: absolute;
85
+ top: 0; left: 0;
86
+ width: 100%; height: 100%;
87
+ background: #000;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ z-index: 1;
92
+ /* Never swallow the tap — the overlay's own pointerdown gate sits below. */
93
+ pointer-events: none;
94
+ animation: ge-splash-backdrop ${SPLASH_DURATION_MS}ms linear forwards;
95
+ }
96
+
97
+ @keyframes ge-splash-backdrop {
98
+ 0%, 85% { opacity: 1; }
99
+ 100% { opacity: 0; }
100
+ }
101
+
102
+ .ge-splash-logo {
103
+ width: 62%;
104
+ max-width: 520px;
105
+ height: auto;
106
+ animation: ge-splash-logo ${SPLASH_DURATION_MS}ms ease-in-out forwards;
107
+ }
108
+
109
+ @keyframes ge-splash-logo {
110
+ 0% { opacity: 0; }
111
+ 25% { opacity: 1; }
112
+ 70% { opacity: 1; }
113
+ 85% { opacity: 0; }
114
+ 100% { opacity: 0; }
115
+ }
116
+ `;
@@ -1,4 +1,5 @@
1
1
  import { energy8Variant } from './energy8';
2
+ import { slottechVariant } from './slottech';
2
3
  import { voidmoonVariant } from './voidmoon';
3
4
 
4
5
  /**
@@ -8,6 +9,7 @@ import { voidmoonVariant } from './voidmoon';
8
9
  */
9
10
  export const VARIANTS = {
10
11
  energy8: energy8Variant,
12
+ slottech: slottechVariant,
11
13
  voidmoon: voidmoonVariant,
12
14
  } as const;
13
15
 
@@ -0,0 +1,141 @@
1
+ import type { PreloaderVariant, PreloaderVariantHandle } from './types';
2
+
3
+ /** Element ids the lifecycle handle binds to. */
4
+ const RECT_ID = 'ge-st-loader-rect';
5
+ const TEXT_ID = 'ge-st-loader-text';
6
+
7
+ /** Max width (SVG units) of the slottech loader bar fill — spans the artwork's full width. */
8
+ const LOADER_BAR_MAX_WIDTH = 841.89;
9
+
10
+ /** Flat wordmark fill, straight from the official artwork (no gradient). */
11
+ const LOGO_FILL = '#f5f5f5';
12
+ /** Ambient glow tint, keyed off the fill. */
13
+ const GLOW = 'rgba(245, 245, 245, 0.22)';
14
+
15
+ /**
16
+ * "SlotTech" wordmark — the official logo embedded verbatim as vector outlines
17
+ * (the "ST" monogram above, "SLOTTECH" lettering below). The artwork ends at
18
+ * y=426.32; the loader bar and status text are added beneath it in the extended
19
+ * viewBox.
20
+ *
21
+ * The one deviation from the export: the "O" of SLOTTECH ships as two separate
22
+ * filled paths (outer + counter), which would paint a solid slab. They are
23
+ * merged into a single `fill-rule="evenodd"` path so the counter stays a hole.
24
+ */
25
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.89 520" class="ge-st-logo-svg" role="img">
26
+ <title>SlotTech</title>
27
+ <g fill="${LOGO_FILL}">
28
+ <path d="M147.7,289.83l37.26-55.73c1.75-2.61,4.67-4.18,7.81-4.17,19.91.04,120.3-.2,161.37-.13,15.28.03,27.67-12.36,27.65-27.64h0c-.02-15.26-12.4-27.6-27.66-27.57-.9,0-82.45.49-83.44.49-48.78.09-88.18-39.8-87.46-88.58h0C183.93,38.8,222.81.49,270.52.49l421.66-.49-40.64,56.9c-3.88,4.36-9.44,6.86-15.28,6.87-49.38.03-293.63-.02-365.45.13-12.9.03-23.36,10.42-23.49,23.31h0c-.13,13,10.29,23.64,23.28,23.78,25.44.28,80.28.99,88.33,1.19,48.36,1.24,86.86,40.91,86.6,89.28h0c-.26,48.88-39.96,88.37-88.84,88.37h-209Z"/>
29
+ <path d="M431.26,289.83h76.99l35.07-165.93c1.19-6.22-.72-12.63-5.11-17.19h0c-3.55-3.69-8.4-5.84-13.52-5.98l-102.5-.23c-3.34-.04-5.26,3.79-3.23,6.44,62.27,45.72,62.12,111.86,22.04,167.08-.68.94-1.49,1.79-2.38,2.52l-9.97,8.24c-1.5,2.12.01,5.04,2.61,5.04Z"/>
30
+ <path d="M66.01,376.51v-11.84l-47.67-.13C8.22,364.52,0,372.72,0,382.84h0c0,10.08,8.15,18.26,18.23,18.29l33.07-.16c3.7.01,6.69,3.02,6.69,6.72h0c0,3.71-3.01,6.72-6.72,6.72l-50.07.25v11.5l50.17.16c10.26,0,18.55-8.37,18.45-18.63h0c-.1-10.06-8.23-18.19-18.3-18.28l-34.94.03c-3.38-.03-6.11-2.76-6.16-6.14h0c-.05-3.46,2.74-6.3,6.2-6.31l49.36-.48Z"/>
31
+ <polygon points="107.82 426.32 176.27 426.32 176.27 414.31 120.71 414.31 120.71 364.55 107.82 364.55 107.82 426.32"/>
32
+ <path fill-rule="evenodd" d="M288.1,381.64v27.6c0,9.44-7.66,17.08-17.1,17.08h-40.89c-9.44,0-17.08-7.64-17.08-17.08v-27.6c0-9.44,7.64-17.1,17.08-17.1h40.89c9.44,0,17.1,7.66,17.1,17.1ZM274.68,382.63v24.86c0,3.58-2.89,6.48-6.46,6.48h-35.5c-3.56,0-6.46-2.9-6.46-6.48v-24.86c0-3.57,2.9-6.46,6.46-6.46h35.5c3.57,0,6.46,2.89,6.46,6.46Z"/>
33
+ <polygon points="368.74 426.32 368.91 376.17 400.44 376.17 400.44 364.55 324.84 364.55 324.84 376.17 356.19 376.17 356.19 426.32 368.74 426.32"/>
34
+ <polygon points="482.21 425.95 482.39 375.8 513.91 375.8 513.91 364.18 438.32 364.18 438.32 375.8 469.67 375.8 469.67 425.95 482.21 425.95"/>
35
+ <rect x="554.23" y="364.55" width="71.41" height="11.97"/>
36
+ <rect x="554.23" y="389.26" width="71.41" height="11.97"/>
37
+ <rect x="554.23" y="413.98" width="71.41" height="11.97"/>
38
+ <path d="M733.29,376.51v-11.97h-49.5c-9.79,0-17.73,7.94-17.73,17.73v25.95c0,9.79,7.94,17.73,17.73,17.73h49.5v-11.97h-48.67c-3.52,0-6.37-2.85-6.37-6.37v-24.72c0-3.52,2.85-6.37,6.37-6.37h48.67Z"/>
39
+ <polygon points="773.52 425.95 785.8 425.95 785.98 401.16 829.26 400.99 829.26 425.95 841.89 425.95 841.89 364.55 829.35 364.55 829.35 389.49 786.15 389.49 786.15 364.55 773.52 364.55 773.52 425.95"/>
40
+ </g>
41
+
42
+ <rect x="0" y="460" width="841.89" height="12" rx="6" fill="rgba(255,255,255,0.12)"/>
43
+ <clipPath id="ge-st-loader-clip">
44
+ <rect id="${RECT_ID}" x="0" y="460" width="0" height="12" rx="6" class="ge-st-clip-rect"/>
45
+ </clipPath>
46
+ <rect x="0" y="460" width="841.89" height="12" rx="6" fill="${LOGO_FILL}" clip-path="url(#ge-st-loader-clip)"/>
47
+
48
+ <text id="${TEXT_ID}" x="420.95" y="510" text-anchor="middle" class="ge-st-text">Loading...</text>
49
+ </svg>`;
50
+
51
+ export const slottechVariant: PreloaderVariant = {
52
+ buildContentHTML() {
53
+ return `
54
+ <div class="ge-st-content">
55
+ ${LOGO_SVG}
56
+ </div>
57
+ `;
58
+ },
59
+
60
+ css: `
61
+ .ge-st-content {
62
+ display: flex;
63
+ flex-direction: column;
64
+ align-items: center;
65
+ width: 78%;
66
+ max-width: 620px;
67
+ }
68
+
69
+ .ge-st-logo-svg {
70
+ width: 100%;
71
+ height: auto;
72
+ filter: drop-shadow(0 0 26px ${GLOW});
73
+ }
74
+
75
+ /* Shimmer the loader bar while waiting */
76
+ .ge-st-clip-rect {
77
+ animation: ge-st-fill 2s ease-in-out infinite;
78
+ }
79
+
80
+ @keyframes ge-st-fill {
81
+ 0% { width: 0; }
82
+ 50% { width: 841.89; }
83
+ 100% { width: 0; }
84
+ }
85
+
86
+ /* Stop shimmer once JS-driven progress takes over. */
87
+ .ge-st-clip-rect.driven {
88
+ animation: none;
89
+ }
90
+
91
+ .ge-st-text {
92
+ fill: rgba(255, 255, 255, 0.6);
93
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
94
+ font-size: 22px;
95
+ font-weight: 600;
96
+ letter-spacing: 3px;
97
+ animation: ge-st-pulse 1.5s ease-in-out infinite;
98
+ }
99
+
100
+ @keyframes ge-st-pulse {
101
+ 0%, 100% { opacity: 0.4; }
102
+ 50% { opacity: 1; }
103
+ }
104
+
105
+ /* Tap-to-start CTA pulse. Compound selector outweighs the ambient
106
+ .ge-st-text rule, swapping the animation cleanly. */
107
+ .ge-st-text.ge-st-tap-pulse {
108
+ animation: ge-st-tap 1.2s ease-in-out infinite;
109
+ }
110
+
111
+ @keyframes ge-st-tap {
112
+ 0%, 100% { opacity: 0.5; }
113
+ 50% { opacity: 1; }
114
+ }
115
+ `,
116
+
117
+ mount(overlay): PreloaderVariantHandle | null {
118
+ const rectEl = overlay.querySelector(`#${RECT_ID}`) as SVGRectElement | null;
119
+ const textEl = overlay.querySelector(`#${TEXT_ID}`) as SVGTextElement | null;
120
+ if (!rectEl || !textEl) return null;
121
+
122
+ let driven = false;
123
+
124
+ return {
125
+ setProgress(p, showPercentage) {
126
+ if (!driven) {
127
+ rectEl.classList.add('driven');
128
+ driven = true;
129
+ }
130
+ rectEl.setAttribute('width', String(p * LOADER_BAR_MAX_WIDTH));
131
+ if (showPercentage) {
132
+ textEl.textContent = `${Math.round(p * 100)}%`;
133
+ }
134
+ },
135
+ showTapText(text) {
136
+ textEl.textContent = text;
137
+ textEl.classList.add('ge-st-tap-pulse');
138
+ },
139
+ };
140
+ },
141
+ };
@@ -14,11 +14,44 @@
14
14
  import { spawn, type ChildProcess } from 'node:child_process';
15
15
  import { accessSync, constants as fsConstants, mkdtempSync, writeFileSync } from 'node:fs';
16
16
  import { createRequire } from 'node:module';
17
+ import { createServer as createNetServer } from 'node:net';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { tmpdir } from 'node:os';
19
20
  import { join } from 'node:path';
20
21
  import type { Plugin } from 'vite';
21
22
 
23
+ /** стартовый порт gRPC, если не задан явно и нет E8_SERVER_PORT */
24
+ export const DEFAULT_SPIN_PORT = 50151;
25
+ /** сколько портов подряд пробуем от стартового, прежде чем сдаться */
26
+ const PORT_SCAN_RANGE = 20;
27
+ /** сколько раз перезапускаем поиск, если сервер не поднялся на выбранном порту */
28
+ const START_ATTEMPTS = 3;
29
+
30
+ /**
31
+ * Свободен ли порт. Биндимся на 0.0.0.0 — e8-server слушает все интерфейсы,
32
+ * так что занятость на любом из них ему помешает.
33
+ */
34
+ function isPortFree(port: number): Promise<boolean> {
35
+ return new Promise((resolve) => {
36
+ const probe = createNetServer();
37
+ probe.once('error', () => resolve(false));
38
+ probe.once('listening', () => probe.close(() => resolve(true)));
39
+ probe.listen(port, '0.0.0.0');
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Первый свободный порт начиная со `start`. Нужен, потому что порт по
45
+ * умолчанию один на все игры: без этого второй `npm run dev` не забиндил бы
46
+ * свой e8-server и молча ходил бы в чужой (с чужим --games-dir).
47
+ */
48
+ async function findFreePort(start: number, range = PORT_SCAN_RANGE): Promise<number> {
49
+ for (let p = start; p < start + range; p++) {
50
+ if (await isPortFree(p)) return p;
51
+ }
52
+ throw new Error(`[e8] нет свободного порта в диапазоне ${start}..${start + range - 1}`);
53
+ }
54
+
22
55
  /**
23
56
  * Поиск бинаря e8-server в порядке их NativeSimulationRunner:
24
57
  * явный binPath → env E8_SERVER_BINARY →
@@ -63,7 +96,10 @@ export interface SpinPluginOptions {
63
96
  gamesDir?: string;
64
97
  /** id игры из декларации game "..." (default: первая загруженная) */
65
98
  gameId?: string;
66
- /** порт gRPC (default 50151) */
99
+ /**
100
+ * Стартовый порт gRPC (default: E8_SERVER_PORT → 50151). Если занят —
101
+ * берётся следующий свободный, кроме случая `external`, где порт точный.
102
+ */
67
103
  port?: number;
68
104
  /** server_seed дев-сессий (детерминированный replay) */
69
105
  serverSeed?: string;
@@ -146,7 +182,10 @@ message HealthResponse { bool ok = 1; uint32 games_loaded = 2; string sessions_b
146
182
  `;
147
183
 
148
184
  export function spinPlugin(opts: SpinPluginOptions = {}): Plugin {
149
- const port = opts.port ?? 50151;
185
+ const basePort = opts.port ?? (Number(process.env.E8_SERVER_PORT) || DEFAULT_SPIN_PORT);
186
+ // фактический порт: при `external` — ровно basePort, иначе подбирается в
187
+ // configureServer и дальше читается замыканиями grpc()/логами
188
+ let port = basePort;
150
189
  const serverSeed = opts.serverSeed ?? 'e8-dev-seed';
151
190
  let child: ChildProcess | null = null;
152
191
  let client: any = null;
@@ -224,6 +263,72 @@ export function spinPlugin(opts: SpinPluginOptions = {}): Plugin {
224
263
  };
225
264
  }
226
265
 
266
+ /**
267
+ * Дождаться готовности сервера и запомнить gameId/entry_actions.
268
+ * `isDead` даёт выйти сразу, если наш процесс умер (не забиндил порт) —
269
+ * иначе ждали бы 30 секунд впустую.
270
+ */
271
+ async function connect(isDead: () => boolean = () => false): Promise<boolean> {
272
+ for (let i = 0; i < 100; i++) {
273
+ if (isDead()) return false;
274
+ try {
275
+ const games: any = await call('ListGames', {});
276
+ if (!gameId) gameId = games.games[0]?.game_id ?? '';
277
+ const info = games.games.find((g: any) => g.game_id === gameId);
278
+ entryActions = info?.entry_actions ?? [];
279
+ console.log(
280
+ `[e8] connected: :${port} game=${gameId} script=${info?.script_sha256?.slice(0, 12)} entry=[${entryActions}]`,
281
+ );
282
+ return true;
283
+ } catch {
284
+ await new Promise((res) => setTimeout(res, 300));
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+
290
+ /**
291
+ * Поднять собственный e8-server на свободном порту. Порт проверяется до
292
+ * спавна, но между проверкой и биндом есть гонка (и порт мог занять другой
293
+ * e8-server) — поэтому при неудачном старте ищем дальше со следующего.
294
+ */
295
+ async function startServer(): Promise<void> {
296
+ const bin = resolveServerBinary(opts.binPath);
297
+ let from = basePort;
298
+
299
+ for (let attempt = 0; attempt < START_ATTEMPTS; attempt++) {
300
+ try {
301
+ port = await findFreePort(from);
302
+ } catch (e: any) {
303
+ console.error(e.message);
304
+ return;
305
+ }
306
+ if (port !== basePort) {
307
+ console.log(`[e8] порт ${basePort} занят → e8-server на :${port}`);
308
+ }
309
+
310
+ const args = ['--port', String(port), '--sessions', 'memory', '--watch'];
311
+ if (opts.gamesDir) args.push('--games-dir', opts.gamesDir);
312
+ else if (opts.spinPath) {
313
+ const dir = opts.spinPath.replace(/\/[^/]+$/, '') || '.';
314
+ args.push('--games-dir', dir);
315
+ }
316
+
317
+ let exited = false;
318
+ child = spawn(bin, args, { stdio: 'inherit' });
319
+ child.once('exit', () => (exited = true));
320
+ client = grpc();
321
+
322
+ if (await connect(() => exited)) return;
323
+
324
+ child?.kill();
325
+ child = null;
326
+ from = port + 1;
327
+ }
328
+
329
+ console.error(`[e8] e8-server не поднялся за ${START_ATTEMPTS} попытки от :${basePort}`);
330
+ }
331
+
227
332
  async function play(body: any): Promise<unknown> {
228
333
  const action: string = body.action ?? 'spin';
229
334
  const params = body.params ? JSON.stringify(body.params) : '';
@@ -281,32 +386,15 @@ export function spinPlugin(opts: SpinPluginOptions = {}): Plugin {
281
386
  enforce: 'pre',
282
387
 
283
388
  async configureServer(server) {
284
- if (!opts.external) {
285
- const args = ['--port', String(port), '--sessions', 'memory', '--watch'];
286
- if (opts.gamesDir) args.push('--games-dir', opts.gamesDir);
287
- else if (opts.spinPath) {
288
- const dir = opts.spinPath.replace(/\/[^/]+$/, '') || '.';
289
- args.push('--games-dir', dir);
389
+ if (opts.external) {
390
+ client = grpc();
391
+ if (!(await connect())) {
392
+ console.error(`[e8] нет сервера на 127.0.0.1:${port} (external: true)`);
290
393
  }
291
- child = spawn(resolveServerBinary(opts.binPath), args, { stdio: 'inherit' });
394
+ } else {
395
+ await startServer();
292
396
  server.httpServer?.on('close', () => child?.kill());
293
397
  }
294
- client = grpc();
295
-
296
- for (let i = 0; i < 100; i++) {
297
- try {
298
- const games: any = await call('ListGames', {});
299
- if (!gameId) gameId = games.games[0]?.game_id ?? '';
300
- const info = games.games.find((g: any) => g.game_id === gameId);
301
- entryActions = info?.entry_actions ?? [];
302
- console.log(
303
- `[e8] connected: game=${gameId} script=${info?.script_sha256?.slice(0, 12)} entry=[${entryActions}]`,
304
- );
305
- break;
306
- } catch {
307
- await new Promise((res) => setTimeout(res, 300));
308
- }
309
- }
310
398
 
311
399
  server.middlewares.use('/__lua-play', (req: any, res: any) => {
312
400
  if (req.method !== 'POST') {