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_DB=star_wars_users
|
||||
|
||||
# ── Admin ────────────────────────────────────────────────────────────────────
|
||||
# Password to unlock the team-switching debug widget in the UI
|
||||
ADMIN_PASSWORD=CHANGE_ME
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||
CORS_ORIGIN=*
|
||||
@@ -1,6 +1,44 @@
|
||||
{
|
||||
"clickCooldownSeconds": 5,
|
||||
"clickCooldownSeconds": 2,
|
||||
"databaseWipeoutIntervalSeconds": 21600,
|
||||
"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}"
|
||||
USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}"
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
PORT: "${PORT:-8080}"
|
||||
CONFIG_FILE_PATH: /app/config/game.settings.json
|
||||
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" />
|
||||
</head>
|
||||
<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 ──────────────────────────────────────────────────────────── -->
|
||||
<div class="authOverlay" id="authOverlay">
|
||||
<div class="authModal">
|
||||
@@ -61,11 +48,11 @@
|
||||
<div class="authTeamChoice">
|
||||
<label class="authTeamOption">
|
||||
<input type="radio" name="regTeam" value="blue" required />
|
||||
<span class="authTeamBadge authTeamBadge--blue">Blue</span>
|
||||
<span class="authTeamBadge authTeamBadge--blue">Resistance</span>
|
||||
</label>
|
||||
<label class="authTeamOption">
|
||||
<input type="radio" name="regTeam" value="red" />
|
||||
<span class="authTeamBadge authTeamBadge--red">Red</span>
|
||||
<span class="authTeamBadge authTeamBadge--red">First Order</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,13 +62,13 @@
|
||||
</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">
|
||||
<span class="teamCornerLabel">Team</span>
|
||||
<div class="teamSegmented" role="group" aria-label="Active team">
|
||||
<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="teamRed" data-team="red">Red</button>
|
||||
<button type="button" class="teamSegmentedBtn" id="teamBlue" data-team="blue">Resistance</button>
|
||||
<button type="button" class="teamSegmentedBtn" id="teamRed" data-team="red">First Order</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,23 +77,28 @@
|
||||
<div class="app">
|
||||
|
||||
<!-- ── 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="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>
|
||||
|
||||
<!-- Team score display -->
|
||||
<div class="scoreBoard" id="scoreBoard">
|
||||
<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>
|
||||
<span class="scoreSep">—</span>
|
||||
<span class="scoreTeam scoreTeam--red">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -127,38 +119,87 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="infoRow">
|
||||
<span class="infoKey muted">clickCooldownSeconds</span>
|
||||
<span class="infoKey muted">Temps avant prochain clic</span>
|
||||
<code class="infoVal" id="cooldownConfig">—</code>
|
||||
</div>
|
||||
<div class="infoRow">
|
||||
<span class="infoKey muted">worldSeed</span>
|
||||
<span class="infoKey muted">Graine de la carte</span>
|
||||
<code class="infoVal" id="worldSeedDisplay">—</code>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="infoRow">
|
||||
<span class="infoKey muted">resets in</span>
|
||||
<span class="infoKey muted">Prochaine graine dans</span>
|
||||
<code class="infoVal" id="refreshCountdown">--:--:--</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="refreshBtn" type="button">Refresh</button>
|
||||
<!-- Planet stats (collapsible) -->
|
||||
<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>
|
||||
|
||||
<!-- Selection details panel -->
|
||||
<div class="panel">
|
||||
<div class="panelTitle">Selection</div>
|
||||
<pre id="details" class="details details--hidden">Stats are hidden until you click a tile.</pre>
|
||||
<!-- Resources overview (collapsible) -->
|
||||
<details class="panel panelCollapsible">
|
||||
<summary class="panelTitle panelTitleSummary">💰 Ressources économiques</summary>
|
||||
<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>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── Galaxy (square, anchored to the right edge) ────────────────────── -->
|
||||
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
|
||||
<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>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -49,3 +49,19 @@ export async function apiGetMe(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 { 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const GRID_W = 100;
|
||||
const GRID_H = 100;
|
||||
const OUTER_RADIUS = 50;
|
||||
const INNER_RADIUS = 30;
|
||||
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_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
|
||||
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 = {
|
||||
clickCooldownSeconds: 5,
|
||||
databaseWipeoutIntervalSeconds: 21600,
|
||||
debugModeForTeams: true,
|
||||
debugModeForTeams: false,
|
||||
configReloadIntervalSeconds: 30,
|
||||
worldSeed: "",
|
||||
seedPeriodEndsAtUtc: "",
|
||||
resourceWorth: { common: {}, rare: {} },
|
||||
};
|
||||
window.GAME_CONFIG = GAME_CONFIG;
|
||||
|
||||
@@ -45,6 +48,93 @@ export const teamCooldownEndMs = { blue: 0, red: 0 };
|
||||
let rafId = 0;
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
@@ -59,6 +149,13 @@ const nextPeriodEl = document.getElementById("nextPeriodUtc");
|
||||
const resetCountEl = document.getElementById("refreshCountdown");
|
||||
const scoreBlueEl = document.getElementById("scoreBlue");
|
||||
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 teamTrack = document.getElementById("teamSegmentedTrack");
|
||||
const teamBlueBtn = document.getElementById("teamBlue");
|
||||
@@ -68,16 +165,9 @@ const teamRedBtn = document.getElementById("teamRed");
|
||||
|
||||
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) {
|
||||
const { dx, dy } = cellCenter(x, y);
|
||||
const r = Math.hypot(dx, dy);
|
||||
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
|
||||
if (!exploitableTiles) return false;
|
||||
return exploitableTiles.has(cellKey(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.worldSeed = String(data.worldSeed ?? "");
|
||||
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
|
||||
if (data.resourceWorth && typeof data.resourceWorth === "object") {
|
||||
GAME_CONFIG.resourceWorth = data.resourceWorth;
|
||||
}
|
||||
|
||||
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
||||
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
||||
@@ -107,11 +200,8 @@ export function applyConfigPayload(data) {
|
||||
|
||||
updateResetCountdown();
|
||||
|
||||
if (GAME_CONFIG.debugModeForTeams) {
|
||||
teamCorner.classList.remove("teamCorner--hidden");
|
||||
} else {
|
||||
teamCorner.classList.add("teamCorner--hidden");
|
||||
}
|
||||
// Team switcher visibility is managed exclusively via unlockTeamSwitcher() /
|
||||
// lockTeamSwitcher() — NOT by debugModeForTeams config.
|
||||
}
|
||||
|
||||
export function updateResetCountdown() {
|
||||
@@ -125,6 +215,18 @@ export function updateResetCountdown() {
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAndApplyScores() {
|
||||
@@ -135,6 +237,92 @@ export async function fetchAndApplyScores() {
|
||||
} 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 ──────────────────────────────────────────────────────
|
||||
|
||||
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
||||
@@ -186,6 +374,8 @@ function tickCooldown() {
|
||||
countdownEl.textContent = "0";
|
||||
teamCooldownEndMs[currentTeam] = 0;
|
||||
refreshCursorFromLast();
|
||||
// Auto-refresh from server when cooldown expires
|
||||
refreshFromServer();
|
||||
return;
|
||||
}
|
||||
countdownWrap.classList.remove("hidden");
|
||||
@@ -222,15 +412,23 @@ export function clearCooldown() {
|
||||
export function draw() {
|
||||
const w = canvas.width;
|
||||
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);
|
||||
|
||||
// 2. Draw the SVG playfield as the background layer
|
||||
if (playfieldImg) {
|
||||
ctx.drawImage(playfieldImg, 0, 0, w, h);
|
||||
}
|
||||
|
||||
const cw = w / GRID_W;
|
||||
const ch = h / GRID_H;
|
||||
|
||||
// 3. Draw game tile overlays (only for exploitable tiles)
|
||||
for (let y = 0; y < GRID_H; y++) {
|
||||
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 meta = cellMeta(k);
|
||||
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) {
|
||||
if (meta.discoveredBy !== currentTeam) continue;
|
||||
const [xs, ys] = key.split(",").map(Number);
|
||||
@@ -253,6 +452,7 @@ export function draw() {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 5. Grid lines between adjacent exploitable tiles
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
@@ -369,6 +569,7 @@ async function onCanvasClick(ev) {
|
||||
if (!res.ok) throw new Error("reveal");
|
||||
applyRevealPayload(await res.json());
|
||||
startCooldown();
|
||||
updateEconomyDisplay();
|
||||
draw();
|
||||
fetchAndApplyScores();
|
||||
} catch (e) {
|
||||
@@ -385,6 +586,18 @@ export function updateTeamSegmented() {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
// 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();
|
||||
if (seedChanged) {
|
||||
clearCooldown();
|
||||
resetEconScores();
|
||||
loadEconScores();
|
||||
details.textContent = "Stats are hidden until you click a tile.";
|
||||
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 fetchAndApplyScores();
|
||||
updateEconomyDisplay();
|
||||
draw();
|
||||
refreshCursorFromLast();
|
||||
} catch {
|
||||
@@ -408,9 +624,25 @@ export async function refreshFromServer() {
|
||||
|
||||
// ── 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("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
||||
canvas.addEventListener("click", onCanvasClick);
|
||||
|
||||
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||
// Team switcher buttons (kept in codebase, only functional when admin-unlocked)
|
||||
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
fetchConfig,
|
||||
fetchGridForSeed,
|
||||
fetchAndApplyScores,
|
||||
updateEconomyDisplay,
|
||||
tickEconScore,
|
||||
loadEconScores,
|
||||
refreshFromServer,
|
||||
refreshGridDisplay,
|
||||
loadPlayfieldMask,
|
||||
draw,
|
||||
unlockTeamSwitcher,
|
||||
} from "./game.js";
|
||||
|
||||
import {
|
||||
@@ -18,9 +24,14 @@ import {
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const refreshBtn = document.getElementById("refreshBtn");
|
||||
const hint = document.getElementById("hint");
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,19 +51,87 @@ function scheduleConfigPoll() {
|
||||
}, ms);
|
||||
}
|
||||
|
||||
const ECON_TICK_SECONDS = 5;
|
||||
|
||||
function scheduleScorePoll() {
|
||||
clearTimeout(scorePollTimer);
|
||||
scorePollTimer = window.setTimeout(async () => {
|
||||
await fetchAndApplyScores();
|
||||
tickEconScore(ECON_TICK_SECONDS);
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function boot() {
|
||||
updateTeamSegmented();
|
||||
|
||||
// Load the SVG playfield mask before any drawing or data fetch
|
||||
await loadPlayfieldMask();
|
||||
|
||||
const restored = await tryRestoreSession();
|
||||
if (!restored) {
|
||||
showAuthOverlay();
|
||||
@@ -64,6 +143,8 @@ async function boot() {
|
||||
await fetchConfig();
|
||||
await fetchGridForSeed(seedStr);
|
||||
await fetchAndApplyScores();
|
||||
await loadEconScores();
|
||||
updateEconomyDisplay();
|
||||
} catch {
|
||||
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||
cooldownEl.textContent = "?";
|
||||
@@ -77,12 +158,11 @@ async function boot() {
|
||||
|
||||
scheduleConfigPoll();
|
||||
scheduleScorePoll();
|
||||
|
||||
// Refresh grid every second so all clients see new tiles promptly
|
||||
setInterval(refreshGridDisplay, 1_000);
|
||||
}
|
||||
|
||||
// ── Global event listeners ────────────────────────────────────────────────────
|
||||
|
||||
refreshBtn.addEventListener("click", () => refreshFromServer());
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
boot();
|
||||
494
public/style.css
494
public/style.css
@@ -15,57 +15,6 @@ body {
|
||||
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 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.authOverlay {
|
||||
@@ -242,7 +191,7 @@ body {
|
||||
background: rgba(113, 199, 255, 0.28);
|
||||
}
|
||||
|
||||
/* ── Team corner (debug) ──────────────────────────────────────────────────── */
|
||||
/* ── Team corner (admin only) ─────────────────────────────────────────────── */
|
||||
|
||||
.teamCorner {
|
||||
position: fixed;
|
||||
@@ -327,6 +276,7 @@ body {
|
||||
.scoreBoard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 14px;
|
||||
@@ -334,6 +284,7 @@ body {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -341,9 +292,16 @@ body {
|
||||
.scoreTeam {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.team-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scoreTeamName {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -380,17 +338,16 @@ body {
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Left information column ──────────────────────────────────────────────── */
|
||||
|
||||
.infoColumn {
|
||||
flex: 1 1 0;
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
flex: 0 0 500px;
|
||||
width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -399,6 +356,7 @@ body {
|
||||
overflow-x: hidden;
|
||||
background: rgba(7, 10, 20, 0.55);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for the info column */
|
||||
@@ -413,6 +371,20 @@ body {
|
||||
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 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
@@ -431,6 +403,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
@@ -455,10 +428,12 @@ body {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.infoVal code {
|
||||
font-size: 11px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
padding: 2px 7px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -493,9 +468,11 @@ body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2ch;
|
||||
text-align: right;
|
||||
@@ -510,6 +487,7 @@ body {
|
||||
#userDisplay {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
@@ -584,6 +562,7 @@ button:hover {
|
||||
margin: 0;
|
||||
padding: 12px 14px 14px;
|
||||
font-size: 12px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -595,17 +574,317 @@ button:hover {
|
||||
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 {
|
||||
/* Height fills the viewport; aspect-ratio keeps it perfectly square */
|
||||
flex: 0 0 auto;
|
||||
/* Fit the tallest square that fits in the viewport without overlapping the info column */
|
||||
--map-size: min(100vh, calc(100vw - 340px));
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
aspect-ratio: 1 / 1;
|
||||
/* Constrain width so the canvas never exceeds available space */
|
||||
max-width: calc(100vw - 260px);
|
||||
background: #000;
|
||||
width: var(--map-size);
|
||||
height: var(--map-size);
|
||||
flex-shrink: 0;
|
||||
background: #050810;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -616,12 +895,35 @@ canvas {
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
opacity: 0.8;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -630,3 +932,63 @@ canvas {
|
||||
max-width: calc(100% - 28px);
|
||||
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 =
|
||||
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 = {
|
||||
clickCooldownSeconds: 5,
|
||||
databaseWipeoutIntervalSeconds: 21600,
|
||||
debugModeForTeams: true,
|
||||
configReloadIntervalSeconds: 30,
|
||||
resourceWorth: { common: {}, rare: {} },
|
||||
};
|
||||
|
||||
let lastMtimeMs = 0;
|
||||
@@ -46,6 +47,9 @@ export function loadConfigFile() {
|
||||
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
|
||||
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
|
||||
}
|
||||
if (j.resourceWorth && typeof j.resourceWorth === "object") {
|
||||
cached.resourceWorth = j.resourceWorth;
|
||||
}
|
||||
lastMtimeMs = st.mtimeMs;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
|
||||
@@ -29,6 +29,14 @@ export async function initGameSchema() {
|
||||
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(`
|
||||
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
|
||||
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
|
||||
@@ -59,6 +67,7 @@ export async function ensureSeedEpoch() {
|
||||
if (seedSlot !== lastSeedSlot) {
|
||||
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_econ_scores WHERE world_seed != $1", [worldSeed]);
|
||||
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getScores(worldSeed) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dotenv/config";
|
||||
import { loadConfigFile, getConfig } from "./configLoader.js";
|
||||
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
||||
import { initUsersSchema } from "./db/usersDb.js";
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
getTeamCooldown,
|
||||
upsertTeamCooldown,
|
||||
getScores,
|
||||
getEconScores,
|
||||
addEconScore,
|
||||
} from "../db/gameDb.js";
|
||||
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
||||
|
||||
@@ -43,6 +45,7 @@ router.get("/config", async (req, res) => {
|
||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||
teamCooldownRemaining,
|
||||
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||
});
|
||||
} catch (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
|
||||
router.get("/scores", async (_req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user