Merge branch 'main' into trade3
@@ -101,7 +101,6 @@ All new features and bug fixes should include relevant tests. We use **Vitest**.
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. **Commit your changes**:
|
||||
|
||||
- Write clear, concise commit messages.
|
||||
- Use the present tense ("Add feature" not "Added feature").
|
||||
|
||||
|
||||
@@ -181,7 +181,6 @@ Feel free to ask questions in the translation Discord server!
|
||||
To ensure code quality and project stability, we use a progressive contribution system:
|
||||
|
||||
1. **New Contributors**: Limited to UI improvements and small bug fixes only
|
||||
|
||||
- This helps you become familiar with the codebase
|
||||
- UI changes are easier to review and less likely to break core functionality
|
||||
- Small, focused PRs have a higher chance of being accepted
|
||||
@@ -193,20 +192,17 @@ To ensure code quality and project stability, we use a progressive contribution
|
||||
### How to Contribute Successfully
|
||||
|
||||
1. **Before Starting Work**:
|
||||
|
||||
- Open an issue describing what you want to contribute
|
||||
- Wait for maintainer feedback before investing significant time
|
||||
- Small improvements can proceed directly to PR stage
|
||||
|
||||
2. **Code Quality Requirements**:
|
||||
|
||||
- All code must be well-commented and follow existing style patterns
|
||||
- New features should not break existing functionality
|
||||
- Code should be thoroughly tested before submission
|
||||
- All code changes in src/core _MUST_ be tested.
|
||||
|
||||
3. **Pull Request Process**:
|
||||
|
||||
- Keep PRs focused on a single feature or bug fix
|
||||
- Include screenshots for UI changes
|
||||
- Describe what testing you've performed
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -12,12 +12,12 @@
|
||||
"name": "Apollo 14"
|
||||
},
|
||||
{
|
||||
"coordinates": [780, 345],
|
||||
"coordinates": [780, 340],
|
||||
"flag": "us",
|
||||
"name": "Apollo 15"
|
||||
},
|
||||
{
|
||||
"coordinates": [825, 735],
|
||||
"coordinates": [825, 705],
|
||||
"flag": "us",
|
||||
"name": "Apollo 11"
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
"name": "Surveyor 3"
|
||||
},
|
||||
{
|
||||
"coordinates": [256, 148],
|
||||
"coordinates": [250, 148],
|
||||
"flag": "us",
|
||||
"name": "Apollo 13"
|
||||
},
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
|
||||
{
|
||||
"coordinates": [510, 170],
|
||||
"coordinates": [515, 170],
|
||||
"flag": "Russian SSR",
|
||||
"name": "Luna 17"
|
||||
},
|
||||
@@ -95,7 +95,7 @@
|
||||
"name": "Chang'e 4"
|
||||
},
|
||||
{
|
||||
"coordinates": [270, 2690],
|
||||
"coordinates": [260, 268],
|
||||
"flag": "cn",
|
||||
"name": "Chang'e 5"
|
||||
},
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
|
||||
{
|
||||
"coordinates": [830, 735],
|
||||
"coordinates": [830, 745],
|
||||
"flag": "jp",
|
||||
"name": "S.L.I.M."
|
||||
},
|
||||
@@ -117,7 +117,7 @@
|
||||
"name": "Chandrayaan 3"
|
||||
},
|
||||
{
|
||||
"coordinates": [732, 3490],
|
||||
"coordinates": [732, 3493],
|
||||
"flag": "in",
|
||||
"name": "Chandrayaan 1"
|
||||
},
|
||||
@@ -125,18 +125,18 @@
|
||||
{
|
||||
"coordinates": [755, 3035],
|
||||
"flag": "",
|
||||
"name": "T▅▚░S▅cr▅▟░M▅l▅t▅r▅░B▅s▅"
|
||||
"name": "T▆p░S▅cr▅t░M▊l▊t▅r▆░B▅s▅"
|
||||
},
|
||||
{
|
||||
"coordinates": [628, 921],
|
||||
"flag": "",
|
||||
"name": "[]"
|
||||
"name": "▊"
|
||||
}
|
||||
],
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{ "x": 0, "y": 0, "width": 1308, "height": 1750 },
|
||||
{ "x": 0, "y": 1750, "width": 1308, "height": 1750 }
|
||||
{ "x": 0, "y": 0, "width": 1308, "height": 1754 },
|
||||
{ "x": 0, "y": 1754, "width": 1308, "height": 1754 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "Middle East",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [300, 65],
|
||||
"name": "Ottoman Empire",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [1639, 558],
|
||||
"name": "Qajar Dynasty",
|
||||
"flag": "Persia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1141, 797],
|
||||
"name": "Emirate of Kuwait",
|
||||
"flag": "Socialist_flag"
|
||||
},
|
||||
{
|
||||
"coordinates": [1880, 1353],
|
||||
"name": "Sultanate of Muscat",
|
||||
"flag": "Socialist_flag"
|
||||
},
|
||||
{
|
||||
"coordinates": [1703, 1402],
|
||||
"name": "Imamate of Oman",
|
||||
"flag": "White Flag"
|
||||
},
|
||||
{
|
||||
"coordinates": [1592, 1239],
|
||||
"name": "Trucial States",
|
||||
"flag": "Socialist_flag"
|
||||
},
|
||||
{
|
||||
"coordinates": [1129, 1875],
|
||||
"name": "Aden Protectorate",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [964, 1744],
|
||||
"name": "Kingdom of Yemen",
|
||||
"flag": "Kingdom of Yemen"
|
||||
},
|
||||
{
|
||||
"coordinates": [844, 1655],
|
||||
"name": "Emirate of Asir",
|
||||
"flag": "Emirate of Asir"
|
||||
},
|
||||
{
|
||||
"coordinates": [579, 1173],
|
||||
"name": "Kingdom of Hejaz",
|
||||
"flag": "Arabia"
|
||||
},
|
||||
{
|
||||
"coordinates": [800, 1052],
|
||||
"name": "Rashidi Emirate",
|
||||
"flag": "Rashidi Emirate"
|
||||
},
|
||||
{
|
||||
"coordinates": [1092, 1336],
|
||||
"name": "Sultanate of Nejd",
|
||||
"flag": "Sultanate of Nejd"
|
||||
},
|
||||
{
|
||||
"coordinates": [1397, 1128],
|
||||
"name": "Qatar",
|
||||
"flag": "qa"
|
||||
},
|
||||
{
|
||||
"coordinates": [973, 296],
|
||||
"name": "Kingdom of Iraq",
|
||||
"flag": "Kingdom of Iraq"
|
||||
},
|
||||
{
|
||||
"coordinates": [554, 364],
|
||||
"name": "Kingdom of Syria",
|
||||
"flag": "Kingdom of Syria"
|
||||
},
|
||||
{
|
||||
"coordinates": [423, 647],
|
||||
"name": "Palestine Mandate",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [100, 781],
|
||||
"name": "Kingdom of Egypt",
|
||||
"flag": "Kingdom of Egypt"
|
||||
},
|
||||
{
|
||||
"coordinates": [159, 1530],
|
||||
"name": "Anglo-Egyptian Sudan",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [578, 1766],
|
||||
"name": "Italian Eritrea",
|
||||
"flag": "italy"
|
||||
},
|
||||
{
|
||||
"coordinates": [401, 2005],
|
||||
"name": "Ethiopian Empire",
|
||||
"flag": "Ethiopian Empire"
|
||||
},
|
||||
{
|
||||
"coordinates": [826, 2044],
|
||||
"name": "French Somaliland",
|
||||
"flag": "fr"
|
||||
},
|
||||
{
|
||||
"coordinates": [1455, 902],
|
||||
"name": "British Bushehr",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [185, 375],
|
||||
"name": "British Cyprus",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [2127, 373],
|
||||
"name": "Emirate of Afghanistan",
|
||||
"flag": "Emirate of Afghanistan"
|
||||
},
|
||||
{
|
||||
"coordinates": [2087, 925],
|
||||
"name": "Baluchistan Agency",
|
||||
"flag": "gb"
|
||||
},
|
||||
{
|
||||
"coordinates": [932, 15],
|
||||
"name": "Republic of Armenia",
|
||||
"flag": "am"
|
||||
},
|
||||
{
|
||||
"coordinates": [1671, 71],
|
||||
"name": "Russian State",
|
||||
"flag": "ru"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,7 @@ var maps = []struct {
|
||||
{Name: "straitofmalacca"},
|
||||
{Name: "mars"},
|
||||
{Name: "mena"},
|
||||
{Name: "middleeast"},
|
||||
{Name: "montreal"},
|
||||
{Name: "newyorkcity"},
|
||||
{Name: "northamerica"},
|
||||
@@ -107,6 +108,9 @@ var maps = []struct {
|
||||
// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument.
|
||||
var mapsFlag string
|
||||
|
||||
// workersFlag controls how many maps are processed concurrently, bounding peak memory usage.
|
||||
var workersFlag int
|
||||
|
||||
// logFlags holds all the flags related to configuring the map-generator logging
|
||||
var logFlags LogFlags
|
||||
|
||||
@@ -248,15 +252,20 @@ func parseMapsFlag() (map[string]bool, error) {
|
||||
|
||||
// loadTerrainMaps manages the concurrent generation of all selected maps.
|
||||
// It spins up goroutines for each map and aggregates any errors.
|
||||
// Concurrency is bounded by --workers to cap peak memory usage.
|
||||
func loadTerrainMaps() error {
|
||||
if workersFlag < 1 {
|
||||
return fmt.Errorf("--workers must be >= 1, got %d", workersFlag)
|
||||
}
|
||||
selectedMaps, err := parseMapsFlag()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(maps))
|
||||
sem := make(chan struct{}, workersFlag)
|
||||
|
||||
// Process maps concurrently
|
||||
// Process maps concurrently, bounded by the semaphore
|
||||
for _, mapItem := range maps {
|
||||
if selectedMaps != nil && !selectedMaps[mapItem.Name] {
|
||||
continue
|
||||
@@ -265,6 +274,8 @@ func loadTerrainMaps() error {
|
||||
mapItem := mapItem
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
mapLogTag := slog.String("map", mapItem.Name)
|
||||
testLogTag := slog.Bool("isTest", mapItem.IsTest)
|
||||
logger := slog.Default().With(mapLogTag).With(testLogTag)
|
||||
@@ -293,6 +304,7 @@ func loadTerrainMaps() error {
|
||||
// It parses flags and triggers the map generation process.
|
||||
func main() {
|
||||
flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains")
|
||||
flag.IntVar(&workersFlag, "workers", 4, "number of maps to process concurrently. reduce to lower peak memory usage.")
|
||||
flag.StringVar(&logFlags.logLevel, "log-level", "", "Explicitly sets the log level to one of: ALL, DEBUG, INFO (default), WARN, ERROR.")
|
||||
flag.BoolVar(&logFlags.verbose, "verbose", false, "Adds additional logging and prefixes logs with the [mapname]. Alias of log-level=DEBUG.")
|
||||
flag.BoolVar(&logFlags.verbose, "v", false, "-verbose shorthand")
|
||||
|
||||
@@ -36,7 +36,7 @@ type Coord struct {
|
||||
}
|
||||
|
||||
// TerrainType represents the classification of a map tile (e.g., Land or Water).
|
||||
type TerrainType int
|
||||
type TerrainType uint8
|
||||
|
||||
// Enumeration of possible TerrainType values.
|
||||
const (
|
||||
@@ -46,10 +46,13 @@ const (
|
||||
|
||||
// Terrain represents the properties of a single map tile.
|
||||
// Magnitude represents elevation for Land (0-30) or distance to land for Water.
|
||||
// Fields are ordered to minimise alignment padding: float64 first (8 bytes,
|
||||
// offset 0), then three 1-byte fields, giving 16 bytes total vs 24 with the
|
||||
// original layout.
|
||||
type Terrain struct {
|
||||
Magnitude float64
|
||||
Type TerrainType
|
||||
Shoreline bool
|
||||
Magnitude float64
|
||||
Ocean bool
|
||||
}
|
||||
|
||||
@@ -147,6 +150,9 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Image data is no longer needed; release it for GC.
|
||||
img = nil
|
||||
args.ImageBuffer = nil
|
||||
|
||||
removeSmallIslands(ctx, terrain, minIslandSize, args.RemoveSmall)
|
||||
processWater(ctx, terrain, args.RemoveSmall)
|
||||
@@ -169,8 +175,11 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
|
||||
}
|
||||
|
||||
mapData, mapNumLandTiles := packTerrain(ctx, terrain)
|
||||
terrain = nil
|
||||
mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x)
|
||||
terrain4x = nil
|
||||
mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x)
|
||||
terrain16x = nil
|
||||
|
||||
logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles))
|
||||
logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x))
|
||||
@@ -272,24 +281,25 @@ func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
|
||||
var buf [4]Coord
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
tile := &terrain[x][y]
|
||||
neighbors := getNeighbors(x, y, terrain)
|
||||
tile.Shoreline = false
|
||||
n := neighborCoords(x, y, width, height, &buf)
|
||||
|
||||
if tile.Type == Land {
|
||||
// Land tile adjacent to water is shoreline
|
||||
for _, n := range neighbors {
|
||||
if n.Type == Water {
|
||||
for _, c := range buf[:n] {
|
||||
if terrain[c.X][c.Y].Type == Water {
|
||||
tile.Shoreline = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Water tile adjacent to land is shoreline
|
||||
for _, n := range neighbors {
|
||||
if n.Type == Land {
|
||||
for _, c := range buf[:n] {
|
||||
if terrain[c.X][c.Y].Type == Land {
|
||||
tile.Shoreline = true
|
||||
shorelineWaters = append(shorelineWaters, Coord{X: x, Y: y})
|
||||
break
|
||||
@@ -351,37 +361,30 @@ func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][
|
||||
}
|
||||
}
|
||||
|
||||
// getNeighbors returns a list of Terrain tiles adjacent to the specified coordinates.
|
||||
func getNeighbors(x, y int, terrain [][]Terrain) []Terrain {
|
||||
coords := getNeighborCoords(x, y, terrain)
|
||||
neighbors := make([]Terrain, len(coords))
|
||||
for i, coord := range coords {
|
||||
neighbors[i] = terrain[coord.X][coord.Y]
|
||||
}
|
||||
return neighbors
|
||||
}
|
||||
|
||||
// getNeighborCoords returns a list of valid adjacent coordinates (up, down, left, right).
|
||||
// It ensures that the returned coordinates are within the bounds of the terrain grid.
|
||||
func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord {
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
var coords []Coord
|
||||
|
||||
// neighborCoords fills out with the valid orthogonal neighbours of (x, y) and
|
||||
// returns the count. out must be a caller-allocated [4]Coord buffer; by
|
||||
// reusing the same buffer across calls the caller avoids any heap allocation.
|
||||
// Neighbours that would fall outside [0,width) × [0,height) are omitted, so
|
||||
// the count is 2 at corners, 3 on edges, and 4 in the interior.
|
||||
func neighborCoords(x, y, width, height int, out *[4]Coord) int {
|
||||
n := 0
|
||||
if x > 0 {
|
||||
coords = append(coords, Coord{X: x - 1, Y: y})
|
||||
out[n] = Coord{X: x - 1, Y: y}
|
||||
n++
|
||||
}
|
||||
if x < width-1 {
|
||||
coords = append(coords, Coord{X: x + 1, Y: y})
|
||||
out[n] = Coord{X: x + 1, Y: y}
|
||||
n++
|
||||
}
|
||||
if y > 0 {
|
||||
coords = append(coords, Coord{X: x, Y: y - 1})
|
||||
out[n] = Coord{X: x, Y: y - 1}
|
||||
n++
|
||||
}
|
||||
if y < height-1 {
|
||||
coords = append(coords, Coord{X: x, Y: y + 1})
|
||||
out[n] = Coord{X: x, Y: y + 1}
|
||||
n++
|
||||
}
|
||||
|
||||
return coords
|
||||
return n
|
||||
}
|
||||
|
||||
// processWater identifies and processes bodies of water in the terrain.
|
||||
@@ -391,7 +394,16 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord {
|
||||
func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
|
||||
logger := LoggerFromContext(ctx)
|
||||
logger.Info("Processing water bodies")
|
||||
visited := make(map[string]bool)
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
visited := make([]bool, width*height)
|
||||
|
||||
// Clear any Ocean flags inherited from a previous scale's struct copy.
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
terrain[x][y].Ocean = false
|
||||
}
|
||||
}
|
||||
|
||||
type waterBody struct {
|
||||
coords []Coord
|
||||
@@ -401,11 +413,10 @@ func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
|
||||
var waterBodies []waterBody
|
||||
|
||||
// Find all distinct water bodies
|
||||
for x := 0; x < len(terrain); x++ {
|
||||
for y := 0; y < len(terrain[0]); y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
if terrain[x][y].Type == Water {
|
||||
key := fmt.Sprintf("%d,%d", x, y)
|
||||
if visited[key] {
|
||||
if visited[x*height+y] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -463,27 +474,32 @@ func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
|
||||
|
||||
// getArea performs a Breadth-First Search (BFS) to find a contiguous area of tiles
|
||||
// sharing the same TerrainType as the passed x,y coordinates.
|
||||
// The visited map is updated to prevent reprocessing tiles.
|
||||
func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord {
|
||||
// visited is a flat bool slice of size width*height indexed by x*height+y
|
||||
// (column-major, matching the terrain[x][y] grid layout); it is updated to
|
||||
// prevent reprocessing tiles across multiple getArea calls.
|
||||
func getArea(x, y int, terrain [][]Terrain, visited []bool) []Coord {
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
targetType := terrain[x][y].Type
|
||||
var area []Coord
|
||||
|
||||
visited[x*height+y] = true
|
||||
queue := []Coord{{X: x, Y: y}}
|
||||
|
||||
var buf [4]Coord
|
||||
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...)
|
||||
n := neighborCoords(coord.X, coord.Y, width, height, &buf)
|
||||
for _, c := range buf[:n] {
|
||||
if !visited[c.X*height+c.Y] {
|
||||
visited[c.X*height+c.Y] = true
|
||||
queue = append(queue, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +515,7 @@ func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, r
|
||||
return
|
||||
}
|
||||
|
||||
visited := make(map[string]bool)
|
||||
visited := make([]bool, len(terrain)*len(terrain[0]))
|
||||
|
||||
type landBody struct {
|
||||
coords []Coord
|
||||
@@ -509,11 +525,11 @@ func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, r
|
||||
var landBodies []landBody
|
||||
|
||||
// Find all distinct land bodies
|
||||
height := len(terrain[0])
|
||||
for x := 0; x < len(terrain); x++ {
|
||||
for y := 0; y < len(terrain[0]); y++ {
|
||||
for y := 0; y < height; y++ {
|
||||
if terrain[x][y].Type == Land {
|
||||
key := fmt.Sprintf("%d,%d", x, y)
|
||||
if visited[key] {
|
||||
if visited[x*height+y] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -543,6 +559,8 @@ func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, r
|
||||
}
|
||||
|
||||
// packTerrain serializes the terrain grid into a byte slice.
|
||||
// The output buffer is row-major (y*width+x), matching the expected
|
||||
// raster scan order of the binary map format.
|
||||
// Each byte represents a single tile with bit flags:
|
||||
// - Bit 7: Land (1) / Water (0)
|
||||
// - Bit 6: Shoreline
|
||||
|
||||
@@ -29,103 +29,91 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@datastructures-js/priority-queue": "^6.3.3",
|
||||
"@eslint/compat": "^1.2.7",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@datastructures-js/priority-queue": "^6.3.5",
|
||||
"@eslint/compat": "^2.0.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/benchmark": "^2.1.5",
|
||||
"@types/chai": "^4.3.17",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/google-protobuf": "^3.15.12",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/msgpack5": "^3.4.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"@types/systeminformation": "^3.23.1",
|
||||
"@types/ws": "^8.5.11",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"benchmark": "^2.1.4",
|
||||
"canvas": "^3.2.1",
|
||||
"chai": "^5.1.1",
|
||||
"canvas": "^3.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"d3": "^7.9.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-formatter-gha": "^1.5.2",
|
||||
"glob": "^13.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-formatter-gha": "^2.0.1",
|
||||
"glob": "^13.0.6",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"lit": "^3.3.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lit": "^3.3.2",
|
||||
"lit-markdown": "^1.3.2",
|
||||
"mrmime": "^2.0.0",
|
||||
"mrmime": "^2.0.1",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.18.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-sh": "^0.17.4",
|
||||
"protobufjs": "^7.5.5",
|
||||
"sinon": "^21.0.1",
|
||||
"sinon-chai": "^4.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-sh": "^0.18.1",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^7.3.2",
|
||||
"vite": "^8.0.10",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-canvas-mock": "^1.1.3"
|
||||
"vitest": "^4.1.5",
|
||||
"vitest-canvas-mock": "^1.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@lit-labs/virtualizer": "^2.1.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-logs": "^0.200.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.32.0",
|
||||
"@opentelemetry/winston-transport": "^0.11.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.216.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.216.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.216.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-logs": "^0.216.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@opentelemetry/winston-transport": "^0.26.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.10",
|
||||
"dompurify": "^3.4.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^5.0.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"fastpriorityqueue": "^0.7.5",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fastpriorityqueue": "^0.8.0",
|
||||
"howler": "^2.2.4",
|
||||
"intl-messageformat": "^10.7.16",
|
||||
"intl-messageformat": "^11.2.3",
|
||||
"ip-anonymize": "^0.1.0",
|
||||
"jose": "^6.0.10",
|
||||
"jose": "^6.2.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"limiter": "^3.0.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"node-html-parser": "^7.0.2",
|
||||
"obscenity": "^0.4.3",
|
||||
"nanoid": "^5.1.11",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"obscenity": "^0.4.6",
|
||||
"seedrandom": "^3.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.17.0",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^4.0.5"
|
||||
"tsx": "^4.21.0",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -665,6 +665,16 @@
|
||||
"continent": "North America",
|
||||
"name": "El Salvador"
|
||||
},
|
||||
{
|
||||
"code": "Emirate of Afghanistan",
|
||||
"continent": "Asia",
|
||||
"name": "Emirate of Afghanistan"
|
||||
},
|
||||
{
|
||||
"code": "Emirate of Asir",
|
||||
"continent": "Asia",
|
||||
"name": "Emirate of Asir"
|
||||
},
|
||||
{
|
||||
"code": "granada",
|
||||
"continent": "Europe",
|
||||
@@ -721,6 +731,11 @@
|
||||
"continent": "Africa",
|
||||
"name": "Ethiopia"
|
||||
},
|
||||
{
|
||||
"code": "Ethiopian Empire",
|
||||
"continent": "Africa",
|
||||
"name": "Ethiopian Empire"
|
||||
},
|
||||
{
|
||||
"code": "eu",
|
||||
"continent": "Europe",
|
||||
@@ -1050,11 +1065,6 @@
|
||||
"continent": "Europe",
|
||||
"name": "Italy"
|
||||
},
|
||||
{
|
||||
"code": "italy",
|
||||
"continent": "Europe",
|
||||
"name": "Kingdom of Italy"
|
||||
},
|
||||
{
|
||||
"code": "jm",
|
||||
"continent": "North America",
|
||||
@@ -1130,6 +1140,11 @@
|
||||
"continent": "Asia",
|
||||
"name": "Kingdom of Iraq"
|
||||
},
|
||||
{
|
||||
"code": "italy",
|
||||
"continent": "Europe",
|
||||
"name": "Kingdom of Italy"
|
||||
},
|
||||
{
|
||||
"code": "Kingdom of Jerusalem",
|
||||
"continent": "Asia",
|
||||
@@ -1140,6 +1155,16 @@
|
||||
"continent": "Asia",
|
||||
"name": "Kingdom of Judah"
|
||||
},
|
||||
{
|
||||
"code": "Kingdom of Syria",
|
||||
"continent": "Asia",
|
||||
"name": "Kingdom of Syria"
|
||||
},
|
||||
{
|
||||
"code": "Kingdom of Yemen",
|
||||
"continent": "Asia",
|
||||
"name": "Kingdom of Yemen"
|
||||
},
|
||||
{
|
||||
"code": "Kirghiz SSR",
|
||||
"continent": "Asia",
|
||||
@@ -1808,6 +1833,11 @@
|
||||
"continent": "North America",
|
||||
"name": "Quebec"
|
||||
},
|
||||
{
|
||||
"code": "Rashidi Emirate",
|
||||
"continent": "Asia",
|
||||
"name": "Rashidi Emirate"
|
||||
},
|
||||
{
|
||||
"code": "Republic of China",
|
||||
"continent": "Asia",
|
||||
@@ -2057,7 +2087,7 @@
|
||||
},
|
||||
{
|
||||
"code": "Socialist_flag",
|
||||
"name": "Socialist Flag"
|
||||
"name": "Red Flag"
|
||||
},
|
||||
{
|
||||
"code": "sb",
|
||||
@@ -2463,6 +2493,10 @@
|
||||
"continent": "Africa",
|
||||
"name": "Western Sahara"
|
||||
},
|
||||
{
|
||||
"code": "White Flag",
|
||||
"name": "White Flag"
|
||||
},
|
||||
{
|
||||
"code": "Wisconsin",
|
||||
"continent": "North America",
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
|
||||
<image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAADICAYAAACZBDirAAAKlklEQVR4AezZsW5VRx7A4QGEREcBEn0a6KAECwjd7ivQUKy0GB4ATINoMZQ0mIUt8hS7HQEKJArcw6ZHgo4CISH2jCMnFnbsc+6dM2dmzhf5Ark+d+Y/3zg/JcrhsPuvn7u3nnWv/3Wv716BQWDgn4OqfwZiy2LTYtu6q/zz68cAPum+9bx7/aN7/dS9fBEgQKB2gdiy2LTYtti4P86zM4D/6d79Z/fyRYAAgVYFYuNi67bOtx3AWMW/bb0zo18clQCBWQrE1sXmhRjA+N/FsYqzlHBoAgRmKRCb93MM4LVZHt+hCRCYu8C1GMArc1eY5fkdmgCBKzGA8f+QoCBAgMDcBH6KAZzboZ2XAAECWwICuMXgl3kJOC2B3wUWDuD58+fDxsZGePHiRfj48WP4/v27FwM/A34GsvwMxObE9jx+/DjEFv2es+G/Dg7gsWPHwvr6enj16lW4fv16uHTpUjhx4sTwnX2CAAECCwrE5sT2rK6ubrXo/v374ejRo4NXGxTAs2fPhs3NzXD79u1w5MiRwZv5AAECkws0N0Bs0draWnjz5k2IjRpywEEBvHr1ajh9+vSQ9T1LgACBLAIxfrFRQzbrHcCVlZVw69at4C8CBAiUKhAbFUPYd77eAbx27Vo4fLj343339xwBAgSSCcRG3bx58y/X+/EbvYt27ty5Hz/r7wkQIFCcwJkzZ3rP1DuAQxbtvbsHCRAgkFhgSKt6B/D48eOJx7QcAQIE0gucOnWq96K9A9h7xYIfNBoBAgR2CgjgTg1/JkBgVgICOKvrdlgCBHYKCOBOjZb/7GwECOwSEMBdJN4gQGAuAgI4l5t2TgIEdgkI4C4Sb7Qn4EQE9hYQwL1dvEuAwAwEBHAGl+yIBAjsLSCAe7t4l0ArAs6xj4AA7oPjWwQItC0ggG3fr9MRILCPgADug+NbBAjULXDQ9AJ4kJDvEyDQrIAANnu1DkaAwEECAniQkO8TINCsQNMBbPbWHIwAgSQCApiE0SIECNQoIIA13pqZCRBIIiCASRgLXMRIBAgcKCCABxJ5gACBVgUEsNWbdS4CBA4UEMADiTxQn4CJCfQTEMB+Tp4iQKBBAQFs8FIdiQCBfgIC2M/JUwRqETDnAAEBHIDlUQIE2hIQwLbu02kIEBggIIADsDxKgEDZAkOnE8ChYp4nQKAZAQFs5iodhACBoQICOFTM8wQINCPQVACbuRUHIUAgi4AAZmG2CQECJQoIYIm3YiYCBLIICGAW5gyb2IIAgcECAjiYzAcIEGhFQABbuUnnIEBgsIAADibzgfIETERgMQEBXMzNpwgQaEBAABu4REcgQGAxAQFczM2nCJQiYI4lBARwCTwfJUCgbgEBrPv+TE+AwBICArgEno8SIDCtwLK7C+Cygj5PgEC1AgJY7dUZnACBZQUEcFlBnydAoFqBqgNYrbrBCRAoQkAAi7gGQxAgMIWAAE6hbk8CBIoQEMAirmGBIXyEAIGlBQRwaUILECBQq4AA1npz5iZAYGkBAVya0AL5BexIII2AAKZxtAoBAhUKCGCFl2ZkAgTSCAhgGkerEMglYJ+EAgKYENNSBAjUJSCAdd2XaQkQSCgggAkxLUWAwLgCqVcXwNSi1iNAoBoBAazmqgxKgEBqAQFMLWo9AgSqEagqgNWoGpQAgSoEBLCKazIkAQJjCAjgGKrWJECgCgEBrOKaQgjmJEAguYAAJie1IAECtQgIYC03ZU4CBJILCGByUgumF7AigXEEBHAcV6sSIFCBgABWcElGJEBgHAEBHMfVqgRSCVhnRAEBHBHX0gQIlC0ggGXfj+kIEBhRQABHxLU0AQLLCYz9aQEcW9j6BAgUKyCAxV6NwQgQGFtAAMcWtj4BAsUKFB3AYtUMRoBAEwIC2MQ1OgQBAosICOAiaj5DgEATAgJY6jWaiwCB0QUEcHRiGxAgUKqAAJZ6M+YiQGB0AQEcndgGwwV8gkAeAQHM42wXAgQKFBDAAi/FSAQI5BEQwDzOdiHQV8BzGQUEMCO2rQgQKEtAAMu6D9MQIJBRQAAzYtuKAIH9BXJ/VwBzi9uPAIFiBASwmKswCAECuQUEMLe4/QgQKEagqAAWo2IQAgRmISCAs7hmhyRAYC8BAdxLxXsECMxCQABLuWZzECCQXUAAs5PbkACBUgQEsJSbMAcBAtkFBDA7uQ13C3iHwDQCAjiNu10JEChAQAALuAQjECAwjYAATuNuVwLbAn6fUEAAJ8S3NQEC0woI4LT+didAYEIBAZwQ39YE5i4w9fkFcOobsD8BApMJCOBk9DYmQGBqAQGc+gbsT4DAZAKTBnCyU9uYAAECnYAAdgi+CBCYp4AAzvPenZoAgU5AADuESb5sSoDA5AICOPkVGIAAgakEBHAqefsSIDC5gABOfgVzHMCZCZQhIIBl3IMpCBCYQEAAJ0C3JQECZQgIYBn3YIr5CDhpQQICWNBlGIUAgbwCApjX224ECBQkIIAFXYZRCLQuUNr5BLC0GzEPAQLZBAQwG7WNCBAoTUAAS7sR8xAgkE0gawCzncpGBAgQ6CEggD2QPEKAQJsCAtjmvToVAQI9BASwB1KSRyxCgEBxAgJY3JUYiACBXAICmEvaPgQIFCcggMVdSYsDOROBMgUEsMx7MRUBAhkEBDADsi0IEChTQADLvBdTtSPgJAULCGDBl2M0AgTGFRDAcX2tToBAwQICWPDlGI1A7QKlzy+Apd+Q+QgQGE1AAEejtTABAqULCGDpN2Q+AgRGExg1gKNNbWECBAgkEBDABIiWIECgTgEBrPPeTE2AQAIBAUyAuOcS3iRAoHgBASz+igxIgMBYAgI4lqx1CRAoXkAAi7+iGgc0M4E6BASwjnsyJQECIwgI4AioliRAoA4BAazjnkxZj4BJKxIQwIouy6gECKQVEMC0nlYjQKAiAQGs6LKMSqB0gdrmE8Dabsy8BAgkExDAZJQWIkCgNgEBrO3GzEuAQDKBpAFMNpWFCBAgkEFAADMg24IAgTIFBLDMezEVAQIZBHoH8MOHDxnGqXgLoxMgUITAkFb1DuDm5mYRhzMEAQIE9hMY0ioB3E/S9wgQqE5glAA+e/YsfP36tToMA+cQsAeBMgQ+f/4cnj592nuY3v8G+O7du3Dv3r3eC3uQAAECuQXu3r0b3r9/33vb3gGMKz58+DC8ffs2/tGLAAECRQnE//R99OjRoJkGBfDbt29hZWUlrK+vh/jnQTt5mECbAk41sUBs0YMHD8KFCxcGd2lQAOM5v3z5Eu7cuRMuXrwYNjY2wsuXL8OnT5/it7wIECCQRSA2J7bnyZMnWy1aW1sLsU1DNx8cwO0NXr9+HW7cuBEuX74cTp48GQ4dOuTFwM+An4EsPwOxObE9q6urIbZou0tDf184gEM38jwBAu0J1H4iAaz9Bs1PgMDCAjGAvy38aR8kQIBAvQK/xQA+r3d+kxMgQGBhgecxgL8s+nGfI0CAQMUCv8QA/tod4F/dyxcBAgTmIhCb92sMYDzw9e6X/3YvXwQIEGhdILYuNi9sBzAe+O/dL7GK3W++DhTwAAECNQrExsXWbc2+M4DxjVjFK90f/t29/N/hDsEXAQLVC8SWxabFtsXG/XGg/wMAAP//p4HOyAAAAAZJREFUAwA3Oy4c7Kb0ZAAAAABJRU5ErkJggg==" x="7.500" y="32.813" width="135.000" height="84.375" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -292,6 +292,14 @@
|
||||
"stats_hvn": "HvN",
|
||||
"stats_ranked": "Ranked",
|
||||
"stats_1v1": "1v1",
|
||||
"stats_duos": "Duos",
|
||||
"stats_trios": "Trios",
|
||||
"stats_quads": "Quads",
|
||||
"stats_team_count": "{count} Teams",
|
||||
"stats_expand": "Show breakdown",
|
||||
"stats_collapse": "Hide breakdown",
|
||||
"stats_expand_all": "Expand all",
|
||||
"stats_collapse_all": "Collapse all",
|
||||
"no_description": "No description",
|
||||
"saving": "Saving...",
|
||||
"join_request_cancelled": "Join request cancelled.",
|
||||
@@ -310,6 +318,7 @@
|
||||
"ban": "Ban",
|
||||
"unban": "Unban",
|
||||
"banned_players": "Banned Players",
|
||||
"banned_players_count": "{count, plural, one {# banned player} other {# banned players}}",
|
||||
"no_bans": "No banned players.",
|
||||
"ban_reason_prompt": "Ban reason (optional, max 200 characters):",
|
||||
"confirm_ban": "Are you sure you want to ban this player? They will be removed from the clan and unable to rejoin.",
|
||||
@@ -409,6 +418,7 @@
|
||||
"giantworldmap": "Giant World Map",
|
||||
"europe": "Europe",
|
||||
"mena": "MENA",
|
||||
"middleeast": "Middle East",
|
||||
"northamerica": "North America",
|
||||
"oceania": "Oceania",
|
||||
"blacksea": "Black Sea",
|
||||
@@ -748,6 +758,8 @@
|
||||
"boat_attack_desc": "Send a boat attack to the tile under your cursor.",
|
||||
"ground_attack": "Ground Attack",
|
||||
"ground_attack_desc": "Send a ground attack to the tile under your cursor.",
|
||||
"retaliate_attack": "Retaliate",
|
||||
"retaliate_attack_desc": "Send a retaliation attack to blunt/negate the force of the most recent active attacker. Only available when you are being attacked.",
|
||||
"ally_keybinds": "Ally Keybinds",
|
||||
"request_alliance": "Request Alliance",
|
||||
"request_alliance_desc": "Send an alliance request to the player whose tile is under your cursor.",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 3500,
|
||||
"num_land_tiles": 1646031,
|
||||
"height": 3508,
|
||||
"num_land_tiles": 1517614,
|
||||
"width": 1308
|
||||
},
|
||||
"map16x": {
|
||||
"height": 875,
|
||||
"num_land_tiles": 93490,
|
||||
"height": 877,
|
||||
"num_land_tiles": 84692,
|
||||
"width": 327
|
||||
},
|
||||
"map4x": {
|
||||
"height": 1750,
|
||||
"num_land_tiles": 396787,
|
||||
"height": 1754,
|
||||
"num_land_tiles": 364566,
|
||||
"width": 654
|
||||
},
|
||||
"name": "Luna",
|
||||
@@ -27,12 +27,12 @@
|
||||
"name": "Apollo 14"
|
||||
},
|
||||
{
|
||||
"coordinates": [780, 345],
|
||||
"coordinates": [780, 340],
|
||||
"flag": "us",
|
||||
"name": "Apollo 15"
|
||||
},
|
||||
{
|
||||
"coordinates": [825, 735],
|
||||
"coordinates": [825, 705],
|
||||
"flag": "us",
|
||||
"name": "Apollo 11"
|
||||
},
|
||||
@@ -52,7 +52,7 @@
|
||||
"name": "Surveyor 3"
|
||||
},
|
||||
{
|
||||
"coordinates": [256, 148],
|
||||
"coordinates": [250, 148],
|
||||
"flag": "us",
|
||||
"name": "Apollo 13"
|
||||
},
|
||||
@@ -62,7 +62,7 @@
|
||||
"name": "Artemis II"
|
||||
},
|
||||
{
|
||||
"coordinates": [510, 170],
|
||||
"coordinates": [515, 170],
|
||||
"flag": "Russian SSR",
|
||||
"name": "Luna 17"
|
||||
},
|
||||
@@ -107,7 +107,7 @@
|
||||
"name": "Chang'e 4"
|
||||
},
|
||||
{
|
||||
"coordinates": [270, 2690],
|
||||
"coordinates": [260, 268],
|
||||
"flag": "cn",
|
||||
"name": "Chang'e 5"
|
||||
},
|
||||
@@ -117,7 +117,7 @@
|
||||
"name": "Chang'e 6"
|
||||
},
|
||||
{
|
||||
"coordinates": [830, 735],
|
||||
"coordinates": [830, 745],
|
||||
"flag": "jp",
|
||||
"name": "S.L.I.M."
|
||||
},
|
||||
@@ -127,34 +127,34 @@
|
||||
"name": "Chandrayaan 3"
|
||||
},
|
||||
{
|
||||
"coordinates": [732, 3490],
|
||||
"coordinates": [732, 3493],
|
||||
"flag": "in",
|
||||
"name": "Chandrayaan 1"
|
||||
},
|
||||
{
|
||||
"coordinates": [755, 3035],
|
||||
"flag": "",
|
||||
"name": "T▅▚░S▅cr▅▟░M▅l▅t▅r▅░B▅s▅"
|
||||
"name": "T▆p░S▅cr▅t░M▊l▊t▅r▆░B▅s▅"
|
||||
},
|
||||
{
|
||||
"coordinates": [628, 921],
|
||||
"flag": "",
|
||||
"name": "[]"
|
||||
"name": "▊"
|
||||
}
|
||||
],
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
"height": 1750,
|
||||
"height": 1754,
|
||||
"width": 1308,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"height": 1750,
|
||||
"height": 1754,
|
||||
"width": 1308,
|
||||
"x": 0,
|
||||
"y": 1750
|
||||
"y": 1754
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 2060,
|
||||
"num_land_tiles": 3449078,
|
||||
"width": 2200
|
||||
},
|
||||
"map16x": {
|
||||
"height": 515,
|
||||
"num_land_tiles": 211600,
|
||||
"width": 550
|
||||
},
|
||||
"map4x": {
|
||||
"height": 1030,
|
||||
"num_land_tiles": 856603,
|
||||
"width": 1100
|
||||
},
|
||||
"name": "Middle East",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [300, 65],
|
||||
"flag": "tr",
|
||||
"name": "Ottoman Empire"
|
||||
},
|
||||
{
|
||||
"coordinates": [1639, 558],
|
||||
"flag": "Persia",
|
||||
"name": "Qajar Dynasty"
|
||||
},
|
||||
{
|
||||
"coordinates": [1141, 797],
|
||||
"flag": "Socialist_flag",
|
||||
"name": "Emirate of Kuwait"
|
||||
},
|
||||
{
|
||||
"coordinates": [1880, 1353],
|
||||
"flag": "Socialist_flag",
|
||||
"name": "Sultanate of Muscat"
|
||||
},
|
||||
{
|
||||
"coordinates": [1703, 1402],
|
||||
"flag": "White Flag",
|
||||
"name": "Imamate of Oman"
|
||||
},
|
||||
{
|
||||
"coordinates": [1592, 1239],
|
||||
"flag": "Socialist_flag",
|
||||
"name": "Trucial States"
|
||||
},
|
||||
{
|
||||
"coordinates": [1129, 1875],
|
||||
"flag": "gb",
|
||||
"name": "Aden Protectorate"
|
||||
},
|
||||
{
|
||||
"coordinates": [964, 1744],
|
||||
"flag": "Kingdom of Yemen",
|
||||
"name": "Kingdom of Yemen"
|
||||
},
|
||||
{
|
||||
"coordinates": [844, 1655],
|
||||
"flag": "Emirate of Asir",
|
||||
"name": "Emirate of Asir"
|
||||
},
|
||||
{
|
||||
"coordinates": [579, 1173],
|
||||
"flag": "Arabia",
|
||||
"name": "Kingdom of Hejaz"
|
||||
},
|
||||
{
|
||||
"coordinates": [800, 1052],
|
||||
"flag": "Rashidi Emirate",
|
||||
"name": "Rashidi Emirate"
|
||||
},
|
||||
{
|
||||
"coordinates": [1092, 1336],
|
||||
"flag": "Sultanate of Nejd",
|
||||
"name": "Sultanate of Nejd"
|
||||
},
|
||||
{
|
||||
"coordinates": [1397, 1128],
|
||||
"flag": "qa",
|
||||
"name": "Qatar"
|
||||
},
|
||||
{
|
||||
"coordinates": [973, 296],
|
||||
"flag": "Kingdom of Iraq",
|
||||
"name": "Kingdom of Iraq"
|
||||
},
|
||||
{
|
||||
"coordinates": [554, 364],
|
||||
"flag": "Kingdom of Syria",
|
||||
"name": "Kingdom of Syria"
|
||||
},
|
||||
{
|
||||
"coordinates": [423, 647],
|
||||
"flag": "gb",
|
||||
"name": "Palestine Mandate"
|
||||
},
|
||||
{
|
||||
"coordinates": [100, 781],
|
||||
"flag": "Kingdom of Egypt",
|
||||
"name": "Kingdom of Egypt"
|
||||
},
|
||||
{
|
||||
"coordinates": [159, 1530],
|
||||
"flag": "gb",
|
||||
"name": "Anglo-Egyptian Sudan"
|
||||
},
|
||||
{
|
||||
"coordinates": [578, 1766],
|
||||
"flag": "italy",
|
||||
"name": "Italian Eritrea"
|
||||
},
|
||||
{
|
||||
"coordinates": [401, 2005],
|
||||
"flag": "Ethiopian Empire",
|
||||
"name": "Ethiopian Empire"
|
||||
},
|
||||
{
|
||||
"coordinates": [826, 2044],
|
||||
"flag": "fr",
|
||||
"name": "French Somaliland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1455, 902],
|
||||
"flag": "gb",
|
||||
"name": "British Bushehr"
|
||||
},
|
||||
{
|
||||
"coordinates": [185, 375],
|
||||
"flag": "gb",
|
||||
"name": "British Cyprus"
|
||||
},
|
||||
{
|
||||
"coordinates": [2127, 373],
|
||||
"flag": "Emirate of Afghanistan",
|
||||
"name": "Emirate of Afghanistan"
|
||||
},
|
||||
{
|
||||
"coordinates": [2087, 925],
|
||||
"flag": "gb",
|
||||
"name": "Baluchistan Agency"
|
||||
},
|
||||
{
|
||||
"coordinates": [932, 15],
|
||||
"flag": "am",
|
||||
"name": "Republic of Armenia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1671, 71],
|
||||
"flag": "ru",
|
||||
"name": "Russian State"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -109,7 +109,7 @@ export async function fetchClans(
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (search && search.length >= 3) params.set("search", search);
|
||||
if (search && search.length >= 2) params.set("search", search);
|
||||
const res = await clanFetch(`/clans?${params}`);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
|
||||
@@ -60,8 +60,20 @@ export class ClanModal extends BaseModal {
|
||||
} | null = null;
|
||||
|
||||
render() {
|
||||
const content = this.renderInner();
|
||||
if (this.inline) return content;
|
||||
const onListView = this.view === "list" && !this.selectedClanTag;
|
||||
const tabs = onListView
|
||||
? [
|
||||
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
|
||||
{ key: "browse", label: translateText("clan_modal.browse") },
|
||||
]
|
||||
: [];
|
||||
const header = onListView
|
||||
? modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})
|
||||
: this.renderSubViewHeader();
|
||||
return html`
|
||||
<o-modal
|
||||
id="clan-modal"
|
||||
@@ -69,12 +81,87 @@ export class ClanModal extends BaseModal {
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) => this.handleTabChange(key as Tab)}
|
||||
>
|
||||
${content}
|
||||
${header ? html`<div slot="header">${header}</div>` : ""}
|
||||
<div class="p-4 lg:p-[1.4rem]">${this.renderInner()}</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private tagPill(tag: string) {
|
||||
return html`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
|
||||
>[${tag}]</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private renderSubViewHeader() {
|
||||
const clan = this.selectedClan;
|
||||
const ariaLabel = translateText("common.back");
|
||||
if (this.view === "my-requests") {
|
||||
return modalHeader({
|
||||
title: translateText("clan_modal.pending_applications"),
|
||||
onBack: () => (this.view = "list"),
|
||||
ariaLabel,
|
||||
});
|
||||
}
|
||||
if (this.view === "manage") {
|
||||
return modalHeader({
|
||||
title: translateText("clan_modal.manage_clan"),
|
||||
onBack: () => (this.view = "detail"),
|
||||
ariaLabel,
|
||||
rightContent: clan ? this.tagPill(clan.tag) : undefined,
|
||||
});
|
||||
}
|
||||
if (this.view === "transfer") {
|
||||
return modalHeader({
|
||||
title: translateText("clan_modal.transfer_leadership"),
|
||||
onBack: () => (this.view = "manage"),
|
||||
ariaLabel,
|
||||
});
|
||||
}
|
||||
if (this.view === "requests") {
|
||||
return modalHeader({
|
||||
title: translateText("clan_modal.join_requests"),
|
||||
onBack: () => (this.view = "detail"),
|
||||
ariaLabel,
|
||||
});
|
||||
}
|
||||
if (this.view === "bans") {
|
||||
return modalHeader({
|
||||
title: translateText("clan_modal.banned_players"),
|
||||
onBack: () => (this.view = "manage"),
|
||||
ariaLabel,
|
||||
});
|
||||
}
|
||||
// Default: detail
|
||||
return modalHeader({
|
||||
title: clan?.name ?? translateText("clan_modal.title"),
|
||||
onBack: () => {
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.detailCache = null;
|
||||
},
|
||||
ariaLabel,
|
||||
rightContent: clan ? this.tagPill(clan.tag) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private handleTabChange(tab: Tab) {
|
||||
this.activeTab = tab;
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
if (tab === "my-clans") {
|
||||
this.loadMyClans();
|
||||
}
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.loadMyClans();
|
||||
}
|
||||
@@ -131,16 +218,7 @@ export class ClanModal extends BaseModal {
|
||||
|
||||
private renderInner() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderLoadingSpinner()}
|
||||
</div>
|
||||
`;
|
||||
return this.renderLoadingSpinner();
|
||||
}
|
||||
|
||||
if (this.view === "my-requests") {
|
||||
@@ -181,6 +259,7 @@ export class ClanModal extends BaseModal {
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.detailCache = null;
|
||||
this.view = "list";
|
||||
this.loadMyClans();
|
||||
}}
|
||||
@@ -273,6 +352,7 @@ export class ClanModal extends BaseModal {
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.detailCache = null;
|
||||
this.view = "list";
|
||||
this.loadMyClans();
|
||||
}}
|
||||
@@ -289,30 +369,20 @@ export class ClanModal extends BaseModal {
|
||||
></clan-detail-view>`;
|
||||
}
|
||||
|
||||
// List view (tabs + my clans / browse)
|
||||
// List view (my clans / browse) — header + tabs are rendered by o-modal
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderTabs()}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
|
||||
${this.activeTab === "my-clans"
|
||||
? this.renderMyClans()
|
||||
: html`<clan-browse-view
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedState=${this.browseCache}
|
||||
@browse-updated=${(e: CustomEvent<BrowseState>) => {
|
||||
this.browseCache = e.detail;
|
||||
}}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-browse-view>`}
|
||||
</div>
|
||||
</div>
|
||||
${this.activeTab === "my-clans"
|
||||
? this.renderMyClans()
|
||||
: html`<clan-browse-view
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedState=${this.browseCache}
|
||||
@browse-updated=${(e: CustomEvent<BrowseState>) => {
|
||||
this.browseCache = e.detail;
|
||||
}}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-browse-view>`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -321,44 +391,6 @@ export class ClanModal extends BaseModal {
|
||||
this.view = "detail";
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
|
||||
{ key: "browse", label: translateText("clan_modal.browse") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.activeTab = tab.key;
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
if (tab.key === "my-clans") {
|
||||
this.loadMyClans();
|
||||
}
|
||||
}}
|
||||
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
|
||||
${this.activeTab === tab.key
|
||||
? "text-aquarius"
|
||||
: "text-white/40 hover:text-white/70"}"
|
||||
>
|
||||
${tab.label}
|
||||
${this.activeTab === tab.key
|
||||
? html`<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
|
||||
></div>`
|
||||
: ""}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMyClans() {
|
||||
const hasClans = this.myClans.length > 0;
|
||||
const hasRequests = this.myPendingRequests.length > 0;
|
||||
@@ -380,7 +412,7 @@ export class ClanModal extends BaseModal {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="p-4 lg:p-6 space-y-3">
|
||||
<div class="space-y-3">
|
||||
${hasRequests ? this.renderPendingRequestsButton() : ""}
|
||||
${this.myClans.map(
|
||||
(clan) => html`
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getGameLogicConfig } from "../core/configuration/ConfigLoader";
|
||||
import { BuildableUnit, Structures, UnitType } from "../core/game/Game";
|
||||
import {
|
||||
BuildableUnit,
|
||||
PlayerType,
|
||||
Structures,
|
||||
UnitType,
|
||||
} from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import { GameMapLoader } from "../core/game/GameMapLoader";
|
||||
import {
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
DoBreakAllianceEvent,
|
||||
DoGroundAttackEvent,
|
||||
DoRequestAllianceEvent,
|
||||
DoRetaliateAttackEvent,
|
||||
InputHandler,
|
||||
MouseMoveEvent,
|
||||
MouseUpEvent,
|
||||
@@ -237,7 +243,7 @@ async function createClientGame(
|
||||
userSettings,
|
||||
lobbyConfig.gameRecord !== undefined,
|
||||
);
|
||||
let gameMap: TerrainMapData | null = null;
|
||||
let gameMap: TerrainMapData;
|
||||
|
||||
if (terrainLoad) {
|
||||
gameMap = await terrainLoad;
|
||||
@@ -391,6 +397,10 @@ export class ClientGameRunner {
|
||||
DoGroundAttackEvent,
|
||||
this.doGroundAttackUnderCursor.bind(this),
|
||||
);
|
||||
this.eventBus.on(
|
||||
DoRetaliateAttackEvent,
|
||||
this.doRetaliateAttackMostRecent.bind(this),
|
||||
);
|
||||
this.eventBus.on(
|
||||
DoRequestAllianceEvent,
|
||||
this.doRequestAllianceUnderCursor.bind(this),
|
||||
@@ -783,6 +793,41 @@ export class ClientGameRunner {
|
||||
});
|
||||
}
|
||||
|
||||
private doRetaliateAttackMostRecent(): void {
|
||||
if (!this.isActive || this.gameView.inSpawnPhase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) return;
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
|
||||
const incomingAttacks = this.myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (
|
||||
this.gameView.playerBySmallID(a.attackerID) as PlayerView
|
||||
).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
if (incomingAttacks.length === 0) return;
|
||||
|
||||
const mostRecentAttack = incomingAttacks[incomingAttacks.length - 1];
|
||||
|
||||
const attacker = this.gameView.playerBySmallID(
|
||||
mostRecentAttack.attackerID,
|
||||
) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
mostRecentAttack.troops,
|
||||
this.renderer.uiState.attackRatio * this.myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private doRequestAllianceUnderCursor(): void {
|
||||
const tile = this.getTileUnderCursor();
|
||||
if (tile === null) return;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class FlagInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="flag-input"
|
||||
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
|
||||
@@ -120,7 +120,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
|
||||
)}
|
||||
</div>
|
||||
<!-- Create/ranked/join: mobile only, below solo -->
|
||||
@@ -128,19 +128,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
</div>
|
||||
<!-- iOS Add to Home Screen banner -->
|
||||
@@ -192,7 +192,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
|
||||
)}
|
||||
</div>
|
||||
<!-- Bottom row: create + ranked + join (desktop only) -->
|
||||
@@ -200,19 +200,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ export class GameModeSelector extends LitElement {
|
||||
? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset)
|
||||
: undefined;
|
||||
|
||||
let timeDisplay: string = "";
|
||||
let timeDisplay: string;
|
||||
let timeDisplayUppercase = false;
|
||||
if (timeRemaining === undefined) {
|
||||
timeDisplay = renderDuration(this.defaultLobbyTime);
|
||||
|
||||
@@ -153,6 +153,8 @@ export class DoBoatAttackEvent implements GameEvent {}
|
||||
|
||||
export class DoGroundAttackEvent implements GameEvent {}
|
||||
|
||||
export class DoRetaliateAttackEvent implements GameEvent {}
|
||||
|
||||
export class DoRequestAllianceEvent implements GameEvent {}
|
||||
|
||||
export class DoBreakAllianceEvent implements GameEvent {}
|
||||
@@ -496,6 +498,11 @@ export class InputHandler {
|
||||
this.eventBus.emit(new DoGroundAttackEvent());
|
||||
}
|
||||
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.retaliateAttack)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new DoRetaliateAttackEvent());
|
||||
}
|
||||
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) {
|
||||
e.preventDefault();
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
@@ -519,10 +526,12 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
||||
if (matchedBuild !== null) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(matchedBuild);
|
||||
if (this.canUseBuildKeybinds()) {
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
||||
if (matchedBuild !== null) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(matchedBuild);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.requestAlliance)) {
|
||||
@@ -943,6 +952,11 @@ export class InputHandler {
|
||||
return null;
|
||||
}
|
||||
|
||||
private canUseBuildKeybinds(): boolean {
|
||||
const myPlayer = this.gameView.myPlayer?.();
|
||||
return !this.gameView.inSpawnPhase() && myPlayer?.isAlive() === true;
|
||||
}
|
||||
|
||||
private getPinchDistance(): number {
|
||||
const pointerEvents = Array.from(this.pointers.values());
|
||||
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
|
||||
|
||||
@@ -210,7 +210,6 @@ export class LangSelector extends LitElement {
|
||||
"join-lobby-modal",
|
||||
"emoji-table",
|
||||
"leader-board",
|
||||
"leaderboard-tabs",
|
||||
"leaderboard-player-list",
|
||||
"leaderboard-clan-table",
|
||||
"build-menu",
|
||||
|
||||
@@ -5,7 +5,6 @@ import "./components/leaderboard/LeaderboardClanTable";
|
||||
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
|
||||
import "./components/leaderboard/LeaderboardPlayerList";
|
||||
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
|
||||
import "./components/leaderboard/LeaderboardTabs";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -81,46 +80,13 @@ export class LeaderboardModal extends BaseModal {
|
||||
>(${translateText("leaderboard_modal.refresh_time")})</span
|
||||
>`;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
|
||||
>
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<leaderboard-tabs
|
||||
.activeTab=${this.activeTab}
|
||||
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
|
||||
this.handleTabChange(event.detail)}
|
||||
></leaderboard-tabs>
|
||||
<div class="flex-1 min-h-0">
|
||||
<leaderboard-player-list
|
||||
class=${this.activeTab === "players" ? "h-full" : "hidden"}
|
||||
></leaderboard-player-list>
|
||||
<leaderboard-clan-table
|
||||
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
|
||||
@date-range-change=${(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) => this.handleClanDateRangeChange(event)}
|
||||
></leaderboard-clan-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) return content;
|
||||
const tabs = [
|
||||
{
|
||||
key: "players",
|
||||
label: translateText("leaderboard_modal.ranked_tab"),
|
||||
},
|
||||
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -128,8 +94,39 @@ export class LeaderboardModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
this.handleTabChange(key as "players" | "clans")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
|
||||
>
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 h-full">
|
||||
<leaderboard-player-list
|
||||
class=${this.activeTab === "players" ? "h-full" : "hidden"}
|
||||
></leaderboard-player-list>
|
||||
<leaderboard-clan-table
|
||||
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
|
||||
@date-range-change=${(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) => this.handleClanDateRangeChange(event)}
|
||||
></leaderboard-clan-table>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class PatternInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="pattern-input"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
|
||||
@@ -41,43 +41,12 @@ export class StoreModal extends BaseModal {
|
||||
}
|
||||
|
||||
private renderHeader(): TemplateResult {
|
||||
return html`
|
||||
${modalHeader({
|
||||
title: translateText("store.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
})}
|
||||
<div class="flex items-center gap-2 justify-center pt-2">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "packs"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "packs")}
|
||||
>
|
||||
${translateText("store.packs")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("store.patterns")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "flags"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "flags")}
|
||||
>
|
||||
${translateText("store.flags")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return modalHeader({
|
||||
title: translateText("store.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
});
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
@@ -188,22 +157,18 @@ export class StoreModal extends BaseModal {
|
||||
render() {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${this.renderHeader()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.activeTab === "flags"
|
||||
? this.renderFlagGrid()
|
||||
: this.renderPackGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const tabs = [
|
||||
{ key: "packs", label: translateText("store.packs") },
|
||||
{ key: "patterns", label: translateText("store.patterns") },
|
||||
{ key: "flags", label: translateText("store.flags") },
|
||||
];
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
const grid =
|
||||
this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.activeTab === "flags"
|
||||
? this.renderFlagGrid()
|
||||
: this.renderPackGrid();
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -212,8 +177,13 @@ export class StoreModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
(this.activeTab = key as "patterns" | "flags" | "packs")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">${this.renderHeader()}</div>
|
||||
${grid}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -319,51 +319,10 @@ export class UserSettingModal extends BaseModal {
|
||||
? this.renderBasicSettings()
|
||||
: this.renderKeybindSettings();
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("user_setting.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
showDivider: true,
|
||||
})}
|
||||
|
||||
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "basic"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "basic")}
|
||||
>
|
||||
${translateText("user_setting.tab_basic")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "keybinds"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "keybinds")}
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-6 flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
<div class="flex flex-col gap-2">${activeContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
const tabs = [
|
||||
{ key: "basic", label: translateText("user_setting.tab_basic") },
|
||||
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -371,8 +330,22 @@ export class UserSettingModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
(this.activeTab = key as "basic" | "keybinds")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">
|
||||
${modalHeader({
|
||||
title: translateText("user_setting.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
showDivider: true,
|
||||
})}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-4 lg:p-[1.4rem]">
|
||||
${activeContent}
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
@@ -647,6 +620,16 @@ export class UserSettingModal extends BaseModal {
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="retaliateAttack"
|
||||
label=${translateText("user_setting.retaliate_attack")}
|
||||
description=${translateText("user_setting.retaliate_attack_desc")}
|
||||
defaultKey="Shift+KeyR"
|
||||
.value=${this.getKeyValue("retaliateAttack")}
|
||||
.display=${this.getKeyChar("retaliateAttack")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="swapDirection"
|
||||
label=${translateText("user_setting.swap_direction")}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import tailwindStyles from "../../styles.css?inline";
|
||||
|
||||
export type OModalTab = { key: string; label: string };
|
||||
|
||||
@customElement("o-modal")
|
||||
export class OModal extends LitElement {
|
||||
static styles = [unsafeCSS(tailwindStyles)];
|
||||
@@ -28,6 +30,15 @@ export class OModal extends LitElement {
|
||||
@property({ type: String })
|
||||
public maxWidth = "";
|
||||
|
||||
@property({ type: Array })
|
||||
public tabs: OModalTab[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public activeTab = "";
|
||||
|
||||
@property({ attribute: false })
|
||||
public onTabChange?: (key: string) => void;
|
||||
|
||||
public onClose?: () => void;
|
||||
|
||||
public open() {
|
||||
@@ -60,7 +71,48 @@ export class OModal extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleTabClick(key: string) {
|
||||
this.onTabChange?.(key);
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
|
||||
>
|
||||
${this.tabs.map((tab) => {
|
||||
const active = this.activeTab === tab.key;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-key=${tab.key}
|
||||
aria-selected=${active}
|
||||
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative cursor-pointer ${active
|
||||
? "text-aquarius"
|
||||
: "text-white/40 hover:text-white/70"}"
|
||||
@click=${() => this.handleTabClick(tab.key)}
|
||||
>
|
||||
${tab.label}
|
||||
${active
|
||||
? html`<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
|
||||
></div>`
|
||||
: ""}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const shouldRender = this.isModalOpen || this.inline;
|
||||
if (!shouldRender) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const backdropClass = this.inline
|
||||
? "relative z-10 w-full h-full flex items-stretch bg-transparent"
|
||||
: "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden";
|
||||
@@ -73,42 +125,44 @@ export class OModal extends LitElement {
|
||||
const wrapperStyle =
|
||||
!this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : "";
|
||||
|
||||
const hasTabs = this.tabs.length > 0;
|
||||
const sectionClass =
|
||||
"relative flex-1 min-h-0 flex flex-col text-white bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10 overflow-hidden";
|
||||
|
||||
return html`
|
||||
${this.isModalOpen
|
||||
? html`
|
||||
<aside
|
||||
class="${backdropClass}"
|
||||
@click=${this.inline ? null : () => this.close()}
|
||||
>
|
||||
<div
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="${wrapperClass}"
|
||||
style="${wrapperStyle}"
|
||||
<aside
|
||||
class="${backdropClass}"
|
||||
@click=${this.inline ? null : () => this.close()}
|
||||
>
|
||||
<div
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="${wrapperClass}"
|
||||
style="${wrapperStyle}"
|
||||
>
|
||||
${this.inline || this.hideCloseButton
|
||||
? html``
|
||||
: html`<div
|
||||
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
|
||||
@click=${() => this.close()}
|
||||
>
|
||||
${this.inline || this.hideCloseButton
|
||||
? html``
|
||||
: html`<div
|
||||
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
|
||||
@click=${() => this.close()}
|
||||
>
|
||||
✕
|
||||
</div>`}
|
||||
${!this.hideHeader && this.title
|
||||
? html`<div
|
||||
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
|
||||
>
|
||||
${this.title}
|
||||
</div>`
|
||||
: html``}
|
||||
<section
|
||||
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
`
|
||||
: html``}
|
||||
✕
|
||||
</div>`}
|
||||
${!this.hideHeader && this.title
|
||||
? html`<div
|
||||
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
|
||||
>
|
||||
${this.title}
|
||||
</div>`
|
||||
: html``}
|
||||
<section class="${sectionClass}">
|
||||
<slot name="header"></slot>
|
||||
${hasTabs ? this.renderTabs() : html``}
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import { type ClanBan, fetchClanBans, unbanClanMember } from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../CopyButton";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import {
|
||||
formatClanDate,
|
||||
modalContainerClass,
|
||||
renderLoadingSpinner,
|
||||
renderMemberSearchInput,
|
||||
renderServerPagination,
|
||||
@@ -92,20 +90,7 @@ export class ClanBansView extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading)
|
||||
return html`<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.banned_players"),
|
||||
onBack: () =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}${renderLoadingSpinner()}
|
||||
</div>`;
|
||||
if (this.loading) return renderLoadingSpinner();
|
||||
|
||||
const totalPages = Math.ceil(this.bansTotal / this.bansLimit);
|
||||
const filtered = this.memberSearch
|
||||
@@ -115,110 +100,98 @@ export class ClanBansView extends LitElement {
|
||||
: this.bans;
|
||||
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.banned_players"),
|
||||
onBack: () =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
|
||||
>${this.bansTotal}</span
|
||||
>`,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
"clan_modal.search_members_placeholder",
|
||||
)}
|
||||
${filtered.length === 0
|
||||
? html`<div
|
||||
class="flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.no_bans")}
|
||||
</p>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${filtered.map(
|
||||
(ban) => html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-4 h-4 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.publicId}
|
||||
.displayText=${ban.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${translateText(
|
||||
"clan_modal.banned_by_label",
|
||||
)}</span
|
||||
>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.bannedBy}
|
||||
.displayText=${ban.bannedBy}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${formatClanDate(ban.createdAt)}</span
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
@click=${() => this.handleUnban(ban.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none shrink-0"
|
||||
>
|
||||
${translateText("clan_modal.unban")}
|
||||
</button>
|
||||
</div>
|
||||
${ban.reason
|
||||
? html`<div class="text-white/50 text-xs pl-10">
|
||||
${translateText("clan_modal.ban_reason", {
|
||||
reason: ban.reason,
|
||||
})}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${totalPages > 1
|
||||
? renderServerPagination(this.bansPage, totalPages, (p) =>
|
||||
this.loadBans(p, false),
|
||||
)
|
||||
: ""}
|
||||
`}
|
||||
<div>
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
|
||||
>
|
||||
${translateText("clan_modal.banned_players_count", {
|
||||
count: this.bansTotal,
|
||||
})}
|
||||
</div>
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
"clan_modal.search_members_placeholder",
|
||||
)}
|
||||
${filtered.length === 0
|
||||
? html`<div
|
||||
class="flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.no_bans")}
|
||||
</p>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${filtered.map(
|
||||
(ban) => html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-4 h-4 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.publicId}
|
||||
.displayText=${ban.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${translateText("clan_modal.banned_by_label")}</span
|
||||
>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.bannedBy}
|
||||
.displayText=${ban.bannedBy}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${formatClanDate(ban.createdAt)}</span
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
@click=${() => this.handleUnban(ban.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none shrink-0"
|
||||
>
|
||||
${translateText("clan_modal.unban")}
|
||||
</button>
|
||||
</div>
|
||||
${ban.reason
|
||||
? html`<div class="text-white/50 text-xs pl-10">
|
||||
${translateText("clan_modal.ban_reason", {
|
||||
reason: ban.reason,
|
||||
})}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${totalPages > 1
|
||||
? renderServerPagination(this.bansPage, totalPages, (p) =>
|
||||
this.loadBans(p, false),
|
||||
)
|
||||
: ""}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -90,8 +90,7 @@ export class ClanBrowseView extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading && !this.browseData)
|
||||
return html`<div class="p-4 lg:p-6">${renderLoadingSpinner()}</div>`;
|
||||
if (this.loading && !this.browseData) return renderLoadingSpinner();
|
||||
|
||||
const totalPages = this.browseData
|
||||
? Math.ceil(this.browseData.total / this.browseData.limit)
|
||||
@@ -102,7 +101,7 @@ export class ClanBrowseView extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="p-4 lg:p-6 space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -16,12 +16,10 @@ import {
|
||||
import { translateText } from "../../Utils";
|
||||
import "../ConfirmDialog";
|
||||
import "../CopyButton";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import {
|
||||
type ClanRole,
|
||||
defaultOrderForSort,
|
||||
filterMembersBySearch,
|
||||
modalContainerClass,
|
||||
renderClanWL,
|
||||
renderLoadingSpinner,
|
||||
renderMemberPagination,
|
||||
@@ -31,6 +29,7 @@ import {
|
||||
renderStat,
|
||||
showToast,
|
||||
} from "./ClanShared";
|
||||
import { ClanStatsBreakdown } from "./ClanStatsBreakdown";
|
||||
|
||||
@customElement("clan-detail-view")
|
||||
export class ClanDetailView extends LitElement {
|
||||
@@ -67,6 +66,7 @@ export class ClanDetailView extends LitElement {
|
||||
@state() private clanStats: ClanStats | null = null;
|
||||
@state() private loading = false;
|
||||
@state() private actionPending = false;
|
||||
@state() private allStatsExpanded = false;
|
||||
private memberSearch = "";
|
||||
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
private asyncGeneration = 0;
|
||||
@@ -96,6 +96,14 @@ export class ClanDetailView extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
if (this.allStatsExpanded) {
|
||||
this.querySelectorAll<ClanStatsBreakdown>("clan-stats-breakdown").forEach(
|
||||
(el) => el.setAllExpanded(true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDetail() {
|
||||
const gen = ++this.asyncGeneration;
|
||||
this.loading = true;
|
||||
@@ -282,16 +290,7 @@ export class ClanDetailView extends LitElement {
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${renderLoadingSpinner()}
|
||||
</div>
|
||||
`;
|
||||
return renderLoadingSpinner();
|
||||
}
|
||||
|
||||
const clan = this.selectedClan;
|
||||
@@ -306,58 +305,40 @@ export class ClanDetailView extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: clan.name,
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
|
||||
>
|
||||
[${clan.tag}]
|
||||
</span>
|
||||
`,
|
||||
})}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
|
||||
<p class="text-white/70 text-sm">
|
||||
${clan.description || translateText("clan_modal.no_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
|
||||
<p class="text-white/70 text-sm">
|
||||
${clan.description ||
|
||||
translateText("clan_modal.no_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
${renderStat(
|
||||
translateText("clan_modal.members"),
|
||||
`${clan.memberCount ?? 0}`,
|
||||
)}
|
||||
${renderStat(
|
||||
translateText("clan_modal.status"),
|
||||
clan.isOpen
|
||||
? translateText("clan_modal.open")
|
||||
: translateText("clan_modal.invite_only"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
${renderStat(
|
||||
translateText("clan_modal.members"),
|
||||
`${clan.memberCount ?? 0}`,
|
||||
)}
|
||||
${renderStat(
|
||||
translateText("clan_modal.status"),
|
||||
clan.isOpen
|
||||
? translateText("clan_modal.open")
|
||||
: translateText("clan_modal.invite_only"),
|
||||
)}
|
||||
</div>
|
||||
${this.clanStats ? renderClanWL(this.clanStats) : ""}
|
||||
${canManageRequests && this.pendingRequestCount > 0
|
||||
? this.renderRequestsButton()
|
||||
: ""}
|
||||
${isMember ? this.renderMembersList() : ""}
|
||||
|
||||
${this.clanStats ? renderClanWL(this.clanStats) : ""}
|
||||
${canManageRequests && this.pendingRequestCount > 0
|
||||
? this.renderRequestsButton()
|
||||
: ""}
|
||||
${isMember ? this.renderMembersList() : ""}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
${this.renderActionButtons(
|
||||
isMember,
|
||||
isLeader,
|
||||
isOfficer,
|
||||
hasPendingRequest,
|
||||
clan,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
${this.renderActionButtons(
|
||||
isMember,
|
||||
isLeader,
|
||||
isOfficer,
|
||||
hasPendingRequest,
|
||||
clan,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -430,13 +411,37 @@ export class ClanDetailView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private toggleAllStats() {
|
||||
this.allStatsExpanded = !this.allStatsExpanded;
|
||||
const target = this.allStatsExpanded;
|
||||
this.querySelectorAll<ClanStatsBreakdown>("clan-stats-breakdown").forEach(
|
||||
(el) => el.setAllExpanded(target),
|
||||
);
|
||||
}
|
||||
|
||||
private renderMembersList() {
|
||||
const filtered = filterMembersBySearch(this.members, this.memberSearch);
|
||||
const toggleLabel = translateText(
|
||||
this.allStatsExpanded
|
||||
? "clan_modal.stats_collapse_all"
|
||||
: "clan_modal.stats_expand_all",
|
||||
);
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.members")}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.members")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => this.toggleAllStats()}
|
||||
class="text-[10px] font-bold text-white/50 hover:text-white uppercase tracking-wider px-2 py-1 rounded-md border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
|
||||
title=${toggleLabel}
|
||||
aria-pressed=${this.allStatsExpanded}
|
||||
>
|
||||
${toggleLabel}
|
||||
</button>
|
||||
</div>
|
||||
${renderMemberSearchInput(
|
||||
(e: Event) => this.onSearchInput(e),
|
||||
undefined,
|
||||
@@ -531,10 +536,4 @@ export class ClanDetailView extends LitElement {
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,11 @@ import {
|
||||
import { translateText } from "../../Utils";
|
||||
import "../ConfirmDialog";
|
||||
import "../CopyButton";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import {
|
||||
type ClanRole,
|
||||
defaultOrderForSort,
|
||||
filterMembersBySearch,
|
||||
formatClanDate,
|
||||
modalContainerClass,
|
||||
renderLoadingSpinner,
|
||||
renderMemberPagination,
|
||||
renderMemberSearchInput,
|
||||
@@ -262,21 +260,8 @@ export class ClanManageView extends LitElement {
|
||||
this.loadMembers(1);
|
||||
}
|
||||
|
||||
private navigateDetail = () =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-detail", { bubbles: true, composed: true }),
|
||||
);
|
||||
|
||||
render() {
|
||||
if (this.loading)
|
||||
return html`<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.manage_clan"),
|
||||
onBack: this.navigateDetail,
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${renderLoadingSpinner()}
|
||||
</div>`;
|
||||
if (this.loading) return renderLoadingSpinner();
|
||||
|
||||
const clan = this.selectedClan;
|
||||
if (!clan) return "";
|
||||
@@ -335,187 +320,169 @@ export class ClanManageView extends LitElement {
|
||||
|
||||
private renderManageContent(clan: ClanInfo) {
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.manage_clan"),
|
||||
onBack: this.navigateDetail,
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
|
||||
>[${clan.tag}]</span
|
||||
>`,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Edit Settings -->
|
||||
<div
|
||||
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-5"
|
||||
<div class="space-y-6">
|
||||
<!-- Edit Settings -->
|
||||
<div
|
||||
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-5"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.clan_settings")}
|
||||
</h3>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
|
||||
>${translateText("clan_modal.clan_name")}</label
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-bold text-white/60 uppercase tracking-wider"
|
||||
>
|
||||
${translateText("clan_modal.clan_settings")}
|
||||
</h3>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
|
||||
>${translateText("clan_modal.clan_name")}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.manageName}
|
||||
@input=${(e: Event) =>
|
||||
(this.manageName = (e.target as HTMLInputElement).value)}
|
||||
maxlength="35"
|
||||
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
|
||||
>${translateText("clan_modal.description")}</label
|
||||
>
|
||||
<textarea
|
||||
.value=${this.manageDescription}
|
||||
@input=${(e: Event) =>
|
||||
(this.manageDescription = (
|
||||
e.target as HTMLTextAreaElement
|
||||
).value)}
|
||||
maxlength="200"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-white text-sm font-bold">
|
||||
${translateText("clan_modal.open_clan")}
|
||||
</div>
|
||||
<div class="text-white/40 text-xs">
|
||||
${translateText("clan_modal.open_clan_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked="${this.manageIsOpen}"
|
||||
aria-label="${translateText("clan_modal.open_clan")}"
|
||||
@click=${() => (this.manageIsOpen = !this.manageIsOpen)}
|
||||
class="relative w-12 h-7 rounded-full transition-all ${this
|
||||
.manageIsOpen
|
||||
? "bg-malibu-blue"
|
||||
: "bg-white/20"}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-all ${this
|
||||
.manageIsOpen
|
||||
? "left-6"
|
||||
: "left-1"}"
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click=${() => this.handleSaveSettings()}
|
||||
?disabled=${this.saving}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50"
|
||||
>
|
||||
${this.saving
|
||||
? translateText("clan_modal.saving")
|
||||
: translateText("clan_modal.save_changes")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member Management -->
|
||||
<div
|
||||
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-4"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-bold text-white/60 uppercase tracking-wider"
|
||||
>
|
||||
${translateText("clan_modal.members")}
|
||||
(${clan.memberCount ?? 0})
|
||||
</h3>
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
undefined,
|
||||
renderMemberSortControl(
|
||||
this.memberSort,
|
||||
this.memberOrder,
|
||||
(s) => this.onSortChange(s),
|
||||
() => this.onOrderToggle(),
|
||||
),
|
||||
)}
|
||||
${(() => {
|
||||
const filtered = filterMembersBySearch(
|
||||
this.members,
|
||||
this.memberSearch,
|
||||
);
|
||||
return html`
|
||||
<div class="space-y-2">
|
||||
${filtered.map((m) => this.renderManageMemberRow(m))}
|
||||
</div>
|
||||
${renderMemberPagination(
|
||||
this.memberPage,
|
||||
this.membersTotal,
|
||||
this.membersPerPage,
|
||||
(p) => this.loadMembers(p),
|
||||
(pp) => {
|
||||
this.membersPerPage = pp;
|
||||
this.loadMembers(1);
|
||||
},
|
||||
)}
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div
|
||||
class="bg-red-500/5 rounded-2xl border border-red-500/20 p-6 space-y-4"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-bold text-red-400/80 uppercase tracking-wider"
|
||||
>
|
||||
${translateText("clan_modal.danger_zone")}
|
||||
</h3>
|
||||
<button
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-bans", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30"
|
||||
>
|
||||
${translateText("clan_modal.banned_players")}
|
||||
</button>
|
||||
${this.myRole === "leader"
|
||||
? html`
|
||||
<button
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-transfer", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-amber-400 uppercase tracking-wider bg-amber-600/20 hover:bg-amber-600/30 rounded-xl transition-all border border-amber-500/30"
|
||||
>
|
||||
${translateText("clan_modal.transfer_leadership")}
|
||||
</button>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.confirmAction = "disband";
|
||||
this.confirmTargetId = null;
|
||||
}}
|
||||
?disabled=${this.confirmAction === "disband"}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.disband_clan")}
|
||||
</button>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.manageName}
|
||||
@input=${(e: Event) =>
|
||||
(this.manageName = (e.target as HTMLInputElement).value)}
|
||||
maxlength="35"
|
||||
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
|
||||
>${translateText("clan_modal.description")}</label
|
||||
>
|
||||
<textarea
|
||||
.value=${this.manageDescription}
|
||||
@input=${(e: Event) =>
|
||||
(this.manageDescription = (
|
||||
e.target as HTMLTextAreaElement
|
||||
).value)}
|
||||
maxlength="200"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-white text-sm font-bold">
|
||||
${translateText("clan_modal.open_clan")}
|
||||
</div>
|
||||
<div class="text-white/40 text-xs">
|
||||
${translateText("clan_modal.open_clan_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked="${this.manageIsOpen}"
|
||||
aria-label="${translateText("clan_modal.open_clan")}"
|
||||
@click=${() => (this.manageIsOpen = !this.manageIsOpen)}
|
||||
class="relative w-12 h-7 rounded-full transition-all ${this
|
||||
.manageIsOpen
|
||||
? "bg-malibu-blue"
|
||||
: "bg-white/20"}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-all ${this
|
||||
.manageIsOpen
|
||||
? "left-6"
|
||||
: "left-1"}"
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click=${() => this.handleSaveSettings()}
|
||||
?disabled=${this.saving}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50"
|
||||
>
|
||||
${this.saving
|
||||
? translateText("clan_modal.saving")
|
||||
: translateText("clan_modal.save_changes")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member Management -->
|
||||
<div
|
||||
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-4"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.members")} (${clan.memberCount ?? 0})
|
||||
</h3>
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
undefined,
|
||||
renderMemberSortControl(
|
||||
this.memberSort,
|
||||
this.memberOrder,
|
||||
(s) => this.onSortChange(s),
|
||||
() => this.onOrderToggle(),
|
||||
),
|
||||
)}
|
||||
${(() => {
|
||||
const filtered = filterMembersBySearch(
|
||||
this.members,
|
||||
this.memberSearch,
|
||||
);
|
||||
return html`
|
||||
<div class="space-y-2">
|
||||
${filtered.map((m) => this.renderManageMemberRow(m))}
|
||||
</div>
|
||||
${renderMemberPagination(
|
||||
this.memberPage,
|
||||
this.membersTotal,
|
||||
this.membersPerPage,
|
||||
(p) => this.loadMembers(p),
|
||||
(pp) => {
|
||||
this.membersPerPage = pp;
|
||||
this.loadMembers(1);
|
||||
},
|
||||
)}
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div
|
||||
class="bg-red-500/5 rounded-2xl border border-red-500/20 p-6 space-y-4"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-bold text-red-400/80 uppercase tracking-wider"
|
||||
>
|
||||
${translateText("clan_modal.danger_zone")}
|
||||
</h3>
|
||||
<button
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-bans", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30"
|
||||
>
|
||||
${translateText("clan_modal.banned_players")}
|
||||
</button>
|
||||
${this.myRole === "leader"
|
||||
? html`
|
||||
<button
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-transfer", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-amber-400 uppercase tracking-wider bg-amber-600/20 hover:bg-amber-600/30 rounded-xl transition-all border border-amber-500/30"
|
||||
>
|
||||
${translateText("clan_modal.transfer_leadership")}
|
||||
</button>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.confirmAction = "disband";
|
||||
this.confirmTargetId = null;
|
||||
}}
|
||||
?disabled=${this.confirmAction === "disband"}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.disband_clan")}
|
||||
</button>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import { invalidateUserMe } from "../../Api";
|
||||
import { withdrawClanRequest } from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import { formatClanDate, modalContainerClass, showToast } from "./ClanShared";
|
||||
import { formatClanDate, showToast } from "./ClanShared";
|
||||
|
||||
@customElement("clan-my-requests-view")
|
||||
export class ClanMyRequestsView extends LitElement {
|
||||
@@ -45,60 +44,47 @@ export class ClanMyRequestsView extends LitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.pending_applications"),
|
||||
onBack: () =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
${this.myPendingRequests.length === 0
|
||||
? html`<p class="text-white/40 text-sm text-center py-8">
|
||||
${translateText("clan_modal.no_pending_applications")}
|
||||
</p>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${this.myPendingRequests.map(
|
||||
(req) => html`
|
||||
<div>
|
||||
${this.myPendingRequests.length === 0
|
||||
? html`<p class="text-white/40 text-sm text-center py-8">
|
||||
${translateText("clan_modal.no_pending_applications")}
|
||||
</p>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${this.myPendingRequests.map(
|
||||
(req) => html`
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
|
||||
class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 shrink-0"
|
||||
<span class="text-amber-400 font-bold text-xs"
|
||||
>${req.tag}</span
|
||||
>
|
||||
<span class="text-amber-400 font-bold text-xs"
|
||||
>${req.tag}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span
|
||||
class="text-white font-bold text-sm truncate block"
|
||||
>${req.name}</span
|
||||
>
|
||||
<span class="text-white/30 text-xs">
|
||||
${translateText("clan_modal.applied")}
|
||||
${formatClanDate(req.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click=${() => this.handleWithdrawRequest(req.tag)}
|
||||
?disabled=${this.actionPending}
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-red-500/15 text-red-400 border border-red-500/20 hover:bg-red-500/25 transition-all cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.cancel_request")}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span
|
||||
class="text-white font-bold text-sm truncate block"
|
||||
>${req.name}</span
|
||||
>
|
||||
<span class="text-white/30 text-xs">
|
||||
${translateText("clan_modal.applied")}
|
||||
${formatClanDate(req.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click=${() => this.handleWithdrawRequest(req.tag)}
|
||||
?disabled=${this.actionPending}
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-red-500/15 text-red-400 border border-red-500/20 hover:bg-red-500/25 transition-all cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.cancel_request")}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
} from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../CopyButton";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import {
|
||||
filterRequestsBySearch,
|
||||
formatClanDate,
|
||||
modalContainerClass,
|
||||
renderLoadingSpinner,
|
||||
renderMemberSearchInput,
|
||||
renderServerPagination,
|
||||
@@ -122,97 +120,80 @@ export class ClanRequestsView extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading)
|
||||
return html`<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.join_requests"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}${renderLoadingSpinner()}
|
||||
</div>`;
|
||||
if (this.loading) return renderLoadingSpinner();
|
||||
|
||||
const totalPages = Math.ceil(this.requestsTotal / this.requestsLimit);
|
||||
const filtered = filterRequestsBySearch(this.requests, this.memberSearch);
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.join_requests"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
|
||||
>${this.requestsTotal}</span
|
||||
>`,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
"clan_modal.search_requests_placeholder",
|
||||
)}
|
||||
${filtered.length === 0
|
||||
? html`<div
|
||||
class="flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.no_requests")}
|
||||
</p>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${filtered.map(
|
||||
(req) => html`
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${req.publicId}
|
||||
.displayText=${req.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-[10px]">
|
||||
${translateText("clan_modal.requested_on", {
|
||||
tag: this.selectedClan?.tag ?? this.clanTag,
|
||||
date: formatClanDate(req.createdAt),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click=${() => this.handleApprove(req.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.approve")}
|
||||
</button>
|
||||
<button
|
||||
@click=${() => this.handleDeny(req.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.deny")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${totalPages > 1
|
||||
? renderServerPagination(this.requestsPage, totalPages, (p) =>
|
||||
this.loadRequests(p, false),
|
||||
)
|
||||
: ""}
|
||||
`}
|
||||
<div>
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
|
||||
>
|
||||
${translateText("clan_modal.pending_requests_count", {
|
||||
count: this.requestsTotal,
|
||||
})}
|
||||
</div>
|
||||
${renderMemberSearchInput(
|
||||
(e) => this.onSearchInput(e),
|
||||
"clan_modal.search_requests_placeholder",
|
||||
)}
|
||||
${filtered.length === 0
|
||||
? html`<div
|
||||
class="flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.no_requests")}
|
||||
</p>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
${filtered.map(
|
||||
(req) => html`
|
||||
<div
|
||||
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${req.publicId}
|
||||
.displayText=${req.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<span class="text-white/30 text-[10px]">
|
||||
${translateText("clan_modal.requested_on", {
|
||||
tag: this.selectedClan?.tag ?? this.clanTag,
|
||||
date: formatClanDate(req.createdAt),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click=${() => this.handleApprove(req.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.approve")}
|
||||
</button>
|
||||
<button
|
||||
@click=${() => this.handleDeny(req.publicId)}
|
||||
?disabled=${this.memberActionPending}
|
||||
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
${translateText("clan_modal.deny")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
${totalPages > 1
|
||||
? renderServerPagination(this.requestsPage, totalPages, (p) =>
|
||||
this.loadRequests(p, false),
|
||||
)
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ClanStats,
|
||||
} from "../../ClanApi";
|
||||
import { showToast, translateText } from "../../Utils";
|
||||
import "./ClanStatsBreakdown";
|
||||
export { renderLoadingSpinner } from "../BaseModal";
|
||||
export { showToast };
|
||||
|
||||
@@ -17,9 +18,6 @@ export function defaultOrderForSort(sort: ClanMemberSort): ClanMemberOrder {
|
||||
return sort === "default" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
export const modalContainerClass =
|
||||
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
|
||||
|
||||
const dateCache = new Map<string, string>();
|
||||
|
||||
export function formatClanDate(iso: string): string {
|
||||
@@ -91,15 +89,7 @@ export function renderClanWL(stats: ClanStats): TemplateResult | string {
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.statistics")}
|
||||
</h3>
|
||||
<div class="space-y-1.5">
|
||||
${statBuckets.map(({ key, labelKey }) =>
|
||||
renderWLBarRow(
|
||||
translateText(labelKey),
|
||||
stats.stats[key].wins,
|
||||
stats.stats[key].losses,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<clan-stats-breakdown .stats=${stats.stats}></clan-stats-breakdown>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -329,16 +319,7 @@ export function renderMemberPagination(
|
||||
`;
|
||||
}
|
||||
|
||||
const statBuckets = [
|
||||
{ key: "total" as const, labelKey: "clan_modal.stats_total" },
|
||||
{ key: "ffa" as const, labelKey: "clan_modal.stats_ffa" },
|
||||
{ key: "team" as const, labelKey: "clan_modal.stats_team" },
|
||||
{ key: "hvn" as const, labelKey: "clan_modal.stats_hvn" },
|
||||
{ key: "ranked" as const, labelKey: "clan_modal.stats_ranked" },
|
||||
{ key: "1v1" as const, labelKey: "clan_modal.stats_1v1" },
|
||||
];
|
||||
|
||||
function renderWLBarRow(
|
||||
export function renderWLBarRow(
|
||||
label: string,
|
||||
wins: number,
|
||||
losses: number,
|
||||
@@ -362,26 +343,30 @@ function renderWLBarRow(
|
||||
${label}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 flex h-5 rounded-md overflow-hidden bg-white/5 text-[11px] font-bold text-white tabular-nums"
|
||||
class="relative flex-1 h-5 rounded-md overflow-hidden bg-white/5"
|
||||
role="img"
|
||||
aria-label="${wins} wins, ${losses} losses"
|
||||
>
|
||||
${wins > 0
|
||||
? html`<div
|
||||
class="bg-malibu-blue flex items-center px-1.5 overflow-hidden whitespace-nowrap"
|
||||
style="width:${winPct}%"
|
||||
>
|
||||
${wins}W
|
||||
</div>`
|
||||
: ""}
|
||||
${losses > 0
|
||||
? html`<div
|
||||
class="bg-red-500 flex items-center justify-end px-1.5 overflow-hidden whitespace-nowrap"
|
||||
style="width:${lossPct}%"
|
||||
>
|
||||
${losses}L
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="absolute inset-0 flex">
|
||||
${wins > 0
|
||||
? html`<div
|
||||
class="bg-malibu-blue h-full"
|
||||
style="width:${winPct}%"
|
||||
></div>`
|
||||
: ""}
|
||||
${losses > 0
|
||||
? html`<div
|
||||
class="bg-red-500 h-full"
|
||||
style="width:${lossPct}%"
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 text-[11px] font-bold text-white tabular-nums whitespace-nowrap pointer-events-none"
|
||||
>
|
||||
<span>${wins > 0 ? `${wins}W` : ""}</span>
|
||||
<span>${losses > 0 ? `${losses}L` : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
|
||||
@@ -397,14 +382,8 @@ export function renderMemberStats(
|
||||
): TemplateResult | string {
|
||||
if (!stats) return "";
|
||||
return html`
|
||||
<div class="mt-1.5 space-y-1">
|
||||
${statBuckets.map(({ key, labelKey }) =>
|
||||
renderWLBarRow(
|
||||
translateText(labelKey),
|
||||
stats[key].wins,
|
||||
stats[key].losses,
|
||||
),
|
||||
)}
|
||||
<div class="mt-1.5">
|
||||
<clan-stats-breakdown .stats=${stats}></clan-stats-breakdown>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
RANKED_BREAKDOWN_KEYS,
|
||||
TEAM_BREAKDOWN_KEYS,
|
||||
type ClanMemberStats,
|
||||
type ClanMemberWL,
|
||||
} from "../../../core/ClanApiSchemas";
|
||||
import { translateText } from "../../Utils";
|
||||
import { renderWLBarRow } from "./ClanShared";
|
||||
|
||||
type SubKey =
|
||||
| (typeof TEAM_BREAKDOWN_KEYS)[number]
|
||||
| (typeof RANKED_BREAKDOWN_KEYS)[number];
|
||||
|
||||
const LEVEL_LEFT_PAD: Record<0 | 1 | 2, string> = {
|
||||
0: "pl-1.5",
|
||||
1: "pl-5",
|
||||
2: "pl-9",
|
||||
};
|
||||
|
||||
function labelForSubKey(key: SubKey): string {
|
||||
switch (key) {
|
||||
case "duos":
|
||||
return translateText("clan_modal.stats_duos");
|
||||
case "trios":
|
||||
return translateText("clan_modal.stats_trios");
|
||||
case "quads":
|
||||
return translateText("clan_modal.stats_quads");
|
||||
case "1v1":
|
||||
return translateText("clan_modal.stats_1v1");
|
||||
default:
|
||||
return translateText("clan_modal.stats_team_count", { count: key });
|
||||
}
|
||||
}
|
||||
|
||||
function hasGames(wl: ClanMemberWL): boolean {
|
||||
return wl.wins > 0 || wl.losses > 0;
|
||||
}
|
||||
|
||||
@customElement("clan-stats-breakdown")
|
||||
export class ClanStatsBreakdown extends LitElement {
|
||||
@property({ type: Object }) stats!: ClanMemberStats;
|
||||
@state() private expandedTotal = false;
|
||||
@state() private expandedTeam = false;
|
||||
@state() private expandedRanked = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private get teamSubKeys(): readonly (typeof TEAM_BREAKDOWN_KEYS)[number][] {
|
||||
return TEAM_BREAKDOWN_KEYS.filter((k) => hasGames(this.stats[k]));
|
||||
}
|
||||
|
||||
private get rankedSubKeys(): readonly (typeof RANKED_BREAKDOWN_KEYS)[number][] {
|
||||
return RANKED_BREAKDOWN_KEYS.filter((k) => hasGames(this.stats[k]));
|
||||
}
|
||||
|
||||
public setAllExpanded(expanded: boolean) {
|
||||
this.expandedTotal = expanded;
|
||||
this.expandedTeam = expanded;
|
||||
this.expandedRanked = expanded;
|
||||
}
|
||||
|
||||
private toggleTotal = () => {
|
||||
this.expandedTotal = !this.expandedTotal;
|
||||
};
|
||||
|
||||
private toggleTeam = () => {
|
||||
this.expandedTeam = !this.expandedTeam;
|
||||
};
|
||||
|
||||
private toggleRanked = () => {
|
||||
this.expandedRanked = !this.expandedRanked;
|
||||
};
|
||||
|
||||
private renderRow(
|
||||
label: string,
|
||||
wl: ClanMemberWL,
|
||||
level: 0 | 1 | 2,
|
||||
expand?: { expanded: boolean; onToggle: () => void; disabled: boolean },
|
||||
): TemplateResult {
|
||||
const row = renderWLBarRow(label, wl.wins, wl.losses);
|
||||
const toggleVisible = !!expand && !expand.disabled;
|
||||
const toggleIcon = html`
|
||||
<span
|
||||
class="w-3 h-3 shrink-0 flex items-center justify-center text-white/40 transition-transform duration-150
|
||||
${expand?.expanded ? "rotate-90" : ""}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${toggleVisible
|
||||
? html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-2.5 h-2.5"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>`
|
||||
: ""}
|
||||
</span>
|
||||
`;
|
||||
const padding = `${LEVEL_LEFT_PAD[level]} pr-1.5 py-0.5`;
|
||||
if (!expand || expand.disabled) {
|
||||
return html`
|
||||
<div class="flex items-center gap-2 ${padding}">
|
||||
${toggleIcon}
|
||||
<div class="flex-1 min-w-0">${row}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const title = translateText(
|
||||
expand.expanded ? "clan_modal.stats_collapse" : "clan_modal.stats_expand",
|
||||
);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 ${padding} text-left rounded-md transition-colors cursor-pointer
|
||||
hover:bg-white/10 focus-visible:bg-white/10 focus:outline-none
|
||||
${expand.expanded ? "bg-white/5" : ""}"
|
||||
@click=${expand.onToggle}
|
||||
title=${title}
|
||||
aria-expanded=${expand.expanded}
|
||||
>
|
||||
${toggleIcon}
|
||||
<div class="flex-1 min-w-0">${row}</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.stats) return html``;
|
||||
const teamKeys = this.teamSubKeys;
|
||||
const rankedKeys = this.rankedSubKeys;
|
||||
return html`
|
||||
<div class="space-y-0">
|
||||
${this.renderRow(
|
||||
translateText("clan_modal.stats_total"),
|
||||
this.stats.total,
|
||||
0,
|
||||
{
|
||||
expanded: this.expandedTotal,
|
||||
onToggle: this.toggleTotal,
|
||||
disabled: false,
|
||||
},
|
||||
)}
|
||||
${this.expandedTotal
|
||||
? html`
|
||||
${this.renderRow(
|
||||
translateText("clan_modal.stats_ffa"),
|
||||
this.stats.ffa,
|
||||
1,
|
||||
)}
|
||||
${this.renderRow(
|
||||
translateText("clan_modal.stats_team"),
|
||||
this.stats.team,
|
||||
1,
|
||||
{
|
||||
expanded: this.expandedTeam,
|
||||
onToggle: this.toggleTeam,
|
||||
disabled: teamKeys.length === 0,
|
||||
},
|
||||
)}
|
||||
${this.expandedTeam
|
||||
? html`${teamKeys.map((k) =>
|
||||
this.renderRow(labelForSubKey(k), this.stats[k], 2),
|
||||
)}`
|
||||
: ""}
|
||||
${this.renderRow(
|
||||
translateText("clan_modal.stats_hvn"),
|
||||
this.stats.hvn,
|
||||
1,
|
||||
)}
|
||||
${this.renderRow(
|
||||
translateText("clan_modal.stats_ranked"),
|
||||
this.stats.ranked,
|
||||
1,
|
||||
{
|
||||
expanded: this.expandedRanked,
|
||||
onToggle: this.toggleRanked,
|
||||
disabled: rankedKeys.length === 0,
|
||||
},
|
||||
)}
|
||||
${this.expandedRanked
|
||||
? html`${rankedKeys.map((k) =>
|
||||
this.renderRow(labelForSubKey(k), this.stats[k], 2),
|
||||
)}`
|
||||
: ""}
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
import { translateText } from "../../Utils";
|
||||
import "../ConfirmDialog";
|
||||
import "../CopyButton";
|
||||
import { modalHeader } from "../ui/ModalHeader";
|
||||
import {
|
||||
filterMembersBySearch,
|
||||
modalContainerClass,
|
||||
renderLoadingSpinner,
|
||||
renderMemberSearchInput,
|
||||
renderRoleIcon,
|
||||
@@ -108,14 +106,7 @@ export class ClanTransferView extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading)
|
||||
return html`<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.transfer_leadership"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}${renderLoadingSpinner()}
|
||||
</div>`;
|
||||
if (this.loading) return renderLoadingSpinner();
|
||||
|
||||
const nonLeaders = this.members.filter(
|
||||
(m: ClanMember) => m.role !== "leader",
|
||||
@@ -148,111 +139,94 @@ export class ClanTransferView extends LitElement {
|
||||
|
||||
private renderContent(nonLeaders: ClanMember[], totalMemberPages: number) {
|
||||
return html`
|
||||
<div class="${modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.transfer_leadership"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
|
||||
<div class="space-y-6">
|
||||
${this.errorMsg
|
||||
? html`<p class="text-red-400 text-sm">${this.errorMsg}</p>`
|
||||
: ""}
|
||||
<div class="space-y-6">
|
||||
${this.errorMsg
|
||||
? html`<p class="text-red-400 text-sm">${this.errorMsg}</p>`
|
||||
: ""}
|
||||
|
||||
<div
|
||||
class="bg-amber-500/10 rounded-xl border border-amber-500/20 p-4"
|
||||
>
|
||||
<p class="text-amber-400/80 text-sm">
|
||||
${translateText("clan_modal.transfer_warning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${renderMemberSearchInput((e) => this.onSearchInput(e))}
|
||||
|
||||
<div class="space-y-2">
|
||||
${filterMembersBySearch(nonLeaders, this.memberSearch).map(
|
||||
(m) => html`
|
||||
<button
|
||||
@click=${() => (this.transferTarget = m.publicId)}
|
||||
class="w-full flex items-center gap-3 py-2.5 px-3 rounded-xl border cursor-pointer transition-all text-left focus:outline-none focus:ring-2 focus:ring-amber-500/50
|
||||
${this.transferTarget === m.publicId
|
||||
? "bg-amber-500/10 border-amber-500/20"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"}"
|
||||
aria-selected=${this.transferTarget === m.publicId}
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-xs font-bold shrink-0"
|
||||
>
|
||||
${renderRoleIcon(m.role)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${m.publicId}
|
||||
.displayText=${m.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
|
||||
${m.role === "officer"
|
||||
? "bg-purple-500/20 text-purple-400 border border-purple-500/30"
|
||||
: "bg-white/10 text-white/40 border border-white/10"}"
|
||||
>
|
||||
${translateClanRole(m.role)}
|
||||
</span>
|
||||
${this.transferTarget === m.publicId
|
||||
? html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-amber-400 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>`
|
||||
: ""}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${totalMemberPages > 1
|
||||
? renderServerPagination(this.memberPage, totalMemberPages, (p) =>
|
||||
this.loadMembers(p),
|
||||
)
|
||||
: ""}
|
||||
|
||||
<button
|
||||
@click=${() => (this.confirmAction = "transfer")}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider rounded-xl transition-all border disabled:opacity-50 disabled:pointer-events-none
|
||||
${this.transferTarget && !this.actionPending
|
||||
? "bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 shadow-lg hover:shadow-amber-900/40 border-white/5"
|
||||
: "bg-white/5 border-white/10 text-white/30 cursor-not-allowed"}"
|
||||
?disabled=${!this.transferTarget || this.actionPending}
|
||||
>
|
||||
${this.transferTarget
|
||||
? translateText("clan_modal.confirm_transfer", {
|
||||
name: this.transferTarget,
|
||||
})
|
||||
: translateText("clan_modal.select_new_leader")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-amber-500/10 rounded-xl border border-amber-500/20 p-4">
|
||||
<p class="text-amber-400/80 text-sm">
|
||||
${translateText("clan_modal.transfer_warning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${renderMemberSearchInput((e) => this.onSearchInput(e))}
|
||||
|
||||
<div class="space-y-2">
|
||||
${filterMembersBySearch(nonLeaders, this.memberSearch).map(
|
||||
(m) => html`
|
||||
<button
|
||||
@click=${() => (this.transferTarget = m.publicId)}
|
||||
class="w-full flex items-center gap-3 py-2.5 px-3 rounded-xl border cursor-pointer transition-all text-left focus:outline-none focus:ring-2 focus:ring-amber-500/50
|
||||
${this.transferTarget === m.publicId
|
||||
? "bg-amber-500/10 border-amber-500/20"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"}"
|
||||
aria-selected=${this.transferTarget === m.publicId}
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-xs font-bold shrink-0"
|
||||
>
|
||||
${renderRoleIcon(m.role)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${m.publicId}
|
||||
.displayText=${m.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
|
||||
${m.role === "officer"
|
||||
? "bg-purple-500/20 text-purple-400 border border-purple-500/30"
|
||||
: "bg-white/10 text-white/40 border border-white/10"}"
|
||||
>
|
||||
${translateClanRole(m.role)}
|
||||
</span>
|
||||
${this.transferTarget === m.publicId
|
||||
? html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-amber-400 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>`
|
||||
: ""}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${totalMemberPages > 1
|
||||
? renderServerPagination(this.memberPage, totalMemberPages, (p) =>
|
||||
this.loadMembers(p),
|
||||
)
|
||||
: ""}
|
||||
|
||||
<button
|
||||
@click=${() => (this.confirmAction = "transfer")}
|
||||
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider rounded-xl transition-all border disabled:opacity-50 disabled:pointer-events-none
|
||||
${this.transferTarget && !this.actionPending
|
||||
? "bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 shadow-lg hover:shadow-amber-900/40 border-white/5"
|
||||
: "bg-white/5 border-white/10 text-white/30 cursor-not-allowed"}"
|
||||
?disabled=${!this.transferTarget || this.actionPending}
|
||||
>
|
||||
${this.transferTarget
|
||||
? translateText("clan_modal.confirm_transfer", {
|
||||
name: this.transferTarget,
|
||||
})
|
||||
: translateText("clan_modal.select_new_leader")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
export type LeaderboardTab = "players" | "clans";
|
||||
|
||||
@customElement("leaderboard-tabs")
|
||||
export class LeaderboardTabs extends LitElement {
|
||||
@property({ type: String }) activeTab: LeaderboardTab = "players";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private baseTabClass =
|
||||
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
|
||||
private activeTabClass = "bg-blue-600 text-white";
|
||||
private inactiveTabClass =
|
||||
"text-white/40 hover:text-white/60 hover:bg-white/5";
|
||||
|
||||
private getTabClass(active: boolean) {
|
||||
return [
|
||||
this.baseTabClass,
|
||||
active ? this.activeTabClass : this.inactiveTabClass,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@state()
|
||||
private playerClass = this.getTabClass(this.activeTab === "players");
|
||||
@state()
|
||||
private clanClass = this.getTabClass(this.activeTab === "clans");
|
||||
|
||||
private handleTabChange(tab: LeaderboardTab) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<LeaderboardTab>("tab-change", {
|
||||
detail: tab,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.playerClass = this.getTabClass(tab === "players");
|
||||
this.clanClass = this.getTabClass(tab === "clans");
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.playerClass}"
|
||||
@click=${() => this.handleTabChange("players")}
|
||||
id="player-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "players"}
|
||||
>
|
||||
${translateText("leaderboard_modal.ranked_tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.clanClass}"
|
||||
@click=${() => this.handleTabChange("clans")}
|
||||
id="clan-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "clans"}
|
||||
>
|
||||
${translateText("leaderboard_modal.clans_tab")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function placeName(game: Game, player: Player): NameViewData {
|
||||
player.largestClusterBoundingBox ??
|
||||
calculateBoundingBox(game, player.borderTiles());
|
||||
|
||||
let scalingFactor = 1;
|
||||
let scalingFactor: number;
|
||||
const width = boundingBox.max.x - boundingBox.min.x;
|
||||
const height = boundingBox.max.y - boundingBox.min.y;
|
||||
const size = Math.min(width, height);
|
||||
|
||||
@@ -1,54 +1,33 @@
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { Cell, PlayerType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
|
||||
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
|
||||
const OUTGOING_COLOR = "var(--color-aquarius)";
|
||||
const INCOMING_COLOR = "var(--color-red-400)";
|
||||
|
||||
// At/above this zoom, the label stays at its full screen size. Below it the
|
||||
// label shrinks linearly with zoom-out, floored so it never disappears.
|
||||
const LABEL_FULL_SIZE_ZOOM = 1.5;
|
||||
const LABEL_MIN_SCREEN_SCALE = 0.5;
|
||||
const OUTGOING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
|
||||
const INCOMING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
|
||||
// At/above this zoom the label is rendered at full size; below it shrinks
|
||||
// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
const LABEL_FULL_SIZE_ZOOM = 4.0;
|
||||
const LABEL_MIN_RENDERED_SIZE = 0.63;
|
||||
// Overall size multiplier applied to the rendered label.
|
||||
const LABEL_SIZE_MULTIPLIER = 1.0;
|
||||
|
||||
// Vertical strength bar to the left of the icon: grows in height as the
|
||||
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
|
||||
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
|
||||
const BAR_FULL_HEIGHT_RATIO = 2;
|
||||
const BAR_MAX_HEIGHT_PX = 13;
|
||||
|
||||
// Element scale factor that, combined with the container's `scale(zoom)`,
|
||||
// yields the desired on-screen label size: constant screen size when zoomed
|
||||
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
|
||||
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
|
||||
// Counter-scale against the container's `scale(zoom)`. At/above
|
||||
// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER;
|
||||
// below it the rendered size shrinks linearly toward
|
||||
// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
export function computeLabelScale(zoom: number): number {
|
||||
const netScale = Math.max(
|
||||
LABEL_MIN_SCREEN_SCALE,
|
||||
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
|
||||
);
|
||||
return netScale / zoom;
|
||||
}
|
||||
|
||||
// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
|
||||
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
|
||||
// of the opposing troops.
|
||||
export function computeBarStrength(
|
||||
attackerTroops: number,
|
||||
opposingTroops: number,
|
||||
): number {
|
||||
if (opposingTroops <= 0) return 1;
|
||||
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
|
||||
const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM);
|
||||
const renderedSize =
|
||||
LABEL_SIZE_MULTIPLIER *
|
||||
(LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t);
|
||||
return renderedSize / zoom;
|
||||
}
|
||||
|
||||
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
|
||||
@@ -70,7 +49,6 @@ interface AttackLabel {
|
||||
positions: (Cell | null)[];
|
||||
isIncoming: boolean;
|
||||
attackerTroops: number;
|
||||
barStrength: number;
|
||||
}
|
||||
|
||||
export class AttackingTroopsOverlay implements Layer {
|
||||
@@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
const activeIDs = new Set<string>();
|
||||
|
||||
// Outgoing: cyan bar widens as our attack outnumbers the defender.
|
||||
// Outgoing: only label attacks targeting another player.
|
||||
for (const attack of myPlayer.outgoingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
if (!attack.targetID) {
|
||||
@@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, defender.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, false, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, false);
|
||||
}
|
||||
|
||||
// Incoming: red bar widens as the attacker outnumbers the player.
|
||||
// Incoming: only label attacks coming from another player; skip tribes.
|
||||
for (const attack of myPlayer.incomingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
if (!attacker || !attacker.isPlayer()) {
|
||||
if (
|
||||
!attacker ||
|
||||
!attacker.isPlayer() ||
|
||||
attacker.type() === PlayerType.Bot
|
||||
) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, true, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, true);
|
||||
}
|
||||
|
||||
for (const [id] of this.labels) {
|
||||
@@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
attackID: string,
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
) {
|
||||
let label = this.labels.get(attackID);
|
||||
if (!label) {
|
||||
@@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
positions: [],
|
||||
isIncoming,
|
||||
attackerTroops,
|
||||
barStrength,
|
||||
};
|
||||
this.labels.set(attackID, label);
|
||||
} else {
|
||||
label.attackerTroops = attackerTroops;
|
||||
label.barStrength = barStrength;
|
||||
}
|
||||
for (const el of label.elements) {
|
||||
this.updateLabelContent(el, attackerTroops, barStrength);
|
||||
this.updateLabelContent(el, attackerTroops);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
// Hoist the per-frame label scale once; zoom is constant within a frame.
|
||||
const scale = this.labelScale();
|
||||
const innerTransform = `scale(${scale})`;
|
||||
for (const label of this.labels.values()) {
|
||||
for (let i = 0; i < label.elements.length; i++) {
|
||||
const el = label.elements[i];
|
||||
@@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
el.style.display = "inline-flex";
|
||||
// Centre the label on its world position; counter-scale keeps the
|
||||
// label at constant screen size while zoomed in, then it shrinks
|
||||
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
|
||||
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
|
||||
if (this.lastTransform.get(el) !== transform) {
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
el.style.display = "";
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
// Outer: world position only — the 0.25s transition smooths cluster
|
||||
// shifts. Inner: scale only — applied without transition so zoom is
|
||||
// instant.
|
||||
const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
|
||||
if (this.lastTransform.get(el) !== outerTransform) {
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
}
|
||||
inner.style.transform = innerTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
// Add elements for new clusters.
|
||||
while (lbl.elements.length < positions.length) {
|
||||
lbl.elements.push(
|
||||
this.createLabelElement(
|
||||
lbl.attackerTroops,
|
||||
lbl.isIncoming,
|
||||
lbl.barStrength,
|
||||
),
|
||||
this.createLabelElement(lbl.attackerTroops, lbl.isIncoming),
|
||||
);
|
||||
lbl.positions.push(null);
|
||||
}
|
||||
@@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
|
||||
const el = lbl.elements[i];
|
||||
el.style.transition = "none";
|
||||
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`;
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
});
|
||||
@@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
// Outer wraps position+transition (animates cluster moves). Inner holds the
|
||||
// scale (instant on zoom) plus all visual chrome. Splitting them keeps the
|
||||
// 0.25s transition off zoom changes.
|
||||
private createLabelTemplate(): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.display = "none";
|
||||
el.style.alignItems = "center";
|
||||
el.style.gap = "3px";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.fontSize = "14px";
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.padding = "2px 5px";
|
||||
el.style.borderRadius = "3px";
|
||||
el.style.backgroundColor = "rgba(0,0,0,0.85)";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.lineHeight = "1.3";
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
el.style.width = "max-content";
|
||||
const outer = document.createElement("div");
|
||||
outer.style.position = "absolute";
|
||||
outer.style.display = "none";
|
||||
outer.style.pointerEvents = "none";
|
||||
outer.style.transition = "transform 0.25s linear";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.width = "2px";
|
||||
bar.style.borderRadius = "1px";
|
||||
bar.style.alignSelf = "flex-end";
|
||||
bar.style.transition = "height 0.25s linear";
|
||||
el.appendChild(bar);
|
||||
const inner = document.createElement("div");
|
||||
inner.style.whiteSpace = "nowrap";
|
||||
inner.style.fontSize = "17px";
|
||||
inner.style.fontWeight = "bold";
|
||||
inner.style.lineHeight = "1.3";
|
||||
inner.style.width = "max-content";
|
||||
// No background — let the territory border show through. Stacked black
|
||||
// text-shadows form a soft dark glow so the number stays readable over
|
||||
// any terrain.
|
||||
inner.style.textShadow =
|
||||
"0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)";
|
||||
outer.appendChild(inner);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.style.width = "13px";
|
||||
icon.style.height = "13px";
|
||||
el.appendChild(icon);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.minWidth = "25px";
|
||||
el.appendChild(span);
|
||||
|
||||
return el;
|
||||
return outer;
|
||||
}
|
||||
|
||||
private createLabelElement(
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
): HTMLDivElement {
|
||||
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
|
||||
el.style.fontFamily = this.game.config().theme().font();
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const icon = el.children[1] as HTMLImageElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
icon.src = soldierIcon;
|
||||
icon.style.filter = isIncoming
|
||||
? INCOMING_ICON_FILTER
|
||||
: OUTGOING_ICON_FILTER;
|
||||
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.style.fontFamily = this.game.config().theme().font();
|
||||
inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
this.container.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
private updateLabelContent(
|
||||
el: HTMLDivElement,
|
||||
attackerTroops: number,
|
||||
barStrength: number,
|
||||
) {
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
private updateLabelContent(el: HTMLDivElement, attackerTroops: number) {
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
}
|
||||
|
||||
private removeLabel(attackID: string) {
|
||||
|
||||
@@ -489,7 +489,7 @@ export class SpriteFactory {
|
||||
if (stage === undefined) throw new Error("Not initialized");
|
||||
const parentContainer = new PIXI.Container();
|
||||
const circle = new PIXI.Graphics();
|
||||
let radius = 0;
|
||||
let radius: number;
|
||||
switch (type) {
|
||||
case UnitType.SAMLauncher:
|
||||
radius = this.game.config().samRange(level ?? 1);
|
||||
|
||||
@@ -244,7 +244,7 @@ export class TerritoryLayer implements Layer {
|
||||
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
|
||||
|
||||
const baseColor = this.theme.spawnHighlightSelfColor(); //white
|
||||
let teamColor: Colord | null = null;
|
||||
let teamColor: Colord;
|
||||
|
||||
const team: Team | null = focusedPlayer.team();
|
||||
if (team !== null && Object.values(ColoredTeams).includes(team)) {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
--shadow-malibu-blue-ring-sm: 0 0 0 4px rgba(0, 132, 209, 0.2);
|
||||
--shadow-malibu-blue-ring-lg: 0 0 0 6px rgba(0, 132, 209, 0.3);
|
||||
--shadow-lobby-card-hover: 0 0 0 2px #0084d1, 0 0 20px rgba(0, 132, 209, 0.5);
|
||||
--shadow-action-card-hover:
|
||||
0 0 0 1px #0084d1, 0 0 12px rgba(0, 132, 209, 0.35);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -63,10 +63,9 @@ export async function collectGraphicsDiagnostics(
|
||||
|
||||
/* ---------- Rendering ---------- */
|
||||
|
||||
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
|
||||
let type: RendererType = "Canvas2D";
|
||||
|
||||
gl =
|
||||
const gl =
|
||||
canvas.getContext("webgl2", { antialias: true }) ??
|
||||
canvas.getContext("webgl", { antialias: true });
|
||||
|
||||
@@ -111,7 +110,7 @@ export async function collectGraphicsDiagnostics(
|
||||
|
||||
/* ---------- Power ---------- */
|
||||
|
||||
let power: PowerInfo = {};
|
||||
let power: PowerInfo;
|
||||
|
||||
if ("getBattery" in navigator) {
|
||||
try {
|
||||
|
||||
@@ -55,11 +55,36 @@ export const ClanMemberStatsSchema = z.object({
|
||||
ffa: ClanMemberWLSchema,
|
||||
team: ClanMemberWLSchema,
|
||||
hvn: ClanMemberWLSchema,
|
||||
duos: ClanMemberWLSchema,
|
||||
trios: ClanMemberWLSchema,
|
||||
quads: ClanMemberWLSchema,
|
||||
"2": ClanMemberWLSchema,
|
||||
"3": ClanMemberWLSchema,
|
||||
"4": ClanMemberWLSchema,
|
||||
"5": ClanMemberWLSchema,
|
||||
"6": ClanMemberWLSchema,
|
||||
"7": ClanMemberWLSchema,
|
||||
ranked: ClanMemberWLSchema,
|
||||
"1v1": ClanMemberWLSchema,
|
||||
});
|
||||
export type ClanMemberStats = z.infer<typeof ClanMemberStatsSchema>;
|
||||
|
||||
export const TEAM_BREAKDOWN_KEYS = [
|
||||
"duos",
|
||||
"trios",
|
||||
"quads",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
] as const satisfies readonly (keyof ClanMemberStats)[];
|
||||
|
||||
export const RANKED_BREAKDOWN_KEYS = [
|
||||
"1v1",
|
||||
] as const satisfies readonly (keyof ClanMemberStats)[];
|
||||
|
||||
export const ClanMemberSchema = z.object({
|
||||
role: z.enum(["leader", "officer", "member"]),
|
||||
joinedAt: z.iso.datetime(),
|
||||
|
||||
@@ -131,7 +131,7 @@ export class GameRunner {
|
||||
this.currTurn++;
|
||||
|
||||
let updates: GameUpdates;
|
||||
let tickExecutionDuration: number = 0;
|
||||
let tickExecutionDuration: number;
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ColorAllocator {
|
||||
this.availableColors = [...this.fallbackColors];
|
||||
}
|
||||
|
||||
let selectedIndex = 0;
|
||||
let selectedIndex: number;
|
||||
|
||||
if (this.assigned.size === 0 || this.assigned.size > 50) {
|
||||
// Randomly pick the first color if no colors have been assigned yet.
|
||||
|
||||
@@ -624,8 +624,8 @@ export class DefaultConfig implements Config {
|
||||
defenderTroopLoss: number;
|
||||
tilesPerTickUsed: number;
|
||||
} {
|
||||
let mag = 0;
|
||||
let speed = 0;
|
||||
let mag: number;
|
||||
let speed: number;
|
||||
const type = gm.terrainType(tileToConquer);
|
||||
switch (type) {
|
||||
case TerrainType.Plains:
|
||||
|
||||
@@ -274,12 +274,11 @@ export class AttackExecution implements Execution {
|
||||
this.attack.removeBorderTile(tileToConquer);
|
||||
|
||||
let onBorder = false;
|
||||
for (const n of this.mg.neighbors(tileToConquer)) {
|
||||
if (this.mg.owner(n) === this._owner) {
|
||||
this.mg.forEachNeighbor(tileToConquer, (n) => {
|
||||
if (!onBorder && this.mg.owner(n) === this._owner) {
|
||||
onBorder = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
|
||||
continue;
|
||||
}
|
||||
@@ -323,22 +322,22 @@ export class AttackExecution implements Execution {
|
||||
|
||||
const tickNow = this.mg.ticks(); // cache tick
|
||||
|
||||
for (const neighbor of this.mg.neighbors(tile)) {
|
||||
this.mg.forEachNeighbor(tile, (neighbor) => {
|
||||
if (
|
||||
this.mg.isWater(neighbor) ||
|
||||
this.mg.owner(neighbor) !== this.target
|
||||
) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
this.attack.addBorderTile(neighbor);
|
||||
this.attack!.addBorderTile(neighbor);
|
||||
let numOwnedByMe = 0;
|
||||
for (const n of this.mg.neighbors(neighbor)) {
|
||||
this.mg.forEachNeighbor(neighbor, (n) => {
|
||||
if (this.mg.owner(n) === this._owner) {
|
||||
numOwnedByMe++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mag = 0;
|
||||
let mag: number;
|
||||
switch (this.mg.terrainType(neighbor)) {
|
||||
case TerrainType.Plains:
|
||||
mag = 1;
|
||||
@@ -349,6 +348,9 @@ export class AttackExecution implements Execution {
|
||||
case TerrainType.Mountain:
|
||||
mag = 2;
|
||||
break;
|
||||
default:
|
||||
mag = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
const priority =
|
||||
@@ -356,33 +358,35 @@ export class AttackExecution implements Execution {
|
||||
tickNow;
|
||||
|
||||
this.toConquer.enqueue(neighbor, priority);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleDeadDefender() {
|
||||
if (!(this.target.isPlayer() && this.target.numTilesOwned() < 100)) return;
|
||||
const target: Player = this.target;
|
||||
|
||||
this.mg.conquerPlayer(this._owner, this.target);
|
||||
this.mg.conquerPlayer(this._owner, target);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const tile of this.target.tiles()) {
|
||||
const borders = this.mg
|
||||
.neighbors(tile)
|
||||
.some((t) => this.mg.owner(t) === this._owner);
|
||||
for (const tile of target.tiles()) {
|
||||
let borders = false;
|
||||
this.mg.forEachNeighbor(tile, (t) => {
|
||||
if (!borders && this.mg.owner(t) === this._owner) {
|
||||
borders = true;
|
||||
}
|
||||
});
|
||||
if (borders) {
|
||||
this._owner.conquer(tile);
|
||||
} else {
|
||||
for (const neighbor of this.mg.neighbors(tile)) {
|
||||
let captured = false;
|
||||
this.mg.forEachNeighbor(tile, (neighbor) => {
|
||||
if (captured) return;
|
||||
const no = this.mg.owner(neighbor);
|
||||
if (
|
||||
no.isPlayer() &&
|
||||
no !== this.target &&
|
||||
!no.isFriendly(this.target)
|
||||
) {
|
||||
if (no.isPlayer() && no !== target && !no.isFriendly(target)) {
|
||||
this.mg.player(no.id()).conquer(tile);
|
||||
break;
|
||||
captured = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export class AiAttackBehavior {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchesCriteria = false;
|
||||
let matchesCriteria: boolean;
|
||||
if (highInterestOnly) {
|
||||
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
|
||||
matchesCriteria = !owner.isPlayer() || owner.type() === PlayerType.Bot;
|
||||
|
||||
@@ -169,6 +169,7 @@ export enum GameMapType {
|
||||
Antarctica = "Antarctica",
|
||||
ArchipelagoSea = "ArchipelagoSea",
|
||||
BajaCalifornia = "Baja California",
|
||||
MiddleEast = "Middle East",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -230,6 +231,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.BeringSea,
|
||||
GameMapType.ArchipelagoSea,
|
||||
GameMapType.BajaCalifornia,
|
||||
GameMapType.MiddleEast,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
|
||||
@@ -19,6 +19,7 @@ export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
|
||||
attackRatioUp: "KeyY",
|
||||
boatAttack: "KeyB",
|
||||
groundAttack: "KeyG",
|
||||
retaliateAttack: "Shift+KeyR",
|
||||
requestAlliance: "KeyK",
|
||||
breakAlliance: "KeyL",
|
||||
swapDirection: "KeyU",
|
||||
|
||||
@@ -11,9 +11,7 @@ export interface ParabolaOptions {
|
||||
|
||||
const PARABOLA_MIN_HEIGHT = 50;
|
||||
|
||||
export class ParabolaUniversalPathFinder
|
||||
implements SteppingPathFinder<TileRef>
|
||||
{
|
||||
export class ParabolaUniversalPathFinder implements SteppingPathFinder<TileRef> {
|
||||
private curve: DistanceBasedBezierCurve | null = null;
|
||||
private lastTo: TileRef | null = null;
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async function drain(): Promise<void> {
|
||||
|
||||
draining = true;
|
||||
drainRequested = false;
|
||||
let shouldContinue = false;
|
||||
let shouldContinue: boolean;
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
|
||||
@@ -13,45 +13,10 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
// ?worker&url returns the worker bundle's URL as a string. We load it via a
|
||||
// same-origin Blob trampoline because browsers refuse cross-origin
|
||||
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
|
||||
// the page so the constructor accepts it, and dynamic `import()` inside the
|
||||
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
|
||||
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
|
||||
import workerUrl from "./Worker.worker.ts?worker&url";
|
||||
|
||||
function createGameWorker(): Worker {
|
||||
const cdnBase = getCdnBase().replace(/\/+$/, "");
|
||||
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
|
||||
// worker directly. The Blob trampoline below is only needed for cross-origin
|
||||
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
|
||||
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
|
||||
// regular ES modules so the trampoline's dynamic `import()` would hang.
|
||||
if (!cdnBase) {
|
||||
return new Worker(workerUrl, { type: "module" });
|
||||
}
|
||||
const fullUrl = `${cdnBase}${workerUrl}`;
|
||||
// Buffer-and-replay: the worker's port enables when the trampoline script
|
||||
// starts, so any messages posted before the imported module attaches its
|
||||
// `message` handler would dispatch to no listener and be dropped. Capture
|
||||
// them here, then re-dispatch after the import resolves.
|
||||
const trampoline = `
|
||||
const buffered = [];
|
||||
const buffer = (e) => buffered.push(e);
|
||||
self.addEventListener("message", buffer);
|
||||
import(${JSON.stringify(fullUrl)}).then(() => {
|
||||
self.removeEventListener("message", buffer);
|
||||
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
|
||||
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
|
||||
`;
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([trampoline], { type: "application/javascript" }),
|
||||
);
|
||||
const worker = new Worker(blobUrl, { type: "module" });
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return worker;
|
||||
}
|
||||
// Inlined into the main bundle as a same-origin Blob, sidestepping the
|
||||
// cross-origin `new Worker(url)` restriction that would otherwise apply when
|
||||
// the worker bundle is served from the CDN.
|
||||
import GameWorker from "./Worker.worker.ts?worker&inline";
|
||||
|
||||
export class WorkerClient {
|
||||
private worker: Worker;
|
||||
@@ -65,7 +30,7 @@ export class WorkerClient {
|
||||
private gameStartInfo: GameStartInfo,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
this.worker = createGameWorker();
|
||||
this.worker = new GameWorker();
|
||||
this.messageHandlers = new Map();
|
||||
|
||||
// Set up global message handler
|
||||
@@ -112,21 +77,8 @@ export class WorkerClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = generateID();
|
||||
|
||||
const onTrampolineError = (event: MessageEvent) => {
|
||||
if (event.data?.type !== "trampoline_error") return;
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(
|
||||
new Error(
|
||||
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
this.worker.addEventListener("message", onTrampolineError);
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (message.type === "initialized") {
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.isInitialized = true;
|
||||
resolve();
|
||||
}
|
||||
@@ -140,15 +92,12 @@ export class WorkerClient {
|
||||
cdnBase: getCdnBase(),
|
||||
});
|
||||
|
||||
// Backstop for the worker hanging after a successful import (the
|
||||
// trampoline_error path handles the cross-origin / CORS load failure).
|
||||
setTimeout(() => {
|
||||
if (!this.isInitialized) {
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(new Error("Worker initialization timeout"));
|
||||
}
|
||||
}, 20000);
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ export type WorkerMessageType =
|
||||
| "attack_clustered_positions"
|
||||
| "attack_clustered_positions_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result"
|
||||
| "trampoline_error";
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
// Base interface for all messages
|
||||
interface BaseWorkerMessage {
|
||||
@@ -122,8 +121,7 @@ export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
|
||||
attackID?: string;
|
||||
}
|
||||
|
||||
export interface AttackClusteredPositionsResultMessage
|
||||
extends BaseWorkerMessage {
|
||||
export interface AttackClusteredPositionsResultMessage extends BaseWorkerMessage {
|
||||
type: "attack_clustered_positions_result";
|
||||
attacks: { id: string; positions: { x: number; y: number }[] }[];
|
||||
}
|
||||
@@ -139,15 +137,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
result: TileRef | false;
|
||||
}
|
||||
|
||||
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
|
||||
// dynamic import of the real worker module fails. The real worker module
|
||||
// never loaded, so no other message will ever arrive — initialize() must
|
||||
// reject on this rather than wait out its timeout.
|
||||
export interface TrampolineErrorMessage extends BaseWorkerMessage {
|
||||
type: "trampoline_error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| InitMessage
|
||||
@@ -170,5 +159,4 @@ export type WorkerMessage =
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
| AttackClusteredPositionsResultMessage
|
||||
| TransportShipSpawnResultMessage
|
||||
| TrampolineErrorMessage;
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
const MAX_INTENT_SIZE = 500;
|
||||
const MAX_CONFIG_INTENT_SIZE = 2000;
|
||||
const MAX_INTENT_SIZE = 2000;
|
||||
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
|
||||
export type RateLimitResult = "ok" | "limit" | "kick";
|
||||
|
||||
@@ -17,30 +16,19 @@ interface ClientBucket {
|
||||
export class ClientMsgRateLimiter {
|
||||
private buckets = new Map<ClientID, ClientBucket>();
|
||||
|
||||
check(
|
||||
clientID: ClientID,
|
||||
type: string,
|
||||
bytes: number,
|
||||
intentType?: string,
|
||||
): RateLimitResult {
|
||||
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
|
||||
const bucket = this.getOrCreate(clientID);
|
||||
bucket.totalBytes += bytes;
|
||||
|
||||
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
|
||||
|
||||
if (type === "intent") {
|
||||
// Config updates are lobby-only and not stored in turn history,
|
||||
// so they can be larger than regular intents.
|
||||
const maxSize =
|
||||
intentType === "update_game_config"
|
||||
? MAX_CONFIG_INTENT_SIZE
|
||||
: MAX_INTENT_SIZE;
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
// Intents are also sent to all players, so it increase outgoing
|
||||
// data.
|
||||
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
|
||||
if (bytes > maxSize) {
|
||||
if (bytes > MAX_INTENT_SIZE) {
|
||||
return "kick";
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -210,7 +210,7 @@ export async function buildPreview(
|
||||
? `${mode} on ${map}${gameTypeLabel}`
|
||||
: "OpenFront Game";
|
||||
|
||||
let description = "";
|
||||
let description: string;
|
||||
if (isFinished) {
|
||||
const parts: string[] = [];
|
||||
if (winner) {
|
||||
|
||||
@@ -349,13 +349,10 @@ export class GameServer {
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
const bytes = Buffer.byteLength(message, "utf8");
|
||||
const intentType =
|
||||
clientMsg.type === "intent" ? clientMsg.intent.type : undefined;
|
||||
const rateResult = this.intentRateLimiter.check(
|
||||
client.clientID,
|
||||
clientMsg.type,
|
||||
bytes,
|
||||
intentType,
|
||||
);
|
||||
if (rateResult === "kick") {
|
||||
this.log.warn(`Client rate limit exceeded, kicking`, {
|
||||
@@ -794,6 +791,8 @@ export class GameServer {
|
||||
} satisfies ServerStartGameMessage),
|
||||
);
|
||||
} catch (error) {
|
||||
// can be enabled once we can use {cause: error} in Error constructor starting with ES2022
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(
|
||||
`error sending start message for game ${this.id}, ${error}`.substring(
|
||||
0,
|
||||
|
||||
@@ -15,11 +15,6 @@ const config = getServerConfigFromServer();
|
||||
|
||||
const resource = getOtelResource();
|
||||
|
||||
// Initialize the OpenTelemetry Logger Provider
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
});
|
||||
|
||||
if (config.otelEnabled()) {
|
||||
console.log("OTEL enabled");
|
||||
// Configure OpenTelemetry endpoint with basic auth (if provided)
|
||||
@@ -31,10 +26,11 @@ if (config.otelEnabled()) {
|
||||
headers,
|
||||
});
|
||||
|
||||
// Add a log processor with the exporter
|
||||
loggerProvider.addLogRecordProcessor(
|
||||
new SimpleLogRecordProcessor(logExporter),
|
||||
);
|
||||
// Initialize the OpenTelemetry Logger Provider
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [new SimpleLogRecordProcessor(logExporter)],
|
||||
});
|
||||
|
||||
// Set as the global logger provider
|
||||
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
|
||||
|
||||
@@ -96,6 +96,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Antarctica: 1,
|
||||
ArchipelagoSea: 3,
|
||||
BajaCalifornia: 4,
|
||||
MiddleEast: 8,
|
||||
};
|
||||
|
||||
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import countries from "resources/countries.json";
|
||||
@@ -71,15 +72,21 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
];
|
||||
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
|
||||
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
|
||||
// skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses
|
||||
// like "n.i.g.g.e.r".
|
||||
const substringMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, false),
|
||||
blacklistMatcherTransformers: baseTransformers,
|
||||
blacklistMatcherTransformers: [
|
||||
...baseTransformers,
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
const collapseMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, true),
|
||||
blacklistMatcherTransformers: [
|
||||
...baseTransformers,
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
return {
|
||||
@@ -212,6 +219,8 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
try {
|
||||
decodePatternData(found.pattern, this.b64urlDecode);
|
||||
} catch (e) {
|
||||
// can be enabled once we can use {cause: error} in Error constructor starting with ES2022
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(`Invalid pattern ${name}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { UIState } from "../src/client/graphics/UIState";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
import { UnitType } from "../src/core/game/Game";
|
||||
import { GameView } from "../src/core/game/GameView";
|
||||
import { GameView, PlayerView } from "../src/core/game/GameView";
|
||||
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
class MockPointerEvent {
|
||||
@@ -49,7 +49,10 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
testSettings = new UserSettings();
|
||||
testSettings.removeCached(KEYBINDS_KEY, false);
|
||||
|
||||
mockGameView = { inSpawnPhase: () => false } as GameView;
|
||||
mockGameView = {
|
||||
inSpawnPhase: () => false,
|
||||
myPlayer: () => ({ isAlive: () => true }),
|
||||
} as GameView;
|
||||
mockCanvas = document.createElement("canvas");
|
||||
mockCanvas.width = 800;
|
||||
mockCanvas.height = 600;
|
||||
@@ -626,6 +629,17 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
);
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MIRV);
|
||||
});
|
||||
|
||||
test("does not set ghost structure when the player is dead", () => {
|
||||
mockGameView.myPlayer = () =>
|
||||
({ isAlive: () => false }) as unknown as PlayerView;
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
||||
);
|
||||
|
||||
expect(inputHandler["uiState"].ghostStructure).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
|
||||
|
||||
@@ -114,11 +114,9 @@ describe("UsernameCensor", () => {
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with underscores/dots/numbers mixed in", () => {
|
||||
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
|
||||
// Words separated by non-alpha chars are treated as separate tokens
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
|
||||
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
|
||||
test("detects banned words with non-alphabetic characters mixed in", () => {
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("hi_tler")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
@@ -141,6 +139,19 @@ describe("UsernameCensor", () => {
|
||||
expect(matcher.hasMatch("kkklover")).toBe(true);
|
||||
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
|
||||
});
|
||||
|
||||
test("catches slurs separated by periods (bypass attempt)", () => {
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("N.I.G.G.E.R")).toBe(true);
|
||||
expect(matcher.hasMatch("n.i.g.g.a")).toBe(true);
|
||||
expect(matcher.hasMatch("h.i.t.l.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("hello n.i.g.g.e.r world")).toBe(true);
|
||||
});
|
||||
|
||||
test("censor replaces period-separated slur usernames", () => {
|
||||
const result = checker.censor("n.i.g.g.e.r", null);
|
||||
expect(shadowNames).toContain(result.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censor", () => {
|
||||
|
||||
@@ -620,5 +620,5 @@ describe("Translation System", () => {
|
||||
|
||||
expect(missingKeys).toEqual([]);
|
||||
expect(unusedKeys).toEqual([]);
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
import "../../src/client/components/baseComponents/Modal";
|
||||
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
|
||||
|
||||
describe("LeaderboardModal", () => {
|
||||
@@ -334,7 +335,14 @@ describe("LeaderboardModal", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const tab = modal.querySelector("#clan-leaderboard-tab");
|
||||
modal.inline = true;
|
||||
await modal.updateComplete;
|
||||
const oModal = modal.querySelector("o-modal");
|
||||
await (oModal as unknown as { updateComplete: Promise<unknown> })
|
||||
.updateComplete;
|
||||
const tab = oModal!.shadowRoot!.querySelector(
|
||||
'button[role="tab"][data-key="clans"]',
|
||||
);
|
||||
expect(tab).toBeTruthy();
|
||||
|
||||
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
@@ -83,6 +83,15 @@ describe("fetchClanStats", () => {
|
||||
ffa: { wins: 7, losses: 3 },
|
||||
team: { wins: 4, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 2, losses: 0 },
|
||||
trios: { wins: 1, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 2, losses: 0 },
|
||||
"3": { wins: 1, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 3, losses: 1 },
|
||||
"1v1": { wins: 3, losses: 1 },
|
||||
},
|
||||
@@ -193,20 +202,6 @@ describe("fetchClans", () => {
|
||||
expect(url.searchParams.get("search")).toBe("abc");
|
||||
});
|
||||
|
||||
it("omits search param for 2-char query (below min length of 3)", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(okJson(browseResponse)),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await fetchClans("AB", 1, 20);
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("search")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits search param when too short and non-alphanumeric", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
|
||||
@@ -93,6 +93,15 @@ describe("ClanMemberSchema", () => {
|
||||
ffa: { wins: 2, losses: 4 },
|
||||
team: { wins: 5, losses: 1 },
|
||||
hvn: { wins: 0, losses: 0 },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 2, losses: 0 },
|
||||
quads: { wins: 2, losses: 1 },
|
||||
"2": { wins: 1, losses: 0 },
|
||||
"3": { wins: 2, losses: 0 },
|
||||
"4": { wins: 2, losses: 1 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 1, losses: 3 },
|
||||
"1v1": { wins: 1, losses: 3 },
|
||||
},
|
||||
@@ -155,6 +164,15 @@ describe("ClanStatsSchema", () => {
|
||||
ffa: { wins: 3, losses: 2 },
|
||||
team: { wins: 2, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 0, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 1, losses: 0 },
|
||||
"3": { wins: 0, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 1, losses: 0 },
|
||||
"1v1": { wins: 1, losses: 0 },
|
||||
},
|
||||
|
||||
@@ -53,9 +53,8 @@ describe("ClanModal — handlers", () => {
|
||||
|
||||
describe("handleApprove increments selectedClan.memberCount", () => {
|
||||
it("increments memberCount by 1 after successful approveClanRequest", async () => {
|
||||
const { approveClanRequest, fetchClanRequests } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { approveClanRequest, fetchClanRequests } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
results: [
|
||||
@@ -90,9 +89,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("does not increment memberCount when approveClanRequest fails", async () => {
|
||||
const { approveClanRequest, fetchClanRequests } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { approveClanRequest, fetchClanRequests } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
error: "clan_modal.error_generic",
|
||||
});
|
||||
@@ -125,9 +123,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("treats undefined memberCount as 0 and increments to 1", async () => {
|
||||
const { approveClanRequest, fetchClanRequests } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { approveClanRequest, fetchClanRequests } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
results: [
|
||||
@@ -271,9 +268,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("handleBan syncs memberCount via clan-updated event on success", async () => {
|
||||
const { banClanMember, fetchClanMembers } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { banClanMember, fetchClanMembers } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
// Server returns the post-ban member total (was 5, now 4).
|
||||
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
@@ -310,9 +306,8 @@ describe("ClanModal — handlers", () => {
|
||||
|
||||
describe("handleUnban", () => {
|
||||
it("removes ban from list and decrements total on success", async () => {
|
||||
const { unbanClanMember, fetchClanBans } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { unbanClanMember, fetchClanBans } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(unbanClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
results: [
|
||||
@@ -377,9 +372,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("calls kickMember and syncs memberCount on success", async () => {
|
||||
const { kickMember, fetchClanMembers } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { kickMember, fetchClanMembers } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
results: [],
|
||||
@@ -411,9 +405,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("does not mutate state when kickMember fails", async () => {
|
||||
const { kickMember, fetchClanMembers } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { kickMember, fetchClanMembers } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
error: "clan_modal.error_generic",
|
||||
});
|
||||
@@ -600,9 +593,8 @@ describe("ClanModal — handlers", () => {
|
||||
|
||||
describe("handleJoin", () => {
|
||||
beforeEach(async () => {
|
||||
const { fetchClanDetail, fetchClanStats } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { fetchClanDetail, fetchClanStats } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeClan({ isOpen: true, memberCount: 5 }),
|
||||
);
|
||||
@@ -615,9 +607,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("switches detail view into member mode immediately after open-clan join", async () => {
|
||||
const { joinClan, fetchClanMembers } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { joinClan, fetchClanMembers } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(joinClan as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
status: "joined",
|
||||
});
|
||||
@@ -781,9 +772,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("clears confirmAction and removes the dialog after confirming", async () => {
|
||||
const { transferLeadership } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { transferLeadership } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(transferLeadership as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
const dialog = modal.querySelector("confirm-dialog");
|
||||
@@ -802,9 +792,8 @@ describe("ClanModal — handlers", () => {
|
||||
});
|
||||
|
||||
it("clears confirmAction when cancel is clicked, without calling the API", async () => {
|
||||
const { transferLeadership } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { transferLeadership } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
|
||||
const dialog = modal.querySelector("confirm-dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
|
||||
@@ -241,9 +241,8 @@ describe("ClanModal — rendering", () => {
|
||||
});
|
||||
|
||||
it("shows 0 in the stats row of the detail view when memberCount is undefined", async () => {
|
||||
const { fetchClanDetail, fetchClanStats } = await import(
|
||||
"../../../src/client/ClanApi"
|
||||
);
|
||||
const { fetchClanDetail, fetchClanStats } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeClan({ memberCount: undefined }),
|
||||
);
|
||||
|
||||
@@ -41,6 +41,15 @@ export function clanApiMockFactory() {
|
||||
ffa: { wins: 3, losses: 2 },
|
||||
team: { wins: 2, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 0, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 1, losses: 0 },
|
||||
"3": { wins: 0, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 1, losses: 0 },
|
||||
"1v1": { wins: 1, losses: 0 },
|
||||
},
|
||||
|
||||
@@ -57,66 +57,137 @@ describe("filterMembersBySearch", () => {
|
||||
});
|
||||
|
||||
describe("renderMemberStats", () => {
|
||||
const ZERO = { wins: 0, losses: 0 } as const;
|
||||
const stats: ClanMemberStats = {
|
||||
total: { wins: 7, losses: 5 },
|
||||
ffa: { wins: 2, losses: 4 },
|
||||
team: { wins: 5, losses: 1 },
|
||||
hvn: { wins: 0, losses: 0 },
|
||||
ranked: { wins: 0, losses: 0 },
|
||||
"1v1": { wins: 0, losses: 0 },
|
||||
hvn: { ...ZERO },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 4, losses: 1 },
|
||||
quads: { ...ZERO },
|
||||
"2": { ...ZERO },
|
||||
"3": { ...ZERO },
|
||||
"4": { ...ZERO },
|
||||
"5": { ...ZERO },
|
||||
"6": { ...ZERO },
|
||||
"7": { ...ZERO },
|
||||
ranked: { ...ZERO },
|
||||
"1v1": { ...ZERO },
|
||||
};
|
||||
|
||||
function renderTo(result: ReturnType<typeof renderMemberStats>): HTMLElement {
|
||||
async function renderTo(
|
||||
result: ReturnType<typeof renderMemberStats>,
|
||||
): Promise<HTMLElement> {
|
||||
const host = document.createElement("div");
|
||||
render(result, host);
|
||||
document.body.appendChild(host);
|
||||
// Allow Lit to upgrade the <clan-stats-breakdown> custom element.
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
return host;
|
||||
}
|
||||
|
||||
it("renders nothing when stats is undefined", () => {
|
||||
const host = renderTo(renderMemberStats(undefined));
|
||||
function findExpandableButton(
|
||||
host: HTMLElement,
|
||||
labelKey: string,
|
||||
): HTMLButtonElement | undefined {
|
||||
return Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
|
||||
).find((b) => (b.textContent ?? "").includes(labelKey));
|
||||
}
|
||||
|
||||
async function expandTotal(host: HTMLElement) {
|
||||
const btn = findExpandableButton(host, "clan_modal.stats_total");
|
||||
btn!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
it("renders nothing when stats is undefined", async () => {
|
||||
const host = await renderTo(renderMemberStats(undefined));
|
||||
expect(host.textContent?.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
|
||||
const host = renderTo(renderMemberStats(stats));
|
||||
it("collapses everything except the total row by default", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
const text = host.textContent ?? "";
|
||||
expect(text).toContain("clan_modal.stats_total");
|
||||
expect(text).not.toContain("clan_modal.stats_ffa");
|
||||
expect(text).not.toContain("clan_modal.stats_team");
|
||||
expect(text).not.toContain("clan_modal.stats_hvn");
|
||||
expect(text).not.toContain("clan_modal.stats_ranked");
|
||||
});
|
||||
|
||||
it("renders W/L labels inside bar segments and the win-rate per bucket", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
await expandTotal(host);
|
||||
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
|
||||
// Each bucket with games shows `{wins}W` and `{losses}L` inside segments
|
||||
expect(text).toContain("2W");
|
||||
expect(text).toContain("4L");
|
||||
expect(text).toContain("5W");
|
||||
expect(text).toContain("1L");
|
||||
// Win-rate, and em-dash placeholder for empty bucket
|
||||
expect(text).toContain("33%");
|
||||
expect(text).toContain("83%");
|
||||
expect(text).toContain("—");
|
||||
});
|
||||
|
||||
it("renders a proportional win-loss bar when there are games", () => {
|
||||
const host = renderTo(renderMemberStats(stats));
|
||||
it("renders a proportional win-loss bar when there are games", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
await expandTotal(host);
|
||||
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
|
||||
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
|
||||
// and 1v1 have 0 games → no segments.
|
||||
// Top-level rows after expanding Total: total, ffa, team, hvn, ranked (5).
|
||||
// Ranked and hvn have 0 games → no segments. Others contribute 2 each.
|
||||
expect(bars.length).toBe(6);
|
||||
const widths = Array.from(bars).map((b) =>
|
||||
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
|
||||
);
|
||||
// total: 7/12 ≈ 58.3% wins, 41.7% losses
|
||||
expect(widths[0]).toContain("width:58.33");
|
||||
expect(widths[1]).toContain("width:41.66");
|
||||
// ffa: 2/6 ≈ 33.3% wins, 66.7% losses
|
||||
expect(widths[2]).toContain("width:33.33");
|
||||
expect(widths[3]).toContain("width:66.66");
|
||||
});
|
||||
|
||||
it("includes all six translated bucket labels", () => {
|
||||
const host = renderTo(renderMemberStats(stats));
|
||||
it("includes the visible top-level translated bucket labels", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
await expandTotal(host);
|
||||
const text = host.textContent ?? "";
|
||||
expect(text).toContain("clan_modal.stats_total");
|
||||
expect(text).toContain("clan_modal.stats_ffa");
|
||||
expect(text).toContain("clan_modal.stats_team");
|
||||
expect(text).toContain("clan_modal.stats_hvn");
|
||||
expect(text).toContain("clan_modal.stats_ranked");
|
||||
expect(text).toContain("clan_modal.stats_1v1");
|
||||
// 1v1 lives under the ranked dropdown — hidden until expanded.
|
||||
expect(text).not.toContain("clan_modal.stats_1v1");
|
||||
});
|
||||
|
||||
it("reveals team sub-buckets when the team row is expanded", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
await expandTotal(host);
|
||||
const teamButton = findExpandableButton(host, "clan_modal.stats_team");
|
||||
expect(teamButton).toBeDefined();
|
||||
expect(teamButton!.disabled).toBe(false);
|
||||
teamButton!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const text = host.textContent ?? "";
|
||||
expect(text).toContain("clan_modal.stats_duos");
|
||||
expect(text).toContain("clan_modal.stats_trios");
|
||||
// Buckets with no games are hidden.
|
||||
expect(text).not.toContain("clan_modal.stats_quads");
|
||||
});
|
||||
|
||||
it("does not render an expandable button for ranked when no breakdown has games", async () => {
|
||||
const host = await renderTo(renderMemberStats(stats));
|
||||
await expandTotal(host);
|
||||
const expandableLabels = Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
|
||||
).map((b) => b.textContent ?? "");
|
||||
expect(
|
||||
expandableLabels.some((t) => t.includes("clan_modal.stats_ranked")),
|
||||
).toBe(false);
|
||||
// Sanity: team is still expandable since it has sub-bucket games.
|
||||
expect(
|
||||
expandableLabels.some((t) => t.includes("clan_modal.stats_team")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,66 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
alignClusterOrder,
|
||||
computeBarStrength,
|
||||
computeLabelScale,
|
||||
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
|
||||
import { Cell } from "../../../../src/core/game/Game";
|
||||
|
||||
describe("computeLabelScale", () => {
|
||||
test("counter-scales the zoom when above the full-size threshold", () => {
|
||||
// zoom = 2 → label rendered at 1/2 to stay at full screen size.
|
||||
expect(computeLabelScale(2)).toBeCloseTo(0.5);
|
||||
// LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63,
|
||||
// LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z:
|
||||
// 1.0 * (0.63 + 0.37 * min(1, z/4)).
|
||||
test("at the full-size threshold, rendered size is capped at the multiplier", () => {
|
||||
// zoom = 4 → rendered = 1.0 → scale = 1.0 / 4.
|
||||
expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4);
|
||||
});
|
||||
|
||||
test("counter-scales exactly at the full-size threshold", () => {
|
||||
// zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667.
|
||||
expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5);
|
||||
test("above the threshold, rendered size stays capped (counter-scales zoom)", () => {
|
||||
// zoom = 8 → rendered still 1.0 → scale = 1.0 / 8.
|
||||
expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8);
|
||||
});
|
||||
|
||||
test("rides the world transform between the floor and the threshold", () => {
|
||||
// Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5.
|
||||
expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5);
|
||||
expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5);
|
||||
test("at zoom = 0+, rendered size approaches the floor", () => {
|
||||
// As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor).
|
||||
// At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge.
|
||||
const scale = computeLabelScale(0.001);
|
||||
const floorRendered = 1.0 * 0.63;
|
||||
// Within 1% of the floor-divided-by-zoom value.
|
||||
expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99);
|
||||
expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01);
|
||||
});
|
||||
|
||||
test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => {
|
||||
expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5);
|
||||
});
|
||||
|
||||
test("grows in screen space when zoomed out past the floor", () => {
|
||||
// zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1.
|
||||
expect(computeLabelScale(0.5)).toBeCloseTo(1);
|
||||
// zoom = 0.25 → factor = 0.5 / 0.25 = 2.
|
||||
expect(computeLabelScale(0.25)).toBeCloseTo(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBarStrength", () => {
|
||||
test("equal troops sit at the midpoint", () => {
|
||||
// 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5.
|
||||
expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("attacker with no troops yields a zero-height bar", () => {
|
||||
expect(computeBarStrength(0, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
test("scales linearly between zero and the full-height threshold", () => {
|
||||
// 500 vs 1000 → ratio 0.5 → 0.25.
|
||||
expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25);
|
||||
// 1500 vs 1000 → ratio 1.5 → 0.75.
|
||||
expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
test("clamps at full height when attacker has 2× the opposition", () => {
|
||||
expect(computeBarStrength(2000, 1000)).toBeCloseTo(1);
|
||||
expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
test("returns full height when the opposing side has no troops", () => {
|
||||
// Avoids division-by-zero: an undefended target is maximum strength.
|
||||
expect(computeBarStrength(500, 0)).toBe(1);
|
||||
expect(computeBarStrength(0, 0)).toBe(1);
|
||||
test("interpolates linearly between floor and full-size threshold", () => {
|
||||
// zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815.
|
||||
expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,16 @@ describe("ClientMsgRateLimiter", () => {
|
||||
}
|
||||
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
|
||||
it("allows intents up to MAX_INTENT_SIZE", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", 2000)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks intents exceeding MAX_INTENT_SIZE", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", 2001)).toBe("kick");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-intent messages", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import {
|
||||
type AssetManifest,
|
||||
buildAssetUrl,
|
||||
@@ -155,17 +154,13 @@ export default defineConfig(({ mode }) => {
|
||||
publicDir: isProduction ? false : "resources",
|
||||
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: {
|
||||
"protobufjs/minimal": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/protobufjs/minimal.js",
|
||||
),
|
||||
resources: path.resolve(__dirname, "resources"),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
...(!isProduction
|
||||
? [serveProprietaryDir(proprietaryDir, resourcesDir)]
|
||||
: []),
|
||||
@@ -209,8 +204,11 @@ export default defineConfig(({ mode }) => {
|
||||
assetsDir: "assets", // Sub-directory for assets
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["pixi.js", "howler", "zod", "protobufjs"],
|
||||
manualChunks: (id) => {
|
||||
const vendorModules = ["pixi.js", "howler", "zod"];
|
||||
if (vendorModules.some((module) => id.includes(module))) {
|
||||
return "vendor";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||