1
0
Fork 0

AI now use turn plans instead of maneuvers (WIP)

This commit is contained in:
Michaël Lemaire 2019-05-22 20:06:42 +02:00
parent 542eda792b
commit 79e0a6bc83
21 changed files with 677 additions and 900 deletions

View File

@ -13,11 +13,9 @@ importScripts("app.js");
onmessage = function (e) {
debug("[AI Worker] Received", e.data.length);
var serializer = new TK.Serializer(TK.SpaceTac);
var battle = serializer.unserialize(e.data);
var processing = new TK.SpaceTac.AIWorker(battle);
processing.processHere(function (maneuver) {
debug("[AI Worker] Send", maneuver);
postMessage(serializer.serialize(maneuver));
return maneuver.apply(battle);
var processing = serializer.unserialize(e.data);
processing.processHere().then(function (plan) {
debug("[AI Worker] Send", plan);
postMessage(serializer.serialize(plan));
}).catch(postMessage);
}

View File

@ -72,6 +72,13 @@ module TK {
clear(start = 0): void {
this.diffs = this.diffs.slice(0, start);
}
/**
* List all the diffs
*/
list(): ReadonlyArray<Readonly<Diff<T>>> {
return this.diffs;
}
}
/**

View File

@ -53,6 +53,11 @@ module TK.SpaceTac {
* Apply a turn plan through a resolution
*/
applyTurnPlan(plan: TurnPlan, random?: RandomGenerator): void {
if (!TurnPlanning.isValid(plan, this)) {
console.error("Tried to apply an invalid turn plan", this, plan);
return;
}
const resolution = new TurnResolution(this, plan, random);
resolution.resolve();
this.performChecks();
@ -104,15 +109,17 @@ module TK.SpaceTac {
/**
* Return an iterator over ships allies of (or owned by) a player
*/
iallies(ship: Ship, alive_only = false): Iterable<Ship> {
return ifilter(this.iships(alive_only), iship => iship.fleet.player.is(ship.fleet.player));
iallies(of: Ship | Player, alive_only = false): Iterable<Ship> {
const player = of instanceof Ship ? of.getPlayer() : of;
return ifilter(this.iships(alive_only), iship => iship.fleet.player.is(player));
}
/**
* Return an iterator over ships enemy of a player
*/
ienemies(ship: Ship, alive_only = false): Iterable<Ship> {
return ifilter(this.iships(alive_only), iship => !iship.fleet.player.is(ship.fleet.player));
ienemies(of: Ship | Player, alive_only = false): Iterable<Ship> {
const player = of instanceof Ship ? of.getPlayer() : of;
return ifilter(this.iships(alive_only), iship => !iship.fleet.player.is(player));
}
/**
@ -148,12 +155,10 @@ module TK.SpaceTac {
}
/**
* Make an AI play the current turn
* Get AI plan
*/
playAI(debug = false): boolean {
// TODO
//AIWorker.process(this, debug);
return false;
getAIPlan(player: Player, debug = false): Promise<TurnPlan> {
return AIWorker.process(this, player, debug);
}
/**

View File

@ -17,6 +17,10 @@ namespace TK.SpaceTac {
};
}
setTurnPlan(plan: TurnPlan) {
this.plan = plan;
}
getTurnPlan(): TurnPlan {
return this.plan;
}
@ -52,6 +56,14 @@ namespace TK.SpaceTac {
return flatten(flatten(this.plan.fleets.map(fleet => fleet.ships.map(ship => ship.actions))));
}
/**
* Check if a plan is valid or not
*/
static isValid(plan: TurnPlan, battle: Battle): boolean {
// TODO check power usage
return true;
}
/**
* Get the target object for a given ship
*/

View File

@ -0,0 +1,48 @@
module TK.SpaceTac.Specs {
export function planSingleAction(ship: Ship, action: BaseAction, target?: Target): AIPlan {
const battle = nn(ship.getBattle());
const player = ship.getPlayer();
const planning = new TurnPlanning(battle, player);
const distance = target ? arenaDistance(ship.location, target) : undefined;
const angle = target && distance ? arenaAngle(ship.location, target) : undefined;
planning.addAction(ship, action, distance, angle);
return new AIPlan(planning.getTurnPlan(), battle, player);
}
testing("AIPlan", test => {
test.case("stores a score for the plan", check => {
const plan = new AIPlan();
check.equals(plan.isScored(), false);
check.equals(plan.score, -Infinity);
plan.setScore(0);
check.equals(plan.isScored(), true);
check.equals(plan.score, 0);
plan.setScore(5.2);
check.equals(plan.isScored(), true);
check.equals(plan.score, 5.2);
});
test.case("simulates the diffs of applying the plan", check => {
let plan = new AIPlan();
check.equals(plan.effects, [
new TurnStartDiff([]),
new TurnEndDiff(0),
new EndBattleDiff(null, 1),
]);
const ship = plan.battle.fleets[0].addShip();
const engine = ship.actions.addCustom(new MoveAction());
plan = planSingleAction(ship, engine, Target.newFromLocation(12, 0));
check.equals(plan.effects, [
new TurnStartDiff([ship]),
new ShipActionUsedDiff(ship, engine, Target.newFromLocation(12, 0)),
new ShipMoveDiff(ship, new ArenaLocationAngle(0, 0, 0), new ArenaLocationAngle(12, 0, 0), engine),
new ShipDeathDiff(plan.battle, ship),
new EndBattleDiff(null, 0),
new TurnEndDiff(0),
]);
});
});
}

44
src/core/ai/AIPlan.ts Normal file
View File

@ -0,0 +1,44 @@
module TK.SpaceTac {
type PlanEffects = ReadonlyArray<Readonly<BaseBattleDiff>>;
/**
* Plan for an AI fleet
*/
export class AIPlan {
// List of guessed effects (lazy property)
_effects?: PlanEffects
// Associated scoring
score = -Infinity
constructor(readonly plan: TurnPlan = { fleets: [] }, readonly battle = new Battle(), readonly player = new Player()) {
}
isValid(): boolean {
return TurnPlanning.isValid(this.plan, this.battle);
}
isScored(): boolean {
return this.score != -Infinity;
}
setScore(score: number): void {
this.score = score;
}
get effects(): PlanEffects {
if (typeof this._effects == "undefined") {
this._effects = this.resolveEffects();
}
return this._effects;
}
resolveEffects(): PlanEffects {
let sim_battle = duplicate(this.battle, TK.SpaceTac);
sim_battle.log.clear();
sim_battle.applyTurnPlan(this.plan); // random ?
return sim_battle.log.list();
}
}
}

View File

@ -0,0 +1,159 @@
module TK.SpaceTac.Specs {
testing("AIScoringHelpers", test => {
test.case("evaluates the drawback of doing nothing", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
TestTools.setShipModel(ship, 100, 0, 10);
let weapon = TestTools.addWeapon(ship, 10, 2, 100, 10);
let toggle = ship.actions.addCustom(new ToggleAction("test"));
let plan = planSingleAction(ship, weapon, Target.newFromLocation(0, 0));
check.equals(AIScoringHelpers.evaluateIdling(plan), 0.5, "fire");
plan = planSingleAction(ship, toggle, Target.newFromShip(ship));
check.equals(AIScoringHelpers.evaluateIdling(plan), 0.5, "toggle on");
ship.actions.toggle(toggle, true);
plan = planSingleAction(ship, toggle, Target.newFromShip(ship));
check.equals(AIScoringHelpers.evaluateIdling(plan), -0.2, "toggle off");
});
test.case("evaluates damage to enemies", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let action = TestTools.addWeapon(ship, 50, 5, 500, 100);
let enemy1 = battle.fleets[1].addShip();
enemy1.setArenaPosition(250, 0);
TestTools.setShipModel(enemy1, 50, 25);
let enemy2 = battle.fleets[1].addShip();
enemy2.setArenaPosition(300, 0);
TestTools.setShipModel(enemy2, 25, 0);
// no enemies hurt
let plan = planSingleAction(ship, action, Target.newFromLocation(100, 0));
check.nears(AIScoringHelpers.evaluateEnemyHealth(plan), 0, 8);
// one enemy loses half-life
plan = planSingleAction(ship, action, Target.newFromLocation(180, 0));
check.nears(AIScoringHelpers.evaluateEnemyHealth(plan), 0.1666666666, 8);
// one enemy loses half-life, the other one is dead
plan = planSingleAction(ship, action, Target.newFromLocation(280, 0));
check.nears(AIScoringHelpers.evaluateEnemyHealth(plan), 0.6666666666, 8);
});
test.case("evaluates ship clustering", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
TestTools.setShipModel(ship, 100, 0, 10);
TestTools.addEngine(ship, 1000);
let weapon = TestTools.addWeapon(ship, 100, 1, 100, 10);
let plan = planSingleAction(ship, weapon, Target.newFromLocation(200, 0));
check.equals(AIScoringHelpers.evaluateClustering(plan), 0);
battle.fleets[1].addShip().setArenaPosition(battle.width, battle.height);
check.nears(AIScoringHelpers.evaluateClustering(plan), -0.01, 2);
battle.fleets[1].addShip().setArenaPosition(120, 40);
check.nears(AIScoringHelpers.evaluateClustering(plan), -0.4, 1);
battle.fleets[0].addShip().setArenaPosition(80, 60);
check.nears(AIScoringHelpers.evaluateClustering(plan), -0.7, 1);
battle.fleets[0].addShip().setArenaPosition(110, 20);
check.equals(AIScoringHelpers.evaluateClustering(plan), -1);
});
test.case("evaluates ship position", check => {
let battle = new Battle(undefined, undefined, 200, 100);
let ship = battle.fleets[0].addShip();
let weapon = TestTools.addWeapon(ship, 1, 1, 400);
let action = weapon;
ship.setArenaPosition(0, 0);
let plan = planSingleAction(ship, action, new Target(0, 0));
check.equals(AIScoringHelpers.evaluatePosition(plan), -1);
ship.setArenaPosition(100, 0);
plan = planSingleAction(ship, action, new Target(0, 0));
check.equals(AIScoringHelpers.evaluatePosition(plan), -1);
ship.setArenaPosition(100, 10);
plan = planSingleAction(ship, action, new Target(0, 0));
check.equals(AIScoringHelpers.evaluatePosition(plan), -0.6);
ship.setArenaPosition(100, 50);
plan = planSingleAction(ship, action, new Target(0, 0));
check.equals(AIScoringHelpers.evaluatePosition(plan), 1);
});
test.case("evaluates overheat", check => {
let battle = new Battle(undefined, undefined, 200, 100);
let ship = battle.fleets[0].addShip();
let weapon = TestTools.addWeapon(ship, 1, 1, 400);
let plan = planSingleAction(ship, weapon, new Target(0, 0));
check.equals(AIScoringHelpers.evaluateOverheat(plan), 0);
weapon.configureCooldown(1, 1);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(AIScoringHelpers.evaluateOverheat(plan), -0.4);
weapon.configureCooldown(1, 2);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(AIScoringHelpers.evaluateOverheat(plan), -0.8);
weapon.configureCooldown(1, 3);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(AIScoringHelpers.evaluateOverheat(plan), -1);
weapon.configureCooldown(2, 1);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(AIScoringHelpers.evaluateOverheat(plan), 0);
});
test.case("evaluates active effects", check => {
let battle = TestTools.createBattle();
let ship = battle.fleets[0].ships[0];
let enemy = battle.fleets[1].ships[0];
TestTools.setShipModel(ship, 5, 0, 1);
TestTools.setShipModel(enemy, 5, 5);
let action = new TriggerAction("Test", { range: 100, power: 1 });
ship.actions.addCustom(action);
let plan = planSingleAction(ship, action, Target.newFromShip(enemy));
check.equals(AIScoringHelpers.evaluateActiveEffects(plan), 0);
action.effects = [new StickyEffect(new DamageEffect(1), 1)];
plan = planSingleAction(ship, action, Target.newFromShip(enemy));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), 0.5);
plan = planSingleAction(ship, action, Target.newFromShip(ship));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), -0.5);
action.effects = [new StickyEffect(new CooldownEffect(1), 1)];
plan = planSingleAction(ship, action, Target.newFromShip(enemy));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), -0.5);
plan = planSingleAction(ship, action, Target.newFromShip(ship));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), 0.5);
battle.fleets[0].addShip();
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), 0.3333333333333333);
action.effects = [new StickyEffect(new CooldownEffect(1), 1), new StickyEffect(new CooldownEffect(1), 1)];
plan = planSingleAction(ship, action, Target.newFromShip(enemy));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), -0.6666666666666666);
action.effects = range(10).map(() => new StickyEffect(new CooldownEffect(1), 1));
plan = planSingleAction(ship, action, Target.newFromShip(enemy));
check.nears(AIScoringHelpers.evaluateActiveEffects(plan), -1);
});
});
}

