Merge branch 'main' into canbuildtransport-perf
@@ -120,6 +120,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -135,6 +136,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -192,6 +194,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
@@ -249,6 +252,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
|
||||
@@ -176,7 +176,7 @@ GET https://openfront.io/public/clan/:clanTag
|
||||
**Query Parameters:**
|
||||
|
||||
- `start` (optional): ISO 8601 timestamp
|
||||
- `end` (optonal): ISO 8601 timestamp
|
||||
- `end` (optional): ISO 8601 timestamp
|
||||
|
||||
**Example**
|
||||
|
||||
@@ -198,7 +198,7 @@ GET https://api.openfront.io/public/clan/:clanTag/sessions
|
||||
**Query Parameters:**
|
||||
|
||||
- `start` (optional): ISO 8601 timestamp
|
||||
- `end` (optonal): ISO 8601 timestamp
|
||||
- `end` (optional): ISO 8601 timestamp
|
||||
|
||||
**Example**
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ Pritchard, H.D., Fretwell, P.T., Fremand, A.C. et al. Bedmap3 updated ice bed, s
|
||||
[https://doi.org/10.1038/s41597-025-04672-y](https://doi.org/10.1038/s41597-025-04672-y)
|
||||
Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### Copernicus Global Digital Elevation Models
|
||||
|
||||
© DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.
|
||||
License: (https://docs.sentinel-hub.com/api/latest/static/files/data/dem/resources/license/License-COPDEM-30.pdf)
|
||||
|
||||
## Icons
|
||||
|
||||
### [The Noun Project](https://thenounproject.com/)
|
||||
|
||||
@@ -176,6 +176,7 @@ R2_ACCESS_KEY=$R2_ACCESS_KEY
|
||||
R2_SECRET_KEY=$R2_SECRET_KEY
|
||||
R2_BUCKET=$R2_BUCKET
|
||||
CF_API_TOKEN=$CF_API_TOKEN
|
||||
TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY
|
||||
API_KEY=$API_KEY
|
||||
DOMAIN=$DOMAIN
|
||||
SUBDOMAIN=$SUBDOMAIN
|
||||
|
||||
@@ -10,24 +10,69 @@ This is a tool to generate map files for OpenFront.
|
||||
|
||||
## Creating a new map
|
||||
|
||||
1. Create a new folder in assets/maps/<map_name>
|
||||
1. Create a new folder in `assets/maps/<map_name>`
|
||||
2. Create image.png
|
||||
3. Create info.json with name and countries
|
||||
4. Add the map name in main.go
|
||||
5. Run the generator: `go run .`
|
||||
6. Find the output folder at generated/maps/<map_name>
|
||||
6. Find the output folder at `../resources/maps/<map_name>`
|
||||
|
||||
By default, this will process all defined maps.
|
||||
|
||||
Use `--maps` to process a single map:
|
||||
|
||||
`go run . --maps=fourislands`
|
||||
|
||||
To process a subset of maps, pass a comma-separated list:
|
||||
|
||||
`go run . --maps=northamerica,world`
|
||||
|
||||
## Create image.png
|
||||
|
||||
1. Download world map (warning very large file) https://drive.google.com/file/d/1W2oMPj1L5zWRyPhh8LfmnY3_kve-FBR2/view?usp=sharing
|
||||
2. Crop the file (recommend Gimp), we recommend roughly 2 million pixels for performance reasons. Do not go over 4 million pixels.
|
||||
1. [Download world map (warning very large file)](https://drive.google.com/file/d/1W2oMPj1L5zWRyPhh8LfmnY3_kve-FBR2/view?usp=sharing)
|
||||
2. Crop the file (recommend Gimp)
|
||||
|
||||
- We recommend roughly 2 million pixels for performance reasons
|
||||
- Do not go over 4 million pixels.
|
||||
|
||||
## Create info.json
|
||||
|
||||
- Look at existing info.json for structure
|
||||
- Use country codes found here: https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
|
||||
- [Use country codes found here](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes)
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "MySampleMap",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [396, 364],
|
||||
"name": "United States",
|
||||
"strength": 3,
|
||||
"flag": "us"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## To Enable In-Game
|
||||
|
||||
- Add a translation for the map name to `resources/lang/en.json`
|
||||
- Add the MapDescription `src/client/components/Maps.ts`
|
||||
- Add the numPlayersConfig `src/core/configuration/DefaultConfig.ts`
|
||||
- Add the GameMapType `src/core/game/Game.ts`
|
||||
- To add to the map playlist, modify `src/server/MapPlaylist.ts`
|
||||
|
||||
## Notes
|
||||
|
||||
- Islands smaller than 30 tiles (pixels) are automatically removed by the script.
|
||||
- Bodies of water smaller than 200 tiles (pixels) are also removed.
|
||||
|
||||
## 🛠️ Development Tools
|
||||
|
||||
- **Format map-generator code**:
|
||||
|
||||
```bash
|
||||
go fmt .
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 612 KiB |
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"name": "Gulf of St. Lawrence",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [88, 364],
|
||||
"name": "Quebec",
|
||||
"strength": 3,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [777, 170],
|
||||
"name": "Nitassinan",
|
||||
"strength": 3,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [570, 460],
|
||||
"name": "Anticosti Island",
|
||||
"strength": 2,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [300, 568],
|
||||
"name": "Gaspesia",
|
||||
"strength": 2,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [256, 60],
|
||||
"name": "Manicouagan",
|
||||
"strength": 3,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [522, 266],
|
||||
"name": "Mingan",
|
||||
"strength": 1,
|
||||
"flag": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 632],
|
||||
"name": "Newfoundland",
|
||||
"strength": 3,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1166, 38],
|
||||
"name": "Labrador",
|
||||
"strength": 2,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1180, 199],
|
||||
"name": "Northern Peninsula",
|
||||
"strength": 2,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1544, 740],
|
||||
"name": "St Johns",
|
||||
"strength": 2,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1456, 620],
|
||||
"name": "Bonavista",
|
||||
"strength": 1,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 528],
|
||||
"name": "Corner Brook",
|
||||
"strength": 1,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1254, 511],
|
||||
"name": "Grand Falls",
|
||||
"strength": 1,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1040, 400],
|
||||
"name": "Gros Morne",
|
||||
"strength": 1,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [912, 720],
|
||||
"name": "Port aux Basques",
|
||||
"strength": 1,
|
||||
"flag": "Newfoundland"
|
||||
},
|
||||
{
|
||||
"coordinates": [82, 912],
|
||||
"name": "New Brunswick",
|
||||
"strength": 3,
|
||||
"flag": "ca_nb"
|
||||
},
|
||||
{
|
||||
"coordinates": [288, 742],
|
||||
"name": "Acadia",
|
||||
"strength": 2,
|
||||
"flag": "ca_nb"
|
||||
},
|
||||
{
|
||||
"coordinates": [184, 1000],
|
||||
"name": "Fredericton",
|
||||
"strength": 1,
|
||||
"flag": "ca_nb"
|
||||
},
|
||||
{
|
||||
"coordinates": [338, 938],
|
||||
"name": "Moncton",
|
||||
"strength": 1,
|
||||
"flag": "ca_nb"
|
||||
},
|
||||
{
|
||||
"coordinates": [44, 1110],
|
||||
"name": "Maine",
|
||||
"strength": 2,
|
||||
"flag": "Maine"
|
||||
},
|
||||
{
|
||||
"coordinates": [475, 915],
|
||||
"name": "Prince Edward Island",
|
||||
"strength": 3,
|
||||
"flag": "ca_pe"
|
||||
},
|
||||
{
|
||||
"coordinates": [588, 1054],
|
||||
"name": "Nova Scotia",
|
||||
"strength": 3,
|
||||
"flag": "ca_ns"
|
||||
},
|
||||
{
|
||||
"coordinates": [725, 920],
|
||||
"name": "Cape Breton Island",
|
||||
"strength": 2,
|
||||
"flag": "ca_ns"
|
||||
},
|
||||
{
|
||||
"coordinates": [310, 1130],
|
||||
"name": "Annapolis",
|
||||
"strength": 1,
|
||||
"flag": "ca_ns"
|
||||
},
|
||||
{
|
||||
"coordinates": [445, 1160],
|
||||
"name": "Halifax",
|
||||
"strength": 1,
|
||||
"flag": "ca_ns"
|
||||
},
|
||||
{
|
||||
"coordinates": [235, 1255],
|
||||
"name": "Yarmouth",
|
||||
"strength": 1,
|
||||
"flag": "ca_ns"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 906 KiB |
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "Lisbon",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [750, 630],
|
||||
"name": "Lisbon",
|
||||
"strength": 3,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [602, 450],
|
||||
"name": "Amadora",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [120, 644],
|
||||
"name": "Cascais",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [372, 334],
|
||||
"name": "Pero Pinheiro",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [214, 36],
|
||||
"name": "Ericeira",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [924, 210],
|
||||
"name": "Alverca do Ribatejo",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [680, 760],
|
||||
"name": "Almada",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [944, 808],
|
||||
"name": "Barreiro",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1078, 630],
|
||||
"name": "Montijo",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [762, 1266],
|
||||
"name": "Sesimbra",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 60],
|
||||
"name": "Samora Correia",
|
||||
"strength": 2,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1506, 412],
|
||||
"name": "Pegoes",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1210, 1100],
|
||||
"name": "Setubal",
|
||||
"strength": 3,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1560, 1186],
|
||||
"name": "Sado",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
},
|
||||
{
|
||||
"coordinates": [1470, 1470],
|
||||
"name": "Carvalhal",
|
||||
"strength": 1,
|
||||
"flag": "pt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,13 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var mapsFlag string
|
||||
|
||||
var maps = []struct {
|
||||
Name string
|
||||
IsTest bool
|
||||
@@ -31,10 +35,12 @@ var maps = []struct {
|
||||
{Name: "fourislands"},
|
||||
{Name: "gatewaytotheatlantic"},
|
||||
{Name: "giantworldmap"},
|
||||
{Name: "gulfofstlawrence"},
|
||||
{Name: "halkidiki"},
|
||||
{Name: "iceland"},
|
||||
{Name: "italia"},
|
||||
{Name: "japan"},
|
||||
{Name: "lisbon"},
|
||||
{Name: "mars"},
|
||||
{Name: "mena"},
|
||||
{Name: "montreal"},
|
||||
@@ -69,13 +75,12 @@ func inputMapDir(isTest bool) (string, error) {
|
||||
return "", fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
if isTest {
|
||||
return filepath.Join(cwd, "assets", "test_maps"), nil
|
||||
return filepath.Join(cwd, "assets", "test_maps"), nil
|
||||
} else {
|
||||
return filepath.Join(cwd, "assets", "maps"), nil
|
||||
return filepath.Join(cwd, "assets", "maps"), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func processMap(name string, isTest bool) error {
|
||||
outputMapBaseDir, err := outputMapDir(isTest)
|
||||
if err != nil {
|
||||
@@ -117,18 +122,18 @@ func processMap(name string, isTest bool) error {
|
||||
}
|
||||
|
||||
manifest["map"] = map[string]interface{}{
|
||||
"width": result.Map.Width,
|
||||
"height": result.Map.Height,
|
||||
"width": result.Map.Width,
|
||||
"height": result.Map.Height,
|
||||
"num_land_tiles": result.Map.NumLandTiles,
|
||||
}
|
||||
}
|
||||
manifest["map4x"] = map[string]interface{}{
|
||||
"width": result.Map4x.Width,
|
||||
"height": result.Map4x.Height,
|
||||
"width": result.Map4x.Width,
|
||||
"height": result.Map4x.Height,
|
||||
"num_land_tiles": result.Map4x.NumLandTiles,
|
||||
}
|
||||
manifest["map16x"] = map[string]interface{}{
|
||||
"width": result.Map16x.Width,
|
||||
"height": result.Map16x.Height,
|
||||
"width": result.Map16x.Width,
|
||||
"height": result.Map16x.Height,
|
||||
"num_land_tiles": result.Map16x.NumLandTiles,
|
||||
}
|
||||
|
||||
@@ -148,26 +153,54 @@ func processMap(name string, isTest bool) error {
|
||||
if err := os.WriteFile(filepath.Join(mapDir, "thumbnail.webp"), result.Thumbnail, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write thumbnail for %s: %w", name, err)
|
||||
}
|
||||
|
||||
|
||||
// Serialize the updated manifest to JSON
|
||||
updatedManifest, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize manifest for %s: %w", name, err)
|
||||
}
|
||||
|
||||
|
||||
if err := os.WriteFile(filepath.Join(mapDir, "manifest.json"), updatedManifest, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write manifest for %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMapsFlag() (map[string]bool, error) {
|
||||
if mapsFlag == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
validNames := make(map[string]bool, len(maps))
|
||||
for _, m := range maps {
|
||||
validNames[m.Name] = true
|
||||
}
|
||||
|
||||
selected := make(map[string]bool)
|
||||
for _, name := range strings.Split(mapsFlag, ",") {
|
||||
if !validNames[name] {
|
||||
return nil, fmt.Errorf("map %q is not defined", name)
|
||||
}
|
||||
selected[name] = true
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func loadTerrainMaps() error {
|
||||
selectedMaps, err := parseMapsFlag()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(maps))
|
||||
|
||||
// Process maps concurrently
|
||||
for _, mapItem := range maps {
|
||||
if selectedMaps != nil && !selectedMaps[mapItem.Name] {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
mapItem := mapItem
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := processMap(mapItem.Name, mapItem.IsTest); err != nil {
|
||||
@@ -191,9 +224,12 @@ func loadTerrainMaps() error {
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains")
|
||||
flag.Parse()
|
||||
|
||||
if err := loadTerrainMaps(); err != nil {
|
||||
log.Fatalf("Error generating terrain maps: %v", err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Terrain maps generated successfully")
|
||||
}
|
||||
|
||||
@@ -43,20 +43,20 @@ type Terrain struct {
|
||||
|
||||
type MapResult struct {
|
||||
Thumbnail []byte
|
||||
Map MapInfo
|
||||
Map4x MapInfo
|
||||
Map16x MapInfo
|
||||
Map MapInfo
|
||||
Map4x MapInfo
|
||||
Map16x MapInfo
|
||||
}
|
||||
|
||||
type MapInfo struct {
|
||||
Data []byte
|
||||
Width int
|
||||
Height int
|
||||
Data []byte
|
||||
Width int
|
||||
Height int
|
||||
NumLandTiles int
|
||||
}
|
||||
|
||||
type GeneratorArgs struct {
|
||||
Name string
|
||||
Name string
|
||||
ImageBuffer []byte
|
||||
RemoveSmall bool
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
|
||||
} else {
|
||||
// Land
|
||||
terrain[x][y] = Terrain{Type: Land}
|
||||
|
||||
|
||||
// Calculate magnitude from blue channel (140-200 range)
|
||||
mag := math.Min(200, math.Max(140, float64(blue))) - 140
|
||||
terrain[x][y].Magnitude = mag / 2
|
||||
@@ -108,11 +108,11 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
|
||||
processWater(terrain, args.RemoveSmall)
|
||||
|
||||
terrain4x := createMiniMap(terrain)
|
||||
processWater(terrain4x, false)
|
||||
|
||||
processWater(terrain4x, false)
|
||||
|
||||
terrain16x := createMiniMap(terrain4x)
|
||||
processWater(terrain16x, false)
|
||||
|
||||
|
||||
thumb := createMapThumbnail(terrain4x, 0.5)
|
||||
webp, err := convertToWebP(ThumbData{
|
||||
Data: thumb.Pix,
|
||||
@@ -129,21 +129,21 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
|
||||
|
||||
return MapResult{
|
||||
Map: MapInfo{
|
||||
Data: mapData,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Data: mapData,
|
||||
Width: width,
|
||||
Height: height,
|
||||
NumLandTiles: mapNumLandTiles,
|
||||
},
|
||||
Map4x: MapInfo{
|
||||
Data: mapData4x,
|
||||
Width: width / 2,
|
||||
Height: height / 2,
|
||||
Data: mapData4x,
|
||||
Width: width / 2,
|
||||
Height: height / 2,
|
||||
NumLandTiles: numLandTiles4x,
|
||||
},
|
||||
Map16x: MapInfo{
|
||||
Data: mapData16x,
|
||||
Width: width / 4,
|
||||
Height: height / 4,
|
||||
Data: mapData16x,
|
||||
Width: width / 4,
|
||||
Height: height / 4,
|
||||
NumLandTiles: numLandTiles16x,
|
||||
},
|
||||
Thumbnail: webp,
|
||||
@@ -153,13 +153,13 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
|
||||
func convertToWebP(thumb ThumbData) ([]byte, error) {
|
||||
// Create RGBA image from raw data
|
||||
img := image.NewRGBA(image.Rect(0, 0, thumb.Width, thumb.Height))
|
||||
|
||||
|
||||
// Copy the raw RGBA data
|
||||
if len(thumb.Data) != thumb.Width*thumb.Height*4 {
|
||||
return nil, fmt.Errorf("invalid thumb data length: expected %d, got %d",
|
||||
return nil, fmt.Errorf("invalid thumb data length: expected %d, got %d",
|
||||
thumb.Width*thumb.Height*4, len(thumb.Data))
|
||||
}
|
||||
|
||||
|
||||
copy(img.Pix, thumb.Data)
|
||||
|
||||
// Encode as WebP with quality 45 (equivalent to the JavaScript version)
|
||||
@@ -174,10 +174,10 @@ func convertToWebP(thumb ThumbData) ([]byte, error) {
|
||||
func createMiniMap(tm [][]Terrain) [][]Terrain {
|
||||
width := len(tm)
|
||||
height := len(tm[0])
|
||||
|
||||
|
||||
miniWidth := width / 2
|
||||
miniHeight := height / 2
|
||||
|
||||
|
||||
miniMap := make([][]Terrain, miniWidth)
|
||||
for x := range miniMap {
|
||||
miniMap[x] = make([]Terrain, miniHeight)
|
||||
@@ -187,7 +187,7 @@ func createMiniMap(tm [][]Terrain) [][]Terrain {
|
||||
for y := 0; y < height; y++ {
|
||||
miniX := x / 2
|
||||
miniY := y / 2
|
||||
|
||||
|
||||
if miniX < miniWidth && miniY < miniHeight {
|
||||
// If any of the 4 tiles has water, mini tile is water
|
||||
if miniMap[miniX][miniY].Type != Water {
|
||||
@@ -196,7 +196,7 @@ func createMiniMap(tm [][]Terrain) [][]Terrain {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return miniMap
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func processShore(terrain [][]Terrain) []Coord {
|
||||
for y := 0; y < height; y++ {
|
||||
tile := &terrain[x][y]
|
||||
neighbors := getNeighbors(x, y, terrain)
|
||||
|
||||
|
||||
if tile.Type == Land {
|
||||
// Land tile adjacent to water is shoreline
|
||||
for _, n := range neighbors {
|
||||
@@ -231,47 +231,47 @@ func processShore(terrain [][]Terrain) []Coord {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return shorelineWaters
|
||||
}
|
||||
|
||||
func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) {
|
||||
log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land")
|
||||
|
||||
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
|
||||
|
||||
visited := make([][]bool, width)
|
||||
for x := range visited {
|
||||
visited[x] = make([]bool, height)
|
||||
}
|
||||
|
||||
|
||||
type queueItem struct {
|
||||
x, y, dist int
|
||||
}
|
||||
|
||||
|
||||
queue := make([]queueItem, 0)
|
||||
|
||||
|
||||
// Initialize queue with shoreline waters
|
||||
for _, coord := range shorelineWaters {
|
||||
queue = append(queue, queueItem{x: coord.X, y: coord.Y, dist: 0})
|
||||
visited[coord.X][coord.Y] = true
|
||||
terrain[coord.X][coord.Y].Magnitude = 0
|
||||
}
|
||||
|
||||
|
||||
directions := []Coord{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}
|
||||
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
|
||||
for _, dir := range directions {
|
||||
nx := current.x + dir.X
|
||||
ny := current.y + dir.Y
|
||||
|
||||
|
||||
if nx >= 0 && ny >= 0 && nx < width && ny < height &&
|
||||
!visited[nx][ny] && terrain[nx][ny].Type == Water {
|
||||
|
||||
|
||||
visited[nx][ny] = true
|
||||
terrain[nx][ny].Magnitude = float64(current.dist + 1)
|
||||
queue = append(queue, queueItem{x: nx, y: ny, dist: current.dist + 1})
|
||||
@@ -293,7 +293,7 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord {
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
var coords []Coord
|
||||
|
||||
|
||||
if x > 0 {
|
||||
coords = append(coords, Coord{X: x - 1, Y: y})
|
||||
}
|
||||
@@ -306,21 +306,21 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord {
|
||||
if y < height-1 {
|
||||
coords = append(coords, Coord{X: x, Y: y + 1})
|
||||
}
|
||||
|
||||
|
||||
return coords
|
||||
}
|
||||
|
||||
func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
log.Println("Processing water bodies")
|
||||
visited := make(map[string]bool)
|
||||
|
||||
|
||||
type waterBody struct {
|
||||
coords []Coord
|
||||
size int
|
||||
}
|
||||
|
||||
|
||||
var waterBodies []waterBody
|
||||
|
||||
|
||||
// Find all distinct water bodies
|
||||
for x := 0; x < len(terrain); x++ {
|
||||
for y := 0; y < len(terrain[0]); y++ {
|
||||
@@ -329,7 +329,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
if visited[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
coords := getArea(x, y, terrain, visited)
|
||||
waterBodies = append(waterBodies, waterBody{
|
||||
coords: coords,
|
||||
@@ -338,7 +338,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sort by size (largest first)
|
||||
for i := 0; i < len(waterBodies)-1; i++ {
|
||||
for j := i + 1; j < len(waterBodies); j++ {
|
||||
@@ -347,9 +347,9 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
smallLakes := 0
|
||||
|
||||
|
||||
if len(waterBodies) > 0 {
|
||||
// Mark largest water body as ocean
|
||||
largestWaterBody := waterBodies[0]
|
||||
@@ -357,7 +357,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
terrain[coord.X][coord.Y].Ocean = true
|
||||
}
|
||||
log.Printf("Identified ocean with %d water tiles", largestWaterBody.size)
|
||||
|
||||
|
||||
if removeSmall {
|
||||
// Remove small water bodies
|
||||
log.Println("Searching for small water bodies for removal")
|
||||
@@ -370,10 +370,10 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("Identified and removed %d bodies of water smaller than %d tiles",
|
||||
log.Printf("Identified and removed %d bodies of water smaller than %d tiles",
|
||||
smallLakes, minLakeSize)
|
||||
}
|
||||
|
||||
|
||||
// Process shorelines and distances
|
||||
shorelineWaters := processShore(terrain)
|
||||
processDistToLand(shorelineWaters, terrain)
|
||||
@@ -386,25 +386,25 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord {
|
||||
targetType := terrain[x][y].Type
|
||||
var area []Coord
|
||||
queue := []Coord{{X: x, Y: y}}
|
||||
|
||||
|
||||
for len(queue) > 0 {
|
||||
coord := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
|
||||
key := fmt.Sprintf("%d,%d", coord.X, coord.Y)
|
||||
if visited[key] {
|
||||
continue
|
||||
}
|
||||
visited[key] = true
|
||||
|
||||
|
||||
if terrain[coord.X][coord.Y].Type == targetType {
|
||||
area = append(area, coord)
|
||||
|
||||
|
||||
neighborCoords := getNeighborCoords(coord.X, coord.Y, terrain)
|
||||
queue = append(queue, neighborCoords...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return area
|
||||
}
|
||||
|
||||
@@ -412,16 +412,16 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
|
||||
if !removeSmall {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
visited := make(map[string]bool)
|
||||
|
||||
|
||||
type landBody struct {
|
||||
coords []Coord
|
||||
size int
|
||||
}
|
||||
|
||||
|
||||
var landBodies []landBody
|
||||
|
||||
|
||||
// Find all distinct land bodies
|
||||
for x := 0; x < len(terrain); x++ {
|
||||
for y := 0; y < len(terrain[0]); y++ {
|
||||
@@ -430,7 +430,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
|
||||
if visited[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
coords := getArea(x, y, terrain, visited)
|
||||
landBodies = append(landBodies, landBody{
|
||||
coords: coords,
|
||||
@@ -439,9 +439,9 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
smallIslands := 0
|
||||
|
||||
|
||||
for _, body := range landBodies {
|
||||
if body.size < minIslandSize {
|
||||
smallIslands++
|
||||
@@ -451,8 +451,8 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Identified and removed %d islands smaller than %d tiles",
|
||||
|
||||
log.Printf("Identified and removed %d islands smaller than %d tiles",
|
||||
smallIslands, minIslandSize)
|
||||
}
|
||||
|
||||
@@ -461,12 +461,12 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) {
|
||||
height := len(terrain[0])
|
||||
packedData := make([]byte, width*height)
|
||||
numLandTiles = 0
|
||||
|
||||
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
tile := terrain[x][y]
|
||||
var packedByte byte = 0
|
||||
|
||||
|
||||
if tile.Type == Land {
|
||||
packedByte |= 0b10000000
|
||||
numLandTiles++
|
||||
@@ -477,46 +477,46 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) {
|
||||
if tile.Ocean {
|
||||
packedByte |= 0b00100000
|
||||
}
|
||||
|
||||
|
||||
if tile.Type == Land {
|
||||
packedByte |= byte(math.Min(math.Ceil(tile.Magnitude), 31))
|
||||
} else {
|
||||
packedByte |= byte(math.Min(math.Ceil(tile.Magnitude/2), 31))
|
||||
}
|
||||
|
||||
|
||||
packedData[y*width+x] = packedByte
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logBinaryAsBits(packedData, 8)
|
||||
return packedData, numLandTiles
|
||||
}
|
||||
|
||||
func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA {
|
||||
log.Println("Creating thumbnail")
|
||||
|
||||
|
||||
srcWidth := len(terrain)
|
||||
srcHeight := len(terrain[0])
|
||||
|
||||
|
||||
targetWidth := int(math.Max(1, math.Floor(float64(srcWidth)*quality)))
|
||||
targetHeight := int(math.Max(1, math.Floor(float64(srcHeight)*quality)))
|
||||
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||
|
||||
|
||||
for x := 0; x < targetWidth; x++ {
|
||||
for y := 0; y < targetHeight; y++ {
|
||||
srcX := int(math.Floor(float64(x) / quality))
|
||||
srcY := int(math.Floor(float64(y) / quality))
|
||||
|
||||
|
||||
srcX = int(math.Min(float64(srcX), float64(srcWidth-1)))
|
||||
srcY = int(math.Min(float64(srcY), float64(srcHeight-1)))
|
||||
|
||||
|
||||
terrain := terrain[srcX][srcY]
|
||||
rgba := getThumbnailColor(terrain)
|
||||
img.Set(x, y, color.RGBA{R: rgba.R, G: rgba.G, B: rgba.B, A: rgba.A})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
@@ -539,12 +539,12 @@ func getThumbnailColor(t Terrain) RGBA {
|
||||
A: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Shoreline land
|
||||
if t.Shoreline {
|
||||
return RGBA{R: 204, G: 203, B: 158, A: 255}
|
||||
}
|
||||
|
||||
|
||||
var adjRGB float64
|
||||
if t.Magnitude < 10 {
|
||||
// Plains
|
||||
@@ -580,7 +580,7 @@ func logBinaryAsBits(data []byte, length int) {
|
||||
if length > len(data) {
|
||||
length = len(data)
|
||||
}
|
||||
|
||||
|
||||
var bits string
|
||||
for i := 0; i < length; i++ {
|
||||
bits += fmt.Sprintf("%08b ", data[i])
|
||||
@@ -593,7 +593,7 @@ func createCombinedBinary(infoBuffer []byte, mapData []byte, miniMapData []byte)
|
||||
infoSize := len(infoBuffer)
|
||||
mapSize := len(mapData)
|
||||
miniMapSize := len(miniMapData)
|
||||
|
||||
|
||||
// Header structure:
|
||||
// Bytes 0-3: Version (1)
|
||||
// Bytes 4-7: Info section offset (always 28)
|
||||
@@ -602,35 +602,35 @@ func createCombinedBinary(infoBuffer []byte, mapData []byte, miniMapData []byte)
|
||||
// Bytes 16-19: Map section size
|
||||
// Bytes 20-23: MiniMap section offset
|
||||
// Bytes 24-27: MiniMap section size
|
||||
|
||||
|
||||
headerSize := 28
|
||||
infoOffset := headerSize
|
||||
mapOffset := infoOffset + infoSize
|
||||
miniMapOffset := mapOffset + mapSize
|
||||
|
||||
|
||||
totalSize := miniMapOffset + miniMapSize
|
||||
combined := make([]byte, totalSize)
|
||||
|
||||
|
||||
// Write version
|
||||
writeUint32(combined, 0, 1)
|
||||
|
||||
|
||||
// Write info section info
|
||||
writeUint32(combined, 4, uint32(infoOffset))
|
||||
writeUint32(combined, 8, uint32(infoSize))
|
||||
|
||||
|
||||
// Write map section info
|
||||
writeUint32(combined, 12, uint32(mapOffset))
|
||||
writeUint32(combined, 16, uint32(mapSize))
|
||||
|
||||
|
||||
// Write miniMap section info
|
||||
writeUint32(combined, 20, uint32(miniMapOffset))
|
||||
writeUint32(combined, 24, uint32(miniMapSize))
|
||||
|
||||
|
||||
// Copy data sections
|
||||
copy(combined[infoOffset:], infoBuffer)
|
||||
copy(combined[mapOffset:], mapData)
|
||||
copy(combined[miniMapOffset:], miniMapData)
|
||||
|
||||
|
||||
return combined
|
||||
}
|
||||
|
||||
@@ -649,38 +649,38 @@ func decodeCombinedBinary(data []byte) (*CombinedBinaryHeader, []byte, []byte, [
|
||||
if len(data) < 28 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("data too short for header")
|
||||
}
|
||||
|
||||
|
||||
header := &CombinedBinaryHeader{
|
||||
Version: readUint32(data, 0),
|
||||
InfoOffset: readUint32(data, 4),
|
||||
InfoSize: readUint32(data, 8),
|
||||
MapOffset: readUint32(data, 12),
|
||||
MapSize: readUint32(data, 16),
|
||||
Version: readUint32(data, 0),
|
||||
InfoOffset: readUint32(data, 4),
|
||||
InfoSize: readUint32(data, 8),
|
||||
MapOffset: readUint32(data, 12),
|
||||
MapSize: readUint32(data, 16),
|
||||
MiniMapOffset: readUint32(data, 20),
|
||||
MiniMapSize: readUint32(data, 24),
|
||||
MiniMapSize: readUint32(data, 24),
|
||||
}
|
||||
|
||||
|
||||
// Validate offsets and sizes
|
||||
if header.InfoOffset+header.InfoSize > uint32(len(data)) ||
|
||||
header.MapOffset+header.MapSize > uint32(len(data)) ||
|
||||
header.MiniMapOffset+header.MiniMapSize > uint32(len(data)) {
|
||||
return nil, nil, nil, nil, fmt.Errorf("invalid offsets or sizes in header")
|
||||
}
|
||||
|
||||
|
||||
// Extract sections
|
||||
infoData := data[header.InfoOffset : header.InfoOffset+header.InfoSize]
|
||||
mapData := data[header.MapOffset : header.MapOffset+header.MapSize]
|
||||
miniMapData := data[header.MiniMapOffset : header.MiniMapOffset+header.MiniMapSize]
|
||||
|
||||
|
||||
return header, infoData, mapData, miniMapData, nil
|
||||
}
|
||||
|
||||
type CombinedBinaryHeader struct {
|
||||
Version uint32
|
||||
InfoOffset uint32
|
||||
InfoSize uint32
|
||||
MapOffset uint32
|
||||
MapSize uint32
|
||||
Version uint32
|
||||
InfoOffset uint32
|
||||
InfoSize uint32
|
||||
MapOffset uint32
|
||||
MapSize uint32
|
||||
MiniMapOffset uint32
|
||||
MiniMapSize uint32
|
||||
}
|
||||
MiniMapSize uint32
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"perf": "npx tsx tests/perf/*.ts",
|
||||
"test:coverage": "jest --coverage",
|
||||
"format": "prettier --ignore-unknown --write .",
|
||||
"format:map-generator": "cd map-generator && go fmt .",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"prepare": "husky",
|
||||
|
||||
@@ -161,12 +161,12 @@
|
||||
- Revert tradeship path caching by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/927
|
||||
- Meta Adjustments from [UN] clan test by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/932
|
||||
- fix alternate view regression by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/937
|
||||
- fix warship targetting range by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/938
|
||||
- fix warship targeting range by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/938
|
||||
- Add instructional overlay message during spawn phase by @spicydll in https://github.com/openfrontio/OpenFrontIO/pull/934
|
||||
- Add test coverage script by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/929
|
||||
- Added two checkboxes to the default pull request template by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/930
|
||||
- Fix slow singleplayer timer by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/943
|
||||
- improved perfomance of PseudoRandom by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/933
|
||||
- improved performance of PseudoRandom by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/933
|
||||
- Change deploy concurrency group by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/944
|
||||
- Set singleplayer gitCommit in the client by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/945
|
||||
- Simplify bots retaliation logic by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/946
|
||||
@@ -202,7 +202,7 @@
|
||||
- Use bigint for gold by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1000
|
||||
- Fix : Donation when max pop already reached by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/904
|
||||
- Validate incoming API data with zod by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/891
|
||||
- this is a fix for the "possibly null" error. dosent seem to cause runtime issues but does cause the compiler to throw an error by @Jerryslang in https://github.com/openfrontio/OpenFrontIO/pull/1005
|
||||
- this is a fix for the "possibly null" error. doesn't seem to cause runtime issues but does cause the compiler to throw an error by @Jerryslang in https://github.com/openfrontio/OpenFrontIO/pull/1005
|
||||
- Fixnukeboatbug by @rldtech in https://github.com/openfrontio/OpenFrontIO/pull/1011
|
||||
- added ratio controls by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/963
|
||||
- Add a status check for the milestone field by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1029
|
||||
@@ -333,7 +333,7 @@
|
||||
- improve astar perf by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1268
|
||||
- Log public id by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1278
|
||||
- clarify license by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1277
|
||||
- Fix sam targetting everything by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1280
|
||||
- Fix sam targeting everything by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1280
|
||||
- Add Creative Commons License to resources/non-commercial by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1284
|
||||
- Sword pattern by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1283
|
||||
- Display OFM25 ad in WinModal by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1281
|
||||
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<path d="M0 0 C1.0725 -0.01675781 2.145 -0.03351562 3.25 -0.05078125 C4.1575 0.06910156 5.065 0.18898438 6 0.3125 C8.35579986 3.84619979 8.65736711 6.90012694 9.125 11 C9.41375 13.413125 9.7025 15.82625 10 18.3125 C3.4 18.3125 -3.2 18.3125 -10 18.3125 C-9.71125 15.71375 -9.4225 13.115 -9.125 10.4375 C-9.03879395 9.62039551 -8.95258789 8.80329102 -8.86376953 7.96142578 C-8.05470453 1.117728 -6.75802103 0.02436786 0 0 Z " fill="#FFFFFF" transform="translate(16,6.6875)"/>
|
||||
<path d="M0 0 C8.58 0 17.16 0 26 0 C26 1.98 26 3.96 26 6 C17.42 6 8.84 6 0 6 C0 4.02 0 2.04 0 0 Z " fill="#FFFFFF" transform="translate(3,26)"/>
|
||||
<path d="M0 0 C-0.33 0.99 -0.66 1.98 -1 3 C-2.32 3 -3.64 3 -5 3 C-5 2.34 -5 1.68 -5 1 C-3 0 -3 0 0 0 Z " fill="#FFFFFF" transform="translate(32,7)"/>
|
||||
<path d="M0 0 C2.475 0.495 2.475 0.495 5 1 C5 1.66 5 2.32 5 3 C3.35 2.67 1.7 2.34 0 2 C0 1.34 0 0.68 0 0 Z " fill="#FFFFFF" transform="translate(0,7)"/>
|
||||
<path d="M0 0 C1.32 0.66 2.64 1.32 4 2 C4 2.66 4 3.32 4 4 C1.525 3.01 1.525 3.01 -1 2 C-0.67 1.34 -0.34 0.68 0 0 Z " fill="#FFFFFF" transform="translate(28,16)"/>
|
||||
<path d="M0 0 C0.66 0.66 1.32 1.32 2 2 C0.35 2.66 -1.3 3.32 -3 4 C-2.67 3.01 -2.34 2.02 -2 1 C-1.34 0.67 -0.68 0.34 0 0 Z " fill="#FFFFFF" transform="translate(3,16)"/>
|
||||
<path d="M0 0 C0.66 0 1.32 0 2 0 C1.34 1.65 0.68 3.3 0 5 C-0.66 4.34 -1.32 3.68 -2 3 C-1.34 2.01 -0.68 1.02 0 0 Z " fill="#FFFFFF" transform="translate(22,0)"/>
|
||||
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C3.33 1.99 3.66 2.98 4 4 C3.01 4 2.02 4 1 4 C0.67 2.68 0.34 1.36 0 0 Z " fill="#FFFFFF" transform="translate(8,0)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -146,7 +146,6 @@
|
||||
"compact_map": "Compact Map",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"disable_nukes": "Disable Nukes",
|
||||
"automatic_difficulty": "Automatic Difficulty",
|
||||
"enables_title": "Enable Settings",
|
||||
"start": "Start Game"
|
||||
},
|
||||
@@ -172,7 +171,8 @@
|
||||
"games": "Games",
|
||||
"win_score": "Win Score",
|
||||
"loss_score": "Loss Score",
|
||||
"win_loss_ratio": "Win/Loss"
|
||||
"win_loss_ratio": "Win/Loss",
|
||||
"rank": "Rank"
|
||||
},
|
||||
"map": {
|
||||
"map": "Map",
|
||||
@@ -209,7 +209,9 @@
|
||||
"montreal": "Montreal",
|
||||
"achiran": "Achiran",
|
||||
"baikalnukewars": "Baikal (Nuke Wars)",
|
||||
"fourislands": "Four Islands"
|
||||
"fourislands": "Four Islands",
|
||||
"gulfofstlawrence": "Gulf of St. Lawrence",
|
||||
"lisbon": "Lisbon"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Continental",
|
||||
@@ -234,11 +236,12 @@
|
||||
"public_lobby": {
|
||||
"join": "Join next Game",
|
||||
"waiting": "players waiting",
|
||||
"teams_Duos": "Duos (teams of 2)",
|
||||
"teams_Trios": "Trios (teams of 3)",
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams_Duos": "of 2 (Duos)",
|
||||
"teams_Trios": "of 3 (Trios)",
|
||||
"teams_Quads": "of 4 (Quads)",
|
||||
"teams_hvn": "Humans Vs Nations",
|
||||
"teams": "{num} teams"
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "of {num}"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "Matchmaking",
|
||||
@@ -269,10 +272,11 @@
|
||||
"infinite_troops": "Infinite troops",
|
||||
"donate_troops": "Donate troops",
|
||||
"compact_map": "Compact Map",
|
||||
"automatic_difficulty": "Automatic Difficulty",
|
||||
"enables_title": "Enable Settings",
|
||||
"player": "Player",
|
||||
"players": "Players",
|
||||
"nation_players": "Nations",
|
||||
"nation_player": "Nation",
|
||||
"waiting": "Waiting for players...",
|
||||
"random_spawn": "Random spawn",
|
||||
"start": "Start Game",
|
||||
@@ -280,7 +284,7 @@
|
||||
"assigned_teams": "Assigned Teams",
|
||||
"empty_teams": "Empty Teams",
|
||||
"empty_team": "Empty",
|
||||
"remove_player": "Remove {{username}}"
|
||||
"remove_player": "Remove {username}"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "Red",
|
||||
@@ -332,7 +336,7 @@
|
||||
"emojis_label": "Emojis",
|
||||
"emojis_desc": "Toggle whether emojis are shown in game",
|
||||
"alert_frame_label": "Alert Frame",
|
||||
"alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed.",
|
||||
"alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed or attacked over land.",
|
||||
"special_effects_label": "Special effects",
|
||||
"special_effects_desc": "Toggle special effects. Deactivate to improve performances",
|
||||
"structure_sprites_label": "Structure Sprites",
|
||||
@@ -535,7 +539,8 @@
|
||||
"join_tournament": "Join Tournament",
|
||||
"join_discord": "Join Our Discord Community!",
|
||||
"discord_description": "Connect with other players, get updates, and share strategies",
|
||||
"join_server": "Join Server"
|
||||
"join_server": "Join Server",
|
||||
"youtube_tutorial": "Need some help?"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
@@ -644,7 +649,6 @@
|
||||
"flag": "Flag",
|
||||
"chat": "Chat",
|
||||
"target": "Target",
|
||||
"break": "Break",
|
||||
"break_alliance": "Break Alliance",
|
||||
"alliance": "Alliance",
|
||||
"send_alliance": "Send Alliance",
|
||||
@@ -656,10 +660,7 @@
|
||||
"title_with_name": "Send Troops to {name}",
|
||||
"available_tooltip": "Your current available troops",
|
||||
"min_keep": "Min keep",
|
||||
"min_keep_pct": "(30%)",
|
||||
"slider_tooltip": "{{percent}}% • {{amount}}",
|
||||
"toggle_attack_bar_mode": "Use attack bar to send troops",
|
||||
"warning_attackbar": "Once enabled, you can't open this modal directly. You'll only send troops via the attack bar.",
|
||||
"aria_slider": "Troops slider",
|
||||
"capacity_note": "Receiver can accept only {{amount}} right now."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1348,
|
||||
"num_land_tiles": 874011,
|
||||
"width": 1620
|
||||
},
|
||||
"map16x": {
|
||||
"height": 337,
|
||||
"num_land_tiles": 50860,
|
||||
"width": 405
|
||||
},
|
||||
"map4x": {
|
||||
"height": 674,
|
||||
"num_land_tiles": 213053,
|
||||
"width": 810
|
||||
},
|
||||
"name": "Gulf of St. Lawrence",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [88, 364],
|
||||
"flag": "Quebec",
|
||||
"name": "Quebec",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [777, 170],
|
||||
"flag": "Quebec",
|
||||
"name": "Nitassinan",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [570, 460],
|
||||
"flag": "Quebec",
|
||||
"name": "Anticosti Island",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [300, 568],
|
||||
"flag": "Quebec",
|
||||
"name": "Gaspesia",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [256, 60],
|
||||
"flag": "Quebec",
|
||||
"name": "Manicouagan",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [522, 266],
|
||||
"flag": "Quebec",
|
||||
"name": "Mingan",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 632],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Newfoundland",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [1166, 38],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Labrador",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1180, 199],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Northern Peninsula",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1544, 740],
|
||||
"flag": "Newfoundland",
|
||||
"name": "St Johns",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1456, 620],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Bonavista",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 528],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Corner Brook",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1254, 511],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Grand Falls",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1040, 400],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Gros Morne",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [912, 720],
|
||||
"flag": "Newfoundland",
|
||||
"name": "Port aux Basques",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [82, 912],
|
||||
"flag": "ca_nb",
|
||||
"name": "New Brunswick",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [288, 742],
|
||||
"flag": "ca_nb",
|
||||
"name": "Acadia",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [184, 1000],
|
||||
"flag": "ca_nb",
|
||||
"name": "Fredericton",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [338, 938],
|
||||
"flag": "ca_nb",
|
||||
"name": "Moncton",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [44, 1110],
|
||||
"flag": "Maine",
|
||||
"name": "Maine",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [475, 915],
|
||||
"flag": "ca_pe",
|
||||
"name": "Prince Edward Island",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [588, 1054],
|
||||
"flag": "ca_ns",
|
||||
"name": "Nova Scotia",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [725, 920],
|
||||
"flag": "ca_ns",
|
||||
"name": "Cape Breton Island",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [310, 1130],
|
||||
"flag": "ca_ns",
|
||||
"name": "Annapolis",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [445, 1160],
|
||||
"flag": "ca_ns",
|
||||
"name": "Halifax",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [235, 1255],
|
||||
"flag": "ca_ns",
|
||||
"name": "Yarmouth",
|
||||
"strength": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1600,
|
||||
"num_land_tiles": 1495986,
|
||||
"width": 1600
|
||||
},
|
||||
"map16x": {
|
||||
"height": 400,
|
||||
"num_land_tiles": 90227,
|
||||
"width": 400
|
||||
},
|
||||
"map4x": {
|
||||
"height": 800,
|
||||
"num_land_tiles": 369447,
|
||||
"width": 800
|
||||
},
|
||||
"name": "Lisbon",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [750, 630],
|
||||
"flag": "pt",
|
||||
"name": "Lisbon",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [602, 450],
|
||||
"flag": "pt",
|
||||
"name": "Amadora",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [120, 644],
|
||||
"flag": "pt",
|
||||
"name": "Cascais",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [372, 334],
|
||||
"flag": "pt",
|
||||
"name": "Pero Pinheiro",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [214, 36],
|
||||
"flag": "pt",
|
||||
"name": "Ericeira",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [924, 210],
|
||||
"flag": "pt",
|
||||
"name": "Alverca do Ribatejo",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [680, 760],
|
||||
"flag": "pt",
|
||||
"name": "Almada",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [944, 808],
|
||||
"flag": "pt",
|
||||
"name": "Barreiro",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1078, 630],
|
||||
"flag": "pt",
|
||||
"name": "Montijo",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [762, 1266],
|
||||
"flag": "pt",
|
||||
"name": "Sesimbra",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 60],
|
||||
"flag": "pt",
|
||||
"name": "Samora Correia",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1506, 412],
|
||||
"flag": "pt",
|
||||
"name": "Pegoes",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1210, 1100],
|
||||
"flag": "pt",
|
||||
"name": "Setubal",
|
||||
"strength": 3
|
||||
},
|
||||
{
|
||||
"coordinates": [1560, 1186],
|
||||
"flag": "pt",
|
||||
"name": "Sado",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1470, 1470],
|
||||
"flag": "pt",
|
||||
"name": "Carvalhal",
|
||||
"strength": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -62,6 +62,7 @@ export interface LobbyConfig {
|
||||
clientID: ClientID;
|
||||
gameID: GameID;
|
||||
token: string;
|
||||
turnstileToken: string | null;
|
||||
// GameStartInfo only exists when playing a singleplayer game.
|
||||
gameStartInfo?: GameStartInfo;
|
||||
// GameRecord exists when replaying an archived game.
|
||||
@@ -83,9 +84,17 @@ export function joinLobby(
|
||||
|
||||
const transport = new Transport(lobbyConfig, eventBus);
|
||||
|
||||
let hasJoined = false;
|
||||
|
||||
const onconnect = () => {
|
||||
console.log(`Joined game lobby ${lobbyConfig.gameID}`);
|
||||
transport.joinGame(0);
|
||||
if (hasJoined) {
|
||||
console.log("rejoining game");
|
||||
transport.rejoinGame(0);
|
||||
} else {
|
||||
hasJoined = true;
|
||||
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
|
||||
transport.joinGame();
|
||||
}
|
||||
};
|
||||
let terrainLoad: Promise<TerrainMapData> | null = null;
|
||||
|
||||
@@ -120,15 +129,25 @@ export function joinLobby(
|
||||
).then((r) => r.start());
|
||||
}
|
||||
if (message.type === "error") {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
);
|
||||
if (message.error === "full-lobby") {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: lobbyConfig.gameID },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
transport.connect(onconnect, onmessage);
|
||||
@@ -202,7 +221,6 @@ export class ClientGameRunner {
|
||||
private isActive = false;
|
||||
|
||||
private turnsSeen = 0;
|
||||
private hasJoined = false;
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
|
||||
private lastMessageTime: number = 0;
|
||||
@@ -326,13 +344,12 @@ export class ClientGameRunner {
|
||||
|
||||
const onconnect = () => {
|
||||
console.log("Connected to game server!");
|
||||
this.transport.joinGame(this.turnsSeen);
|
||||
this.transport.rejoinGame(this.turnsSeen);
|
||||
};
|
||||
const onmessage = (message: ServerMessage) => {
|
||||
this.lastMessageTime = Date.now();
|
||||
if (message.type === "start") {
|
||||
this.hasJoined = true;
|
||||
console.log("starting game!");
|
||||
console.log("starting game! in client game runner");
|
||||
|
||||
if (this.gameView.config().isRandomSpawn()) {
|
||||
const goToPlayer = () => {
|
||||
@@ -407,10 +424,6 @@ export class ClientGameRunner {
|
||||
);
|
||||
}
|
||||
if (message.type === "turn") {
|
||||
if (!this.hasJoined) {
|
||||
this.transport.joinGame(0);
|
||||
return;
|
||||
}
|
||||
// Track when we receive the turn to calculate delay
|
||||
const now = Date.now();
|
||||
if (this.lastTickReceiveTime > 0) {
|
||||
@@ -429,7 +442,10 @@ export class ClientGameRunner {
|
||||
}
|
||||
}
|
||||
};
|
||||
this.transport.connect(onconnect, onmessage);
|
||||
this.transport.updateCallback(onconnect, onmessage);
|
||||
console.log("sending join game");
|
||||
// Rejoin game from the start so we don't miss any turns.
|
||||
this.transport.rejoinGame(0);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
|
||||
@@ -8,6 +8,19 @@ export class GameStartingModal extends LitElement {
|
||||
isVisible = false;
|
||||
|
||||
static styles = css`
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.overlay.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -117,6 +130,7 @@ export class GameStartingModal extends LitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="overlay ${this.isVisible ? "visible" : ""}"></div>
|
||||
<div class="modal ${this.isVisible ? "visible" : ""}">
|
||||
<div class="copyright">© OpenFront and Contributors</div>
|
||||
<a
|
||||
|
||||
@@ -28,8 +28,8 @@ import "./components/Difficulties";
|
||||
import "./components/LobbyTeamView";
|
||||
import "./components/Maps";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
|
||||
@customElement("host-lobby-modal")
|
||||
export class HostLobbyModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
@@ -58,11 +58,13 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private disabledUnits: UnitType[] = [];
|
||||
@state() private lobbyCreatorClientID: string = "";
|
||||
@state() private lobbyIdVisible: boolean = true;
|
||||
@state() private nationCount: number = 0;
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
// Add a new timer for debouncing bot changes
|
||||
private botsUpdateTimer: number | null = null;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -553,6 +555,13 @@ export class HostLobbyModal extends LitElement {
|
||||
? translateText("host_modal.player")
|
||||
: translateText("host_modal.players")
|
||||
}
|
||||
<span style="margin: 0 8px;">•</span>
|
||||
${this.nationCount}
|
||||
${
|
||||
this.nationCount === 1
|
||||
? translateText("host_modal.nation_player")
|
||||
: translateText("host_modal.nation_players")
|
||||
}
|
||||
</div>
|
||||
|
||||
<lobby-team-view
|
||||
@@ -560,6 +569,7 @@ export class HostLobbyModal extends LitElement {
|
||||
.clients=${this.clients}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.nationCount=${this.disableNPCs ? 0 : this.nationCount}
|
||||
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
|
||||
></lobby-team-view>
|
||||
</div>
|
||||
@@ -613,6 +623,7 @@ export class HostLobbyModal extends LitElement {
|
||||
});
|
||||
this.modalEl?.open();
|
||||
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
|
||||
this.loadNationCount();
|
||||
}
|
||||
|
||||
public close() {
|
||||
@@ -631,12 +642,15 @@ export class HostLobbyModal extends LitElement {
|
||||
|
||||
private async handleRandomMapToggle() {
|
||||
this.useRandomMap = true;
|
||||
this.selectedMap = this.getRandomMap();
|
||||
await this.loadNationCount();
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async handleMapSelection(value: GameMapType) {
|
||||
this.selectedMap = value;
|
||||
this.useRandomMap = false;
|
||||
await this.loadNationCount();
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
@@ -794,10 +808,6 @@ export class HostLobbyModal extends LitElement {
|
||||
}
|
||||
|
||||
private async startGame() {
|
||||
if (this.useRandomMap) {
|
||||
this.selectedMap = this.getRandomMap();
|
||||
}
|
||||
|
||||
await this.putGameConfig();
|
||||
console.log(
|
||||
`Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
|
||||
@@ -857,6 +867,17 @@ export class HostLobbyModal extends LitElement {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadNationCount() {
|
||||
try {
|
||||
const mapData = this.mapLoader.getMapData(this.selectedMap);
|
||||
const manifest = await mapData.manifest();
|
||||
this.nationCount = manifest.nations.length;
|
||||
} catch (error) {
|
||||
console.warn("Failed to load nation count", error);
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createLobby(creatorClientID: string): Promise<GameInfo> {
|
||||
|
||||
@@ -41,16 +41,25 @@ export class LocalServer {
|
||||
private turnStartTime = 0;
|
||||
|
||||
private turnCheckInterval: NodeJS.Timeout;
|
||||
private clientConnect: () => void;
|
||||
private clientMessage: (message: ServerMessage) => void;
|
||||
|
||||
constructor(
|
||||
private lobbyConfig: LobbyConfig,
|
||||
private clientConnect: () => void,
|
||||
private clientMessage: (message: ServerMessage) => void,
|
||||
private isReplay: boolean,
|
||||
private eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
public updateCallback(
|
||||
clientConnect: () => void,
|
||||
clientMessage: (message: ServerMessage) => void,
|
||||
) {
|
||||
this.clientConnect = clientConnect;
|
||||
this.clientMessage = clientMessage;
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log("local server starting");
|
||||
this.turnCheckInterval = setInterval(() => {
|
||||
const turnIntervalMs =
|
||||
this.lobbyConfig.serverConfig.turnIntervalMs() *
|
||||
@@ -97,6 +106,14 @@ export class LocalServer {
|
||||
}
|
||||
|
||||
onMessage(clientMsg: ClientMessage) {
|
||||
if (clientMsg.type === "rejoin") {
|
||||
this.clientMessage({
|
||||
type: "start",
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo!,
|
||||
turns: this.turns,
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
if (clientMsg.type === "intent") {
|
||||
if (this.lobbyConfig.gameRecord) {
|
||||
// If we are replaying a game, we don't want to process intents
|
||||
|
||||
@@ -2,7 +2,9 @@ import version from "../../resources/version.txt";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
@@ -46,6 +48,7 @@ import "./styles.css";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile: any;
|
||||
enableAds: boolean;
|
||||
PageOS: {
|
||||
session: {
|
||||
@@ -105,9 +108,18 @@ class Client {
|
||||
|
||||
private gutterAds: GutterAds;
|
||||
|
||||
private turnstileTokenPromise: Promise<{
|
||||
token: string;
|
||||
createdAt: number;
|
||||
}> | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
initialize(): void {
|
||||
// Prefetch turnstile token so it is available when
|
||||
// the user joins a lobby.
|
||||
this.turnstileTokenPromise = getTurnstileToken();
|
||||
|
||||
const gameVersion = document.getElementById(
|
||||
"game-version",
|
||||
) as HTMLDivElement;
|
||||
@@ -484,6 +496,7 @@ class Client {
|
||||
? ""
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
},
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName: this.usernameInput?.getCurrentUsername() ?? "",
|
||||
token: getPlayToken(),
|
||||
clientID: lobby.clientID,
|
||||
@@ -548,7 +561,7 @@ class Client {
|
||||
|
||||
// Ensure there's a homepage entry in history before adding the lobby entry
|
||||
if (window.location.hash === "" || window.location.hash === "#") {
|
||||
history.pushState(null, "", window.location.origin + "#refresh");
|
||||
history.replaceState(null, "", window.location.origin + "#refresh");
|
||||
}
|
||||
history.pushState(null, "", `#join=${lobby.gameID}`);
|
||||
},
|
||||
@@ -596,6 +609,40 @@ class Client {
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private async getTurnstileToken(
|
||||
lobby: JoinLobbyEvent,
|
||||
): Promise<string | null> {
|
||||
const config = await getServerConfigFromClient();
|
||||
if (
|
||||
config.env() === GameEnv.Dev ||
|
||||
lobby.gameStartInfo?.config.gameType === GameType.Singleplayer
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.turnstileTokenPromise === null) {
|
||||
console.log("No prefetched turnstile token, getting new token");
|
||||
return (await getTurnstileToken())?.token ?? null;
|
||||
}
|
||||
|
||||
const token = await this.turnstileTokenPromise;
|
||||
// Clear promise so a new token is fetched next time
|
||||
this.turnstileTokenPromise = null;
|
||||
if (!token) {
|
||||
console.log("No turnstile token");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenTTL = 3 * 60 * 1000;
|
||||
if (Date.now() < token.createdAt + tokenTTL) {
|
||||
console.log("Prefetched turnstile token is valid");
|
||||
return token.token;
|
||||
} else {
|
||||
console.log("Turnstile token expired, getting new token");
|
||||
return (await getTurnstileToken())?.token ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the client when the DOM is loaded
|
||||
@@ -642,3 +689,43 @@ function getPersistentIDFromCookie(): string {
|
||||
|
||||
return newID;
|
||||
}
|
||||
|
||||
async function getTurnstileToken(): Promise<{
|
||||
token: string;
|
||||
createdAt: number;
|
||||
}> {
|
||||
// Wait for Turnstile script to load (handles slow connections)
|
||||
let attempts = 0;
|
||||
while (typeof window.turnstile === "undefined" && attempts < 100) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (typeof window.turnstile === "undefined") {
|
||||
throw new Error("Failed to load Turnstile script");
|
||||
}
|
||||
|
||||
const config = await getServerConfigFromClient();
|
||||
const widgetId = window.turnstile.render("#turnstile-container", {
|
||||
sitekey: config.turnstileSiteKey(),
|
||||
size: "normal",
|
||||
appearance: "interaction-only",
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
window.turnstile.execute(widgetId, {
|
||||
callback: (token: string) => {
|
||||
window.turnstile.remove(widgetId);
|
||||
console.log(`Turnstile token received: ${token}`);
|
||||
resolve({ token, createdAt: Date.now() });
|
||||
},
|
||||
"error-callback": (errorCode: string) => {
|
||||
window.turnstile.remove(widgetId);
|
||||
console.error(`Turnstile error: ${errorCode}`);
|
||||
alert(`Turnstile error: ${errorCode}. Please refresh and try again.`);
|
||||
reject(new Error(`Turnstile failed: ${errorCode}`));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import { GameMapType, GameMode, HumansVsNations } from "../core/game/Game";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
@@ -122,6 +129,20 @@ export class PublicLobby extends LitElement {
|
||||
? (lobby.gameConfig.playerTeams ?? 0)
|
||||
: null;
|
||||
|
||||
const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
|
||||
const teamSize = this.getTeamSize(teamCount, maxPlayers);
|
||||
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
|
||||
const modeLabel = this.getModeLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
);
|
||||
const teamDetailLabel = this.getTeamDetailLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
@@ -158,17 +179,16 @@ export class PublicLobby extends LitElement {
|
||||
class="text-sm ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-sm px-1"
|
||||
>${modeLabel}</span
|
||||
>
|
||||
${lobby.gameConfig.gameMode === GameMode.Team
|
||||
? typeof teamCount === "string"
|
||||
? teamCount === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`public_lobby.teams_${teamCount}`)
|
||||
: translateText("public_lobby.teams", {
|
||||
num: teamCount ?? 0,
|
||||
})
|
||||
: translateText("game_mode.ffa")}</span
|
||||
>
|
||||
${teamDetailLabel
|
||||
? html`<span
|
||||
class="text-sm ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-sm px-1 ml-1"
|
||||
>${teamDetailLabel}</span
|
||||
>`
|
||||
: ""}
|
||||
<span
|
||||
>${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
|
||||
@@ -193,6 +213,68 @@ export class PublicLobby extends LitElement {
|
||||
this.currLobby = null;
|
||||
}
|
||||
|
||||
private getTeamSize(
|
||||
teamCount: number | string | null,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "string") {
|
||||
if (teamCount === Duos) return 2;
|
||||
if (teamCount === Trios) return 3;
|
||||
if (teamCount === Quads) return 4;
|
||||
if (teamCount === HumansVsNations) return Math.floor(maxPlayers / 2);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof teamCount === "number" && teamCount > 0) {
|
||||
return Math.floor(maxPlayers / teamCount);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getTeamTotal(
|
||||
teamCount: number | string | null,
|
||||
teamSize: number | undefined,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "number") return teamCount;
|
||||
if (teamCount === HumansVsNations) return 2;
|
||||
if (teamSize && teamSize > 0) return Math.floor(maxPlayers / teamSize);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getModeLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
): string {
|
||||
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
|
||||
if (teamCount === HumansVsNations)
|
||||
return translateText("public_lobby.teams_hvn");
|
||||
const totalTeams =
|
||||
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
|
||||
return translateText("public_lobby.teams", { num: totalTeams });
|
||||
}
|
||||
|
||||
private getTeamDetailLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): string | null {
|
||||
if (gameMode !== GameMode.Team) return null;
|
||||
|
||||
if (typeof teamCount === "string" && teamCount !== HumansVsNations) {
|
||||
const teamKey = `public_lobby.teams_${teamCount}`;
|
||||
const maybeTranslated = translateText(teamKey);
|
||||
if (maybeTranslated !== teamKey) return maybeTranslated;
|
||||
}
|
||||
|
||||
if (teamTotal !== undefined && teamSize !== undefined) {
|
||||
return translateText("public_lobby.players_per_team", { num: teamSize });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: GameInfo) {
|
||||
if (this.isButtonDebounced) {
|
||||
return;
|
||||
|
||||
@@ -134,6 +134,9 @@ export class StatsModal extends LitElement {
|
||||
<table class="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
${translateText("stats_modal.rank")}
|
||||
</th>
|
||||
<th class="py-2 pr-3 text-left">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
@@ -153,8 +156,11 @@ export class StatsModal extends LitElement {
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan) => html`
|
||||
(clan, index) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 text-center">
|
||||
${(index + 1).toLocaleString()}
|
||||
</td>
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
</td>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ClientJoinMessage,
|
||||
ClientMessage,
|
||||
ClientPingMessage,
|
||||
ClientRejoinMessage,
|
||||
ClientSendWinnerMessage,
|
||||
Intent,
|
||||
ServerMessage,
|
||||
@@ -287,17 +288,28 @@ export class Transport {
|
||||
}
|
||||
}
|
||||
|
||||
public updateCallback(
|
||||
onconnect: () => void,
|
||||
onmessage: (message: ServerMessage) => void,
|
||||
) {
|
||||
if (this.isLocal) {
|
||||
this.localServer.updateCallback(onconnect, onmessage);
|
||||
} else {
|
||||
this.onconnect = onconnect;
|
||||
this.onmessage = onmessage;
|
||||
}
|
||||
}
|
||||
|
||||
private connectLocal(
|
||||
onconnect: () => void,
|
||||
onmessage: (message: ServerMessage) => void,
|
||||
) {
|
||||
this.localServer = new LocalServer(
|
||||
this.lobbyConfig,
|
||||
onconnect,
|
||||
onmessage,
|
||||
this.lobbyConfig.gameRecord !== undefined,
|
||||
this.eventBus,
|
||||
);
|
||||
this.localServer.updateCallback(onconnect, onmessage);
|
||||
this.localServer.start();
|
||||
}
|
||||
|
||||
@@ -376,18 +388,28 @@ export class Transport {
|
||||
}
|
||||
}
|
||||
|
||||
joinGame(numTurns: number) {
|
||||
joinGame() {
|
||||
this.sendMsg({
|
||||
type: "join",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
lastTurn: numTurns,
|
||||
token: this.lobbyConfig.token,
|
||||
username: this.lobbyConfig.playerName,
|
||||
cosmetics: this.lobbyConfig.cosmetics,
|
||||
turnstileToken: this.lobbyConfig.turnstileToken,
|
||||
} satisfies ClientJoinMessage);
|
||||
}
|
||||
|
||||
rejoinGame(lastTurn: number) {
|
||||
this.sendMsg({
|
||||
type: "rejoin",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
lastTurn: lastTurn,
|
||||
token: this.lobbyConfig.token,
|
||||
} satisfies ClientRejoinMessage);
|
||||
}
|
||||
|
||||
leaveGame() {
|
||||
if (this.isLocal) {
|
||||
this.localServer.endGame();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Team,
|
||||
Trios,
|
||||
} from "../../core/game/Game";
|
||||
import { assignTeams } from "../../core/game/TeamAssignment";
|
||||
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
|
||||
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@@ -31,19 +31,23 @@ export class LobbyTeamView extends LitElement {
|
||||
@property({ type: String }) lobbyCreatorClientID: string = "";
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
@property({ type: Number }) nationCount: number = 0;
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
// clients is 'changed' every 1s from pollPlayers, chose to not compare for actual change
|
||||
if (
|
||||
changedProperties.has("gameMode") ||
|
||||
changedProperties.has("clients") ||
|
||||
changedProperties.has("teamCount")
|
||||
changedProperties.has("teamCount") ||
|
||||
changedProperties.has("nationCount")
|
||||
) {
|
||||
this.computeTeamPreview();
|
||||
this.showTeamColors = this.getTeamList().length <= 7;
|
||||
const teamsList = this.getTeamList();
|
||||
this.computeTeamPreview(teamsList);
|
||||
this.showTeamColors = teamsList.length <= 7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +64,12 @@ export class LobbyTeamView extends LitElement {
|
||||
}
|
||||
|
||||
private renderTeamMode() {
|
||||
const active = this.teamPreview.filter((t) => t.players.length > 0);
|
||||
const empty = this.teamPreview.filter((t) => t.players.length === 0);
|
||||
const active = this.teamPreview.filter(
|
||||
(t) => t.players.length > 0 || t.team === ColoredTeams.Nations,
|
||||
);
|
||||
const empty = this.teamPreview.filter(
|
||||
(t) => t.players.length === 0 && t.team !== ColoredTeams.Nations,
|
||||
);
|
||||
return html` <div
|
||||
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
|
||||
>
|
||||
@@ -96,9 +104,11 @@ export class LobbyTeamView extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.empty_teams")}
|
||||
</div>
|
||||
${empty.length > 0
|
||||
? html`<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.empty_teams")}
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
${repeat(
|
||||
empty,
|
||||
@@ -136,6 +146,16 @@ export class LobbyTeamView extends LitElement {
|
||||
}
|
||||
|
||||
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
||||
const displayCount =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? this.nationCount
|
||||
: preview.players.length;
|
||||
|
||||
const maxTeamSize =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? this.nationCount
|
||||
: this.teamMaxSize;
|
||||
|
||||
return html`
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
|
||||
<div
|
||||
@@ -148,9 +168,7 @@ export class LobbyTeamView extends LitElement {
|
||||
></span>`
|
||||
: null}
|
||||
<span class="truncate">${preview.team}</span>
|
||||
<span class="text-white/90"
|
||||
>${preview.players.length}/${this.teamMaxSize}</span
|
||||
>
|
||||
<span class="text-white/90">${displayCount}/${maxTeamSize}</span>
|
||||
</div>
|
||||
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
|
||||
${isEmpty
|
||||
@@ -190,7 +208,7 @@ export class LobbyTeamView extends LitElement {
|
||||
|
||||
private getTeamList(): Team[] {
|
||||
if (this.gameMode !== GameMode.Team) return [];
|
||||
const playerCount = this.clients.length;
|
||||
const playerCount = this.clients.length + this.nationCount;
|
||||
const config = this.teamCount;
|
||||
|
||||
if (config === HumansVsNations) {
|
||||
@@ -230,13 +248,12 @@ export class LobbyTeamView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private computeTeamPreview() {
|
||||
private computeTeamPreview(teams: Team[] = []) {
|
||||
if (this.gameMode !== GameMode.Team) {
|
||||
this.teamPreview = [];
|
||||
this.teamMaxSize = 0;
|
||||
return;
|
||||
}
|
||||
const teams = this.getTeamList();
|
||||
|
||||
// HumansVsNations: show all clients under Humans initially
|
||||
if (this.teamCount === HumansVsNations) {
|
||||
@@ -252,7 +269,11 @@ export class LobbyTeamView extends LitElement {
|
||||
(c) =>
|
||||
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
|
||||
);
|
||||
const assignment = assignTeams(players, teams);
|
||||
const assignment = assignTeamsLobbyPreview(
|
||||
players,
|
||||
teams,
|
||||
this.nationCount,
|
||||
);
|
||||
const buckets = new Map<Team, ClientInfo[]>();
|
||||
for (const t of teams) buckets.set(t, []);
|
||||
|
||||
@@ -260,9 +281,7 @@ export class LobbyTeamView extends LitElement {
|
||||
if (team === "kicked") continue;
|
||||
const bucket = buckets.get(team);
|
||||
if (!bucket) continue;
|
||||
const client =
|
||||
this.clients.find((c) => c.clientID === p.clientID) ??
|
||||
this.clients.find((c) => c.username === p.name);
|
||||
const client = this.clients.find((c) => c.clientID === p.clientID);
|
||||
if (client) bucket.push(client);
|
||||
}
|
||||
|
||||
@@ -277,7 +296,7 @@ export class LobbyTeamView extends LitElement {
|
||||
// Fallback: divide players across teams; guard against 0 and empty lobbies
|
||||
this.teamMaxSize = Math.max(
|
||||
1,
|
||||
Math.ceil(this.clients.length / teams.length),
|
||||
Math.ceil((this.clients.length + this.nationCount) / teams.length),
|
||||
);
|
||||
}
|
||||
this.teamPreview = teams.map((t) => ({
|
||||
|
||||
@@ -38,6 +38,8 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Achiran: "Achiran",
|
||||
BaikalNukeWars: "Baikal (Nuke Wars)",
|
||||
FourIslands: "Four Islands",
|
||||
GulfOfStLawrence: "Gulf of St. Lawrence",
|
||||
Lisbon: "Lisbon",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -311,7 +311,11 @@ export class GameRenderer {
|
||||
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
|
||||
this.layers.forEach((l) => l.init?.());
|
||||
|
||||
document.body.appendChild(this.canvas);
|
||||
// only append the canvas if it's not already in the document to avoid reparenting side-effects
|
||||
if (!document.body.contains(this.canvas)) {
|
||||
document.body.appendChild(this.canvas);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => this.resizeCanvas());
|
||||
this.resizeCanvas();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
|
||||
const AD_SHOW_TICKS = 5 * 60 * 10; // 5 minutes
|
||||
|
||||
export class AdTimer implements Layer {
|
||||
private isHidden: boolean = false;
|
||||
|
||||
@@ -21,6 +21,8 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
|
||||
@state()
|
||||
private isActive = false;
|
||||
@state()
|
||||
private alertType: "betrayal" | "land-attack" = "betrayal";
|
||||
|
||||
private animationTimeout: number | null = null;
|
||||
private seenAttackIds: Set<string> = new Set();
|
||||
@@ -36,12 +38,20 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
border: 17px solid #ee0000;
|
||||
border: 17px solid;
|
||||
box-sizing: border-box;
|
||||
z-index: 40;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.alert-border.betrayal {
|
||||
border-color: #ee0000;
|
||||
}
|
||||
|
||||
.alert-border.land-attack {
|
||||
border-color: #ffa500;
|
||||
}
|
||||
|
||||
.alert-border.animate {
|
||||
animation: alertBlink ${ALERT_SPEED}s ease-in-out ${ALERT_COUNT};
|
||||
}
|
||||
@@ -119,6 +129,7 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
|
||||
// Only trigger alert if the current player is the betrayed one
|
||||
if (betrayed === myPlayer) {
|
||||
this.alertType = "betrayal";
|
||||
this.activateAlert();
|
||||
}
|
||||
}
|
||||
@@ -202,6 +213,7 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
// 3. The attack is too small (less than 1/5 of our troops)
|
||||
if (!inCooldown && !isRetaliation && !isSmallAttack) {
|
||||
this.seenAttackIds.add(attack.id);
|
||||
this.alertType = "land-attack";
|
||||
this.activateAlert();
|
||||
} else {
|
||||
// Still mark as seen so we don't alert later
|
||||
@@ -237,7 +249,7 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="alert-border animate"
|
||||
class=${`alert-border animate ${this.alertType}`}
|
||||
@animationend=${() => this.dismissAlert()}
|
||||
></div>
|
||||
`;
|
||||
|
||||
@@ -974,32 +974,30 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<!-- Events Toggle (when hidden) -->
|
||||
${this._hidden
|
||||
? html`
|
||||
<div class="relative w-fit lg:bottom-2.5 lg:right-2.5 z-50">
|
||||
<div class="relative w-fit lg:bottom-4 lg:right-4 z-50">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
Events
|
||||
<span
|
||||
class="${this.newEvents
|
||||
? ""
|
||||
: "hidden"} inline-block px-2 bg-red-500 rounded-xl text-sm"
|
||||
: "hidden"} inline-block px-2 bg-red-500 rounded-lg text-sm"
|
||||
>${this.newEvents}</span
|
||||
>
|
||||
`,
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-md bg-gray-800/70 backdrop-blur",
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-lg bg-gray-800/70 backdrop-blur",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full sm:bottom-2.5 sm:right-2.5 z-50 sm:w-96 backdrop-blur"
|
||||
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div
|
||||
class="w-full p-2 lg:p-3 rounded-t-none md:rounded-t-md bg-gray-800/70"
|
||||
>
|
||||
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-4">
|
||||
${this.renderToggleButton(
|
||||
@@ -1042,7 +1040,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
<!-- Content Area -->
|
||||
<div
|
||||
class="rounded-b-none md:rounded-b-md bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full"
|
||||
class="bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full sm:rounded-b-lg"
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
|
||||
@@ -129,7 +129,7 @@ export class FxLayer implements Layer {
|
||||
if (gold > 0) {
|
||||
const shortened = renderNumber(gold, 0);
|
||||
this.addTextFx(`+ ${shortened}`, x, y);
|
||||
y += 10; // increase y so the next popup starts bellow
|
||||
y += 10; // increase y so the next popup starts below
|
||||
}
|
||||
|
||||
if (troops > 0) {
|
||||
|
||||
@@ -87,8 +87,8 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
render() {
|
||||
return html`
|
||||
<aside
|
||||
class=${`fixed top-[20px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
|
||||
this.isVisible ? "translate-x-0" : "-translate-x-full"
|
||||
class=${`fixed top-4 left-4 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
|
||||
this.isVisible ? "translate-x-0" : "hidden"
|
||||
}`}
|
||||
>
|
||||
${this.isPlayerTeamLabelVisible
|
||||
@@ -99,7 +99,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
>
|
||||
${translateText("help_modal.ui_your_team")}
|
||||
<span style="color: ${this.playerColor.toRgbString()}">
|
||||
${this.getTranslatedPlayerTeamLabel()} ⦿
|
||||
${this.getTranslatedPlayerTeamLabel()} ⦿
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
@@ -109,7 +109,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
this.isLeaderboardShow || this.isTeamLeaderboardShow ? "mb-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div class="w-6 h-6 cursor-pointer" @click=${this.toggleLeaderboard}>
|
||||
<div class="cursor-pointer" @click=${this.toggleLeaderboard}>
|
||||
<img
|
||||
src=${this.isLeaderboardShow
|
||||
? leaderboardSolidIcon
|
||||
@@ -122,7 +122,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
${this.isTeamGame
|
||||
? html`
|
||||
<div
|
||||
class="w-6 h-6 cursor-pointer"
|
||||
class="cursor-pointer"
|
||||
@click=${this.toggleTeamLeaderboard}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -118,7 +118,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<aside
|
||||
class=${`flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-sm shadow-xs rounded-tl-lg rounded-bl-lg transition-transform duration-300 ease-out transform ${
|
||||
class=${`flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-sm shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
|
||||
this._isVisible ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
@@ -148,7 +148,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
<!-- Timer display below buttons -->
|
||||
<div class="flex justify-center items-center mt-2">
|
||||
<div
|
||||
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center justify-center text-white px-1"
|
||||
class="w-[70px] h-8 lg:w-24 lg:h-10 p-0.5 text-xs md:text-sm lg:text-base flex items-center justify-center text-white px-1"
|
||||
style="${this.game.config().gameConfig().maxTimerValue !==
|
||||
undefined && this.timer < 60
|
||||
? "color: #ff8080;"
|
||||
|
||||
@@ -15,10 +15,13 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
// Trajectory preview state
|
||||
private mousePos = { x: 0, y: 0 };
|
||||
private trajectoryPoints: TileRef[] = [];
|
||||
private untargetableSegmentBounds: [number, number] = [-1, -1];
|
||||
private targetedIndex = -1;
|
||||
private lastTrajectoryUpdate: number = 0;
|
||||
private lastTargetTile: TileRef | null = null;
|
||||
private currentGhostStructure: UnitType | null = null;
|
||||
private cachedSpawnTile: TileRef | null = null; // Cache spawn tile to avoid expensive player.actions() calls
|
||||
// Cache spawn tile to avoid expensive player.actions() calls
|
||||
private cachedSpawnTile: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -210,6 +213,72 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
);
|
||||
|
||||
this.trajectoryPoints = pathFinder.allTiles();
|
||||
|
||||
// NOTE: This is a lot to do in the rendering method, naive
|
||||
// But trajectory is already calculated here and needed for prediction.
|
||||
// From testing, does not seem to have much effect, so I keep it this way.
|
||||
|
||||
// Calculate points when bomb targetability switches
|
||||
const targetRangeSquared =
|
||||
this.game.config().defaultNukeTargetableRange() ** 2;
|
||||
|
||||
// Find two switch points where bomb transitions:
|
||||
// [0]: leaves spawn range, enters untargetable zone
|
||||
// [1]: enters target range, becomes targetable again
|
||||
this.untargetableSegmentBounds = [-1, -1];
|
||||
for (let i = 0; i < this.trajectoryPoints.length; i++) {
|
||||
const tile = this.trajectoryPoints[i];
|
||||
if (this.untargetableSegmentBounds[0] === -1) {
|
||||
if (
|
||||
this.game.euclideanDistSquared(tile, this.cachedSpawnTile) >
|
||||
targetRangeSquared
|
||||
) {
|
||||
if (
|
||||
this.game.euclideanDistSquared(tile, targetTile) <
|
||||
targetRangeSquared
|
||||
) {
|
||||
// overlapping spawn & target range
|
||||
break;
|
||||
} else {
|
||||
this.untargetableSegmentBounds[0] = i;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared
|
||||
) {
|
||||
this.untargetableSegmentBounds[1] = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Find the point where SAM can intercept
|
||||
this.targetedIndex = this.trajectoryPoints.length;
|
||||
// Check trajectory
|
||||
for (let i = 0; i < this.trajectoryPoints.length; i++) {
|
||||
const tile = this.trajectoryPoints[i];
|
||||
for (const sam of this.game.nearbyUnits(
|
||||
tile,
|
||||
this.game.config().maxSamRange(),
|
||||
UnitType.SAMLauncher,
|
||||
)) {
|
||||
if (
|
||||
sam.unit.owner().isMe() ||
|
||||
this.game.myPlayer()?.isFriendly(sam.unit.owner())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
sam.distSquared <=
|
||||
this.game.config().samRange(sam.unit.level()) ** 2
|
||||
) {
|
||||
this.targetedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.targetedIndex !== this.trajectoryPoints.length) break;
|
||||
// Jump over untargetable segment
|
||||
if (i === this.untargetableSegmentBounds[0])
|
||||
i = this.untargetableSegmentBounds[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,17 +299,78 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
const territoryColor = player.territoryColor();
|
||||
const lineColor = territoryColor.alpha(0.7).toRgbString();
|
||||
// Set of line colors, targeted is after SAM intercept is detected.
|
||||
const untargetedOutlineColor = "rgba(140, 140, 140, 1)";
|
||||
const targetedOutlineColor = "rgba(150, 90, 90, 1)";
|
||||
const symbolOutlineColor = "rgba(0, 0, 0, 1)";
|
||||
const targetedLocationColor = "rgba(255, 0, 0, 1)";
|
||||
const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)";
|
||||
const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)";
|
||||
const untargetableAndTargetedLineColor = "rgba(255, 80, 80, 1)";
|
||||
const targetableAndTargetedLineColor = "rgba(255, 80, 80, 1)";
|
||||
|
||||
// Set of line widths
|
||||
const outlineExtraWidth = 1.5; // adds onto below
|
||||
const lineWidth = 1.25;
|
||||
const XLineWidth = 2;
|
||||
const XSize = 6;
|
||||
|
||||
// Set of line dashes
|
||||
// Outline dashes calculated automatically
|
||||
const untargetableAndUntargetedLineDash = [2, 6];
|
||||
const targetableAndUntargetedLineDash = [8, 4];
|
||||
const untargetableAndTargetedLineDash = [2, 6];
|
||||
const targetableAndTargetedLineDash = [8, 4];
|
||||
|
||||
const outlineDash = (dash: number[], extra: number) => {
|
||||
return [dash[0] + extra, Math.max(dash[1] - extra, 0)];
|
||||
};
|
||||
|
||||
// Tracks the change of color and dash length throughout
|
||||
let currentOutlineColor = untargetedOutlineColor;
|
||||
let currentLineColor = targetableAndUntargetedLineColor;
|
||||
let currentLineDash = targetableAndUntargetedLineDash;
|
||||
let currentLineWidth = lineWidth;
|
||||
|
||||
// Take in set of "current" parameters and draw both outline and line.
|
||||
const outlineAndStroke = () => {
|
||||
context.lineWidth = currentLineWidth + outlineExtraWidth;
|
||||
context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth));
|
||||
context.lineDashOffset = outlineExtraWidth / 2;
|
||||
context.strokeStyle = currentOutlineColor;
|
||||
context.stroke();
|
||||
context.lineWidth = currentLineWidth;
|
||||
context.setLineDash(currentLineDash);
|
||||
context.lineDashOffset = 0;
|
||||
context.strokeStyle = currentLineColor;
|
||||
context.stroke();
|
||||
};
|
||||
const drawUntargetableCircle = (x: number, y: number) => {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 4, 0, 2 * Math.PI, false);
|
||||
currentOutlineColor = untargetedOutlineColor;
|
||||
currentLineColor = targetableAndUntargetedLineColor;
|
||||
currentLineDash = [1, 0];
|
||||
outlineAndStroke();
|
||||
};
|
||||
const drawTargetedX = (x: number, y: number) => {
|
||||
context.beginPath();
|
||||
context.moveTo(x - XSize, y - XSize);
|
||||
context.lineTo(x + XSize, y + XSize);
|
||||
context.moveTo(x - XSize, y + XSize);
|
||||
context.lineTo(x + XSize, y - XSize);
|
||||
currentOutlineColor = symbolOutlineColor;
|
||||
currentLineColor = targetedLocationColor;
|
||||
currentLineDash = [1, 0];
|
||||
currentLineWidth = XLineWidth;
|
||||
outlineAndStroke();
|
||||
};
|
||||
|
||||
// Calculate offset to center coordinates (same as canvas drawing)
|
||||
const offsetX = -this.game.width() / 2;
|
||||
const offsetY = -this.game.height() / 2;
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = lineColor;
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([8, 4]);
|
||||
context.beginPath();
|
||||
|
||||
// Draw line connecting trajectory points
|
||||
@@ -254,9 +384,46 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
if (i === this.untargetableSegmentBounds[0]) {
|
||||
outlineAndStroke();
|
||||
drawUntargetableCircle(x, y);
|
||||
context.beginPath();
|
||||
if (i >= this.targetedIndex) {
|
||||
currentOutlineColor = targetedOutlineColor;
|
||||
currentLineColor = untargetableAndTargetedLineColor;
|
||||
currentLineDash = untargetableAndTargetedLineDash;
|
||||
} else {
|
||||
currentOutlineColor = untargetedOutlineColor;
|
||||
currentLineColor = untargetableAndUntargetedLineColor;
|
||||
currentLineDash = untargetableAndUntargetedLineDash;
|
||||
}
|
||||
} else if (i === this.untargetableSegmentBounds[1]) {
|
||||
outlineAndStroke();
|
||||
drawUntargetableCircle(x, y);
|
||||
context.beginPath();
|
||||
if (i >= this.targetedIndex) {
|
||||
currentOutlineColor = targetedOutlineColor;
|
||||
currentLineColor = targetableAndTargetedLineColor;
|
||||
currentLineDash = targetableAndTargetedLineDash;
|
||||
} else {
|
||||
currentOutlineColor = untargetedOutlineColor;
|
||||
currentLineColor = targetableAndUntargetedLineColor;
|
||||
currentLineDash = targetableAndUntargetedLineDash;
|
||||
}
|
||||
}
|
||||
if (i === this.targetedIndex) {
|
||||
outlineAndStroke();
|
||||
drawTargetedX(x, y);
|
||||
context.beginPath();
|
||||
// Always in the targetable zone by definition.
|
||||
currentOutlineColor = targetedOutlineColor;
|
||||
currentLineColor = targetableAndTargetedLineColor;
|
||||
currentLineDash = targetableAndTargetedLineDash;
|
||||
currentLineWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
outlineAndStroke();
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="block lg:flex fixed top-[150px] right-0 w-full z-50 flex-col max-w-[180px]"
|
||||
class="block lg:flex fixed top-[150px] right-4 w-full z-50 flex-col max-w-[180px]"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -27,6 +27,9 @@ export class RailroadLayer implements Layer {
|
||||
private existingRailroads = new Map<TileRef, RailRef>();
|
||||
private nextRailIndexToCheck = 0;
|
||||
private railTileList: TileRef[] = [];
|
||||
private railTileIndex = new Map<TileRef, number>();
|
||||
private lastRailColorUpdate = 0;
|
||||
private readonly railColorIntervalMs = 50;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -49,7 +52,21 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
|
||||
updateRailColors() {
|
||||
const maxTilesPerFrame = this.railTileList.length / 60;
|
||||
if (this.railTileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Throttle color checks so we do not re-evaluate on every frame
|
||||
const now = performance.now();
|
||||
if (now - this.lastRailColorUpdate < this.railColorIntervalMs) {
|
||||
return;
|
||||
}
|
||||
this.lastRailColorUpdate = now;
|
||||
|
||||
// Spread work over multiple frames to avoid large bursts when many rails exist
|
||||
const maxTilesPerFrame = Math.max(
|
||||
1,
|
||||
Math.ceil(this.railTileList.length / 120),
|
||||
);
|
||||
let checked = 0;
|
||||
|
||||
while (checked < maxTilesPerFrame && this.railTileList.length > 0) {
|
||||
@@ -58,15 +75,14 @@ export class RailroadLayer implements Layer {
|
||||
if (railRef) {
|
||||
const currentOwner = this.game.owner(tile)?.id() ?? null;
|
||||
if (railRef.lastOwnerId !== currentOwner) {
|
||||
// Repaint only when the owner changed to keep colors in sync
|
||||
railRef.lastOwnerId = currentOwner;
|
||||
this.paintRail(railRef.tile);
|
||||
}
|
||||
}
|
||||
|
||||
this.nextRailIndexToCheck++;
|
||||
if (this.nextRailIndexToCheck >= this.railTileList.length) {
|
||||
this.nextRailIndexToCheck = 0;
|
||||
}
|
||||
this.nextRailIndexToCheck =
|
||||
(this.nextRailIndexToCheck + 1) % this.railTileList.length;
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
@@ -95,22 +111,49 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
this.updateRailColors();
|
||||
const scale = this.transformHandler.scale;
|
||||
if (scale <= 1) {
|
||||
return;
|
||||
}
|
||||
if (this.existingRailroads.size === 0) {
|
||||
return;
|
||||
}
|
||||
this.updateRailColors();
|
||||
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
|
||||
const alpha = Math.max(0, Math.min(1, rawAlpha));
|
||||
|
||||
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
const padding = 2; // small margin so edges do not pop
|
||||
const visLeft = Math.max(0, topLeft.x - padding);
|
||||
const visTop = Math.max(0, topLeft.y - padding);
|
||||
const visRight = Math.min(this.game.width(), bottomRight.x + padding);
|
||||
const visBottom = Math.min(this.game.height(), bottomRight.y + padding);
|
||||
const visWidth = Math.max(0, visRight - visLeft);
|
||||
const visHeight = Math.max(0, visBottom - visTop);
|
||||
if (visWidth === 0 || visHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const srcX = visLeft * 2;
|
||||
const srcY = visTop * 2;
|
||||
const srcW = visWidth * 2;
|
||||
const srcH = visHeight * 2;
|
||||
|
||||
const dstX = -this.game.width() / 2 + visLeft;
|
||||
const dstY = -this.game.height() / 2 + visTop;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
srcX,
|
||||
srcY,
|
||||
srcW,
|
||||
srcH,
|
||||
dstX,
|
||||
dstY,
|
||||
visWidth,
|
||||
visHeight,
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
@@ -139,6 +182,7 @@ export class RailroadLayer implements Layer {
|
||||
numOccurence: 1,
|
||||
lastOwnerId: currentOwner,
|
||||
});
|
||||
this.railTileIndex.set(railRoad.tile, this.railTileList.length);
|
||||
this.railTileList.push(railRoad.tile);
|
||||
this.paintRail(railRoad);
|
||||
}
|
||||
@@ -150,7 +194,7 @@ export class RailroadLayer implements Layer {
|
||||
|
||||
if (!ref || ref.numOccurence <= 0) {
|
||||
this.existingRailroads.delete(railRoad.tile);
|
||||
this.railTileList = this.railTileList.filter((t) => t !== railRoad.tile);
|
||||
this.removeRailTile(railRoad.tile);
|
||||
if (this.context === undefined) throw new Error("Not initialized");
|
||||
if (this.game.isWater(railRoad.tile)) {
|
||||
this.context.clearRect(
|
||||
@@ -170,6 +214,24 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private removeRailTile(tile: TileRef) {
|
||||
const idx = this.railTileIndex.get(tile);
|
||||
if (idx === undefined) return;
|
||||
|
||||
const lastIndex = this.railTileList.length - 1;
|
||||
const lastTile = this.railTileList[lastIndex];
|
||||
|
||||
this.railTileList[idx] = lastTile;
|
||||
this.railTileIndex.set(lastTile, idx);
|
||||
|
||||
this.railTileList.pop();
|
||||
this.railTileIndex.delete(tile);
|
||||
|
||||
if (this.nextRailIndexToCheck >= this.railTileList.length) {
|
||||
this.nextRailIndexToCheck = 0;
|
||||
}
|
||||
}
|
||||
|
||||
paintRail(railRoad: RailTile) {
|
||||
if (this.context === undefined) throw new Error("Not initialized");
|
||||
const { tile } = railRoad;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import type { GameView } from "../../../core/game/GameView";
|
||||
import type { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
/**
|
||||
* Layer responsible for rendering SAM launcher defense radiuses
|
||||
* Layer responsible for rendering SAM launcher defense radii
|
||||
*/
|
||||
export class SAMRadiusLayer implements Layer {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
@@ -107,8 +107,9 @@ export class SAMRadiusLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
// show when in ghost mode for sam/atom/hydrogen
|
||||
// show when in ghost mode for silo/sam/atom/hydrogen
|
||||
this.ghostShow =
|
||||
this.uiState.ghostStructure === UnitType.MissileSilo ||
|
||||
this.uiState.ghostStructure === UnitType.SAMLauncher ||
|
||||
this.uiState.ghostStructure === UnitType.AtomBomb ||
|
||||
this.uiState.ghostStructure === UnitType.HydrogenBomb;
|
||||
@@ -157,14 +158,14 @@ export class SAMRadiusLayer implements Layer {
|
||||
this.samLaunchers.set(sam.id(), sam.owner().smallID()),
|
||||
);
|
||||
|
||||
// Draw union of SAM radiuses. Collect circle data then draw union outer arcs only
|
||||
// Draw union of SAM radii. Collect circle data then draw union outer arcs only
|
||||
const circles = samLaunchers.map((sam) => {
|
||||
const tile = sam.tile();
|
||||
return {
|
||||
x: this.game.x(tile),
|
||||
y: this.game.y(tile),
|
||||
r: this.game.config().samRange(sam.level()),
|
||||
owner: sam.owner().smallID(),
|
||||
owner: sam.owner(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -176,13 +177,19 @@ export class SAMRadiusLayer implements Layer {
|
||||
* so overlapping circles appear as one combined shape.
|
||||
*/
|
||||
private drawCirclesUnion(
|
||||
circles: Array<{ x: number; y: number; r: number; owner: number }>,
|
||||
circles: Array<{ x: number; y: number; r: number; owner: PlayerView }>,
|
||||
) {
|
||||
const ctx = this.context;
|
||||
if (circles.length === 0) return;
|
||||
|
||||
// styles
|
||||
const strokeStyleOuter = "rgba(0, 0, 0, 1)";
|
||||
// Line Parameters
|
||||
const outlineColor = "rgba(0, 0, 0, 1)";
|
||||
const lineColorSelf = "rgba(0, 255, 0, 1)";
|
||||
const lineColorEnemy = "rgba(255, 0, 0, 1)";
|
||||
const lineColorFriend = "rgba(255, 255, 0, 1)";
|
||||
const extraOutlineWidth = 1; // adds onto below
|
||||
const lineWidth = 2;
|
||||
const lineDash = [12, 6];
|
||||
|
||||
// 1) Fill union simply by drawing all full circle paths and filling once
|
||||
ctx.save();
|
||||
@@ -199,10 +206,6 @@ export class SAMRadiusLayer implements Layer {
|
||||
if (!this.showStroke) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([12, 6]);
|
||||
ctx.lineDashOffset = this.dashOffset;
|
||||
ctx.strokeStyle = strokeStyleOuter;
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
@@ -258,7 +261,8 @@ export class SAMRadiusLayer implements Layer {
|
||||
// Only consider coverage from circles owned by the same player.
|
||||
// This shows separate boundaries for different players' SAM coverage,
|
||||
// making contested areas visually distinct.
|
||||
if (a.owner !== circles[j].owner) continue;
|
||||
if (a.owner.smallID() !== circles[j].owner.smallID()) continue;
|
||||
|
||||
const b = circles[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
@@ -318,6 +322,27 @@ export class SAMRadiusLayer implements Layer {
|
||||
if (e - s < 1e-3) continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(a.x, a.y, a.r, s, e);
|
||||
|
||||
// Outline
|
||||
ctx.strokeStyle = outlineColor;
|
||||
ctx.lineWidth = lineWidth + extraOutlineWidth;
|
||||
ctx.setLineDash([
|
||||
lineDash[0] + extraOutlineWidth,
|
||||
Math.max(lineDash[1] - extraOutlineWidth, 0),
|
||||
]);
|
||||
ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2;
|
||||
ctx.stroke();
|
||||
// Inline
|
||||
if (a.owner.isMe()) {
|
||||
ctx.strokeStyle = lineColorSelf;
|
||||
} else if (this.game.myPlayer()?.isFriendly(a.owner)) {
|
||||
ctx.strokeStyle = lineColorFriend;
|
||||
} else {
|
||||
ctx.strokeStyle = lineColorEnemy;
|
||||
}
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.setLineDash(lineDash);
|
||||
ctx.lineDashOffset = this.dashOffset;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg";
|
||||
import mouseIcon from "../../../../resources/images/MouseIconWhite.svg";
|
||||
import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg";
|
||||
import settingsIcon from "../../../../resources/images/SettingIconWhite.svg";
|
||||
import sirenIcon from "../../../../resources/images/SirenIconWhite.svg";
|
||||
import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
|
||||
import musicIcon from "../../../../resources/images/music.svg";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -130,6 +131,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleAlertFrameButtonClick() {
|
||||
this.userSettings.toggleAlertFrame();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleDarkModeButtonClick() {
|
||||
this.userSettings.toggleDarkMode();
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
@@ -346,6 +352,26 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
|
||||
@click="${this.onToggleAlertFrameButtonClick}"
|
||||
>
|
||||
<img src=${sirenIcon} alt="alertFrame" width="20" height="20" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText("user_setting.alert_frame_label")}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${translateText("user_setting.alert_frame_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.alertFrame()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
|
||||
@click="${this.onToggleStructureSpritesButtonClick}"
|
||||
|
||||
@@ -148,6 +148,9 @@ export class SpriteFactory {
|
||||
const { type, stage } = options;
|
||||
const { scale } = this.transformHandler;
|
||||
|
||||
this.renderSprites =
|
||||
this.game.config().userSettings()?.structureSprites() ?? true;
|
||||
|
||||
if (type === "icon" || type === "dot") {
|
||||
const texture = this.createTexture(
|
||||
structureType,
|
||||
|
||||
@@ -376,7 +376,7 @@ export class UnitLayer implements Layer {
|
||||
);
|
||||
}
|
||||
|
||||
// interception missle from SAM
|
||||
// interception missile from SAM
|
||||
private handleMissileEvent(unit: UnitView) {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import ofmWintersLogo from "../../../../resources/images/OfmWintersLogo.png";
|
||||
import { isInIframe, translateText } from "../../../client/Utils";
|
||||
import {
|
||||
getGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
} from "../../../client/Utils";
|
||||
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
@@ -105,6 +109,9 @@ export class WinModal extends LitElement implements Layer {
|
||||
return this.steamWishlist();
|
||||
}
|
||||
|
||||
if (!this.isWin && getGamesPlayed() < 3) {
|
||||
return this.renderYoutubeTutorial();
|
||||
}
|
||||
if (this.rand < 0.25) {
|
||||
return this.steamWishlist();
|
||||
} else if (this.rand < 0.5) {
|
||||
@@ -116,6 +123,28 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
renderYoutubeTutorial() {
|
||||
return html`
|
||||
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
|
||||
<h3 class="text-xl font-semibold text-white mb-3">
|
||||
${translateText("win_modal.youtube_tutorial")}
|
||||
</h3>
|
||||
<div class="relative w-full" style="padding-bottom: 56.25%;">
|
||||
<iframe
|
||||
class="absolute top-0 left-0 w-full h-full rounded"
|
||||
src="${this.isVisible
|
||||
? "https://www.youtube.com/embed/EN2oOog3pSs"
|
||||
: ""}"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderPatternButton() {
|
||||
return html`
|
||||
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
|
||||
|
||||
@@ -90,6 +90,13 @@
|
||||
document.documentElement.className = "preload";
|
||||
</script>
|
||||
|
||||
<!-- Cloudflare Turnstile -->
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
|
||||
<!-- Publift/Fuse ads -->
|
||||
<script
|
||||
async
|
||||
@@ -201,7 +208,10 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
|
||||
<div
|
||||
id="turnstile-container"
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
|
||||
></div>
|
||||
<gutter-ads></gutter-ads>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
@@ -301,7 +311,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bottom-0 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px]"
|
||||
class="left-0 bottom-0 sm:left-4 sm:bottom-4 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px]"
|
||||
style="position: fixed; pointer-events: none"
|
||||
>
|
||||
<div
|
||||
@@ -398,7 +408,7 @@
|
||||
<game-starting-modal></game-starting-modal>
|
||||
<game-top-bar></game-top-bar>
|
||||
<unit-display></unit-display>
|
||||
<div class="flex fixed top-[20px] right-[20px] z-[1000] items-start gap-2">
|
||||
<div class="flex fixed top-4 right-4 z-[1000] items-start gap-2">
|
||||
<replay-panel></replay-panel>
|
||||
<game-right-sidebar></game-right-sidebar>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@ export type ClientMessage =
|
||||
| ClientPingMessage
|
||||
| ClientIntentMessage
|
||||
| ClientJoinMessage
|
||||
| ClientRejoinMessage
|
||||
| ClientLogMessage
|
||||
| ClientHashMessage;
|
||||
export type ServerMessage =
|
||||
@@ -110,6 +111,7 @@ export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
|
||||
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
|
||||
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
|
||||
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
|
||||
export type ClientRejoinMessage = z.infer<typeof ClientRejoinMessageSchema>;
|
||||
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
|
||||
export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
|
||||
|
||||
@@ -529,10 +531,18 @@ export const ClientJoinMessageSchema = z.object({
|
||||
clientID: ID,
|
||||
token: TokenSchema, // WARNING: PII
|
||||
gameID: ID,
|
||||
lastTurn: z.number(), // The last turn the client saw.
|
||||
username: UsernameSchema,
|
||||
// Server replaces the refs with the actual cosmetic data.
|
||||
cosmetics: PlayerCosmeticRefsSchema.optional(),
|
||||
turnstileToken: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ClientRejoinMessageSchema = z.object({
|
||||
type: z.literal("rejoin"),
|
||||
gameID: ID,
|
||||
clientID: ID,
|
||||
lastTurn: z.number(),
|
||||
token: TokenSchema,
|
||||
});
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
@@ -540,6 +550,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
ClientPingMessageSchema,
|
||||
ClientIntentMessageSchema,
|
||||
ClientJoinMessageSchema,
|
||||
ClientRejoinMessageSchema,
|
||||
ClientLogMessageSchema,
|
||||
ClientHashSchema,
|
||||
]);
|
||||
|
||||
@@ -27,6 +27,8 @@ export enum GameEnv {
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
turnstileSiteKey(): string;
|
||||
turnstileSecretKey(): string;
|
||||
turnIntervalMs(): number;
|
||||
gameCreationRate(): number;
|
||||
lobbyMaxPlayers(
|
||||
|
||||
@@ -64,10 +64,12 @@ const numPlayersConfig = {
|
||||
[GameMapType.FaroeIslands]: [20, 15, 10],
|
||||
[GameMapType.GatewayToTheAtlantic]: [100, 70, 50],
|
||||
[GameMapType.GiantWorldMap]: [100, 70, 50],
|
||||
[GameMapType.GulfOfStLawrence]: [60, 40, 30],
|
||||
[GameMapType.Halkidiki]: [100, 50, 40],
|
||||
[GameMapType.Iceland]: [50, 40, 30],
|
||||
[GameMapType.Italia]: [50, 30, 20],
|
||||
[GameMapType.Japan]: [20, 15, 10],
|
||||
[GameMapType.Lisbon]: [50, 40, 30],
|
||||
[GameMapType.Mars]: [70, 40, 30],
|
||||
[GameMapType.Mena]: [70, 50, 40],
|
||||
[GameMapType.Montreal]: [60, 40, 30],
|
||||
@@ -81,6 +83,10 @@ const numPlayersConfig = {
|
||||
} as const satisfies Record<GameMapType, [number, number, number]>;
|
||||
|
||||
export abstract class DefaultServerConfig implements ServerConfig {
|
||||
turnstileSecretKey(): string {
|
||||
return process.env.TURNSTILE_SECRET_KEY ?? "";
|
||||
}
|
||||
abstract turnstileSiteKey(): string;
|
||||
allowedFlares(): string[] | undefined {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { UnitInfo, UnitType } from "../game/Game";
|
||||
import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig } from "../Schemas";
|
||||
import { GameEnv, ServerConfig } from "./Config";
|
||||
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
|
||||
|
||||
export class DevServerConfig extends DefaultServerConfig {
|
||||
turnstileSiteKey(): string {
|
||||
return "1x00000000000000000000AA";
|
||||
}
|
||||
|
||||
turnstileSecretKey(): string {
|
||||
return "1x0000000000000000000000000000000AA";
|
||||
}
|
||||
|
||||
adminToken(): string {
|
||||
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
|
||||
}
|
||||
@@ -57,31 +64,4 @@ export class DevConfig extends DefaultConfig {
|
||||
) {
|
||||
super(sc, gc, us, isReplay);
|
||||
}
|
||||
|
||||
unitInfo(type: UnitType): UnitInfo {
|
||||
const info = super.unitInfo(type);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const oldCost = info.cost;
|
||||
// info.cost = (p: Player) => oldCost(p) / 1000000000;
|
||||
return info;
|
||||
}
|
||||
|
||||
// tradeShipSpawnRate(): number {
|
||||
// return 10;
|
||||
// }
|
||||
|
||||
// percentageTilesOwnedToWin(): number {
|
||||
// return 1
|
||||
// }
|
||||
|
||||
// boatMaxDistance(): number {
|
||||
// return 5000
|
||||
// }
|
||||
|
||||
// numBots(): number {
|
||||
// return 0;
|
||||
// }
|
||||
// spawnNPCs(): boolean {
|
||||
// return false;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export const preprodConfig = new (class extends DefaultServerConfig {
|
||||
numWorkers(): number {
|
||||
return 2;
|
||||
}
|
||||
turnstileSiteKey(): string {
|
||||
return "0x4AAAAAAB7QetxHwRCKw-aP";
|
||||
}
|
||||
jwtAudience(): string {
|
||||
return "openfront.dev";
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ export const prodConfig = new (class extends DefaultServerConfig {
|
||||
jwtAudience(): string {
|
||||
return "openfront.io";
|
||||
}
|
||||
turnstileSiteKey(): string {
|
||||
return "0x4AAAAAACFLkaecN39lS8sk";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -4,11 +4,16 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES } from "./utils/BotNames";
|
||||
import {
|
||||
COMMUNITY_FULL_ELF_NAMES,
|
||||
COMMUNITY_PREFIXES,
|
||||
SPECIAL_FULL_ELF_NAMES,
|
||||
} from "./utils/BotNames";
|
||||
|
||||
export class BotSpawner {
|
||||
private random: PseudoRandom;
|
||||
private bots: SpawnExecution[] = [];
|
||||
private nameIndex = 0;
|
||||
|
||||
constructor(
|
||||
private gs: Game,
|
||||
@@ -24,9 +29,13 @@ export class BotSpawner {
|
||||
console.log("too many retries while spawning bots, giving up");
|
||||
return this.bots;
|
||||
}
|
||||
const botName = this.randomBotName();
|
||||
const spawn = this.spawnBot(botName);
|
||||
const candidate = this.nextCandidateName();
|
||||
const spawn = this.spawnBot(candidate.name);
|
||||
if (spawn !== null) {
|
||||
// Only use candidate name once bot successfully spawned
|
||||
if (candidate.source === "list") {
|
||||
this.nameIndex++;
|
||||
}
|
||||
this.bots.push(spawn);
|
||||
} else {
|
||||
tries++;
|
||||
@@ -51,10 +60,42 @@ export class BotSpawner {
|
||||
);
|
||||
}
|
||||
|
||||
private randomBotName(): string {
|
||||
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
|
||||
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
|
||||
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
|
||||
private nextCandidateName(): {
|
||||
name: string;
|
||||
source: "list" | "random";
|
||||
} {
|
||||
if (this.bots.length < 20) {
|
||||
//first few usually overwritten by Nation spawn
|
||||
return { name: this.getRandomElf(), source: "random" };
|
||||
}
|
||||
|
||||
if (this.nameIndex < COMMUNITY_FULL_ELF_NAMES.length) {
|
||||
return {
|
||||
name: COMMUNITY_FULL_ELF_NAMES[this.nameIndex],
|
||||
source: "list",
|
||||
};
|
||||
}
|
||||
const specialOffset = COMMUNITY_FULL_ELF_NAMES.length;
|
||||
if (this.nameIndex < specialOffset + SPECIAL_FULL_ELF_NAMES.length) {
|
||||
return {
|
||||
name: SPECIAL_FULL_ELF_NAMES[this.nameIndex - specialOffset],
|
||||
source: "list",
|
||||
};
|
||||
}
|
||||
const prefixOffset = specialOffset + SPECIAL_FULL_ELF_NAMES.length;
|
||||
if (this.nameIndex < prefixOffset + COMMUNITY_PREFIXES.length) {
|
||||
return {
|
||||
name: `${COMMUNITY_PREFIXES[this.nameIndex - prefixOffset]} the Elf`,
|
||||
source: "list",
|
||||
};
|
||||
}
|
||||
|
||||
return { name: this.getRandomElf(), source: "random" };
|
||||
}
|
||||
|
||||
private getRandomElf(): string {
|
||||
const suffixNumber = this.random.nextInt(1, 10001);
|
||||
return `Elf ${suffixNumber}`;
|
||||
}
|
||||
|
||||
private randTile(): TileRef {
|
||||
|
||||
@@ -82,7 +82,7 @@ export class NukeExecution implements Execution {
|
||||
this.nuke.type() !== UnitType.MIRVWarhead
|
||||
) {
|
||||
// Resolves exploit of alliance breaking in which a pending alliance request
|
||||
// was accepeted in the middle of an missle attack.
|
||||
// was accepted in the middle of a missile attack.
|
||||
const allianceRequest = attackedPlayer
|
||||
.incomingAllianceRequests()
|
||||
.find((ar) => ar.requestor() === this.player);
|
||||
|
||||
@@ -253,3 +253,184 @@ export const BOT_NAME_SUFFIXES = [
|
||||
"Democracy",
|
||||
"Autocracy",
|
||||
];
|
||||
export const COMMUNITY_FULL_ELF_NAMES = [
|
||||
"evan the Creator Elf",
|
||||
"iamlewis the Head Elf",
|
||||
"Restart the Community Elf",
|
||||
"Mr Box the Dev Elf",
|
||||
"InGloriousTom the Dev Elf",
|
||||
"Sheikh the First Elf",
|
||||
"N0ur the Flag Elf",
|
||||
"Diessel the UI Elf",
|
||||
"Nikola123 the Map Elf",
|
||||
"Aotumuri the Language Elf",
|
||||
"Pilkey the Admin Elf",
|
||||
"Mr tryout33s Elf",
|
||||
"Biffeur the YT Elf",
|
||||
"Enzo the YT Elf",
|
||||
"Molky the YT Elf",
|
||||
"FuzeIII the YT Elf",
|
||||
"Node the YT Elf",
|
||||
"Lumiin the YT Elf",
|
||||
"youngfentanyl OFM Elf",
|
||||
"Remorse the Wiki Elf",
|
||||
"Lonely Millennial Twitch Elf",
|
||||
"Kaizeron OFM Elf",
|
||||
"Zilka OFM Elf",
|
||||
"JIZK Caster Elf",
|
||||
"MiraCZ the FP Elf",
|
||||
"aPuddle best Elf",
|
||||
"lucas the sound Elf",
|
||||
];
|
||||
export const SPECIAL_FULL_ELF_NAMES = [
|
||||
"Santa",
|
||||
"Rudolf the Red-Nosed Reindeer",
|
||||
"Frosty the Snowman",
|
||||
"Hermey the Elf",
|
||||
"Ivan the Elf",
|
||||
"Elf on the Shelf",
|
||||
"Buddy the Elf",
|
||||
"Legolas",
|
||||
"Elrond",
|
||||
"Galadriel",
|
||||
"Celeborn",
|
||||
"Glorfindel",
|
||||
];
|
||||
export const COMMUNITY_PREFIXES = [
|
||||
"Baguette Bot",
|
||||
"Kiwi",
|
||||
"FakeNeo",
|
||||
"Nash",
|
||||
"1brucben",
|
||||
"Toyatak",
|
||||
"Readix",
|
||||
"Danny",
|
||||
"php",
|
||||
"Redincon",
|
||||
"Sachx.",
|
||||
"Fuity Mctooty",
|
||||
"Vimacs",
|
||||
"Wraith",
|
||||
"Phantom",
|
||||
"Crescent",
|
||||
"OF Therapist",
|
||||
"Aviid",
|
||||
"brunoo",
|
||||
"Ezaru",
|
||||
"prices",
|
||||
"Santos",
|
||||
"Wonder",
|
||||
"Vincent",
|
||||
"Smith M",
|
||||
"Acer Alex",
|
||||
"Controller",
|
||||
"d3n0x",
|
||||
"devalnor",
|
||||
"FloPinguin",
|
||||
"falcon",
|
||||
"GlacialDrift",
|
||||
"Jax",
|
||||
"Killersoren",
|
||||
"MiniMeTiny",
|
||||
"Remissile",
|
||||
"Sorikairo",
|
||||
"That Otter",
|
||||
"Arya",
|
||||
"Nebula",
|
||||
"takeser",
|
||||
"Kai IL PAZZO",
|
||||
"Vanon",
|
||||
"Foorack",
|
||||
"Abod",
|
||||
"aaa4xu",
|
||||
"Goblinon",
|
||||
"dx",
|
||||
"Pod",
|
||||
"Demonessica",
|
||||
"Dovg",
|
||||
"Joel",
|
||||
"LegitimatelyCool1",
|
||||
"OxMzimzy",
|
||||
"RTHOne",
|
||||
"Egophobic",
|
||||
"djmrFunnyMan",
|
||||
"5oliloguy",
|
||||
"cfsolver",
|
||||
"nvm",
|
||||
"Supbro",
|
||||
"Mischa",
|
||||
"WALMART NINJA",
|
||||
"Magico",
|
||||
"sidious",
|
||||
"Bruny",
|
||||
"Goofer",
|
||||
"Backn",
|
||||
"EyeSeeEm",
|
||||
"TrionX",
|
||||
"Theodora",
|
||||
"platz1de",
|
||||
"Maths Empire",
|
||||
"Moha",
|
||||
"SyntaxPM",
|
||||
"theskeleton4393",
|
||||
"juliosilvaqwerty5",
|
||||
"NewHappyRabbit",
|
||||
"Moki",
|
||||
"Xaelor",
|
||||
"NiclasWK",
|
||||
"cldprv",
|
||||
"r3ms",
|
||||
"Tanepro193",
|
||||
"gx21",
|
||||
"toldinsound",
|
||||
"jacks0n",
|
||||
"floriankilian",
|
||||
"Fibig",
|
||||
"Texxter",
|
||||
"pantelispantelidis",
|
||||
"ap ms",
|
||||
"frappa10",
|
||||
"Lollosean",
|
||||
"daimyo panda2",
|
||||
"gafunuko",
|
||||
"Jinyoon",
|
||||
"Perdiccas",
|
||||
"zibi",
|
||||
"RinkyDinky",
|
||||
"Rulfam",
|
||||
"Nobody",
|
||||
"Vekser",
|
||||
"extraextra",
|
||||
"MotivatedMonkey",
|
||||
"6uzm4n",
|
||||
"theangel2",
|
||||
"Keevee",
|
||||
"Makonede",
|
||||
"grassified",
|
||||
"Zjefken",
|
||||
"Summers Nick",
|
||||
"Marvin",
|
||||
"EagleEye",
|
||||
"Shahiid",
|
||||
"INGSOC",
|
||||
"SIG",
|
||||
"Bobo",
|
||||
"seekerreturns",
|
||||
"SlyTy",
|
||||
"Leo 21",
|
||||
"FX",
|
||||
"Calrathan",
|
||||
"AzloD",
|
||||
"SunnyBoyWTF",
|
||||
"BeGj",
|
||||
"tnhnblgl",
|
||||
"BrunoJurkovic",
|
||||
"q8gazy",
|
||||
"Kipstz",
|
||||
"aqw42",
|
||||
"TylerHavanan",
|
||||
"KerodK",
|
||||
"ghisloufou",
|
||||
"dxtron",
|
||||
"Sii",
|
||||
];
|
||||
|
||||
@@ -101,6 +101,8 @@ export enum GameMapType {
|
||||
Achiran = "Achiran",
|
||||
BaikalNukeWars = "Baikal (Nuke Wars)",
|
||||
FourIslands = "Four Islands",
|
||||
GulfOfStLawrence = "Gulf of St. Lawrence",
|
||||
Lisbon = "Lisbon",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -134,6 +136,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Italia,
|
||||
GameMapType.Japan,
|
||||
GameMapType.Montreal,
|
||||
GameMapType.GulfOfStLawrence,
|
||||
GameMapType.Lisbon,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
|
||||
@@ -213,7 +213,9 @@ export class PlayerView {
|
||||
.theme()
|
||||
.borderColor(defaultTerritoryColor);
|
||||
|
||||
const pattern = this.cosmetics.pattern;
|
||||
const pattern = userSettings.territoryPatterns()
|
||||
? this.cosmetics.pattern
|
||||
: undefined;
|
||||
if (pattern) {
|
||||
pattern.colorPalette ??= {
|
||||
name: "",
|
||||
@@ -225,7 +227,7 @@ export class PlayerView {
|
||||
if (this.team() === null) {
|
||||
this._territoryColor = colord(
|
||||
this.cosmetics.color?.color ??
|
||||
this.cosmetics.pattern?.colorPalette?.primaryColor ??
|
||||
pattern?.colorPalette?.primaryColor ??
|
||||
defaultTerritoryColor.toHex(),
|
||||
);
|
||||
} else {
|
||||
@@ -254,9 +256,9 @@ export class PlayerView {
|
||||
.defendedBorderColors(this._borderColor);
|
||||
|
||||
this.decoder =
|
||||
this.cosmetics.pattern === undefined
|
||||
pattern === undefined
|
||||
? undefined
|
||||
: new PatternDecoder(this.cosmetics.pattern, base64url.decode);
|
||||
: new PatternDecoder(pattern, base64url.decode);
|
||||
}
|
||||
|
||||
territoryColor(tile?: TileRef): Colord {
|
||||
@@ -385,10 +387,15 @@ export class PlayerView {
|
||||
|
||||
totalUnitLevels(type: UnitType): number {
|
||||
return this.units(type)
|
||||
.filter((unit) => !unit.isUnderConstruction())
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
isMe(): boolean {
|
||||
return this.smallID() === this.game.myPlayer()?.smallID();
|
||||
}
|
||||
|
||||
isAlliedWith(other: PlayerView): boolean {
|
||||
return this.data.allies.some((n) => other.smallID() === n);
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ export class PlayerImpl implements Player {
|
||||
if (this.isDisconnected() || other.isDisconnected()) {
|
||||
// Disconnected players are marked as not-friendly even if they are allies,
|
||||
// so we need to return early if either player is disconnected.
|
||||
// Otherise we could end up sending an alliance request to someone
|
||||
// Otherwise we could end up sending an alliance request to someone
|
||||
// we are already allied with.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PlayerInfo, PlayerType, Team } from "./Game";
|
||||
export function assignTeams(
|
||||
players: PlayerInfo[],
|
||||
teams: Team[],
|
||||
maxTeamSize: number = getMaxTeamSize(players.length, teams.length),
|
||||
): Map<PlayerInfo, Team | "kicked"> {
|
||||
const result = new Map<PlayerInfo, Team | "kicked">();
|
||||
const teamPlayerCount = new Map<Team, number>();
|
||||
@@ -25,8 +26,6 @@ export function assignTeams(
|
||||
}
|
||||
}
|
||||
|
||||
const maxTeamSize = Math.ceil(players.length / teams.length);
|
||||
|
||||
// Sort clans by size (largest first)
|
||||
const sortedClans = Array.from(clanGroups.entries()).sort(
|
||||
(a, b) => b[1].length - a[1].length,
|
||||
@@ -87,3 +86,19 @@ export function assignTeams(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function assignTeamsLobbyPreview(
|
||||
players: PlayerInfo[],
|
||||
teams: Team[],
|
||||
nationCount: number,
|
||||
): Map<PlayerInfo, Team | "kicked"> {
|
||||
const maxTeamSize = getMaxTeamSize(
|
||||
players.length + nationCount,
|
||||
teams.length,
|
||||
);
|
||||
return assignTeams(players, teams, maxTeamSize);
|
||||
}
|
||||
|
||||
export function getMaxTeamSize(numPlayers: number, numTeams: number): number {
|
||||
return Math.ceil(numPlayers / numTeams);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export class UnitImpl implements Unit {
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
this.mg.stats().unitBuild(_owner, this._type);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +194,7 @@ export class UnitImpl implements Unit {
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
this.mg.stats().unitCapture(newOwner, this._type);
|
||||
this.mg.stats().unitLose(this._owner, this._type);
|
||||
break;
|
||||
|
||||
@@ -75,7 +75,7 @@ export class UserSettings {
|
||||
|
||||
focusLocked() {
|
||||
return false;
|
||||
// TODO: renable when performance issues are fixed.
|
||||
// TODO: re-enable when performance issues are fixed.
|
||||
this.get("settings.focusLocked", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
// didnt find the start tile in the path
|
||||
// didn't find the start tile in the path
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
// found start tile but not at the start
|
||||
@@ -115,7 +115,7 @@ function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
|
||||
const dstIndex = findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
// didnt find the dst tile in the path
|
||||
// didn't find the dst tile in the path
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
// found dst tile but not at the end
|
||||
|
||||
@@ -54,7 +54,7 @@ export function isProfaneUsername(username: string): boolean {
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each seperate local player name with a profane clan tag will remain, no clan team assignment
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
|
||||
@@ -18,7 +18,8 @@ export class Client {
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public readonly username: string,
|
||||
public readonly ws: WebSocket,
|
||||
public ws: WebSocket,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
public readonly isRejoin: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Logger } from "winston";
|
||||
import WebSocket from "ws";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import {
|
||||
Difficulty,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig, GameID } from "../core/Schemas";
|
||||
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
|
||||
import { Client } from "./Client";
|
||||
import { GamePhase, GameServer } from "./GameServer";
|
||||
|
||||
@@ -25,10 +26,23 @@ export class GameManager {
|
||||
return this.games.get(id) ?? null;
|
||||
}
|
||||
|
||||
addClient(client: Client, gameID: GameID, lastTurn: number): boolean {
|
||||
joinClient(client: Client, gameID: GameID): boolean {
|
||||
const game = this.games.get(gameID);
|
||||
if (game) {
|
||||
game.addClient(client, lastTurn);
|
||||
game.joinClient(client);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
rejoinClient(
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
msg: ClientRejoinMessage,
|
||||
): boolean {
|
||||
const game = this.games.get(msg.gameID);
|
||||
if (game) {
|
||||
game.rejoinClient(ws, persistentID, msg);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientID,
|
||||
ClientMessageSchema,
|
||||
ClientRejoinMessage,
|
||||
ClientSendWinnerMessage,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
@@ -129,7 +130,7 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
public addClient(client: Client, lastTurn: number) {
|
||||
public joinClient(client: Client) {
|
||||
this.websockets.add(client.ws);
|
||||
if (this.kickedClients.has(client.clientID)) {
|
||||
this.log.warn(`cannot add client, already kicked`, {
|
||||
@@ -137,6 +138,31 @@ export class GameServer {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.allClients.has(client.clientID)) {
|
||||
this.log.warn("cannot add client, already in game", {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.gameConfig.maxPlayers &&
|
||||
this.activeClients.length >= this.gameConfig.maxPlayers
|
||||
) {
|
||||
this.log.warn(`cannot add client, game full`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "full-lobby",
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log when lobby creator joins private game
|
||||
if (client.clientID === this.lobbyCreatorID) {
|
||||
this.log.info("Lobby creator joined", {
|
||||
@@ -144,11 +170,10 @@ export class GameServer {
|
||||
creatorID: this.lobbyCreatorID,
|
||||
});
|
||||
}
|
||||
this.log.info("client (re)joining game", {
|
||||
this.log.info("client joining game", {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
isRejoin: lastTurn > 0,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -185,36 +210,67 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale client if this is a reconnect
|
||||
const existing = this.activeClients.find(
|
||||
(c) => c.clientID === client.clientID,
|
||||
);
|
||||
if (existing !== undefined) {
|
||||
if (client.persistentID !== existing.persistentID) {
|
||||
this.log.error("persistent ids do not match", {
|
||||
clientID: client.clientID,
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
clientPersistentID: client.persistentID,
|
||||
existingIP: ipAnonymize(existing.ip),
|
||||
existingPersistentID: existing.persistentID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
client.lastPing = existing.lastPing;
|
||||
client.reportedWinner = existing.reportedWinner;
|
||||
|
||||
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
||||
}
|
||||
|
||||
// Client connection accepted
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
|
||||
this.allClients.set(client.clientID, client);
|
||||
this.addListeners(client);
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public rejoinClient(
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
msg: ClientRejoinMessage,
|
||||
): void {
|
||||
this.websockets.add(ws);
|
||||
|
||||
if (this.kickedClients.has(msg.clientID)) {
|
||||
this.log.warn("cannot rejoin client, client has been kicked", {
|
||||
clientID: msg.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.allClients.get(msg.clientID);
|
||||
if (!client) {
|
||||
this.log.warn("cannot rejoin client, existing client not found", {
|
||||
clientID: msg.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.persistentID !== persistentID) {
|
||||
this.log.error("persistent ids do not match", {
|
||||
clientID: msg.clientID,
|
||||
clientPersistentID: persistentID,
|
||||
existingIP: ipAnonymize(client.ip),
|
||||
existingPersistentID: client.persistentID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== msg.clientID,
|
||||
);
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(msg.clientID, false);
|
||||
|
||||
client.ws = ws;
|
||||
this.addListeners(client);
|
||||
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, msg.lastTurn);
|
||||
}
|
||||
}
|
||||
|
||||
private addListeners(client: Client) {
|
||||
client.ws.removeAllListeners("message");
|
||||
client.ws.on("message", async (message: string) => {
|
||||
try {
|
||||
@@ -236,6 +292,13 @@ export class GameServer {
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
switch (clientMsg.type) {
|
||||
case "rejoin": {
|
||||
// Client is already connected, no auth required, send start game message if game has started
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, clientMsg.lastTurn);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "intent": {
|
||||
if (clientMsg.intent.clientID !== client.clientID) {
|
||||
this.log.warn(
|
||||
@@ -333,11 +396,6 @@ export class GameServer {
|
||||
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
|
||||
}
|
||||
});
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, lastTurn);
|
||||
}
|
||||
}
|
||||
|
||||
public numClients(): number {
|
||||
|
||||
@@ -37,10 +37,12 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
FalklandIslands: 4,
|
||||
FaroeIslands: 4,
|
||||
GatewayToTheAtlantic: 5,
|
||||
GulfOfStLawrence: 4,
|
||||
Halkidiki: 4,
|
||||
Iceland: 4,
|
||||
Italia: 6,
|
||||
Japan: 6,
|
||||
Lisbon: 4,
|
||||
Mars: 3,
|
||||
Mena: 6,
|
||||
Montreal: 6,
|
||||
@@ -67,7 +69,6 @@ const TEAM_COUNTS = [
|
||||
Duos,
|
||||
Trios,
|
||||
Quads,
|
||||
HumansVsNations,
|
||||
] as const satisfies TeamCountConfig[];
|
||||
|
||||
export class MapPlaylist {
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Cloudflare, TunnelConfig } from "./Cloudflare";
|
||||
import { startMaster } from "./Master";
|
||||
import { startWorker } from "./Worker";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Load environment variables before we read configuration values derived from them.
|
||||
dotenv.config();
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Main entry point of the application
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export async function verifyTurnstileToken(
|
||||
ip: string,
|
||||
turnstileToken: string | null,
|
||||
turnstileSecret: string,
|
||||
): Promise<
|
||||
| { status: "approved" }
|
||||
| { status: "rejected"; reason: string }
|
||||
| { status: "error"; reason: string }
|
||||
> {
|
||||
if (!turnstileToken) {
|
||||
return { status: "rejected", reason: "No turnstile token provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: turnstileSecret,
|
||||
response: turnstileToken,
|
||||
remoteip: ip,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: `Turnstile API returned ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
challenge_ts?: string;
|
||||
hostname?: string;
|
||||
"error-codes"?: string[];
|
||||
action?: string;
|
||||
cdata?: string;
|
||||
};
|
||||
|
||||
if (!result.success) {
|
||||
const codes = result["error-codes"]?.join(", ") ?? "unknown";
|
||||
return {
|
||||
status: "rejected",
|
||||
reason: `Turnstile token validation failed: ${codes}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "approved" };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "Turnstile token validation timed out after 3 seconds",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
reason: `Turnstile token validation failed, ${e}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import { GameManager } from "./GameManager";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { verifyTurnstileToken } from "./Turnstile";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
@@ -317,7 +319,7 @@ export async function startWorker() {
|
||||
if (clientMsg.type === "ping") {
|
||||
// Ignore ping
|
||||
return;
|
||||
} else if (clientMsg.type !== "join") {
|
||||
} else if (clientMsg.type !== "join" && clientMsg.type !== "rejoin") {
|
||||
log.warn(
|
||||
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
|
||||
);
|
||||
@@ -342,6 +344,23 @@ export async function startWorker() {
|
||||
}
|
||||
const { persistentId, claims } = result;
|
||||
|
||||
if (clientMsg.type === "rejoin") {
|
||||
log.info("rejoining game", {
|
||||
gameID: clientMsg.gameID,
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
});
|
||||
const wasFound = gm.rejoinClient(ws, persistentId, clientMsg);
|
||||
|
||||
if (!wasFound) {
|
||||
log.warn(
|
||||
`game ${clientMsg.gameID} not found on worker ${workerId}`,
|
||||
);
|
||||
ws.close(1002, "Game not found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let roles: string[] | undefined;
|
||||
let flares: string[] | undefined;
|
||||
|
||||
@@ -389,6 +408,31 @@ export async function startWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.env() !== GameEnv.Dev) {
|
||||
const turnstileResult = await verifyTurnstileToken(
|
||||
ip,
|
||||
clientMsg.turnstileToken,
|
||||
config.turnstileSecretKey(),
|
||||
);
|
||||
switch (turnstileResult.status) {
|
||||
case "approved":
|
||||
break;
|
||||
case "rejected":
|
||||
log.warn("Unauthorized: Turnstile token rejected", {
|
||||
clientID: clientMsg.clientID,
|
||||
reason: turnstileResult.reason,
|
||||
});
|
||||
ws.close(1002, "Unauthorized");
|
||||
return;
|
||||
case "error":
|
||||
// Fail open, allow the client to join.
|
||||
log.error("Turnstile token error", {
|
||||
clientID: clientMsg.clientID,
|
||||
reason: turnstileResult.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create client and add to game
|
||||
const client = new Client(
|
||||
clientMsg.clientID,
|
||||
@@ -402,11 +446,7 @@ export async function startWorker() {
|
||||
cosmeticResult.cosmetics,
|
||||
);
|
||||
|
||||
const wasFound = gm.addClient(
|
||||
client,
|
||||
clientMsg.gameID,
|
||||
clientMsg.lastTurn,
|
||||
);
|
||||
const wasFound = gm.joinClient(client, clientMsg.gameID);
|
||||
|
||||
if (!wasFound) {
|
||||
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("AllianceRequestExecution", () => {
|
||||
expect(player1.outgoingAllianceRequests().length).toBe(1);
|
||||
expect(player2.incomingAllianceRequests().length).toBe(1);
|
||||
|
||||
// Player 1 Builds a silo & launches a missle at player 2.
|
||||
// Player 1 Builds a silo & launches a missile at player 2.
|
||||
constructionExecution(game, player1, 0, 0, UnitType.MissileSilo);
|
||||
game.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, player1, game.ref(0, 1), null),
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("MissileSilo", () => {
|
||||
|
||||
test("missilesilo should cooldown as long as configured", async () => {
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isInCooldown()).toBeFalsy();
|
||||
// send the nuke far enough away so it doesnt destroy the silo
|
||||
// send the nuke far enough away so it doesn't destroy the silo
|
||||
attackerBuildsNuke(null, game.ref(50, 50));
|
||||
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "fs";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
|
||||
type Nation = {
|
||||
name?: string;
|
||||
@@ -12,9 +11,7 @@ type Manifest = {
|
||||
|
||||
describe("Map manifests: nation name length constraint", () => {
|
||||
test("All nations' names must be ≤ 27 characters", () => {
|
||||
const manifestPaths = globSync(
|
||||
path.resolve(process.cwd(), "resources/maps/**/manifest.json"),
|
||||
);
|
||||
const manifestPaths = globSync("resources/maps/**/manifest.json");
|
||||
|
||||
expect(manifestPaths.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -70,12 +70,12 @@ describe("UILayer", () => {
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
|
||||
// a full hp unit doesnt have a health bar
|
||||
// a full hp unit doesn't have a health bar
|
||||
unit.health = () => 10;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(false);
|
||||
|
||||
// a dead unit doesnt have a health bar
|
||||
// a dead unit doesn't have a health bar
|
||||
unit.health = () => 5;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
@@ -98,7 +98,7 @@ describe("UILayer", () => {
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
|
||||
// an inactive unit doesnt have a health bar
|
||||
// an inactive unit doesn't have a health bar
|
||||
unit.isActive = () => false;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(false);
|
||||
|
||||
@@ -4,6 +4,12 @@ import { GameMapType } from "../../src/core/game/Game";
|
||||
import { GameID } from "../../src/core/Schemas";
|
||||
|
||||
export class TestServerConfig implements ServerConfig {
|
||||
turnstileSiteKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
turnstileSecretKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
enableMatchmaking(): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||