Merge main into nations-ai

This commit is contained in:
Scott Anderson
2025-04-16 01:06:00 -04:00
101 changed files with 3859 additions and 924 deletions
+6 -7
View File
@@ -6,7 +6,7 @@ import {
GameMode,
GameType,
PlayerType,
TeamName,
Team,
UnitType,
} from "./game/Game";
@@ -121,16 +121,14 @@ const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
});
const SafeString = z
.string()
// Remove common dangerous characters and patterns
// The weird \u stuff is to allow emojis
.regex(
/^[a-zA-Z0-9\s.,!?@#$%&*()-_+=\[\]{}|;:"'\/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ]+$/,
/^([a-zA-Z0-9\s.,!?@#$%&*()-_+=\[\]{}|;:"'\/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ])*$/,
)
// Reasonable max length to prevent DOS
.max(1000);
const EmojiSchema = z.string().refine(
@@ -364,7 +362,7 @@ const ClientBaseMessageSchema = z.object({
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(TeamName)).nullable(),
winner: ID.or(z.nativeEnum(Team)).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
@@ -396,6 +394,7 @@ export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal("join"),
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
flag: SafeString.nullable().optional(),
});
export const ClientMessageSchema = z.union([
@@ -425,7 +424,7 @@ export const GameRecordSchema = z.object({
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: z
.union([ID, z.nativeEnum(TeamName)])
.union([ID, z.nativeEnum(Team)])
.nullable()
.optional(),
winnerType: z.enum(["player", "team"]).nullable().optional(),
+2 -2
View File
@@ -1,7 +1,7 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Game, Player, TeamName, Unit } from "./game/Game";
import { Cell, Game, Player, Team, Unit } from "./game/Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
import {
AllPlayersStats,
@@ -253,7 +253,7 @@ export function createGameRecord(
turns: Turn[],
start: number,
end: number,
winner: ClientID | TeamName | null,
winner: ClientID | Team | null,
winnerType: "player" | "team" | null,
allPlayersStats: AllPlayersStats,
): GameRecord {
+5
View File
@@ -2,6 +2,11 @@ import { colord, Colord } from "colord";
export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red
export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue
export const teal = colord({ h: 172, s: 66, l: 50 });
export const purple = colord({ h: 271, s: 81, l: 56 });
export const yellow = colord({ h: 45, s: 93, l: 47 });
export const orange = colord({ h: 25, s: 95, l: 53 });
export const green = colord({ h: 128, s: 49, l: 50 });
export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray
export const territoryColors: Colord[] = [
+3
View File
@@ -7,6 +7,7 @@ import {
Gold,
Player,
PlayerInfo,
Team,
TerraNullius,
Tick,
UnitInfo,
@@ -65,6 +66,7 @@ export interface Config {
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
@@ -122,6 +124,7 @@ export interface Config {
}
export interface Theme {
teamColor(team: Team): Colord;
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
+27 -16
View File
@@ -189,6 +189,9 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
numPlayerTeams(): number {
return this._gameConfig.numPlayerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
@@ -211,12 +214,12 @@ export class DefaultConfig implements Config {
return 10000 + 150 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(numberOfPorts: number): number {
if (numberOfPorts <= 3) return 180;
if (numberOfPorts <= 5) return 250;
if (numberOfPorts <= 8) return 350;
if (numberOfPorts <= 10) return 400;
if (numberOfPorts <= 12) return 450;
return 500;
if (numberOfPorts <= 3) return 18;
if (numberOfPorts <= 5) return 25;
if (numberOfPorts <= 8) return 35;
if (numberOfPorts <= 10) return 40;
if (numberOfPorts <= 12) return 45;
return 50;
}
unitInfo(type: UnitType): UnitInfo {
@@ -268,7 +271,7 @@ export class DefaultConfig implements Config {
case UnitType.AtomBomb:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 500_000,
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 750_000,
territoryBound: false,
};
case UnitType.HydrogenBomb:
@@ -336,7 +339,7 @@ export class DefaultConfig implements Config {
p.type() == PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
2_000_000,
1_000_000,
Math.pow(
2,
p.unitsIncludingConstruction(UnitType.City).length,
@@ -473,18 +476,25 @@ export class DefaultConfig implements Config {
}
if (defender.isPlayer()) {
const ratio = within(
Math.pow(defender.troops() / attackTroops, 0.4),
0.1,
10,
);
const speedRatio = within(
defender.troops() / (5 * attackTroops),
0.1,
10,
);
return {
attackerTroopLoss:
within(defender.troops() / attackTroops, 0.6, 2) *
ratio *
mag *
0.8 *
largeLossModifier *
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
largeSpeedMalus,
defenderTroopLoss: defender.population() / defender.numTilesOwned(),
tilesPerTickUsed: Math.floor(speedRatio * speed * largeSpeedMalus),
};
} else {
return {
@@ -620,7 +630,8 @@ export class DefaultConfig implements Config {
}
goldAdditionRate(player: Player): number {
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
const ratio = Math.pow(player.workers() / player.population(), 1.3);
return Math.floor(Math.sqrt(player.workers()) * ratio * 5);
}
troopAdjustmentRate(player: Player): number {
+5 -5
View File
@@ -1,4 +1,4 @@
import { GameType, UnitInfo, UnitType } from "../game/Game";
import { UnitInfo, UnitType } from "../game/Game";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { GameEnv, ServerConfig } from "./Config";
@@ -40,10 +40,10 @@ export class DevConfig extends DefaultConfig {
super(sc, gc, us);
}
numSpawnPhaseTurns(): number {
return this.gameConfig().gameType == GameType.Singleplayer ? 40 : 100;
// return 100
}
// numSpawnPhaseTurns(): number {
// return this.gameConfig().gameType == GameType.Singleplayer ? 70 : 100;
// // return 100
// }
unitInfo(type: UnitType): UnitInfo {
const info = super.unitInfo(type);
+30 -9
View File
@@ -1,16 +1,21 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, TeamName, TerrainType } from "../game/Game";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
blue,
botColor,
botColors,
green,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
} from "./Colors";
import { Theme } from "./Config";
@@ -36,15 +41,31 @@ export const pastelTheme = new (class implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
return blue;
case Team.Red:
return red;
case Team.Teal:
return teal;
case Team.Purple:
return purple;
case Team.Yellow:
return yellow;
case Team.Orange:
return orange;
case Team.Green:
return green;
case Team.Bot:
return botColor;
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.teamName() == TeamName.Bot) {
return botColor;
}
if (player.teamName() == TeamName.Red) {
return red;
}
if (player.teamName() == TeamName.Blue) {
return blue;
if (player.team() !== null) {
return this.teamColor(player.team());
}
if (player.info().playerType == PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
+30 -9
View File
@@ -1,16 +1,21 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, TeamName, TerrainType } from "../game/Game";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
blue,
botColor,
botColors,
green,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
} from "./Colors";
import { Theme } from "./Config";
@@ -36,15 +41,31 @@ export const pastelThemeDark = new (class implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
return blue;
case Team.Red:
return red;
case Team.Teal:
return teal;
case Team.Purple:
return purple;
case Team.Yellow:
return yellow;
case Team.Orange:
return orange;
case Team.Green:
return green;
case Team.Bot:
return botColor;
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.teamName() == TeamName.Bot) {
return botColor;
}
if (player.teamName() == TeamName.Red) {
return red;
}
if (player.teamName() == TeamName.Blue) {
return blue;
if (player.team() !== null) {
return this.teamColor(player.team());
}
if (player.info().playerType == PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
+3 -3
View File
@@ -168,12 +168,12 @@ export class FakeHumanExecution implements Execution {
}
if (enemyborder.length == 0) {
if (this.random.chance(5)) {
if (this.random.chance(10)) {
this.sendBoatRandomly();
}
return;
}
if (this.random.chance(10)) {
if (this.random.chance(20)) {
this.sendBoatRandomly();
return;
}
@@ -598,7 +598,7 @@ export class FakeHumanExecution implements Execution {
const src = this.random.randElement(oceanShore);
const dst = this.randOceanShoreTile(src, 250);
const dst = this.randOceanShoreTile(src, 150);
if (dst == null) {
return;
}
+2 -1
View File
@@ -156,6 +156,7 @@ export class MirvExecution implements Execution {
randomLand(ref: TileRef, taken: TileRef[]): TileRef | null {
let tries = 0;
const mirvRange2 = this.mirvRange * this.mirvRange;
while (tries < 100) {
tries++;
const x = this.random.nextInt(
@@ -174,7 +175,7 @@ export class MirvExecution implements Execution {
if (!this.mg.isLand(tile)) {
continue;
}
if (this.mg.euclideanDist(tile, ref) > this.mirvRange) {
if (this.mg.euclideanDistSquared(tile, ref) > mirvRange2) {
continue;
}
if (this.mg.owner(tile) != this.targetPlayer) {
+24 -26
View File
@@ -52,30 +52,25 @@ export class NukeExecution implements Execution {
private tilesToDestroy(): Set<TileRef> {
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const rand = new PseudoRandom(this.mg.ticks());
const inner2 = magnitude.inner * magnitude.inner;
const outer2 = magnitude.outer * magnitude.outer;
return this.mg.bfs(this.dst, (_, n: TileRef) => {
const d = this.mg.euclideanDist(this.dst, n);
return (d <= magnitude.inner || rand.chance(2)) && d <= magnitude.outer;
const d2 = this.mg.euclideanDistSquared(this.dst, n);
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
}
private getAttackedTiles() {
const toDestroy = this.tilesToDestroy();
private breakAlliances(toDestroy: Set<TileRef>) {
const attacked = new Map<Player, number>();
for (const tile of toDestroy) {
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
const mp = this.mg.player(owner.id());
const prev = attacked.get(mp) ?? 0;
attacked.set(mp, prev + 1);
const prev = attacked.get(owner) ?? 0;
attacked.set(owner, prev + 1);
}
}
return attacked;
}
private breakAlliances() {
for (const [other, tilesDestroyed] of this.getAttackedTiles()) {
for (const [other, tilesDestroyed] of attacked) {
if (tilesDestroyed > 100 && this.nuke.type() != UnitType.MIRVWarhead) {
// Mirv warheads shouldn't break alliances
const alliance = this.player.allianceWith(other);
@@ -143,8 +138,6 @@ export class NukeExecution implements Execution {
return;
}
this.breakAlliances();
if (this.waitTicks > 0) {
this.waitTicks--;
return;
@@ -196,28 +189,32 @@ export class NukeExecution implements Execution {
private detonate() {
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const toDestroy = this.tilesToDestroy();
this.breakAlliances(toDestroy);
for (const tile of toDestroy) {
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
const mp = this.mg.player(owner.id());
mp.relinquish(tile);
mp.removeTroops(
this.mg.config().nukeDeathFactor(mp.troops(), mp.numTilesOwned()),
owner.relinquish(tile);
owner.removeTroops(
this.mg
.config()
.nukeDeathFactor(owner.troops(), owner.numTilesOwned()),
);
mp.removeWorkers(
this.mg.config().nukeDeathFactor(mp.workers(), mp.numTilesOwned()),
owner.removeWorkers(
this.mg
.config()
.nukeDeathFactor(owner.workers(), owner.numTilesOwned()),
);
mp.outgoingAttacks().forEach((attack) => {
owner.outgoingAttacks().forEach((attack) => {
const deaths = this.mg
.config()
.nukeDeathFactor(attack.troops(), mp.numTilesOwned());
.nukeDeathFactor(attack.troops(), owner.numTilesOwned());
attack.setTroops(attack.troops() - deaths);
});
mp.units(UnitType.TransportShip).forEach((attack) => {
owner.units(UnitType.TransportShip).forEach((attack) => {
const deaths = this.mg
.config()
.nukeDeathFactor(attack.troops(), mp.numTilesOwned());
.nukeDeathFactor(attack.troops(), owner.numTilesOwned());
attack.setTroops(attack.troops() - deaths);
});
}
@@ -227,6 +224,7 @@ export class NukeExecution implements Execution {
}
}
const outer2 = magnitude.outer * magnitude.outer;
for (const unit of this.mg.units()) {
if (
unit.type() != UnitType.AtomBomb &&
@@ -234,7 +232,7 @@ export class NukeExecution implements Execution {
unit.type() != UnitType.MIRVWarhead &&
unit.type() != UnitType.MIRV
) {
if (this.mg.euclideanDist(this.dst, unit.tile()) < magnitude.outer) {
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
unit.delete();
}
}
+7
View File
@@ -17,6 +17,7 @@ export class PortExecution implements Execution {
private mg: Game;
private port: Unit;
private random: PseudoRandom;
private checkOffset: number;
constructor(
private _owner: PlayerID,
@@ -31,6 +32,7 @@ export class PortExecution implements Execution {
}
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
this.checkOffset = mg.ticks() % 10;
}
tick(ticks: number): void {
@@ -55,6 +57,11 @@ export class PortExecution implements Execution {
this._owner = this.port.owner().id();
}
// Only check every 10 ticks for performance.
if ((this.mg.ticks() + this.checkOffset) % 10 != 0) {
return;
}
const totalNbOfPorts = this.mg.units(UnitType.Port).length;
if (
!this.random.chance(this.mg.config().tradeShipSpawnRate(totalNbOfPorts))
+4 -1
View File
@@ -105,7 +105,10 @@ export class SAMLauncherExecution implements Execution {
if (this.target && !this.sam.isCooldown() && !this.target.targetedBySAM()) {
this.sam.setCooldown(true);
const random = this.pseudoRandom.next();
const hit = random < this.mg.config().samHittingChance();
let hit = true;
if (this.target.type() != UnitType.AtomBomb) {
hit = random < this.mg.config().samHittingChance();
}
if (!hit) {
this.mg.displayMessage(
`Missile failed to intercept ${this.target.type()}`,
+4 -11
View File
@@ -1,12 +1,5 @@
import { GameEvent } from "../EventBus";
import {
Execution,
Game,
GameMode,
Player,
Team,
TeamName,
} from "../game/Game";
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {}
@@ -73,9 +66,9 @@ export class WinCheckExecution implements Execution {
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
if (max[0].name == TeamName.Bot) return;
this.mg.setWinner(max[0].name, this.mg.stats().stats());
console.log(`${max[0].name} has won the game`);
if (max[0] == Team.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
}
}
+7 -6
View File
@@ -37,9 +37,14 @@ export enum Difficulty {
Impossible = "Impossible",
}
export enum TeamName {
export enum Team {
Red = "Red",
Blue = "Blue",
Teal = "Teal",
Purple = "Purple",
Yellow = "Yellow",
Orange = "Orange",
Green = "Green",
Bot = "Bot",
}
@@ -75,10 +80,6 @@ export enum GameMode {
Team = "Team",
}
export interface Team {
name: TeamName;
}
export interface UnitInfo {
cost: (player: Player | PlayerView) => Gold;
// Determines if its owner changes when its tile is conquered.
@@ -443,7 +444,7 @@ export interface Game extends GameMap {
ticks(): Tick;
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
setWinner(winner: Player | TeamName, allPlayersStats: AllPlayersStats): void;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
config(): Config;
// Units
+17 -10
View File
@@ -20,7 +20,6 @@ import {
PlayerInfo,
PlayerType,
Team,
TeamName,
TerrainType,
TerraNullius,
Unit,
@@ -76,11 +75,8 @@ export class GameImpl implements Game {
private _stats: StatsImpl = new StatsImpl();
private playerTeams: Team[] = [
{ name: TeamName.Red },
{ name: TeamName.Blue },
];
private botTeam: Team = { name: TeamName.Bot };
private playerTeams: Team[] = [Team.Red, Team.Blue];
private botTeam: Team = Team.Bot;
constructor(
private _humans: PlayerInfo[],
@@ -103,6 +99,17 @@ export class GameImpl implements Game {
),
);
this.unitGrid = new UnitGrid(this._map);
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
if (numPlayerTeams < 2) throw new Error("Too few teams!");
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
if (numPlayerTeams >= 8) throw new Error("Too many teams!");
}
}
private addHumans() {
@@ -110,7 +117,7 @@ export class GameImpl implements Game {
this._humans.forEach((p) => this.addPlayer(p));
return;
}
const playerToTeam = assignTeams(this._humans);
const playerToTeam = assignTeams(this._humans, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team == "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
@@ -555,7 +562,7 @@ export class GameImpl implements Game {
});
}
setWinner(winner: Player | TeamName, allPlayersStats: AllPlayersStats): void {
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this.addUpdate({
type: GameUpdateType.Win,
winner: typeof winner === "string" ? winner : winner.smallID(),
@@ -684,8 +691,8 @@ export class GameImpl implements Game {
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDist(c1, c2);
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDistSquared(c1, c2);
}
bfs(
tile: TileRef,
+9 -8
View File
@@ -38,7 +38,7 @@ export interface GameMap {
forEachTile(fn: (tile: TileRef) => void): void;
manhattanDist(c1: TileRef, c2: TileRef): number;
euclideanDist(c1: TileRef, c2: TileRef): number;
euclideanDistSquared(c1: TileRef, c2: TileRef): number;
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
@@ -266,11 +266,10 @@ export class GameMapImpl implements GameMap {
Math.abs(this.x(c1) - this.x(c2)) + Math.abs(this.y(c1) - this.y(c2))
);
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return Math.sqrt(
Math.pow(this.x(c1) - this.x(c2), 2) +
Math.pow(this.y(c1) - this.y(c2), 2),
);
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
const x = this.x(c1) - this.x(c2);
const y = this.y(c1) - this.y(c2);
return x * x + y * y;
}
bfs(
tile: TileRef,
@@ -322,8 +321,10 @@ export function euclDistFN(
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
const dist2 = dist * dist;
if (!center) {
return (gm: GameMap, n: TileRef) => gm.euclideanDist(root, n) <= dist;
return (gm: GameMap, n: TileRef) =>
gm.euclideanDistSquared(root, n) <= dist2;
} else {
return (gm: GameMap, n: TileRef) => {
// shifts the root tiles coordinates by -0.5 so that its “center”
@@ -333,7 +334,7 @@ export function euclDistFN(
const rootY = gm.y(root) - 0.5;
const dx = gm.x(n) - rootX;
const dy = gm.y(n) - rootY;
return Math.sqrt(dx * dx + dy * dy) <= dist;
return dx * dx + dy * dy <= dist2;
};
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ import {
NameViewData,
PlayerID,
PlayerType,
TeamName,
Team,
Tick,
UnitType,
} from "./Game";
@@ -92,7 +92,7 @@ export interface PlayerUpdate {
name: string;
displayName: string;
id: PlayerID;
teamName?: TeamName;
team?: Team;
smallID: number;
playerType: PlayerType;
isAlive: boolean;
@@ -161,7 +161,7 @@ export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
// Player id or team name.
winner: number | TeamName;
winner: number | Team;
winnerType: "player" | "team";
}
+8 -8
View File
@@ -15,7 +15,7 @@ import {
PlayerInfo,
PlayerProfile,
PlayerType,
TeamName,
Team,
TerrainType,
TerraNullius,
Tick,
@@ -177,8 +177,8 @@ export class PlayerView {
id(): PlayerID {
return this.data.id;
}
teamName(): TeamName {
return this.data.teamName;
team(): Team | null {
return this.data.team ?? null;
}
type(): PlayerType {
return this.data.playerType;
@@ -223,9 +223,7 @@ export class PlayerView {
}
isOnSameTeam(other: PlayerView): boolean {
return (
this.data.teamName != null && this.data.teamName == other.data.teamName
);
return this.data.team != null && this.data.team == other.data.team;
}
isFriendly(other: PlayerView): boolean {
@@ -529,8 +527,8 @@ export class GameView implements GameMap {
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDist(c1, c2);
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDistSquared(c1, c2);
}
bfs(
tile: TileRef,
@@ -552,6 +550,8 @@ export class GameView implements GameMap {
}
focusedPlayer(): PlayerView | null {
// TODO: renable when performance issues are fixed.
return this.myPlayer();
if (userSettings.focusLocked()) return this.myPlayer();
return this._focusedPlayer;
}
+8 -2
View File
@@ -127,7 +127,7 @@ export class PlayerImpl implements Player {
name: this.name(),
displayName: this.displayName(),
id: this.id(),
teamName: this.team()?.name,
team: this.team(),
smallID: this.smallID(),
playerType: this.type(),
isAlive: this.isAlive(),
@@ -596,7 +596,7 @@ export class PlayerImpl implements Player {
if (this.team() == null || other.team() == null) {
return false;
}
return this._team.name == other.team().name;
return this._team == other.team();
}
isFriendly(other: Player): boolean {
@@ -768,6 +768,12 @@ export class PlayerImpl implements Player {
}
nukeSpawn(tile: TileRef): TileRef | false {
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) {
return false;
}
}
// only get missilesilos that are not on cooldown
const spawns = this.units(UnitType.MissileSilo)
.filter((silo) => {
+29 -29
View File
@@ -1,11 +1,11 @@
import { PlayerInfo, Team, TeamName } from "./Game";
import { PlayerInfo, Team } from "./Game";
export function assignTeams(
players: PlayerInfo[],
teams: Team[],
): Map<PlayerInfo, Team | "kicked"> {
const result = new Map<PlayerInfo, Team | "kicked">();
let redTeamCount = 0;
let blueTeamCount = 0;
const teamPlayerCount = new Map<Team, number>();
// Group players by clan
const clanGroups = new Map<string, PlayerInfo[]>();
@@ -23,7 +23,7 @@ export function assignTeams(
}
}
const maxTeamSize = Math.ceil(players.length / 2);
const maxTeamSize = Math.ceil(players.length / teams.length);
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
@@ -33,38 +33,38 @@ export function assignTeams(
// First, assign clan players
for (const [_, clanPlayers] of sortedClans) {
// Try to keep the clan together on the team with fewer players
if (redTeamCount <= blueTeamCount) {
// Assign to red team
for (const player of clanPlayers) {
if (redTeamCount < maxTeamSize) {
redTeamCount++;
result.set(player, { name: TeamName.Red });
} else {
result.set(player, "kicked");
}
}
} else {
// Assign to blue team
for (const player of clanPlayers) {
if (blueTeamCount < maxTeamSize) {
blueTeamCount++;
result.set(player, { name: TeamName.Blue });
} else {
result.set(player, "kicked");
}
let team: Team | null = null;
let teamSize = 0;
for (const t of teams) {
const p = teamPlayerCount.get(t) ?? 0;
if (team !== null && teamSize <= p) continue;
teamSize = p;
team = t;
}
for (const player of clanPlayers) {
if (teamSize < maxTeamSize) {
teamSize++;
result.set(player, team);
} else {
result.set(player, "kicked");
}
}
teamPlayerCount.set(team, teamSize);
}
// Then, assign non-clan players to balance teams
for (const player of noClanPlayers) {
if (redTeamCount <= blueTeamCount) {
redTeamCount++;
result.set(player, { name: TeamName.Red });
} else {
blueTeamCount++;
result.set(player, { name: TeamName.Blue });
let team: Team | null = null;
let teamSize = 0;
for (const t of teams) {
const p = teamPlayerCount.get(t) ?? 0;
if (team !== null && teamSize <= p) continue;
teamSize = p;
team = t;
}
teamPlayerCount.set(team, teamSize + 1);
result.set(player, team);
}
return result;
+2
View File
@@ -86,6 +86,8 @@ export class UnitImpl implements Unit {
}
this._lastTile = this._tile;
this._tile = tile;
this.mg.removeUnit(this);
this.mg.addUnit(this);
this.mg.addUpdate(this.toUpdate());
}
setTroops(troops: number): void {
+3 -1
View File
@@ -25,7 +25,9 @@ export class UserSettings {
}
focusLocked() {
return this.get("settings.focusLocked", false);
return false;
// TODO: renable when performance issues are fixed.
this.get("settings.focusLocked", true);
}
toggleLeftClickOpenMenu() {