View File

@ -0,0 +1,141 @@
module TK.SpaceTac {
/**
* Get the proportional effect done to a ship's health (in -1,1 range)
*/
function getProportionalHealth(plan: AIPlan, ship: Ship): number {
let chull = ship.getAttribute("hull_capacity");
let cshield = ship.getAttribute("shield_capacity");
let hull = ship.getValue("hull")
let shield = ship.getValue("shield");
let dhull = 0;
let dshield = 0;
plan.effects.forEach(diff => {
if (diff instanceof ShipValueDiff) {
if (ship.is(diff.ship_id)) {
if (diff.code == "hull") {
dhull += clamp(hull + diff.diff, 0, chull) - hull;
} else if (diff.code == "shield") {
dshield += clamp(shield + diff.diff, 0, cshield) - shield;
}
}
}
});
if (hull + dhull <= 0) {
return -1;
} else {
let diff = dhull + dshield;
return clamp(diff / (hull + shield), -1, 1);
}
}
/**
* Functions to help scoring a turn plan produced by an AI
*/
export class AIScoringHelpers {
/**
* Evaluate doing nothing, between -1 and 1
*/
static evaluateIdling(plan: AIPlan): number {
// TODO evaluate summed used power over available power
return 0;
}
/**
* Evaluate the effect on health for a group of ships
*/
static evaluateHealthEffect(plan: AIPlan, ships: Ship[]): number {
if (ships.length) {
let diffs = ships.map(ship => getProportionalHealth(plan, ship));
let deaths = sum(diffs.map(i => i == -1 ? 1 : 0));
return ((sum(diffs) * 0.5) - (deaths * 0.5)) / ships.length;
} else {
return 0;
}
}
/**
* Evaluate the effect on health to the enemy, between -1 and 1
*/
static evaluateEnemyHealth(plan: AIPlan): number {
let enemies = imaterialize(plan.battle.ienemies(plan.player, true));
return -AIScoringHelpers.evaluateHealthEffect(plan, enemies);
}
/**
* Evaluate the effect on health to allied ships, between -1 and 1
*/
static evaluateAllyHealth(plan: AIPlan): number {
let allies = imaterialize(plan.battle.iallies(plan.player, true));
return AIScoringHelpers.evaluateHealthEffect(plan, allies);
}
/**
* Evaluate the clustering of ships, between -1 and 1
*/
static evaluateClustering(plan: AIPlan): number {
/*let move_location = maneuver.getFinalLocation();
let distances = imaterialize(imap(ifilter(battle.iships(), iship => iship != ship), iship => Target.newFromShip(iship).getDistanceTo(move_location)));
if (distances.length == 0) {
return 0;
} else {
let factor = max([battle.width, battle.height]) * 0.01;
let result = -clamp(sum(distances.map(distance => factor / distance)), 0, 1);
return result;
}*/
// TODO Compute all final locations
return 0;
}
/**
* Evaluate the global positioning of a ship on the arena, between -1 and 1
*/
static evaluatePosition(plan: AIPlan): number {
/*let pos = maneuver.getFinalLocation();
let distance = min([pos.x, pos.y, battle.width - pos.x, battle.height - pos.y]);
let factor = min([battle.width / 2, battle.height / 2]);
return -1 + 2 * distance / factor;*/
// TODO
return 0;
}
/**
* Evaluate the cost of overheating equipments
*/
static evaluateOverheat(plan: AIPlan): number {
/*let cooldown = ship.actions.getCooldown(maneuver.action);
if (cooldown.willOverheat()) {
return -Math.min(1, 0.4 * cooldown.cooling);
} else {
return 0;
}*/
// TODO
return 0;
}
/**
* Evaluate the gain or loss of active effects
*/
static evaluateActiveEffects(plan: AIPlan): number {
let result = 0;
plan.effects.forEach(effect => {
if (effect instanceof ShipEffectAddedDiff || effect instanceof ShipEffectRemovedDiff) {
let target = plan.battle.getShip(effect.ship_id);
let enemy = target && !target.isPlayedBy(plan.player);
let beneficial = effect.effect.isBeneficial();
if (effect instanceof ShipEffectRemovedDiff) {
beneficial = !beneficial;
}
// TODO Evaluate the "power" of the effect
if ((beneficial && !enemy) || (!beneficial && enemy)) {
result += 1;
} else {
result -= 1;
}
}
});
return clamp(result / plan.battle.ships.count(), -1, 1);
}
}
}

