diff --git a/map-generator/codegen.go b/map-generator/codegen.go index bbaecb0cdc..1897f4afcd 100644 --- a/map-generator/codegen.go +++ b/map-generator/codegen.go @@ -52,8 +52,9 @@ type mapInfo struct { // Empty or omitted uses the "default" theme. Themes []string `json:"themes"` // Custom tribe names that take priority over theme-generated names. - // Each entry is used as-is (no prefix/suffix composition). - CustomTribes []string `json:"custom_tribes"` + // Each entry is either a plain string (random spawn) or an object + // with "name" and "coordinates" for a fixed spawn location. + CustomTribes []json.RawMessage `json:"custom_tribes"` // Nations defined on this map (used for validation only). Nations []struct { Name string `json:"name"` @@ -78,6 +79,54 @@ func (m mapInfo) displayName() string { return m.Name } +// customTribe represents a single custom tribe entry, which is either a +// plain string (random spawn) or an object with name and optional coordinates. +type customTribe struct { + Name string + Coordinates *[2]int // nil for random-spawn tribes +} + +// parseCustomTribes decodes the mixed string/object custom_tribes array. +func parseCustomTribes(raw []json.RawMessage) ([]customTribe, error) { + tribes := make([]customTribe, 0, len(raw)) + for i, r := range raw { + // Try as plain string first. + var s string + if err := json.Unmarshal(r, &s); err == nil { + if s == "" { + return nil, fmt.Errorf("custom_tribes[%d]: empty string", i) + } + tribes = append(tribes, customTribe{Name: s}) + continue + } + // Try as object with name and optional coordinates. + var obj struct { + Name string `json:"name"` + Coordinates *json.RawMessage `json:"coordinates"` + } + if err := json.Unmarshal(r, &obj); err != nil { + return nil, fmt.Errorf("custom_tribes[%d]: invalid entry: %w", i, err) + } + if obj.Name == "" { + return nil, fmt.Errorf("custom_tribes[%d]: name is empty", i) + } + ct := customTribe{Name: obj.Name} + if obj.Coordinates != nil { + var coords []int64 + if err := json.Unmarshal(*obj.Coordinates, &coords); err != nil { + return nil, fmt.Errorf("custom_tribes[%d]: coordinates must be [x, y]", i) + } + if len(coords) != 2 { + return nil, fmt.Errorf("custom_tribes[%d]: coordinates must be [x, y]", i) + } + c := [2]int{int(coords[0]), int(coords[1])} + ct.Coordinates = &c + } + tribes = append(tribes, ct) + } + return tribes, nil +} + // loadMapInfos reads and validates every non-test map's info.json, in // registry (alphabetical) order. func loadMapInfos() ([]mapInfo, error) { @@ -123,18 +172,17 @@ func loadMapInfos() ([]mapInfo, error) { if len(info.Categories) == 0 { return nil, fmt.Errorf("map %s: info.json \"categories\" must list at least one category", m.Name) } - for _, ct := range info.CustomTribes { - if ct == "" { - return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" contains an empty string", m.Name) - } + parsedTribes, err := parseCustomTribes(info.CustomTribes) + if err != nil { + return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" %w", m.Name, err) } { ctSeen := make(map[string]bool) - for _, ct := range info.CustomTribes { - if ctSeen[ct] { - return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" contains duplicate %q", m.Name, ct) + for _, ct := range parsedTribes { + if ctSeen[ct.Name] { + return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" contains duplicate %q", m.Name, ct.Name) } - ctSeen[ct] = true + ctSeen[ct.Name] = true } } { @@ -142,9 +190,9 @@ func loadMapInfos() ([]mapInfo, error) { for _, n := range info.Nations { nationNames[n.Name] = true } - for _, ct := range info.CustomTribes { - if nationNames[ct] { - return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" contains %q which is already a nation name", m.Name, ct) + for _, ct := range parsedTribes { + if nationNames[ct.Name] { + return nil, fmt.Errorf("map %s: info.json \"custom_tribes\" contains %q which is already a nation name", m.Name, ct.Name) } } } @@ -227,8 +275,12 @@ func generateMapsTS(infos []mapInfo) error { b.WriteString(" specialTeamCount?: number;\n") b.WriteString(" /** Tribe name theme(s) (keys in tribeNameThemes.json). */\n") b.WriteString(" themes?: string[];\n") - b.WriteString(" /** Custom tribe names with priority over theme-generated names. */\n") - b.WriteString(" customTribes?: string[];\n") + b.WriteString(" /** Custom tribe entry: a string (random spawn) or an object with name and coordinates. */\n") + b.WriteString(" customTribes?: CustomTribe[];\n") + b.WriteString("}\n\n") + b.WriteString("export interface CustomTribe {\n") + b.WriteString(" name: string;\n") + b.WriteString(" coordinates?: [number, number];\n") b.WriteString("}\n\n") b.WriteString("export const maps: readonly MapInfo[] = [\n") @@ -263,12 +315,17 @@ func generateMapsTS(infos []mapInfo) error { b.WriteString("],\n") } if len(info.CustomTribes) > 0 { + parsed, _ := parseCustomTribes(info.CustomTribes) b.WriteString(" customTribes: [") - for i, ct := range info.CustomTribes { + for i, ct := range parsed { if i > 0 { b.WriteString(", ") } - b.WriteString(fmt.Sprintf("%q", ct)) + if ct.Coordinates != nil { + b.WriteString(fmt.Sprintf("{name: %q, coordinates: [%d, %d]}", ct.Name, ct.Coordinates[0], ct.Coordinates[1])) + } else { + b.WriteString(fmt.Sprintf("{name: %q}", ct.Name)) + } } b.WriteString("],\n") } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d69..74d011ef71 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -127,7 +127,13 @@ export class Executor { } spawnTribes(numTribes: number): SpawnExecution[] { - return new TribeSpawner(this.mg, this.gameID).spawnTribes(numTribes); + const nationCells = this.mg + .nations() + .map((n) => n.spawnCell) + .filter((c): c is NonNullable => c !== undefined); + return new TribeSpawner(this.mg, this.gameID, nationCells).spawnTribes( + numTribes, + ); } spawnPlayers(): SpawnExecution[] { diff --git a/src/core/execution/TribeSpawner.ts b/src/core/execution/TribeSpawner.ts index ec1f775b71..ff2f45941b 100644 --- a/src/core/execution/TribeSpawner.ts +++ b/src/core/execution/TribeSpawner.ts @@ -1,4 +1,6 @@ -import { Game, PlayerInfo, PlayerType } from "../game/Game"; +import { Cell, Game, GameMapSize, PlayerInfo, PlayerType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { type CustomTribe } from "../game/Maps.gen"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { simpleHash } from "../Util"; @@ -9,20 +11,39 @@ export class TribeSpawner { private random: PseudoRandom; private tribeNameData: TribeNameData; private usedCustomTribes: Set = new Set(); + private nationTiles: Set; constructor( private gs: Game, private gameID: GameID, + nationCells: Cell[] = [], ) { // Use a different seed than createGameRunner (which uses simpleHash(gameID)) // to avoid tribe IDs colliding with nation/human IDs from the same PRNG sequence. this.random = new PseudoRandom(simpleHash(gameID) + 2); this.tribeNameData = resolveTribeNameData(gs.config().gameConfig().gameMap); + this.nationTiles = new Set(nationCells.map((c) => gs.ref(c.x, c.y))); } spawnTribes(numTribes: number): SpawnExecution[] { const tribes: SpawnExecution[] = []; - for (let i = 0; i < numTribes; i++) { + const { customTribes } = this.tribeNameData; + + // Spawn positioned custom tribes first (those with coordinates). + if (customTribes !== undefined) { + const positioned = customTribes.filter((ct) => ct.coordinates); + for (const ct of positioned) { + if (tribes.length >= numTribes) break; + const exec = this.spawnPositionedTribe(ct); + if (exec !== undefined) { + tribes.push(exec); + this.usedCustomTribes.add(ct.name); + } + } + } + + // Fill remaining slots with random-spawn tribes. + while (tribes.length < numTribes) { tribes.push(this.spawnTribe(this.randomTribeName())); } return tribes; @@ -35,19 +56,56 @@ export class TribeSpawner { ); } + /** + * Spawn a custom tribe at its exact coordinates. + * Returns undefined if the tile is not valid (water, impassable, or already owned). + */ + private spawnPositionedTribe(ct: CustomTribe): SpawnExecution | undefined { + const coords = ct.coordinates!; + const isCompact = + this.gs.config().gameConfig().gameMapSize === GameMapSize.Compact; + const x = isCompact ? Math.floor(coords[0] / 2) : coords[0]; + const y = isCompact ? Math.floor(coords[1] / 2) : coords[1]; + + if (!this.gs.isValidCoord(x, y)) { + console.warn( + `[TribeSpawner] Tribe "${ct.name}" coordinates [${x},${y}] out of bounds`, + ); + return undefined; + } + const tile = this.gs.ref(x, y); + if ( + !this.gs.isLand(tile) || + this.gs.hasOwner(tile) || + this.gs.isImpassable(tile) || + this.nationTiles.has(tile) + ) { + console.warn( + `[TribeSpawner] Tribe "${ct.name}" spawn tile [${x},${y}] is not available`, + ); + return undefined; + } + return new SpawnExecution( + this.gameID, + new PlayerInfo(ct.name, PlayerType.Bot, null, this.random.nextID()), + tile, + ); + } + private randomTribeName(): string { const { customTribes, prefixes, suffixes } = this.tribeNameData; // Use custom tribes first (random selection, no duplicates until exhausted). if (customTribes !== undefined) { const available = customTribes.filter( - (name) => !this.usedCustomTribes.has(name), + (ct) => + ct.coordinates === undefined && !this.usedCustomTribes.has(ct.name), ); if (available.length > 0) { const index = this.random.nextInt(0, available.length); const chosen = available[index]; - this.usedCustomTribes.add(chosen); - return chosen; + this.usedCustomTribes.add(chosen.name); + return chosen.name; } } diff --git a/src/core/execution/utils/TribeNames.ts b/src/core/execution/utils/TribeNames.ts index 198506db2c..586991c576 100644 --- a/src/core/execution/utils/TribeNames.ts +++ b/src/core/execution/utils/TribeNames.ts @@ -1,10 +1,15 @@ import tribeNameThemesData from "resources/tribeNameThemes.json"; -import { GameMapType, type MapInfo, maps } from "../../game/Maps.gen"; +import { + type CustomTribe, + GameMapType, + type MapInfo, + maps, +} from "../../game/Maps.gen"; export interface TribeNameData { prefixes: string[]; suffixes: string[]; - customTribes?: string[]; + customTribes?: CustomTribe[]; } interface TribeNameTheme { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 854c81d8a1..51b23d2c81 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -105,6 +105,7 @@ export { GameMapType, mapCategoryOrder, maps, + type CustomTribe, type GameMapName, type MapCategory, type MapInfo, diff --git a/src/core/game/Maps.gen.ts b/src/core/game/Maps.gen.ts index a7485a70db..0578b7641b 100644 --- a/src/core/game/Maps.gen.ts +++ b/src/core/game/Maps.gen.ts @@ -170,8 +170,13 @@ export interface MapInfo { specialTeamCount?: number; /** Tribe name theme(s) (keys in tribeNameThemes.json). */ themes?: string[]; - /** Custom tribe names with priority over theme-generated names. */ - customTribes?: string[]; + /** Custom tribe entry: a string (random spawn) or an object with name and coordinates. */ + customTribes?: CustomTribe[]; +} + +export interface CustomTribe { + name: string; + coordinates?: [number, number]; } export const maps: readonly MapInfo[] = [ @@ -483,414 +488,414 @@ export const maps: readonly MapInfo[] = [ categories: ["europe", "new"], multiplayerFrequency: 5, customTribes: [ - "Aachen, Städteregion", - "Ahrweiler", - "Aichach-Friedberg", - "Alb-Donau-Kreis", - "Altenburger Land", - "Altenkirchen (Westerwald)", - "Altmarkkreis Salzwedel", - "Altötting", - "Alzey-Worms", - "Amberg-Sulzbach", - "Ammerland", - "Anhalt-Bitterfeld", - "Ansbach", - "Aschaffenburg", - "Augsburg", - "Aurich", - "Bad Dürkheim", - "Bad Kissingen", - "Bad Kreuznach", - "Bad Tölz-Wolfratshausen", - "Bamberg", - "Barnim", - "Bautzen", - "Bayreuth", - "Berchtesgadener Land", - "Bergstraße", - "Bernkastel-Wittlich", - "Biberach", - "Birkenfeld", - "Böblingen", - "Bodenseekreis", - "Börde", - "Borken", - "Breisgau-Hochschwarzwald", - "Burgenlandkreis", - "Calw", - "Celle", - "Cham", - "Cloppenburg", - "Coburg", - "Cochem-Zell", - "Coesfeld", - "Cuxhaven", - "Dachau", - "Dahme-Spreewald", - "Darmstadt-Dieburg", - "Deggendorf", - "Diepholz", - "Dillingen an der Donau", - "Dingolfing-Landau", - "Dithmarschen", - "Donau-Ries", - "Donnersbergkreis", - "Düren", - "Ebersberg", - "Eichsfeld", - "Eichstätt", - "Eifelkreis Bitburg-Prüm", - "Elbe-Elster", - "Emmendingen", - "Emsland", - "Ennepe-Ruhr-Kreis", - "Enzkreis", - "Erding", - "Erlangen-Höchstadt", - "Erzgebirgskreis", - "Esslingen", - "Euskirchen", - "Forchheim", - "Freising", - "Freudenstadt", - "Freyung-Grafenau", - "Friesland", - "Fulda", - "Fürstenfeldbruck", - "Fürth", - "Garmisch-Partenkirchen", - "Germersheim", - "Gießen", - "Gifhorn", - "Göppingen", - "Görlitz", - "Goslar", - "Gotha", - "Göttingen", - "Grafschaft Bentheim", - "Greiz", - "Groß-Gerau", - "Günzburg", - "Gütersloh", - "Hameln-Pyrmont", - "Hannover, Region", - "Harburg", - "Harz", - "Haßberge", - "Havelland", - "Heidekreis", - "Heidenheim", - "Heilbronn", - "Heinsberg", - "Helmstedt", - "Herford", - "Hersfeld-Rotenburg", - "Herzogtum Lauenburg", - "Hildburghausen", - "Hildesheim", - "Hochsauerlandkreis", - "Hochtaunuskreis", - "Hof", - "Hohenlohekreis", - "Holzminden", - "Höxter", - "Ilm-Kreis", - "Jerichower Land", - "Kaiserslautern", - "Karlsruhe", - "Kassel", - "Kelheim", - "Kitzingen", - "Kleve", - "Konstanz", - "Kronach", - "Kulmbach", - "Kusel", - "Kyffhäuserkreis", - "Lahn-Dill-Kreis", - "Landsberg am Lech", - "Landshut", - "Leer", - "Leipzig", - "Lichtenfels", - "Limburg-Weilburg", - "Lindau (Bodensee)", - "Lippe", - "Lörrach", - "Lüchow-Dannenberg", - "Ludwigsburg", - "Ludwigslust-Parchim", - "Lüneburg", - "Main-Kinzig-Kreis", - "Main-Spessart", - "Main-Tauber-Kreis", - "Main-Taunus-Kreis", - "Mainz-Bingen", - "Mansfeld-Südharz", - "Marburg-Biedenkopf", - "Märkischer Kreis", - "Märkisch-Oderland", - "Mayen-Koblenz", - "Mecklenburgische Seenplatte", - "Meißen", - "Merzig-Wadern", - "Mettmann", - "Miesbach", - "Miltenberg", - "Minden-Lübbecke", - "Mittelsachsen", - "Mühldorf am Inn", - "München", - "Neckar-Odenwald-Kreis", - "Neu-Ulm", - "Neuburg-Schrobenhausen", - "Neumarkt in der Oberpfalz", - "Neunkirchen", - "Neustadt an der Aisch-Bad Windsheim", - "Neustadt an der Waldnaab", - "Neuwied", - "Nienburg/Weser", - "Nordfriesland", - "Nordhausen", - "Nordsachsen", - "Nordwestmecklenburg", - "Northeim", - "Nürnberger Land", - "Oberallgäu", - "Oberbergischer Kreis", - "Oberhavel", - "Oberspreewald-Lausitz", - "Odenwaldkreis", - "Oder-Spree", - "Offenbach", - "Oldenburg", - "Olpe", - "Ortenaukreis", - "Osnabrück", - "Ostalbkreis", - "Ostallgäu", - "Osterholz", - "Ostholstein", - "Ostprignitz-Ruppin", - "Paderborn", - "Passau", - "Peine", - "Pfaffenhofen an der Ilm", - "Pinneberg", - "Plön", - "Potsdam-Mittelmark", - "Prignitz", - "Rastatt", - "Ravensburg", - "Recklinghausen", - "Regen", - "Regensburg", - "Rems-Murr-Kreis", - "Rendsburg-Eckernförde", - "Reutlingen", - "Rhein-Erft-Kreis", - "Rheingau-Taunus-Kreis", - "Rhein-Hunsrück-Kreis", - "Rheinisch-Bergischer Kreis", - "Rhein-Kreis Neuss", - "Rhein-Lahn-Kreis", - "Rhein-Neckar-Kreis", - "Rhein-Pfalz-Kreis", - "Rhein-Sieg-Kreis", - "Rhön-Grabfeld", - "Rosenheim", - "Rostock", - "Rotenburg (Wümme)", - "Roth", - "Rottal-Inn", - "Rottweil", - "Saale-Holzland-Kreis", - "Saalekreis", - "Saale-Orla-Kreis", - "Saalfeld-Rudolstadt", - "Saarbrücken, Regionalverband", - "Saarlouis", - "Saarpfalz-Kreis", - "Sächsische Schweiz-Osterzgebirge", - "Salzlandkreis", - "Schaumburg", - "Schleswig-Flensburg", - "Schmalkalden-Meiningen", - "Schwäbisch Hall", - "Schwalm-Eder-Kreis", - "Schwandorf", - "Schwarzwald-Baar-Kreis", - "Schweinfurt", - "Segeberg", - "Siegen-Wittgenstein", - "Sigmaringen", - "Soest", - "Sömmerda", - "Sonneberg", - "Spree-Neiße", - "St. Wendel", - "Stade", - "Starnberg", - "Steinburg", - "Steinfurt", - "Stendal", - "Stormarn", - "Straubing-Bogen", - "Südliche Weinstraße", - "Südwestpfalz", - "Teltow-Fläming", - "Tirschenreuth", - "Traunstein", - "Trier-Saarburg", - "Tübingen", - "Tuttlingen", - "Uckermark", - "Uelzen", - "Unna", - "Unstrut-Hainich-Kreis", - "Unterallgäu", - "Vechta", - "Verden", - "Viersen", - "Vogelsbergkreis", - "Vogtlandkreis", - "Vorpommern-Greifswald", - "Vorpommern-Rügen", - "Vulkaneifel", - "Waldeck-Frankenberg", - "Waldshut", - "Warendorf", - "Wartburgkreis", - "Weilheim-Schongau", - "Weimarer Land", - "Weißenburg-Gunzenhausen", - "Werra-Meißner-Kreis", - "Wesel", - "Wesermarsch", - "Westerwaldkreis", - "Wetteraukreis", - "Wittenberg", - "Wittmund", - "Wolfenbüttel", - "Wunsiedel im Fichtelgebirge", - "Würzburg", - "Zollernalbkreis", - "Zwickau", - "Aachen", - "Amberg", - "Baden-Baden", - "Bielefeld", - "Bochum", - "Bonn", - "Bottrop", - "Brandenburg an der Havel", - "Braunschweig", - "Bremerhaven", - "Chemnitz", - "Cottbus", - "Darmstadt", - "Delmenhorst", - "Dessau-Roßlau", - "Dortmund", - "Dresden", - "Duisburg", - "Düsseldorf", - "Emden", - "Erfurt", - "Erlangen", - "Essen", - "Flensburg", - "Frankenthal (Pfalz)", - "Frankfurt (Oder)", - "Frankfurt am Main", - "Freiburg im Breisgau", - "Gelsenkirchen", - "Gera", - "Hagen", - "Halle (Saale)", - "Hamm", - "Hanau", - "Hannover", - "Heidelberg", - "Herne", - "Ingolstadt", - "Jena", - "Kaufbeuren", - "Kempten (Allgäu)", - "Kiel", - "Koblenz", - "Köln", - "Krefeld", - "Landau in der Pfalz", - "Leverkusen", - "Lübeck", - "Ludwigshafen am Rhein", - "Magdeburg", - "Mainz", - "Mannheim", - "Memmingen", - "Mönchengladbach", - "Mülheim an der Ruhr", - "Münster", - "Neumünster", - "Neustadt an der Weinstraße", - "Nürnberg", - "Oberhausen", - "Offenbach am Main", - "Oldenburg (Oldb)", - "Pforzheim", - "Pirmasens", - "Potsdam", - "Remscheid", - "Salzgitter", - "Schwabach", - "Schwerin", - "Solingen", - "Speyer", - "Straubing", - "Stuttgart", - "Suhl", - "Trier", - "Ulm", - "Weiden in der Oberpfalz", - "Weimar", - "Wiesbaden", - "Wilhelmshaven", - "Wolfsburg", - "Worms", - "Wuppertal", - "Zweibrücken", - "Preußen", - "Schwaben", - "Franken", - "Rheinland", - "Westfalen", - "Pommern", - "Schleswig", - "Holstein", - "Anhalt", - "Lausitz", - "Elsass", - "Deutscher Orden", - "Nassau", - "Kurpfalz", - "Berg", - "Jülich", - "Lothringen", - "Harzgau", - "Wetterau", - "Ruhrgebiet", - "Allgäu", - "Schwarzwald", - "Spreewald", - "Altmühltal", - "Spessart", - "Odenwald", - "Eifel", - "Sauerland", - "Westerwald", - "Pfalz", + { name: "Aachen, Städteregion" }, + { name: "Ahrweiler" }, + { name: "Aichach-Friedberg" }, + { name: "Alb-Donau-Kreis" }, + { name: "Altenburger Land" }, + { name: "Altenkirchen (Westerwald)" }, + { name: "Altmarkkreis Salzwedel" }, + { name: "Altötting" }, + { name: "Alzey-Worms" }, + { name: "Amberg-Sulzbach" }, + { name: "Ammerland" }, + { name: "Anhalt-Bitterfeld" }, + { name: "Ansbach" }, + { name: "Aschaffenburg" }, + { name: "Augsburg" }, + { name: "Aurich" }, + { name: "Bad Dürkheim" }, + { name: "Bad Kissingen" }, + { name: "Bad Kreuznach" }, + { name: "Bad Tölz-Wolfratshausen" }, + { name: "Bamberg" }, + { name: "Barnim" }, + { name: "Bautzen" }, + { name: "Bayreuth" }, + { name: "Berchtesgadener Land" }, + { name: "Bergstraße" }, + { name: "Bernkastel-Wittlich" }, + { name: "Biberach" }, + { name: "Birkenfeld" }, + { name: "Böblingen" }, + { name: "Bodenseekreis" }, + { name: "Börde" }, + { name: "Borken" }, + { name: "Breisgau-Hochschwarzwald" }, + { name: "Burgenlandkreis" }, + { name: "Calw" }, + { name: "Celle" }, + { name: "Cham" }, + { name: "Cloppenburg" }, + { name: "Coburg" }, + { name: "Cochem-Zell" }, + { name: "Coesfeld" }, + { name: "Cuxhaven" }, + { name: "Dachau" }, + { name: "Dahme-Spreewald" }, + { name: "Darmstadt-Dieburg" }, + { name: "Deggendorf" }, + { name: "Diepholz" }, + { name: "Dillingen an der Donau" }, + { name: "Dingolfing-Landau" }, + { name: "Dithmarschen" }, + { name: "Donau-Ries" }, + { name: "Donnersbergkreis" }, + { name: "Düren" }, + { name: "Ebersberg" }, + { name: "Eichsfeld" }, + { name: "Eichstätt" }, + { name: "Eifelkreis Bitburg-Prüm" }, + { name: "Elbe-Elster" }, + { name: "Emmendingen" }, + { name: "Emsland" }, + { name: "Ennepe-Ruhr-Kreis" }, + { name: "Enzkreis" }, + { name: "Erding" }, + { name: "Erlangen-Höchstadt" }, + { name: "Erzgebirgskreis" }, + { name: "Esslingen" }, + { name: "Euskirchen" }, + { name: "Forchheim" }, + { name: "Freising" }, + { name: "Freudenstadt" }, + { name: "Freyung-Grafenau" }, + { name: "Friesland" }, + { name: "Fulda" }, + { name: "Fürstenfeldbruck" }, + { name: "Fürth" }, + { name: "Garmisch-Partenkirchen" }, + { name: "Germersheim" }, + { name: "Gießen" }, + { name: "Gifhorn" }, + { name: "Göppingen" }, + { name: "Görlitz" }, + { name: "Goslar" }, + { name: "Gotha" }, + { name: "Göttingen" }, + { name: "Grafschaft Bentheim" }, + { name: "Greiz" }, + { name: "Groß-Gerau" }, + { name: "Günzburg" }, + { name: "Gütersloh" }, + { name: "Hameln-Pyrmont" }, + { name: "Hannover, Region" }, + { name: "Harburg" }, + { name: "Harz" }, + { name: "Haßberge" }, + { name: "Havelland" }, + { name: "Heidekreis" }, + { name: "Heidenheim" }, + { name: "Heilbronn" }, + { name: "Heinsberg" }, + { name: "Helmstedt" }, + { name: "Herford" }, + { name: "Hersfeld-Rotenburg" }, + { name: "Herzogtum Lauenburg" }, + { name: "Hildburghausen" }, + { name: "Hildesheim" }, + { name: "Hochsauerlandkreis" }, + { name: "Hochtaunuskreis" }, + { name: "Hof" }, + { name: "Hohenlohekreis" }, + { name: "Holzminden" }, + { name: "Höxter" }, + { name: "Ilm-Kreis" }, + { name: "Jerichower Land" }, + { name: "Kaiserslautern" }, + { name: "Karlsruhe" }, + { name: "Kassel" }, + { name: "Kelheim" }, + { name: "Kitzingen" }, + { name: "Kleve" }, + { name: "Konstanz" }, + { name: "Kronach" }, + { name: "Kulmbach" }, + { name: "Kusel" }, + { name: "Kyffhäuserkreis" }, + { name: "Lahn-Dill-Kreis" }, + { name: "Landsberg am Lech" }, + { name: "Landshut" }, + { name: "Leer" }, + { name: "Leipzig" }, + { name: "Lichtenfels" }, + { name: "Limburg-Weilburg" }, + { name: "Lindau (Bodensee)" }, + { name: "Lippe" }, + { name: "Lörrach" }, + { name: "Lüchow-Dannenberg" }, + { name: "Ludwigsburg" }, + { name: "Ludwigslust-Parchim" }, + { name: "Lüneburg" }, + { name: "Main-Kinzig-Kreis" }, + { name: "Main-Spessart" }, + { name: "Main-Tauber-Kreis" }, + { name: "Main-Taunus-Kreis" }, + { name: "Mainz-Bingen" }, + { name: "Mansfeld-Südharz" }, + { name: "Marburg-Biedenkopf" }, + { name: "Märkischer Kreis" }, + { name: "Märkisch-Oderland" }, + { name: "Mayen-Koblenz" }, + { name: "Mecklenburgische Seenplatte" }, + { name: "Meißen" }, + { name: "Merzig-Wadern" }, + { name: "Mettmann" }, + { name: "Miesbach" }, + { name: "Miltenberg" }, + { name: "Minden-Lübbecke" }, + { name: "Mittelsachsen" }, + { name: "Mühldorf am Inn" }, + { name: "München" }, + { name: "Neckar-Odenwald-Kreis" }, + { name: "Neu-Ulm" }, + { name: "Neuburg-Schrobenhausen" }, + { name: "Neumarkt in der Oberpfalz" }, + { name: "Neunkirchen" }, + { name: "Neustadt an der Aisch-Bad Windsheim" }, + { name: "Neustadt an der Waldnaab" }, + { name: "Neuwied" }, + { name: "Nienburg/Weser" }, + { name: "Nordfriesland" }, + { name: "Nordhausen" }, + { name: "Nordsachsen" }, + { name: "Nordwestmecklenburg" }, + { name: "Northeim" }, + { name: "Nürnberger Land" }, + { name: "Oberallgäu" }, + { name: "Oberbergischer Kreis" }, + { name: "Oberhavel" }, + { name: "Oberspreewald-Lausitz" }, + { name: "Odenwaldkreis" }, + { name: "Oder-Spree" }, + { name: "Offenbach" }, + { name: "Oldenburg" }, + { name: "Olpe" }, + { name: "Ortenaukreis" }, + { name: "Osnabrück" }, + { name: "Ostalbkreis" }, + { name: "Ostallgäu" }, + { name: "Osterholz" }, + { name: "Ostholstein" }, + { name: "Ostprignitz-Ruppin" }, + { name: "Paderborn" }, + { name: "Passau" }, + { name: "Peine" }, + { name: "Pfaffenhofen an der Ilm" }, + { name: "Pinneberg" }, + { name: "Plön" }, + { name: "Potsdam-Mittelmark" }, + { name: "Prignitz" }, + { name: "Rastatt" }, + { name: "Ravensburg" }, + { name: "Recklinghausen" }, + { name: "Regen" }, + { name: "Regensburg" }, + { name: "Rems-Murr-Kreis" }, + { name: "Rendsburg-Eckernförde" }, + { name: "Reutlingen" }, + { name: "Rhein-Erft-Kreis" }, + { name: "Rheingau-Taunus-Kreis" }, + { name: "Rhein-Hunsrück-Kreis" }, + { name: "Rheinisch-Bergischer Kreis" }, + { name: "Rhein-Kreis Neuss" }, + { name: "Rhein-Lahn-Kreis" }, + { name: "Rhein-Neckar-Kreis" }, + { name: "Rhein-Pfalz-Kreis" }, + { name: "Rhein-Sieg-Kreis" }, + { name: "Rhön-Grabfeld" }, + { name: "Rosenheim" }, + { name: "Rostock" }, + { name: "Rotenburg (Wümme)" }, + { name: "Roth" }, + { name: "Rottal-Inn" }, + { name: "Rottweil" }, + { name: "Saale-Holzland-Kreis" }, + { name: "Saalekreis" }, + { name: "Saale-Orla-Kreis" }, + { name: "Saalfeld-Rudolstadt" }, + { name: "Saarbrücken, Regionalverband" }, + { name: "Saarlouis" }, + { name: "Saarpfalz-Kreis" }, + { name: "Sächsische Schweiz-Osterzgebirge" }, + { name: "Salzlandkreis" }, + { name: "Schaumburg" }, + { name: "Schleswig-Flensburg" }, + { name: "Schmalkalden-Meiningen" }, + { name: "Schwäbisch Hall" }, + { name: "Schwalm-Eder-Kreis" }, + { name: "Schwandorf" }, + { name: "Schwarzwald-Baar-Kreis" }, + { name: "Schweinfurt" }, + { name: "Segeberg" }, + { name: "Siegen-Wittgenstein" }, + { name: "Sigmaringen" }, + { name: "Soest" }, + { name: "Sömmerda" }, + { name: "Sonneberg" }, + { name: "Spree-Neiße" }, + { name: "St. Wendel" }, + { name: "Stade" }, + { name: "Starnberg" }, + { name: "Steinburg" }, + { name: "Steinfurt" }, + { name: "Stendal" }, + { name: "Stormarn" }, + { name: "Straubing-Bogen" }, + { name: "Südliche Weinstraße" }, + { name: "Südwestpfalz" }, + { name: "Teltow-Fläming" }, + { name: "Tirschenreuth" }, + { name: "Traunstein" }, + { name: "Trier-Saarburg" }, + { name: "Tübingen" }, + { name: "Tuttlingen" }, + { name: "Uckermark" }, + { name: "Uelzen" }, + { name: "Unna" }, + { name: "Unstrut-Hainich-Kreis" }, + { name: "Unterallgäu" }, + { name: "Vechta" }, + { name: "Verden" }, + { name: "Viersen" }, + { name: "Vogelsbergkreis" }, + { name: "Vogtlandkreis" }, + { name: "Vorpommern-Greifswald" }, + { name: "Vorpommern-Rügen" }, + { name: "Vulkaneifel" }, + { name: "Waldeck-Frankenberg" }, + { name: "Waldshut" }, + { name: "Warendorf" }, + { name: "Wartburgkreis" }, + { name: "Weilheim-Schongau" }, + { name: "Weimarer Land" }, + { name: "Weißenburg-Gunzenhausen" }, + { name: "Werra-Meißner-Kreis" }, + { name: "Wesel" }, + { name: "Wesermarsch" }, + { name: "Westerwaldkreis" }, + { name: "Wetteraukreis" }, + { name: "Wittenberg" }, + { name: "Wittmund" }, + { name: "Wolfenbüttel" }, + { name: "Wunsiedel im Fichtelgebirge" }, + { name: "Würzburg" }, + { name: "Zollernalbkreis" }, + { name: "Zwickau" }, + { name: "Aachen" }, + { name: "Amberg" }, + { name: "Baden-Baden" }, + { name: "Bielefeld" }, + { name: "Bochum" }, + { name: "Bonn" }, + { name: "Bottrop" }, + { name: "Brandenburg an der Havel" }, + { name: "Braunschweig" }, + { name: "Bremerhaven" }, + { name: "Chemnitz" }, + { name: "Cottbus" }, + { name: "Darmstadt" }, + { name: "Delmenhorst" }, + { name: "Dessau-Roßlau" }, + { name: "Dortmund" }, + { name: "Dresden" }, + { name: "Duisburg" }, + { name: "Düsseldorf" }, + { name: "Emden" }, + { name: "Erfurt" }, + { name: "Erlangen" }, + { name: "Essen" }, + { name: "Flensburg" }, + { name: "Frankenthal (Pfalz)" }, + { name: "Frankfurt (Oder)" }, + { name: "Frankfurt am Main" }, + { name: "Freiburg im Breisgau" }, + { name: "Gelsenkirchen" }, + { name: "Gera" }, + { name: "Hagen" }, + { name: "Halle (Saale)" }, + { name: "Hamm" }, + { name: "Hanau" }, + { name: "Hannover" }, + { name: "Heidelberg" }, + { name: "Herne" }, + { name: "Ingolstadt" }, + { name: "Jena" }, + { name: "Kaufbeuren" }, + { name: "Kempten (Allgäu)" }, + { name: "Kiel" }, + { name: "Koblenz" }, + { name: "Köln" }, + { name: "Krefeld" }, + { name: "Landau in der Pfalz" }, + { name: "Leverkusen" }, + { name: "Lübeck" }, + { name: "Ludwigshafen am Rhein" }, + { name: "Magdeburg" }, + { name: "Mainz" }, + { name: "Mannheim" }, + { name: "Memmingen" }, + { name: "Mönchengladbach" }, + { name: "Mülheim an der Ruhr" }, + { name: "Münster" }, + { name: "Neumünster" }, + { name: "Neustadt an der Weinstraße" }, + { name: "Nürnberg" }, + { name: "Oberhausen" }, + { name: "Offenbach am Main" }, + { name: "Oldenburg (Oldb)" }, + { name: "Pforzheim" }, + { name: "Pirmasens" }, + { name: "Potsdam" }, + { name: "Remscheid" }, + { name: "Salzgitter" }, + { name: "Schwabach" }, + { name: "Schwerin" }, + { name: "Solingen" }, + { name: "Speyer" }, + { name: "Straubing" }, + { name: "Stuttgart" }, + { name: "Suhl" }, + { name: "Trier" }, + { name: "Ulm" }, + { name: "Weiden in der Oberpfalz" }, + { name: "Weimar" }, + { name: "Wiesbaden" }, + { name: "Wilhelmshaven" }, + { name: "Wolfsburg" }, + { name: "Worms" }, + { name: "Wuppertal" }, + { name: "Zweibrücken" }, + { name: "Preußen" }, + { name: "Schwaben" }, + { name: "Franken" }, + { name: "Rheinland" }, + { name: "Westfalen" }, + { name: "Pommern" }, + { name: "Schleswig" }, + { name: "Holstein" }, + { name: "Anhalt" }, + { name: "Lausitz" }, + { name: "Elsass" }, + { name: "Deutscher Orden" }, + { name: "Nassau" }, + { name: "Kurpfalz" }, + { name: "Berg" }, + { name: "Jülich" }, + { name: "Lothringen" }, + { name: "Harzgau" }, + { name: "Wetterau" }, + { name: "Ruhrgebiet" }, + { name: "Allgäu" }, + { name: "Schwarzwald" }, + { name: "Spreewald" }, + { name: "Altmühltal" }, + { name: "Spessart" }, + { name: "Odenwald" }, + { name: "Eifel" }, + { name: "Sauerland" }, + { name: "Westerwald" }, + { name: "Pfalz" }, ], }, { diff --git a/tests/MapConsistency.test.ts b/tests/MapConsistency.test.ts index b0e22cd6d3..207d58586a 100644 --- a/tests/MapConsistency.test.ts +++ b/tests/MapConsistency.test.ts @@ -1,6 +1,12 @@ import fs from "fs"; import path from "path"; -import { GameMapName, GameMapType, MapInfo, maps } from "../src/core/game/Game"; +import { + type CustomTribe, + GameMapName, + GameMapType, + MapInfo, + maps, +} from "../src/core/game/Game"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -59,6 +65,18 @@ function orOmitted(value: unknown): unknown { return value || undefined; } +/** + * Normalize info.json custom_tribes (mixed string/object array) to the + * Maps.gen.ts format (all CustomTribe objects with name and optional coordinates). + */ +function normalizeCustomTribes(raw: unknown): CustomTribe[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) return undefined; + return raw.map((entry) => { + if (typeof entry === "string") return { name: entry }; + return entry as CustomTribe; + }); +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe("Map consistency", () => { @@ -145,7 +163,11 @@ describe("Map consistency", () => { map.specialTeamCount, ], ["themes", orOmitted(info.themes), map.themes], - ["custom_tribes", orOmitted(info.custom_tribes), map.customTribes], + [ + "custom_tribes", + normalizeCustomTribes(info.custom_tribes), + map.customTribes, + ], ]; for (const [field, infoValue, mapValue] of fields) { if (JSON.stringify(infoValue) !== JSON.stringify(mapValue)) { diff --git a/tests/core/execution/TribeSpawner.test.ts b/tests/core/execution/TribeSpawner.test.ts new file mode 100644 index 0000000000..399293aebb --- /dev/null +++ b/tests/core/execution/TribeSpawner.test.ts @@ -0,0 +1,246 @@ +import { vi } from "vitest"; +import type { Game } from "../../../src/core/game/Game"; +import { + GameMapSize, + GameMapType, + PlayerType, +} from "../../../src/core/game/Game"; +import { GameID } from "../../../src/core/Schemas"; +import { setup } from "../../util/Setup"; + +const mockResolveTribeNameData = vi.fn(); + +vi.mock("../../../src/core/execution/utils/TribeNames", () => ({ + resolveTribeNameData: (...args: unknown[]) => + mockResolveTribeNameData(...args), +})); + +import { TribeSpawner } from "../../../src/core/execution/TribeSpawner"; + +const GAME_ID: GameID = "test_game_id"; + +/** Find the first land tile on the map. */ +function findLandTile(game: Game): number { + for (let x = 0; x < game.width(); x++) { + for (let y = 0; y < game.height(); y++) { + const t = game.ref(x, y); + if (game.isLand(t) && !game.isImpassable(t)) return t; + } + } + throw new Error("no land tile found"); +} + +/** Find the first water tile on the map. */ +function findWaterTile(game: Game): number { + for (let x = 0; x < game.width(); x++) { + for (let y = 0; y < game.height(); y++) { + const t = game.ref(x, y); + if (game.isWater(t)) return t; + } + } + throw new Error("no water tile found"); +} + +describe("TribeSpawner", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("positioned tribes spawn before random tribes", async () => { + const game = await setup("plains", { bots: 3, gameMap: GameMapType.Asia }); + const tile = findLandTile(game); + const x = game.x(tile); + const y = game.y(tile); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Alpha"], + suffixes: ["Tribe"], + customTribes: [ + { name: "Positioned", coordinates: [x, y] }, + { name: "Random1" }, + ], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(3); + + expect(execs).toHaveLength(3); + // Positioned tribe first (has a tile), then random tribes (no tile). + expect(execs[0].tile).toBeDefined(); + expect(execs[1].tile).toBeUndefined(); + expect(execs[2].tile).toBeUndefined(); + }); + + test("compact-map coordinates are halved", async () => { + const game = await setup("plains", { + bots: 1, + gameMap: GameMapType.Asia, + gameMapSize: GameMapSize.Compact, + }); + const tile = findLandTile(game); + const x = game.x(tile); + const y = game.y(tile); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Test"], + suffixes: ["Tribe"], + customTribes: [{ name: "Compact", coordinates: [x * 2, y * 2] }], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(1); + + expect(execs).toHaveLength(1); + expect(execs[0].tile).toBe(game.ref(x, y)); + }); + + test("returns undefined for coordinates on water", async () => { + const game = await setup("ocean_and_land", { + bots: 1, + gameMap: GameMapType.Asia, + }); + const tile = findWaterTile(game); + const x = game.x(tile); + const y = game.y(tile); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Test"], + suffixes: ["Tribe"], + customTribes: [{ name: "WaterTribe", coordinates: [x, y] }], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const execs = spawner.spawnTribes(1); + + expect(execs).toHaveLength(1); + // Should fall back to random (no tile set). + expect(execs[0].tile).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + }); + + test("falls back to random names when positioned spawn fails", async () => { + const game = await setup("half_land_half_ocean", { + bots: 2, + gameMap: GameMapType.Asia, + }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Fallback"], + suffixes: ["Bot"], + customTribes: [ + { name: "OOB", coordinates: [99999, 99999] }, + { name: "Pool" }, + ], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const execs = spawner.spawnTribes(2); + + expect(execs).toHaveLength(2); + // Both should be random (no tile), since OOB failed. + expect(execs[0].tile).toBeUndefined(); + expect(execs[1].tile).toBeUndefined(); + // OOB must not appear — it has coordinates and failed to spawn. + const names = execs.map( + (e) => (e as unknown as { playerInfo: { name: string } }).playerInfo.name, + ); + expect(names).not.toContain("OOB"); + expect(warnSpy).toHaveBeenCalled(); + }); + + test("failed positioned tribe is NOT spawned randomly", async () => { + const game = await setup("half_land_half_ocean", { + bots: 3, + gameMap: GameMapType.Asia, + }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Fallback"], + suffixes: ["Bot"], + customTribes: [{ name: "FixedFail", coordinates: [99999, 99999] }], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const execs = spawner.spawnTribes(3); + + expect(execs).toHaveLength(3); + // "FixedFail" must NOT appear — it has coordinates and failed to spawn. + const names = execs.map( + (e) => (e as unknown as { playerInfo: { name: string } }).playerInfo.name, + ); + expect(names).not.toContain("FixedFail"); + expect(warnSpy).toHaveBeenCalled(); + }); + + test("random tribe selection avoids duplicates", async () => { + const game = await setup("plains", { + bots: 3, + gameMap: GameMapType.Asia, + }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["A"], + suffixes: ["B"], + customTribes: [{ name: "Only" }], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(3); + + expect(execs).toHaveLength(3); + // All should be random (no positioned tribes). + for (const exec of execs) { + expect(exec.tile).toBeUndefined(); + } + }); + + test("no positioned tribes uses all random names", async () => { + const game = await setup("plains", { + bots: 2, + gameMap: GameMapType.Asia, + }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["X"], + suffixes: ["Y"], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(2); + + expect(execs).toHaveLength(2); + for (const exec of execs) { + expect(exec.tile).toBeUndefined(); + } + }); + + test("all players spawn on valid land tiles", async () => { + const game = await setup("plains", { + bots: 0, + gameMap: GameMapType.Asia, + }); + + mockResolveTribeNameData.mockReturnValue({ + prefixes: ["Test"], + suffixes: ["Tribe"], + }); + + const spawner = new TribeSpawner(game, GAME_ID); + const execs = spawner.spawnTribes(5); + + game.addExecution(...execs); + game.executeNextTick(); + game.executeNextTick(); + + const bots = game.allPlayers().filter((p) => p.type() === PlayerType.Bot); + expect(bots.length).toBe(5); + for (const bot of bots) { + const tile = bot.spawnTile()!; + expect(tile).toBeDefined(); + expect(game.isLand(tile)).toBe(true); + } + }); +});