Merge branch 'main' into canbuildtransport-perf

This commit is contained in:
VariableVince
2025-12-10 21:08:31 +01:00
committed by GitHub
84 changed files with 2097 additions and 368 deletions
+1
View File
@@ -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 }}
+4
View File
@@ -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
+2 -2
View File
@@ -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**
+5
View File
@@ -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/)
+1
View File
@@ -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
+50 -5
View File
@@ -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 .
```
Binary file not shown.

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"
}
]
}
Binary file not shown.

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"
}
]
}
+49 -13
View File
@@ -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")
}
+106 -106
View File
@@ -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
}
+1
View File
@@ -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",
+4 -4
View File
@@ -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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

+11
View File
@@ -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

+16 -15
View File
@@ -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
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+110
View File
@@ -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
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+36 -20
View File
@@ -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() {
+14
View File
@@ -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
+26 -5
View File
@@ -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> {
+19 -2
View File
@@ -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
+88 -1
View File
@@ -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}`));
},
});
});
}
+93 -11
View File
@@ -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;
+7 -1
View File
@@ -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>
+26 -4
View File
@@ -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();
+39 -20
View File
@@ -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) => ({
+2
View File
@@ -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")
+5 -1
View File
@@ -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 -1
View File
@@ -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;
+14 -2
View File
@@ -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>
`;
+6 -8
View File
@@ -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
+1 -1
View File
@@ -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()} &#10687;
&nbsp;${this.getTranslatedPlayerTeamLabel()} &#10687;
</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
+73 -11
View File
@@ -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;
+38 -13
View File
@@ -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,
+1 -1
View File
@@ -376,7 +376,7 @@ export class UnitLayer implements Layer {
);
}
// interception missle from SAM
// interception missile from SAM
private handleMissileEvent(unit: UnitView) {
this.drawSprite(unit);
}
+30 -1
View File
@@ -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">
+13 -3
View File
@@ -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>
+12 -1
View File
@@ -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,
]);
+2
View File
@@ -27,6 +27,8 @@ export enum GameEnv {
}
export interface ServerConfig {
turnstileSiteKey(): string;
turnstileSecretKey(): string;
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(
+6
View File
@@ -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;
}
+8 -28
View File
@@ -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;
// }
}
+3
View File
@@ -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";
}
+3
View File
@@ -11,4 +11,7 @@ export const prodConfig = new (class extends DefaultServerConfig {
jwtAudience(): string {
return "openfront.io";
}
turnstileSiteKey(): string {
return "0x4AAAAAACFLkaecN39lS8sk";
}
})();
+48 -7
View File
@@ -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 {
+1 -1
View File
@@ -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);
+181
View File
@@ -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",
];
+4
View File
@@ -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,
+11 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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;
}
+17 -2
View File
@@ -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);
}
+2
View File
@@ -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;
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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,
) {}
}
+17 -3
View File
@@ -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;
+90 -32
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+2 -2
View File
@@ -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() {
+73
View File
@@ -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}`,
};
}
}
+46 -6
View File
@@ -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}`);
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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 -4
View File
@@ -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);
+3 -3
View File
@@ -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);
+6
View File
@@ -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.");
}