View File

@ -19,15 +19,9 @@ module TK.SpaceTac {
* AI processing, either in the current process or in a web worker
*/
export class AIWorker {
private battle: Battle;
private ship: Ship;
private debug: boolean;
private static worker = initializeWorker();
constructor(battle: Battle, debug = false) {
this.battle = battle;
this.ship = battle.ships.list()[0]; // FIXME
this.debug = debug;
constructor(private battle: Battle, private player = battle.fleets[1].player, private debug = false) {
}
/**
@ -35,59 +29,55 @@ module TK.SpaceTac {
*
* This should be done on the real battle state
*/
static async process(battle: Battle, debug = false): Promise<void> {
let processing = new AIWorker(battle, debug);
await processing.processAuto(maneuver => maneuver.apply(battle));
static process(battle: Battle, player?: Player, debug = false): Promise<TurnPlan> {
const processing = new AIWorker(battle, player, debug);
return processing.processAuto();
}
/**
* Process AI in a webworker if possible, else do the work in the render thread
*/
async processAuto(feedback: AIFeedback): Promise<void> {
processAuto(): Promise<TurnPlan> {
if (!this.debug && AIWorker.worker) {
try {
await this.processInWorker(AIWorker.worker, feedback);
return this.processInWorker(AIWorker.worker);
} catch (err) {
console.error("Web worker error, falling back to main thread", err);
await this.processHere(feedback);
return this.processHere();
}
} else {
await this.processHere(feedback);
return this.processHere();
}
}
/**
* Process AI in a webworker
*/
async processInWorker(worker: Worker, feedback: AIFeedback): Promise<void> {
processInWorker(worker: Worker): Promise<TurnPlan> {
let serializer = new Serializer(TK.SpaceTac);
let promise = new Promise((resolve, reject) => {
let promise: Promise<TurnPlan> = new Promise((resolve, reject) => {
worker.onerror = reject;
worker.onmessage = (message) => {
let maneuver = serializer.unserialize(message.data);
if (maneuver instanceof Maneuver) {
if (this.debug) {
console.log("Received from AI worker", maneuver);
}
let result = maneuver.apply(this.battle);
if (!result) {
resolve();
}
} else {
reject("Received something that is not a Maneuver");
let plan = serializer.unserialize(message.data);
if (this.debug) {
console.log("Received from AI worker", plan);
}
// TODO check type
resolve(plan);
};
});
worker.postMessage(serializer.serialize(this.battle));
await promise;
worker.postMessage(serializer.serialize(this));
return promise;
}
/**
* Process AI in current thread
*/
async processHere(feedback: AIFeedback): Promise<void> {
let ai = new TacticalAI(this.ship, feedback, this.debug);
await ai.play();
async processHere(): Promise<TurnPlan> {
let ai = new BruteAI(this.battle, this.player, this.debug); // TODO AI choice ?
ai.play();
const result = await ai.getPlan(); // TODO Only when human player is done
return result.plan;
}
}
}

View File

@ -0,0 +1,25 @@
module TK.SpaceTac.Specs {
class FixedPlan extends AIPlan {
constructor(score: number) {
super();
this.score = score;
}
}
testing("AbstractAI", test => {
test.acase("keeps track of the best produced plan so far", async check => {
const battle = new Battle();
const ai = new AbstractAI(battle, battle.fleets[0].player);
ai.timer = Timer.synchronous;
const producer = (...scores: number[]) => imap(iarray(scores), score => new FixedPlan(score));
check.patch(ai, "getPlanProducer", () => producer(1, -8, 4, 3, 7, 0, 6, 1));
check.patch(ai, "getPlanScoring", () => (plan: AIPlan) => (plan instanceof FixedPlan) ? plan.score : -Infinity);
await ai.play();
const played = await ai.getPlan();
check.equals(played.score, 7);
});
});
}

View File

@ -1,87 +1,159 @@
module TK.SpaceTac {
/**
* Feeback that will be called with each proposed maneuver, and should return true if the AI is to continue playing
*/
export type AIFeedback = (maneuver: Maneuver) => boolean;
export type AIPlanProducer = Iterable<AIPlan>;
export type AIPlanScoring = (plan: AIPlan) => number;
/**
* Base class for all Artificial Intelligence interaction
*
* An AI should work indefinitely on a battle state, to provide the best TurnPlan possible
*/
export class AbstractAI {
// Name of the AI
name: string
// Current ship being played
ship: Ship
// Random generator, if needed
random = RandomGenerator.global
// Timer for scheduled calls
timer: Timer
// Debug mode
debug = false
// Feedback to send maneuvers to
feedback: AIFeedback
timer = Timer.global
// Time at which work as started
private started = 0
constructor(ship: Ship, feedback?: AIFeedback, debug = false, timer = Timer.global, name?: string) {
this.ship = ship;
this.feedback = feedback ? feedback : ((maneuver: Maneuver) => maneuver.apply(nn(this.ship.getBattle())));
this.debug = debug;
this.timer = timer;
// Best plan so far
private best_plan = new AIPlan()
// Number of plans produced
private produced = 0
// Is the work interrupted
private interrupted = false
constructor(protected readonly battle: Battle, protected readonly player: Player, protected readonly debug = false, name?: string) {
this.name = name || classname(this);
}
toString = () => this.name;
toString() {
return this.name;
}
/**
* Start playing current ship's turn.
* Start working on the current battle state.
*
* This will only be interrupted by a call to getPlan.
*/
async play(): Promise<void> {
// Init
this.interrupted = false;
this.produced = 0;
this.best_plan = new AIPlan();
this.started = (new Date()).getTime();
// Work loop
this.initWork();
let last = new Date().getTime();
let ship = this.ship;
while (this.doWorkUnit()) {
if (this.getDuration() >= 10000) {
console.warn(`${this.name} takes too long to play, forcing turn end`);
const producer = this.getPlanProducer();
const scoring = this.getPlanScoring();
for (let plan of producer) {
if (plan.isValid()) {
if (!plan.isScored()) {
plan.setScore(scoring(plan));
}
this.produced++;
this.pushScoredPlan(plan);
} else {
console.warn("AI produced an invalid plan", this.name, plan);
}
if (this.interrupted) {
break;
}
let t = new Date().getTime();
if (t - last > 50) {
await this.timer.sleep(10);
last = t + 10;
}
await this.timer.sleep(10);
}
this.interrupted = true;
}
/**
* Interrupt the thinking
*/
interrupt(): void {
this.interrupted = true;
}
/**
* Ask for a final plan to play
*/
async getPlan(): Promise<AIPlan> {
// Leave at least 5 seconds of thinking
while (!this.interrupted && this.getDuration() < 5000) {
await this.timer.sleep(1000);
}
// TODO Wait for a sufficient score, at most 5 seconds
this.interrupt();
const result = this.peekBestPlan();
if (this.debug) {
console.log(`AI plan after ${this.produced} produced:`, result);
}
return result;
}
/**
* Get the producer of plans for this AI
*
* Plans produced may be scored or not.
* The iterable may (in fact, should) be infinite.
*/
getPlanProducer(): AIPlanProducer {
return [];
}
/**
* Get the plan scoring method for this AI
*
* A standard scoring system is provided by default
*/
getPlanScoring(): AIPlanScoring {
const scaled = (evaluator: AIPlanScoring, factor: number) => (plan: AIPlan) => factor * evaluator(plan);
// TODO If a score is way out of bounds for one of these, it may not need to go further
const scorers = [
scaled(AIScoringHelpers.evaluateOverheat, 3),
scaled(AIScoringHelpers.evaluateEnemyHealth, 5),
scaled(AIScoringHelpers.evaluateAllyHealth, 20),
scaled(AIScoringHelpers.evaluateActiveEffects, 3),
scaled(AIScoringHelpers.evaluateClustering, 4),
scaled(AIScoringHelpers.evaluatePosition, 0.5),
scaled(AIScoringHelpers.evaluateIdling, 2),
]
return plan => sum(scorers.map(scorer => scorer(plan)));
}
/**
* Add a scored plan to the memory (by default, it keeps only the best one)
*/
pushScoredPlan(plan: AIPlan): void {
const diff = plan.score - this.best_plan.score;
if (diff > 0.0001 || (diff > -0.0001 && this.random.bool())) {
this.best_plan = plan;
}
}
/**
* Prepare the groundwork for future doWorkUnit calls
* Peek at the current best plan the AI came with
*/
protected initWork(): void {
}
/**
* Do a single unit of synchronous work
*
* Returns true if something was done, false if the AI should end the ship turn and stop.
*/
protected doWorkUnit(): boolean {
return false;
peekBestPlan(): AIPlan {
return this.best_plan;
}
/**
* Get the time spent thinking on this turn
*/
protected getDuration() {
getDuration() {
return (new Date()).getTime() - this.started;
}
}

View File

@ -0,0 +1,6 @@
module TK.SpaceTac.Specs {
testing("BruteAI", test => {
test.acase("produces something", async check => {
});
});
}

46
src/core/ai/BruteAI.ts Normal file
View File

@ -0,0 +1,46 @@
module TK.SpaceTac {
/**
* AI that produces random valid plans, exploring the whole set of possibilities
*/
export class BruteAI extends AbstractAI {
getPlanProducer(): AIPlanProducer {
const builder = () => this.getRandomPlan();
function* producer() {
while (true) {
yield builder();
}
}
return producer();
}
/**
* Get a single random plan
*/
getRandomPlan(): AIPlan {
const planning = new TurnPlanning(this.battle, this.player);
for (let ship of this.battle.ships.iterator()) {
if (ship.isPlayedBy(this.player)) {
for (let action of ship.actions.listAll()) {
if (action instanceof MoveAction) {
if (this.random.bool()) {
this.addMove(planning, ship, action);
}
}
}
}
}
return new AIPlan(planning.getTurnPlan(), this.battle, this.player);
}
/**
* Add a random move action
*/
addMove(planning: TurnPlanning, ship: Ship, action: MoveAction): void {
const distance = this.random.random() * (action.max_distance - action.min_distance) + action.min_distance;
const angle = this.random.random() * Math.PI * 2;
planning.addAction(ship, action, distance, angle);
}
}
}

View File

@ -1,33 +0,0 @@
module TK.SpaceTac.Specs {
testing("Maneuver", test => {
test.case("uses move-fire simulation to build a list of battle diffs", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let ship3 = battle.fleets[1].addShip();
let ship4 = battle.fleets[1].addShip();
ship1.setArenaPosition(0, 0);
TestTools.setShipModel(ship1, 20, 20, 10);
ship2.setArenaPosition(500, 0);
TestTools.setShipModel(ship2, 70, 100);
ship3.setArenaPosition(560, 0);
TestTools.setShipModel(ship3, 80, 30);
ship4.setArenaPosition(640, 0);
TestTools.setShipModel(ship4, 30, 30);
let weapon = TestTools.addWeapon(ship1, 50, 2, 200, 100);
let engine = TestTools.addEngine(ship1, 1000);
let maneuver = new Maneuver(ship1, weapon, Target.newFromLocation(530, 0));
check.contains(maneuver.effects, new ShipActionUsedDiff(ship1, engine, Target.newFromLocation(331, 0)), "engine use");
check.contains(maneuver.effects, new ShipMoveDiff(ship1, ship1.location, new ArenaLocationAngle(331, 0), engine), "move");
check.contains(maneuver.effects, new ShipActionUsedDiff(ship1, weapon, Target.newFromLocation(530, 0)), "weapon use");
check.contains(maneuver.effects, new ProjectileFiredDiff(ship1, weapon, Target.newFromLocation(530, 0)), "weapon projectile");
check.contains(maneuver.effects, new ShipValueDiff(ship2, "shield", -50), "ship2 shield value");
check.contains(maneuver.effects, new ShipValueDiff(ship3, "shield", -30), "ship3 shield value");
check.contains(maneuver.effects, new ShipValueDiff(ship3, "hull", -20), "ship3 hull value");
check.contains(maneuver.effects, new ShipDamageDiff(ship2, 0, 50, 0, 50), "ship2 damage");
check.contains(maneuver.effects, new ShipDamageDiff(ship3, 20, 30, 0, 50), "ship3 damage");
});
});
}

View File

@ -1,95 +0,0 @@
module TK.SpaceTac {
/**
* Ship maneuver for an artifical intelligence
*
* A maneuver is like a human player action, choosing an action and using it
*/
export class Maneuver {
// Concerned ship
ship: Ship
// Reference to battle
battle: Battle
// Action to use
action: BaseAction
// Target for the action
target: Target
// Result of move-fire simulation
simulation: MoveFireResult
// List of guessed effects of this maneuver (lazy property)
_effects?: BaseBattleDiff[]
constructor(ship: Ship, action: BaseAction, target: Target, move_margin = 1) {
this.ship = ship;
this.battle = nn(ship.getBattle());
this.action = action;
this.target = target;
let simulator = new MoveFireSimulator(this.ship);
this.simulation = simulator.simulateAction(this.action, this.target, move_margin);
}
jasmineToString() {
return `Use ${this.action.code} on ${this.target.jasmineToString()}`;
}
get effects(): BaseBattleDiff[] {
if (!this._effects) {
let simulator = new MoveFireSimulator(this.ship);
this._effects = simulator.getExpectedDiffs(this.battle, this.simulation);
}
return this._effects;
}
/**
* Returns true if the maneuver has at least one part doable
*/
isPossible(): boolean {
return any(this.simulation.parts, part => part.possible);
}
/**
* Get the location of the ship after the action
*/
getFinalLocation(): { x: number, y: number } {
if (this.simulation.need_move) {
return this.simulation.move_location;
} else {
return { x: this.ship.arena_x, y: this.ship.arena_y };
}
}
/**
* Get the total power usage of this maneuver
*/
getPowerUsage(): number {
return this.simulation.total_move_ap + this.simulation.total_fire_ap;
}
/**
* Standard feedback for this maneuver. It will apply it on the battle state.
*/
apply(battle: Battle): boolean {
if (!this.simulation.success) {
return false;
} else {
let parts = this.simulation.parts;
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
if (part.possible) {
if (!battle.applyOneAction(part.action.id, part.target)) {
return false;
}
} else {
return false;
}
}
return false;
}
}
}
}

View File

@ -1,45 +0,0 @@
module TK.SpaceTac.Specs {
testing("TacticalAI", test => {
class FixedManeuver extends Maneuver {
score: number;
constructor(score: number) {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
super(ship, new BaseAction("nothing"), new Target(0, 0));
this.score = score;
}
}
// producer of FixedManeuver from a list of scores
let producer = (...scores: number[]) => imap(iarray(scores), score => new FixedManeuver(score));
let applied: number[] = [];
test.setup(function () {
test.check.patch(console, "log", null);
applied = [];
});
test.case("applies the highest evaluated maneuver", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let ai = new TacticalAI(ship, maneuver => {
if (maneuver instanceof FixedManeuver) {
applied.push(maneuver.score);
}
return false;
}, false, Timer.synchronous);
check.patch(ai, "getDefaultProducers", () => [
producer(1, -8, 4),
producer(3, 7, 0, 6, 1)
]);
check.patch(ai, "getDefaultEvaluators", () => [
(maneuver: Maneuver) => (<FixedManeuver>maneuver).score
]);
ai.play();
check.equals(applied, [7]);
});
});
}

View File

@ -1,129 +0,0 @@
/// <reference path="AbstractAI.ts"/>
/// <reference path="Maneuver.ts"/>
module TK.SpaceTac {
export type TacticalProducer = Iterable<Maneuver>;
export type TacticalEvaluator = (maneuver: 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).
*
* As much work as possible is done using iterators, without materializing every possibilities.
*/
export class TacticalAI extends AbstractAI {
private producers: TacticalProducer[] = []
private work: Iterator<Maneuver> = IATEND
private evaluators: TacticalEvaluator[] = []
private best: Maneuver | null = null
private best_score = 0
private produced = 0
private evaluated = 0
protected initWork(): void {
this.best = null;
this.best_score = -Infinity;
this.producers = this.getDefaultProducers();
this.work = ialternate(this.producers)[Symbol.iterator]();
this.evaluators = this.getDefaultEvaluators();
this.produced = 0;
this.evaluated = 0;
if (this.debug) {
console.log("AI started", this.name, this.ship.name);
}
}
protected doWorkUnit(): boolean {
let state = this.work.next();
if (!state.done && this.getDuration() < 8000) {
let maneuver = state.value;
this.produced++;
if (maneuver.isPossible()) {
// Evaluate the maneuver
let score = this.evaluate(maneuver);
this.evaluated++;
if (this.debug) {
console.debug("AI evaluation", maneuver, score);
}
if ((Math.abs(score - this.best_score) < 0.0001 && this.random.bool()) || score > this.best_score) {
this.best = maneuver;
this.best_score = score;
}
}
return true;
} else if (this.best) {
if (!state.done) {
console.warn(`AI did not analyze every possible maneuver (${this.produced} produced, ${this.evaluated} evaluated)`);
}
// Choose the best maneuver so far
let best_maneuver = this.best;
if (this.debug) {
console.log("AI maneuver", this.name, this.ship.name, best_maneuver, this.best_score);
}
let success = this.feedback(best_maneuver);
if (success) {
// Try to play another maneuver
this.initWork();
return true;
} else {
return false;
}
} else {
// No maneuver produced
return false;
}
}
/**
* Evaluate a single maneuver
*/
evaluate(maneuver: Maneuver) {
return sum(this.evaluators.map(evaluator => evaluator(maneuver)));
}
/**
* Get the default set of maneuver producers
*/
getDefaultProducers() {
let producers = [
TacticalAIHelpers.produceDirectShots,
TacticalAIHelpers.produceBlastShots,
TacticalAIHelpers.produceToggleActions,
TacticalAIHelpers.produceRandomMoves,
]
return producers.map(producer => producer(this.ship, this.ship.getBattle() || new Battle()));
}
/**
* Get the default set of maneuver evaluators
*/
getDefaultEvaluators() {
type EvaluatorHelper = (ship: Ship, battle: Battle, maneuver: Maneuver) => number;
function scaled(evaluator: EvaluatorHelper, factor: number): EvaluatorHelper {
return (ship: Ship, battle: Battle, maneuver: Maneuver) => factor * evaluator(ship, battle, maneuver);
}
let evaluators: EvaluatorHelper[] = [
scaled(TacticalAIHelpers.evaluateOverheat, 3),
scaled(TacticalAIHelpers.evaluateEnemyHealth, 5),
scaled(TacticalAIHelpers.evaluateAllyHealth, 20),
scaled(TacticalAIHelpers.evaluateActiveEffects, 3),
scaled(TacticalAIHelpers.evaluateClustering, 4),
scaled(TacticalAIHelpers.evaluatePosition, 0.5),
scaled(TacticalAIHelpers.evaluateIdling, 2),
]
let battle = nn(this.ship.getBattle());
return evaluators.map(evaluator => ((maneuver: Maneuver) => evaluator(this.ship, battle, maneuver)));
}
}
}

View File

@ -1,263 +0,0 @@
module TK.SpaceTac.Specs {
testing("TacticalAIHelpers", test => {
test.case("produces direct weapon shots", check => {
let battle = new Battle();
let ship0a = battle.fleets[0].addShip(new Ship(null, "0A"));
let ship0b = battle.fleets[0].addShip(new Ship(null, "0B"));
let ship1a = battle.fleets[1].addShip(new Ship(null, "1A"));
let ship1b = battle.fleets[1].addShip(new Ship(null, "1B"));
TestTools.setShipModel(ship0a, 100, 0, 10);
let result = imaterialize(TacticalAIHelpers.produceDirectShots(ship0a, battle));
check.equals(result.length, 0);
let weapon1 = TestTools.addWeapon(ship0a, 10);
let weapon2 = TestTools.addWeapon(ship0a, 15);
result = imaterialize(TacticalAIHelpers.produceDirectShots(ship0a, battle));
check.equals(result.length, 4);
check.contains(result, new Maneuver(ship0a, weapon1, Target.newFromShip(ship1a)));
check.contains(result, new Maneuver(ship0a, weapon1, Target.newFromShip(ship1b)));
check.contains(result, new Maneuver(ship0a, weapon2, Target.newFromShip(ship1a)));
check.contains(result, new Maneuver(ship0a, weapon2, Target.newFromShip(ship1b)));
});
test.case("produces random moves inside a grid", check => {
let battle = new Battle();
battle.width = 100;
battle.height = 100;
let ship = battle.fleets[0].addShip();
TestTools.setShipModel(ship, 100, 0, 10);
let result = imaterialize(TacticalAIHelpers.produceRandomMoves(ship, battle, 2, 1));
check.equals(result.length, 0);
let engine = TestTools.addEngine(ship, 1000);
result = imaterialize(TacticalAIHelpers.produceRandomMoves(ship, battle, 2, 1, new SkewedRandomGenerator([0.5], true)));
check.equals(result, [
new Maneuver(ship, engine, Target.newFromLocation(25, 25)),
new Maneuver(ship, engine, Target.newFromLocation(75, 25)),
new Maneuver(ship, engine, Target.newFromLocation(25, 75)),
new Maneuver(ship, engine, Target.newFromLocation(75, 75)),
]);
});
test.case("produces interesting blast shots", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let weapon = TestTools.addWeapon(ship, 50, 1, 1000, 105);
TestTools.setShipModel(ship, 100, 0, 10, 1, [weapon]);
let result = imaterialize(TacticalAIHelpers.produceInterestingBlastShots(ship, battle));
check.equals(result.length, 0);
let enemy1 = battle.fleets[1].addShip();
enemy1.setArenaPosition(500, 0);
result = imaterialize(TacticalAIHelpers.produceInterestingBlastShots(ship, battle));
check.equals(result.length, 0);
let enemy2 = battle.fleets[1].addShip();
enemy2.setArenaPosition(700, 0);
result = imaterialize(TacticalAIHelpers.produceInterestingBlastShots(ship, battle));
check.equals(result, [
new Maneuver(ship, weapon, Target.newFromLocation(600, 0)),
new Maneuver(ship, weapon, Target.newFromLocation(600, 0)),
]);
let enemy3 = battle.fleets[1].addShip();
enemy3.setArenaPosition(700, 300);
result = imaterialize(TacticalAIHelpers.produceInterestingBlastShots(ship, battle));
check.equals(result, [
new Maneuver(ship, weapon, Target.newFromLocation(600, 0)),
new Maneuver(ship, weapon, Target.newFromLocation(600, 0)),
]);
});
test.case("produces toggle/untoggle actions", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let action1 = new DeployDroneAction("Drone");
let action2 = new ToggleAction("Toggle");
let action3 = new VigilanceAction("Vigilance", { radius: 150 });
TestTools.setShipModel(ship, 100, 0, 10, 1, [action1, action2, action3]);
TestTools.addEngine(ship, 1000);
check.patch(TacticalAIHelpers, "scanArena", () => iarray([
Target.newFromLocation(1, 0),
Target.newFromLocation(0, 1),
]));
let result = imaterialize(TacticalAIHelpers.produceToggleActions(ship, battle));
check.equals(result, [
new Maneuver(ship, action2, Target.newFromShip(ship)),
new Maneuver(ship, action1, Target.newFromLocation(1, 0)),
new Maneuver(ship, action3, Target.newFromLocation(1, 0)),
new Maneuver(ship, action1, Target.newFromLocation(0, 1)),
new Maneuver(ship, action3, Target.newFromLocation(0, 1)),
]);
});
test.case("evaluates the drawback of doing nothing", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
TestTools.setShipModel(ship, 100, 0, 10);
let weapon = TestTools.addWeapon(ship, 10, 2, 100, 10);
let toggle = ship.actions.addCustom(new ToggleAction("test"));
let maneuver = new Maneuver(ship, weapon, Target.newFromLocation(0, 0));
check.equals(TacticalAIHelpers.evaluateIdling(ship, battle, maneuver), 0.5, "fire");
maneuver = new Maneuver(ship, toggle, Target.newFromShip(ship));
check.equals(TacticalAIHelpers.evaluateIdling(ship, battle, maneuver), 0.5, "toggle on");
ship.actions.toggle(toggle, true);
maneuver = new Maneuver(ship, toggle, Target.newFromShip(ship));
check.equals(TacticalAIHelpers.evaluateIdling(ship, battle, maneuver), -0.2, "toggle off");
});
test.case("evaluates damage to enemies", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let action = TestTools.addWeapon(ship, 50, 5, 500, 100);
let enemy1 = battle.fleets[1].addShip();
enemy1.setArenaPosition(250, 0);
TestTools.setShipModel(enemy1, 50, 25);
let enemy2 = battle.fleets[1].addShip();
enemy2.setArenaPosition(300, 0);
TestTools.setShipModel(enemy2, 25, 0);
// no enemies hurt
let maneuver = new Maneuver(ship, action, Target.newFromLocation(100, 0));
check.nears(TacticalAIHelpers.evaluateEnemyHealth(ship, battle, maneuver), 0, 8);
// one enemy loses half-life
maneuver = new Maneuver(ship, action, Target.newFromLocation(180, 0));
check.nears(TacticalAIHelpers.evaluateEnemyHealth(ship, battle, maneuver), 0.1666666666, 8);
// one enemy loses half-life, the other one is dead
maneuver = new Maneuver(ship, action, Target.newFromLocation(280, 0));
check.nears(TacticalAIHelpers.evaluateEnemyHealth(ship, battle, maneuver), 0.6666666666, 8);
});
test.case("evaluates ship clustering", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
TestTools.setShipModel(ship, 100, 0, 10);
TestTools.addEngine(ship, 1000);
let weapon = TestTools.addWeapon(ship, 100, 1, 100, 10);
let maneuver = new Maneuver(ship, weapon, Target.newFromLocation(200, 0), 0.5);
check.nears(maneuver.simulation.move_location.x, 100.5, 1);
check.equals(maneuver.simulation.move_location.y, 0);
check.equals(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver), 0);
battle.fleets[1].addShip().setArenaPosition(battle.width, battle.height);
check.nears(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver), -0.01, 2);
battle.fleets[1].addShip().setArenaPosition(120, 40);
check.nears(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver), -0.4, 1);
battle.fleets[0].addShip().setArenaPosition(80, 60);
check.nears(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver), -0.7, 1);
battle.fleets[0].addShip().setArenaPosition(110, 20);
check.equals(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver), -1);
});
test.case("evaluates ship position", check => {
let battle = new Battle(undefined, undefined, 200, 100);
let ship = battle.fleets[0].addShip();
let weapon = TestTools.addWeapon(ship, 1, 1, 400);
let action = weapon;
ship.setArenaPosition(0, 0);
let maneuver = new Maneuver(ship, action, new Target(0, 0), 0);
check.equals(TacticalAIHelpers.evaluatePosition(ship, battle, maneuver), -1);
ship.setArenaPosition(100, 0);
maneuver = new Maneuver(ship, action, new Target(0, 0), 0);
check.equals(TacticalAIHelpers.evaluatePosition(ship, battle, maneuver), -1);
ship.setArenaPosition(100, 10);
maneuver = new Maneuver(ship, action, new Target(0, 0), 0);
check.equals(TacticalAIHelpers.evaluatePosition(ship, battle, maneuver), -0.6);
ship.setArenaPosition(100, 50);
maneuver = new Maneuver(ship, action, new Target(0, 0), 0);
check.equals(TacticalAIHelpers.evaluatePosition(ship, battle, maneuver), 1);
});
test.case("evaluates overheat", check => {
let battle = new Battle(undefined, undefined, 200, 100);
let ship = battle.fleets[0].addShip();
let weapon = TestTools.addWeapon(ship, 1, 1, 400);
let maneuver = new Maneuver(ship, weapon, new Target(0, 0));
check.equals(TacticalAIHelpers.evaluateOverheat(ship, battle, maneuver), 0);
weapon.configureCooldown(1, 1);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(TacticalAIHelpers.evaluateOverheat(ship, battle, maneuver), -0.4);
weapon.configureCooldown(1, 2);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(TacticalAIHelpers.evaluateOverheat(ship, battle, maneuver), -0.8);
weapon.configureCooldown(1, 3);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(TacticalAIHelpers.evaluateOverheat(ship, battle, maneuver), -1);
weapon.configureCooldown(2, 1);
ship.actions.updateFromShip(ship);
ship.actions.addCustom(weapon);
check.equals(TacticalAIHelpers.evaluateOverheat(ship, battle, maneuver), 0);
});
test.case("evaluates active effects", check => {
let battle = TestTools.createBattle();
let ship = battle.fleets[0].ships[0];
let enemy = battle.fleets[1].ships[0];
TestTools.setShipModel(ship, 5, 0, 1);
TestTools.setShipModel(enemy, 5, 5);
let action = new TriggerAction("Test", { range: 100, power: 1 });
ship.actions.addCustom(action);
let maneuver = new Maneuver(ship, action, Target.newFromShip(enemy));
check.equals(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), 0);
action.effects = [new StickyEffect(new DamageEffect(1), 1)];
maneuver = new Maneuver(ship, action, Target.newFromShip(enemy));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), 0.5);
maneuver = new Maneuver(ship, action, Target.newFromShip(ship));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), -0.5);
action.effects = [new StickyEffect(new CooldownEffect(1), 1)];
maneuver = new Maneuver(ship, action, Target.newFromShip(enemy));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), -0.5);
maneuver = new Maneuver(ship, action, Target.newFromShip(ship));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), 0.5);
battle.fleets[0].addShip();
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), 0.3333333333333333);
action.effects = [new StickyEffect(new CooldownEffect(1), 1), new StickyEffect(new CooldownEffect(1), 1)];
maneuver = new Maneuver(ship, action, Target.newFromShip(enemy));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), -0.6666666666666666);
action.effects = range(10).map(() => new StickyEffect(new CooldownEffect(1), 1));
maneuver = new Maneuver(ship, action, Target.newFromShip(enemy));
check.nears(TacticalAIHelpers.evaluateActiveEffects(ship, battle, maneuver), -1);
});
});
}

