feat: Adding economic system to do scoring
This commit is contained in:
@@ -15,5 +15,9 @@ POSTGRES_USERS_USER=users
|
|||||||
POSTGRES_USERS_PASSWORD=CHANGE_ME
|
POSTGRES_USERS_PASSWORD=CHANGE_ME
|
||||||
POSTGRES_USERS_DB=star_wars_users
|
POSTGRES_USERS_DB=star_wars_users
|
||||||
|
|
||||||
|
# ── Admin ────────────────────────────────────────────────────────────────────
|
||||||
|
# Password to unlock the team-switching debug widget in the UI
|
||||||
|
ADMIN_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=*
|
||||||
@@ -1,6 +1,44 @@
|
|||||||
{
|
{
|
||||||
"clickCooldownSeconds": 5,
|
"clickCooldownSeconds": 2,
|
||||||
"databaseWipeoutIntervalSeconds": 21600,
|
"databaseWipeoutIntervalSeconds": 21600,
|
||||||
"debugModeForTeams": true,
|
"debugModeForTeams": true,
|
||||||
"configReloadIntervalSeconds": 30
|
"configReloadIntervalSeconds": 30,
|
||||||
|
"resourceWorth": {
|
||||||
|
"common": {
|
||||||
|
"rock": 1,
|
||||||
|
"wood": 1,
|
||||||
|
"mineral": 2,
|
||||||
|
"stones": 2,
|
||||||
|
"liquid": 1,
|
||||||
|
"oil": 4,
|
||||||
|
"gas": 3,
|
||||||
|
"grain": 1,
|
||||||
|
"livestock": 2,
|
||||||
|
"fish": 1,
|
||||||
|
"plant": 1,
|
||||||
|
"goods": 4,
|
||||||
|
"animals": 2,
|
||||||
|
"science": 6,
|
||||||
|
"factory": 5,
|
||||||
|
"acid": 1
|
||||||
|
},
|
||||||
|
"rare": {
|
||||||
|
"rock": 3,
|
||||||
|
"wood": 3,
|
||||||
|
"mineral": 4,
|
||||||
|
"stones": 5,
|
||||||
|
"liquid": 2,
|
||||||
|
"oil": 7,
|
||||||
|
"gas": 6,
|
||||||
|
"grain": 3,
|
||||||
|
"livestock": 3,
|
||||||
|
"fish": 5,
|
||||||
|
"plant": 3,
|
||||||
|
"goods": 8,
|
||||||
|
"animals": 5,
|
||||||
|
"science": 15,
|
||||||
|
"factory": 12,
|
||||||
|
"acid": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
DATABASE_URL: "postgres://${POSTGRES_USER:-game}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-star_wars_grid}"
|
DATABASE_URL: "postgres://${POSTGRES_USER:-game}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-star_wars_grid}"
|
||||||
USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}"
|
USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}"
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
PORT: "${PORT:-8080}"
|
PORT: "${PORT:-8080}"
|
||||||
CONFIG_FILE_PATH: /app/config/game.settings.json
|
CONFIG_FILE_PATH: /app/config/game.settings.json
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
61
public/graphism/logo_first_order.svg
Normal file
61
public/graphism/logo_first_order.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
44
public/graphism/logo_resistance.svg
Normal file
44
public/graphism/logo_resistance.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="256"
|
||||||
|
height="256"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||||
|
sodipodi:docname="logo_resistance.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="3.9981481"
|
||||||
|
inkscape:cx="115.80361"
|
||||||
|
inkscape:cy="107.79991"
|
||||||
|
inkscape:window-width="3440"
|
||||||
|
inkscape:window-height="1351"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="222"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="resistance" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="resistance"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="resistance"><title
|
||||||
|
id="title8">resistance</title><path
|
||||||
|
style="display:inline;fill:#0055d4"
|
||||||
|
d="M 120.2,244.37673 C 98.535868,242.64759 78.228614,235.06452 60.974092,222.26077 48.681429,213.13898 38.591946,201.95061 30.348843,188.3 29.485318,186.87 27.677054,183.495 26.330477,180.8 19.174366,166.47796 15.275172,151.96991 14.182496,135.6 13.920842,131.68003 14.03847,122.27281 14.395065,118.6 17.19371,89.774889 29.700463,64.429289 51.093864,44.227934 55.667873,39.90879 62.109209,34.748455 67.138176,31.374387 70.13984,29.36049 70.284433,29.472499 68.074873,32.1 59.773612,41.971454 52.114001,55.665103 47.994029,68 c -8.210303,24.581046 -4.572784,46.7494 10.632781,64.8 3.863283,4.58612 6.497521,6.93258 10.873612,9.68571 4.046112,2.54554 9.092068,4.56112 13.07335,5.2221 2.963476,0.492 7.422134,0.32302 9.926228,-0.3762 5.960775,-1.66442 11.59203,-6.07148 14.63215,-11.45122 2.76467,-4.8923 4.81277,-11.02216 5.77371,-17.28039 0.43559,-2.83683 0.60865,-10.05723 0.31399,-13.1 -0.83205,-8.591907 -3.24259,-16.686706 -7.57366,-25.43302 -2.05107,-4.141986 -3.59175,-6.375188 -6.428443,-9.317944 -3.866463,-4.011029 -7.377694,-6.583821 -12.566868,-9.208147 -1.567017,-0.792489 -2.827017,-1.503301 -2.8,-1.579582 C 83.877895,59.885026 86.69,56.74675 90.1,52.987359 c 3.41,-3.75939 6.388598,-7.065243 6.619107,-7.34634 l 0.419107,-0.511084 1.580893,0.865683 c 6.373033,3.489813 12.851413,9.304869 17.483663,15.693477 1.52265,2.099988 1.56314,2.053688 1.20838,-1.381954 -0.45421,-4.398774 -1.70144,-9.406539 -3.42394,-13.747497 -1.11116,-2.800305 -3.61951,-7.621362 -5.25981,-10.109372 -0.83762,-1.270508 -1.29285,-2.144872 -1.19748,-2.3 0.0846,-0.13765 4.7818,-5.50145 10.43815,-11.919556 l 10.28427,-11.669284 0.44974,0.469284 c 0.3681,0.384097 19.12496,21.721127 20.03433,22.790223 0.23704,0.278681 0.12592,0.555191 -0.84396,2.1 -2.55492,4.06943 -5.50395,10.635832 -6.98479,15.552553 -0.84356,2.800807 -1.59516,6.431847 -1.91415,9.247396 -0.34121,3.011691 -0.27019,3.167533 0.76965,1.688809 3.36452,-4.784585 9.52811,-10.655395 15.08988,-14.3731 2.15312,-1.439224 4.56749,-2.893743 4.63938,-2.794955 0.0234,0.0321 2.99069,3.372285 6.59408,7.422639 3.60338,4.050355 6.52053,7.386223 6.48255,7.413041 -0.038,0.02682 -0.74405,0.342785 -1.56905,0.702149 -2.01219,0.876494 -5.47454,2.960936 -7.8,4.695829 -3.98523,2.973157 -8.77209,8.274616 -11.40222,12.627981 -3.59266,5.946536 -6.59978,15.735352 -7.83257,25.496719 -0.41467,3.28342 -0.35565,13.29391 0.0955,16.2 2.4439,15.74209 10.34137,25.92596 21.78935,28.0976 2.26968,0.43055 6.26656,0.39335 8.7141,-0.0811 8.40595,-1.6295 17.54964,-7.6523 23.86047,-15.7165 8.18852,-10.4636 13.01765,-22.06013 14.61664,-35.1 0.33596,-2.739795 0.32696,-11.24217 -0.0149,-14.1 -1.73088,-14.468395 -7.47227,-28.210068 -17.79863,-42.6 -1.91618,-2.670232 -4.99712,-6.578938 -6.957,-8.826171 C 187.20368,30.250339 187.05677,30 187.40582,30 c 0.62932,0 0.96172,0.215304 5.19418,3.364349 16.26502,12.101551 26.54451,22.717104 34.35856,35.481809 9.43343,15.410081 14.67168,33.266872 16.2409,55.364012 0.23952,3.37277 0.0505,8.85266 -0.50314,14.58983 -3.67122,38.04042 -21.56085,69.35879 -50.29632,88.0509 -15.58751,10.1395 -34.27283,16.14386 -54.6,17.54522 -3.4012,0.23448 -14.57352,0.22217 -17.6,-0.0194 z"
|
||||||
|
id="blue"
|
||||||
|
inkscape:label="blue"><title
|
||||||
|
id="title9">blue</title></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
71
public/graphism/playground.svg
Normal file
71
public/graphism/playground.svg
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="1000"
|
||||||
|
height="1000"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="playground.svg"
|
||||||
|
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.5867476"
|
||||||
|
inkscape:cx="178.66736"
|
||||||
|
inkscape:cy="601.86006"
|
||||||
|
inkscape:window-width="3440"
|
||||||
|
inkscape:window-height="1351"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="222"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath4">
|
||||||
|
<path
|
||||||
|
d="M 0,2048 H 2048 V 0 H 0 Z"
|
||||||
|
transform="translate(-1507.918,-1029.2018)"
|
||||||
|
id="path4" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath4-4">
|
||||||
|
<path
|
||||||
|
d="M 0,2048 H 2048 V 0 H 0 Z"
|
||||||
|
transform="translate(-1507.918,-1029.2018)"
|
||||||
|
id="path4-8" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
id="outer_regions"
|
||||||
|
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-width:0.51783;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000"
|
||||||
|
d="M 421.56055 25 C 373.38507 24.854113 329.1082 52.904873 305.05664 94.201172 C 293.06758 105.95087 270.36232 134.71112 257.10352 150.71875 C 247.32899 164.49231 234.8216 176.43594 222.05469 226.12109 C 219.03118 242.36653 217.06491 259.07203 214.29883 275.56445 C 212.67557 298.98884 264.13768 311.51357 287.87891 327.09375 C 296.0849 332.47893 307.97987 340.00424 320.7832 348.60742 C 320.1134 344.11854 323.47791 339.10584 327.63672 332.05078 C 333.0299 323.75882 336.92332 323.45873 343.03125 314.89453 C 351.31162 309.1134 354.9907 298.11968 360.89844 289.64062 C 359.97627 280.01528 353.75632 277.99875 351.76367 273.21289 C 337.55097 259.56523 345.01527 239.46967 354.64648 226.36523 C 362.39976 217.92275 365.16934 208.4102 375.75195 201.58008 C 393.07795 200.38815 393.75913 182.17379 405.15039 169.01367 C 411.69598 149.88677 428.93477 147.15396 446.0293 152.33398 C 463.84346 150.58687 513.99705 149.58582 495.74609 178.20312 C 481.87191 191.89422 490.71187 216.23079 511.38086 216.18359 C 535.38757 220.51262 559.44416 197.25521 582.21484 210.84375 C 583.66092 187.31652 616.67414 194.17432 627.66992 211.77344 C 639.63565 222.2925 657.2255 200.75036 670.13086 212.12109 C 692.3961 211.1996 674.81659 227.38108 675.00195 237.70117 C 671.23258 256.47466 686.23931 264.12366 691.91797 280.63867 C 698.02993 281.73392 699.72808 289.02616 705.51367 291.75 C 719.70278 301.66027 728.05547 314.34223 743.5 321.95312 C 738.76526 335.61082 748.70232 356.30939 741.57227 373.19922 C 729.1255 388.20226 742.26702 383.34648 749.36719 389.08789 C 750.53135 401.37329 740.32006 397.65199 735.78711 409.5918 C 730.09592 428.34355 740.45242 447.3727 742.08789 461.16211 C 744.77627 479.10466 749.10645 479.06498 749.95312 497.29883 C 751.39719 514.8114 748.38528 531.58471 746.43359 548.65039 C 746.77291 565.35558 741.35189 582.18322 742.0332 597.92383 C 739.3678 611.44419 736.1463 628.11891 730.26172 639.51953 C 729.26253 661.79034 717.30912 682.89918 706.31055 702.10547 C 695.71562 721.15694 675.6338 730.68077 655.84375 737.67578 C 643.54509 741.03008 629.14988 736.21891 620.83008 747.55859 C 600.79718 739.99855 601.64775 757.25019 608.26758 772.82227 C 622.50221 792.38563 605.21984 809.86991 583.91211 799.55859 C 559.10376 790.70814 518.22734 773.90224 510.28516 814.05664 C 509.17234 841.04703 481.10084 820.19103 470.73047 836.43164 C 479.5771 853.61378 463.50343 850.19036 459.09766 837.0957 C 448.69254 826.70252 423.44592 823.91476 414.74219 835.57812 C 402.43796 835.08453 388.49386 847.09036 373.93359 849.48633 C 364.87672 842.34189 362.53209 828.37655 346.71289 824.5 C 348.92431 799.51325 316.89209 778.69284 303.48438 807.22656 C 276.36808 813.39829 298.46075 777.29162 301.49414 766.49609 C 297.28513 757.65654 318.0412 735.18774 306.83398 732.92969 C 308.06058 715.91282 288.52149 694.67408 268.26172 687.36914 C 232.49774 693.13392 263.89538 649.66269 268.03516 634.19141 C 270.66323 623.27925 272.49877 622.46824 273.95508 625.85742 C 275.96345 610.91265 276.07179 593.50984 267.37891 580.21484 C 252.2274 557.04193 223.70727 537.43372 188.05664 548.12891 C 181.42086 550.11964 169.81936 552.90135 164.4043 561.08984 C 144.58836 594.04992 132.26742 632.12959 126.00391 669.55664 C 119.77245 713.01501 143.65783 750.68967 167.35742 784.14453 C 155.33363 828.28331 180.22411 879.31446 216.54297 906.95312 C 246.91473 930.04449 286.64182 932.68349 314.79883 960.30469 C 351.47425 986.96424 395.09906 969.18658 435.38867 969.2793 C 488.61384 986.20754 538.18949 949.92996 583.54102 928.72656 C 626.36154 926.74102 673.9568 920.77568 702.33203 884.4668 C 720.04276 849.20725 753.55087 830.82081 782.38477 806.63281 C 816.72989 771.58231 838.33704 725.52218 851.42578 678.64062 C 853.74925 660.01959 870.72296 611.5352 869.11719 579.91211 C 876.51005 532.95074 878.33465 484.14554 866.31641 437.89844 C 879.98281 393.37 868.87111 347.08312 866.87109 301.52148 C 858.01092 246.34361 799.39416 215.12646 792.55078 158.82227 C 768.95243 110.68367 714.34642 83.955117 662.24805 84.765625 C 619.53959 69.32581 571.50126 70.255977 535.91602 38.785156 C 499.34325 25.027918 459.71597 28.170707 421.56055 25 z "
|
||||||
|
inkscape:label="outer_regions">
|
||||||
|
<title
|
||||||
|
id="title164">outer_regions</title>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
id="galaxy"
|
||||||
|
d="m 0,0 c -1.6392001,35.116515 -10.023207,35.039508 -15.228,69.595 -3.16633,26.556969 -23.215345,63.20707 -12.197,99.321 8.775939,22.99484 28.5458585,15.82859 26.292,39.489 -13.746171,11.05737 -39.190764,1.70514 -15.093391,30.59945 13.8040271,32.52806 -5.434238,72.38925 3.732391,98.69255 -29.901136,14.65779 -46.072395,39.08484 -73.543,58.171 -11.20111,5.24584 -14.48904,19.28766 -26.322,21.397 -10.99407,31.8062 -40.04663,46.53721 -32.749,82.693 -0.35886,19.87543 33.67428,51.0387 -9.432,49.264 -24.98523,21.89886 -59.03894,-19.58862 -82.205,0.67 -21.28822,33.89408 -85.20435,47.10102 -88.004,1.79 -44.08483,26.17012 -90.65619,-18.62026 -137.134,-10.283 -40.01587,-0.0909 -57.1319,46.77937 -30.271,73.147 35.33449,55.11398 -61.76417,53.18677 -96.253,49.822 -33.09559,9.97619 -66.47051,4.71143 -79.143,-32.125 -22.05388,-25.34503 -23.37227,-60.42446 -56.916,-62.72 -20.4883,-13.15411 -25.8494,-31.47265 -40.86,-47.732 -18.64637,-25.23778 -33.09729,-63.94102 -5.581,-90.225 3.85784,-9.21708 15.89965,-13.09958 17.685,-31.637 -11.43758,-16.32978 -18.56292,-37.50213 -34.594,-48.636 -11.82516,-16.49377 -19.36161,-17.07354 -29.803,-33.043 -14.73921,-24.87286 -24.32464,-36.55882 9.722,-53.02 -4.46635,-30.01183 27.85389,-43.74102 -4.50894,-76.21989 -41.15805,-9.92894 -28.31953,-49.82846 10.02294,-43.45411 27.71821,12.87712 84.09192,-10.4708 31.292,-27.711 -30.52347,-27.419057 -43.28626,-68.708385 11.977,-75.612 -2.55343,-31.955736 -0.49004,-67.4389 20.284,-94.317 -9.21704,-11.892963 -47.71154,-2.006868 -50.588,-24.1 18.68092,-14.240523 35.97904,-34.807 7.814,-52.503 -37.3362,18.82968 -27.89316,-49.1466 -38.85036,-77.57622 15.50352,-20.08451 47.6389,-25.39807 23.04336,-51.35178 19.90369,-23.02097 60.50378,-65.64143 2.124,-48.359 -43.82094,18.53403 -12.57036,-21.67302 -26.33425,-16.04163 -37.15002,-29.11066 -79.0999,-21.12707 -89.96725,29.91388 -5.73779,15.4801 -9.03267,79.63161 -21.4215,28.46075 -8.01474,-29.79609 -68.8023,-113.51537 0.438,-102.413 39.22362,-14.06855 77.05303,-54.97327 74.67829,-87.746 21.69756,-4.34878 -18.48907,-47.62093 -10.34029,-64.645 -5.87275,-20.79107 -48.64208,-90.32811 3.856,-78.442 25.95779,54.95301 87.97139,14.8539 83.69,-33.268 30.62651,-7.46583 35.16758,-34.36055 52.702,-48.12 28.18917,4.61439 55.18559,27.73461 79.007,26.784 16.85072,22.46244 65.72934,17.0922 85.874,-2.924 8.52973,-25.21895 39.647,-31.81127 22.51962,1.27975 20.0774,31.27774 74.42592,-8.88745 76.58038,43.09325 15.37634,77.33322 94.51355,44.96818 142.54337,27.92312 41.25251,-19.85852 74.71211,13.81099 47.15338,51.488 -12.81622,29.9902 -14.46113,63.21674 24.32325,48.65688 16.10743,21.83905 43.9754,12.57457 67.78603,19.03461 38.31421,13.47166 77.19382,31.81122 97.70597,68.50239 21.293609,36.98933 44.436529,77.64473 46.371,120.536 11.39275,21.95642 17.62657,54.06755 22.786875,80.10637 -1.319042,30.31478 9.1770622,62.72516 8.520125,94.89763 C -3.0364735,-66.030256 2.7957668,-33.727398 0,0 Z"
|
||||||
|
style="display:inline;fill:#000015;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00024;stroke-dasharray:none;paint-order:stroke markers fill"
|
||||||
|
clip-path="url(#clipPath4)"
|
||||||
|
transform="matrix(0.51651973,0,0,-0.51923866,749.95318,497.299)"
|
||||||
|
inkscape:label="galaxy">
|
||||||
|
<title
|
||||||
|
id="title163">galaxy</title>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -7,19 +7,6 @@
|
|||||||
<link rel="stylesheet" href="./style.css" />
|
<link rel="stylesheet" href="./style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Mobile / small-screen notice ───────────────────────────────────────── -->
|
|
||||||
<div class="mobileOverlay" id="mobileOverlay" aria-live="polite" role="alert">
|
|
||||||
<div class="mobileOverlayCard">
|
|
||||||
<div class="mobileOverlayIcon">🚀</div>
|
|
||||||
<h2 class="mobileOverlayTitle">Desktop only</h2>
|
|
||||||
<p class="mobileOverlayText">
|
|
||||||
<strong>Star Wars – Wild Space</strong> requires a desktop browser.<br />
|
|
||||||
The galaxy map is not playable on phones or small screens.<br />
|
|
||||||
Please open this page on a computer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
|
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
|
||||||
<div class="authOverlay" id="authOverlay">
|
<div class="authOverlay" id="authOverlay">
|
||||||
<div class="authModal">
|
<div class="authModal">
|
||||||
@@ -61,11 +48,11 @@
|
|||||||
<div class="authTeamChoice">
|
<div class="authTeamChoice">
|
||||||
<label class="authTeamOption">
|
<label class="authTeamOption">
|
||||||
<input type="radio" name="regTeam" value="blue" required />
|
<input type="radio" name="regTeam" value="blue" required />
|
||||||
<span class="authTeamBadge authTeamBadge--blue">Blue</span>
|
<span class="authTeamBadge authTeamBadge--blue">Resistance</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="authTeamOption">
|
<label class="authTeamOption">
|
||||||
<input type="radio" name="regTeam" value="red" />
|
<input type="radio" name="regTeam" value="red" />
|
||||||
<span class="authTeamBadge authTeamBadge--red">Red</span>
|
<span class="authTeamBadge authTeamBadge--red">First Order</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,13 +62,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug team switcher (only visible when debugModeForTeams is enabled) -->
|
<!-- Debug team switcher (only visible when admin unlocked) -->
|
||||||
<div class="teamCorner teamCorner--hidden" id="teamCorner">
|
<div class="teamCorner teamCorner--hidden" id="teamCorner">
|
||||||
<span class="teamCornerLabel">Team</span>
|
<span class="teamCornerLabel">Team</span>
|
||||||
<div class="teamSegmented" role="group" aria-label="Active team">
|
<div class="teamSegmented" role="group" aria-label="Active team">
|
||||||
<div class="teamSegmentedTrack" id="teamSegmentedTrack" data-active="blue">
|
<div class="teamSegmentedTrack" id="teamSegmentedTrack" data-active="blue">
|
||||||
<button type="button" class="teamSegmentedBtn" id="teamBlue" data-team="blue">Blue</button>
|
<button type="button" class="teamSegmentedBtn" id="teamBlue" data-team="blue">Resistance</button>
|
||||||
<button type="button" class="teamSegmentedBtn" id="teamRed" data-team="red">Red</button>
|
<button type="button" class="teamSegmentedBtn" id="teamRed" data-team="red">First Order</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,23 +77,28 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
||||||
<!-- ── Left information column ────────────────────────────────────────── -->
|
<!-- ── Left information column ────────────────────────────────────────── -->
|
||||||
<aside class="infoColumn">
|
<aside class="infoColumn" id="infoColumn">
|
||||||
|
|
||||||
|
<!-- Mobile close button -->
|
||||||
|
<button type="button" id="closeMenuBtn" class="closeMenuBtn" aria-label="Close menu">✕</button>
|
||||||
|
|
||||||
<div class="infoSection infoSection--title">
|
<div class="infoSection infoSection--title">
|
||||||
<div class="h1">Star Wars – Wild Space</div>
|
<div class="h1">Star Wars – Wild Space</div>
|
||||||
<div class="sub">100×100 — exploitable ring only (inner Ø60, outer Ø100)</div>
|
<div class="sub">100×100 — exploitable zone from playground SVG map</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team score display -->
|
<!-- Team score display -->
|
||||||
<div class="scoreBoard" id="scoreBoard">
|
<div class="scoreBoard" id="scoreBoard">
|
||||||
<span class="scoreTeam scoreTeam--blue">
|
<span class="scoreTeam scoreTeam--blue">
|
||||||
<span class="scoreTeamName">Blue</span>
|
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
|
||||||
|
<span class="scoreTeamName">Resistance</span>
|
||||||
<span class="scoreValue" id="scoreBlue">0</span>
|
<span class="scoreValue" id="scoreBlue">0</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="scoreSep">—</span>
|
<span class="scoreSep">—</span>
|
||||||
<span class="scoreTeam scoreTeam--red">
|
<span class="scoreTeam scoreTeam--red">
|
||||||
<span class="scoreValue" id="scoreRed">0</span>
|
<span class="scoreValue" id="scoreRed">0</span>
|
||||||
<span class="scoreTeamName">Red</span>
|
<span class="scoreTeamName">First Order</span>
|
||||||
|
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,38 +119,87 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
<span class="infoKey muted">clickCooldownSeconds</span>
|
<span class="infoKey muted">Temps avant prochain clic</span>
|
||||||
<code class="infoVal" id="cooldownConfig">—</code>
|
<code class="infoVal" id="cooldownConfig">—</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
<span class="infoKey muted">worldSeed</span>
|
<span class="infoKey muted">Graine de la carte</span>
|
||||||
<code class="infoVal" id="worldSeedDisplay">—</code>
|
<code class="infoVal" id="worldSeedDisplay">—</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
<span class="infoKey muted">next period (UTC)</span>
|
<span class="infoKey muted">Prochaine graine (UTC)</span>
|
||||||
<code class="infoVal" id="nextPeriodUtc">—</code>
|
<code class="infoVal" id="nextPeriodUtc">—</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
<span class="infoKey muted">resets in</span>
|
<span class="infoKey muted">Prochaine graine dans</span>
|
||||||
<code class="infoVal" id="refreshCountdown">--:--:--</code>
|
<code class="infoVal" id="refreshCountdown">--:--:--</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<!-- Planet stats (collapsible) -->
|
||||||
<button id="refreshBtn" type="button">Refresh</button>
|
<details class="panel panelCollapsible" id="planetStatsDetails" open>
|
||||||
|
<summary class="panelTitle panelTitleSummary">🪐 Planet stats</summary>
|
||||||
|
<pre id="details" class="details details--hidden">Stats are hidden until you click a tile.</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Team income summary + cumulative economic score -->
|
||||||
|
<div class="econSummary">
|
||||||
|
<!-- Row 1: income per second -->
|
||||||
|
<div class="econSummaryRow">
|
||||||
|
<span class="econSummaryTeam econSummaryTeam--blue">
|
||||||
|
<span class="econSummaryLabel">Résistance</span>
|
||||||
|
<span class="econSummaryVal" id="incomeBlue">+0.000/s</span>
|
||||||
|
</span>
|
||||||
|
<span class="econSummarySep">—</span>
|
||||||
|
<span class="econSummaryTeam econSummaryTeam--red">
|
||||||
|
<span class="econSummaryVal" id="incomeRed">+0.000/s</span>
|
||||||
|
<span class="econSummaryLabel">Premier Ordre</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: cumulative economic score -->
|
||||||
|
<div class="econSummaryRow econSummaryRow--score">
|
||||||
|
<span class="econSummaryTeam econSummaryTeam--blue">
|
||||||
|
<span class="econScoreVal" id="econScoreBlue">0.000</span>
|
||||||
|
<span class="econDelta" id="econDeltaBlue"></span>
|
||||||
|
</span>
|
||||||
|
<span class="econSummarySep">—</span>
|
||||||
|
<span class="econSummaryTeam econSummaryTeam--red">
|
||||||
|
<span class="econDelta" id="econDeltaRed"></span>
|
||||||
|
<span class="econScoreVal" id="econScoreRed">0.000</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selection details panel -->
|
<!-- Resources overview (collapsible) -->
|
||||||
<div class="panel">
|
<details class="panel panelCollapsible">
|
||||||
<div class="panelTitle">Selection</div>
|
<summary class="panelTitle panelTitleSummary">💰 Ressources économiques</summary>
|
||||||
<pre id="details" class="details details--hidden">Stats are hidden until you click a tile.</pre>
|
<div id="resourceTableBody" class="econTableWrap">
|
||||||
|
<p class="econEmpty">Chargement…</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Admin / options section -->
|
||||||
|
<div class="infoSection infoSection--options">
|
||||||
|
<details class="optionsDetails">
|
||||||
|
<summary class="optionsSummary">⚙ Options</summary>
|
||||||
|
<div class="optionsPanel">
|
||||||
|
<div class="authField">
|
||||||
|
<label>Admin password</label>
|
||||||
|
<input type="password" id="adminPasswordInput" placeholder="Enter admin password" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<button type="button" id="adminUnlockBtn" class="adminUnlockBtn">Unlock</button>
|
||||||
|
<div id="adminStatus" class="adminStatus hidden"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ── Galaxy (square, anchored to the right edge) ────────────────────── -->
|
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
|
||||||
<main class="galaxyMain">
|
<main class="galaxyMain">
|
||||||
<canvas id="canvas" width="800" height="800"></canvas>
|
<!-- Mobile burger button -->
|
||||||
|
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu">☰</button>
|
||||||
|
<canvas id="canvas" width="1000" height="1000"></canvas>
|
||||||
<div id="hint" class="hint">Click a cell in the ring. Planet stats stay hidden until you reveal a tile.</div>
|
<div id="hint" class="hint">Click a cell in the ring. Planet stats stay hidden until you reveal a tile.</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,19 @@ export async function apiGetMe(token) {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiFetchEconScores() {
|
||||||
|
const res = await fetch("/api/econ-scores");
|
||||||
|
if (!res.ok) throw new Error("econ_scores_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiTickEconScores(seed, blue, red) {
|
||||||
|
const res = await fetch("/api/econ-scores/tick", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ seed, blue, red }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("econ_tick_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
125
public/src/economy.js
Normal file
125
public/src/economy.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { resources } from "./planetEconomy.js";
|
||||||
|
|
||||||
|
// ── Sort state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 0=Ressource, 1=Rareté, 2=Valeur, 3=Revenu/s */
|
||||||
|
let _sortCol = 3;
|
||||||
|
let _sortDir = "desc";
|
||||||
|
|
||||||
|
export function setEconSort(col, dir) {
|
||||||
|
_sortCol = col;
|
||||||
|
_sortDir = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEconSort() {
|
||||||
|
return { col: _sortCol, dir: _sortDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Label → resource key lookup ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Map from French label string → { cat: "common"|"rare", key: string } */
|
||||||
|
const LABEL_TO_RESOURCE = (() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const [cat, entries] of Object.entries(resources)) {
|
||||||
|
for (const [key, label] of Object.entries(entries)) {
|
||||||
|
map.set(label, { cat, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Income calculation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute income per second for a team based on their discovered planets.
|
||||||
|
*
|
||||||
|
* @param {string} team - "blue" or "red"
|
||||||
|
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
|
||||||
|
* @param {object} resourceWorth - { common: { rock: 1, ... }, rare: { rock: 3, ... } }
|
||||||
|
* @returns {{ total: number, byResource: Map<string, number> }}
|
||||||
|
* byResource keys are resource label strings (French names), values are credits/sec
|
||||||
|
*/
|
||||||
|
export function computeTeamIncome(team, cells, resourceWorth) {
|
||||||
|
/** @type {Map<string, number>} label → cumulative income/sec */
|
||||||
|
const byResource = new Map();
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const [, meta] of cells) {
|
||||||
|
if (meta.discoveredBy !== team) continue;
|
||||||
|
if (!meta.hasPlanet || !meta.planet) continue;
|
||||||
|
const { naturalResources } = meta.planet;
|
||||||
|
if (!naturalResources) continue;
|
||||||
|
|
||||||
|
for (const [label, pct] of Object.entries(naturalResources)) {
|
||||||
|
const info = LABEL_TO_RESOURCE.get(label);
|
||||||
|
if (!info) continue;
|
||||||
|
const worth = resourceWorth?.[info.cat]?.[info.key] ?? 0;
|
||||||
|
if (worth === 0) continue;
|
||||||
|
const income = (pct / 100) * worth;
|
||||||
|
byResource.set(label, (byResource.get(label) ?? 0) + income);
|
||||||
|
total += income;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, byResource };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resource table for the sidebar ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the resource overview table for the economy panel.
|
||||||
|
*
|
||||||
|
* @param {object} resourceWorth - { common: {…}, rare: {…} }
|
||||||
|
* @param {Map<string, number>} teamByResource - income/sec per label for current team
|
||||||
|
* @returns {string} HTML string
|
||||||
|
*/
|
||||||
|
export function renderResourceTable(resourceWorth, teamByResource) {
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const [cat, entries] of Object.entries(resources)) {
|
||||||
|
for (const [key, label] of Object.entries(entries)) {
|
||||||
|
const worth = resourceWorth?.[cat]?.[key] ?? 0;
|
||||||
|
const income = teamByResource?.get(label) ?? 0;
|
||||||
|
const incomeStr = income > 0 ? `+${income.toFixed(3)}/s` : "—";
|
||||||
|
const catLabel = cat === "rare" ? "Rare" : "Commun";
|
||||||
|
rows.push({ label, catLabel, worth, income, incomeStr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by selected column
|
||||||
|
const mult = _sortDir === "asc" ? 1 : -1;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (_sortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
|
||||||
|
if (_sortCol === 1) return mult * a.catLabel.localeCompare(b.catLabel, "fr");
|
||||||
|
if (_sortCol === 2) return mult * (a.worth - b.worth);
|
||||||
|
if (_sortCol === 3) return mult * (a.income - b.income);
|
||||||
|
return b.income - a.income || b.worth - a.worth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableRows = rows
|
||||||
|
.map(({ label, catLabel, worth, incomeStr, income }) => {
|
||||||
|
const incomeClass = income > 0 ? " econ-income--positive" : "";
|
||||||
|
return `<tr>
|
||||||
|
<td class="econ-label">${label}</td>
|
||||||
|
<td class="econ-cat econ-cat--${catLabel.toLowerCase()}">${catLabel}</td>
|
||||||
|
<td class="econ-worth">${worth}</td>
|
||||||
|
<td class="econ-income${incomeClass}">${incomeStr}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const thLabels = ["Ressource", "Rareté", "Valeur", "Revenu/s"];
|
||||||
|
const headers = thLabels
|
||||||
|
.map((lbl, i) => {
|
||||||
|
const isActive = i === _sortCol;
|
||||||
|
const indicator = isActive ? (_sortDir === "asc" ? " ▲" : " ▼") : " ⇅";
|
||||||
|
const activeClass = isActive ? " econTh--active" : "";
|
||||||
|
return `<th class="econTh${activeClass}" data-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<table class="econTable">
|
||||||
|
<thead><tr>${headers}</tr></thead>
|
||||||
|
<tbody>${tableRows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
||||||
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
||||||
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
|
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores } from "./api.js";
|
||||||
|
import { computeTeamIncome, renderResourceTable, setEconSort, getEconSort } from "./economy.js";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GRID_W = 100;
|
const GRID_W = 100;
|
||||||
const GRID_H = 100;
|
const GRID_H = 100;
|
||||||
const OUTER_RADIUS = 50;
|
|
||||||
const INNER_RADIUS = 30;
|
|
||||||
const PLANET_CHANCE = 0.1;
|
const PLANET_CHANCE = 0.1;
|
||||||
|
|
||||||
const COLOR_OUTSIDE = "#000000";
|
// SVG playfield dimensions (must match /graphism/playground.svg viewBox)
|
||||||
|
const SVG_W = 1000;
|
||||||
|
const SVG_H = 1000;
|
||||||
|
|
||||||
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
|
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
|
||||||
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
|
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
|
||||||
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
|
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
|
||||||
@@ -21,10 +23,11 @@ const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
|
|||||||
export const GAME_CONFIG = {
|
export const GAME_CONFIG = {
|
||||||
clickCooldownSeconds: 5,
|
clickCooldownSeconds: 5,
|
||||||
databaseWipeoutIntervalSeconds: 21600,
|
databaseWipeoutIntervalSeconds: 21600,
|
||||||
debugModeForTeams: true,
|
debugModeForTeams: false,
|
||||||
configReloadIntervalSeconds: 30,
|
configReloadIntervalSeconds: 30,
|
||||||
worldSeed: "",
|
worldSeed: "",
|
||||||
seedPeriodEndsAtUtc: "",
|
seedPeriodEndsAtUtc: "",
|
||||||
|
resourceWorth: { common: {}, rare: {} },
|
||||||
};
|
};
|
||||||
window.GAME_CONFIG = GAME_CONFIG;
|
window.GAME_CONFIG = GAME_CONFIG;
|
||||||
|
|
||||||
@@ -45,6 +48,93 @@ export const teamCooldownEndMs = { blue: 0, red: 0 };
|
|||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
let lastPointerEvent = null;
|
let lastPointerEvent = null;
|
||||||
|
|
||||||
|
// ── Playfield mask ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
|
||||||
|
let exploitableTiles = null;
|
||||||
|
|
||||||
|
/** Preloaded SVG image for background rendering. */
|
||||||
|
let playfieldImg = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads /graphism/playground.svg, rasterises it to an offscreen canvas,
|
||||||
|
* then classifies each 100×100 grid tile as exploitable if ≥80 % of its
|
||||||
|
* sampled pixels are the black playable zone (R≤20, G≤20, B≤20).
|
||||||
|
*/
|
||||||
|
export function loadPlayfieldMask() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
playfieldImg = img;
|
||||||
|
|
||||||
|
// ── Rasterise SVG at native size onto an offscreen canvas ──
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = SVG_W;
|
||||||
|
oc.height = SVG_H;
|
||||||
|
const octx = oc.getContext("2d");
|
||||||
|
|
||||||
|
// White fill so transparent SVG areas appear white (not black).
|
||||||
|
octx.fillStyle = "#ffffff";
|
||||||
|
octx.fillRect(0, 0, SVG_W, SVG_H);
|
||||||
|
octx.drawImage(img, 0, 0, SVG_W, SVG_H);
|
||||||
|
|
||||||
|
let imageData;
|
||||||
|
try {
|
||||||
|
imageData = octx.getImageData(0, 0, SVG_W, SVG_H);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[playfield] canvas tainted – no exploitable tiles:", e);
|
||||||
|
exploitableTiles = new Set();
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = imageData.data; // RGBA flat array
|
||||||
|
|
||||||
|
const SAMPLES_X = 10;
|
||||||
|
const SAMPLES_Y = 10;
|
||||||
|
const cellW = SVG_W / GRID_W; // 7.5 px
|
||||||
|
const cellH = SVG_H / GRID_H; // 10 px
|
||||||
|
const tiles = new Set();
|
||||||
|
|
||||||
|
for (let gy = 0; gy < GRID_H; gy++) {
|
||||||
|
for (let gx = 0; gx < GRID_W; gx++) {
|
||||||
|
let blackCount = 0;
|
||||||
|
|
||||||
|
for (let sy = 0; sy < SAMPLES_Y; sy++) {
|
||||||
|
for (let sx = 0; sx < SAMPLES_X; sx++) {
|
||||||
|
const px = Math.min(
|
||||||
|
SVG_W - 1,
|
||||||
|
Math.floor(gx * cellW + (sx + 0.5) * cellW / SAMPLES_X)
|
||||||
|
);
|
||||||
|
const py = Math.min(
|
||||||
|
SVG_H - 1,
|
||||||
|
Math.floor(gy * cellH + (sy + 0.5) * cellH / SAMPLES_Y)
|
||||||
|
);
|
||||||
|
const idx = (py * SVG_W + px) * 4;
|
||||||
|
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
|
||||||
|
// Pure black = playable zone (path2, fill:#000000)
|
||||||
|
if (r <= 20 && g <= 20 && b <= 20) blackCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = blackCount / (SAMPLES_X * SAMPLES_Y);
|
||||||
|
if (coverage >= 0.8) tiles.add(cellKey(gx, gy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exploitableTiles = tiles;
|
||||||
|
console.log(`[playfield] ${tiles.size} exploitable tiles loaded.`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.warn("[playfield] failed to load playground.svg");
|
||||||
|
exploitableTiles = new Set();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.src = "/graphism/playground.svg";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const canvas = document.getElementById("canvas");
|
const canvas = document.getElementById("canvas");
|
||||||
@@ -59,6 +149,13 @@ const nextPeriodEl = document.getElementById("nextPeriodUtc");
|
|||||||
const resetCountEl = document.getElementById("refreshCountdown");
|
const resetCountEl = document.getElementById("refreshCountdown");
|
||||||
const scoreBlueEl = document.getElementById("scoreBlue");
|
const scoreBlueEl = document.getElementById("scoreBlue");
|
||||||
const scoreRedEl = document.getElementById("scoreRed");
|
const scoreRedEl = document.getElementById("scoreRed");
|
||||||
|
const incomeBlueEl = document.getElementById("incomeBlue");
|
||||||
|
const incomeRedEl = document.getElementById("incomeRed");
|
||||||
|
const resourceTableEl = document.getElementById("resourceTableBody");
|
||||||
|
const econScoreBlueEl = document.getElementById("econScoreBlue");
|
||||||
|
const econScoreRedEl = document.getElementById("econScoreRed");
|
||||||
|
const econDeltaBlueEl = document.getElementById("econDeltaBlue");
|
||||||
|
const econDeltaRedEl = document.getElementById("econDeltaRed");
|
||||||
const teamCorner = document.getElementById("teamCorner");
|
const teamCorner = document.getElementById("teamCorner");
|
||||||
const teamTrack = document.getElementById("teamSegmentedTrack");
|
const teamTrack = document.getElementById("teamSegmentedTrack");
|
||||||
const teamBlueBtn = document.getElementById("teamBlue");
|
const teamBlueBtn = document.getElementById("teamBlue");
|
||||||
@@ -68,16 +165,9 @@ const teamRedBtn = document.getElementById("teamRed");
|
|||||||
|
|
||||||
export function cellKey(x, y) { return `${x},${y}`; }
|
export function cellKey(x, y) { return `${x},${y}`; }
|
||||||
|
|
||||||
function cellCenter(x, y) {
|
|
||||||
const cx = (GRID_W - 1) / 2;
|
|
||||||
const cy = (GRID_H - 1) / 2;
|
|
||||||
return { dx: x - cx, dy: y - cy };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isExploitable(x, y) {
|
export function isExploitable(x, y) {
|
||||||
const { dx, dy } = cellCenter(x, y);
|
if (!exploitableTiles) return false;
|
||||||
const r = Math.hypot(dx, dy);
|
return exploitableTiles.has(cellKey(x, y));
|
||||||
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPlanetAt(x, y) {
|
function hasPlanetAt(x, y) {
|
||||||
@@ -98,6 +188,9 @@ export function applyConfigPayload(data) {
|
|||||||
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
|
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
|
||||||
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
|
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
|
||||||
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
|
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
|
||||||
|
if (data.resourceWorth && typeof data.resourceWorth === "object") {
|
||||||
|
GAME_CONFIG.resourceWorth = data.resourceWorth;
|
||||||
|
}
|
||||||
|
|
||||||
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
||||||
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
||||||
@@ -107,11 +200,8 @@ export function applyConfigPayload(data) {
|
|||||||
|
|
||||||
updateResetCountdown();
|
updateResetCountdown();
|
||||||
|
|
||||||
if (GAME_CONFIG.debugModeForTeams) {
|
// Team switcher visibility is managed exclusively via unlockTeamSwitcher() /
|
||||||
teamCorner.classList.remove("teamCorner--hidden");
|
// lockTeamSwitcher() — NOT by debugModeForTeams config.
|
||||||
} else {
|
|
||||||
teamCorner.classList.add("teamCorner--hidden");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateResetCountdown() {
|
export function updateResetCountdown() {
|
||||||
@@ -125,6 +215,18 @@ export function updateResetCountdown() {
|
|||||||
String(s % 60).padStart(2, "0");
|
String(s % 60).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Team switcher (admin-only) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Show the team switcher widget. Called only after successful admin unlock. */
|
||||||
|
export function unlockTeamSwitcher() {
|
||||||
|
teamCorner.classList.remove("teamCorner--hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the team switcher widget. */
|
||||||
|
export function lockTeamSwitcher() {
|
||||||
|
teamCorner.classList.add("teamCorner--hidden");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchAndApplyScores() {
|
export async function fetchAndApplyScores() {
|
||||||
@@ -135,6 +237,92 @@ export async function fetchAndApplyScores() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Economy display ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Recalculates and renders team income and resource table. */
|
||||||
|
export function updateEconomyDisplay() {
|
||||||
|
const worth = GAME_CONFIG.resourceWorth;
|
||||||
|
|
||||||
|
const blueIncome = computeTeamIncome("blue", cells, worth);
|
||||||
|
const redIncome = computeTeamIncome("red", cells, worth);
|
||||||
|
|
||||||
|
if (incomeBlueEl) incomeBlueEl.textContent = `+${blueIncome.total.toFixed(3)}/s`;
|
||||||
|
if (incomeRedEl) incomeRedEl.textContent = `+${redIncome.total.toFixed(3)}/s`;
|
||||||
|
|
||||||
|
// Resource table shows own-team breakdown
|
||||||
|
const teamIncome = currentTeam === "blue" ? blueIncome : redIncome;
|
||||||
|
if (resourceTableEl) {
|
||||||
|
resourceTableEl.innerHTML = renderResourceTable(worth, teamIncome.byResource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Economic score ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let econScoreBlue = 0;
|
||||||
|
let econScoreRed = 0;
|
||||||
|
|
||||||
|
/** Trigger the delta fade animation on an element. */
|
||||||
|
function showEconDelta(el, delta) {
|
||||||
|
if (!el || delta <= 0) return;
|
||||||
|
el.textContent = `+${delta.toFixed(3)}`;
|
||||||
|
el.classList.remove("econDelta--active");
|
||||||
|
// Force reflow so removing/re-adding the class restarts the animation
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add("econDelta--active");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called every ECON_TICK_MS milliseconds.
|
||||||
|
* Adds income × (interval in seconds) to each team's cumulative score,
|
||||||
|
* updates the score display, and triggers the delta animation.
|
||||||
|
*
|
||||||
|
* @param {number} intervalSeconds - how many seconds elapsed since last tick
|
||||||
|
*/
|
||||||
|
export async function tickEconScore(intervalSeconds) {
|
||||||
|
const worth = GAME_CONFIG.resourceWorth;
|
||||||
|
|
||||||
|
const blueIncome = computeTeamIncome("blue", cells, worth);
|
||||||
|
const redIncome = computeTeamIncome("red", cells, worth);
|
||||||
|
|
||||||
|
const blueDelta = blueIncome.total * intervalSeconds;
|
||||||
|
const redDelta = redIncome.total * intervalSeconds;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scores = await apiTickEconScores(seedStr, blueDelta, redDelta);
|
||||||
|
econScoreBlue = scores.blue;
|
||||||
|
econScoreRed = scores.red;
|
||||||
|
} catch {
|
||||||
|
// fallback: update locally if server unreachable
|
||||||
|
econScoreBlue += blueDelta;
|
||||||
|
econScoreRed += redDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
|
||||||
|
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
|
||||||
|
|
||||||
|
showEconDelta(econDeltaBlueEl, blueDelta);
|
||||||
|
showEconDelta(econDeltaRedEl, redDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load economic scores from server (called on boot / after seed change). */
|
||||||
|
export async function loadEconScores() {
|
||||||
|
try {
|
||||||
|
const scores = await apiFetchEconScores();
|
||||||
|
econScoreBlue = scores.blue ?? 0;
|
||||||
|
econScoreRed = scores.red ?? 0;
|
||||||
|
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
|
||||||
|
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset economic scores (called when world period changes). */
|
||||||
|
export function resetEconScores() {
|
||||||
|
econScoreBlue = 0;
|
||||||
|
econScoreRed = 0;
|
||||||
|
if (econScoreBlueEl) econScoreBlueEl.textContent = "0.000";
|
||||||
|
if (econScoreRedEl) econScoreRedEl.textContent = "0.000";
|
||||||
|
}
|
||||||
|
|
||||||
// ── Config fetch + apply ──────────────────────────────────────────────────────
|
// ── Config fetch + apply ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
||||||
@@ -186,6 +374,8 @@ function tickCooldown() {
|
|||||||
countdownEl.textContent = "0";
|
countdownEl.textContent = "0";
|
||||||
teamCooldownEndMs[currentTeam] = 0;
|
teamCooldownEndMs[currentTeam] = 0;
|
||||||
refreshCursorFromLast();
|
refreshCursorFromLast();
|
||||||
|
// Auto-refresh from server when cooldown expires
|
||||||
|
refreshFromServer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
countdownWrap.classList.remove("hidden");
|
countdownWrap.classList.remove("hidden");
|
||||||
@@ -222,15 +412,23 @@ export function clearCooldown() {
|
|||||||
export function draw() {
|
export function draw() {
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
ctx.fillStyle = COLOR_OUTSIDE;
|
|
||||||
|
// 1. Dark base fill (shows in areas outside the SVG paths)
|
||||||
|
ctx.fillStyle = "#050810";
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// 2. Draw the SVG playfield as the background layer
|
||||||
|
if (playfieldImg) {
|
||||||
|
ctx.drawImage(playfieldImg, 0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
const cw = w / GRID_W;
|
const cw = w / GRID_W;
|
||||||
const ch = h / GRID_H;
|
const ch = h / GRID_H;
|
||||||
|
|
||||||
|
// 3. Draw game tile overlays (only for exploitable tiles)
|
||||||
for (let y = 0; y < GRID_H; y++) {
|
for (let y = 0; y < GRID_H; y++) {
|
||||||
for (let x = 0; x < GRID_W; x++) {
|
for (let x = 0; x < GRID_W; x++) {
|
||||||
if (!isExploitable(x, y)) { ctx.fillStyle = COLOR_OUTSIDE; ctx.fillRect(x * cw, y * ch, cw, ch); continue; }
|
if (!isExploitable(x, y)) continue;
|
||||||
const k = cellKey(x, y);
|
const k = cellKey(x, y);
|
||||||
const meta = cellMeta(k);
|
const meta = cellMeta(k);
|
||||||
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
|
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
|
||||||
@@ -240,6 +438,7 @@ export function draw() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Draw planet dots on own discovered tiles
|
||||||
for (const [key, meta] of cells) {
|
for (const [key, meta] of cells) {
|
||||||
if (meta.discoveredBy !== currentTeam) continue;
|
if (meta.discoveredBy !== currentTeam) continue;
|
||||||
const [xs, ys] = key.split(",").map(Number);
|
const [xs, ys] = key.split(",").map(Number);
|
||||||
@@ -253,6 +452,7 @@ export function draw() {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Grid lines between adjacent exploitable tiles
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -369,6 +569,7 @@ async function onCanvasClick(ev) {
|
|||||||
if (!res.ok) throw new Error("reveal");
|
if (!res.ok) throw new Error("reveal");
|
||||||
applyRevealPayload(await res.json());
|
applyRevealPayload(await res.json());
|
||||||
startCooldown();
|
startCooldown();
|
||||||
|
updateEconomyDisplay();
|
||||||
draw();
|
draw();
|
||||||
fetchAndApplyScores();
|
fetchAndApplyScores();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -385,6 +586,18 @@ export function updateTeamSegmented() {
|
|||||||
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
|
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lightweight grid refresh (called every second) ────────────────────────────
|
||||||
|
|
||||||
|
/** Fetches the current grid and redraws without touching config or scores. */
|
||||||
|
export async function refreshGridDisplay() {
|
||||||
|
try {
|
||||||
|
await fetchGridForSeed(seedStr);
|
||||||
|
updateEconomyDisplay();
|
||||||
|
draw();
|
||||||
|
refreshCursorFromLast();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Full server refresh ───────────────────────────────────────────────────────
|
// ── Full server refresh ───────────────────────────────────────────────────────
|
||||||
// Exported so auth.js / main.js can call it after login or on timer.
|
// Exported so auth.js / main.js can call it after login or on timer.
|
||||||
|
|
||||||
@@ -393,12 +606,15 @@ export async function refreshFromServer() {
|
|||||||
const seedChanged = await fetchConfig();
|
const seedChanged = await fetchConfig();
|
||||||
if (seedChanged) {
|
if (seedChanged) {
|
||||||
clearCooldown();
|
clearCooldown();
|
||||||
|
resetEconScores();
|
||||||
|
loadEconScores();
|
||||||
details.textContent = "Stats are hidden until you click a tile.";
|
details.textContent = "Stats are hidden until you click a tile.";
|
||||||
details.classList.add("details--hidden");
|
details.classList.add("details--hidden");
|
||||||
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
|
hint.textContent = "World period changed — grid reset. Click a cell in the playfield.";
|
||||||
}
|
}
|
||||||
await fetchGridForSeed(seedStr);
|
await fetchGridForSeed(seedStr);
|
||||||
await fetchAndApplyScores();
|
await fetchAndApplyScores();
|
||||||
|
updateEconomyDisplay();
|
||||||
draw();
|
draw();
|
||||||
refreshCursorFromLast();
|
refreshCursorFromLast();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -408,9 +624,25 @@ export async function refreshFromServer() {
|
|||||||
|
|
||||||
// ── Event listeners ───────────────────────────────────────────────────────────
|
// ── Event listeners ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Resource table sort (delegated, set up once) ──────────────────────────────
|
||||||
|
|
||||||
|
resourceTableEl?.addEventListener("click", (ev) => {
|
||||||
|
const th = ev.target.closest("th[data-sort-col]");
|
||||||
|
if (!th) return;
|
||||||
|
const col = Number(th.dataset.sortCol);
|
||||||
|
const { col: curCol, dir: curDir } = getEconSort();
|
||||||
|
// Toggle direction if same column; otherwise default to desc for numeric cols, asc for text
|
||||||
|
const newDir = col === curCol
|
||||||
|
? (curDir === "asc" ? "desc" : "asc")
|
||||||
|
: (col === 2 || col === 3 ? "desc" : "asc");
|
||||||
|
setEconSort(col, newDir);
|
||||||
|
updateEconomyDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
||||||
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
||||||
canvas.addEventListener("click", onCanvasClick);
|
canvas.addEventListener("click", onCanvasClick);
|
||||||
|
|
||||||
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
// Team switcher buttons (kept in codebase, only functional when admin-unlocked)
|
||||||
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||||
|
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ import {
|
|||||||
fetchConfig,
|
fetchConfig,
|
||||||
fetchGridForSeed,
|
fetchGridForSeed,
|
||||||
fetchAndApplyScores,
|
fetchAndApplyScores,
|
||||||
|
updateEconomyDisplay,
|
||||||
|
tickEconScore,
|
||||||
|
loadEconScores,
|
||||||
refreshFromServer,
|
refreshFromServer,
|
||||||
|
refreshGridDisplay,
|
||||||
|
loadPlayfieldMask,
|
||||||
draw,
|
draw,
|
||||||
|
unlockTeamSwitcher,
|
||||||
} from "./game.js";
|
} from "./game.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +24,14 @@ import {
|
|||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const refreshBtn = document.getElementById("refreshBtn");
|
|
||||||
const hint = document.getElementById("hint");
|
const hint = document.getElementById("hint");
|
||||||
const cooldownEl = document.getElementById("cooldownConfig");
|
const cooldownEl = document.getElementById("cooldownConfig");
|
||||||
|
const burgerBtn = document.getElementById("burgerBtn");
|
||||||
|
const closeMenuBtn = document.getElementById("closeMenuBtn");
|
||||||
|
const infoColumn = document.getElementById("infoColumn");
|
||||||
|
const adminPasswordIn = document.getElementById("adminPasswordInput");
|
||||||
|
const adminUnlockBtn = document.getElementById("adminUnlockBtn");
|
||||||
|
const adminStatus = document.getElementById("adminStatus");
|
||||||
|
|
||||||
// ── Polling ───────────────────────────────────────────────────────────────────
|
// ── Polling ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -40,19 +51,87 @@ function scheduleConfigPoll() {
|
|||||||
}, ms);
|
}, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ECON_TICK_SECONDS = 5;
|
||||||
|
|
||||||
function scheduleScorePoll() {
|
function scheduleScorePoll() {
|
||||||
clearTimeout(scorePollTimer);
|
clearTimeout(scorePollTimer);
|
||||||
scorePollTimer = window.setTimeout(async () => {
|
scorePollTimer = window.setTimeout(async () => {
|
||||||
await fetchAndApplyScores();
|
await fetchAndApplyScores();
|
||||||
|
tickEconScore(ECON_TICK_SECONDS);
|
||||||
scheduleScorePoll();
|
scheduleScorePoll();
|
||||||
}, 5_000);
|
}, ECON_TICK_SECONDS * 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Burger / mobile menu ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
infoColumn.classList.add("infoColumn--open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
infoColumn.classList.remove("infoColumn--open");
|
||||||
|
}
|
||||||
|
|
||||||
|
burgerBtn.addEventListener("click", openMenu);
|
||||||
|
closeMenuBtn.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
|
// Close when clicking outside the panel (on the galaxy overlay)
|
||||||
|
document.addEventListener("click", (ev) => {
|
||||||
|
if (
|
||||||
|
infoColumn.classList.contains("infoColumn--open") &&
|
||||||
|
!infoColumn.contains(ev.target) &&
|
||||||
|
ev.target !== burgerBtn
|
||||||
|
) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin password unlock ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showAdminStatus(message, isOk) {
|
||||||
|
adminStatus.textContent = message;
|
||||||
|
adminStatus.className = "adminStatus " + (isOk ? "adminStatus--ok" : "adminStatus--err");
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUnlockBtn.addEventListener("click", async () => {
|
||||||
|
const password = adminPasswordIn.value.trim();
|
||||||
|
if (!password) {
|
||||||
|
showAdminStatus("Enter a password.", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
showAdminStatus("Admin unlocked — team switcher enabled.", true);
|
||||||
|
unlockTeamSwitcher();
|
||||||
|
adminPasswordIn.value = "";
|
||||||
|
} else {
|
||||||
|
showAdminStatus("Invalid password.", false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showAdminStatus("Could not verify — server unreachable.", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow Enter key in password field
|
||||||
|
adminPasswordIn.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") adminUnlockBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
updateTeamSegmented();
|
updateTeamSegmented();
|
||||||
|
|
||||||
|
// Load the SVG playfield mask before any drawing or data fetch
|
||||||
|
await loadPlayfieldMask();
|
||||||
|
|
||||||
const restored = await tryRestoreSession();
|
const restored = await tryRestoreSession();
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
showAuthOverlay();
|
showAuthOverlay();
|
||||||
@@ -64,6 +143,8 @@ async function boot() {
|
|||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
await fetchGridForSeed(seedStr);
|
await fetchGridForSeed(seedStr);
|
||||||
await fetchAndApplyScores();
|
await fetchAndApplyScores();
|
||||||
|
await loadEconScores();
|
||||||
|
updateEconomyDisplay();
|
||||||
} catch {
|
} catch {
|
||||||
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||||
cooldownEl.textContent = "?";
|
cooldownEl.textContent = "?";
|
||||||
@@ -77,12 +158,11 @@ async function boot() {
|
|||||||
|
|
||||||
scheduleConfigPoll();
|
scheduleConfigPoll();
|
||||||
scheduleScorePoll();
|
scheduleScorePoll();
|
||||||
|
|
||||||
|
// Refresh grid every second so all clients see new tiles promptly
|
||||||
|
setInterval(refreshGridDisplay, 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global event listeners ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
refreshBtn.addEventListener("click", () => refreshFromServer());
|
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
494
public/style.css
494
public/style.css
@@ -15,57 +15,6 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile / small-screen overlay ───────────────────────────────────────── */
|
|
||||||
|
|
||||||
.mobileOverlay {
|
|
||||||
display: none; /* hidden on desktop — shown via media query below */
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
background: rgba(5, 8, 18, 0.97);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileOverlayCard {
|
|
||||||
max-width: 360px;
|
|
||||||
padding: 40px 28px;
|
|
||||||
border-radius: 24px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background: rgba(15, 22, 42, 0.9);
|
|
||||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileOverlayIcon {
|
|
||||||
font-size: 52px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileOverlayTitle {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
color: rgba(113, 199, 255, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileOverlayText {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: rgba(233, 238, 246, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show overlay on small / narrow screens */
|
|
||||||
@media (max-width: 768px), (max-height: 500px) {
|
|
||||||
.mobileOverlay {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Auth overlay ─────────────────────────────────────────────────────────── */
|
/* ── Auth overlay ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.authOverlay {
|
.authOverlay {
|
||||||
@@ -242,7 +191,7 @@ body {
|
|||||||
background: rgba(113, 199, 255, 0.28);
|
background: rgba(113, 199, 255, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Team corner (debug) ──────────────────────────────────────────────────── */
|
/* ── Team corner (admin only) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.teamCorner {
|
.teamCorner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -327,6 +276,7 @@ body {
|
|||||||
.scoreBoard {
|
.scoreBoard {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -334,6 +284,7 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -341,9 +292,16 @@ body {
|
|||||||
.scoreTeam {
|
.scoreTeam {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.scoreTeamName {
|
.scoreTeamName {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -380,17 +338,16 @@ body {
|
|||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
height: 100vh;
|
justify-content: center;
|
||||||
overflow: hidden;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Left information column ──────────────────────────────────────────────── */
|
/* ── Left information column ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.infoColumn {
|
.infoColumn {
|
||||||
flex: 1 1 0;
|
flex: 0 0 500px;
|
||||||
min-width: 260px;
|
width: 500px;
|
||||||
max-width: 420px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -399,6 +356,7 @@ body {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: rgba(7, 10, 20, 0.55);
|
background: rgba(7, 10, 20, 0.55);
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling for the info column */
|
/* Scrollbar styling for the info column */
|
||||||
@@ -413,6 +371,20 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile close button — hidden on desktop */
|
||||||
|
.closeMenuBtn {
|
||||||
|
display: none;
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: #e9eef6;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.infoSection--title .h1 {
|
.infoSection--title .h1 {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -431,6 +403,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infoRow {
|
.infoRow {
|
||||||
@@ -455,10 +428,12 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infoVal code {
|
.infoVal code {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
@@ -493,9 +468,11 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
min-width: 2ch;
|
min-width: 2ch;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -510,6 +487,7 @@ body {
|
|||||||
#userDisplay {
|
#userDisplay {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoutBtn {
|
.logoutBtn {
|
||||||
@@ -584,6 +562,7 @@ button:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 14px 14px;
|
padding: 12px 14px 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -595,17 +574,317 @@ button:hover {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Galaxy: square, pinned to the right ──────────────────────────────────── */
|
/* ── Collapsible panel variant ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.panelCollapsible {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitleSummary {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitleSummary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelCollapsible[open] .panelTitleSummary {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitleSummary:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Team income summary ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.econSummary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econSummaryTeam {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econSummaryTeam--blue .econSummaryLabel { color: rgba(90, 200, 255, 0.85); }
|
||||||
|
.econSummaryTeam--blue .econSummaryVal { color: rgba(90, 200, 255, 1); font-weight: 700; }
|
||||||
|
.econSummaryTeam--red .econSummaryLabel { color: rgba(220, 75, 85, 0.85); }
|
||||||
|
.econSummaryTeam--red .econSummaryVal { color: rgba(220, 75, 85, 1); font-weight: 700; }
|
||||||
|
|
||||||
|
.econSummarySep {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Economic score (cumulative) ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.econSummaryRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econSummaryRow--score {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econScoreVal {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econSummaryTeam--blue .econScoreVal { color: rgba(90, 200, 255, 1); }
|
||||||
|
.econSummaryTeam--red .econScoreVal { color: rgba(220, 75, 85, 1); }
|
||||||
|
|
||||||
|
/* Delta badge */
|
||||||
|
@keyframes econDeltaFade {
|
||||||
|
0% { opacity: 0; transform: translateY(2px); }
|
||||||
|
12% { opacity: 1; transform: translateY(-3px); }
|
||||||
|
70% { opacity: 1; transform: translateY(-3px); }
|
||||||
|
100% { opacity: 0; transform: translateY(-7px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.econDelta {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
color: rgba(90, 210, 130, 0.95);
|
||||||
|
opacity: 0;
|
||||||
|
min-width: 6ch;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econDelta--active {
|
||||||
|
animation: econDeltaFade 3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Economy resource table ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.econTableWrap {
|
||||||
|
padding: 4px 8px 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econEmpty {
|
||||||
|
margin: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTable thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.55;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTh {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTh:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTh--active {
|
||||||
|
opacity: 0.9;
|
||||||
|
color: rgba(113, 199, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econSortIcon {
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTh--active .econSortIcon {
|
||||||
|
opacity: 1;
|
||||||
|
color: rgba(113, 199, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTable tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-label {
|
||||||
|
color: rgba(233, 238, 246, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-cat {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-cat--commun {
|
||||||
|
color: rgba(170, 180, 200, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-cat--rare {
|
||||||
|
color: rgba(255, 210, 100, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-worth {
|
||||||
|
text-align: right;
|
||||||
|
color: rgba(113, 199, 255, 0.85);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-income {
|
||||||
|
text-align: right;
|
||||||
|
color: rgba(233, 238, 246, 0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.econ-income--positive {
|
||||||
|
color: rgba(90, 210, 130, 0.95);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Options / admin section ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.infoSection--options {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsDetails {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsSummary {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsSummary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsSummary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsPanel {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionsPanel .authField input[type="password"] {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminUnlockBtn {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminStatus.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminStatus--ok {
|
||||||
|
color: rgba(90, 200, 130, 0.95);
|
||||||
|
background: rgba(30, 120, 60, 0.15);
|
||||||
|
border: 1px solid rgba(30, 150, 70, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminStatus--err {
|
||||||
|
color: rgba(255, 130, 100, 0.95);
|
||||||
|
background: rgba(200, 50, 30, 0.12);
|
||||||
|
border: 1px solid rgba(200, 50, 30, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
|
||||||
|
|
||||||
.galaxyMain {
|
.galaxyMain {
|
||||||
/* Height fills the viewport; aspect-ratio keeps it perfectly square */
|
/* Fit the tallest square that fits in the viewport without overlapping the info column */
|
||||||
flex: 0 0 auto;
|
--map-size: min(100vh, calc(100vw - 340px));
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
width: var(--map-size);
|
||||||
aspect-ratio: 1 / 1;
|
height: var(--map-size);
|
||||||
/* Constrain width so the canvas never exceeds available space */
|
flex-shrink: 0;
|
||||||
max-width: calc(100vw - 260px);
|
background: #050810;
|
||||||
background: #000;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,12 +895,35 @@ canvas {
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.burgerBtn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 20;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(7, 10, 20, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #e9eef6;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 14px;
|
||||||
bottom: 14px;
|
bottom: 14px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -630,3 +932,63 @@ canvas {
|
|||||||
max-width: calc(100% - 28px);
|
max-width: calc(100% - 28px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile responsive (≤768px) ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the left column by default on mobile */
|
||||||
|
.infoColumn {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
min-height: unset;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(7, 10, 20, 0.97);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-right: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the panel when open */
|
||||||
|
.infoColumn--open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show close button on mobile */
|
||||||
|
.closeMenuBtn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show burger button on mobile */
|
||||||
|
.burgerBtn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Galaxy scales to viewport width (square) */
|
||||||
|
.galaxyMain {
|
||||||
|
--map-size: 100vw;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scoreboard wraps nicely on narrow screens */
|
||||||
|
.scoreBoard {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const CONFIG_FILE_PATH =
|
const CONFIG_FILE_PATH =
|
||||||
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
|
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
|
||||||
|
|
||||||
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number }} */
|
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object }} */
|
||||||
let cached = {
|
let cached = {
|
||||||
clickCooldownSeconds: 5,
|
clickCooldownSeconds: 5,
|
||||||
databaseWipeoutIntervalSeconds: 21600,
|
databaseWipeoutIntervalSeconds: 21600,
|
||||||
debugModeForTeams: true,
|
debugModeForTeams: true,
|
||||||
configReloadIntervalSeconds: 30,
|
configReloadIntervalSeconds: 30,
|
||||||
|
resourceWorth: { common: {}, rare: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastMtimeMs = 0;
|
let lastMtimeMs = 0;
|
||||||
@@ -46,6 +47,9 @@ export function loadConfigFile() {
|
|||||||
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
|
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
|
||||||
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
|
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
|
||||||
}
|
}
|
||||||
|
if (j.resourceWorth && typeof j.resourceWorth === "object") {
|
||||||
|
cached.resourceWorth = j.resourceWorth;
|
||||||
|
}
|
||||||
lastMtimeMs = st.mtimeMs;
|
lastMtimeMs = st.mtimeMs;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === "ENOENT") {
|
if (e.code === "ENOENT") {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export async function initGameSchema() {
|
|||||||
PRIMARY KEY (world_seed, team)
|
PRIMARY KEY (world_seed, team)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS team_econ_scores (
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
|
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (world_seed, team)
|
||||||
|
);
|
||||||
|
`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
|
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
|
||||||
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
|
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
|
||||||
@@ -59,6 +67,7 @@ export async function ensureSeedEpoch() {
|
|||||||
if (seedSlot !== lastSeedSlot) {
|
if (seedSlot !== lastSeedSlot) {
|
||||||
await pool.query("TRUNCATE grid_cells RESTART IDENTITY");
|
await pool.query("TRUNCATE grid_cells RESTART IDENTITY");
|
||||||
await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]);
|
await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]);
|
||||||
|
await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]);
|
||||||
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||||
lastSeedSlot = seedSlot;
|
lastSeedSlot = seedSlot;
|
||||||
}
|
}
|
||||||
@@ -115,6 +124,29 @@ export async function upsertTeamCooldown(worldSeed, team) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Economic scores ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getEconScores(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT team, score FROM team_econ_scores WHERE world_seed = $1`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
const result = { blue: 0, red: 0 };
|
||||||
|
for (const row of rows) result[row.team] = Number(row.score);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addEconScore(worldSeed, team, delta) {
|
||||||
|
if (delta <= 0) return;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO team_econ_scores (world_seed, team, score)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (world_seed, team) DO UPDATE
|
||||||
|
SET score = team_econ_scores.score + EXCLUDED.score`,
|
||||||
|
[worldSeed, team, delta]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getScores(worldSeed) {
|
export async function getScores(worldSeed) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "dotenv/config";
|
||||||
import { loadConfigFile, getConfig } from "./configLoader.js";
|
import { loadConfigFile, getConfig } from "./configLoader.js";
|
||||||
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
||||||
import { initUsersSchema } from "./db/usersDb.js";
|
import { initUsersSchema } from "./db/usersDb.js";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
getTeamCooldown,
|
getTeamCooldown,
|
||||||
upsertTeamCooldown,
|
upsertTeamCooldown,
|
||||||
getScores,
|
getScores,
|
||||||
|
getEconScores,
|
||||||
|
addEconScore,
|
||||||
} from "../db/gameDb.js";
|
} from "../db/gameDb.js";
|
||||||
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ router.get("/config", async (req, res) => {
|
|||||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||||
teamCooldownRemaining,
|
teamCooldownRemaining,
|
||||||
|
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -134,6 +137,49 @@ router.post("/cell/reveal", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/admin/verify
|
||||||
|
router.post("/admin/verify", (req, res) => {
|
||||||
|
const password = String(req.body?.password ?? "");
|
||||||
|
const adminPwd = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPwd) {
|
||||||
|
return res.status(503).json({ ok: false, error: "not_configured" });
|
||||||
|
}
|
||||||
|
if (password && password === adminPwd) {
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ ok: false, error: "invalid_password" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/econ-scores
|
||||||
|
router.get("/econ-scores", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const scores = await getEconScores(worldSeed);
|
||||||
|
res.json(scores);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/econ-scores/tick body: { seed, blue, red }
|
||||||
|
router.post("/econ-scores/tick", async (req, res) => {
|
||||||
|
const seed = String(req.body?.seed ?? "");
|
||||||
|
const blue = Number(req.body?.blue ?? 0);
|
||||||
|
const red = Number(req.body?.red ?? 0);
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||||
|
await addEconScore(worldSeed, "blue", blue);
|
||||||
|
await addEconScore(worldSeed, "red", red);
|
||||||
|
const scores = await getEconScores(worldSeed);
|
||||||
|
res.json(scores);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/scores
|
// GET /api/scores
|
||||||
router.get("/scores", async (_req, res) => {
|
router.get("/scores", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user