Merge branch 'main' into trade3

This commit is contained in:
Ryan
2026-05-06 23:25:45 +01:00
committed by GitHub
89 changed files with 6778 additions and 6553 deletions
-1
View File
@@ -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").
-4
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+11 -11
View File
@@ -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 }
]
}
}
Binary file not shown.

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"
}
]
}
+13 -1
View File
@@ -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")
Binary file not shown.
+68 -50
View File
@@ -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
+1799 -4872
View File
File diff suppressed because it is too large Load Diff
+52 -64
View File
@@ -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"
}
+40 -6
View File
@@ -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",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

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

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

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

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

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
@@ -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();
+107 -75
View File
@@ -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`
+47 -2
View File
@@ -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;
+1 -1
View File
@@ -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}
>
+9 -9
View File
@@ -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);
+18 -4
View File
@@ -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;
-1
View File
@@ -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",
+39 -42
View File
@@ -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>
`;
}
+1 -1
View File
@@ -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}
>
+23 -53
View File
@@ -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>
`;
}
+29 -46
View File
@@ -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")}
+88 -34
View File
@@ -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>
`;
}
}
+92 -119
View File
@@ -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>
`;
}
+2 -3
View File
@@ -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"
+69 -70
View File
@@ -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 }),
);
}
}
+162 -195
View File
@@ -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>
`;
}
+68 -87
View File
@@ -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 }),
);
}
}
+26 -47
View File
@@ -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>
`;
}
}
+86 -112
View File
@@ -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>
`;
}
}
+1 -1
View File
@@ -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 (01) 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);
+1 -1
View File
@@ -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)) {
+2
View File
@@ -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 {
+2 -3
View File
@@ -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 {
+25
View File
@@ -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(),
+1 -1
View File
@@ -131,7 +131,7 @@ export class GameRunner {
this.currTurn++;
let updates: GameUpdates;
let tickExecutionDuration: number = 0;
let tickExecutionDuration: number;
try {
const startTime = performance.now();
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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:
+28 -24
View File
@@ -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;
}
}
});
}
}
}
+1 -1
View File
@@ -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;
+2
View File
@@ -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,
+1
View File
@@ -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",
+1 -3
View File
@@ -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;
+1 -1
View File
@@ -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) {
+6 -57
View File
@@ -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);
});
}
+3 -15
View File
@@ -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 -15
View File
@@ -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 (
+1 -1
View File
@@ -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) {
+2 -3
View File
@@ -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,
+5 -9
View File
@@ -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);
+1
View File
@@ -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 }[] = [
+10 -1
View File
@@ -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}`);
}
+16 -2
View File
@@ -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)", () => {
+16 -5
View File
@@ -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", () => {
+1 -1
View File
@@ -620,5 +620,5 @@ describe("Translation System", () => {
expect(missingKeys).toEqual([]);
expect(unusedKeys).toEqual([]);
});
}, 30000);
});
+9 -1
View File
@@ -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 }));
+9 -14
View File
@@ -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) =>
+18
View File
@@ -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 },
},
+22 -33
View File
@@ -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 }),
);
+9
View File
@@ -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 },
},
+90 -19
View File
@@ -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);
});
});
+10
View File
@@ -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", () => {
+6 -8
View File
@@ -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";
}
},
},
},