Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 74 additions & 17 deletions map-generator/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return tribes, nil
}

// loadMapInfos reads and validates every non-test map's info.json, in
// registry (alphabetical) order.
func loadMapInfos() ([]mapInfo, error) {
Expand Down Expand Up @@ -123,28 +172,27 @@ 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
}
}
{
nationNames := make(map[string]bool)
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)
}
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down
8 changes: 7 additions & 1 deletion src/core/execution/ExecutionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof c> => c !== undefined);
return new TribeSpawner(this.mg, this.gameID, nationCells).spawnTribes(
numTribes,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

spawnPlayers(): SpawnExecution[] {
Expand Down
68 changes: 63 additions & 5 deletions src/core/execution/TribeSpawner.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,20 +11,39 @@ export class TribeSpawner {
private random: PseudoRandom;
private tribeNameData: TribeNameData;
private usedCustomTribes: Set<string> = new Set();
private nationTiles: Set<TileRef>;

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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Fill remaining slots with random-spawn tribes.
while (tribes.length < numTribes) {
tribes.push(this.spawnTribe(this.randomTribeName()));
}
return tribes;
Expand All @@ -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];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/core/execution/utils/TribeNames.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export {
GameMapType,
mapCategoryOrder,
maps,
type CustomTribe,
type GameMapName,
type MapCategory,
type MapInfo,
Expand Down
Loading
Loading