View File

@ -1,231 +0,0 @@
module TK.SpaceTac {
/**
* Get a list of all playable actions (like the actionbar for player) for a ship
*/
function getPlayableActions(ship: Ship): Iterable<BaseAction> {
let actions = ship.actions.listAll();
return ifilter(iarray(actions), action => !action.checkCannotBeApplied(ship));
}
/**
* Get the proportional effect done to a ship's health (in -1,1 range)
*/
function getProportionalHealth(maneuver: Maneuver, ship: Ship): number {
let chull = ship.getAttribute("hull_capacity");
let cshield = ship.getAttribute("shield_capacity");
let hull = ship.getValue("hull")
let shield = ship.getValue("shield");
let dhull = 0;
let dshield = 0;
maneuver.effects.forEach(diff => {
if (diff instanceof ShipValueDiff) {
if (ship.is(diff.ship_id)) {
if (diff.code == "hull") {
dhull += clamp(hull + diff.diff, 0, chull) - hull;
} else if (diff.code == "shield") {
dshield += clamp(shield + diff.diff, 0, cshield) - shield;
}
}
}
});
if (hull + dhull <= 0) {
return -1;
} else {
let diff = dhull + dshield;
return clamp(diff / (hull + shield), -1, 1);
}
}
/**
* Standard producers and evaluators for TacticalAI
*
* These are static methods that may be used as base for TacticalAI ruleset.
*/
export class TacticalAIHelpers {
/**
* Iterator of a list of "random" arena coordinates, based on a grid
*/
static scanArena(battle: Battle, cells = 10, random = RandomGenerator.global): Iterable<Target> {
return imap(irange(cells * cells), cellpos => {
let y = Math.floor(cellpos / cells);
let x = cellpos - y * cells;
return Target.newFromLocation((x + random.random()) * battle.width / cells, (y + random.random()) * battle.height / cells);
});
}
/**
* Produce all "direct hit" weapon shots.
*/
static produceDirectShots(ship: Ship, battle: Battle): TacticalProducer {
let enemies = battle.ienemies(ship, true);
let weapons = ifilter(getPlayableActions(ship), action => action instanceof TriggerAction);
return imap(icombine(enemies, weapons), ([enemy, weapon]) => new Maneuver(ship, weapon, Target.newFromShip(enemy)));
}
/**
* Produce random moves inside arena cell
*/
static produceRandomMoves(ship: Ship, battle: Battle, cells = 10, iterations = 1, random = RandomGenerator.global): TacticalProducer {
let engines = ifilter(getPlayableActions(ship), action => action instanceof MoveAction);
return ichainit(imap(irange(iterations), iteration => {
let moves = icombine(engines, TacticalAIHelpers.scanArena(battle, cells, random));
return imap(moves, ([engine, target]) => new Maneuver(ship, engine, target));
}));
}
/**
* Produce blast weapon shots, with multiple targets.
*/
static produceInterestingBlastShots(ship: Ship, battle: Battle): TacticalProducer {
// TODO Work with groups of 3, 4 ...
let weapons = ifilter(ifilterclass(getPlayableActions(ship), TriggerAction), action => action.blast > 0);
let enemies = battle.ienemies(ship, true);
// TODO This produces duplicates (x, y) and (y, x)
let couples = ifilter(icombine(enemies, enemies), ([e1, e2]) => e1 != e2);
let candidates = ifilter(icombine(weapons, couples), ([weapon, [e1, e2]]) => Target.newFromShip(e1).getDistanceTo(Target.newFromShip(e2)) < weapon.blast * 2);
let result = imap(candidates, ([weapon, [e1, e2]]) => new Maneuver(ship, weapon, Target.newFromLocation((e1.arena_x + e2.arena_x) / 2, (e1.arena_y + e2.arena_y) / 2)));
return result;
}
/**
* Produce random blast weapon shots, on a grid.
*/
static produceRandomBlastShots(ship: Ship, battle: Battle): TacticalProducer {
let weapons = ifilter(getPlayableActions(ship), action => action instanceof TriggerAction && action.blast > 0);
let candidates = ifilter(icombine(weapons, TacticalAIHelpers.scanArena(battle)), ([weapon, location]) => (<TriggerAction>weapon).getEffects(ship, location).length > 0);
let result = imap(candidates, ([weapon, location]) => new Maneuver(ship, weapon, location));
return result;
}
/**
* Produce interesting then random blast shots
*/
static produceBlastShots(ship: Ship, battle: Battle): TacticalProducer {
return ichain(TacticalAIHelpers.produceInterestingBlastShots(ship, battle), TacticalAIHelpers.produceRandomBlastShots(ship, battle));
}
/**
* Produce toggle actions at random locations.
*/
static produceToggleActions(ship: Ship, battle: Battle): TacticalProducer {
let toggles = ifilter(getPlayableActions(ship), action => action instanceof ToggleAction);
let self_toggles = ifilter(toggles, toggle => contains([ActionTargettingMode.SELF_CONFIRM, ActionTargettingMode.SELF], toggle.getTargettingMode(ship)));
let self_maneuvers = imap(self_toggles, toggle => new Maneuver(ship, toggle, Target.newFromShip(ship)));
let distant_toggles = ifilter(toggles, toggle => contains([ActionTargettingMode.SPACE, ActionTargettingMode.SURROUNDINGS], toggle.getTargettingMode(ship)));
let grid = TacticalAIHelpers.scanArena(battle);
let distant_maneuvers = imap(icombine(grid, distant_toggles), ([location, toggle]) => new Maneuver(ship, toggle, location));
return ichain(self_maneuvers, distant_maneuvers);
}
/**
* Evaluate doing nothing, between -1 and 1
*/
static evaluateIdling(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let power_capacity = ship.getAttribute("power_capacity") || 1;
if (maneuver.action instanceof TriggerAction) {
return 0.5;
} else if (maneuver.action instanceof ToggleAction) {
return ship.actions.isToggled(maneuver.action) ? -0.2 : 0.5;
} else {
return 0;
}
}
/**
* Evaluate the effect on health for a group of ships
*/
static evaluateHealthEffect(maneuver: Maneuver, ships: Ship[]): number {
if (ships.length) {
let diffs = ships.map(ship => getProportionalHealth(maneuver, ship));
let deaths = sum(diffs.map(i => i == -1 ? 1 : 0));
return ((sum(diffs) * 0.5) - (deaths * 0.5)) / ships.length;
} else {
return 0;
}
}
/**
* Evaluate the effect on health to the enemy, between -1 and 1
*/
static evaluateEnemyHealth(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let enemies = imaterialize(battle.ienemies(ship, true));
return -TacticalAIHelpers.evaluateHealthEffect(maneuver, enemies);
}
/**
* Evaluate the effect on health to allied ships, between -1 and 1
*/
static evaluateAllyHealth(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let allies = imaterialize(battle.iallies(ship, true));
return TacticalAIHelpers.evaluateHealthEffect(maneuver, allies);
}
/**
* Evaluate the clustering of ships, between -1 and 1
*/
static evaluateClustering(ship: Ship, battle: Battle, maneuver: Maneuver): number {
// TODO Take into account blast radius of in-play weapons
let move_location = maneuver.getFinalLocation();
let distances = imaterialize(imap(ifilter(battle.iships(), iship => iship != ship), iship => Target.newFromShip(iship).getDistanceTo(move_location)));
if (distances.length == 0) {
return 0;
} else {
let factor = max([battle.width, battle.height]) * 0.01;
let result = -clamp(sum(distances.map(distance => factor / distance)), 0, 1);
return result;
}
}
/**
* Evaluate the global positioning of a ship on the arena, between -1 and 1
*/
static evaluatePosition(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let pos = maneuver.getFinalLocation();
let distance = min([pos.x, pos.y, battle.width - pos.x, battle.height - pos.y]);
let factor = min([battle.width / 2, battle.height / 2]);
return -1 + 2 * distance / factor;
}
/**
* Evaluate the cost of overheating an equipment
*/
static evaluateOverheat(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let cooldown = ship.actions.getCooldown(maneuver.action);
if (cooldown.willOverheat()) {
return -Math.min(1, 0.4 * cooldown.cooling);
} else {
return 0;
}
}
/**
* Evaluate the gain or loss of active effects
*/
static evaluateActiveEffects(ship: Ship, battle: Battle, maneuver: Maneuver): number {
let result = 0;
maneuver.effects.forEach(effect => {
if (effect instanceof ShipEffectAddedDiff || effect instanceof ShipEffectRemovedDiff) {
let target = battle.getShip(effect.ship_id);
let enemy = target && !target.isPlayedBy(ship.getPlayer());
let beneficial = effect.effect.isBeneficial();
if (effect instanceof ShipEffectRemovedDiff) {
beneficial = !beneficial;
}
// TODO Evaluate the "power" of the effect
if ((beneficial && !enemy) || (!beneficial && enemy)) {
result += 1;
} else {
result -= 1;
}
}
});
return clamp(result / battle.ships.count(), -1, 1);
}
}
}

