Merge branch 'openfrontio:main' into main

This commit is contained in:
Samuel Safahi
2025-04-27 23:54:35 -07:00
committed by GitHub
23 changed files with 244 additions and 151 deletions
+69 -34
View File
@@ -1,16 +1,18 @@
name: 🚀 Deploy
on:
# Allow contributors to schedule manual deployments.
# Permission to deploy can be restricted by requiring approval in environment configuration.
workflow_dispatch:
inputs:
target_environment:
description: "Deployment Environment"
target_domain:
description: "Deployment Domain"
required: true
default: "staging"
default: "openfront.dev"
type: choice
options:
- prod
- staging
- openfront.io
- openfront.dev
target_host:
description: "Deployment Host"
required: true
@@ -25,53 +27,86 @@ on:
required: false
default: ""
type: string
# Automatic deployment when pushing to main
push:
branches:
- main
jobs:
deploy:
# Don't deploy on push if this is a fork
if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'openfrontio/OpenFrontIO' }}
# Use different logic based on event type
name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
name: Deploy to ${{ inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain) || inputs.target_domain }}
runs-on: ubuntu-latest
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
environment: ${{ inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain) || inputs.target_domain }}
steps:
- uses: actions/checkout@v4
- name: Update deployment status
env:
FQDN: ${{ inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain) || inputs.target_domain }}
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
### In progress :ship:
Deploying from $GITHUB_REF to $FQDN
EOF
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: |
- name: Create SSH private key
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST_STAGING }} >> ~/.ssh/known_hosts
- name: Deploy
env:
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }}
DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
DOMAIN: ${{ inputs.target_domain }}
ENV: ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }}
MON_PASSWORD: ${{ secrets.MON_PASSWORD }}
MON_USERNAME: ${{ secrets.MON_USERNAME }}
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US: ${{ secrets.SERVER_HOST_US }}
SSH_KEY: ~/.ssh/id_rsa
SUBDOMAIN: ${{ github.event_name == 'workflow_dispatch' && inputs.target_subdomain || 'main' }}
VERSION_TAG: latest
run: |
echo "::group::deploy.sh"
./deploy.sh "$ENV" "$HOST" "$SUBDOMAIN"
echo "::endgroup::"
- name: Update deployment status ✅
if: success()
env:
FQDN: ${{ inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain) || inputs.target_domain }}
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
### Success! :rocket:
# Determine environment based on trigger type
TARGET_ENV="${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}"
TARGET_HOST="${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }}"
TARGET_SUBDOMAIN="${{ github.event_name == 'workflow_dispatch' && inputs.target_subdomain || 'main' }}"
cat >.env.$TARGET_ENV <<EOF
ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID=${{ secrets.CF_ACCOUNT_ID }}
CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}
DOCKER_REPO=${{ vars.DOCKERHUB_REPO }}
DOCKER_USERNAME=${{ vars.DOCKERHUB_USERNAME }}
DOMAIN=${{ vars.DOMAIN }}
MON_PASSWORD=${{ secrets.MON_PASSWORD }}
MON_USERNAME=${{ secrets.MON_USERNAME }}
R2_ACCESS_KEY=${{ secrets.R2_ACCESS_KEY }}
R2_BUCKET=${{ secrets.R2_BUCKET }}
R2_SECRET_KEY=${{ secrets.R2_SECRET_KEY }}
SERVER_HOST_STAGING=${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US=${{ secrets.SERVER_HOST_US }}
SERVER_HOST_EU=${{ secrets.SERVER_HOST_EU }}
SSH_KEY=~/.ssh/id_rsa
VERSION_TAG="latest"
Deployed from $GITHUB_REF to $FQDN
EOF
- name: Update deployment status ❌
if: failure()
env:
FQDN: ${{ inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain) || inputs.target_domain }}
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
### Failure! :fire:
./deploy.sh $TARGET_ENV $TARGET_HOST $TARGET_SUBDOMAIN
echo "Deployed to $TARGET_ENV environment on $TARGET_HOST host with subdomain $TARGET_SUBDOMAIN"
Unable to deploy from $GITHUB_REF to $FQDN
EOF
+1 -5
View File
@@ -57,9 +57,6 @@ fi
if [ -f .env.$ENV ]; then
echo "Loading $ENV-specific configuration from .env.$ENV file..."
export $(grep -v '^#' .env.$ENV | xargs)
else
echo "Error: Environment file .env.$ENV not found"
exit 1
fi
if [ "$HOST" == "staging" ]; then
@@ -140,7 +137,6 @@ cat > $REMOTE_UPDATE_PATH/.env << 'EOL'
GAME_ENV=$ENV
ENV=$ENV
HOST=$HOST
SUBDOMAIN=$SUBDOMAIN
DOCKER_IMAGE=$DOCKER_IMAGE
DOCKER_TOKEN=$DOCKER_TOKEN
ADMIN_TOKEN=$ADMIN_TOKEN
@@ -165,4 +161,4 @@ fi
print_header "DEPLOYMENT COMPLETED SUCCESSFULLY"
echo "✅ New version deployed to ${ENV} environment in ${HOST} with subdomain ${SUBDOMAIN}!"
echo "🌐 Check your server to verify the deployment."
echo "======================================================="
echo "======================================================="
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Япония и съседи",
"betweentwoseas": "Между Две Морета",
"knownworld": "Познат Свят",
"faroeislands": "Фарьорски острови"
"faroeislands": "Фарьорски острови",
"europeclassic": "Европа (класическа)"
},
"private_lobby": {
"title": "Присъединяване към частна игра",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japan and Neighbors",
"betweentwoseas": "Between Two Seas",
"knownworld": "Known World",
"faroeislands": "Faroe Islands"
"faroeislands": "Faroe Islands",
"europeclassic": "Europe (classic)"
},
"map_categories": {
"continental": "Continental",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japanio kaj najbaroj",
"betweentwoseas": "Inter du maroj",
"knownworld": "Konata Mondo",
"faroeislands": "Ferooj"
"faroeislands": "Ferooj",
"europeclassic": "Eŭropo (klasika)"
},
"private_lobby": {
"title": "Aliĝi al privata salono",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japon et pays voisins",
"betweentwoseas": "Entre deux mers",
"knownworld": "Monde connu",
"faroeislands": "Îles Féroé"
"faroeislands": "Îles Féroé",
"europeclassic": "Europe (classique)"
},
"private_lobby": {
"title": "Rejoindre un salon privé",
+3 -2
View File
@@ -115,13 +115,14 @@
"britannia": "ブリタニア",
"gatewaytotheatlantic": "西ヨーロッパ",
"australia": "オーストラリア",
"random": "アイスランド",
"random": "ランダム",
"iceland": "アイスランド",
"pangaea": "パンゲア",
"japan": "日本とその隣国",
"betweentwoseas": "2つの海の間",
"knownworld": "知られてる世界",
"faroeislands": "フェロー諸島"
"faroeislands": "フェロー諸島",
"europeclassic": "ヨーロッパ (クラシック)"
},
"private_lobby": {
"title": "ランダム",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japan en buren",
"betweentwoseas": "Tussen twee zeeën",
"knownworld": "Bekende Wereld",
"faroeislands": "Faeröer eilanden"
"faroeislands": "Faeröer eilanden",
"europeclassic": "Europa (klassiek)"
},
"private_lobby": {
"title": "Privélobby toetreden",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japonia i sąsiedzi",
"betweentwoseas": "Między dwoma morzami",
"knownworld": "Znany Świat",
"faroeislands": "Wyspy Owcze"
"faroeislands": "Wyspy Owcze",
"europeclassic": ""
},
"private_lobby": {
"title": "Dołącz do prywatnego Lobby",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Япония и соседи",
"betweentwoseas": "Между двух морей",
"knownworld": "Известный мир",
"faroeislands": "Фарерские острова"
"faroeislands": "Фарерские острова",
"europeclassic": "Европа (классическая)"
},
"private_lobby": {
"title": "Присоединиться к приватному лобби",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Japan i susjedi",
"betweentwoseas": "Između dva mora",
"knownworld": "Poznati svijet",
"faroeislands": "Farska ostrva"
"faroeislands": "Farska ostrva",
"europeclassic": "Evropa (klasika)"
},
"private_lobby": {
"title": "Pridruži se privatnoj čekaonici",
+2 -1
View File
@@ -121,7 +121,8 @@
"japan": "Японія та сусіди",
"betweentwoseas": "Поміж двох морів",
"knownworld": "Відомий світ",
"faroeislands": "Фарерські острови"
"faroeislands": "Фарерські острови",
"europeclassic": "Європа (класична)"
},
"private_lobby": {
"title": "Приєднатися до приватного лобі",
+5
View File
@@ -194,6 +194,11 @@ label.option-card:hover {
margin: 8px 0px 0px 0px;
}
#lobbyIdInput {
font-family: monospace;
font-weight: 600;
}
.lobby-id-button {
display: flex;
align-items: center;
+1 -1
View File
@@ -671,7 +671,7 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
return 18;
return 12;
}
shellLifetime(): number {
+4 -17
View File
@@ -21,13 +21,6 @@ export class AttackExecution implements Execution {
private active: boolean = true;
private toConquer: PriorityQueue<TileContainer> =
new PriorityQueue<TileContainer>((a: TileContainer, b: TileContainer) => {
if (a.priority == b.priority) {
if (a.tick == b.tick) {
return 0;
// return this.random.nextInt(-1, 1)
}
return a.tick - b.tick;
}
return a.priority - b.priority;
});
private random = new PseudoRandom(123);
@@ -227,8 +220,6 @@ export class AttackExecution implements Execution {
this.target,
this.border.size + this.random.nextInt(0, 5),
);
// consolex.log(`num tiles per tick: ${numTilesPerTick}`)
// consolex.log(`num execs: ${this.mg.executions().length}`)
while (numTilesPerTick > 0) {
if (this.attack.troops() < 1) {
@@ -279,13 +270,9 @@ export class AttackExecution implements Execution {
continue;
}
this.border.add(neighbor);
let numOwnedByMe = this.mg
const numOwnedByMe = this.mg
.neighbors(neighbor)
.filter((t) => this.mg.owner(t) == this._owner).length;
const dist = 0;
if (numOwnedByMe > 2) {
numOwnedByMe = 10;
}
let mag = 0;
switch (this.mg.terrainType(tile)) {
case TerrainType.Plains:
@@ -301,8 +288,9 @@ export class AttackExecution implements Execution {
this.toConquer.enqueue(
new TileContainer(
neighbor,
dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag,
this.mg.ticks(),
(this.random.nextInt(0, 7) + 10) *
(1 - numOwnedByMe * 0.5 + mag / 2) +
this.mg.ticks(),
),
);
}
@@ -355,6 +343,5 @@ class TileContainer {
constructor(
public readonly tile: TileRef,
public readonly priority: number,
public readonly tick: number,
) {}
}
+20 -11
View File
@@ -6,15 +6,21 @@ import { BotBehavior } from "./utils/BotBehavior";
export class BotExecution implements Execution {
private active = true;
private random: PseudoRandom;
private attackRate: number;
private mg: Game;
private neighborsTerraNullius = true;
private behavior: BotBehavior | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(10, 50);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
}
activeDuringSpawnPhase(): boolean {
@@ -27,17 +33,21 @@ export class BotExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (!this.bot.isAlive()) {
this.active = false;
return;
}
if (ticks % this.attackRate != 0) {
return;
}
if (this.behavior === null) {
this.behavior = new BotBehavior(this.random, this.mg, this.bot, 1 / 20);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.bot,
this.triggerRatio,
this.reserveRatio,
);
}
this.behavior.handleAllianceRequests();
@@ -65,15 +75,14 @@ export class BotExecution implements Execution {
this.neighborsTerraNullius = false;
}
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
this.behavior.sendAttack(enemy);
}
owner(): Player {
return this.bot;
}
isActive(): boolean {
return this.active;
}
@@ -80,6 +80,9 @@ export class DefensePostExecution implements Execution {
this.target = null;
}
// TODO: Reconsider how/if defense posts target ships.
return;
const ships = this.mg
.nearbyUnits(
this.post.tile(),
+39 -36
View File
@@ -35,6 +35,11 @@ export class FakeHumanExecution implements Execution {
private mg: Game;
private player: Player = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
@@ -46,6 +51,10 @@ export class FakeHumanExecution implements Execution {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
}
init(mg: Game) {
@@ -96,17 +105,18 @@ export class FakeHumanExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (this.mg.inSpawnPhase()) {
if (ticks % this.random.nextInt(5, 30) == 0) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
return;
}
if (this.player == null) {
this.player = this.mg.players().find((p) => p.id() == this.playerInfo.id);
if (this.player == null) {
@@ -121,7 +131,13 @@ export class FakeHumanExecution implements Execution {
if (this.behavior === null) {
// Player is unavailable during init()
this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.player,
this.triggerRatio,
this.reserveRatio,
);
}
if (this.firstMove) {
@@ -130,10 +146,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (ticks % this.random.nextInt(40, 80) != 0) {
return;
}
if (
this.player.troops() > 100_000 &&
this.player.targetTroopRatio() > 0.7
@@ -146,7 +158,10 @@ export class FakeHumanExecution implements Execution {
this.handleEnemies();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
}
private maybeAttack() {
const enemyborder = Array.from(this.player.borderTiles())
.flatMap((t) => this.mg.neighbors(t))
.filter(
@@ -174,9 +189,9 @@ export class FakeHumanExecution implements Execution {
const enemies = enemiesWithTN
.filter((o) => o.isPlayer())
.map((o) => o as Player)
.sort((a, b) => a.troops() - b.troops());
// 5% chance to send a random alliance request
if (this.random.chance(20)) {
const toAlly = this.random.randElement(enemies);
if (this.player.canSendAllianceRequest(toAlly)) {
@@ -211,7 +226,7 @@ export class FakeHumanExecution implements Execution {
}
}
shouldDiscourageAttack(other: Player) {
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
@@ -227,6 +242,8 @@ export class FakeHumanExecution implements Execution {
}
handleEnemies() {
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
if (!enemy) return;
@@ -257,8 +274,7 @@ export class FakeHumanExecution implements Execution {
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length == 0 ||
this.player.gold() <
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
other.type() == PlayerType.Bot ||
this.player.isOnSameTeam(other)
) {
@@ -397,36 +413,21 @@ export class FakeHumanExecution implements Execution {
}
return;
}
this.maybeSpawnStructure(
UnitType.City,
2,
(t) => new ConstructionExecution(this.player.id(), t, UnitType.City),
);
this.maybeSpawnStructure(UnitType.City, 2);
if (this.maybeSpawnWarship()) {
return;
}
if (!this.mg.config().disableNukes()) {
this.maybeSpawnStructure(
UnitType.MissileSilo,
1,
(t) =>
new ConstructionExecution(this.player.id(), t, UnitType.MissileSilo),
);
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
}
private maybeSpawnStructure(
type: UnitType,
maxNum: number,
build: (tile: TileRef) => Execution,
) {
private maybeSpawnStructure(type: UnitType, maxNum: number) {
const units = this.player.units(type);
if (units.length >= maxNum) {
return;
}
if (
this.player.gold() < this.mg.config().unitInfo(type).cost(this.player)
) {
if (this.player.gold() < this.cost(type)) {
return;
}
const tile = this.randTerritoryTile(this.player);
@@ -437,7 +438,9 @@ export class FakeHumanExecution implements Execution {
if (canBuild == false) {
return;
}
this.mg.addExecution(build(tile));
this.mg.addExecution(
new ConstructionExecution(this.player.id(), tile, type),
);
}
private maybeSpawnWarship(): boolean {
+12 -17
View File
@@ -17,9 +17,6 @@ export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
private target: Unit = null;
private warheadTargets: Unit[] = [];
private searchRangeRadius = 80;
// As MIRV go very fast we have to detect them very early but we only
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
@@ -119,7 +116,7 @@ export class SAMLauncherExecution implements Execution {
this.pseudoRandom = new PseudoRandom(this.sam.id());
}
this.warheadTargets = this.mg
const mirvWarheadTargets = this.mg
.nearbyUnits(
this.sam.tile(),
this.MIRVWarheadSearchRadius,
@@ -136,8 +133,9 @@ export class SAMLauncherExecution implements Execution {
this.MIRVWarheadProtectionRadius,
);
if (this.warheadTargets.length == 0) {
this.target = this.getSingleTarget();
let target: Unit | null = null;
if (mirvWarheadTargets.length == 0) {
target = this.getSingleTarget();
}
if (
@@ -147,16 +145,14 @@ export class SAMLauncherExecution implements Execution {
this.sam.setCooldown(false);
}
const isSingleTarget = this.target && !this.target.targetedBySAM();
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || this.warheadTargets.length > 0) &&
(isSingleTarget || mirvWarheadTargets.length > 0) &&
!this.sam.isCooldown()
) {
this.sam.setCooldown(true);
const type =
this.warheadTargets.length > 0
? UnitType.MIRVWarhead
: this.target.type();
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target.type();
const random = this.pseudoRandom.next();
const hit = this.isHit(type, random);
if (!hit) {
@@ -166,26 +162,25 @@ export class SAMLauncherExecution implements Execution {
this.sam.owner().id(),
);
} else {
if (this.warheadTargets.length > 0) {
if (mirvWarheadTargets.length > 0) {
// Message
this.mg.displayMessage(
`${this.warheadTargets.length} MIRV warheads intercepted`,
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
MessageType.SUCCESS,
this.sam.owner().id(),
);
// Delete warheads
this.warheadTargets.forEach((u) => u.delete());
mirvWarheadTargets.forEach((u) => u.delete());
} else {
this.target.setTargetedBySAM(true);
target.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
this.sam.tile(),
this.sam.owner(),
this.sam,
this.target,
target,
),
);
this.warheadTargets = [];
}
}
}
+65 -14
View File
@@ -19,7 +19,8 @@ export class BotBehavior {
private random: PseudoRandom,
private game: Game,
private player: Player,
private attackRatio: number,
private triggerRatio: number,
private reserveRatio: number,
) {}
handleAllianceRequests() {
@@ -39,6 +40,24 @@ export class BotBehavior {
);
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
}
}
checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length > 0) {
this.enemy = incomingAttacks
.sort((a, b) => b.troops() - a.troops())[0]
.attacker();
this.enemyUpdated = this.game.ticks();
}
}
assistAllies() {
outer: for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
@@ -66,9 +85,11 @@ export class BotBehavior {
}
selectEnemy(): Player | null {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
}
// Prefer neighboring bots
@@ -100,24 +121,54 @@ export class BotBehavior {
}
selectRandomEnemy(): Player | TerraNullius | null {
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
}
this.enemy = neighbor;
this.enemyUpdated = this.game.ticks();
}
// Select a traitor as an enemy
const traitors = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const odds = this.player.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.enemy = toAttack;
this.enemyUpdated = this.game.ticks();
}
}
return neighbor;
}
return null;
// Sanity check, don't attack our allies or teammates
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.enemy = null;
}
return this.enemy;
}
sendAttack(target: Player | TerraNullius) {
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
const troops = this.player.troops() * this.attackRatio;
const maxPop = this.game.config().maxPopulation(this.player);
const maxTroops = maxPop * this.player.targetTroopRatio();
const targetTroops = maxTroops * this.reserveRatio;
const troops = this.player.troops() - targetTroops;
if (troops < 1) return;
this.game.addExecution(
new AttackExecution(
+1 -1
View File
@@ -85,7 +85,6 @@ export class GameImpl implements Game {
nationMap: NationMap,
private _config: Config,
) {
this.addHumans();
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
@@ -112,6 +111,7 @@ export class GameImpl implements Game {
if (numPlayerTeams >= 8)
throw new Error(`Too many teams: ${numPlayerTeams}`);
}
this.addHumans();
}
private addHumans() {
+2 -3
View File
@@ -469,12 +469,12 @@ export class PlayerImpl implements Player {
this.mg.target(this, other);
}
targets(): PlayerImpl[] {
targets(): Player[] {
return this.targets_
.filter(
(t) => this.mg.ticks() - t.tick < this.mg.config().targetDuration(),
)
.map((t) => t.target as PlayerImpl);
.map((t) => t.target);
}
transitiveTargets(): Player[] {
@@ -809,7 +809,6 @@ export class PlayerImpl implements Player {
}
// only get missilesilos that are not on cooldown
const spawns = this.units(UnitType.MissileSilo)
.map((u) => u as Unit)
.filter((silo) => {
return !silo.isCooldown();
})
+1 -1
View File
@@ -88,9 +88,9 @@ export class UnitImpl implements Unit {
if (tile == null) {
throw new Error("tile cannot be null");
}
this.mg.removeUnit(this);
this._lastTile = this._tile;
this._tile = tile;
this.mg.removeUnit(this);
this.mg.addUnit(this);
this.mg.addUpdate(this.toUpdate());
}