From 80a82664e145691b5f040795b6ad0ec2ba289934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Tue, 21 Feb 2017 22:16:18 +0100 Subject: [PATCH] Started work on TacticalAI and added AI tournament --- out/ai.html | 20 ++++++++ src/common | 2 +- src/core/Battle.ts | 11 ++++- src/core/ai/AITournament.ts | 84 ++++++++++++++++++++++++++++++++++ src/core/ai/AbstractAI.ts | 65 ++++++++++++-------------- src/core/ai/BullyAI.spec.ts | 17 +++---- src/core/ai/BullyAI.ts | 32 ++++++------- src/core/ai/TacticalAI.spec.ts | 34 ++++++++++++++ src/core/ai/TacticalAI.ts | 63 +++++++++++++++++++++++++ src/ui/BaseView.ts | 5 +- src/ui/battle/BattleView.ts | 6 +++ 11 files changed, 267 insertions(+), 72 deletions(-) create mode 100644 out/ai.html create mode 100644 src/core/ai/AITournament.ts create mode 100644 src/core/ai/TacticalAI.spec.ts create mode 100644 src/core/ai/TacticalAI.ts diff --git a/out/ai.html b/out/ai.html new file mode 100644 index 0000000..8e78a09 --- /dev/null +++ b/out/ai.html @@ -0,0 +1,20 @@ + + + + + + SpaceTac - AI Tournament + + + + + + + + + + \ No newline at end of file diff --git a/src/common b/src/common index 915a273..9a82049 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 915a2736524c6087854677311af34607bf3eef78 +Subproject commit 9a82049a4a898cfb3eb939156cb07f94e66cce2e diff --git a/src/core/Battle.ts b/src/core/Battle.ts index e9c5559..7a52b35 100644 --- a/src/core/Battle.ts +++ b/src/core/Battle.ts @@ -16,6 +16,9 @@ module TS.SpaceTac { // List of ships, sorted by their initiative throw play_order: Ship[]; + // Current turn + turn: number; + // Current ship whose turn it is to play playing_ship_index: number; playing_ship: Ship; @@ -181,6 +184,9 @@ module TS.SpaceTac { } if (this.playing_ship) { + if (this.playing_ship_index == 0) { + this.turn += 1; + } this.playing_ship.startTurn(); } @@ -195,9 +201,9 @@ module TS.SpaceTac { playAI(ai: AbstractAI | null = null) { if (!ai) { // TODO Use an AI adapted to the fleet - ai = new BullyAI(this.playing_ship.fleet); + ai = new BullyAI(this.playing_ship, this.timer); } - ai.playShip(this.playing_ship, this.timer); + ai.play(); } // Start the battle @@ -205,6 +211,7 @@ module TS.SpaceTac { // This will not add any event to the battle log start(): void { this.ended = false; + this.turn = 0; this.placeShips(); this.throwInitiative(); this.play_order.forEach((ship: Ship) => { diff --git a/src/core/ai/AITournament.ts b/src/core/ai/AITournament.ts new file mode 100644 index 0000000..4bc2419 --- /dev/null +++ b/src/core/ai/AITournament.ts @@ -0,0 +1,84 @@ +module TS.SpaceTac { + /** + * Tournament to test AIs against each other, over a lot of battles + */ + export class AITournament { + duels: [AbstractAI, number, AbstractAI, number][] = []; + + constructor() { + this.addDuel(new AbstractAI(null), new BullyAI(null)); + this.addDuel(new AbstractAI(null), new TacticalAI(null)); + this.addDuel(new BullyAI(null), new TacticalAI(null)); + + this.start(); + } + + addDuel(ai1: AbstractAI, ai2: AbstractAI) { + ai1.timer = Timer.synchronous; + ai2.timer = Timer.synchronous; + this.duels.push([ai1, 0, ai2, 0]); + } + + start(rounds = 100) { + if (this.duels.length == 0) { + console.error("No duel to perform"); + return; + } + + while (rounds--) { + this.duels.forEach(duel => { + console.log(`${duel[0].name} vs ${duel[2].name}`); + + let winner = this.doOneBattle(duel[0], duel[2]); + + if (winner) { + if (winner == duel[0]) { + duel[1] += 1; + } else { + duel[3] += 1; + } + console.log(` => ${winner.name} wins`); + } else { + console.log(" => draw"); + } + }); + } + + console.log("--------------------------------------------------------"); + console.log("Final result :"); + this.duels.forEach(duel => { + let message = `${duel[0].name} ${duel[1]} - ${duel[2].name} ${duel[3]}` + console.log(message); + if (typeof document != "undefined") { + let line = document.createElement("div"); + line.textContent = message; + document.body.appendChild(line); + } + }); + } + + doOneBattle(ai1: AbstractAI, ai2: AbstractAI): AbstractAI | null { + let battle = Battle.newQuickRandom(); + let playing = battle.playing_ship; + while (!battle.ended && battle.turn < 100) { + //console.debug(`Turn ${battle.turn} - Ship ${battle.play_order.indexOf(playing)}`); + let ai = (playing.fleet == battle.fleets[0]) ? ai1 : ai2; + + ai.ship = playing; + ai.play(); + + if (!battle.ended && battle.playing_ship == playing) { + console.error(`${ai.name} did not end its turn !`); + battle.advanceToNextShip(); + } + playing = battle.playing_ship; + } + + if (battle.ended && !battle.outcome.draw) { + return (battle.outcome.winner == battle.fleets[0]) ? ai1 : ai2; + } else { + return null; + } + } + } +} diff --git a/src/core/ai/AbstractAI.ts b/src/core/ai/AbstractAI.ts index a13d90a..e45cda4 100644 --- a/src/core/ai/AbstractAI.ts +++ b/src/core/ai/AbstractAI.ts @@ -1,15 +1,12 @@ module TS.SpaceTac { // Base class for all Artificial Intelligence interaction export class AbstractAI { - // The fleet controlled by this AI - fleet: Fleet; + // Name of the AI + name: string; // Current ship being played ship: Ship; - // Set this to false to force synchronous behavior (playShip will block until finished) - async: boolean; - // Time at which work as started started: number; @@ -17,7 +14,7 @@ module TS.SpaceTac { random: RandomGenerator; // Timer for scheduled calls - timer = Timer.global; + timer: Timer; // Queue of work items to process // Work items will be called successively, leaving time for other processing between them. @@ -25,45 +22,43 @@ module TS.SpaceTac { // When the queue is empty, the ship will end its turn. private workqueue: Function[]; - constructor(fleet: Fleet) { - this.fleet = fleet; - this.async = true; + constructor(ship: Ship, timer = Timer.global, name: string = null) { + this.name = name || classname(this); + this.ship = ship; this.workqueue = []; this.random = new RandomGenerator(); + this.timer = timer; } // Play a ship turn // This will start asynchronous work. The AI will then call action methods, then advanceToNextShip to // indicate it has finished. - playShip(ship: Ship, timer: Timer | null = null): void { - this.ship = ship; + play(): void { this.workqueue = []; this.started = (new Date()).getTime(); - if (timer) { - this.timer = timer; - } this.initWork(); if (this.workqueue.length > 0) { this.processNextWorkItem(); + } else { + this.endTurn(); } } // Add a work item to the work queue addWorkItem(item: Function, delay = 100): void { - if (!this.async) { + if (this.timer.isSynchronous()) { if (item) { item(); } - return; + } else { + var wrapped = () => { + if (item) { + item(); + } + this.processNextWorkItem(); + }; + this.workqueue.push(() => this.timer.schedule(delay, wrapped)); } - - var wrapped = () => { - if (item) { - item(); - } - this.processNextWorkItem(); - }; - this.workqueue.push(() => this.timer.schedule(delay, wrapped)); } // Initially fill the work queue. @@ -99,24 +94,22 @@ module TS.SpaceTac { * Effectively end the current ship's turn */ private effectiveEndTurn() { - this.ship.endTurn(); - this.ship = null; - this.fleet.battle.advanceToNextShip(); + if (this.ship.playing) { + let battle = this.ship.getBattle(); + this.ship.endTurn(); + this.ship = null; + if (battle) { + battle.advanceToNextShip(); + } + } } /** * Called when we want the AI decides to end the ship turn */ private endTurn(): void { - if (this.async) { - var duration = this.getDuration(); - if (duration < 2000) { - // Delay, as to make the AI not too fast to play - this.timer.schedule(2000 - duration, () => this.effectiveEndTurn()); - return; - } - } - this.effectiveEndTurn(); + // Delay, as to make the AI not too fast to play + this.timer.schedule(2000 - this.getDuration(), () => this.effectiveEndTurn()); } } } diff --git a/src/core/ai/BullyAI.spec.ts b/src/core/ai/BullyAI.spec.ts index d1bf0cf..2e029dc 100644 --- a/src/core/ai/BullyAI.spec.ts +++ b/src/core/ai/BullyAI.spec.ts @@ -9,8 +9,7 @@ module TS.SpaceTac.Specs { var random = new RandomGenerator(0, 0.5, 1); battle.throwInitiative(random); - var ai = new BullyAI(battle.fleets[0]); - ai.ship = battle.fleets[0].ships[0]; + var ai = new BullyAI(battle.fleets[0].ships[0], Timer.synchronous); var result = ai.listAllEnemies(); expect(result).toEqual([battle.fleets[1].ships[1], battle.fleets[1].ships[0]]); @@ -19,7 +18,7 @@ module TS.SpaceTac.Specs { it("lists weapons", function () { var ship = new Ship(); - var ai = new BullyAI(ship.fleet); + var ai = new BullyAI(ship, Timer.synchronous); ai.ship = ship; var result = ai.listAllWeapons(); @@ -49,7 +48,7 @@ module TS.SpaceTac.Specs { ship.values.power.setMaximal(10); ship.values.power.set(8); var enemy = new Ship(); - var ai = new BullyAI(ship.fleet); + var ai = new BullyAI(ship, Timer.synchronous); ai.ship = ship; ai.move_margin = 0; var weapon = new Equipment(SlotType.Weapon); @@ -140,7 +139,7 @@ module TS.SpaceTac.Specs { battle.fleets[1].addShip(ship3); battle.throwInitiative(new RandomGenerator(1, 0.5, 0)); - var ai = new BullyAI(ship1.fleet); + var ai = new BullyAI(ship1, Timer.synchronous); ai.ship = ship1; var result = ai.listAllManeuvers(); @@ -166,9 +165,7 @@ module TS.SpaceTac.Specs { it("gets a fallback maneuver", function () { var battle = TestTools.createBattle(1, 3); - var ai = new BullyAI(battle.fleets[0]); - ai.async = false; - ai.ship = battle.fleets[0].ships[0]; + var ai = new BullyAI(battle.fleets[0].ships[0], Timer.synchronous); TestTools.setShipAP(ai.ship, 5); var engine = TestTools.addEngine(ai.ship, 100); @@ -206,10 +203,8 @@ module TS.SpaceTac.Specs { ship2.setArenaPosition(8, 0); battle.fleets[1].addShip(ship2); - var ai = new BullyAI(ship1.fleet); + var ai = new BullyAI(ship1, Timer.synchronous); ai.move_margin = 0; - ai.async = false; - ai.ship = ship1; var engine = new Equipment(SlotType.Engine); engine.distance = 1; diff --git a/src/core/ai/BullyAI.ts b/src/core/ai/BullyAI.ts index 1ff5f75..b8eb2a5 100644 --- a/src/core/ai/BullyAI.ts +++ b/src/core/ai/BullyAI.ts @@ -23,13 +23,7 @@ module TS.SpaceTac { // Basic Artificial Intelligence, with a tendency to move forward and shoot the nearest enemy export class BullyAI extends AbstractAI { // Safety margin in moves to account for floating-point rounding errors - move_margin: number; - - constructor(fleet: Fleet) { - super(fleet); - - this.move_margin = 0.1; - } + move_margin = 0.1; protected initWork(): void { this.addWorkItem(() => { @@ -54,7 +48,7 @@ module TS.SpaceTac { listAllEnemies(): Ship[] { var result: Ship[] = []; - this.fleet.battle.play_order.forEach((ship: Ship) => { + this.ship.getBattle().play_order.forEach((ship: Ship) => { if (ship.alive && ship.getPlayer() !== this.ship.getPlayer()) { result.push(ship); } @@ -159,7 +153,7 @@ module TS.SpaceTac { if (distance > safety_distance) { // Don't move too close target = target.constraintInRange(this.ship.arena_x, this.ship.arena_y, (distance - safety_distance) * APPROACH_FACTOR); - target = engine.action.checkLocationTarget(this.fleet.battle, this.ship, target); + target = engine.action.checkLocationTarget(this.ship.getBattle(), this.ship, target); return new BullyManeuver(new Maneuver(this.ship, engine, target)); } else { return null; @@ -183,16 +177,18 @@ module TS.SpaceTac { // Effectively apply the chosen maneuver applyManeuver(maneuver: BullyManeuver): void { - if (maneuver.move) { - this.addWorkItem(() => { - maneuver.move.apply(); - }, 500); - } + if (maneuver) { + if (maneuver.move) { + this.addWorkItem(() => { + maneuver.move.apply(); + }, 500); + } - if (maneuver.fire) { - this.addWorkItem(() => { - maneuver.fire.apply(); - }, 1500); + if (maneuver.fire) { + this.addWorkItem(() => { + maneuver.fire.apply(); + }, 1500); + } } this.addWorkItem(null, 1500); diff --git a/src/core/ai/TacticalAI.spec.ts b/src/core/ai/TacticalAI.spec.ts new file mode 100644 index 0000000..64489db --- /dev/null +++ b/src/core/ai/TacticalAI.spec.ts @@ -0,0 +1,34 @@ +/// + +module TS.SpaceTac.Specs { + describe("TacticalAI", function () { + class FixedManeuver extends Maneuver { + score: number; + constructor(score: number) { + super(new Ship(), new Equipment(), new Target(0, 0)); + this.score = score; + } + apply() { + applied.push(this.score); + } + } + + // producer of FixedManeuver from a list of scores + let producer = (...scores: number[]) => iterator(scores.map(score => new FixedManeuver(score))); + let applied = []; + + beforeEach(function () { + applied = []; + }); + + it("applies the highest evaluated maneuver", function () { + let ai = new TacticalAI(new Ship(), Timer.synchronous); + ai.evaluators.push(maneuver => maneuver.score); + ai.producers.push(producer(1, -8, 4)); + ai.producers.push(producer(3, 7, 0, 6, 1)); + + ai.play(); + expect(applied).toEqual([7]); + }); + }); +} diff --git a/src/core/ai/TacticalAI.ts b/src/core/ai/TacticalAI.ts new file mode 100644 index 0000000..a0eee72 --- /dev/null +++ b/src/core/ai/TacticalAI.ts @@ -0,0 +1,63 @@ +/// +/// +module TS.SpaceTac { + + type TacticalProducer = () => Maneuver | null; + type TacticalEvaluator = (Maneuver) => number; + + /** + * AI that applies a set of tactical rules + * + * It uses a set of producers (to propose new maneuvers), and evaluators (to choose the best maneuver). + */ + export class TacticalAI extends AbstractAI { + producers: TacticalProducer[] = [] + evaluators: TacticalEvaluator[] = [] + + best: Maneuver | null = null; + best_score = -Infinity; + + protected initWork(): void { + this.addWorkItem(() => this.unitWork()); + } + + /** + * Evaluate a single maneuver + */ + evaluate(maneuver: Maneuver) { + return sum(this.evaluators.map(evaluator => evaluator(maneuver))); + } + + /** + * Single unit of work => produce a single maneuver and evaluate it + */ + private unitWork() { + if (this.producers.length == 0) { + return; + } + + // Produce a maneuver + let producer = this.producers.shift(); + let maneuver = producer(); + + if (maneuver) { + this.producers.push(producer); + + // Evaluate the maneuver + let score = this.evaluate(maneuver); + if (score > this.best_score) { + this.best = maneuver; + this.best_score = score; + } + } + + // Continue or stop ? + if (this.producers.length > 0) { + this.addWorkItem(() => this.unitWork()); + } else if (this.best) { + // TODO Also apply after a certain time of not finding better + this.best.apply(); + } + } + } +} diff --git a/src/ui/BaseView.ts b/src/ui/BaseView.ts index 255fc8e..98ac3e4 100644 --- a/src/ui/BaseView.ts +++ b/src/ui/BaseView.ts @@ -31,10 +31,7 @@ module TS.SpaceTac.UI { init(...args: any[]) { this.gameui = this.game; - this.timer = new Timer(); - if (this.gameui.headless) { - this.timer.makeSynchronous(); - } + this.timer = new Timer(this.gameui.headless); } create() { diff --git a/src/ui/battle/BattleView.ts b/src/ui/battle/BattleView.ts index a231f1e..c27f4a8 100644 --- a/src/ui/battle/BattleView.ts +++ b/src/ui/battle/BattleView.ts @@ -91,6 +91,12 @@ module TS.SpaceTac.UI { this.inputs.bindCheat(Phaser.Keyboard.W, "Win current battle", () => { this.battle.endBattle(this.player.fleet); }); + this.inputs.bindCheat(Phaser.Keyboard.A, "Use AI to play", () => { + if (this.interacting) { + this.setInteractionEnabled(false); + this.battle.playAI(new TacticalAI(this.battle.playing_ship)); + } + }); // Start processing the log this.log_processor.start();