View File

@ -7,6 +7,7 @@ module TK.SpaceTac.UI {
export enum BattleInfoBarPhase {
PLANNING,
RESOLUTION,
AI,
END,
}
@ -42,14 +43,18 @@ module TK.SpaceTac.UI {
case BattleInfoBarPhase.RESOLUTION:
this.container.getBuilder().change(this.title, "Resolution phase");
break;
case BattleInfoBarPhase.AI:
this.container.getBuilder().change(this.title, "AI is still thinking...");
break;
case BattleInfoBarPhase.END:
this.container.getBuilder().change(this.title, "End");
break;
}
this.planning.setCurrentTurn(turn);
this.planning.setVisible(phase == BattleInfoBarPhase.PLANNING);
this.awaiter.setVisible(phase == BattleInfoBarPhase.RESOLUTION);
this.awaiter.setVisible(phase == BattleInfoBarPhase.RESOLUTION || phase == BattleInfoBarPhase.AI);
}
}

View File

@ -1,5 +1,3 @@
/// <reference path="../BaseView.ts"/>
module TK.SpaceTac.UI {
/**
* Interface for interacting with a ship (hover and click)
@ -150,7 +148,7 @@ module TK.SpaceTac.UI {
nn(this.player.getCheats()).lose();
this.log_processor.fastForward();
});
this.inputs.bindCheat("a", "Use AI to play", () => this.playAI());
this.inputs.bindCheat("a", "AI suggestion", () => this.suggestByAI());
// Bind to log events
this.log_processor.register(diff => {
@ -205,20 +203,37 @@ module TK.SpaceTac.UI {
}
/**
* Make the AI play current ship
*
* If the AI is already playing, do nothing
* Make the AI fill the turn play of the human player
*/
playAI(): void {
if (this.session.spectator) {
async suggestByAI() {
if (this.session.spectator || !this.interacting) {
return;
}
if (this.actual_battle.playAI(this.debug)) {
if (this.interacting) {
this.action_bar.setShip(new Ship());
this.setInteractionEnabled(false);
this.infobar.setPhase(this.battle.turncount, BattleInfoBarPhase.AI);
try {
const plan = await this.actual_battle.getAIPlan(this.player, this.debug);
this.setPlayerTurnPlan(this.player, plan);
} finally {
this.setInteractionEnabled(true);
this.infobar.setPhase(this.battle.turncount, BattleInfoBarPhase.PLANNING);
}
}
/**
* Set the turn plan for a specific player
*/
setPlayerTurnPlan(player: Player, plan: TurnPlan): void {
let changed = false;
this.turn_plannings.forEach(planning => {
if (player.is(planning.player)) {
planning.setTurnPlan(plan);
changed = true;
}
this.setInteractionEnabled(false);
});
if (changed) {
this.planningsChanged();
}
}