1
0
Fork 0
This commit is contained in:
Michaël Lemaire 2019-11-21 23:14:27 +01:00
parent ae8ad13a84
commit 6890118b83
317 changed files with 35293 additions and 29349 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = off

15
.gitignore vendored
View file

@ -1,12 +1,5 @@
.venv
coverage
/node_modules
/out/assets
/out/app.*
/out/tests.*
/out/dependencies.js
/graphics/**/*.blend?*
/graphics/**/output.png
/typings/
*.log
*.tsbuildinfo
node_modules
.rts2_cache_*
.coverage
/dist/

11
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,11 @@
image: node:latest
cache:
paths:
- node_modules/
test:
before_script:
- npm install
script:
- npm test

View file

@ -3,7 +3,7 @@
# source activate_node
vdir="./.venv"
expected="10.15.3"
expected="12.13.0"
if [ \! -f "./activate_node" ]
then

BIN
graphics/ships/_base.blend1 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
graphics/title.blend1 Normal file

Binary file not shown.

27
jest.config.js Normal file
View file

@ -0,0 +1,27 @@
module.exports = {
transform: {
"^.+\\.ts$": "ts-jest"
},
moduleFileExtensions: [
"ts",
"js",
"json",
"node"
],
watchPathIgnorePatterns: [
"<rootDir>/dist/",
"<rootDir>/node_modules/",
],
restoreMocks: true,
collectCoverage: true,
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.test.ts",
],
coverageDirectory: ".coverage",
coverageReporters: [
"lcovonly",
"html",
"text-summary"
]
}

View file

@ -1,23 +0,0 @@
var handler = {
get(target, name) {
return new Proxy(function () { }, handler);
}
}
var Phaser = new Proxy(function () { }, handler);
//var debug = console.log;
var debug = function () { };
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);
}).catch(postMessage);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View file

@ -1,54 +0,0 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SpaceTac</title>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000000;
padding: 0;
margin: 0;
}
.game {
width: 100%;
height: 100vh;
}
@font-face {
font-family: 'SpaceTac';
src: url('fonts/daggersquare.regular.otf');
}
body {
font-family: 'SpaceTac';
}
.fontLoader {
position: absolute;
left: -1000px;
}
</style>
</head>
<body>
<div id="-space-tac" class="game"></div>
<div class=".fontLoader">.</div>
<script src="dependencies.js"></script>
<script src="app.js"></script>
<script>
window.onload = function () {
window.game = new TK.SpaceTac.MainUI();
};
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -1,68 +0,0 @@
const Pool = require('process-pool').default;
const pool = new Pool({ processLimit: 8 });
const work = pool.prepare(function () {
const App = require("./app").TK.SpaceTac;
async function doOneBattle(i) {
let ai1 = new App.TacticalAI();
let ai2 = new App.TacticalAI();
// Prepare battle
let battle = App.Battle.newQuickRandom(true, 1, 2 + i % 4);
battle.fleets.forEach((fleet, findex) => {
fleet.ships.forEach((ship, sindex) => {
ship.name = `F${findex + 1}S${sindex + 1} (${ship.model.name})`;
});
});
// Run battle
while (!battle.ended && battle.cycle < 100) {
let playing = battle.playing_ship;
if (playing) {
let ai = (playing.fleet == battle.fleets[0]) ? ai1 : ai2;
ai.ship = playing;
await ai.play();
}
}
// Collect results
if (battle.outcome && battle.outcome.winner) {
let results = {};
battle.fleets.forEach(fleet => {
fleet.ships.forEach(ship => {
let name = `Level ${ship.level.get()} ${ship.model.name}`;
results[name] = (results[name] || 0) + (ship.fleet === battle.outcome.winner ? 1 : 0);
});
});
return results;
} else {
return {};
}
}
return (i) => doOneBattle(i);
});
let played = {};
let winned = {};
let works = Array.from({ length: 1000 }, (v, i) => i).map(i => {
return work(i).then(result => {
Object.keys(result).forEach(model => {
if (result[model]) {
winned[model] = (winned[model] || 0) + 1;
}
played[model] = (played[model] || 0) + 1;
});
console.warn("------------------------------------------------");
console.warn(`--- Results after battle ${i}`);
Object.keys(played).sort().forEach(model => {
let factor = (winned[model] || 0) / played[model];
console.warn(`${model} ${Math.round(factor * 100)}%`);
});
console.warn("------------------------------------------------");
});
});
Promise.all(works).then(() => process.exit(0));

View file

@ -1,34 +0,0 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SpaceTac - Unit tests</title>
<link rel="stylesheet" href="/jasmine/jasmine.css">
<style>
canvas {
display: none;
}
.jasmine-result-message {
white-space: pre-wrap !important;
}
</style>
</head>
<body>
<div style="display: none; visibility: hidden; height: 0; overflow: hidden;">
<div id="-space-tac" class="game"></div>
</div>
<script src="/jasmine/jasmine.js"></script>
<script src="/jasmine/jasmine-html.js"></script>
<script src="/jasmine/boot.js"></script>
<script src="dependencies.js"></script>
<script src="app.js"></script>
<script src="tests.js"></script>
</body>
</html>

7675
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,21 +2,31 @@
"name": "spacetac",
"version": "0.1.0",
"description": "A tactical RPG set in space",
"main": "out/build.js",
"main": "dist/spacetac.umd.js",
"scripts": {
"build": "run build",
"test": "run ci",
"start": "run continuous"
"build": "microbundle build -f modern,umd",
"test": "jest",
"start": "run continuous",
"normalize": "tk-base",
"dev": "run-p dev:*",
"dev:test": "jest --watchAll",
"dev:build": "microbundle watch -f modern,umd",
"dev:serve": "live-server --host=localhost --port=5000 --no-browser --ignorePattern='.*\\.d\\.ts' dist",
"prepare": "npm run build",
"prepublishOnly": "npm test"
},
"repository": {
"type": "git",
"url": "https://code.thunderk.net/michael/spacetac.git"
"url": "https://code.thunderk.net/games/spacetac.git"
},
"author": "Michael Lemaire",
"license": "MIT",
"author": {
"name": "Michaël Lemaire",
"url": "https://thunderk.net"
},
"license": "ISC",
"devDependencies": {
"@types/jasmine": "^3.3.12",
"codecov": "^3.4.0",
"@types/parse": "^2.9.0",
"gamefroot-texture-packer": "github:Gamefroot/Gamefroot-Texture-Packer#f3687111afc94f80ea8f2877c188fb8e2004e8ff",
"glob": "^7.1.4",
"glob-watcher": "^5.0.3",
@ -27,20 +37,31 @@
"karma-coverage": "^1.1.2",
"karma-jasmine": "^2.0.1",
"karma-spec-reporter": "^0.0.32",
"live-server": "1.2.1",
"process-pool": "^0.3.5",
"remap-istanbul": "^0.13.0",
"runjs": "^4.4.2",
"shelljs": "^0.8.3",
"terser": "^3.17.0",
"typescript": "^3.5.0-rc"
"tk-base": "^0.2.5"
},
"dependencies": {
"parse": "^2.4.0",
"phaser": "^3.17.0"
"phaser": "^3.20.1"
},
"dependenciesMap": {
"parse": "dist/parse.min.js",
"phaser": "dist/phaser.min.js"
}
},
"source": "src/index.ts",
"module": "dist/spacetac.modern.js",
"types": "dist/index.d.ts",
"files": [
"/src",
"/dist"
],
"bugs": {
"url": "https://gitlab.com/thunderk/spacetac/issues"
},
"homepage": "https://code.thunderk.net/tslib/spacetac",
"keywords": [
"typescript"
]
}

View file

@ -1,41 +1,42 @@
/// <reference path="../node_modules/phaser/types/phaser.d.ts"/>
import { testing } from "./common/Testing";
import { bool } from "./common/Tools";
import { GameSession } from "./core/GameSession";
import { setupEmptyView } from "./ui/TestGame";
module TK.SpaceTac.UI.Specs {
class FakeStorage {
data: any = {}
getItem(name: string) {
return this.data[name];
}
setItem(name: string, value: string) {
this.data[name] = value;
}
}
testing("MainUI", test => {
let testgame = setupEmptyView(test);
test.case("saves games in local browser storage", check => {
let ui = testgame.ui;
ui.storage = <any>new FakeStorage();
let result = ui.loadGame("spacetac-test-save");
check.equals(result, false);
ui.session.startNewGame();
let systems = ui.session.universe.stars.length;
let links = ui.session.universe.starlinks.length;
result = ui.saveGame("spacetac-test-save");
check.equals(result, true);
check.equals(bool(ui.storage.getItem("spacetac-test-save")), true);
ui.session = new GameSession();
check.notsame(ui.session.universe.stars.length, systems);
result = ui.loadGame("spacetac-test-save");
check.equals(result, true);
check.same(ui.session.universe.stars.length, systems);
check.same(ui.session.universe.starlinks.length, links);
});
});
class FakeStorage {
data: any = {}
getItem(name: string) {
return this.data[name];
}
setItem(name: string, value: string) {
this.data[name] = value;
}
}
testing("MainUI", test => {
let testgame = setupEmptyView(test);
test.case("saves games in local browser storage", check => {
let ui = testgame.ui;
ui.storage = <any>new FakeStorage();
let result = ui.loadGame("spacetac-test-save");
check.equals(result, false);
ui.session.startNewGame();
let systems = ui.session.universe.stars.length;
let links = ui.session.universe.starlinks.length;
result = ui.saveGame("spacetac-test-save");
check.equals(result, true);
check.equals(bool(ui.storage.getItem("spacetac-test-save")), true);
ui.session = new GameSession();
check.notsame(ui.session.universe.stars.length, systems);
result = ui.loadGame("spacetac-test-save");
check.equals(result, true);
check.same(ui.session.universe.stars.length, systems);
check.same(ui.session.universe.starlinks.length, links);
});
});

View file

@ -1,217 +1,208 @@
/// <reference path="../node_modules/phaser/types/phaser.d.ts"/>
declare var global: any;
declare var module: any;
import { RandomGenerator } from "./common/RandomGenerator"
import { iteritems, keys } from "./common/Tools"
import { GameSession } from "./core/GameSession"
import { AssetLoading } from "./ui/AssetLoading"
import { BaseView } from "./ui/BaseView"
import { BattleView } from "./ui/battle/BattleView"
import { Boot } from "./ui/Boot"
import { FleetCreationView } from "./ui/character/FleetCreationView"
import { AudioManager } from "./ui/common/AudioManager"
import { IntroView } from "./ui/intro/IntroView"
import { UniverseMapView } from "./ui/map/UniverseMapView"
import { MainMenu } from "./ui/menu/MainMenu"
import { GameOptions } from "./ui/options/GameOptions"
import { Router } from "./ui/Router"
if (typeof window != "undefined") {
// If jasmine is not present, ignore describe
(<any>window).describe = (<any>window).describe || function () { };
} else {
if (typeof global != "undefined") {
// In node, does not extend Phaser classes
var handler = {
get(target: any, name: any): any {
return new Proxy(function () { }, handler);
}
}
global.Phaser = new Proxy(function () { }, handler);
}
/**
* Main class to bootstrap the whole game
*/
export class MainUI extends Phaser.Game {
// Current game session
session: GameSession
session_token: string | null
if (typeof module != "undefined") {
module.exports = { TK };
}
}
module TK.SpaceTac {
/**
* Main class to bootstrap the whole game
*/
export class MainUI extends Phaser.Game {
// Current game session
session: GameSession
session_token: string | null
// Audio manager
audio = new UI.Audio(this)
// Game options
options = new UI.GameOptions(this)
// Storage used
storage: Storage
// Debug mode
debug = false
// Current scaling
scaling = 1
constructor(private testmode = false) {
super({
width: 1920,
height: 1080,
type: testmode ? Phaser.CANVAS : Phaser.WEBGL, // cannot really use HEADLESS because of bugs
backgroundColor: '#000000',
parent: '-space-tac',
disableContextMenu: true,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
});
this.storage = localStorage;
this.session = new GameSession();
this.session_token = null;
if (!testmode) {
this.events.on("blur", () => {
this.scene.scenes.forEach(scene => this.scene.pause(scene));
});
this.events.on("focus", () => {
this.scene.scenes.forEach(scene => this.scene.resume(scene));
});
this.scene.add('boot', UI.Boot);
this.scene.add('loading', UI.AssetLoading);
this.scene.add('mainmenu', UI.MainMenu);
this.scene.add('router', UI.Router);
this.scene.add('battle', UI.BattleView);
this.scene.add('intro', UI.IntroView);
this.scene.add('creation', UI.FleetCreationView);
this.scene.add('universe', UI.UniverseMapView);
this.goToScene('boot');
}
}
get isTesting(): boolean {
return this.testmode;
}
/**
* Reset the game session
*/
resetSession(): void {
this.session = new GameSession();
this.session_token = null;
}
/**
* Display a popup message in current view
*/
displayMessage(message: string) {
iteritems(<any>this.scene.keys, (key: string, scene: UI.BaseView) => {
if (scene.messages && this.scene.isVisible(key)) {
scene.messages.addMessage(message);
}
});
}
/**
* Change the active scene
*/
goToScene(name: string): void {
keys(this.scene.keys).forEach(key => {
if (this.scene.isActive(key) || this.scene.isVisible(key)) {
this.scene.stop(key);
}
});
this.scene.start(name);
}
/**
* Quit the current session, and go back to mainmenu
*/
quitGame() {
this.resetSession();
this.goToScene('router');
}
/**
* Save current game in local browser storage
*/
saveGame(name = "spacetac-savegame"): boolean {
if (typeof this.storage != "undefined") {
this.storage.setItem(name, this.session.saveToString());
this.displayMessage("Game saved");
return true;
} else {
this.displayMessage("Your browser does not support saving");
return false;
}
}
/**
* Set the current game session, and redirect to view router
*/
setSession(session: GameSession, token?: string): void {
this.session = session;
this.session_token = token || null;
this.goToScene("router");
}
/**
* Load current game from local browser storage
*/
loadGame(name = "spacetac-savegame"): boolean {
if (typeof this.storage != "undefined") {
var loaded = this.storage.getItem(name);
if (loaded) {
this.session = GameSession.loadFromString(loaded);
this.session_token = null;
console.log("Game loaded");
return true;
} else {
console.warn("No saved game found");
return false;
}
} else {
console.error("localStorage not available");
return false;
}
}
/**
* Get an hopefully unique device identifier
*/
getDeviceId(): string | null {
if (this.storage) {
const key = "spacetac-device-id";
let stored = this.storage.getItem(key);
if (stored) {
return stored;
} else {
let generated = RandomGenerator.global.id(20);
this.storage.setItem(key, generated);
return generated;
}
} else {
return null;
}
}
/**
* Check if the game is currently fullscreen
*/
isFullscreen(): boolean {
return this.scale.isFullscreen;
}
/**
* Toggle fullscreen mode.
*
* Returns true if the result is fullscreen
*/
toggleFullscreen(active: boolean | null = null): boolean {
if (active === false || (active !== true && this.isFullscreen())) {
this.scale.stopFullscreen();
return false;
} else {
this.scale.startFullscreen();
return true;
}
}
// Audio manager
audio = new AudioManager(this)
// Game options
options = new GameOptions(this)
// Storage used
storage: Storage
// Debug mode
debug = false
// Current scaling
scaling = 1
constructor(private testmode = false) {
super({
width: 1920,
height: 1080,
type: testmode ? Phaser.CANVAS : Phaser.WEBGL, // cannot really use HEADLESS because of bugs
backgroundColor: '#000000',
parent: '-space-tac',
disableContextMenu: true,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
});
this.storage = localStorage;
this.session = new GameSession();
this.session_token = null;
if (!testmode) {
this.events.on("blur", () => {
this.scene.scenes.forEach(scene => this.scene.pause(scene));
});
this.events.on("focus", () => {
this.scene.scenes.forEach(scene => this.scene.resume(scene));
});
this.scene.add('boot', Boot);
this.scene.add('loading', AssetLoading);
this.scene.add('mainmenu', MainMenu);
this.scene.add('router', Router);
this.scene.add('battle', BattleView);
this.scene.add('intro', IntroView);
this.scene.add('creation', FleetCreationView);
this.scene.add('universe', UniverseMapView);
this.goToScene('boot');
}
}
get isTesting(): boolean {
return this.testmode;
}
/**
* Reset the game session
*/
resetSession(): void {
this.session = new GameSession();
this.session_token = null;
}
/**
* Display a popup message in current view
*/
displayMessage(message: string) {
iteritems(<any>this.scene.keys, (key: string, scene: BaseView) => {
if (scene.messages && this.scene.isVisible(key)) {
scene.messages.addMessage(message);
}
});
}
/**
* Change the active scene
*/
goToScene(name: string): void {
keys(this.scene.keys).forEach(key => {
if (this.scene.isActive(key) || this.scene.isVisible(key)) {
this.scene.stop(key);
}
});
this.scene.start(name);
}
/**
* Quit the current session, and go back to mainmenu
*/
quitGame() {
this.resetSession();
this.goToScene('router');
}
/**
* Save current game in local browser storage
*/
saveGame(name = "spacetac-savegame"): boolean {
if (typeof this.storage != "undefined") {
this.storage.setItem(name, this.session.saveToString());
this.displayMessage("Game saved");
return true;
} else {
this.displayMessage("Your browser does not support saving");
return false;
}
}
/**
* Set the current game session, and redirect to view router
*/
setSession(session: GameSession, token?: string): void {
this.session = session;
this.session_token = token || null;
this.goToScene("router");
}
/**
* Load current game from local browser storage
*/
loadGame(name = "spacetac-savegame"): boolean {
if (typeof this.storage != "undefined") {
var loaded = this.storage.getItem(name);
if (loaded) {
this.session = GameSession.loadFromString(loaded);
this.session_token = null;
console.log("Game loaded");
return true;
} else {
console.warn("No saved game found");
return false;
}
} else {
console.error("localStorage not available");
return false;
}
}
/**
* Get an hopefully unique device identifier
*/
getDeviceId(): string | null {
if (this.storage) {
const key = "spacetac-device-id";
let stored = this.storage.getItem(key);
if (stored) {
return stored;
} else {
let generated = RandomGenerator.global.id(20);
this.storage.setItem(key, generated);
return generated;
}
} else {
return null;
}
}
/**
* Check if the game is currently fullscreen
*/
isFullscreen(): boolean {
return this.scale.isFullscreen;
}
/**
* Toggle fullscreen mode.
*
* Returns true if the result is fullscreen
*/
toggleFullscreen(active: boolean | null = null): boolean {
if (active === false || (active !== true && this.isFullscreen())) {
this.scale.stopFullscreen();
return false;
} else {
this.scale.startFullscreen();
return true;
}
}
}

View file

@ -1,230 +1,228 @@
module TK.Specs {
class TestState {
counter = 0
}
class TestState {
counter = 0
}
class TestDiff extends Diff<TestState> {
private value: number
constructor(value = 1) {
super();
this.value = value;
}
apply(state: TestState) {
state.counter += this.value;
}
getReverse() {
return new TestDiff(-this.value);
}
}
class TestDiff extends Diff<TestState> {
private value: number
constructor(value = 1) {
super();
this.value = value;
}
apply(state: TestState) {
state.counter += this.value;
}
getReverse() {
return new TestDiff(-this.value);
}
}
testing("DiffLog", test => {
test.case("stores sequential events", check => {
let log = new DiffLog<TestState>();
check.equals(log.count(), 0);
check.equals(log.get(0), null);
check.equals(log.get(1), null);
check.equals(log.get(2), null);
testing("DiffLog", test => {
test.case("stores sequential events", check => {
let log = new DiffLog<TestState>();
check.equals(log.count(), 0);
check.equals(log.get(0), null);
check.equals(log.get(1), null);
check.equals(log.get(2), null);
log.add(new TestDiff(2));
check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), null);
check.equals(log.get(2), null);
log.add(new TestDiff(2));
check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), null);
check.equals(log.get(2), null);
log.add(new TestDiff(-4));
check.equals(log.count(), 2);
check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), new TestDiff(-4));
check.equals(log.get(2), null);
log.add(new TestDiff(-4));
check.equals(log.count(), 2);
check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), new TestDiff(-4));
check.equals(log.get(2), null);
log.clear(1);
check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2));
log.clear(1);
check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2));
log.clear();
check.equals(log.count(), 0);
})
})
log.clear();
check.equals(log.count(), 0);
})
})
testing("DiffLogClient", test => {
test.case("adds diffs to the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
testing("DiffLogClient", test => {
test.case("adds diffs to the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
check.equals(client.atEnd(), true, "client is empty, should be at end");
check.equals(log.count(), 0, "log is empty initially");
check.equals(state.counter, 0, "initial state is 0");
check.equals(client.atEnd(), true, "client is empty, should be at end");
check.equals(log.count(), 0, "log is empty initially");
check.equals(state.counter, 0, "initial state is 0");
client.add(new TestDiff(3));
check.equals(client.atEnd(), true, "client still at end");
check.equals(log.count(), 1, "diff added to log");
check.equals(state.counter, 3, "diff applied to state");
client.add(new TestDiff(3));
check.equals(client.atEnd(), true, "client still at end");
check.equals(log.count(), 1, "diff added to log");
check.equals(state.counter, 3, "diff applied to state");
client.add(new TestDiff(2), false);
check.equals(client.atEnd(), false, "client lapsing behind");
check.equals(log.count(), 2, "diff added to log");
check.equals(state.counter, 3, "diff not applied to state");
})
client.add(new TestDiff(2), false);
check.equals(client.atEnd(), false, "client lapsing behind");
check.equals(log.count(), 2, "diff added to log");
check.equals(state.counter, 3, "diff not applied to state");
})
test.case("initializes at current state (end of log)", check => {
let state = new TestState();
let log = new DiffLog<TestState>();
log.add(new TestDiff(7));
let client = new DiffLogClient(state, log);
check.equals(client.atStart(), false);
check.equals(client.atEnd(), true);
check.equals(state.counter, 0);
client.forward();
check.equals(state.counter, 0);
client.backward();
check.equals(state.counter, -7);
})
test.case("initializes at current state (end of log)", check => {
let state = new TestState();
let log = new DiffLog<TestState>();
log.add(new TestDiff(7));
let client = new DiffLogClient(state, log);
check.equals(client.atStart(), false);
check.equals(client.atEnd(), true);
check.equals(state.counter, 0);
client.forward();
check.equals(state.counter, 0);
client.backward();
check.equals(state.counter, -7);
})
test.case("moves forward or backward in the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
test.case("moves forward or backward in the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
log.add(new TestDiff(7));
log.add(new TestDiff(-2));
log.add(new TestDiff(4));
log.add(new TestDiff(7));
log.add(new TestDiff(-2));
log.add(new TestDiff(4));
check.equals(state.counter, 0, "initial state is 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end");
check.equals(state.counter, 0, "initial state is 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end");
client.forward();
check.equals(state.counter, 7, "0+7 => 7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.forward();
check.equals(state.counter, 7, "0+7 => 7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.forward();
check.equals(state.counter, 5, "7-2 => 5");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.forward();
check.equals(state.counter, 5, "7-2 => 5");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.forward();
check.equals(state.counter, 9, "5+4 => 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end");
client.forward();
check.equals(state.counter, 9, "5+4 => 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end");
client.forward();
check.equals(state.counter, 9, "at end, still 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end");
client.forward();
check.equals(state.counter, 9, "at end, still 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end");
client.backward();
check.equals(state.counter, 5, "9-4=>5");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 5, "9-4=>5");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 7, "5+2=>7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 7, "5+2=>7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 0, "7-7=>0");
check.equals(client.atStart(), true, "client is back at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 0, "7-7=>0");
check.equals(client.atStart(), true, "client is back at start");
check.equals(client.atEnd(), false, "client is not at end");
client.backward();
check.equals(state.counter, 0, "at start, still 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end");
})
client.backward();
check.equals(state.counter, 0, "at start, still 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end");
})
test.case("jumps to start or end of the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
test.case("jumps to start or end of the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
client.add(new TestDiff(7));
log.add(new TestDiff(-2));
log.add(new TestDiff(4));
client.add(new TestDiff(7));
log.add(new TestDiff(-2));
log.add(new TestDiff(4));
check.equals(state.counter, 7, "initial state is 7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
check.equals(state.counter, 7, "initial state is 7");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end");
client.jumpToEnd();
check.equals(state.counter, 9, "7-2+4=>9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end");
client.jumpToEnd();
check.equals(state.counter, 9, "7-2+4=>9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end");
client.jumpToEnd();
check.equals(state.counter, 9, "still 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end");
client.jumpToEnd();
check.equals(state.counter, 9, "still 9");
check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end");
client.jumpToStart();
check.equals(state.counter, 0, "9-4+2-7=>0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end");
client.jumpToStart();
check.equals(state.counter, 0, "9-4+2-7=>0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end");
client.jumpToStart();
check.equals(state.counter, 0, "still 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end");
})
client.jumpToStart();
check.equals(state.counter, 0, "still 0");
check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end");
})
test.case("truncate the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
test.case("truncate the log", check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
client.add(new TestDiff(7));
client.add(new TestDiff(3));
client.add(new TestDiff(5));
client.add(new TestDiff(7));
client.add(new TestDiff(3));
client.add(new TestDiff(5));
check.in("initial state", check => {
check.equals(state.counter, 15, "state=15");
check.equals(log.count(), 3, "count=3");
});
check.in("initial state", check => {
check.equals(state.counter, 15, "state=15");
check.equals(log.count(), 3, "count=3");
});
client.backward();
client.backward();
check.in("after backward", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 3, "count=3");
});
check.in("after backward", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 3, "count=3");
});
client.truncate();
client.truncate();
check.in("after truncate", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2");
});
check.in("after truncate", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2");
});
client.truncate();
client.truncate();
check.in("after another truncate", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2");
});
})
check.in("after another truncate", check => {
check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2");
});
})
test.acase("plays the log continuously", async check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
test.acase("plays the log continuously", async check => {
let log = new DiffLog<TestState>();
let state = new TestState();
let client = new DiffLogClient(state, log);
let inter: number[] = [];
let promise = client.play(diff => {
inter.push((<any>diff).value);
return Promise.resolve();
});
let inter: number[] = [];
let promise = client.play(diff => {
inter.push((<any>diff).value);
return Promise.resolve();
});
log.add(new TestDiff(5));
log.add(new TestDiff(-1));
log.add(new TestDiff(2));
client.stop(false);
log.add(new TestDiff(5));
log.add(new TestDiff(-1));
log.add(new TestDiff(2));
client.stop(false);
await promise;
await promise;
check.equals(state.counter, 6);
check.equals(inter, [5, -1, 2]);
})
})
}
check.equals(state.counter, 6);
check.equals(inter, [5, -1, 2]);
})
})

View file

@ -1,249 +1,244 @@
import { Timer } from "./Timer";
/**
* Framework to maintain a state from a log of changes
*
* This allows for repeatable, serializable and revertable state modifications.
* Base class for a single diff.
*
* This represents an atomic change of the state, that can be applied, or reverted.
*/
module TK {
/**
* Base class for a single diff.
*
* This represents an atomic change of the state, that can be applied, or reverted.
*/
export class Diff<T> {
/**
* Apply the diff on a given state
*
* By default it does nothing
*/
apply(state: T): void {
}
export class Diff<T> {
/**
* Apply the diff on a given state
*
* By default it does nothing
*/
apply(state: T): void {
}
/**
* Reverts the diff from a given state
*
* By default it applies the reverse event
*/
revert(state: T): void {
this.getReverse().apply(state);
}
/**
* Reverts the diff from a given state
*
* By default it applies the reverse event
*/
revert(state: T): void {
this.getReverse().apply(state);
}
/**
* Get the reverse event
*
* By default it returns a stub event that does nothing
*/
protected getReverse(): Diff<T> {
return new Diff<T>();
}
}
/**
* Collection of sequential diffs
*/
export class DiffLog<T> {
private diffs: Diff<T>[] = []
/**
* Add a single diff at the end of the log
*/
add(diff: Diff<T>): void {
this.diffs.push(diff);
}
/**
* Get the diff at a specific index
*/
get(idx: number): Diff<T> | null {
return this.diffs[idx] || null;
}
/**
* Return the total count of diffs
*/
count(): number {
return this.diffs.length;
}
/**
* Clean all stored diffs, starting at a given index
*
* The caller should be sure that no log client is beyond the cut index.
*/
clear(start = 0): void {
this.diffs = this.diffs.slice(0, start);
}
}
/**
* Client for a DiffLog, able to go forward or backward in the log, applying diffs as needed
*/
export class DiffLogClient<T> {
private state: T
private log: DiffLog<T>
private cursor = -1
private playing = false
private stopping = false
private paused = false
private timer = Timer.global
constructor(state: T, log: DiffLog<T>) {
this.state = state;
this.log = log;
this.cursor = log.count() - 1;
}
/**
* Returns true if the log is currently playing
*/
isPlaying(): boolean {
return this.playing && !this.paused && !this.stopping;
}
/**
* Get the current diff pointed at
*/
getCurrent(): Diff<T> | null {
return this.log.get(this.cursor);
}
/**
* Push a diff to the underlying log, applying it immediately if required
*/
add(diff: Diff<T>, apply = true): void {
this.log.add(diff);
if (apply) {
this.jumpToEnd();
}
}
/**
* Apply the underlying log continuously, until *stop* is called
*
* If *after_apply* is provided, it will be called after each diff is applied, and waited upon before resuming
*/
async play(after_apply?: (diff: Diff<T>) => Promise<void>): Promise<void> {
if (this.playing) {
console.error("DiffLogClient already playing", this);
return;
}
this.playing = true;
this.stopping = false;
while (this.playing) {
if (!this.paused) {
let diff = this.forward();
if (diff && after_apply) {
await after_apply(diff);
}
}
if (this.atEnd()) {
if (this.stopping) {
break;
} else {
await this.timer.sleep(50);
}
}
}
}
/**
* Stop the previous *play*
*/
stop(immediate = true): void {
if (!this.playing) {
console.error("DiffLogClient not playing", this);
return;
}
if (immediate) {
this.playing = false;
}
this.stopping = true;
}
/**
* Make a step backward in time (revert one diff)
*/
backward(): Diff<T> | null {
if (!this.atStart()) {
this.cursor -= 1;
this.paused = true;
let diff = this.log.get(this.cursor + 1);
if (diff) {
diff.revert(this.state);
}
return diff;
} else {
return null;
}
}
/**
* Make a step forward in time (apply one diff)
*/
forward(): Diff<T> | null {
if (!this.atEnd()) {
this.cursor += 1;
if (this.atEnd()) {
this.paused = false;
}
let diff = this.log.get(this.cursor);
if (diff) {
diff.apply(this.state);
}
return diff;
} else {
return null;
}
}
/**
* Jump to the start of the log
*
* This will rewind all applied event
*/
jumpToStart() {
while (!this.atStart()) {
this.backward();
}
}
/**
* Jump to the end of the log
*
* This will apply all remaining event
*/
jumpToEnd() {
while (!this.atEnd()) {
this.forward();
}
}
/**
* Check if we are currently at the start of the log
*/
atStart(): boolean {
return this.cursor < 0;
}
/**
* Check if we are currently at the end of the log
*/
atEnd(): boolean {
return this.cursor >= this.log.count() - 1;
}
/**
* Truncate all diffs after the current position
*
* This is useful when using the log to "undo" something
*/
truncate(): void {
this.log.clear(this.cursor + 1);
}
}
/**
* Get the reverse event
*
* By default it returns a stub event that does nothing
*/
protected getReverse(): Diff<T> {
return new Diff<T>();
}
}
/**
* Collection of sequential diffs
*/
export class DiffLog<T> {
private diffs: Diff<T>[] = []
/**
* Add a single diff at the end of the log
*/
add(diff: Diff<T>): void {
this.diffs.push(diff);
}
/**
* Get the diff at a specific index
*/
get(idx: number): Diff<T> | null {
return this.diffs[idx] || null;
}
/**
* Return the total count of diffs
*/
count(): number {
return this.diffs.length;
}
/**
* Clean all stored diffs, starting at a given index
*
* The caller should be sure that no log client is beyond the cut index.
*/
clear(start = 0): void {
this.diffs = this.diffs.slice(0, start);
}
}
/**
* Client for a DiffLog, able to go forward or backward in the log, applying diffs as needed
*/
export class DiffLogClient<T> {
private state: T
private log: DiffLog<T>
private cursor = -1
private playing = false
private stopping = false
private paused = false
private timer = Timer.global
constructor(state: T, log: DiffLog<T>) {
this.state = state;
this.log = log;
this.cursor = log.count() - 1;
}
/**
* Returns true if the log is currently playing
*/
isPlaying(): boolean {
return this.playing && !this.paused && !this.stopping;
}
/**
* Get the current diff pointed at
*/
getCurrent(): Diff<T> | null {
return this.log.get(this.cursor);
}
/**
* Push a diff to the underlying log, applying it immediately if required
*/
add(diff: Diff<T>, apply = true): void {
this.log.add(diff);
if (apply) {
this.jumpToEnd();
}
}
/**
* Apply the underlying log continuously, until *stop* is called
*
* If *after_apply* is provided, it will be called after each diff is applied, and waited upon before resuming
*/
async play(after_apply?: (diff: Diff<T>) => Promise<void>): Promise<void> {
if (this.playing) {
console.error("DiffLogClient already playing", this);
return;
}
this.playing = true;
this.stopping = false;
while (this.playing) {
if (!this.paused) {
let diff = this.forward();
if (diff && after_apply) {
await after_apply(diff);
}
}
if (this.atEnd()) {
if (this.stopping) {
break;
} else {
await this.timer.sleep(50);
}
}
}
}
/**
* Stop the previous *play*
*/
stop(immediate = true): void {
if (!this.playing) {
console.error("DiffLogClient not playing", this);
return;
}
if (immediate) {
this.playing = false;
}
this.stopping = true;
}
/**
* Make a step backward in time (revert one diff)
*/
backward(): Diff<T> | null {
if (!this.atStart()) {
this.cursor -= 1;
this.paused = true;
let diff = this.log.get(this.cursor + 1);
if (diff) {
diff.revert(this.state);
}
return diff;
} else {
return null;
}
}
/**
* Make a step forward in time (apply one diff)
*/
forward(): Diff<T> | null {
if (!this.atEnd()) {
this.cursor += 1;
if (this.atEnd()) {
this.paused = false;
}
let diff = this.log.get(this.cursor);
if (diff) {
diff.apply(this.state);
}
return diff;
} else {
return null;
}
}
/**
* Jump to the start of the log
*
* This will rewind all applied event
*/
jumpToStart() {
while (!this.atStart()) {
this.backward();
}
}
/**
* Jump to the end of the log
*
* This will apply all remaining event
*/
jumpToEnd() {
while (!this.atEnd()) {
this.forward();
}
}
/**
* Check if we are currently at the start of the log
*/
atStart(): boolean {
return this.cursor < 0;
}
/**
* Check if we are currently at the end of the log
*/
atEnd(): boolean {
return this.cursor >= this.log.count() - 1;
}
/**
* Truncate all diffs after the current position
*
* This is useful when using the log to "undo" something
*/
truncate(): void {
this.log.clear(this.cursor + 1);
}
}

View file

@ -1,241 +1,239 @@
module TK {
testing("Iterators", test => {
function checkit<T>(check: TestContext, base_iterator: Iterable<T>, values: T[], infinite = false) {
function checker(check: TestContext) {
let iterator = base_iterator[Symbol.iterator]();
values.forEach((value, idx) => {
let state = iterator.next();
check.equals(state.done, false, `index ${idx} not done`);
check.equals(state.value, value, `index ${idx} value`);
});
if (!infinite) {
range(3).forEach(oidx => {
let state = iterator.next();
check.equals(state.done, true, `index ${values.length + oidx} done`);
});
}
}
check.in("first iteration", checker);
check.in("second iteration", checker);
}
test.case("constructs an iterator from a recurrent formula", check => {
checkit(check, irecur(1, x => x + 2), [1, 3, 5], true);
checkit(check, irecur(4, x => x ? x - 1 : null), [4, 3, 2, 1, 0]);
testing("Iterators", test => {
function checkit<T>(check: TestContext, base_iterator: Iterable<T>, values: T[], infinite = false) {
function checker(check: TestContext) {
let iterator = base_iterator[Symbol.iterator]();
values.forEach((value, idx) => {
let state = iterator.next();
check.equals(state.done, false, `index ${idx} not done`);
check.equals(state.value, value, `index ${idx} value`);
});
if (!infinite) {
range(3).forEach(oidx => {
let state = iterator.next();
check.equals(state.done, true, `index ${values.length + oidx} done`);
});
}
}
test.case("constructs an iterator from an array", check => {
checkit(check, iarray([]), []);
checkit(check, iarray([1, 2, 3]), [1, 2, 3]);
});
check.in("first iteration", checker);
check.in("second iteration", checker);
}
test.case("constructs an iterator from a single value", check => {
checkit(check, isingle(1), [1]);
checkit(check, isingle("a"), ["a"]);
});
test.case("constructs an iterator from a recurrent formula", check => {
checkit(check, irecur(1, x => x + 2), [1, 3, 5], true);
checkit(check, irecur(4, x => x ? x - 1 : null), [4, 3, 2, 1, 0]);
});
test.case("repeats a value", check => {
checkit(check, irepeat("a"), ["a", "a", "a", "a"], true);
checkit(check, irepeat("a", 3), ["a", "a", "a"]);
});
test.case("constructs an iterator from an array", check => {
checkit(check, iarray([]), []);
checkit(check, iarray([1, 2, 3]), [1, 2, 3]);
});
test.case("calls a function for each yielded value", check => {
let iterator = iarray([1, 2, 3]);
let result: number[] = [];
iforeach(iterator, bound(result, "push"));
check.equals(result, [1, 2, 3]);
test.case("constructs an iterator from a single value", check => {
checkit(check, isingle(1), [1]);
checkit(check, isingle("a"), ["a"]);
});
result = [];
iforeach(iterator, i => {
result.push(i);
if (i == 2) {
return null;
} else {
return undefined;
}
});
check.equals(result, [1, 2]);
test.case("repeats a value", check => {
checkit(check, irepeat("a"), ["a", "a", "a", "a"], true);
checkit(check, irepeat("a", 3), ["a", "a", "a"]);
});
result = [];
iforeach(iterator, i => {
result.push(i);
return i;
}, 2);
check.equals(result, [1, 2]);
});
test.case("calls a function for each yielded value", check => {
let iterator = iarray([1, 2, 3]);
let result: number[] = [];
iforeach(iterator, bound(result, "push"));
check.equals(result, [1, 2, 3]);
test.case("finds the first item passing a predicate", check => {
check.equals(ifirst(iarray(<number[]>[]), i => i % 2 == 0), null);
check.equals(ifirst(iarray([1, 2, 3]), i => i % 2 == 0), 2);
check.equals(ifirst(iarray([1, 3, 5]), i => i % 2 == 0), null);
});
test.case("finds the first item mapping to a value", check => {
let predicate = (i: number) => i % 2 == 0 ? (i * 4).toString() : null
check.equals(ifirstmap(iarray([]), predicate), null);
check.equals(ifirstmap(iarray([1, 2, 3]), predicate), "8");
check.equals(ifirstmap(iarray([1, 3, 5]), predicate), null);
});
test.case("materializes an array from an iterator", check => {
check.equals(imaterialize(iarray([1, 2, 3])), [1, 2, 3]);
check.throw(() => imaterialize(iarray([1, 2, 3, 4, 5]), 2), "Length limit on iterator materialize");
});
test.case("creates an iterator in a range of integers", check => {
checkit(check, irange(4), [0, 1, 2, 3]);
checkit(check, irange(4, 1), [1, 2, 3, 4]);
checkit(check, irange(5, 3, 2), [3, 5, 7, 9, 11]);
checkit(check, irange(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true);
});
test.case("uses a step iterator to scan numbers", check => {
checkit(check, istep(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true);
checkit(check, istep(3), [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], true);
checkit(check, istep(3, irepeat(1, 4)), [3, 4, 5, 6, 7]);
checkit(check, istep(8, IEMPTY), [8]);
checkit(check, istep(1, irange()), [1, 1, 2, 4, 7, 11, 16], true);
});
test.case("skips a number of values", check => {
checkit(check, iskip(irange(7), 3), [3, 4, 5, 6]);
checkit(check, iskip(irange(7), 12), []);
checkit(check, iskip(IEMPTY, 3), []);
});
test.case("gets a value at an iterator position", check => {
check.equals(iat(irange(), -1), null);
check.equals(iat(irange(), 0), 0);
check.equals(iat(irange(), 8), 8);
check.equals(iat(irange(5), 8), null);
check.equals(iat(IEMPTY, 0), null);
});
test.case("chains iterator of iterators", check => {
checkit(check, ichainit(IEMPTY), []);
checkit(check, ichainit(iarray([iarray([1, 2, 3]), iarray([]), iarray([4, 5])])), [1, 2, 3, 4, 5]);
});
test.case("chains iterators", check => {
checkit(check, ichain(), []);
checkit(check, ichain(irange(3)), [0, 1, 2]);
checkit(check, ichain(iarray([1, 2]), iarray([]), iarray([3, 4, 5])), [1, 2, 3, 4, 5]);
});
test.case("loops an iterator", check => {
checkit(check, iloop(irange(3), 2), [0, 1, 2, 0, 1, 2]);
checkit(check, iloop(irange(1)), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], true);
let onloop = check.mockfunc("onloop");
let iterator = iloop(irange(2), 3, onloop.func)[Symbol.iterator]();
check.in("idx 0", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 0);
});
check.in("idx 1", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 2", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 1);
});
check.in("idx 3", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 4", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 1);
});
check.in("idx 5", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 6", check => {
check.equals(iterator.next().value, undefined);
check.called(onloop, 0);
});
});
test.case("maps an iterator", check => {
checkit(check, imap(IEMPTY, i => i * 2), []);
checkit(check, imap(irange(3), i => i * 2), [0, 2, 4]);
});
test.case("reduces an iterator", check => {
check.equals(ireduce(IEMPTY, (a, b) => a + b, 2), 2);
check.equals(ireduce([9], (a, b) => a + b, 2), 11);
check.equals(ireduce([9, 1], (a, b) => a + b, 2), 12);
});
test.case("filters an iterator with a predicate", check => {
checkit(check, imap(IEMPTY, i => i % 3 == 0), []);
checkit(check, ifilter(irange(12), i => i % 3 == 0), [0, 3, 6, 9]);
});
test.case("filters an iterator with a type guard", check => {
let result = ifiltertype(<(number | string)[]>[1, "a", 2, "b"], (x): x is number => typeof x == "number");
checkit(check, result, [1, 2]);
});
test.case("filters an iterator with a class type", check => {
let o1 = new RObject();
let o2 = new RObject();
let o3 = new RObjectContainer();
let result = ifilterclass([1, "a", o1, 2, o2, o3, "b"], RObject);
checkit(check, result, [o1, o2]);
});
test.case("combines iterators", check => {
let iterator = icombine(iarray([1, 2, 3]), iarray(["a", "b"]));
checkit(check, iterator, [[1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]]);
});
test.case("zips iterators", check => {
checkit(check, izip(IEMPTY, IEMPTY), []);
checkit(check, izip(iarray([1, 2, 3]), iarray(["a", "b"])), [[1, "a"], [2, "b"]]);
checkit(check, izipg(IEMPTY, IEMPTY), []);
checkit(check, izipg(iarray([1, 2, 3]), iarray(["a", "b"])), <[number | undefined, string | undefined][]>[[1, "a"], [2, "b"], [3, undefined]]);
});
test.case("partitions iterators", check => {
let [it1, it2] = ipartition(IEMPTY, () => true);
checkit(check, it1, []);
checkit(check, it2, []);
[it1, it2] = ipartition(irange(5), i => i % 2 == 0);
checkit(check, it1, [0, 2, 4]);
checkit(check, it2, [1, 3]);
});
test.case("alternatively pick from several iterables", check => {
checkit(check, ialternate([]), []);
checkit(check, ialternate([[1, 2, 3, 4], [], iarray([5, 6]), IEMPTY, iarray([7, 8, 9])]), [1, 5, 7, 2, 6, 8, 3, 9, 4]);
});
test.case("returns unique items", check => {
checkit(check, iunique(IEMPTY), []);
checkit(check, iunique(iarray([5, 3, 2, 3, 4, 5])), [5, 3, 2, 4]);
checkit(check, iunique(iarray([5, 3, 2, 3, 4, 5]), 4), [5, 3, 2, 4]);
check.throw(() => imaterialize(iunique(iarray([5, 3, 2, 3, 4, 5]), 3)), "Unique count limit on iterator");
});
test.case("uses ireduce for some common functions", check => {
check.equals(isum(IEMPTY), 0);
check.equals(isum(irange(4)), 6);
check.equals(icat(IEMPTY), "");
check.equals(icat(iarray(["a", "bc", "d"])), "abcd");
check.equals(imin(IEMPTY), Infinity);
check.equals(imin(iarray([3, 8, 2, 4])), 2);
check.equals(imax(IEMPTY), -Infinity);
check.equals(imax(iarray([3, 8, 2, 4])), 8);
});
result = [];
iforeach(iterator, i => {
result.push(i);
if (i == 2) {
return null;
} else {
return undefined;
}
});
}
check.equals(result, [1, 2]);
result = [];
iforeach(iterator, i => {
result.push(i);
return i;
}, 2);
check.equals(result, [1, 2]);
});
test.case("finds the first item passing a predicate", check => {
check.equals(ifirst(iarray(<number[]>[]), i => i % 2 == 0), null);
check.equals(ifirst(iarray([1, 2, 3]), i => i % 2 == 0), 2);
check.equals(ifirst(iarray([1, 3, 5]), i => i % 2 == 0), null);
});
test.case("finds the first item mapping to a value", check => {
let predicate = (i: number) => i % 2 == 0 ? (i * 4).toString() : null
check.equals(ifirstmap(iarray([]), predicate), null);
check.equals(ifirstmap(iarray([1, 2, 3]), predicate), "8");
check.equals(ifirstmap(iarray([1, 3, 5]), predicate), null);
});
test.case("materializes an array from an iterator", check => {
check.equals(imaterialize(iarray([1, 2, 3])), [1, 2, 3]);
check.throw(() => imaterialize(iarray([1, 2, 3, 4, 5]), 2), "Length limit on iterator materialize");
});
test.case("creates an iterator in a range of integers", check => {
checkit(check, irange(4), [0, 1, 2, 3]);
checkit(check, irange(4, 1), [1, 2, 3, 4]);
checkit(check, irange(5, 3, 2), [3, 5, 7, 9, 11]);
checkit(check, irange(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true);
});
test.case("uses a step iterator to scan numbers", check => {
checkit(check, istep(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true);
checkit(check, istep(3), [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], true);
checkit(check, istep(3, irepeat(1, 4)), [3, 4, 5, 6, 7]);
checkit(check, istep(8, IEMPTY), [8]);
checkit(check, istep(1, irange()), [1, 1, 2, 4, 7, 11, 16], true);
});
test.case("skips a number of values", check => {
checkit(check, iskip(irange(7), 3), [3, 4, 5, 6]);
checkit(check, iskip(irange(7), 12), []);
checkit(check, iskip(IEMPTY, 3), []);
});
test.case("gets a value at an iterator position", check => {
check.equals(iat(irange(), -1), null);
check.equals(iat(irange(), 0), 0);
check.equals(iat(irange(), 8), 8);
check.equals(iat(irange(5), 8), null);
check.equals(iat(IEMPTY, 0), null);
});
test.case("chains iterator of iterators", check => {
checkit(check, ichainit(IEMPTY), []);
checkit(check, ichainit(iarray([iarray([1, 2, 3]), iarray([]), iarray([4, 5])])), [1, 2, 3, 4, 5]);
});
test.case("chains iterators", check => {
checkit(check, ichain(), []);
checkit(check, ichain(irange(3)), [0, 1, 2]);
checkit(check, ichain(iarray([1, 2]), iarray([]), iarray([3, 4, 5])), [1, 2, 3, 4, 5]);
});
test.case("loops an iterator", check => {
checkit(check, iloop(irange(3), 2), [0, 1, 2, 0, 1, 2]);
checkit(check, iloop(irange(1)), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], true);
let onloop = check.mockfunc("onloop");
let iterator = iloop(irange(2), 3, onloop.func)[Symbol.iterator]();
check.in("idx 0", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 0);
});
check.in("idx 1", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 2", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 1);
});
check.in("idx 3", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 4", check => {
check.equals(iterator.next().value, 0);
check.called(onloop, 1);
});
check.in("idx 5", check => {
check.equals(iterator.next().value, 1);
check.called(onloop, 0);
});
check.in("idx 6", check => {
check.equals(iterator.next().value, undefined);
check.called(onloop, 0);
});
});
test.case("maps an iterator", check => {
checkit(check, imap(IEMPTY, i => i * 2), []);
checkit(check, imap(irange(3), i => i * 2), [0, 2, 4]);
});
test.case("reduces an iterator", check => {
check.equals(ireduce(IEMPTY, (a, b) => a + b, 2), 2);
check.equals(ireduce([9], (a, b) => a + b, 2), 11);
check.equals(ireduce([9, 1], (a, b) => a + b, 2), 12);
});
test.case("filters an iterator with a predicate", check => {
checkit(check, imap(IEMPTY, i => i % 3 == 0), []);
checkit(check, ifilter(irange(12), i => i % 3 == 0), [0, 3, 6, 9]);
});
test.case("filters an iterator with a type guard", check => {
let result = ifiltertype(<(number | string)[]>[1, "a", 2, "b"], (x): x is number => typeof x == "number");
checkit(check, result, [1, 2]);
});
test.case("filters an iterator with a class type", check => {
let o1 = new RObject();
let o2 = new RObject();
let o3 = new RObjectContainer();
let result = ifilterclass([1, "a", o1, 2, o2, o3, "b"], RObject);
checkit(check, result, [o1, o2]);
});
test.case("combines iterators", check => {
let iterator = icombine(iarray([1, 2, 3]), iarray(["a", "b"]));
checkit(check, iterator, [[1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]]);
});
test.case("zips iterators", check => {
checkit(check, izip(IEMPTY, IEMPTY), []);
checkit(check, izip(iarray([1, 2, 3]), iarray(["a", "b"])), [[1, "a"], [2, "b"]]);
checkit(check, izipg(IEMPTY, IEMPTY), []);
checkit(check, izipg(iarray([1, 2, 3]), iarray(["a", "b"])), <[number | undefined, string | undefined][]>[[1, "a"], [2, "b"], [3, undefined]]);
});
test.case("partitions iterators", check => {
let [it1, it2] = ipartition(IEMPTY, () => true);
checkit(check, it1, []);
checkit(check, it2, []);
[it1, it2] = ipartition(irange(5), i => i % 2 == 0);
checkit(check, it1, [0, 2, 4]);
checkit(check, it2, [1, 3]);
});
test.case("alternatively pick from several iterables", check => {
checkit(check, ialternate([]), []);
checkit(check, ialternate([[1, 2, 3, 4], [], iarray([5, 6]), IEMPTY, iarray([7, 8, 9])]), [1, 5, 7, 2, 6, 8, 3, 9, 4]);
});
test.case("returns unique items", check => {
checkit(check, iunique(IEMPTY), []);
checkit(check, iunique(iarray([5, 3, 2, 3, 4, 5])), [5, 3, 2, 4]);
checkit(check, iunique(iarray([5, 3, 2, 3, 4, 5]), 4), [5, 3, 2, 4]);
check.throw(() => imaterialize(iunique(iarray([5, 3, 2, 3, 4, 5]), 3)), "Unique count limit on iterator");
});
test.case("uses ireduce for some common functions", check => {
check.equals(isum(IEMPTY), 0);
check.equals(isum(irange(4)), 6);
check.equals(icat(IEMPTY), "");
check.equals(icat(iarray(["a", "bc", "d"])), "abcd");
check.equals(imin(IEMPTY), Infinity);
check.equals(imin(iarray([3, 8, 2, 4])), 2);
check.equals(imax(IEMPTY), -Infinity);
check.equals(imax(iarray([3, 8, 2, 4])), 8);
});
});

View file

@ -1,436 +1,426 @@
import { contains } from "./Tools";
/**
* Lazy iterators to work on dynamic data sets without materializing them.
*
* They allow to work on infinite streams of values, with limited memory consumption.
*
* Functions in this file that do not return an Iterable are "materializing", meaning that they
* may consume iterators up to the end, and will not work well on infinite iterators.
*
* These iterators are guaranteed to be repeatable, meaning that calling Symbol.iterator on them will start over.
* Empty iterator
*/
module TK {
/**
* Empty iterator
*/
export const IATEND: Iterator<any> = {
next: function () {
return { done: true, value: undefined };
}
}
/**
* Empty iterable
*/
export const IEMPTY: Iterable<any> = {
[Symbol.iterator]: () => IATEND
}
/**
* Iterable constructor, from an initial value, and a step value
*/
export function irecur<T, S>(start: T, step: (a: T) => T | null): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let val: T | null = start;
do {
yield val;
val = step(val);
} while (val !== null);
}
}
}
/**
* Iterable constructor, from an array
*
* The iterator will yield the next value each time it is called, then undefined when the array's end is reached.
*/
export function iarray<T>(array: T[], offset = 0): Iterable<T> {
return {
[Symbol.iterator]: function () {
return array.slice(offset)[Symbol.iterator]();
}
}
}
/**
* Iterable constructor, from a single value
*
* The value will be yielded only once, not repeated over.
*/
export function isingle<T>(value: T): Iterable<T> {
return iarray([value]);
}
/**
* Iterable that repeats the same value.
*/
export function irepeat<T>(value: T, count = -1): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let n = count;
while (n != 0) {
yield value;
n--;
}
}
}
}
/**
* Equivalent of Array.forEach for all iterables.
*
* If the callback returns *stopper*, the iteration is stopped.
*/
export function iforeach<T>(iterable: Iterable<T>, callback: (_: T) => any, stopper: any = null): void {
for (let value of iterable) {
if (callback(value) === stopper) {
break;
}
}
}
/**
* Returns the first item passing a predicate
*/
export function ifirst<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): T | null {
for (let value of iterable) {
if (predicate(value)) {
return value;
}
}
return null;
}
/**
* Returns the first non-null result of a value-yielding predicate, applied to each iterator element
*/
export function ifirstmap<T1, T2>(iterable: Iterable<T1>, predicate: (item: T1) => T2 | null): T2 | null {
for (let value of iterable) {
let res = predicate(value);
if (res !== null) {
return res;
}
}
return null;
}
/**
* Materialize an array from consuming an iterable
*
* To avoid materializing infinite iterators (and bursting memory), the item count is limited to 1 million, and an
* exception is thrown when this limit is reached.
*/
export function imaterialize<T>(iterable: Iterable<T>, limit = 1000000): T[] {
let result: T[] = [];
for (let value of iterable) {
result.push(value);
if (result.length >= limit) {
throw new Error("Length limit on iterator materialize");
}
}
return result;
}
/**
* Iterate over natural integers
*
* If *count* is not specified, the iterator is infinite
*/
export function irange(count: number = -1, start = 0, step = 1): Iterable<number> {
return {
[Symbol.iterator]: function* () {
let i = start;
let n = count;
while (n != 0) {
yield i;
i += step;
n--;
}
}
}
}
/**
* Iterate over numbers, by applying a step taken from an other iterator
*
* This iterator stops when the "step iterator" stops
*
* With no argument, istep() == irange()
*/
export function istep(start = 0, step_iterable = irepeat(1)): Iterable<number> {
return {
[Symbol.iterator]: function* () {
let i = start;
yield i;
for (let step of step_iterable) {
i += step;
yield i;
}
}
}
}
/**
* Skip a given number of values from an iterator, discarding them.
*/
export function iskip<T>(iterable: Iterable<T>, count = 1): Iterable<T> {
return {
[Symbol.iterator]: function () {
let iterator = iterable[Symbol.iterator]();
let n = count;
while (n-- > 0) {
iterator.next();
}
return iterator;
}
}
}
/**
* Return the value at a given position in the iterator
*/
export function iat<T>(iterable: Iterable<T>, position: number): T | null {
if (position < 0) {
return null;
} else {
if (position > 0) {
iterable = iskip(iterable, position);
}
let iterator = iterable[Symbol.iterator]();
let state = iterator.next();
return state.done ? null : state.value;
}
}
/**
* Chain an iterable of iterables.
*
* This will yield values from the first yielded iterator, then the second one, and so on...
*/
export function ichainit<T>(iterables: Iterable<Iterable<T>>): Iterable<T> {
return {
[Symbol.iterator]: function* () {
for (let iterable of iterables) {
for (let value of iterable) {
yield value;
}
}
}
}
}
/**
* Chain iterables.
*
* This will yield values from the first iterator, then the second one, and so on...
*/
export function ichain<T>(...iterables: Iterable<T>[]): Iterable<T> {
if (iterables.length == 0) {
return IEMPTY;
} else {
return ichainit(iterables);
}
}
/**
* Loop an iterator for a number of times.
*
* If count is negative, if will loop forever (infinite iterator).
*
* onloop may be used to know when the iterator resets.
*/
export function iloop<T>(base: Iterable<T>, count = -1, onloop?: Function): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let n = count;
let start = false;
while (n-- != 0) {
for (let value of base) {
if (start) {
if (onloop) {
onloop();
}
start = false;
}
yield value;
}
start = true;
}
}
}
}
/**
* Iterator version of "map".
*/
export function imap<T1, T2>(iterable: Iterable<T1>, mapfunc: (_: T1) => T2): Iterable<T2> {
return {
[Symbol.iterator]: function* () {
for (let value of iterable) {
yield mapfunc(value);
}
}
}
}
/**
* Iterator version of "reduce".
*/
export function ireduce<T>(iterable: Iterable<T>, reduce: (item1: T, item2: T) => T, init: T): T {
let result = init;
for (let value of iterable) {
result = reduce(result, value);
}
return result;
}
/**
* Iterator version of "filter".
*/
export function ifilter<T>(iterable: Iterable<T>, filterfunc: (_: T) => boolean): Iterable<T> {
return {
[Symbol.iterator]: function* () {
for (let value of iterable) {
if (filterfunc(value)) {
yield value;
}
}
}
}
}
/**
* Type filter, to return a list of instances of a given type
*/
export function ifiltertype<T>(iterable: Iterable<any>, filter: (item: any) => item is T): Iterable<T> {
return ifilter(iterable, filter);
}
/**
* Class filter, to return a list of instances of a given type
*/
export function ifilterclass<T>(iterable: Iterable<any>, classref: { new(...args: any[]): T }): Iterable<T> {
return ifilter(iterable, (item): item is T => item instanceof classref);
}
/**
* Combine two iterables.
*
* This iterates through the second one several times, so if one iterator may be infinite,
* it should be the first one.
*/
export function icombine<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1, T2]> {
return ichainit(imap(it1, v1 => imap(it2, (v2): [T1, T2] => [v1, v2])));
}
/**
* Advance through two iterables at the same time, yielding item pairs
*
* Iteration will stop at the first of the two iterators that stops.
*/
export function izip<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1, T2]> {
return {
[Symbol.iterator]: function* () {
let iterator1 = it1[Symbol.iterator]();
let iterator2 = it2[Symbol.iterator]();
let state1 = iterator1.next();
let state2 = iterator2.next();
while (!state1.done && !state2.done) {
yield [state1.value, state2.value];
state1 = iterator1.next();
state2 = iterator2.next();
}
}
}
}
/**
* Advance two iterables at the same time, yielding item pairs (greedy version)
*
* Iteration will stop when both iterators are consumed, returning partial couples (undefined in the peer) if needed.
*/
export function izipg<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1 | undefined, T2 | undefined]> {
return {
[Symbol.iterator]: function* () {
let iterator1 = it1[Symbol.iterator]();
let iterator2 = it2[Symbol.iterator]();
let state1 = iterator1.next();
let state2 = iterator2.next();
while (!state1.done || !state2.done) {
yield [state1.value, state2.value];
state1 = iterator1.next();
state2 = iterator2.next();
}
}
}
}
/**
* Partition in two iterables, one with values that pass the predicate, the other with values that don't
*/
export function ipartition<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): [Iterable<T>, Iterable<T>] {
return [ifilter(iterable, predicate), ifilter(iterable, x => !predicate(x))];
}
/**
* Alternate between several iterables (pick one from the first one, then one from the second...)
*/
export function ialternate<T>(iterables: Iterable<T>[]): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let iterators = iterables.map(iterable => iterable[Symbol.iterator]());
let done: boolean;
do {
done = false;
// TODO Remove "dried-out" iterators
for (let iterator of iterators) {
let state = iterator.next();
if (!state.done) {
done = true;
yield state.value;
}
}
} while (done);
}
}
}
/**
* Yield items from an iterator only once.
*
* Beware that even if this function is not materializing, it keeps track of yielded item, and may choke on
* infinite or very long streams. Thus, no more than *limit* items will be yielded (an error is thrown
* when this limit is reached).
*
* This function is O(n²)
*/
export function iunique<T>(iterable: Iterable<T>, limit = 1000000): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let done: T[] = [];
let n = limit;
for (let value of iterable) {
if (!contains(done, value)) {
if (n-- > 0) {
done.push(value);
yield value;
} else {
throw new Error("Unique count limit on iterator");
}
}
}
}
}
}
/**
* Common reduce shortcuts
*/
export const isum = (iterable: Iterable<number>) => ireduce(iterable, (a, b) => a + b, 0);
export const icat = (iterable: Iterable<string>) => ireduce(iterable, (a, b) => a + b, "");
export const imin = (iterable: Iterable<number>) => ireduce(iterable, Math.min, Infinity);
export const imax = (iterable: Iterable<number>) => ireduce(iterable, Math.max, -Infinity);
export const IATEND: Iterator<any> = {
next: function () {
return { done: true, value: undefined };
}
}
/**
* Empty iterable
*/
export const IEMPTY: Iterable<any> = {
[Symbol.iterator]: () => IATEND
}
/**
* Iterable constructor, from an initial value, and a step value
*/
export function irecur<T, S>(start: T, step: (a: T) => T | null): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let val: T | null = start;
do {
yield val;
val = step(val);
} while (val !== null);
}
}
}
/**
* Iterable constructor, from an array
*
* The iterator will yield the next value each time it is called, then undefined when the array's end is reached.
*/
export function iarray<T>(array: T[], offset = 0): Iterable<T> {
return {
[Symbol.iterator]: function () {
return array.slice(offset)[Symbol.iterator]();
}
}
}
/**
* Iterable constructor, from a single value
*
* The value will be yielded only once, not repeated over.
*/
export function isingle<T>(value: T): Iterable<T> {
return iarray([value]);
}
/**
* Iterable that repeats the same value.
*/
export function irepeat<T>(value: T, count = -1): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let n = count;
while (n != 0) {
yield value;
n--;
}
}
}
}
/**
* Equivalent of Array.forEach for all iterables.
*
* If the callback returns *stopper*, the iteration is stopped.
*/
export function iforeach<T>(iterable: Iterable<T>, callback: (_: T) => any, stopper: any = null): void {
for (let value of iterable) {
if (callback(value) === stopper) {
break;
}
}
}
/**
* Returns the first item passing a predicate
*/
export function ifirst<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): T | null {
for (let value of iterable) {
if (predicate(value)) {
return value;
}
}
return null;
}
/**
* Returns the first non-null result of a value-yielding predicate, applied to each iterator element
*/
export function ifirstmap<T1, T2>(iterable: Iterable<T1>, predicate: (item: T1) => T2 | null): T2 | null {
for (let value of iterable) {
let res = predicate(value);
if (res !== null) {
return res;
}
}
return null;
}
/**
* Materialize an array from consuming an iterable
*
* To avoid materializing infinite iterators (and bursting memory), the item count is limited to 1 million, and an
* exception is thrown when this limit is reached.
*/
export function imaterialize<T>(iterable: Iterable<T>, limit = 1000000): T[] {
let result: T[] = [];
for (let value of iterable) {
result.push(value);
if (result.length >= limit) {
throw new Error("Length limit on iterator materialize");
}
}
return result;
}
/**
* Iterate over natural integers
*
* If *count* is not specified, the iterator is infinite
*/
export function irange(count: number = -1, start = 0, step = 1): Iterable<number> {
return {
[Symbol.iterator]: function* () {
let i = start;
let n = count;
while (n != 0) {
yield i;
i += step;
n--;
}
}
}
}
/**
* Iterate over numbers, by applying a step taken from an other iterator
*
* This iterator stops when the "step iterator" stops
*
* With no argument, istep() == irange()
*/
export function istep(start = 0, step_iterable = irepeat(1)): Iterable<number> {
return {
[Symbol.iterator]: function* () {
let i = start;
yield i;
for (let step of step_iterable) {
i += step;
yield i;
}
}
}
}
/**
* Skip a given number of values from an iterator, discarding them.
*/
export function iskip<T>(iterable: Iterable<T>, count = 1): Iterable<T> {
return {
[Symbol.iterator]: function () {
let iterator = iterable[Symbol.iterator]();
let n = count;
while (n-- > 0) {
iterator.next();
}
return iterator;
}
}
}
/**
* Return the value at a given position in the iterator
*/
export function iat<T>(iterable: Iterable<T>, position: number): T | null {
if (position < 0) {
return null;
} else {
if (position > 0) {
iterable = iskip(iterable, position);
}
let iterator = iterable[Symbol.iterator]();
let state = iterator.next();
return state.done ? null : state.value;
}
}
/**
* Chain an iterable of iterables.
*
* This will yield values from the first yielded iterator, then the second one, and so on...
*/
export function ichainit<T>(iterables: Iterable<Iterable<T>>): Iterable<T> {
return {
[Symbol.iterator]: function* () {
for (let iterable of iterables) {
for (let value of iterable) {
yield value;
}
}
}
}
}
/**
* Chain iterables.
*
* This will yield values from the first iterator, then the second one, and so on...
*/
export function ichain<T>(...iterables: Iterable<T>[]): Iterable<T> {
if (iterables.length == 0) {
return IEMPTY;
} else {
return ichainit(iterables);
}
}
/**
* Loop an iterator for a number of times.
*
* If count is negative, if will loop forever (infinite iterator).
*
* onloop may be used to know when the iterator resets.
*/
export function iloop<T>(base: Iterable<T>, count = -1, onloop?: Function): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let n = count;
let start = false;
while (n-- != 0) {
for (let value of base) {
if (start) {
if (onloop) {
onloop();
}
start = false;
}
yield value;
}
start = true;
}
}
}
}
/**
* Iterator version of "map".
*/
export function imap<T1, T2>(iterable: Iterable<T1>, mapfunc: (_: T1) => T2): Iterable<T2> {
return {
[Symbol.iterator]: function* () {
for (let value of iterable) {
yield mapfunc(value);
}
}
}
}
/**
* Iterator version of "reduce".
*/
export function ireduce<T>(iterable: Iterable<T>, reduce: (item1: T, item2: T) => T, init: T): T {
let result = init;
for (let value of iterable) {
result = reduce(result, value);
}
return result;
}
/**
* Iterator version of "filter".
*/
export function ifilter<T>(iterable: Iterable<T>, filterfunc: (_: T) => boolean): Iterable<T> {
return {
[Symbol.iterator]: function* () {
for (let value of iterable) {
if (filterfunc(value)) {
yield value;
}
}
}
}
}
/**
* Type filter, to return a list of instances of a given type
*/
export function ifiltertype<T>(iterable: Iterable<any>, filter: (item: any) => item is T): Iterable<T> {
return ifilter(iterable, filter);
}
/**
* Class filter, to return a list of instances of a given type
*/
export function ifilterclass<T>(iterable: Iterable<any>, classref: { new(...args: any[]): T }): Iterable<T> {
return ifilter(iterable, (item): item is T => item instanceof classref);
}
/**
* Combine two iterables.
*
* This iterates through the second one several times, so if one iterator may be infinite,
* it should be the first one.
*/
export function icombine<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1, T2]> {
return ichainit(imap(it1, v1 => imap(it2, (v2): [T1, T2] => [v1, v2])));
}
/**
* Advance through two iterables at the same time, yielding item pairs
*
* Iteration will stop at the first of the two iterators that stops.
*/
export function izip<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1, T2]> {
return {
[Symbol.iterator]: function* () {
let iterator1 = it1[Symbol.iterator]();
let iterator2 = it2[Symbol.iterator]();
let state1 = iterator1.next();
let state2 = iterator2.next();
while (!state1.done && !state2.done) {
yield [state1.value, state2.value];
state1 = iterator1.next();
state2 = iterator2.next();
}
}
}
}
/**
* Advance two iterables at the same time, yielding item pairs (greedy version)
*
* Iteration will stop when both iterators are consumed, returning partial couples (undefined in the peer) if needed.
*/
export function izipg<T1, T2>(it1: Iterable<T1>, it2: Iterable<T2>): Iterable<[T1 | undefined, T2 | undefined]> {
return {
[Symbol.iterator]: function* () {
let iterator1 = it1[Symbol.iterator]();
let iterator2 = it2[Symbol.iterator]();
let state1 = iterator1.next();
let state2 = iterator2.next();
while (!state1.done || !state2.done) {
yield [state1.value, state2.value];
state1 = iterator1.next();
state2 = iterator2.next();
}
}
}
}
/**
* Partition in two iterables, one with values that pass the predicate, the other with values that don't
*/
export function ipartition<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): [Iterable<T>, Iterable<T>] {
return [ifilter(iterable, predicate), ifilter(iterable, x => !predicate(x))];
}
/**
* Alternate between several iterables (pick one from the first one, then one from the second...)
*/
export function ialternate<T>(iterables: Iterable<T>[]): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let iterators = iterables.map(iterable => iterable[Symbol.iterator]());
let done: boolean;
do {
done = false;
// TODO Remove "dried-out" iterators
for (let iterator of iterators) {
let state = iterator.next();
if (!state.done) {
done = true;
yield state.value;
}
}
} while (done);
}
}
}
/**
* Yield items from an iterator only once.
*
* Beware that even if this function is not materializing, it keeps track of yielded item, and may choke on
* infinite or very long streams. Thus, no more than *limit* items will be yielded (an error is thrown
* when this limit is reached).
*
* This function is O(n²)
*/
export function iunique<T>(iterable: Iterable<T>, limit = 1000000): Iterable<T> {
return {
[Symbol.iterator]: function* () {
let done: T[] = [];
let n = limit;
for (let value of iterable) {
if (!contains(done, value)) {
if (n-- > 0) {
done.push(value);
yield value;
} else {
throw new Error("Unique count limit on iterator");
}
}
}
}
}
}
/**
* Common reduce shortcuts
*/
export const isum = (iterable: Iterable<number>) => ireduce(iterable, (a, b) => a + b, 0);
export const icat = (iterable: Iterable<string>) => ireduce(iterable, (a, b) => a + b, "");
export const imin = (iterable: Iterable<number>) => ireduce(iterable, Math.min, Infinity);
export const imax = (iterable: Iterable<number>) => ireduce(iterable, Math.max, -Infinity);

View file

@ -1,125 +1,123 @@
module TK.Specs {
export class TestRObject extends RObject {
x: number
constructor(x = RandomGenerator.global.random()) {
super();
this.x = x;
}
};
export class TestRObject extends RObject {
x: number
constructor(x = RandomGenerator.global.random()) {
super();
this.x = x;
}
};
testing("RObject", test => {
test.setup(function () {
(<any>RObject)._next_id = 0;
})
testing("RObject", test => {
test.setup(function () {
(<any>RObject)._next_id = 0;
})
test.case("gets a sequential id", check => {
let o1 = new TestRObject();
check.equals(o1.id, 0);
let o2 = new TestRObject();
let o3 = new TestRObject();
check.equals(o2.id, 1);
check.equals(o3.id, 2);
test.case("gets a sequential id", check => {
let o1 = new TestRObject();
check.equals(o1.id, 0);
let o2 = new TestRObject();
let o3 = new TestRObject();
check.equals(o2.id, 1);
check.equals(o3.id, 2);
check.equals(rid(o3), 2);
check.equals(rid(o3.id), 2);
})
check.equals(rid(o3), 2);
check.equals(rid(o3.id), 2);
})
test.case("checks object identity", check => {
let o1 = new TestRObject(1);
let o2 = new TestRObject(1);
let o3 = duplicate(o1, TK.Specs);
test.case("checks object identity", check => {
let o1 = new TestRObject(1);
let o2 = new TestRObject(1);
let o3 = duplicate(o1, TK.Specs);
check.equals(o1.is(o1), true, "o1 is o1");
check.equals(o1.is(o2), false, "o1 is not o2");
check.equals(o1.is(o3), true, "o1 is o3");
check.equals(o1.is(null), false, "o1 is not null");
check.equals(o1.is(o1), true, "o1 is o1");
check.equals(o1.is(o2), false, "o1 is not o2");
check.equals(o1.is(o3), true, "o1 is o3");
check.equals(o1.is(null), false, "o1 is not null");
check.equals(o2.is(o1), false, "o2 is not o1");
check.equals(o2.is(o2), true, "o2 is o2");
check.equals(o2.is(o3), false, "o2 is not o3");
check.equals(o2.is(null), false, "o2 is not null");
check.equals(o2.is(o1), false, "o2 is not o1");
check.equals(o2.is(o2), true, "o2 is o2");
check.equals(o2.is(o3), false, "o2 is not o3");
check.equals(o2.is(null), false, "o2 is not null");
check.equals(o3.is(o1), true, "o3 is o1");
check.equals(o3.is(o2), false, "o3 is not o2");
check.equals(o3.is(o3), true, "o3 is o3");
check.equals(o3.is(null), false, "o3 is not null");
})
check.equals(o3.is(o1), true, "o3 is o1");
check.equals(o3.is(o2), false, "o3 is not o2");
check.equals(o3.is(o3), true, "o3 is o3");
check.equals(o3.is(null), false, "o3 is not null");
})
test.case("resets global id on unserialize", check => {
let o1 = new TestRObject();
check.equals(o1.id, 0);
let o2 = new TestRObject();
check.equals(o2.id, 1);
test.case("resets global id on unserialize", check => {
let o1 = new TestRObject();
check.equals(o1.id, 0);
let o2 = new TestRObject();
check.equals(o2.id, 1);
let serializer = new Serializer(TK.Specs);
let packed = serializer.serialize({ objs: [o1, o2] });
let serializer = new Serializer(TK.Specs);
let packed = serializer.serialize({ objs: [o1, o2] });
(<any>RObject)._next_id = 0;
(<any>RObject)._next_id = 0;
check.equals(new TestRObject().id, 0);
let unpacked = serializer.unserialize(packed);
check.equals(unpacked, { objs: [o1, o2] });
check.equals(new TestRObject().id, 2);
serializer.unserialize(packed);
check.equals(new TestRObject().id, 3);
})
})
check.equals(new TestRObject().id, 0);
let unpacked = serializer.unserialize(packed);
check.equals(unpacked, { objs: [o1, o2] });
check.equals(new TestRObject().id, 2);
serializer.unserialize(packed);
check.equals(new TestRObject().id, 3);
})
})
testing("RObjectContainer", test => {
test.case("stored objects and get them by their id", check => {
let o1 = new TestRObject();
let store = new RObjectContainer([o1]);
testing("RObjectContainer", test => {
test.case("stored objects and get them by their id", check => {
let o1 = new TestRObject();
let store = new RObjectContainer([o1]);
let o2 = new TestRObject();
check.same(store.get(o1.id), o1);
check.equals(store.get(o2.id), null);
let o2 = new TestRObject();
check.same(store.get(o1.id), o1);
check.equals(store.get(o2.id), null);
store.add(o2);
check.same(store.get(o1.id), o1);
check.same(store.get(o2.id), o2);
})
store.add(o2);
check.same(store.get(o1.id), o1);
check.same(store.get(o2.id), o2);
})
test.case("lists contained objects", check => {
let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject());
let o2 = store.add(new TestRObject());
test.case("lists contained objects", check => {
let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject());
let o2 = store.add(new TestRObject());
check.equals(store.count(), 2, "count=2");
check.equals(store.count(), 2, "count=2");
let objects = store.list();
check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2");
let objects = store.list();
check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2");
objects = imaterialize(store.iterator());
check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2");
objects = imaterialize(store.iterator());
check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2");
let ids = store.ids();
check.equals(ids.length, 2, "ids length=2");
check.contains(ids, o1.id, "list contains o1.id");
check.contains(ids, o2.id, "list contains o2.id");
})
let ids = store.ids();
check.equals(ids.length, 2, "ids length=2");
check.contains(ids, o1.id, "list contains o1.id");
check.contains(ids, o2.id, "list contains o2.id");
})
test.case("removes objects", check => {
let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject());
let o2 = store.add(new TestRObject());
test.case("removes objects", check => {
let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject());
let o2 = store.add(new TestRObject());
check.in("initial", check => {
check.equals(store.count(), 2, "count=2");
check.same(store.get(o1.id), o1, "o1 present");
check.same(store.get(o2.id), o2, "o2 present");
});
check.in("initial", check => {
check.equals(store.count(), 2, "count=2");
check.same(store.get(o1.id), o1, "o1 present");
check.same(store.get(o2.id), o2, "o2 present");
});
store.remove(o1);
store.remove(o1);
check.in("removed o1", check => {
check.equals(store.count(), 1, "count=1");
check.same(store.get(o1.id), null, "o1 missing");
check.same(store.get(o2.id), o2, "o2 present");
});
})
})
}
check.in("removed o1", check => {
check.equals(store.count(), 1, "count=1");
check.same(store.get(o1.id), null, "o1 missing");
check.same(store.get(o2.id), o2, "o2 present");
});
})
})

View file

@ -1,100 +1,101 @@
module TK {
export type RObjectId = number
import { iarray } from "./Iterators";
import { values } from "./Tools";
/**
* Returns the id of an object
*/
export function rid(obj: RObject | RObjectId): number {
return (obj instanceof RObject) ? obj.id : obj;
}
export type RObjectId = number
/**
* Referenced objects, with unique ID.
*
* Objects extending this class will have an automatic unique ID, and may be tracked from an RObjectContainer.
*/
export class RObject {
readonly id: RObjectId = RObject._next_id++
private static _next_id = 0
postUnserialize() {
if (this.id >= RObject._next_id) {
RObject._next_id = this.id + 1;
}
}
/**
* Check that two objects are the same (only by comparing their ID)
*/
is(other: RObject | RObjectId | null): boolean {
if (other === null) {
return false;
} else if (other instanceof RObject) {
return this.id === other.id;
} else {
return this.id === other;
}
}
}
/**
* Container to track referenced objects
*/
export class RObjectContainer<T extends RObject> {
private objects: { [index: number]: T } = {}
constructor(objects: T[] = []) {
objects.forEach(obj => this.add(obj));
}
/**
* Add an object to the container
*/
add(object: T): T {
this.objects[object.id] = object;
return object;
}
/**
* Remove an object from the container
*/
remove(object: T): void {
delete this.objects[object.id];
}
/**
* Get an object from the container by its id
*/
get(id: RObjectId): T | null {
return this.objects[id] || null;
}
/**
* Count the number of objects
*/
count(): number {
return this.list().length;
}
/**
* Get all contained ids (list)
*/
ids(): RObjectId[] {
return this.list().map(obj => obj.id);
}
/**
* Get all contained objects (list)
*/
list(): T[] {
return values(this.objects);
}
/**
* Get all contained objects (iterator)
*/
iterator(): Iterable<T> {
return iarray(this.list());
}
}
/**
* Returns the id of an object
*/
export function rid(obj: RObject | RObjectId): number {
return (obj instanceof RObject) ? obj.id : obj;
}
/**
* Referenced objects, with unique ID.
*
* Objects extending this class will have an automatic unique ID, and may be tracked from an RObjectContainer.
*/
export class RObject {
readonly id: RObjectId = RObject._next_id++
private static _next_id = 0
postUnserialize() {
if (this.id >= RObject._next_id) {
RObject._next_id = this.id + 1;
}
}
/**
* Check that two objects are the same (only by comparing their ID)
*/
is(other: RObject | RObjectId | null): boolean {
if (other === null) {
return false;
} else if (other instanceof RObject) {
return this.id === other.id;
} else {
return this.id === other;
}
}
}
/**
* Container to track referenced objects
*/
export class RObjectContainer<T extends RObject> {
private objects: { [index: number]: T } = {}
constructor(objects: T[] = []) {
objects.forEach(obj => this.add(obj));
}
/**
* Add an object to the container
*/
add(object: T): T {
this.objects[object.id] = object;
return object;
}
/**
* Remove an object from the container
*/
remove(object: T): void {
delete this.objects[object.id];
}
/**
* Get an object from the container by its id
*/
get(id: RObjectId): T | null {
return this.objects[id] || null;
}
/**
* Count the number of objects
*/
count(): number {
return this.list().length;
}
/**
* Get all contained ids (list)
*/
ids(): RObjectId[] {
return this.list().map(obj => obj.id);
}
/**
* Get all contained objects (list)
*/
list(): T[] {
return values(this.objects);
}
/**
* Get all contained objects (iterator)
*/
iterator(): Iterable<T> {
return iarray(this.list());
}
}

View file

@ -1,92 +1,90 @@
module TK {
testing("RandomGenerator", test => {
test.case("produces floats", check => {
var gen = new RandomGenerator();
testing("RandomGenerator", test => {
test.case("produces floats", check => {
var gen = new RandomGenerator();
var i = 100;
while (i--) {
var value = gen.random();
check.greaterorequal(value, 0);
check.greater(1, value);
}
});
var i = 100;
while (i--) {
var value = gen.random();
check.greaterorequal(value, 0);
check.greater(1, value);
}
});
test.case("produces integers", check => {
var gen = new RandomGenerator();
test.case("produces integers", check => {
var gen = new RandomGenerator();
var i = 100;
while (i--) {
var value = gen.randInt(5, 12);
check.equals(Math.round(value), value);
check.greater(value, 4);
check.greater(13, value);
}
});
var i = 100;
while (i--) {
var value = gen.randInt(5, 12);
check.equals(Math.round(value), value);
check.greater(value, 4);
check.greater(13, value);
}
});
test.case("chooses from an array", check => {
var gen = new RandomGenerator();
test.case("chooses from an array", check => {
var gen = new RandomGenerator();
check.equals(gen.choice([5]), 5);
check.equals(gen.choice([5]), 5);
var i = 100;
while (i--) {
var value = gen.choice(["test", "thing"]);
check.contains(["thing", "test"], value);
}
});
var i = 100;
while (i--) {
var value = gen.choice(["test", "thing"]);
check.contains(["thing", "test"], value);
}
});
test.case("samples from an array", check => {
var gen = new RandomGenerator();
test.case("samples from an array", check => {
var gen = new RandomGenerator();
var i = 100;
while (i-- > 1) {
var input = [1, 2, 3, 4, 5];
var sample = gen.sample(input, i % 5 + 1);
check.same(sample.length, i % 5 + 1);
sample.forEach((num, idx) => {
check.contains(input, num);
check.notcontains(sample.filter((ival, iidx) => iidx != idx), num);
});
}
});
var i = 100;
while (i-- > 1) {
var input = [1, 2, 3, 4, 5];
var sample = gen.sample(input, i % 5 + 1);
check.same(sample.length, i % 5 + 1);
sample.forEach((num, idx) => {
check.contains(input, num);
check.notcontains(sample.filter((ival, iidx) => iidx != idx), num);
});
}
});
test.case("choose from weighted ranges", check => {
let gen = new RandomGenerator();
test.case("choose from weighted ranges", check => {
let gen = new RandomGenerator();
check.equals(gen.weighted([]), -1);
check.equals(gen.weighted([1]), 0);
check.equals(gen.weighted([0, 1, 0]), 1);
check.equals(gen.weighted([0, 12, 0]), 1);
check.equals(gen.weighted([]), -1);
check.equals(gen.weighted([1]), 0);
check.equals(gen.weighted([0, 1, 0]), 1);
check.equals(gen.weighted([0, 12, 0]), 1);
gen = new SkewedRandomGenerator([0, 0.5, 0.7, 0.8, 0.9999]);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 0);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 1);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 3);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 3);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 4);
});
gen = new SkewedRandomGenerator([0, 0.5, 0.7, 0.8, 0.9999]);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 0);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 1);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 3);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 3);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 4);
});
test.case("generates ids", check => {
let gen = new SkewedRandomGenerator([0, 0.4, 0.2, 0.1, 0.3, 0.8]);
check.equals(gen.id(6, "abcdefghij"), "aecbdi");
});
test.case("generates ids", check => {
let gen = new SkewedRandomGenerator([0, 0.4, 0.2, 0.1, 0.3, 0.8]);
check.equals(gen.id(6, "abcdefghij"), "aecbdi");
});
test.case("can be skewed", check => {
var gen = new SkewedRandomGenerator([0, 0.5, 0.2, 0.9]);
test.case("can be skewed", check => {
var gen = new SkewedRandomGenerator([0, 0.5, 0.2, 0.9]);
check.equals(gen.random(), 0);
check.equals(gen.random(), 0.5);
check.equals(gen.randInt(4, 8), 5);
check.equals(gen.random(), 0.9);
check.equals(gen.random(), 0);
check.equals(gen.random(), 0.5);
check.equals(gen.randInt(4, 8), 5);
check.equals(gen.random(), 0.9);
var value = gen.random();
check.greaterorequal(value, 0);
check.greater(1, value);
var value = gen.random();
check.greaterorequal(value, 0);
check.greater(1, value);
gen = new SkewedRandomGenerator([0.7], true);
check.equals(gen.random(), 0.7);
check.equals(gen.random(), 0.7);
check.equals(gen.random(), 0.7);
});
});
}
gen = new SkewedRandomGenerator([0.7], true);
check.equals(gen.random(), 0.7);
check.equals(gen.random(), 0.7);
check.equals(gen.random(), 0.7);
});
});

View file

@ -1,114 +1,114 @@
module TK {
/*
* Random generator.
*/
export class RandomGenerator {
static global: RandomGenerator = new RandomGenerator();
import { range, sum } from "./Tools";
postUnserialize() {
this.random = Math.random;
}
/*
* Random generator.
*/
export class RandomGenerator {
static global: RandomGenerator = new RandomGenerator();
/**
* Get a random number in the (0.0 included -> 1.0 excluded) range
*/
random = Math.random;
postUnserialize() {
this.random = Math.random;
}
/**
* Get a random number in the (*from* included -> *to* included) range
*/
randInt(from: number, to: number): number {
return Math.floor(this.random() * (to - from + 1)) + from;
}
/**
* Get a random number in the (0.0 included -> 1.0 excluded) range
*/
random = Math.random;
/**
* Choose a random item in an array
*/
choice<T>(input: T[]): T {
return input[this.randInt(0, input.length - 1)];
}
/**
* Get a random number in the (*from* included -> *to* included) range
*/
randInt(from: number, to: number): number {
return Math.floor(this.random() * (to - from + 1)) + from;
}
/**
* Choose a random sample of items from an array
*/
sample<T>(input: T[], count: number): T[] {
var minput = input.slice();
var result: T[] = [];
while (count--) {
var idx = this.randInt(0, minput.length - 1);
result.push(minput[idx]);
minput.splice(idx, 1);
}
return result;
}
/**
* Choose a random item in an array
*/
choice<T>(input: T[]): T {
return input[this.randInt(0, input.length - 1)];
}
/**
* Get a random boolean (coin toss)
*/
bool(): boolean {
return this.randInt(0, 1) == 0;
}
/**
* Choose a random sample of items from an array
*/
sample<T>(input: T[], count: number): T[] {
var minput = input.slice();
var result: T[] = [];
while (count--) {
var idx = this.randInt(0, minput.length - 1);
result.push(minput[idx]);
minput.splice(idx, 1);
}
return result;
}
/**
* Get the range in which the number falls, ranges being weighted
*/
weighted(weights: number[]): number {
if (weights.length == 0) {
return -1;
}
/**
* Get a random boolean (coin toss)
*/
bool(): boolean {
return this.randInt(0, 1) == 0;
}
let total = sum(weights);
if (total == 0) {
return 0;
} else {
let cumul = 0;
weights = weights.map(weight => {
cumul += weight / total;
return cumul;
});
let r = this.random();
for (let i = 0; i < weights.length; i++) {
if (r < weights[i]) {
return i;
}
}
return weights.length - 1;
}
}
/**
* Generate a random id string, composed of ascii characters
*/
id(length: number, chars?: string): string {
if (!chars) {
chars = range(94).map(i => String.fromCharCode(i + 33)).join("");
}
return range(length).map(() => this.choice(<any>chars)).join("");
}
/**
* Get the range in which the number falls, ranges being weighted
*/
weighted(weights: number[]): number {
if (weights.length == 0) {
return -1;
}
/*
* Random generator that produces a series of fixed numbers before going back to random ones.
*/
export class SkewedRandomGenerator extends RandomGenerator {
i = 0;
suite: number[];
loop: boolean;
constructor(suite: number[], loop = false) {
super();
this.suite = suite;
this.loop = loop;
}
random = () => {
var result = this.suite[this.i];
this.i += 1;
if (this.loop && this.i == this.suite.length) {
this.i = 0;
}
return (typeof result == "undefined") ? Math.random() : result;
let total = sum(weights);
if (total == 0) {
return 0;
} else {
let cumul = 0;
weights = weights.map(weight => {
cumul += weight / total;
return cumul;
});
let r = this.random();
for (let i = 0; i < weights.length; i++) {
if (r < weights[i]) {
return i;
}
}
return weights.length - 1;
}
}
}
/**
* Generate a random id string, composed of ascii characters
*/
id(length: number, chars?: string): string {
if (!chars) {
chars = range(94).map(i => String.fromCharCode(i + 33)).join("");
}
return range(length).map(() => this.choice(<any>chars)).join("");
}
}
/*
* Random generator that produces a series of fixed numbers before going back to random ones.
*/
export class SkewedRandomGenerator extends RandomGenerator {
i = 0;
suite: number[];
loop: boolean;
constructor(suite: number[], loop = false) {
super();
this.suite = suite;
this.loop = loop;
}
random = () => {
var result = this.suite[this.i];
this.i += 1;
if (this.loop && this.i == this.suite.length) {
this.i = 0;
}
return (typeof result == "undefined") ? Math.random() : result;
}
}

View file

@ -1,104 +1,102 @@
module TK.Specs {
export class TestSerializerObj1 {
a: number;
constructor(a = 0) {
this.a = a;
}
}
export class TestSerializerObj2 {
a = () => 1
b = [(obj: any) => 2]
}
export class TestSerializerObj3 {
a = [1, 2];
postUnserialize() {
remove(this.a, 2);
}
}
testing("Serializer", test => {
function checkReversability(obj: any, namespace = TK.Specs): any {
var serializer = new Serializer(TK.Specs);
var data = serializer.serialize(obj);
serializer = new Serializer(TK.Specs);
var loaded = serializer.unserialize(data);
test.check.equals(loaded, obj);
return loaded;
}
test.case("serializes simple objects", check => {
var obj = {
"a": 5,
"b": null,
"c": [{ "a": 2 }, "test"]
};
checkReversability(obj);
});
test.case("restores objects constructed from class", check => {
var loaded = checkReversability(new TestSerializerObj1(5));
check.equals(loaded.a, 5);
check.same(loaded instanceof TestSerializerObj1, true, "not a TestSerializerObj1 instance");
});
test.case("stores one version of the same object", check => {
var a = new TestSerializerObj1(8);
var b = new TestSerializerObj1(8);
var c = {
'r': a,
's': ["test", a],
't': a,
'u': b
};
var loaded = checkReversability(c);
check.same(loaded.t, loaded.r);
check.same(loaded.s[1], loaded.r);
check.notsame(loaded.u, loaded.r);
});
test.case("handles circular references", check => {
var a: any = { b: {} };
a.b.c = a;
var loaded = checkReversability(a);
});
test.case("ignores some classes", check => {
var serializer = new Serializer(TK.Specs);
serializer.addIgnoredClass("TestSerializerObj1");
var data = serializer.serialize({ a: 5, b: new TestSerializerObj1() });
var loaded = serializer.unserialize(data);
check.equals(loaded, { a: 5, b: undefined });
});
test.case("ignores functions", check => {
let serializer = new Serializer(TK.Specs);
let data = serializer.serialize({ obj: new TestSerializerObj2() });
let loaded = serializer.unserialize(data);
let expected = <any>new TestSerializerObj2();
expected.a = undefined;
expected.b[0] = undefined;
check.equals(loaded, { obj: expected });
});
test.case("calls specific postUnserialize", check => {
let serializer = new Serializer(TK.Specs);
let data = serializer.serialize({ obj: new TestSerializerObj3() });
let loaded = serializer.unserialize(data);
let expected = new TestSerializerObj3();
expected.a = [1];
check.equals(loaded, { obj: expected });
});
test.case("finds TS namespace, even from sub-namespace", check => {
checkReversability(new Timer());
checkReversability(new RandomGenerator());
});
});
export class TestSerializerObj1 {
a: number;
constructor(a = 0) {
this.a = a;
}
}
export class TestSerializerObj2 {
a = () => 1
b = [(obj: any) => 2]
}
export class TestSerializerObj3 {
a = [1, 2];
postUnserialize() {
remove(this.a, 2);
}
}
testing("Serializer", test => {
function checkReversability(obj: any, namespace = TK.Specs): any {
var serializer = new Serializer(TK.Specs);
var data = serializer.serialize(obj);
serializer = new Serializer(TK.Specs);
var loaded = serializer.unserialize(data);
test.check.equals(loaded, obj);
return loaded;
}
test.case("serializes simple objects", check => {
var obj = {
"a": 5,
"b": null,
"c": [{ "a": 2 }, "test"]
};
checkReversability(obj);
});
test.case("restores objects constructed from class", check => {
var loaded = checkReversability(new TestSerializerObj1(5));
check.equals(loaded.a, 5);
check.same(loaded instanceof TestSerializerObj1, true, "not a TestSerializerObj1 instance");
});
test.case("stores one version of the same object", check => {
var a = new TestSerializerObj1(8);
var b = new TestSerializerObj1(8);
var c = {
'r': a,
's': ["test", a],
't': a,
'u': b
};
var loaded = checkReversability(c);
check.same(loaded.t, loaded.r);
check.same(loaded.s[1], loaded.r);
check.notsame(loaded.u, loaded.r);
});
test.case("handles circular references", check => {
var a: any = { b: {} };
a.b.c = a;
var loaded = checkReversability(a);
});
test.case("ignores some classes", check => {
var serializer = new Serializer(TK.Specs);
serializer.addIgnoredClass("TestSerializerObj1");
var data = serializer.serialize({ a: 5, b: new TestSerializerObj1() });
var loaded = serializer.unserialize(data);
check.equals(loaded, { a: 5, b: undefined });
});
test.case("ignores functions", check => {
let serializer = new Serializer(TK.Specs);
let data = serializer.serialize({ obj: new TestSerializerObj2() });
let loaded = serializer.unserialize(data);
let expected = <any>new TestSerializerObj2();
expected.a = undefined;
expected.b[0] = undefined;
check.equals(loaded, { obj: expected });
});
test.case("calls specific postUnserialize", check => {
let serializer = new Serializer(TK.Specs);
let data = serializer.serialize({ obj: new TestSerializerObj3() });
let loaded = serializer.unserialize(data);
let expected = new TestSerializerObj3();
expected.a = [1];
check.equals(loaded, { obj: expected });
});
test.case("finds TS namespace, even from sub-namespace", check => {
checkReversability(new Timer());
checkReversability(new RandomGenerator());
});
});

View file

@ -1,111 +1,105 @@
module TK {
import { add, classname, crawl, merge, STOP_CRAWLING } from "./Tools";
function isObject(value: any): boolean {
return value instanceof Object && !Array.isArray(value);
}
/**
* A typescript object serializer.
*/
export class Serializer {
namespace: any;
ignored: string[] = [];
constructor(namespace: any = TK) {
this.namespace = namespace;
}
/**
* Add a class to the ignore list
*/
addIgnoredClass(name: string) {
this.ignored.push(name);
}
/**
* Construct an object from a constructor name
*/
constructObject(ctype: string): Object {
if (ctype == "Object") {
return {};
} else {
let cl = this.namespace[ctype];
if (cl) {
return Object.create(cl.prototype);
} else {
cl = (<any>TK)[ctype];
if (cl) {
return Object.create(cl.prototype);
} else {
console.error("Can't find class", ctype);
return {};
}
}
}
}
/**
* Serialize an object to a string
*/
serialize(obj: any): string {
// Collect objects
var objects: Object[] = [];
var stats: any = {};
crawl(obj, value => {
if (isObject(value)) {
var vtype = classname(value);
if (vtype != "" && this.ignored.indexOf(vtype) < 0) {
stats[vtype] = (stats[vtype] || 0) + 1;
add(objects, value);
return value;
} else {
return TK.STOP_CRAWLING;
}
} else {
return value;
}
});
//console.log("Serialize stats", stats);
// Serialize objects list, transforming deeper objects to links
var fobjects = objects.map(value => <Object>{ $c: classname(value), $f: merge({}, value) });
return JSON.stringify(fobjects, (key, value) => {
if (key != "$f" && isObject(value) && !value.hasOwnProperty("$c") && !value.hasOwnProperty("$i")) {
return { $i: objects.indexOf(value) };
} else {
return value;
}
});
}
/**
* Unserialize a string to an object
*/
unserialize(data: string): any {
// Unserialize objects list
var fobjects = JSON.parse(data);
// Reconstruct objects
var objects = fobjects.map((objdata: any) => merge(this.constructObject(objdata['$c']), objdata['$f']));
// Reconnect links
crawl(objects, value => {
if (value instanceof Object && value.hasOwnProperty('$i')) {
return objects[value['$i']];
} else {
return value;
}
}, true);
// Post unserialize hooks
crawl(objects[0], value => {
if (value instanceof Object && typeof value.postUnserialize == "function") {
value.postUnserialize();
}
});
// First object was the root
return objects[0];
}
}
function isObject(value: any): boolean {
return value instanceof Object && !Array.isArray(value);
}
/**
* A typescript object serializer.
*/
export class Serializer {
namespace: { [name: string]: (...args: any) => any };
ignored: string[] = [];
constructor(namespace: { [name: string]: (...args: any) => any } = {}) {
this.namespace = namespace;
}
/**
* Add a class to the ignore list
*/
addIgnoredClass(name: string) {
this.ignored.push(name);
}
/**
* Construct an object from a constructor name
*/
constructObject(ctype: string): Object {
if (ctype == "Object") {
return {};
} else {
let cl = this.namespace[ctype];
if (cl) {
return Object.create(cl.prototype);
} else {
console.error("Can't find class", ctype);
return {};
}
}
}
/**
* Serialize an object to a string
*/
serialize(obj: any): string {
// Collect objects
var objects: Object[] = [];
var stats: any = {};
crawl(obj, value => {
if (isObject(value)) {
var vtype = classname(value);
if (vtype != "" && this.ignored.indexOf(vtype) < 0) {
stats[vtype] = (stats[vtype] || 0) + 1;
add(objects, value);
return value;
} else {
return STOP_CRAWLING;
}
} else {
return value;
}
});
//console.log("Serialize stats", stats);
// Serialize objects list, transforming deeper objects to links
var fobjects = objects.map(value => <Object>{ $c: classname(value), $f: merge({}, value) });
return JSON.stringify(fobjects, (key, value) => {
if (key != "$f" && isObject(value) && !value.hasOwnProperty("$c") && !value.hasOwnProperty("$i")) {
return { $i: objects.indexOf(value) };
} else {
return value;
}
});
}
/**
* Unserialize a string to an object
*/
unserialize(data: string): any {
// Unserialize objects list
var fobjects = JSON.parse(data);
// Reconstruct objects
var objects = fobjects.map((objdata: any) => merge(this.constructObject(objdata['$c']), objdata['$f']));
// Reconnect links
crawl(objects, value => {
if (value instanceof Object && value.hasOwnProperty('$i')) {
return objects[value['$i']];
} else {
return value;
}
}, true);
// Post unserialize hooks
crawl(objects[0], value => {
if (value instanceof Object && typeof value.postUnserialize == "function") {
value.postUnserialize();
}
});
// First object was the root
return objects[0];
}
}

View file

@ -1,330 +1,327 @@
import { Timer } from "./Timer";
export type FakeClock = { forward: (milliseconds: number) => void }
export type Mock<F extends Function> = { func: F, getCalls: () => any[][], reset: () => void }
/**
* Various testing functions.
* Main test suite descriptor
*/
module TK {
export type FakeClock = { forward: (milliseconds: number) => void }
export type Mock<F extends Function> = { func: F, getCalls: () => any[][], reset: () => void }
export function testing(desc: string, body: (test: TestSuite) => void) {
if (typeof describe != "undefined") {
describe(desc, () => {
beforeEach(() => jasmine.addMatchers(CUSTOM_MATCHERS));
/**
* Main test suite descriptor
*/
export function testing(desc: string, body: (test: TestSuite) => void) {
if (typeof describe != "undefined") {
describe(desc, () => {
beforeEach(() => jasmine.addMatchers(CUSTOM_MATCHERS));
let test = new TestSuite(desc);
body(test);
});
}
}
let test = new TestSuite(desc);
body(test);
});
}
/**
* Test suite (group of test cases)
*/
export class TestSuite {
private desc: string
constructor(desc: string) {
this.desc = desc;
}
/**
* Add a setup step for each case of the suite
*/
setup(body: Function, cleanup?: Function): void {
beforeEach(() => body());
if (cleanup) {
afterEach(() => cleanup());
}
}
/**
* Add an asynchronous setup step for each case of the suite
*/
asetup(body: () => Promise<void>, cleanup?: () => Promise<void>): void {
beforeEach(async () => await body());
if (cleanup) {
afterEach(async () => await cleanup());
}
}
/**
* Describe a single test case
*/
case(desc: string, body: (ctx: TestContext) => void): void {
it(desc, () => {
console.debug(`${this.desc} ${desc}`);
body(new TestContext())
});
}
/**
* Describe an asynchronous test case
*/
acase<T>(desc: string, body: (ctx: TestContext) => Promise<T>): void {
it(desc, done => {
console.debug(`${this.desc} ${desc}`);
body(new TestContext()).then(done).catch(done.fail);
});
}
/**
* Setup fake clock for the suite
*/
clock(): FakeClock {
let current = 0;
beforeEach(function () {
current = 0;
jasmine.clock().install();
spyOn(Timer, "nowMs").and.callFake(() => current);
});
afterEach(function () {
jasmine.clock().uninstall();
});
return {
forward: milliseconds => {
current += milliseconds;
jasmine.clock().tick(milliseconds);
}
};
}
/**
* Out-of-context assertion helpers
*
* It is better to use in-context checks, for better information
*/
get check(): TestContext {
return new TestContext();
}
}
/**
* A test context, with assertion helpers
*/
export class TestContext {
info: string[];
constructor(info: string[] = []) {
this.info = info;
}
/**
* Create a sub context (adds information for all assertions done with this context)
*/
sub(info: string): TestContext {
return new TestContext(this.info.concat([info]));
}
/**
* Execute a body in a sub context
*/
in(info: string, body: (ctx: TestContext) => void): void {
body(this.sub(info));
}
/**
* Builds a message, with context information added
*/
message(message?: string): string | undefined {
let parts = this.info;
if (message) {
parts = parts.concat([message]);
}
return parts.length ? parts.join(" - ") : undefined;
}
/**
* Patch an object's method with a mock
*
* Replacement may be:
* - undefined to call through
* - null to not call anything
* - a fake function to call instead
*
* All patches are removed at the end of a case
*/
patch<T extends Object, K extends keyof T, F extends T[K] & Function>(obj: T, method: K, replacement?: F | null): Mock<F> {
let spy = spyOn(obj, <any>method);
if (replacement === null) {
spy.and.stub();
} else if (replacement) {
spy.and.callFake(<any>replacement);
} else {
spy.and.callThrough();
}
/**
* Test suite (group of test cases)
*/
export class TestSuite {
private desc: string
constructor(desc: string) {
this.desc = desc;
}
return {
func: <any>spy,
getCalls: () => spy.calls.all().map(info => info.args),
reset: () => spy.calls.reset()
}
}
/**
* Add a setup step for each case of the suite
*/
setup(body: Function, cleanup?: Function): void {
beforeEach(() => body());
if (cleanup) {
afterEach(() => cleanup());
}
}
/**
* Create a mock function
*/
mockfunc<F extends Function>(name = "mock", replacement?: F): Mock<F> {
let spy = jasmine.createSpy(name, <any>replacement);
/**
* Add an asynchronous setup step for each case of the suite
*/
asetup(body: () => Promise<void>, cleanup?: () => Promise<void>): void {
beforeEach(async () => await body());
if (cleanup) {
afterEach(async () => await cleanup());
}
}
/**
* Describe a single test case
*/
case(desc: string, body: (ctx: TestContext) => void): void {
it(desc, () => {
console.debug(`${this.desc} ${desc}`);
body(new TestContext())
});
}
/**
* Describe an asynchronous test case
*/
acase<T>(desc: string, body: (ctx: TestContext) => Promise<T>): void {
it(desc, done => {
console.debug(`${this.desc} ${desc}`);
body(new TestContext()).then(done).catch(done.fail);
});
}
/**
* Setup fake clock for the suite
*/
clock(): FakeClock {
let current = 0;
beforeEach(function () {
current = 0;
jasmine.clock().install();
spyOn(Timer, "nowMs").and.callFake(() => current);
});
afterEach(function () {
jasmine.clock().uninstall();
});
return {
forward: milliseconds => {
current += milliseconds;
jasmine.clock().tick(milliseconds);
}
};
}
/**
* Out-of-context assertion helpers
*
* It is better to use in-context checks, for better information
*/
get check(): TestContext {
return new TestContext();
}
if (replacement) {
spy = spy.and.callThrough();
}
/**
* A test context, with assertion helpers
*/
export class TestContext {
info: string[];
return {
func: <any>spy,
getCalls: () => spy.calls.all().map(info => info.args),
reset: () => spy.calls.reset()
}
}
constructor(info: string[] = []) {
this.info = info;
}
/**
* Create a sub context (adds information for all assertions done with this context)
*/
sub(info: string): TestContext {
return new TestContext(this.info.concat([info]));
}
/**
* Execute a body in a sub context
*/
in(info: string, body: (ctx: TestContext) => void): void {
body(this.sub(info));
}
/**
* Builds a message, with context information added
*/
message(message?: string): string | undefined {
let parts = this.info;
if (message) {
parts = parts.concat([message]);
}
return parts.length ? parts.join(" - ") : undefined;
}
/**
* Patch an object's method with a mock
*
* Replacement may be:
* - undefined to call through
* - null to not call anything
* - a fake function to call instead
*
* All patches are removed at the end of a case
*/
patch<T extends Object, K extends keyof T, F extends T[K] & Function>(obj: T, method: K, replacement?: F | null): Mock<F> {
let spy = spyOn(obj, <any>method);
if (replacement === null) {
spy.and.stub();
} else if (replacement) {
spy.and.callFake(<any>replacement);
} else {
spy.and.callThrough();
}
return {
func: <any>spy,
getCalls: () => spy.calls.all().map(info => info.args),
reset: () => spy.calls.reset()
}
}
/**
* Create a mock function
*/
mockfunc<F extends Function>(name = "mock", replacement?: F): Mock<F> {
let spy = jasmine.createSpy(name, <any>replacement);
if (replacement) {
spy = spy.and.callThrough();
}
return {
func: <any>spy,
getCalls: () => spy.calls.all().map(info => info.args),
reset: () => spy.calls.reset()
}
}
/**
* Check that a mock have been called a given number of times, or with specific args
*/
called(mock: Mock<any>, calls: number | any[][], reset = true): void {
if (typeof calls == "number") {
expect(mock.getCalls().length).toEqual(calls, this.message());
} else {
expect(mock.getCalls()).toEqual(calls, this.message());
}
if (reset) {
mock.reset();
}
}
/**
* Check that a function call throws an error
*/
throw(call: Function, error?: string | Error): void {
if (typeof error == "undefined") {
expect(call).toThrow();
} else if (typeof error == "string") {
expect(call).toThrowError(error);
} else {
expect(call).toThrow(error);
}
}
/**
* Check that an object is an instance of a given type
*/
instance<T>(obj: any, classref: { new(...args: any[]): T }, message: string): obj is T {
let result = obj instanceof classref;
expect(result).toBe(true, this.message(message));
return result;
}
/**
* Check that two references are the same object
*/
same<T extends Object>(ref1: T | null | undefined, ref2: T | null | undefined, message?: string): void {
expect(ref1).toBe(ref2, this.message(message));
}
/**
* Check that two references are not the same object
*/
notsame<T extends Object>(ref1: T | null, ref2: T | null, message?: string): void {
expect(ref1).not.toBe(ref2, this.message(message));
}
/**
* Check that two values are equal, in the sense of deep comparison
*/
equals<T>(val1: T, val2: T, message?: string): void {
expect(val1).toEqual(val2, this.message(message));
}
/**
* Check that two values differs, in the sense of deep comparison
*/
notequals<T>(val1: T, val2: T, message?: string): void {
expect(val1).not.toEqual(val2, this.message(message));
}
/**
* Check that a numerical value is close to another, at a given number of digits precision
*/
nears(val1: number, val2: number, precision = 8, message?: string): void {
if (precision != Math.round(precision)) {
throw new Error(`'nears' precision should be integer, not {precision}`);
}
expect(val1).toBeCloseTo(val2, precision, this.message(message));
}
/**
* Check that a numerical value is greater than another
*/
greater(val1: number, val2: number, message?: string): void {
expect(val1).toBeGreaterThan(val2, this.message(message));
}
/**
* Check that a numerical value is greater than or equal to another
*/
greaterorequal(val1: number, val2: number, message?: string): void {
expect(val1).toBeGreaterThanOrEqual(val2, this.message(message));
}
/**
* Check that a string matches a regex
*/
regex(pattern: RegExp, value: string, message?: string): void {
expect(value).toMatch(pattern, this.message(message));
}
/**
* Check that an array contains an item
*/
contains<T>(array: T[], item: T, message?: string): void {
expect(array).toContain(item, this.message(message));
}
/**
* Check that an array does not contain an item
*/
notcontains<T>(array: T[], item: T, message?: string): void {
expect(array).not.toContain(item, this.message(message));
}
/**
* Check than an object contains a set of properties
*/
containing<T>(val: T, props: Partial<T>, message?: string): void {
expect(val).toEqual(jasmine.objectContaining(props), this.message(message));
}
/**
* Fail the whole case
*/
fail(message?: string): void {
fail(this.message(message));
}
/**
* Check that a mock have been called a given number of times, or with specific args
*/
called(mock: Mock<any>, calls: number | any[][], reset = true): void {
if (typeof calls == "number") {
expect(mock.getCalls().length).toEqual(calls, this.message());
} else {
expect(mock.getCalls()).toEqual(calls, this.message());
}
const CUSTOM_MATCHERS = {
toEqual: function (util: any, customEqualityTesters: any) {
customEqualityTesters = customEqualityTesters || [];
return {
compare: function (actual: any, expected: any, message?: string) {
let result: any = { pass: false };
let diffBuilder = (<any>jasmine).DiffBuilder();
result.pass = util.equals(actual, expected, customEqualityTesters, diffBuilder);
result.message = diffBuilder.getMessage();
if (message) {
result.message += " " + message;
}
return result;
}
};
}
if (reset) {
mock.reset();
}
}
}
/**
* Check that a function call throws an error
*/
throw(call: Function, error?: string | Error): void {
if (typeof error == "undefined") {
expect(call).toThrow();
} else if (typeof error == "string") {
expect(call).toThrowError(error);
} else {
expect(call).toThrow(error);
}
}
/**
* Check that an object is an instance of a given type
*/
instance<T>(obj: any, classref: { new(...args: any[]): T }, message: string): obj is T {
let result = obj instanceof classref;
expect(result).toBe(true, this.message(message));
return result;
}
/**
* Check that two references are the same object
*/
same<T extends Object>(ref1: T | null | undefined, ref2: T | null | undefined, message?: string): void {
expect(ref1).toBe(ref2, this.message(message));
}
/**
* Check that two references are not the same object
*/
notsame<T extends Object>(ref1: T | null, ref2: T | null, message?: string): void {
expect(ref1).not.toBe(ref2, this.message(message));
}
/**
* Check that two values are equal, in the sense of deep comparison
*/
equals<T>(val1: T, val2: T, message?: string): void {
expect(val1).toEqual(val2, this.message(message));
}
/**
* Check that two values differs, in the sense of deep comparison
*/
notequals<T>(val1: T, val2: T, message?: string): void {
expect(val1).not.toEqual(val2, this.message(message));
}
/**
* Check that a numerical value is close to another, at a given number of digits precision
*/
nears(val1: number, val2: number, precision = 8, message?: string): void {
if (precision != Math.round(precision)) {
throw new Error(`'nears' precision should be integer, not {precision}`);
}
expect(val1).toBeCloseTo(val2, precision, this.message(message));
}
/**
* Check that a numerical value is greater than another
*/
greater(val1: number, val2: number, message?: string): void {
expect(val1).toBeGreaterThan(val2, this.message(message));
}
/**
* Check that a numerical value is greater than or equal to another
*/
greaterorequal(val1: number, val2: number, message?: string): void {
expect(val1).toBeGreaterThanOrEqual(val2, this.message(message));
}
/**
* Check that a string matches a regex
*/
regex(pattern: RegExp, value: string, message?: string): void {
expect(value).toMatch(pattern, this.message(message));
}
/**
* Check that an array contains an item
*/
contains<T>(array: T[], item: T, message?: string): void {
expect(array).toContain(item, this.message(message));
}
/**
* Check that an array does not contain an item
*/
notcontains<T>(array: T[], item: T, message?: string): void {
expect(array).not.toContain(item, this.message(message));
}
/**
* Check than an object contains a set of properties
*/
containing<T>(val: T, props: Partial<T>, message?: string): void {
expect(val).toEqual(jasmine.objectContaining(props), this.message(message));
}
/**
* Fail the whole case
*/
fail(message?: string): void {
fail(this.message(message));
}
}
const CUSTOM_MATCHERS = {
toEqual: function (util: any, customEqualityTesters: any) {
customEqualityTesters = customEqualityTesters || [];
return {
compare: function (actual: any, expected: any, message?: string) {
let result: any = { pass: false };
let diffBuilder = (<any>jasmine).DiffBuilder();
result.pass = util.equals(actual, expected, customEqualityTesters, diffBuilder);
result.message = diffBuilder.getMessage();
if (message) {
result.message += " " + message;
}
return result;
}
};
}
}

View file

@ -1,117 +1,115 @@
module TK.Specs {
testing("Timer", test => {
let clock = test.clock();
testing("Timer", test => {
let clock = test.clock();
test.case("schedules and cancels future calls", check => {
let timer = new Timer();
test.case("schedules and cancels future calls", check => {
let timer = new Timer();
let called: any[] = [];
let callback = (item: any) => called.push(item);
let called: any[] = [];
let callback = (item: any) => called.push(item);
let s1 = timer.schedule(50, () => callback(1));
let s2 = timer.schedule(150, () => callback(2));
let s3 = timer.schedule(250, () => callback(3));
let s1 = timer.schedule(50, () => callback(1));
let s2 = timer.schedule(150, () => callback(2));
let s3 = timer.schedule(250, () => callback(3));
check.equals(called, []);
clock.forward(100);
check.equals(called, [1]);
timer.cancel(s1);
check.equals(called, [1]);
clock.forward(100);
check.equals(called, [1, 2]);
timer.cancel(s3);
clock.forward(100);
check.equals(called, [1, 2]);
clock.forward(1000);
check.equals(called, [1, 2]);
});
check.equals(called, []);
clock.forward(100);
check.equals(called, [1]);
timer.cancel(s1);
check.equals(called, [1]);
clock.forward(100);
check.equals(called, [1, 2]);
timer.cancel(s3);
clock.forward(100);
check.equals(called, [1, 2]);
clock.forward(1000);
check.equals(called, [1, 2]);
});
test.case("may cancel all scheduled", check => {
let timer = new Timer();
test.case("may cancel all scheduled", check => {
let timer = new Timer();
let called: any[] = [];
let callback = (item: any) => called.push(item);
let called: any[] = [];
let callback = (item: any) => called.push(item);
timer.schedule(150, () => callback(1));
timer.schedule(50, () => callback(2));
timer.schedule(500, () => callback(3));
timer.schedule(150, () => callback(1));
timer.schedule(50, () => callback(2));
timer.schedule(500, () => callback(3));
check.equals(called, []);
check.equals(called, []);
clock.forward(100);
clock.forward(100);
check.equals(called, [2]);
check.equals(called, [2]);
clock.forward(100);
clock.forward(100);
check.equals(called, [2, 1]);
check.equals(called, [2, 1]);
timer.cancelAll();
timer.cancelAll();
clock.forward(1000);
clock.forward(1000);
check.equals(called, [2, 1]);
check.equals(called, [2, 1]);
timer.schedule(50, () => callback(4));
timer.schedule(150, () => callback(5));
timer.schedule(50, () => callback(4));
timer.schedule(150, () => callback(5));
clock.forward(100);
clock.forward(100);
check.equals(called, [2, 1, 4]);
check.equals(called, [2, 1, 4]);
timer.cancelAll(true);
timer.cancelAll(true);
clock.forward(100);
clock.forward(100);
check.equals(called, [2, 1, 4]);
check.equals(called, [2, 1, 4]);
timer.schedule(50, () => callback(6));
timer.schedule(50, () => callback(6));
clock.forward(100);
clock.forward(100);
check.equals(called, [2, 1, 4]);
});
check.equals(called, [2, 1, 4]);
});
test.case("may switch to synchronous mode", check => {
let timer = new Timer(true);
let called: any[] = [];
let callback = (item: any) => called.push(item);
test.case("may switch to synchronous mode", check => {
let timer = new Timer(true);
let called: any[] = [];
let callback = (item: any) => called.push(item);
timer.schedule(50, () => callback(1));
check.equals(called, [1]);
});
timer.schedule(50, () => callback(1));
check.equals(called, [1]);
});
test.acase("sleeps asynchronously", async check => {
let timer = new Timer();
let x = 1;
test.acase("sleeps asynchronously", async check => {
let timer = new Timer();
let x = 1;
let promise = timer.sleep(500).then(() => {
x++;
});
check.equals(x, 1);
clock.forward(300);
check.equals(x, 1);
clock.forward(300);
check.equals(x, 1);
await promise;
check.equals(x, 2);
});
test.case("gives current time in milliseconds", check => {
check.equals(Timer.nowMs(), 0);
clock.forward(5);
check.equals(Timer.nowMs(), 5);
let t = Timer.nowMs();
clock.forward(10);
check.equals(Timer.nowMs(), 15);
check.equals(Timer.fromMs(t), 10);
});
let promise = timer.sleep(500).then(() => {
x++;
});
}
check.equals(x, 1);
clock.forward(300);
check.equals(x, 1);
clock.forward(300);
check.equals(x, 1);
await promise;
check.equals(x, 2);
});
test.case("gives current time in milliseconds", check => {
check.equals(Timer.nowMs(), 0);
clock.forward(5);
check.equals(Timer.nowMs(), 5);
let t = Timer.nowMs();
clock.forward(10);
check.equals(Timer.nowMs(), 15);
check.equals(Timer.fromMs(t), 10);
});
});

View file

@ -1,100 +1,100 @@
module TK {
/**
* Timing utility.
*
* This extends the standard setTimeout feature.
*/
export class Timer {
// Global timer shared by the whole project
static global = new Timer();
import { add, remove } from "./Tools";
// Global synchronous timer for unit tests
static synchronous = new Timer(true);
/**
* Timing utility.
*
* This extends the standard setTimeout feature.
*/
export class Timer {
// Global timer shared by the whole project
static global = new Timer();
private sync = false;
// Global synchronous timer for unit tests
static synchronous = new Timer(true);
private locked = false;
private sync = false;
private scheduled: any[] = [];
private locked = false;
constructor(sync = false) {
this.sync = sync;
}
private scheduled: any[] = [];
/**
* Get the current timestamp in milliseconds
*/
static nowMs(): number {
return (new Date()).getTime();
}
constructor(sync = false) {
this.sync = sync;
}
/**
* Get the elapsed time in milliseconds since a previous timestamp
*/
static fromMs(previous: number): number {
return this.nowMs() - previous;
}
/**
* Get the current timestamp in milliseconds
*/
static nowMs(): number {
return (new Date()).getTime();
}
/**
* Return true if the timer is synchronous
*/
isSynchronous() {
return this.sync;
}
/**
* Get the elapsed time in milliseconds since a previous timestamp
*/
static fromMs(previous: number): number {
return this.nowMs() - previous;
}
/**
* Cancel all scheduled calls.
*
* If lock=true, no further scheduling will be allowed.
*/
cancelAll(lock = false) {
this.locked = lock;
/**
* Return true if the timer is synchronous
*/
isSynchronous() {
return this.sync;
}
let scheduled = this.scheduled;
this.scheduled = [];
/**
* Cancel all scheduled calls.
*
* If lock=true, no further scheduling will be allowed.
*/
cancelAll(lock = false) {
this.locked = lock;
scheduled.forEach(handle => clearTimeout(handle));
}
let scheduled = this.scheduled;
this.scheduled = [];
/**
* Cancel a scheduled callback.
*/
cancel(scheduled: any) {
if (remove(this.scheduled, scheduled)) {
clearTimeout(scheduled);
}
}
scheduled.forEach(handle => clearTimeout(handle));
}
/**
* Schedule a callback to be called later (time is in milliseconds).
*/
schedule(delay: number, callback: Function): any {
if (this.locked) {
return null;
} else if (this.sync || delay <= 0) {
callback();
return null;
} else {
let handle = setTimeout(() => {
remove(this.scheduled, handle);
callback();
}, delay);
add(this.scheduled, handle);
return handle;
}
}
/**
* Asynchronously sleep a given time.
*/
async sleep(ms: number): Promise<any> {
return new Promise(resolve => {
this.schedule(ms, resolve);
});
}
postUnserialize() {
this.scheduled = [];
}
/**
* Cancel a scheduled callback.
*/
cancel(scheduled: any) {
if (remove(this.scheduled, scheduled)) {
clearTimeout(scheduled);
}
}
/**
* Schedule a callback to be called later (time is in milliseconds).
*/
schedule(delay: number, callback: Function): any {
if (this.locked) {
return null;
} else if (this.sync || delay <= 0) {
callback();
return null;
} else {
let handle = setTimeout(() => {
remove(this.scheduled, handle);
callback();
}, delay);
add(this.scheduled, handle);
return handle;
}
}
/**
* Asynchronously sleep a given time.
*/
async sleep(ms: number): Promise<any> {
return new Promise(resolve => {
this.schedule(ms, resolve);
});
}
postUnserialize() {
this.scheduled = [];
}
}

View file

@ -1,158 +1,156 @@
module TK.Specs {
testing("Toggle", test => {
let on_calls = 0;
let off_calls = 0;
let clock = test.clock();
testing("Toggle", test => {
let on_calls = 0;
let off_calls = 0;
let clock = test.clock();
test.setup(function () {
on_calls = 0;
off_calls = 0;
});
test.setup(function () {
on_calls = 0;
off_calls = 0;
});
function newToggle(): Toggle {
return new Toggle(() => on_calls++, () => off_calls++);
}
function newToggle(): Toggle {
return new Toggle(() => on_calls++, () => off_calls++);
}
function checkstate(on: number, off: number) {
test.check.same(on_calls, on);
test.check.same(off_calls, off);
on_calls = 0;
off_calls = 0;
}
function checkstate(on: number, off: number) {
test.check.same(on_calls, on);
test.check.same(off_calls, off);
on_calls = 0;
off_calls = 0;
}
test.case("toggles on and off", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
test.case("toggles on and off", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
let result = client(true);
check.equals(result, true);
checkstate(1, 0);
let result = client(true);
check.equals(result, true);
checkstate(1, 0);
result = client(true);
check.equals(result, true);
checkstate(0, 0);
result = client(true);
check.equals(result, true);
checkstate(0, 0);
clock.forward(10000000);
checkstate(0, 0);
clock.forward(10000000);
checkstate(0, 0);
result = client(false);
check.equals(result, false);
checkstate(0, 1);
result = client(false);
check.equals(result, false);
checkstate(0, 1);
result = client(false);
check.equals(result, false);
checkstate(0, 0);
result = client(false);
check.equals(result, false);
checkstate(0, 0);
clock.forward(10000000);
checkstate(0, 0);
clock.forward(10000000);
checkstate(0, 0);
result = client(true);
check.equals(result, true);
checkstate(1, 0);
result = client(true);
check.equals(result, true);
checkstate(1, 0);
let client2 = toggle.manipulate("test2");
result = client2(true);
check.equals(result, true);
checkstate(0, 0);
let client2 = toggle.manipulate("test2");
result = client2(true);
check.equals(result, true);
checkstate(0, 0);
result = client(false);
check.equals(result, true);
checkstate(0, 0);
result = client(false);
check.equals(result, true);
checkstate(0, 0);
result = client2(false);
check.equals(result, false);
checkstate(0, 1);
})
result = client2(false);
check.equals(result, false);
checkstate(0, 1);
})
test.case("switches between on and off", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
test.case("switches between on and off", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
let result = client();
check.equals(result, true);
checkstate(1, 0);
let result = client();
check.equals(result, true);
checkstate(1, 0);
result = client();
check.equals(result, false);
checkstate(0, 1);
result = client();
check.equals(result, false);
checkstate(0, 1);
result = client();
check.equals(result, true);
checkstate(1, 0);
result = client();
check.equals(result, true);
checkstate(1, 0);
let client2 = toggle.manipulate("test2");
checkstate(0, 0);
let client2 = toggle.manipulate("test2");
checkstate(0, 0);
result = client2();
check.equals(result, true);
checkstate(0, 0);
result = client2();
check.equals(result, true);
checkstate(0, 0);
result = client();
check.equals(result, true);
checkstate(0, 0);
result = client();
check.equals(result, true);
checkstate(0, 0);
result = client2();
check.equals(result, false);
checkstate(0, 1);
})
result = client2();
check.equals(result, false);
checkstate(0, 1);
})
test.case("toggles on for a given time", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
test.case("toggles on for a given time", check => {
let toggle = newToggle();
let client = toggle.manipulate("test");
checkstate(0, 0);
let result = client(100);
check.equals(result, true);
checkstate(1, 0);
let result = client(100);
check.equals(result, true);
checkstate(1, 0);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(60);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(60);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(60);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(60);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client(200);
check.equals(result, true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client(200);
check.equals(result, true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
let client2 = toggle.manipulate("test2");
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client2(200);
check.equals(result, true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
let client2 = toggle.manipulate("test2");
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client2(200);
check.equals(result, true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
clock.forward(150);
check.equals(toggle.isOn(), false);
checkstate(0, 1);
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client(true);
check.equals(result, true);
checkstate(0, 0);
check.equals(toggle.isOn(), true);
clock.forward(2000);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
})
})
}
result = client(100);
check.equals(result, true);
checkstate(1, 0);
result = client(true);
check.equals(result, true);
checkstate(0, 0);
check.equals(toggle.isOn(), true);
clock.forward(2000);
check.equals(toggle.isOn(), true);
checkstate(0, 0);
})
})

View file

@ -1,93 +1,94 @@
module TK {
/**
* Client for Toggle object, allowing to manipulate it
*
* *state* may be:
* - a boolean to require on or off
* - a number to require on for the duration in milliseconds
* - undefined to switch between on and off (based on the client state, not the toggle state)
*
* The function returns the actual state after applying the requirement
*/
export type ToggleClient = (state?: boolean | number) => boolean
import { Timer } from "./Timer"
import { add, contains, remove } from "./Tools"
/**
* A toggle between two states (on and off).
*
* A toggle will be on if at least one ToggleClient requires it to be on.
*/
export class Toggle {
private on: Function
private off: Function
private status = false
private clients: string[] = []
private scheduled: { [client: string]: any } = {}
private timer = Timer.global
/**
* Client for Toggle object, allowing to manipulate it
*
* *state* may be:
* - a boolean to require on or off
* - a number to require on for the duration in milliseconds
* - undefined to switch between on and off (based on the client state, not the toggle state)
*
* The function returns the actual state after applying the requirement
*/
export type ToggleClient = (state?: boolean | number) => boolean
constructor(on: Function = () => null, off: Function = () => null) {
this.on = on;
this.off = off;
/**
* A toggle between two states (on and off).
*
* A toggle will be on if at least one ToggleClient requires it to be on.
*/
export class Toggle {
private on: Function
private off: Function
private status = false
private clients: string[] = []
private scheduled: { [client: string]: any } = {}
private timer = Timer.global
constructor(on: Function = () => null, off: Function = () => null) {
this.on = on;
this.off = off;
}
/**
* Check if the current state is on
*/
isOn(): boolean {
return this.status;
}
/**
* Register a client to manipulate the toggle's state
*/
manipulate(client: string): ToggleClient {
return state => {
if (this.scheduled[client]) {
this.timer.cancel(this.scheduled[client]);
this.scheduled[client] = null;
}
if (typeof state == "undefined") {
if (contains(this.clients, client)) {
this.stop(client);
} else {
this.start(client);
}
/**
* Check if the current state is on
*/
isOn(): boolean {
return this.status;
}
/**
* Register a client to manipulate the toggle's state
*/
manipulate(client: string): ToggleClient {
return state => {
if (this.scheduled[client]) {
this.timer.cancel(this.scheduled[client]);
this.scheduled[client] = null;
}
if (typeof state == "undefined") {
if (contains(this.clients, client)) {
this.stop(client);
} else {
this.start(client);
}
} else if (typeof state == "number") {
if (state > 0) {
this.start(client);
this.scheduled[client] = this.timer.schedule(state, () => this.stop(client));
} else {
this.stop(client);
}
} else if (!state) {
this.stop(client);
} else {
this.start(client);
}
return this.status;
}
}
/**
* Start the toggle for a client (set the status *on*)
*/
private start(client: string) {
add(this.clients, client);
if (!this.status) {
this.status = true;
this.on();
}
}
/**
* Stop the toggle (set the status *off*)
*/
private stop(client: string) {
remove(this.clients, client);
if (this.status && this.clients.length == 0) {
this.status = false;
this.off();
}
} else if (typeof state == "number") {
if (state > 0) {
this.start(client);
this.scheduled[client] = this.timer.schedule(state, () => this.stop(client));
} else {
this.stop(client);
}
} else if (!state) {
this.stop(client);
} else {
this.start(client);
}
return this.status;
}
}
/**
* Start the toggle for a client (set the status *on*)
*/
private start(client: string) {
add(this.clients, client);
if (!this.status) {
this.status = true;
this.on();
}
}
/**
* Stop the toggle (set the status *off*)
*/
private stop(client: string) {
remove(this.clients, client);
if (this.status && this.clients.length == 0) {
this.status = false;
this.off();
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,27 @@
module TK.SpaceTac.Specs {
function checkLocation(check: TestContext, got: IArenaLocation, expected_x: number, expected_y: number) {
check.equals(got.x, expected_x, `x differs (${got.x},${got.y}) (${expected_x},${expected_y})`);
check.equals(got.y, expected_y, `y differs (${got.x},${got.y}) (${expected_x},${expected_y})`);
}
testing("HexagonalArenaGrid", test => {
test.case("snaps coordinates to the nearest grid point, on a biased grid", check => {
let grid = new HexagonalArenaGrid(4, 0.75);
checkLocation(check, grid.snap({ x: 0, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 1, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 1.9, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 2.1, y: 0 }), 4, 0);
checkLocation(check, grid.snap({ x: 1, y: 1 }), 0, 0);
checkLocation(check, grid.snap({ x: 1, y: 2 }), 2, 3);
checkLocation(check, grid.snap({ x: -1, y: -1 }), 0, 0);
checkLocation(check, grid.snap({ x: -2, y: -2 }), -2, -3);
checkLocation(check, grid.snap({ x: -3, y: -1 }), -4, 0);
checkLocation(check, grid.snap({ x: 6, y: -5 }), 8, -6);
});
test.case("snaps coordinates to the nearest grid point, on a regular grid", check => {
let grid = new HexagonalArenaGrid(10);
checkLocation(check, grid.snap({ x: 0, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 8, y: 0 }), 10, 0);
checkLocation(check, grid.snap({ x: 1, y: 6 }), 5, 10 * Math.sqrt(0.75));
});
});
function checkLocation(check: TestContext, got: IArenaLocation, expected_x: number, expected_y: number) {
check.equals(got.x, expected_x, `x differs (${got.x},${got.y}) (${expected_x},${expected_y})`);
check.equals(got.y, expected_y, `y differs (${got.x},${got.y}) (${expected_x},${expected_y})`);
}
testing("HexagonalArenaGrid", test => {
test.case("snaps coordinates to the nearest grid point, on a biased grid", check => {
let grid = new HexagonalArenaGrid(4, 0.75);
checkLocation(check, grid.snap({ x: 0, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 1, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 1.9, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 2.1, y: 0 }), 4, 0);
checkLocation(check, grid.snap({ x: 1, y: 1 }), 0, 0);
checkLocation(check, grid.snap({ x: 1, y: 2 }), 2, 3);
checkLocation(check, grid.snap({ x: -1, y: -1 }), 0, 0);
checkLocation(check, grid.snap({ x: -2, y: -2 }), -2, -3);
checkLocation(check, grid.snap({ x: -3, y: -1 }), -4, 0);
checkLocation(check, grid.snap({ x: 6, y: -5 }), 8, -6);
});
test.case("snaps coordinates to the nearest grid point, on a regular grid", check => {
let grid = new HexagonalArenaGrid(10);
checkLocation(check, grid.snap({ x: 0, y: 0 }), 0, 0);
checkLocation(check, grid.snap({ x: 8, y: 0 }), 10, 0);
checkLocation(check, grid.snap({ x: 1, y: 6 }), 5, 10 * Math.sqrt(0.75));
});
});

View file

@ -1,34 +1,34 @@
module TK.SpaceTac {
/**
* Abstract grid for the arena where the battle takes place
*
* The grid is used to snap arena coordinates for ships and targets
*/
export interface IArenaGrid {
snap(loc: IArenaLocation): IArenaLocation;
}
import { ArenaLocation, IArenaLocation } from "./ArenaLocation";
/**
* Hexagonal unbounded arena grid
*
* This grid is composed of regular hexagons where all vertices are at a same distance "unit" of the hexagon center
*/
export class HexagonalArenaGrid implements IArenaGrid {
private yunit: number;
constructor(private unit: number, private yfactor = Math.sqrt(0.75)) {
this.yunit = unit * yfactor;
}
snap(loc: IArenaLocation): IArenaLocation {
let yr = Math.round(loc.y / this.yunit);
let xr: number;
if (yr % 2 == 0) {
xr = Math.round(loc.x / this.unit);
} else {
xr = Math.round((loc.x - 0.5 * this.unit) / this.unit) + 0.5;
}
return new ArenaLocation((xr * this.unit) || 0, (yr * this.yunit) || 0);
}
}
/**
* Abstract grid for the arena where the battle takes place
*
* The grid is used to snap arena coordinates for ships and targets
*/
export interface IArenaGrid {
snap(loc: IArenaLocation): IArenaLocation;
}
/**
* Hexagonal unbounded arena grid
*
* This grid is composed of regular hexagons where all vertices are at a same distance "unit" of the hexagon center
*/
export class HexagonalArenaGrid implements IArenaGrid {
private yunit: number;
constructor(private unit: number, private yfactor = Math.sqrt(0.75)) {
this.yunit = unit * yfactor;
}
snap(loc: IArenaLocation): IArenaLocation {
let yr = Math.round(loc.y / this.yunit);
let xr: number;
if (yr % 2 == 0) {
xr = Math.round(loc.x / this.unit);
} else {
xr = Math.round((loc.x - 0.5 * this.unit) / this.unit) + 0.5;
}
return new ArenaLocation((xr * this.unit) || 0, (yr * this.yunit) || 0);
}
}

View file

@ -1,22 +1,20 @@
module TK.SpaceTac.Specs {
testing("ArenaLocation", test => {
test.case("gets distance and angle between two locations", check => {
check.nears(arenaDistance({ x: 0, y: 0 }, { x: 1, y: 1 }), Math.sqrt(2));
check.nears(arenaAngle({ x: 0, y: 0 }, { x: 1, y: 1 }), Math.PI / 4);
})
testing("ArenaLocation", test => {
test.case("gets distance and angle between two locations", check => {
check.nears(arenaDistance({ x: 0, y: 0 }, { x: 1, y: 1 }), Math.sqrt(2));
check.nears(arenaAngle({ x: 0, y: 0 }, { x: 1, y: 1 }), Math.PI / 4);
})
test.case("computes an angular difference", check => {
check.equals(angularDifference(0.5, 1.5), 1.0);
check.nears(angularDifference(0.5, 1.5 + Math.PI * 6), 1.0);
check.same(angularDifference(0.5, -0.5), -1.0);
check.nears(angularDifference(0.5, -0.3 - Math.PI * 4), -0.8);
check.nears(angularDifference(-3 * Math.PI / 4, 3 * Math.PI / 4), -Math.PI / 2);
check.nears(angularDifference(3 * Math.PI / 4, -3 * Math.PI / 4), Math.PI / 2);
})
test.case("computes an angular difference", check => {
check.equals(angularDifference(0.5, 1.5), 1.0);
check.nears(angularDifference(0.5, 1.5 + Math.PI * 6), 1.0);
check.same(angularDifference(0.5, -0.5), -1.0);
check.nears(angularDifference(0.5, -0.3 - Math.PI * 4), -0.8);
check.nears(angularDifference(-3 * Math.PI / 4, 3 * Math.PI / 4), -Math.PI / 2);
check.nears(angularDifference(3 * Math.PI / 4, -3 * Math.PI / 4), Math.PI / 2);
})
test.case("converts between degrees and radians", check => {
check.nears(degrees(Math.PI / 2), 90);
check.nears(radians(45), Math.PI / 4);
});
});
}
test.case("converts between degrees and radians", check => {
check.nears(degrees(Math.PI / 2), 90);
check.nears(radians(45), Math.PI / 4);
});
});

View file

@ -1,99 +1,97 @@
module TK.SpaceTac {
/**
* Location in the arena (coordinates only)
*/
export interface IArenaLocation {
x: number
y: number
}
export class ArenaLocation implements IArenaLocation {
x: number
y: number
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}
/**
* Location in the arena, with a facing angle in radians
*/
export interface IArenaLocationAngle {
x: number
y: number
angle: number
}
export class ArenaLocationAngle extends ArenaLocation implements IArenaLocationAngle {
angle: number
constructor(x = 0, y = 0, angle = 0) {
super(x, y);
this.angle = angle;
}
}
/**
* Circle area in the arena
*/
export interface IArenaCircleArea {
x: number
y: number
radius: number
}
export class ArenaCircleArea extends ArenaLocation implements IArenaCircleArea {
radius: number
constructor(x = 0, y = 0, radius = 0) {
super(x, y);
this.radius = radius;
}
}
/**
* Get the normalized angle (in radians) between two locations
*/
export function arenaAngle(loc1: IArenaLocation, loc2: IArenaLocation): number {
return Math.atan2(loc2.y - loc1.y, loc2.x - loc1.x);
}
/**
* Get the "angular difference" between two angles in radians, in ]-pi,pi] range.
*/
export function angularDifference(angle1: number, angle2: number): number {
let diff = angle2 - angle1;
return diff - Math.PI * 2 * Math.floor((diff + Math.PI) / (Math.PI * 2));
}
/**
* Get the normalized distance between two locations
*/
export function arenaDistance(loc1: IArenaLocation, loc2: IArenaLocation): number {
let dx = loc2.x - loc1.x;
let dy = loc2.y - loc1.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Check if a location is inside an area
*/
export function arenaInside(loc1: IArenaLocation, loc2: IArenaCircleArea, border_inclusive = true): boolean {
let dist = arenaDistance(loc1, loc2);
return border_inclusive ? (dist <= loc2.radius) : (dist < loc2.radius);
}
/**
* Convert radians angle to degrees
*/
export function degrees(angle: number): number {
return angle * 180 / Math.PI;
}
/**
* Convert degrees angle to radians
*/
export function radians(angle: number): number {
return angle * Math.PI / 180;
}
/**
* Location in the arena (coordinates only)
*/
export interface IArenaLocation {
x: number
y: number
}
export class ArenaLocation implements IArenaLocation {
x: number
y: number
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}
/**
* Location in the arena, with a facing angle in radians
*/
export interface IArenaLocationAngle {
x: number
y: number
angle: number
}
export class ArenaLocationAngle extends ArenaLocation implements IArenaLocationAngle {
angle: number
constructor(x = 0, y = 0, angle = 0) {
super(x, y);
this.angle = angle;
}
}
/**
* Circle area in the arena
*/
export interface IArenaCircleArea {
x: number
y: number
radius: number
}
export class ArenaCircleArea extends ArenaLocation implements IArenaCircleArea {
radius: number
constructor(x = 0, y = 0, radius = 0) {
super(x, y);
this.radius = radius;
}
}
/**
* Get the normalized angle (in radians) between two locations
*/
export function arenaAngle(loc1: IArenaLocation, loc2: IArenaLocation): number {
return Math.atan2(loc2.y - loc1.y, loc2.x - loc1.x);
}
/**
* Get the "angular difference" between two angles in radians, in ]-pi,pi] range.
*/
export function angularDifference(angle1: number, angle2: number): number {
let diff = angle2 - angle1;
return diff - Math.PI * 2 * Math.floor((diff + Math.PI) / (Math.PI * 2));
}
/**
* Get the normalized distance between two locations
*/
export function arenaDistance(loc1: IArenaLocation, loc2: IArenaLocation): number {
let dx = loc2.x - loc1.x;
let dy = loc2.y - loc1.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Check if a location is inside an area
*/
export function arenaInside(loc1: IArenaLocation, loc2: IArenaCircleArea, border_inclusive = true): boolean {
let dist = arenaDistance(loc1, loc2);
return border_inclusive ? (dist <= loc2.radius) : (dist < loc2.radius);
}
/**
* Convert radians angle to degrees
*/
export function degrees(angle: number): number {
return angle * 180 / Math.PI;
}
/**
* Convert degrees angle to radians
*/
export function radians(angle: number): number {
return angle * Math.PI / 180;
}

View file

@ -1,381 +1,379 @@
module TK.SpaceTac {
testing("Battle", test => {
test.case("defines play order by initiative throws", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
TestTools.setAttribute(ship1, "initiative", 2);
var ship2 = new Ship(fleet1, "F1S2");
TestTools.setAttribute(ship2, "initiative", 4);
var ship3 = new Ship(fleet1, "F1S3");
TestTools.setAttribute(ship3, "initiative", 1);
var ship4 = new Ship(fleet2, "F2S1");
TestTools.setAttribute(ship4, "initiative", 8);
var ship5 = new Ship(fleet2, "F2S2");
TestTools.setAttribute(ship5, "initiative", 2);
var battle = new Battle(fleet1, fleet2);
check.equals(battle.play_order.length, 0);
var gen = new SkewedRandomGenerator([1.0, 0.1, 1.0, 0.2, 0.6]);
battle.throwInitiative(gen);
check.equals(battle.play_order.length, 5);
check.equals(battle.play_order, [ship1, ship4, ship5, ship3, ship2]);
});
test.case("places ships on lines, facing the arena center", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
var ship3 = new Ship(fleet1, "F1S3");
var ship4 = new Ship(fleet2, "F2S1");
var ship5 = new Ship(fleet2, "F2S2");
var battle = new Battle(fleet1, fleet2, 1000, 500);
battle.placeShips();
check.nears(ship1.arena_x, 250);
check.nears(ship1.arena_y, 150);
check.nears(ship1.arena_angle, 0);
check.nears(ship2.arena_x, 250);
check.nears(ship2.arena_y, 250);
check.nears(ship2.arena_angle, 0);
check.nears(ship3.arena_x, 250);
check.nears(ship3.arena_y, 350);
check.nears(ship3.arena_angle, 0);
check.nears(ship4.arena_x, 750);
check.nears(ship4.arena_y, 300);
check.nears(ship4.arena_angle, Math.PI);
check.nears(ship5.arena_x, 750);
check.nears(ship5.arena_y, 200);
check.nears(ship5.arena_angle, Math.PI);
});
test.case("advances to next ship in play order", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "ship1");
var ship2 = new Ship(fleet1, "ship2");
var ship3 = new Ship(fleet2, "ship3");
var battle = new Battle(fleet1, fleet2);
battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
// Check empty play_order case
check.equals(battle.playing_ship, null);
battle.advanceToNextShip();
check.equals(battle.playing_ship, null);
// Force play order
iforeach(battle.iships(), ship => TestTools.setAttribute(ship, "initiative", 1));
var gen = new SkewedRandomGenerator([0.1, 0.2, 0.0]);
battle.throwInitiative(gen);
check.equals(battle.playing_ship, null);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship1);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship3);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
// A dead ship is skipped
ship1.setDead();
battle.advanceToNextShip();
check.same(battle.playing_ship, ship3);
// Playing ship dies
ship3.setDead();
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
});
test.case("handles the suicide case (playing ship dies because of its action)", check => {
let battle = TestTools.createBattle(3, 1);
let [ship1, ship2, ship3, ship4] = battle.play_order;
ship1.setArenaPosition(0, 0);
ship2.setArenaPosition(0, 0);
ship3.setArenaPosition(1000, 1000);
ship4.setArenaPosition(1000, 1000);
let weapon = TestTools.addWeapon(ship1, 8000, 0, 50, 100);
check.in("initially", check => {
check.same(battle.playing_ship, ship1, "playing ship");
check.equals(battle.ships.list().filter(ship => ship.alive), [ship1, ship2, ship3, ship4], "alive ships");
});
let result = battle.applyOneAction(weapon.id, Target.newFromLocation(0, 0));
check.equals(result, true, "action applied successfully");
check.in("after weapon", check => {
check.same(battle.playing_ship, ship3, "playing ship");
check.equals(battle.ships.list().filter(ship => ship.alive), [ship3, ship4], "alive ships");
});
});
test.case("detects victory condition and logs a final EndBattleEvent", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
let ship3 = new Ship(fleet2, "F2S1");
var battle = new Battle(fleet1, fleet2);
battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
battle.start();
battle.play_order = [ship3, ship2, ship1];
check.equals(battle.ended, false);
ship1.setDead();
ship2.setDead();
battle.advanceToNextShip();
check.equals(battle.ended, true);
let diff = battle.log.get(battle.log.count() - 1);
if (diff instanceof EndBattleDiff) {
check.notequals(diff.outcome.winner, null);
check.same(diff.outcome.winner, fleet2.id);
} else {
check.fail("Not an EndBattleDiff");
}
});
test.case("handles a draw in end battle", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
var ship3 = new Ship(fleet2, "F2S1");
var battle = new Battle(fleet1, fleet2);
battle.start();
check.equals(battle.ended, false);
ship1.setDead();
ship2.setDead();
ship3.setDead();
battle.log.clear();
check.equals(battle.ended, false);
battle.performChecks();
check.equals(battle.ended, true);
check.equals(battle.log.count(), 1);
let diff = battle.log.get(0);
if (diff instanceof EndBattleDiff) {
check.equals(diff.outcome.winner, null);
} else {
check.fail("Not an EndBattleDiff");
}
});
test.case("collects ships present in a circle", check => {
var fleet1 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
ship1.setArenaPosition(0, 0);
var ship2 = new Ship(fleet1, "F1S2");
ship2.setArenaPosition(5, 8);
var ship3 = new Ship(fleet1, "F1S3");
ship3.setArenaPosition(6.5, 9.5);
var ship4 = new Ship(fleet1, "F1S4");
ship4.setArenaPosition(12, 12);
var battle = new Battle(fleet1);
battle.throwInitiative(new SkewedRandomGenerator([5, 4, 3, 2]));
var result = battle.collectShipsInCircle(Target.newFromLocation(5, 8), 3);
check.equals(result, [ship2, ship3]);
});
test.case("adds and remove drones", check => {
let battle = new Battle();
let ship = new Ship();
let drone = new Drone(ship);
check.equals(battle.drones.count(), 0);
battle.addDrone(drone);
check.equals(battle.drones.count(), 1);
check.same(battle.drones.get(drone.id), drone);
battle.addDrone(drone);
check.equals(battle.drones.count(), 1);
battle.removeDrone(drone);
check.equals(battle.drones.count(), 0);
battle.removeDrone(drone);
check.equals(battle.drones.count(), 0);
});
test.case("checks if a player is able to play", check => {
let battle = new Battle();
let player = new Player();
check.equals(battle.canPlay(player), false);
let ship = new Ship();
TestTools.setShipPlaying(battle, ship);
check.equals(battle.canPlay(player), false);
ship.fleet.setPlayer(player);
check.equals(battle.canPlay(player), true);
});
test.case("gets the number of turns before a specific ship plays", check => {
let battle = TestTools.createBattle(2, 1);
check.in("initial", check => {
check.same(battle.playing_ship, battle.play_order[0], "first ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 0);
check.equals(battle.getPlayOrder(battle.play_order[1]), 1);
check.equals(battle.getPlayOrder(battle.play_order[2]), 2);
});
battle.advanceToNextShip();
check.in("1 step", check => {
check.same(battle.playing_ship, battle.play_order[1], "second ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 2);
check.equals(battle.getPlayOrder(battle.play_order[1]), 0);
check.equals(battle.getPlayOrder(battle.play_order[2]), 1);
});
battle.advanceToNextShip();
check.in("2 steps", check => {
check.same(battle.playing_ship, battle.play_order[2], "third ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 1);
check.equals(battle.getPlayOrder(battle.play_order[1]), 2);
check.equals(battle.getPlayOrder(battle.play_order[2]), 0);
});
battle.advanceToNextShip();
check.in("3 steps", check => {
check.same(battle.playing_ship, battle.play_order[0], "first ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 0);
check.equals(battle.getPlayOrder(battle.play_order[1]), 1);
check.equals(battle.getPlayOrder(battle.play_order[2]), 2);
});
});
test.case("lists area effects", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let peer = battle.fleets[1].addShip();
peer.setArenaPosition(100, 50);
check.equals(battle.getAreaEffects(peer), [], "initial");
let drone1 = new Drone(ship);
drone1.x = 120;
drone1.y = 60;
drone1.radius = 40;
drone1.effects = [new DamageEffect(12)];
battle.addDrone(drone1);
let drone2 = new Drone(ship);
drone2.x = 130;
drone2.y = 70;
drone2.radius = 20;
drone2.effects = [new DamageEffect(14)];
battle.addDrone(drone2);
check.equals(battle.getAreaEffects(peer), [[drone1, drone1.effects[0]]], "drone effects");
let eq1 = new ToggleAction("eq1", { power: 0, radius: 500, effects: [new AttributeEffect("initiative", 1)] });
ship.actions.addCustom(eq1);
ship.actions.toggle(eq1, true);
let eq2 = new ToggleAction("eq2", { power: 0, radius: 500, effects: [new AttributeEffect("initiative", 2)] });
ship.actions.addCustom(eq2);
ship.actions.toggle(eq2, false);
let eq3 = new ToggleAction("eq3", { power: 0, radius: 100, effects: [new AttributeEffect("initiative", 3)] });
ship.actions.addCustom(eq3);
ship.actions.toggle(eq3, true);
check.equals(battle.getAreaEffects(peer), [
[drone1, drone1.effects[0]],
[ship, eq1.effects[0]],
], "drone and toggle effects");
});
test.case("is serializable", check => {
let battle = Battle.newQuickRandom();
battle.ai_playing = true;
let serializer = new Serializer(TK.SpaceTac);
let data = serializer.serialize(battle);
let loaded = serializer.unserialize(data);
check.equals(loaded.ai_playing, false, "ai playing is reset");
battle.ai_playing = false;
check.equals(loaded, battle, "unserialized == initial");
let session = new GameSession();
session.startNewGame();
session.start_location.setupEncounter();
session.start_location.enterLocation(session.player.fleet);
let battle1 = nn(session.getBattle());
let data1 = serializer.serialize(battle1);
let ratio = data.length / data1.length;
check.greaterorequal(ratio, 1.2, `quick battle serialized size (${data.length}) should be larger than campaign's (${data1.length})`);
});
test.case("can revert the last action", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
ship.setValue("hull", 13);
battle.log.clear();
battle.log.add(new ShipValueDiff(ship, "hull", 4));
battle.log.add(new ShipActionUsedDiff(ship, EndTurnAction.SINGLETON, Target.newFromShip(ship)));
battle.log.add(new ShipValueDiff(ship, "hull", 7));
battle.log.add(new ShipActionUsedDiff(ship, EndTurnAction.SINGLETON, Target.newFromShip(ship)));
battle.log.add(new ShipValueDiff(ship, "hull", 2));
check.in("initial state", check => {
check.equals(ship.getValue("hull"), 13, "hull=13");
check.equals(battle.log.count(), 5, "log count=5");
});
battle.revertOneAction();
check.in("revert 1 action", check => {
check.equals(ship.getValue("hull"), 11, "hull=11");
check.equals(battle.log.count(), 3, "log count=3");
});
battle.revertOneAction();
check.in("revert 2 actions", check => {
check.equals(ship.getValue("hull"), 4, "hull=4");
check.equals(battle.log.count(), 1, "log count=1");
});
battle.revertOneAction();
check.in("revert 3 actions", check => {
check.equals(ship.getValue("hull"), 0, "hull=0");
check.equals(battle.log.count(), 0, "log count=0");
});
})
testing("Battle", test => {
test.case("defines play order by initiative throws", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
TestTools.setAttribute(ship1, "initiative", 2);
var ship2 = new Ship(fleet1, "F1S2");
TestTools.setAttribute(ship2, "initiative", 4);
var ship3 = new Ship(fleet1, "F1S3");
TestTools.setAttribute(ship3, "initiative", 1);
var ship4 = new Ship(fleet2, "F2S1");
TestTools.setAttribute(ship4, "initiative", 8);
var ship5 = new Ship(fleet2, "F2S2");
TestTools.setAttribute(ship5, "initiative", 2);
var battle = new Battle(fleet1, fleet2);
check.equals(battle.play_order.length, 0);
var gen = new SkewedRandomGenerator([1.0, 0.1, 1.0, 0.2, 0.6]);
battle.throwInitiative(gen);
check.equals(battle.play_order.length, 5);
check.equals(battle.play_order, [ship1, ship4, ship5, ship3, ship2]);
});
test.case("places ships on lines, facing the arena center", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
var ship3 = new Ship(fleet1, "F1S3");
var ship4 = new Ship(fleet2, "F2S1");
var ship5 = new Ship(fleet2, "F2S2");
var battle = new Battle(fleet1, fleet2, 1000, 500);
battle.placeShips();
check.nears(ship1.arena_x, 250);
check.nears(ship1.arena_y, 150);
check.nears(ship1.arena_angle, 0);
check.nears(ship2.arena_x, 250);
check.nears(ship2.arena_y, 250);
check.nears(ship2.arena_angle, 0);
check.nears(ship3.arena_x, 250);
check.nears(ship3.arena_y, 350);
check.nears(ship3.arena_angle, 0);
check.nears(ship4.arena_x, 750);
check.nears(ship4.arena_y, 300);
check.nears(ship4.arena_angle, Math.PI);
check.nears(ship5.arena_x, 750);
check.nears(ship5.arena_y, 200);
check.nears(ship5.arena_angle, Math.PI);
});
test.case("advances to next ship in play order", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "ship1");
var ship2 = new Ship(fleet1, "ship2");
var ship3 = new Ship(fleet2, "ship3");
var battle = new Battle(fleet1, fleet2);
battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
// Check empty play_order case
check.equals(battle.playing_ship, null);
battle.advanceToNextShip();
check.equals(battle.playing_ship, null);
// Force play order
iforeach(battle.iships(), ship => TestTools.setAttribute(ship, "initiative", 1));
var gen = new SkewedRandomGenerator([0.1, 0.2, 0.0]);
battle.throwInitiative(gen);
check.equals(battle.playing_ship, null);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship1);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship3);
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
// A dead ship is skipped
ship1.setDead();
battle.advanceToNextShip();
check.same(battle.playing_ship, ship3);
// Playing ship dies
ship3.setDead();
battle.advanceToNextShip();
check.same(battle.playing_ship, ship2);
});
test.case("handles the suicide case (playing ship dies because of its action)", check => {
let battle = TestTools.createBattle(3, 1);
let [ship1, ship2, ship3, ship4] = battle.play_order;
ship1.setArenaPosition(0, 0);
ship2.setArenaPosition(0, 0);
ship3.setArenaPosition(1000, 1000);
ship4.setArenaPosition(1000, 1000);
let weapon = TestTools.addWeapon(ship1, 8000, 0, 50, 100);
check.in("initially", check => {
check.same(battle.playing_ship, ship1, "playing ship");
check.equals(battle.ships.list().filter(ship => ship.alive), [ship1, ship2, ship3, ship4], "alive ships");
});
}
let result = battle.applyOneAction(weapon.id, Target.newFromLocation(0, 0));
check.equals(result, true, "action applied successfully");
check.in("after weapon", check => {
check.same(battle.playing_ship, ship3, "playing ship");
check.equals(battle.ships.list().filter(ship => ship.alive), [ship3, ship4], "alive ships");
});
});
test.case("detects victory condition and logs a final EndBattleEvent", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
let ship3 = new Ship(fleet2, "F2S1");
var battle = new Battle(fleet1, fleet2);
battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
battle.start();
battle.play_order = [ship3, ship2, ship1];
check.equals(battle.ended, false);
ship1.setDead();
ship2.setDead();
battle.advanceToNextShip();
check.equals(battle.ended, true);
let diff = battle.log.get(battle.log.count() - 1);
if (diff instanceof EndBattleDiff) {
check.notequals(diff.outcome.winner, null);
check.same(diff.outcome.winner, fleet2.id);
} else {
check.fail("Not an EndBattleDiff");
}
});
test.case("handles a draw in end battle", check => {
var fleet1 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship2 = new Ship(fleet1, "F1S2");
var ship3 = new Ship(fleet2, "F2S1");
var battle = new Battle(fleet1, fleet2);
battle.start();
check.equals(battle.ended, false);
ship1.setDead();
ship2.setDead();
ship3.setDead();
battle.log.clear();
check.equals(battle.ended, false);
battle.performChecks();
check.equals(battle.ended, true);
check.equals(battle.log.count(), 1);
let diff = battle.log.get(0);
if (diff instanceof EndBattleDiff) {
check.equals(diff.outcome.winner, null);
} else {
check.fail("Not an EndBattleDiff");
}
});
test.case("collects ships present in a circle", check => {
var fleet1 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
ship1.setArenaPosition(0, 0);
var ship2 = new Ship(fleet1, "F1S2");
ship2.setArenaPosition(5, 8);
var ship3 = new Ship(fleet1, "F1S3");
ship3.setArenaPosition(6.5, 9.5);
var ship4 = new Ship(fleet1, "F1S4");
ship4.setArenaPosition(12, 12);
var battle = new Battle(fleet1);
battle.throwInitiative(new SkewedRandomGenerator([5, 4, 3, 2]));
var result = battle.collectShipsInCircle(Target.newFromLocation(5, 8), 3);
check.equals(result, [ship2, ship3]);
});
test.case("adds and remove drones", check => {
let battle = new Battle();
let ship = new Ship();
let drone = new Drone(ship);
check.equals(battle.drones.count(), 0);
battle.addDrone(drone);
check.equals(battle.drones.count(), 1);
check.same(battle.drones.get(drone.id), drone);
battle.addDrone(drone);
check.equals(battle.drones.count(), 1);
battle.removeDrone(drone);
check.equals(battle.drones.count(), 0);
battle.removeDrone(drone);
check.equals(battle.drones.count(), 0);
});
test.case("checks if a player is able to play", check => {
let battle = new Battle();
let player = new Player();
check.equals(battle.canPlay(player), false);
let ship = new Ship();
TestTools.setShipPlaying(battle, ship);
check.equals(battle.canPlay(player), false);
ship.fleet.setPlayer(player);
check.equals(battle.canPlay(player), true);
});
test.case("gets the number of turns before a specific ship plays", check => {
let battle = TestTools.createBattle(2, 1);
check.in("initial", check => {
check.same(battle.playing_ship, battle.play_order[0], "first ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 0);
check.equals(battle.getPlayOrder(battle.play_order[1]), 1);
check.equals(battle.getPlayOrder(battle.play_order[2]), 2);
});
battle.advanceToNextShip();
check.in("1 step", check => {
check.same(battle.playing_ship, battle.play_order[1], "second ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 2);
check.equals(battle.getPlayOrder(battle.play_order[1]), 0);
check.equals(battle.getPlayOrder(battle.play_order[2]), 1);
});
battle.advanceToNextShip();
check.in("2 steps", check => {
check.same(battle.playing_ship, battle.play_order[2], "third ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 1);
check.equals(battle.getPlayOrder(battle.play_order[1]), 2);
check.equals(battle.getPlayOrder(battle.play_order[2]), 0);
});
battle.advanceToNextShip();
check.in("3 steps", check => {
check.same(battle.playing_ship, battle.play_order[0], "first ship playing");
check.equals(battle.getPlayOrder(battle.play_order[0]), 0);
check.equals(battle.getPlayOrder(battle.play_order[1]), 1);
check.equals(battle.getPlayOrder(battle.play_order[2]), 2);
});
});
test.case("lists area effects", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
let peer = battle.fleets[1].addShip();
peer.setArenaPosition(100, 50);
check.equals(battle.getAreaEffects(peer), [], "initial");
let drone1 = new Drone(ship);
drone1.x = 120;
drone1.y = 60;
drone1.radius = 40;
drone1.effects = [new DamageEffect(12)];
battle.addDrone(drone1);
let drone2 = new Drone(ship);
drone2.x = 130;
drone2.y = 70;
drone2.radius = 20;
drone2.effects = [new DamageEffect(14)];
battle.addDrone(drone2);
check.equals(battle.getAreaEffects(peer), [[drone1, drone1.effects[0]]], "drone effects");
let eq1 = new ToggleAction("eq1", { power: 0, radius: 500, effects: [new AttributeEffect("initiative", 1)] });
ship.actions.addCustom(eq1);
ship.actions.toggle(eq1, true);
let eq2 = new ToggleAction("eq2", { power: 0, radius: 500, effects: [new AttributeEffect("initiative", 2)] });
ship.actions.addCustom(eq2);
ship.actions.toggle(eq2, false);
let eq3 = new ToggleAction("eq3", { power: 0, radius: 100, effects: [new AttributeEffect("initiative", 3)] });
ship.actions.addCustom(eq3);
ship.actions.toggle(eq3, true);
check.equals(battle.getAreaEffects(peer), [
[drone1, drone1.effects[0]],
[ship, eq1.effects[0]],
], "drone and toggle effects");
});
test.case("is serializable", check => {
let battle = Battle.newQuickRandom();
battle.ai_playing = true;
let serializer = new Serializer(TK.SpaceTac);
let data = serializer.serialize(battle);
let loaded = serializer.unserialize(data);
check.equals(loaded.ai_playing, false, "ai playing is reset");
battle.ai_playing = false;
check.equals(loaded, battle, "unserialized == initial");
let session = new GameSession();
session.startNewGame();
session.start_location.setupEncounter();
session.start_location.enterLocation(session.player.fleet);
let battle1 = nn(session.getBattle());
let data1 = serializer.serialize(battle1);
let ratio = data.length / data1.length;
check.greaterorequal(ratio, 1.2, `quick battle serialized size (${data.length}) should be larger than campaign's (${data1.length})`);
});
test.case("can revert the last action", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
ship.setValue("hull", 13);
battle.log.clear();
battle.log.add(new ShipValueDiff(ship, "hull", 4));
battle.log.add(new ShipActionUsedDiff(ship, EndTurnAction.SINGLETON, Target.newFromShip(ship)));
battle.log.add(new ShipValueDiff(ship, "hull", 7));
battle.log.add(new ShipActionUsedDiff(ship, EndTurnAction.SINGLETON, Target.newFromShip(ship)));
battle.log.add(new ShipValueDiff(ship, "hull", 2));
check.in("initial state", check => {
check.equals(ship.getValue("hull"), 13, "hull=13");
check.equals(battle.log.count(), 5, "log count=5");
});
battle.revertOneAction();
check.in("revert 1 action", check => {
check.equals(ship.getValue("hull"), 11, "hull=11");
check.equals(battle.log.count(), 3, "log count=3");
});
battle.revertOneAction();
check.in("revert 2 actions", check => {
check.equals(ship.getValue("hull"), 4, "hull=4");
check.equals(battle.log.count(), 1, "log count=1");
});
battle.revertOneAction();
check.in("revert 3 actions", check => {
check.equals(ship.getValue("hull"), 0, "hull=0");
check.equals(battle.log.count(), 0, "log count=0");
});
})
});

View file

@ -1,416 +1,436 @@
module TK.SpaceTac {
/**
* A turn-based battle between fleets
*/
export class Battle {
// Grid for the arena
grid?: IArenaGrid
import { iarray, ichainit, ifilter, iforeach, imap, imaterialize } from "../common/Iterators"
import { RandomGenerator } from "../common/RandomGenerator"
import { RObjectContainer, RObjectId } from "../common/RObject"
import { bool, flatten } from "../common/Tools"
import { EndTurnAction } from "./actions/EndTurnAction"
import { AIWorker } from "./ai/AIWorker"
import { HexagonalArenaGrid, IArenaGrid } from "./ArenaGrid"
import { BattleChecks } from "./BattleChecks"
import { BattleLog, BattleLogClient } from "./BattleLog"
import { BattleOutcome } from "./BattleOutcome"
import { BattleStats } from "./BattleStats"
import { BaseBattleDiff } from "./diffs/BaseBattleDiff"
import { EndBattleDiff } from "./diffs/EndBattleDiff"
import { ShipActionEndedDiff } from "./diffs/ShipActionEndedDiff"
import { ShipActionUsedDiff } from "./diffs/ShipActionUsedDiff"
import { Drone } from "./Drone"
import { BaseEffect } from "./effects/BaseEffect"
import { Fleet } from "./Fleet"
import { Player } from "./Player"
import { Ship } from "./Ship"
import { Target } from "./Target"
// Battle outcome, if the battle has ended
outcome: BattleOutcome | null = null
/**
* A turn-based battle between fleets
*/
export class Battle {
// Grid for the arena
grid?: IArenaGrid
// Statistics
stats: BattleStats
// Battle outcome, if the battle has ended
outcome: BattleOutcome | null = null
// Log of all battle events
log: BattleLog
// Statistics
stats: BattleStats
// List of fleets engaged in battle
fleets: Fleet[]
// Log of all battle events
log: BattleLog
// Container of all engaged ships
ships: RObjectContainer<Ship>
// List of fleets engaged in battle
fleets: Fleet[]
// List of playing ships, sorted by their initiative throw
play_order: Ship[]
play_index = -1
// Container of all engaged ships
ships: RObjectContainer<Ship>
// Current battle "cycle" (one cycle is one turn done for all ships in the play order)
cycle = 0
// List of playing ships, sorted by their initiative throw
play_order: Ship[]
play_index = -1
// List of deployed drones
drones = new RObjectContainer<Drone>()
// Current battle "cycle" (one cycle is one turn done for all ships in the play order)
cycle = 0
// Size of the battle area
width: number
height: number
border = 50
ship_separation = 100
// List of deployed drones
drones = new RObjectContainer<Drone>()
// Indicator that an AI is playing
ai_playing = false
// Size of the battle area
width: number
height: number
border = 50
ship_separation = 100
constructor(fleet1 = new Fleet(new Player("Attacker")), fleet2 = new Fleet(new Player("Defender")), width = 1808, height = 948) {
this.grid = new HexagonalArenaGrid(50);
// Indicator that an AI is playing
ai_playing = false
this.fleets = [fleet1, fleet2];
this.ships = new RObjectContainer(fleet1.ships.concat(fleet2.ships));
this.play_order = [];
this.width = width;
this.height = height;
constructor(fleet1 = new Fleet(new Player("Attacker")), fleet2 = new Fleet(new Player("Defender")), width = 1808, height = 948) {
this.grid = new HexagonalArenaGrid(50);
this.log = new BattleLog();
this.stats = new BattleStats();
this.fleets = [fleet1, fleet2];
this.ships = new RObjectContainer(fleet1.ships.concat(fleet2.ships));
this.play_order = [];
this.width = width;
this.height = height;
this.fleets.forEach((fleet: Fleet) => {
fleet.setBattle(this);
});
}
this.log = new BattleLog();
this.stats = new BattleStats();
postUnserialize() {
this.ai_playing = false;
}
this.fleets.forEach((fleet: Fleet) => {
fleet.setBattle(this);
});
}
/**
* Property is true if the battle has ended
*/
get ended(): boolean {
return bool(this.outcome);
}
postUnserialize() {
this.ai_playing = false;
}
/**
* Apply a list of diffs to the game state, and add them to the log.
*
* This should be the main way to modify the game state.
*/
applyDiffs(diffs: BaseBattleDiff[]): void {
let client = new BattleLogClient(this, this.log);
diffs.forEach(diff => client.add(diff));
}
/**
* Property is true if the battle has ended
*/
get ended(): boolean {
return bool(this.outcome);
}
/**
* Create a quick random battle, for testing purposes, or quick skirmish
*/
static newQuickRandom(start = true, level = 1, shipcount = 5): Battle {
let player1 = Player.newQuickRandom("Player", level, shipcount, true);
let player2 = Player.newQuickRandom("Enemy", level, shipcount, true);
/**
* Apply a list of diffs to the game state, and add them to the log.
*
* This should be the main way to modify the game state.
*/
applyDiffs(diffs: BaseBattleDiff[]): void {
let client = new BattleLogClient(this, this.log);
diffs.forEach(diff => client.add(diff));
}
let result = new Battle(player1.fleet, player2.fleet);
if (start) {
result.start();
}
return result;
}
/**
* Create a quick random battle, for testing purposes, or quick skirmish
*/
static newQuickRandom(start = true, level = 1, shipcount = 5): Battle {
let player1 = Player.newQuickRandom("Player", level, shipcount, true);
let player2 = Player.newQuickRandom("Enemy", level, shipcount, true);
/**
* Get the currently playing ship
*/
get playing_ship(): Ship | null {
return this.play_order[this.play_index] || null;
}
/**
* Get a ship by its ID.
*/
getShip(id: RObjectId | null): Ship | null {
if (id === null) {
return null;
} else {
return this.ships.get(id);
}
}
/**
* Return an iterator over all ships engaged in the battle
*/
iships(alive_only = false): Iterable<Ship> {
let result = ichainit(imap(iarray(this.fleets), fleet => iarray(fleet.ships)));
return alive_only ? ifilter(result, ship => ship.alive) : result;
}
/**
* 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));
}
/**
* 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));
}
/**
* Check if a player is able to play
*
* This can be used by the UI to determine if player interaction is allowed
*/
canPlay(player: Player): boolean {
if (this.ended) {
return false;
} else if (this.playing_ship && player.is(this.playing_ship.fleet.player)) {
return this.playing_ship.isAbleToPlay(false);
} else {
return false;
}
}
// Create play order, performing an initiative throw
throwInitiative(gen: RandomGenerator = new RandomGenerator()): void {
var play_order: Ship[] = [];
// Throw each ship's initiative
this.fleets.forEach(function (fleet: Fleet) {
fleet.ships.forEach(function (ship: Ship) {
ship.throwInitiative(gen);
play_order.push(ship);
});
});
// Sort by throw result
play_order.sort(function (ship1: Ship, ship2: Ship) {
return (ship2.play_priority - ship1.play_priority);
});
this.play_order = play_order;
this.play_index = -1;
}
/**
* Get the number of turns before a specific ship plays (currently playing ship will return 0).
*
* Returns -1 if the ship is not in the play list.
*/
getPlayOrder(ship: Ship): number {
let index = this.play_order.indexOf(ship);
if (index < 0) {
return -1;
} else {
let result = index - this.play_index;
return (result < 0) ? (result + this.play_order.length) : result;
}
}
/**
* Add a ship in the play order list
*/
removeFromPlayOrder(idx: number): void {
this.play_order.splice(idx, 1);
if (idx <= this.play_index) {
this.play_index -= 1;
}
}
/**
* Remove a ship from the play order list
*/
insertInPlayOrder(idx: number, ship: Ship): void {
this.play_order.splice(idx, 0, ship);
if (idx <= this.play_index) {
this.play_index += 1;
}
}
/**
* Set the currently playing ship
*/
setPlayingShip(ship: Ship): void {
let current = this.playing_ship;
if (current) {
current.playing = false;
}
this.play_index = this.play_order.indexOf(ship);
this.ai_playing = false;
current = this.playing_ship;
if (current) {
current.playing = true;
}
}
// Defines the initial ship positions of all engaged fleets
placeShips(vertical = true): void {
if (vertical) {
this.placeFleetShips(this.fleets[0], this.width * 0.25, this.height * 0.5, 0, this.height);
this.placeFleetShips(this.fleets[1], this.width * 0.75, this.height * 0.5, Math.PI, this.height);
} else {
this.placeFleetShips(this.fleets[0], this.width * 0.5, this.height * 0.90, -Math.PI / 2, this.width);
this.placeFleetShips(this.fleets[1], this.width * 0.5, this.height * 0.10, Math.PI / 2, this.width);
}
}
// Collect all ships within a given radius of a target
collectShipsInCircle(center: Target, radius: number, alive_only = false): Ship[] {
return imaterialize(ifilter(this.iships(), ship => (ship.alive || !alive_only) && Target.newFromShip(ship).getDistanceTo(center) <= radius));
}
/**
* Ends the battle and sets the outcome
*/
endBattle(winner: Fleet | null) {
this.applyDiffs([new EndBattleDiff(winner, this.cycle)]);
}
/**
* Get the next playing ship
*/
getNextShip(): Ship {
return this.play_order[(this.play_index + 1) % this.play_order.length];
}
/**
* Make an AI play the current ship
*
* This will run asynchronous work in background, until the playing ship is changed
*/
playAI(debug = false): boolean {
if (this.playing_ship && !this.ai_playing) {
this.ai_playing = true;
AIWorker.process(this, debug);
return true;
} else {
return false;
}
}
/**
* Start the battle
*
* This will call all necessary initialization steps (initiative, placement...)
*
* This should not put any diff in the log
*/
start(): void {
this.outcome = null;
this.cycle = 1;
this.placeShips();
iforeach(this.iships(), ship => ship.restoreInitialState());
this.throwInitiative();
this.setPlayingShip(this.play_order[0]);
}
/**
* Force current ship's turn to end, then advance to the next one
*/
advanceToNextShip(): void {
if (this.playing_ship) {
this.applyOneAction(EndTurnAction.SINGLETON.id);
} else if (this.play_order.length) {
this.setPlayingShip(this.play_order[0]);
}
}
/**
* Defines the initial ship positions for one fleet
*
* *x* and *y* are the center of the fleet formation
* *facing_angle* is the forward angle in radians
* *width* is the formation width
*/
private placeFleetShips(fleet: Fleet, x: number, y: number, facing_angle: number, width: number): void {
var side_angle = facing_angle + Math.PI * 0.5;
var spacing = width * 0.2;
var total_length = spacing * (fleet.ships.length - 1);
var dx = Math.cos(side_angle);
var dy = Math.sin(side_angle);
x -= dx * total_length * 0.5;
y -= dy * total_length * 0.5;
for (var i = 0; i < fleet.ships.length; i++) {
fleet.ships[i].setArenaPosition(x + i * dx * spacing, y + i * dy * spacing);
fleet.ships[i].setArenaFacingAngle(facing_angle);
}
}
/**
* Add a drone to the battle
*/
addDrone(drone: Drone) {
this.drones.add(drone);
}
/**
* Remove a drone from the battle
*/
removeDrone(drone: Drone) {
this.drones.remove(drone);
}
/**
* Get the list of area effects that are expected to apply on a given ship
*/
getAreaEffects(ship: Ship): [Ship | Drone, BaseEffect][] {
let drone_effects = this.drones.list().map(drone => {
// TODO Should apply filterImpactedShips from drone action
if (drone.isInRange(ship.arena_x, ship.arena_y)) {
return drone.effects.map((effect): [Ship | Drone, BaseEffect] => [drone, effect]);
} else {
return [];
}
});
let ships_effects = this.ships.list().map(iship => {
return iship.getAreaEffects(ship).map((effect): [Ship | Drone, BaseEffect] => [iship, effect]);
});
return flatten(drone_effects.concat(ships_effects));
}
/**
* Perform all battle checks to ensure the state is consistent
*
* Returns all applied diffs
*/
performChecks(): BaseBattleDiff[] {
let checks = new BattleChecks(this);
return checks.apply();
}
/**
* Apply one action to the battle state
*
* At the end of the action, some checks will be applied to ensure the battle state is consistent
*/
applyOneAction(action_id: RObjectId, target?: Target): boolean {
let ship = this.playing_ship;
if (ship) {
let action = ship.actions.getById(action_id);
if (action) {
if (!target) {
target = action.getDefaultTarget(ship);
}
if (action.apply(this, ship, target)) {
this.performChecks();
if (!this.ended) {
this.applyDiffs([new ShipActionEndedDiff(ship, action, target)]);
if (ship.playing && ship.getValue("hull") <= 0) {
// Playing ship died during its action, force a turn end
this.applyOneAction(EndTurnAction.SINGLETON.id);
}
}
return true;
} else {
return false;
}
} else {
console.error("Action not found on ship", action_id, ship);
return false;
}
} else {
console.error("Cannot apply action - ship not playing", action_id, this);
return false;
}
}
/**
* Revert the last applied action
*
* This will remove diffs from the log, so pay attention to other log clients!
*/
revertOneAction(): void {
let client = new BattleLogClient(this, this.log);
while (!client.atStart() && !(client.getCurrent() instanceof ShipActionUsedDiff)) {
client.backward();
}
if (!client.atStart()) {
client.backward();
}
client.truncate();
}
let result = new Battle(player1.fleet, player2.fleet);
if (start) {
result.start();
}
return result;
}
/**
* Get the currently playing ship
*/
get playing_ship(): Ship | null {
return this.play_order[this.play_index] || null;
}
/**
* Get a ship by its ID.
*/
getShip(id: RObjectId | null): Ship | null {
if (id === null) {
return null;
} else {
return this.ships.get(id);
}
}
/**
* Return an iterator over all ships engaged in the battle
*/
iships(alive_only = false): Iterable<Ship> {
let result = ichainit(imap(iarray(this.fleets), fleet => iarray(fleet.ships)));
return alive_only ? ifilter(result, ship => ship.alive) : result;
}
/**
* 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));
}
/**
* 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));
}
/**
* Check if a player is able to play
*
* This can be used by the UI to determine if player interaction is allowed
*/
canPlay(player: Player): boolean {
if (this.ended) {
return false;
} else if (this.playing_ship && player.is(this.playing_ship.fleet.player)) {
return this.playing_ship.isAbleToPlay(false);
} else {
return false;
}
}
// Create play order, performing an initiative throw
throwInitiative(gen: RandomGenerator = new RandomGenerator()): void {
var play_order: Ship[] = [];
// Throw each ship's initiative
this.fleets.forEach(function (fleet: Fleet) {
fleet.ships.forEach(function (ship: Ship) {
ship.throwInitiative(gen);
play_order.push(ship);
});
});
// Sort by throw result
play_order.sort(function (ship1: Ship, ship2: Ship) {
return (ship2.play_priority - ship1.play_priority);
});
this.play_order = play_order;
this.play_index = -1;
}
/**
* Get the number of turns before a specific ship plays (currently playing ship will return 0).
*
* Returns -1 if the ship is not in the play list.
*/
getPlayOrder(ship: Ship): number {
let index = this.play_order.indexOf(ship);
if (index < 0) {
return -1;
} else {
let result = index - this.play_index;
return (result < 0) ? (result + this.play_order.length) : result;
}
}
/**
* Add a ship in the play order list
*/
removeFromPlayOrder(idx: number): void {
this.play_order.splice(idx, 1);
if (idx <= this.play_index) {
this.play_index -= 1;
}
}
/**
* Remove a ship from the play order list
*/
insertInPlayOrder(idx: number, ship: Ship): void {
this.play_order.splice(idx, 0, ship);
if (idx <= this.play_index) {
this.play_index += 1;
}
}
/**
* Set the currently playing ship
*/
setPlayingShip(ship: Ship): void {
let current = this.playing_ship;
if (current) {
current.playing = false;
}
this.play_index = this.play_order.indexOf(ship);
this.ai_playing = false;
current = this.playing_ship;
if (current) {
current.playing = true;
}
}
// Defines the initial ship positions of all engaged fleets
placeShips(vertical = true): void {
if (vertical) {
this.placeFleetShips(this.fleets[0], this.width * 0.25, this.height * 0.5, 0, this.height);
this.placeFleetShips(this.fleets[1], this.width * 0.75, this.height * 0.5, Math.PI, this.height);
} else {
this.placeFleetShips(this.fleets[0], this.width * 0.5, this.height * 0.90, -Math.PI / 2, this.width);
this.placeFleetShips(this.fleets[1], this.width * 0.5, this.height * 0.10, Math.PI / 2, this.width);
}
}
// Collect all ships within a given radius of a target
collectShipsInCircle(center: Target, radius: number, alive_only = false): Ship[] {
return imaterialize(ifilter(this.iships(), ship => (ship.alive || !alive_only) && Target.newFromShip(ship).getDistanceTo(center) <= radius));
}
/**
* Ends the battle and sets the outcome
*/
endBattle(winner: Fleet | null) {
this.applyDiffs([new EndBattleDiff(winner, this.cycle)]);
}
/**
* Get the next playing ship
*/
getNextShip(): Ship {
return this.play_order[(this.play_index + 1) % this.play_order.length];
}
/**
* Make an AI play the current ship
*
* This will run asynchronous work in background, until the playing ship is changed
*/
playAI(debug = false): boolean {
if (this.playing_ship && !this.ai_playing) {
this.ai_playing = true;
AIWorker.process(this, debug);
return true;
} else {
return false;
}
}
/**
* Start the battle
*
* This will call all necessary initialization steps (initiative, placement...)
*
* This should not put any diff in the log
*/
start(): void {
this.outcome = null;
this.cycle = 1;
this.placeShips();
iforeach(this.iships(), ship => ship.restoreInitialState());
this.throwInitiative();
this.setPlayingShip(this.play_order[0]);
}
/**
* Force current ship's turn to end, then advance to the next one
*/
advanceToNextShip(): void {
if (this.playing_ship) {
this.applyOneAction(EndTurnAction.SINGLETON.id);
} else if (this.play_order.length) {
this.setPlayingShip(this.play_order[0]);
}
}
/**
* Defines the initial ship positions for one fleet
*
* *x* and *y* are the center of the fleet formation
* *facing_angle* is the forward angle in radians
* *width* is the formation width
*/
private placeFleetShips(fleet: Fleet, x: number, y: number, facing_angle: number, width: number): void {
var side_angle = facing_angle + Math.PI * 0.5;
var spacing = width * 0.2;
var total_length = spacing * (fleet.ships.length - 1);
var dx = Math.cos(side_angle);
var dy = Math.sin(side_angle);
x -= dx * total_length * 0.5;
y -= dy * total_length * 0.5;
for (var i = 0; i < fleet.ships.length; i++) {
fleet.ships[i].setArenaPosition(x + i * dx * spacing, y + i * dy * spacing);
fleet.ships[i].setArenaFacingAngle(facing_angle);
}
}
/**
* Add a drone to the battle
*/
addDrone(drone: Drone) {
this.drones.add(drone);
}
/**
* Remove a drone from the battle
*/
removeDrone(drone: Drone) {
this.drones.remove(drone);
}
/**
* Get the list of area effects that are expected to apply on a given ship
*/
getAreaEffects(ship: Ship): [Ship | Drone, BaseEffect][] {
let drone_effects = this.drones.list().map(drone => {
// TODO Should apply filterImpactedShips from drone action
if (drone.isInRange(ship.arena_x, ship.arena_y)) {
return drone.effects.map((effect): [Ship | Drone, BaseEffect] => [drone, effect]);
} else {
return [];
}
});
let ships_effects = this.ships.list().map(iship => {
return iship.getAreaEffects(ship).map((effect): [Ship | Drone, BaseEffect] => [iship, effect]);
});
return flatten(drone_effects.concat(ships_effects));
}
/**
* Perform all battle checks to ensure the state is consistent
*
* Returns all applied diffs
*/
performChecks(): BaseBattleDiff[] {
let checks = new BattleChecks(this);
return checks.apply();
}
/**
* Apply one action to the battle state
*
* At the end of the action, some checks will be applied to ensure the battle state is consistent
*/
applyOneAction(action_id: RObjectId, target?: Target): boolean {
let ship = this.playing_ship;
if (ship) {
let action = ship.actions.getById(action_id);
if (action) {
if (!target) {
target = action.getDefaultTarget(ship);
}
if (action.apply(this, ship, target)) {
this.performChecks();
if (!this.ended) {
this.applyDiffs([new ShipActionEndedDiff(ship, action, target)]);
if (ship.playing && ship.getValue("hull") <= 0) {
// Playing ship died during its action, force a turn end
this.applyOneAction(EndTurnAction.SINGLETON.id);
}
}
return true;
} else {
return false;
}
} else {
console.error("Action not found on ship", action_id, ship);
return false;
}
} else {
console.error("Cannot apply action - ship not playing", action_id, this);
return false;
}
}
/**
* Revert the last applied action
*
* This will remove diffs from the log, so pay attention to other log clients!
*/
revertOneAction(): void {
let client = new BattleLogClient(this, this.log);
while (!client.atStart() && !(client.getCurrent() instanceof ShipActionUsedDiff)) {
client.backward();
}
if (!client.atStart()) {
client.backward();
}
client.truncate();
}
}

View file

@ -1,25 +1,23 @@
module TK.SpaceTac.Specs {
testing("BattleCheats", test => {
test.case("wins a battle", check => {
let battle = Battle.newQuickRandom();
let cheats = new BattleCheats(battle, battle.fleets[0].player);
testing("BattleCheats", test => {
test.case("wins a battle", check => {
let battle = Battle.newQuickRandom();
let cheats = new BattleCheats(battle, battle.fleets[0].player);
cheats.win();
cheats.win();
check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[0].id, "winner");
check.equals(any(battle.fleets[1].ships, ship => ship.alive), false, "all enemies dead");
})
check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[0].id, "winner");
check.equals(any(battle.fleets[1].ships, ship => ship.alive), false, "all enemies dead");
})
test.case("loses a battle", check => {
let battle = Battle.newQuickRandom();
let cheats = new BattleCheats(battle, battle.fleets[0].player);
test.case("loses a battle", check => {
let battle = Battle.newQuickRandom();
let cheats = new BattleCheats(battle, battle.fleets[0].player);
cheats.lose();
cheats.lose();
check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[1].id, "winner");
check.equals(any(battle.fleets[0].ships, ship => ship.alive), false, "all allies dead");
})
})
}
check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[1].id, "winner");
check.equals(any(battle.fleets[0].ships, ship => ship.alive), false, "all allies dead");
})
})

View file

@ -1,40 +1,43 @@
module TK.SpaceTac {
/**
* Cheat helpers for current battle
*
* May be used from the console to help development
*/
export class BattleCheats {
battle: Battle
player: Player
import { iforeach } from "../common/Iterators";
import { first } from "../common/Tools";
import { Battle } from "./Battle";
import { Player } from "./Player";
constructor(battle: Battle, player: Player) {
this.battle = battle;
this.player = player;
}
/**
* Cheat helpers for current battle
*
* May be used from the console to help development
*/
export class BattleCheats {
battle: Battle
player: Player
/**
* Make player win the current battle
*/
win(): void {
iforeach(this.battle.iships(), ship => {
if (!this.player.is(ship.fleet.player)) {
ship.setDead();
}
});
this.battle.endBattle(this.player.fleet);
}
constructor(battle: Battle, player: Player) {
this.battle = battle;
this.player = player;
}
/**
* Make player lose the current battle
*/
lose(): void {
iforeach(this.battle.iships(), ship => {
if (this.player.is(ship.fleet.player)) {
ship.setDead();
}
});
this.battle.endBattle(first(this.battle.fleets, fleet => !this.player.is(fleet.player)));
}
}
/**
* Make player win the current battle
*/
win(): void {
iforeach(this.battle.iships(), ship => {
if (!this.player.is(ship.fleet.player)) {
ship.setDead();
}
});
this.battle.endBattle(this.player.fleet);
}
/**
* Make player lose the current battle
*/
lose(): void {
iforeach(this.battle.iships(), ship => {
if (this.player.is(ship.fleet.player)) {
ship.setDead();
}
});
this.battle.endBattle(first(this.battle.fleets, fleet => !this.player.is(fleet.player)));
}
}

View file

@ -1,106 +1,104 @@
module TK.SpaceTac.Specs {
testing("BattleChecks", test => {
test.case("detects victory conditions", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
check.equals(checks.checkVictory(), [], "no victory");
testing("BattleChecks", test => {
test.case("detects victory conditions", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
check.equals(checks.checkVictory(), [], "no victory");
battle.cycle = 5;
ship1.setDead();
check.equals(checks.checkVictory(), [new EndBattleDiff(battle.fleets[1], 5)], "victory");
})
battle.cycle = 5;
ship1.setDead();
check.equals(checks.checkVictory(), [new EndBattleDiff(battle.fleets[1], 5)], "victory");
})
test.case("fixes ship values", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
check.equals(checks.checkShipValues(), [], "no value to fix");
test.case("fixes ship values", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
check.equals(checks.checkShipValues(), [], "no value to fix");
ship1.setValue("hull", -4);
TestTools.setAttribute(ship2, "shield_capacity", 48);
ship2.setValue("shield", 60);
check.equals(checks.checkShipValues(), [
new ShipValueDiff(ship1, "hull", 4),
new ShipValueDiff(ship2, "shield", -12),
], "fixed values");
})
ship1.setValue("hull", -4);
TestTools.setAttribute(ship2, "shield_capacity", 48);
ship2.setValue("shield", 60);
check.equals(checks.checkShipValues(), [
new ShipValueDiff(ship1, "hull", 4),
new ShipValueDiff(ship2, "shield", -12),
], "fixed values");
})
test.case("marks ships as dead, except the playing one", check => {
let battle = TestTools.createBattle(1, 2);
let [ship1, ship2, ship3] = battle.play_order;
let checks = new BattleChecks(battle);
check.equals(checks.checkDeadShips(), [], "no ship to mark as dead");
test.case("marks ships as dead, except the playing one", check => {
let battle = TestTools.createBattle(1, 2);
let [ship1, ship2, ship3] = battle.play_order;
let checks = new BattleChecks(battle);
check.equals(checks.checkDeadShips(), [], "no ship to mark as dead");
battle.ships.list().forEach(ship => ship.setValue("hull", 0));
battle.ships.list().forEach(ship => ship.setValue("hull", 0));
let result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship2)], "ship2 marked as dead");
battle.applyDiffs(result);
let result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship2)], "ship2 marked as dead");
battle.applyDiffs(result);
result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship3)], "ship3 marked as dead");
battle.applyDiffs(result);
result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship3)], "ship3 marked as dead");
battle.applyDiffs(result);
result = checks.checkDeadShips();
check.equals(result, [], "ship1 left playing");
})
result = checks.checkDeadShips();
check.equals(result, [], "ship1 left playing");
})
test.case("fixes area effects", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
test.case("fixes area effects", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle);
check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff");
});
check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff");
});
let effect1 = ship1.active_effects.add(new StickyEffect(new BaseEffect("e1")));
let effect2 = ship1.active_effects.add(new BaseEffect("e2"));
let effect3 = ship1.active_effects.add(new BaseEffect("e3"));
check.patch(battle, "getAreaEffects", (): [Ship, BaseEffect][] => [[ship1, effect3]]);
check.in("sticky+obsolete+missing", check => {
check.equals(checks.checkAreaEffects(), [
new ShipEffectRemovedDiff(ship1, effect2),
new ShipEffectAddedDiff(ship2, effect3)
], "effects diff");
});
})
let effect1 = ship1.active_effects.add(new StickyEffect(new BaseEffect("e1")));
let effect2 = ship1.active_effects.add(new BaseEffect("e2"));
let effect3 = ship1.active_effects.add(new BaseEffect("e3"));
check.patch(battle, "getAreaEffects", (): [Ship, BaseEffect][] => [[ship1, effect3]]);
check.in("sticky+obsolete+missing", check => {
check.equals(checks.checkAreaEffects(), [
new ShipEffectRemovedDiff(ship1, effect2),
new ShipEffectAddedDiff(ship2, effect3)
], "effects diff");
});
})
test.case("applies vigilance actions", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
ship1.setArenaPosition(100, 100);
TestTools.setShipModel(ship1, 10, 0, 5);
let ship2 = battle.fleets[1].addShip();
ship2.setArenaPosition(1000, 1000);
TestTools.setShipModel(ship2, 10);
TestTools.setShipPlaying(battle, ship1);
test.case("applies vigilance actions", check => {
let battle = new Battle();
let ship1 = battle.fleets[0].addShip();
ship1.setArenaPosition(100, 100);
TestTools.setShipModel(ship1, 10, 0, 5);
let ship2 = battle.fleets[1].addShip();
ship2.setArenaPosition(1000, 1000);
TestTools.setShipModel(ship2, 10);
TestTools.setShipPlaying(battle, ship1);
let vig1 = ship1.actions.addCustom(new VigilanceAction("Vig1", { radius: 100, filter: ActionTargettingFilter.ENEMIES }, { intruder_effects: [new DamageEffect(1)] }));
let vig2 = ship1.actions.addCustom(new VigilanceAction("Vig2", { radius: 50, filter: ActionTargettingFilter.ENEMIES }, { intruder_effects: [new DamageEffect(2)] }));
let vig3 = ship1.actions.addCustom(new VigilanceAction("Vig3", { radius: 100, filter: ActionTargettingFilter.ALLIES }, { intruder_effects: [new DamageEffect(3)] }));
battle.applyOneAction(vig1.id);
battle.applyOneAction(vig2.id);
battle.applyOneAction(vig3.id);
let vig1 = ship1.actions.addCustom(new VigilanceAction("Vig1", { radius: 100, filter: ActionTargettingFilter.ENEMIES }, { intruder_effects: [new DamageEffect(1)] }));
let vig2 = ship1.actions.addCustom(new VigilanceAction("Vig2", { radius: 50, filter: ActionTargettingFilter.ENEMIES }, { intruder_effects: [new DamageEffect(2)] }));
let vig3 = ship1.actions.addCustom(new VigilanceAction("Vig3", { radius: 100, filter: ActionTargettingFilter.ALLIES }, { intruder_effects: [new DamageEffect(3)] }));
battle.applyOneAction(vig1.id);
battle.applyOneAction(vig2.id);
battle.applyOneAction(vig3.id);
let checks = new BattleChecks(battle);
check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff");
});
let checks = new BattleChecks(battle);
check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff");
});
ship2.setArenaPosition(100, 160);
check.in("ship2 moved in range", check => {
check.equals(checks.checkAreaEffects(), [
new ShipEffectAddedDiff(ship2, vig1.effects[0]),
new VigilanceAppliedDiff(ship1, vig1, ship2),
new ShipDamageDiff(ship2, 1, 0),
new ShipValueDiff(ship2, "hull", -1),
], "effects diff");
});
})
})
}
ship2.setArenaPosition(100, 160);
check.in("ship2 moved in range", check => {
check.equals(checks.checkAreaEffects(), [
new ShipEffectAddedDiff(ship2, vig1.effects[0]),
new VigilanceAppliedDiff(ship1, vig1, ship2),
new ShipDamageDiff(ship2, 1, 0),
new ShipValueDiff(ship2, "hull", -1),
], "effects diff");
});
})
})

View file

@ -1,164 +1,174 @@
module TK.SpaceTac {
/**
* List of checks to apply at the end of an action, to ensure a correct battle state
*
* This is useful when the list of effects simulated by an action was missing something
*
* To fix the state, new diffs will be applied
*/
export class BattleChecks {
constructor(private battle: Battle) {
}
import { ifirst, iforeach, imaterialize } from "../common/Iterators";
import { RObjectContainer } from "../common/RObject";
import { any, first, flatten, keys } from "../common/Tools";
import { Battle } from "./Battle";
import { BaseBattleDiff } from "./diffs/BaseBattleDiff";
import { EndBattleDiff } from "./diffs/EndBattleDiff";
import { ShipEffectAddedDiff, ShipEffectRemovedDiff } from "./diffs/ShipEffectAddedDiff";
import { ShipValueDiff } from "./diffs/ShipValueDiff";
import { StickyEffect } from "./effects/StickyEffect";
import { Ship } from "./Ship";
import { SHIP_VALUES } from "./ShipValue";
/**
* Apply all the checks
*/
apply(): BaseBattleDiff[] {
let all: BaseBattleDiff[] = [];
let diffs: BaseBattleDiff[];
let loops = 0;
/**
* List of checks to apply at the end of an action, to ensure a correct battle state
*
* This is useful when the list of effects simulated by an action was missing something
*
* To fix the state, new diffs will be applied
*/
export class BattleChecks {
constructor(private battle: Battle) {
}
do {
diffs = this.checkAll();
/**
* Apply all the checks
*/
apply(): BaseBattleDiff[] {
let all: BaseBattleDiff[] = [];
let diffs: BaseBattleDiff[];
let loops = 0;
if (diffs.length > 0) {
//console.log("Battle checks diffs", diffs);
this.battle.applyDiffs(diffs);
all = all.concat(diffs);
}
do {
diffs = this.checkAll();
loops += 1;
if (loops >= 1000) {
console.error("Battle checks stuck in infinite loop", diffs);
break;
}
} while (diffs.length > 0);
if (diffs.length > 0) {
//console.log("Battle checks diffs", diffs);
this.battle.applyDiffs(diffs);
all = all.concat(diffs);
}
return all;
}
loops += 1;
if (loops >= 1000) {
console.error("Battle checks stuck in infinite loop", diffs);
break;
}
} while (diffs.length > 0);
/**
* Get a list of diffs to apply to fix the battle state
*
* This may not contain ALL the diffs needed, and should be called again while it returns diffs.
*/
checkAll(): BaseBattleDiff[] {
let diffs: BaseBattleDiff[] = [];
return all;
}
if (this.battle.ended) {
return diffs;
}
/**
* Get a list of diffs to apply to fix the battle state
*
* This may not contain ALL the diffs needed, and should be called again while it returns diffs.
*/
checkAll(): BaseBattleDiff[] {
let diffs: BaseBattleDiff[] = [];
diffs = this.checkAreaEffects();
if (diffs.length) {
return diffs;
}
diffs = this.checkShipValues();
if (diffs.length) {
return diffs;
}
diffs = this.checkDeadShips();
if (diffs.length) {
return diffs;
}
diffs = this.checkVictory();
if (diffs.length) {
return diffs;
}
return [];
}
/**
* Checks victory conditions, to put an end to the battle
*/
checkVictory(): BaseBattleDiff[] {
if (this.battle.ended) {
return [];
}
let fleets = this.battle.fleets;
if (any(fleets, fleet => !fleet.isAlive())) {
const winner = first(fleets, fleet => fleet.isAlive());
return [new EndBattleDiff(winner, this.battle.cycle)];
} else {
return [];
}
}
/**
* Check that ship values stays in their allowed range
*/
checkShipValues(): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
iforeach(this.battle.iships(true), ship => {
keys(SHIP_VALUES).forEach(valuename => {
let value = ship.getValue(valuename);
if (value < 0) {
result.push(new ShipValueDiff(ship, valuename, -value));
} else {
let maximum = ship.getAttribute(<any>(valuename + "_capacity"));
if (value > maximum) {
result.push(new ShipValueDiff(ship, valuename, maximum - value));
}
}
});
});
return result;
}
/**
* Check that not-playing ships with no more hull are dead
*/
checkDeadShips(): BaseBattleDiff[] {
// We do one ship at a time, because the state of one ship may depend on another
let dying = ifirst(this.battle.iships(true), ship => !ship.playing && ship.getValue("hull") <= 0);
if (dying) {
return dying.getDeathDiffs(this.battle);
} else {
return [];
}
}
/**
* Get the diffs to apply to a ship, if moving at a given location
*/
getAreaEffectsDiff(ship: Ship): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
let expected = this.battle.getAreaEffects(ship);
let expected_hash = new RObjectContainer(expected.map(x => x[1]));
// Remove obsolete effects
ship.active_effects.list().forEach(effect => {
if (!(effect instanceof StickyEffect) && !expected_hash.get(effect.id)) {
result.push(new ShipEffectRemovedDiff(ship, effect));
result = result.concat(effect.getOffDiffs(ship));
}
});
// Add missing effects
expected.forEach(([source, effect]) => {
if (!ship.active_effects.get(effect.id)) {
result.push(new ShipEffectAddedDiff(ship, effect));
result = result.concat(effect.getOnDiffs(ship, source));
}
});
return result;
}
/**
* Check area effects (remove obsolete ones, and add missing ones)
*/
checkAreaEffects(): BaseBattleDiff[] {
let ships = imaterialize(this.battle.iships(true));
return flatten(ships.map(ship => this.getAreaEffectsDiff(ship)));
}
if (this.battle.ended) {
return diffs;
}
diffs = this.checkAreaEffects();
if (diffs.length) {
return diffs;
}
diffs = this.checkShipValues();
if (diffs.length) {
return diffs;
}
diffs = this.checkDeadShips();
if (diffs.length) {
return diffs;
}
diffs = this.checkVictory();
if (diffs.length) {
return diffs;
}
return [];
}
/**
* Checks victory conditions, to put an end to the battle
*/
checkVictory(): BaseBattleDiff[] {
if (this.battle.ended) {
return [];
}
let fleets = this.battle.fleets;
if (any(fleets, fleet => !fleet.isAlive())) {
const winner = first(fleets, fleet => fleet.isAlive());
return [new EndBattleDiff(winner, this.battle.cycle)];
} else {
return [];
}
}
/**
* Check that ship values stays in their allowed range
*/
checkShipValues(): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
iforeach(this.battle.iships(true), ship => {
keys(SHIP_VALUES).forEach(valuename => {
let value = ship.getValue(valuename);
if (value < 0) {
result.push(new ShipValueDiff(ship, valuename, -value));
} else {
let maximum = ship.getAttribute(<any>(valuename + "_capacity"));
if (value > maximum) {
result.push(new ShipValueDiff(ship, valuename, maximum - value));
}
}
});
});
return result;
}
/**
* Check that not-playing ships with no more hull are dead
*/
checkDeadShips(): BaseBattleDiff[] {
// We do one ship at a time, because the state of one ship may depend on another
let dying = ifirst(this.battle.iships(true), ship => !ship.playing && ship.getValue("hull") <= 0);
if (dying) {
return dying.getDeathDiffs(this.battle);
} else {
return [];
}
}
/**
* Get the diffs to apply to a ship, if moving at a given location
*/
getAreaEffectsDiff(ship: Ship): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
let expected = this.battle.getAreaEffects(ship);
let expected_hash = new RObjectContainer(expected.map(x => x[1]));
// Remove obsolete effects
ship.active_effects.list().forEach(effect => {
if (!(effect instanceof StickyEffect) && !expected_hash.get(effect.id)) {
result.push(new ShipEffectRemovedDiff(ship, effect));
result = result.concat(effect.getOffDiffs(ship));
}
});
// Add missing effects
expected.forEach(([source, effect]) => {
if (!ship.active_effects.get(effect.id)) {
result.push(new ShipEffectAddedDiff(ship, effect));
result = result.concat(effect.getOnDiffs(ship, source));
}
});
return result;
}
/**
* Check area effects (remove obsolete ones, and add missing ones)
*/
checkAreaEffects(): BaseBattleDiff[] {
let ships = imaterialize(this.battle.iships(true));
return flatten(ships.map(ship => this.getAreaEffectsDiff(ship)));
}
}

View file

@ -1,15 +1,14 @@
/// <reference path="../common/DiffLog.ts" />
import { DiffLog, DiffLogClient } from "../common/DiffLog";
import { Battle } from "./Battle";
module TK.SpaceTac {
/**
* Log of diffs that change the state of a battle
*/
export class BattleLog extends DiffLog<Battle> {
}
/**
* Client for a battle log
*/
export class BattleLogClient extends DiffLogClient<Battle> {
}
/**
* Log of diffs that change the state of a battle
*/
export class BattleLog extends DiffLog<Battle> {
}
/**
* Client for a battle log
*/
export class BattleLogClient extends DiffLogClient<Battle> {
}

View file

@ -1,36 +1,34 @@
module TK.SpaceTac.Specs {
testing("BattleOutcome", test => {
test.case("grants experience", check => {
let fleet1 = new Fleet();
let ship1a = fleet1.addShip(new Ship());
ship1a.level.forceLevel(3);
let ship1b = fleet1.addShip(new Ship());
ship1b.level.forceLevel(4);
let fleet2 = new Fleet();
let ship2a = fleet2.addShip(new Ship());
ship2a.level.forceLevel(6);
let ship2b = fleet2.addShip(new Ship());
ship2b.level.forceLevel(8);
check.equals(ship1a.level.getExperience(), 300);
check.equals(ship1b.level.getExperience(), 600);
check.equals(ship2a.level.getExperience(), 1500);
check.equals(ship2b.level.getExperience(), 2800);
testing("BattleOutcome", test => {
test.case("grants experience", check => {
let fleet1 = new Fleet();
let ship1a = fleet1.addShip(new Ship());
ship1a.level.forceLevel(3);
let ship1b = fleet1.addShip(new Ship());
ship1b.level.forceLevel(4);
let fleet2 = new Fleet();
let ship2a = fleet2.addShip(new Ship());
ship2a.level.forceLevel(6);
let ship2b = fleet2.addShip(new Ship());
ship2b.level.forceLevel(8);
check.equals(ship1a.level.getExperience(), 300);
check.equals(ship1b.level.getExperience(), 600);
check.equals(ship2a.level.getExperience(), 1500);
check.equals(ship2b.level.getExperience(), 2800);
// draw
let outcome = new BattleOutcome(null);
outcome.grantExperience([fleet1, fleet2]);
check.equals(ship1a.level.getExperience(), 345);
check.equals(ship1b.level.getExperience(), 645);
check.equals(ship2a.level.getExperience(), 1511);
check.equals(ship2b.level.getExperience(), 2811);
// draw
let outcome = new BattleOutcome(null);
outcome.grantExperience([fleet1, fleet2]);
check.equals(ship1a.level.getExperience(), 345);
check.equals(ship1b.level.getExperience(), 645);
check.equals(ship2a.level.getExperience(), 1511);
check.equals(ship2b.level.getExperience(), 2811);
// win/lose
outcome = new BattleOutcome(fleet1);
outcome.grantExperience([fleet1, fleet2]);
check.equals(ship1a.level.getExperience(), 480);
check.equals(ship1b.level.getExperience(), 780);
check.equals(ship2a.level.getExperience(), 1518);
check.equals(ship2b.level.getExperience(), 2818);
});
});
}
// win/lose
outcome = new BattleOutcome(fleet1);
outcome.grantExperience([fleet1, fleet2]);
check.equals(ship1a.level.getExperience(), 480);
check.equals(ship1b.level.getExperience(), 780);
check.equals(ship2a.level.getExperience(), 1518);
check.equals(ship2b.level.getExperience(), 2818);
});
});

View file

@ -1,34 +1,36 @@
module TK.SpaceTac {
/**
* Result of an ended battle
*
* This stores the winner, and the retrievable loot
*/
export class BattleOutcome {
// Indicates if the battle is a draw (no winner)
draw: boolean
import { RObjectId } from "../common/RObject";
import { flatten, sum } from "../common/Tools";
import { Fleet } from "./Fleet";
// Victorious fleet
winner: RObjectId | null
/**
* Result of an ended battle
*
* This stores the winner, and the retrievable loot
*/
export class BattleOutcome {
// Indicates if the battle is a draw (no winner)
draw: boolean
constructor(winner: Fleet | null) {
this.winner = winner ? winner.id : null;
this.draw = winner ? false : true;
}
// Victorious fleet
winner: RObjectId | null
/**
* Grant experience to participating fleets
*/
grantExperience(fleets: Fleet[]) {
fleets.forEach(fleet => {
let winfactor = (fleet.is(this.winner)) ? 0.03 : (this.draw ? 0.01 : 0.005);
let enemies = flatten(fleets.filter(f => f !== fleet).map(f => f.ships));
let difficulty = sum(enemies.map(enemy => 100 + enemy.level.getExperience()));
fleet.ships.forEach(ship => {
ship.level.addExperience(Math.floor(difficulty * winfactor));
ship.level.checkLevelUp();
});
});
}
}
constructor(winner: Fleet | null) {
this.winner = winner ? winner.id : null;
this.draw = winner ? false : true;
}
/**
* Grant experience to participating fleets
*/
grantExperience(fleets: Fleet[]) {
fleets.forEach(fleet => {
let winfactor = (fleet.is(this.winner)) ? 0.03 : (this.draw ? 0.01 : 0.005);
let enemies = flatten(fleets.filter(f => f !== fleet).map(f => f.ships));
let difficulty = sum(enemies.map(enemy => 100 + enemy.level.getExperience()));
fleet.ships.forEach(ship => {
ship.level.addExperience(Math.floor(difficulty * winfactor));
ship.level.checkLevelUp();
});
});
}
}

View file

@ -1,75 +1,73 @@
module TK.SpaceTac.Specs {
testing("BattleStats", test => {
test.case("collects stats", check => {
let stats = new BattleStats();
check.equals(stats.stats, {});
testing("BattleStats", test => {
test.case("collects stats", check => {
let stats = new BattleStats();
check.equals(stats.stats, {});
stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [1, 0] });
stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [1, 0] });
stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [2, 0] });
stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [2, 0] });
stats.addStat("Test", 1, false);
check.equals(stats.stats, { Test: [2, 1] });
stats.addStat("Test", 1, false);
check.equals(stats.stats, { Test: [2, 1] });
stats.addStat("Other Test", 10, true);
check.equals(stats.stats, { Test: [2, 1], "Other Test": [10, 0] });
})
stats.addStat("Other Test", 10, true);
check.equals(stats.stats, { Test: [2, 1], "Other Test": [10, 0] });
})
test.case("collects damage dealt", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
test.case("collects damage dealt", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
battle.log.add(new ShipDamageDiff(defender, 1, 3, 2));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [0, 1], "Damage shielded": [0, 3], "Damage evaded": [0, 2] });
battle.log.add(new ShipDamageDiff(defender, 1, 3, 2));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [0, 1], "Damage shielded": [0, 3], "Damage evaded": [0, 2] });
battle.log.add(new ShipDamageDiff(attacker, 2, 1, 3));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 1], "Damage shielded": [1, 3], "Damage evaded": [3, 2] });
battle.log.add(new ShipDamageDiff(attacker, 2, 1, 3));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 1], "Damage shielded": [1, 3], "Damage evaded": [3, 2] });
battle.log.add(new ShipDamageDiff(defender, 1, 1, 1));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 2], "Damage shielded": [1, 4], "Damage evaded": [3, 3] });
})
battle.log.add(new ShipDamageDiff(defender, 1, 1, 1));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 2], "Damage shielded": [1, 4], "Damage evaded": [3, 3] });
})
test.case("collects distance moved", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
test.case("collects distance moved", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
battle.log.add(new ShipMoveDiff(attacker, new ArenaLocationAngle(0, 0), new ArenaLocationAngle(10, 0)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 0] });
battle.log.add(new ShipMoveDiff(attacker, new ArenaLocationAngle(0, 0), new ArenaLocationAngle(10, 0)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 0] });
battle.log.add(new ShipMoveDiff(defender, new ArenaLocationAngle(10, 5), new ArenaLocationAngle(10, 63)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 58] });
})
battle.log.add(new ShipMoveDiff(defender, new ArenaLocationAngle(10, 5), new ArenaLocationAngle(10, 63)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 58] });
})
test.case("collects deployed drones", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
test.case("collects deployed drones", check => {
let stats = new BattleStats();
let battle = new Battle();
let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {});
battle.log.add(new DroneDeployedDiff(new Drone(attacker)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 0] });
battle.log.add(new DroneDeployedDiff(new Drone(attacker)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 0] });
battle.log.add(new DroneDeployedDiff(new Drone(defender)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 1] });
})
})
}
battle.log.add(new DroneDeployedDiff(new Drone(defender)));
stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 1] });
})
})

View file

@ -1,64 +1,70 @@
module TK.SpaceTac {
/**
* Statistics collection over a battle
*/
export class BattleStats {
stats: { [name: string]: [number, number] } = {}
import { any, iteritems } from "../common/Tools";
import { BattleLog } from "./BattleLog";
import { BaseBattleShipDiff } from "./diffs/BaseBattleDiff";
import { DroneDeployedDiff } from "./diffs/DroneDeployedDiff";
import { ShipDamageDiff } from "./diffs/ShipDamageDiff";
import { ShipMoveDiff } from "./diffs/ShipMoveDiff";
import { Fleet } from "./Fleet";
/**
* Add a value to the collector
*/
addStat(name: string, value: number, attacker: boolean) {
if (!this.stats[name]) {
this.stats[name] = [0, 0];
}
/**
* Statistics collection over a battle
*/
export class BattleStats {
stats: { [name: string]: [number, number] } = {}
if (attacker) {
this.stats[name] = [this.stats[name][0] + value, this.stats[name][1]];
} else {
this.stats[name] = [this.stats[name][0], this.stats[name][1] + value];
}
}
/**
* Get important stats
*/
getImportant(maxcount: number): { name: string, attacker: number, defender: number }[] {
// TODO Sort by importance
let result: { name: string, attacker: number, defender: number }[] = [];
iteritems(this.stats, (name, [attacker, defender]) => {
if (result.length < maxcount) {
result.push({ name: name, attacker: Math.round(attacker), defender: Math.round(defender) });
}
});
return result;
}
/**
* Process a battle log
*/
processLog(log: BattleLog, attacker: Fleet, clear = true) {
if (clear) {
this.stats = {};
}
let n = log.count();
for (let i = 0; i < n; i++) {
let diff = log.get(i);
if (diff instanceof BaseBattleShipDiff) {
let diff_ship = diff.ship_id;
let attacker_ship = any(attacker.ships, ship => ship.is(diff_ship));
if (diff instanceof ShipDamageDiff) {
this.addStat("Damage evaded", diff.evaded, attacker_ship);
this.addStat("Damage shielded", diff.shield, attacker_ship);
this.addStat("Damage taken", diff.hull, attacker_ship);
} else if (diff instanceof ShipMoveDiff) {
this.addStat("Move distance (km)", diff.getDistance(), attacker_ship);
} else if (diff instanceof DroneDeployedDiff) {
this.addStat("Drones deployed", 1, attacker_ship);
}
}
}
}
/**
* Add a value to the collector
*/
addStat(name: string, value: number, attacker: boolean) {
if (!this.stats[name]) {
this.stats[name] = [0, 0];
}
if (attacker) {
this.stats[name] = [this.stats[name][0] + value, this.stats[name][1]];
} else {
this.stats[name] = [this.stats[name][0], this.stats[name][1] + value];
}
}
/**
* Get important stats
*/
getImportant(maxcount: number): { name: string, attacker: number, defender: number }[] {
// TODO Sort by importance
let result: { name: string, attacker: number, defender: number }[] = [];
iteritems(this.stats, (name, [attacker, defender]) => {
if (result.length < maxcount) {
result.push({ name: name, attacker: Math.round(attacker), defender: Math.round(defender) });
}
});
return result;
}
/**
* Process a battle log
*/
processLog(log: BattleLog, attacker: Fleet, clear = true) {
if (clear) {
this.stats = {};
}
let n = log.count();
for (let i = 0; i < n; i++) {
let diff = log.get(i);
if (diff instanceof BaseBattleShipDiff) {
let diff_ship = diff.ship_id;
let attacker_ship = any(attacker.ships, ship => ship.is(diff_ship));
if (diff instanceof ShipDamageDiff) {
this.addStat("Damage evaded", diff.evaded, attacker_ship);
this.addStat("Damage shielded", diff.shield, attacker_ship);
this.addStat("Damage taken", diff.hull, attacker_ship);
} else if (diff instanceof ShipMoveDiff) {
this.addStat("Move distance (km)", diff.getDistance(), attacker_ship);
} else if (diff instanceof DroneDeployedDiff) {
this.addStat("Drones deployed", 1, attacker_ship);
}
}
}
}
}

View file

@ -1,38 +1,36 @@
module TK.SpaceTac.Specs {
testing("Cooldown", test => {
test.case("applies overheat and cooldown", check => {
let cooldown = new Cooldown();
check.equals(cooldown.canUse(), true);
testing("Cooldown", test => {
test.case("applies overheat and cooldown", check => {
let cooldown = new Cooldown();
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), true);
cooldown.configure(2, 3);
check.equals(cooldown.canUse(), true);
cooldown.configure(2, 3);
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), false);
cooldown.use();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), true);
cooldown.cool();
check.equals(cooldown.canUse(), true);
cooldown.configure(1, 0);
check.equals(cooldown.canUse(), true);
cooldown.configure(1, 0);
check.equals(cooldown.canUse(), true);
cooldown.use();
check.equals(cooldown.canUse(), false);
cooldown.use();
check.equals(cooldown.canUse(), false);
cooldown.cool();
check.equals(cooldown.canUse(), true);
});
});
}
cooldown.cool();
check.equals(cooldown.canUse(), true);
});
});

View file

@ -1,93 +1,91 @@
module TK.SpaceTac {
/**
* Cooldown system for equipments
*/
export class Cooldown {
// Number of uses in the current turn
uses = 0
/**
* Cooldown system for equipments
*/
export class Cooldown {
// Number of uses in the current turn
uses = 0
// Accumulated heat to dissipate (number of turns)
heat = 0
// Accumulated heat to dissipate (number of turns)
heat = 0
// Maximum number of uses allowed per turn before overheating (0 for unlimited)
overheat = 0
// Maximum number of uses allowed per turn before overheating (0 for unlimited)
overheat = 0
// Number of "end turn" needed to cooldown when overheated
cooling = 1
// Number of "end turn" needed to cooldown when overheated
cooling = 1
constructor(overheat = 0, cooling = 1) {
this.configure(overheat, cooling);
}
constructor(overheat = 0, cooling = 1) {
this.configure(overheat, cooling);
}
toString(): string {
return `Overheat ${this.overheat} / Cooldown ${this.cooling}`;
}
toString(): string {
return `Overheat ${this.overheat} / Cooldown ${this.cooling}`;
}
/**
* Check if the equipment can be used in regards to heat
*/
canUse(): boolean {
return this.heat == 0;
}
/**
* Check if the equipment can be used in regards to heat
*/
canUse(): boolean {
return this.heat == 0;
}
/**
* Check if the equipment would overheat if used
*/
willOverheat(): boolean {
return this.overheat > 0 && this.uses + 1 >= this.overheat;
}
/**
* Check if the equipment would overheat if used
*/
willOverheat(): boolean {
return this.overheat > 0 && this.uses + 1 >= this.overheat;
}
/**
* Check the number of uses before overheating
*/
getRemainingUses(): number {
if (this.overheat) {
return (this.heat > 0) ? 0 : (this.overheat - this.uses);
} else {
return Infinity;
}
}
/**
* Configure the overheat and cooling
*/
configure(overheat: number, cooling: number) {
this.overheat = overheat;
this.cooling = Math.max(1, cooling);
this.reset();
}
/**
* Use the equipment, increasing the heat
*/
use(times = 1): void {
if (this.overheat) {
this.uses += times;
if (this.uses >= this.overheat) {
this.heat = this.cooling;
} else {
this.heat = 0;
}
}
}
/**
* Apply one cooling-down step if necessary
*/
cool(steps = 1): void {
this.heat = Math.max(this.heat - steps, 0);
if (this.heat == 0) {
this.uses = 0;
}
}
/**
* Reset the cooldown (typically at the end of turn)
*/
reset(): void {
this.uses = 0;
this.heat = 0;
}
/**
* Check the number of uses before overheating
*/
getRemainingUses(): number {
if (this.overheat) {
return (this.heat > 0) ? 0 : (this.overheat - this.uses);
} else {
return Infinity;
}
}
/**
* Configure the overheat and cooling
*/
configure(overheat: number, cooling: number) {
this.overheat = overheat;
this.cooling = Math.max(1, cooling);
this.reset();
}
/**
* Use the equipment, increasing the heat
*/
use(times = 1): void {
if (this.overheat) {
this.uses += times;
if (this.uses >= this.overheat) {
this.heat = this.cooling;
} else {
this.heat = 0;
}
}
}
/**
* Apply one cooling-down step if necessary
*/
cool(steps = 1): void {
this.heat = Math.max(this.heat - steps, 0);
if (this.heat == 0) {
this.uses = 0;
}
}
/**
* Reset the cooldown (typically at the end of turn)
*/
reset(): void {
this.uses = 0;
this.heat = 0;
}
}

View file

@ -1,62 +1,60 @@
module TK.SpaceTac {
testing("Drone", test => {
test.case("applies area effects when deployed", check => {
let battle = TestTools.createBattle();
let ship = nn(battle.playing_ship);
TestTools.setShipModel(ship, 100, 0, 10);
let weapon = new DeployDroneAction("testdrone", { power: 2 }, { deploy_distance: 300, drone_radius: 30, drone_effects: [new AttributeEffect("evasion", 15)] });
ship.actions.addCustom(weapon);
let engine = TestTools.addEngine(ship, 1000);
testing("Drone", test => {
test.case("applies area effects when deployed", check => {
let battle = TestTools.createBattle();
let ship = nn(battle.playing_ship);
TestTools.setShipModel(ship, 100, 0, 10);
let weapon = new DeployDroneAction("testdrone", { power: 2 }, { deploy_distance: 300, drone_radius: 30, drone_effects: [new AttributeEffect("evasion", 15)] });
ship.actions.addCustom(weapon);
let engine = TestTools.addEngine(ship, 1000);
TestTools.actionChain(check, battle, [
[ship, weapon, Target.newFromLocation(150, 50)], // deploy out of effects radius
[ship, engine, Target.newFromLocation(110, 50)], // move out of effects radius
[ship, engine, Target.newFromLocation(130, 50)], // move in effects radius
[ship, weapon, Target.newFromShip(ship)], // recall
[ship, weapon, Target.newFromLocation(130, 70)], // deploy in effects radius
], [
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 10, "power");
check.equals(battle.drones.count(), 0, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 7, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 0, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
]);
});
TestTools.actionChain(check, battle, [
[ship, weapon, Target.newFromLocation(150, 50)], // deploy out of effects radius
[ship, engine, Target.newFromLocation(110, 50)], // move out of effects radius
[ship, engine, Target.newFromLocation(130, 50)], // move in effects radius
[ship, weapon, Target.newFromShip(ship)], // recall
[ship, weapon, Target.newFromLocation(130, 70)], // deploy in effects radius
], [
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 10, "power");
check.equals(battle.drones.count(), 0, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 7, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 0, "drone count");
},
check => {
check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count");
},
]);
});
test.case("builds a textual description", check => {
let drone = new Drone(new Ship());
check.equals(drone.getDescription(), "While deployed:\n• do nothing");
test.case("builds a textual description", check => {
let drone = new Drone(new Ship());
check.equals(drone.getDescription(), "While deployed:\n• do nothing");
drone.effects = [
new DamageEffect(5),
new AttributeEffect("evasion", 1)
]
check.equals(drone.getDescription(), "While deployed:\n• do 5 damage\n• evasion +1");
});
});
}
drone.effects = [
new DamageEffect(5),
new AttributeEffect("evasion", 1)
]
check.equals(drone.getDescription(), "While deployed:\n• do 5 damage\n• evasion +1");
});
});

View file

@ -1,63 +1,70 @@
module TK.SpaceTac {
/**
* Drones are static objects that apply effects in a circular zone around themselves.
*/
export class Drone extends RObject {
// ID of the owning ship
owner: RObjectId
import { ifilter, imaterialize } from "../common/Iterators"
import { RObject, RObjectId } from "../common/RObject"
import { DeployDroneAction } from "./actions/DeployDroneAction"
import { ArenaLocation } from "./ArenaLocation"
import { Battle } from "./Battle"
import { BaseEffect } from "./effects/BaseEffect"
import { Ship } from "./Ship"
import { Target } from "./Target"
// Code of the drone
code: string
/**
* Drones are static objects that apply effects in a circular zone around themselves.
*/
export class Drone extends RObject {
// ID of the owning ship
owner: RObjectId
// Location in arena
x = 0
y = 0
radius = 0
// Code of the drone
code: string
// Effects to apply
effects: BaseEffect[] = []
// Location in arena
x = 0
y = 0
radius = 0
// Action that triggered that drone
parent: DeployDroneAction | null = null;
// Effects to apply
effects: BaseEffect[] = []
constructor(owner: Ship, code = "drone") {
super();
// Action that triggered that drone
parent: DeployDroneAction | null = null;
this.owner = owner.id;
this.code = code;
}
constructor(owner: Ship, code = "drone") {
super();
/**
* Return the current location of the drone
*/
get location(): ArenaLocation {
return new ArenaLocation(this.x, this.y);
}
this.owner = owner.id;
this.code = code;
}
/**
* Get a textual description of this drone
*/
getDescription(): string {
let effects = this.effects.map(effect => "• " + effect.getDescription()).join("\n");
if (effects.length == 0) {
effects = "• do nothing";
}
return `While deployed:\n${effects}`;
}
/**
* Return the current location of the drone
*/
get location(): ArenaLocation {
return new ArenaLocation(this.x, this.y);
}
/**
* Check if a location is in range
*/
isInRange(x: number, y: number): boolean {
return Target.newFromLocation(x, y).getDistanceTo(this) <= this.radius;
}
/**
* Get the list of affected ships.
*/
getAffectedShips(battle: Battle): Ship[] {
let ships = ifilter(battle.iships(), ship => ship.alive && ship.isInCircle(this.x, this.y, this.radius));
return imaterialize(ships);
}
/**
* Get a textual description of this drone
*/
getDescription(): string {
let effects = this.effects.map(effect => "• " + effect.getDescription()).join("\n");
if (effects.length == 0) {
effects = "• do nothing";
}
}
return `While deployed:\n${effects}`;
}
/**
* Check if a location is in range
*/
isInRange(x: number, y: number): boolean {
return Target.newFromLocation(x, y).getDistanceTo(this) <= this.radius;
}
/**
* Get the list of affected ships.
*/
getAffectedShips(battle: Battle): Ship[] {
let ships = ifilter(battle.iships(), ship => ship.alive && ship.isInCircle(this.x, this.y, this.radius));
return imaterialize(ships);
}
}

View file

@ -1,43 +1,46 @@
module TK.SpaceTac.Specs {
testing("ExclusionAreas", test => {
test.case("constructs from a ship or battle", check => {
let battle = new Battle();
battle.border = 17;
battle.ship_separation = 31;
let ship1 = battle.fleets[0].addShip();
ship1.setArenaPosition(12, 5);
let ship2 = battle.fleets[1].addShip();
ship2.setArenaPosition(43, 89);
import { testing } from "../common/Testing";
import { ArenaLocationAngle } from "./ArenaLocation";
import { Battle } from "./Battle";
import { ExclusionAreas } from "./ExclusionAreas";
let exclusion = ExclusionAreas.fromBattle(battle);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]);
testing("ExclusionAreas", test => {
test.case("constructs from a ship or battle", check => {
let battle = new Battle();
battle.border = 17;
battle.ship_separation = 31;
let ship1 = battle.fleets[0].addShip();
ship1.setArenaPosition(12, 5);
let ship2 = battle.fleets[1].addShip();
ship2.setArenaPosition(43, 89);
exclusion = ExclusionAreas.fromBattle(battle, [ship1], 120);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 120);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
let exclusion = ExclusionAreas.fromBattle(battle);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromBattle(battle, [ship2], 10);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]);
exclusion = ExclusionAreas.fromBattle(battle, [ship1], 120);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 120);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromShip(ship1);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromBattle(battle, [ship2], 10);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]);
exclusion = ExclusionAreas.fromShip(ship2, 99);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 99);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]);
exclusion = ExclusionAreas.fromShip(ship1);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromShip(ship2, 10, false);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]);
})
})
}
exclusion = ExclusionAreas.fromShip(ship2, 99);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 99);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]);
exclusion = ExclusionAreas.fromShip(ship2, 10, false);
check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]);
})
})

View file

@ -1,96 +1,101 @@
module TK.SpaceTac {
/**
* Helper for working with exclusion areas (areas where a ship cannot go)
*
* There are three types of exclusion:
* - Hard border exclusion, that prevents a ship from being too close to the battle edges
* - Hard obstacle exclusion, that prevents two ships from being too close to each other
* - Soft obstacle exclusion, usually associated with an engine, that prevents a ship from moving too close to others
*/
export class ExclusionAreas {
xmin: number
xmax: number
ymin: number
ymax: number
active: boolean
hard_border = 50
hard_obstacle = 100
effective_obstacle = this.hard_obstacle
obstacles: ArenaLocation[] = []
import { ifilter, imap, imaterialize } from "../common/Iterators"
import { cmp, contains, sorted } from "../common/Tools"
import { arenaDistance, ArenaLocation } from "./ArenaLocation"
import { Battle } from "./Battle"
import { Ship } from "./Ship"
import { Target } from "./Target"
constructor(width: number, height: number) {
this.xmin = 0;
this.xmax = width - 1;
this.ymin = 0;
this.ymax = height - 1;
this.active = width > 0 && height > 0;
}
/**
* Helper for working with exclusion areas (areas where a ship cannot go)
*
* There are three types of exclusion:
* - Hard border exclusion, that prevents a ship from being too close to the battle edges
* - Hard obstacle exclusion, that prevents two ships from being too close to each other
* - Soft obstacle exclusion, usually associated with an engine, that prevents a ship from moving too close to others
*/
export class ExclusionAreas {
xmin: number
xmax: number
ymin: number
ymax: number
active: boolean
hard_border = 50
hard_obstacle = 100
effective_obstacle = this.hard_obstacle
obstacles: ArenaLocation[] = []
/**
* Build an exclusion helper from a battle.
*/
static fromBattle(battle: Battle, ignore_ships: Ship[] = [], soft_distance = 0): ExclusionAreas {
let result = new ExclusionAreas(battle.width, battle.height);
result.hard_border = battle.border;
result.hard_obstacle = battle.ship_separation;
let obstacles = imap(ifilter(battle.iships(true), ship => !contains(ignore_ships, ship)), ship => ship.location);
result.configure(imaterialize(obstacles), soft_distance);
return result;
}
constructor(width: number, height: number) {
this.xmin = 0;
this.xmax = width - 1;
this.ymin = 0;
this.ymax = height - 1;
this.active = width > 0 && height > 0;
}
/**
* Build an exclusion helper for a ship.
*
* If *ignore_self* is True, the ship will itself not be included in exclusion areas.
*/
static fromShip(ship: Ship, soft_distance = 0, ignore_self = true): ExclusionAreas {
let battle = ship.getBattle();
if (battle) {
return ExclusionAreas.fromBattle(battle, ignore_self ? [ship] : [], soft_distance);
} else {
return new ExclusionAreas(0, 0);
}
}
/**
* Build an exclusion helper from a battle.
*/
static fromBattle(battle: Battle, ignore_ships: Ship[] = [], soft_distance = 0): ExclusionAreas {
let result = new ExclusionAreas(battle.width, battle.height);
result.hard_border = battle.border;
result.hard_obstacle = battle.ship_separation;
let obstacles = imap(ifilter(battle.iships(true), ship => !contains(ignore_ships, ship)), ship => ship.location);
result.configure(imaterialize(obstacles), soft_distance);
return result;
}
/**
* Configure the areas for next check calls.
*/
configure(obstacles: ArenaLocation[], soft_distance: number) {
this.obstacles = obstacles;
this.effective_obstacle = Math.max(soft_distance, this.hard_obstacle);
}
/**
* Keep a location outside exclusion areas, when coming from a source.
*
* It will return the furthest location on the [source, location] segment, that is not inside an exclusion
* area.
*/
stopBefore(location: ArenaLocation, source: ArenaLocation): ArenaLocation {
if (!this.active) {
return location;
}
let target = Target.newFromLocation(location.x, location.y);
// Keep out of arena borders
target = target.keepInsideRectangle(this.xmin + this.hard_border, this.ymin + this.hard_border,
this.xmax - this.hard_border, this.ymax - this.hard_border,
source.x, source.y);
// Apply collision prevention
let obstacles = sorted(this.obstacles, (a, b) => cmp(arenaDistance(a, source), arenaDistance(b, source), true));
obstacles.forEach(s => {
let new_target = target.moveOutOfCircle(s.x, s.y, this.effective_obstacle, source.x, source.y);
if (target != new_target && arenaDistance(s, source) < this.effective_obstacle) {
// Already inside the nearest ship's exclusion area
target = Target.newFromLocation(source.x, source.y);
} else {
target = new_target;
}
});
return new ArenaLocation(target.x, target.y);
}
/**
* Build an exclusion helper for a ship.
*
* If *ignore_self* is True, the ship will itself not be included in exclusion areas.
*/
static fromShip(ship: Ship, soft_distance = 0, ignore_self = true): ExclusionAreas {
let battle = ship.getBattle();
if (battle) {
return ExclusionAreas.fromBattle(battle, ignore_self ? [ship] : [], soft_distance);
} else {
return new ExclusionAreas(0, 0);
}
}
/**
* Configure the areas for next check calls.
*/
configure(obstacles: ArenaLocation[], soft_distance: number) {
this.obstacles = obstacles;
this.effective_obstacle = Math.max(soft_distance, this.hard_obstacle);
}
/**
* Keep a location outside exclusion areas, when coming from a source.
*
* It will return the furthest location on the [source, location] segment, that is not inside an exclusion
* area.
*/
stopBefore(location: ArenaLocation, source: ArenaLocation): ArenaLocation {
if (!this.active) {
return location;
}
let target = Target.newFromLocation(location.x, location.y);
// Keep out of arena borders
target = target.keepInsideRectangle(this.xmin + this.hard_border, this.ymin + this.hard_border,
this.xmax - this.hard_border, this.ymax - this.hard_border,
source.x, source.y);
// Apply collision prevention
let obstacles = sorted(this.obstacles, (a, b) => cmp(arenaDistance(a, source), arenaDistance(b, source), true));
obstacles.forEach(s => {
let new_target = target.moveOutOfCircle(s.x, s.y, this.effective_obstacle, source.x, source.y);
if (target != new_target && arenaDistance(s, source) < this.effective_obstacle) {
// Already inside the nearest ship's exclusion area
target = Target.newFromLocation(source.x, source.y);
} else {
target = new_target;
}
});
return new ArenaLocation(target.x, target.y);
}
}

View file

@ -1,168 +1,174 @@
module TK.SpaceTac {
testing("Fleet", test => {
test.case("get average level", check => {
var fleet = new Fleet();
check.equals(fleet.getLevel(), 0);
import { RObjectId } from "../common/RObject";
import { testing } from "../common/Testing";
import { Battle } from "./Battle";
import { Fleet } from "./Fleet";
import { Ship } from "./Ship";
import { StarLocationType } from "./StarLocation";
import { Universe } from "./Universe";
fleet.addShip(new Ship());
fleet.addShip(new Ship());
fleet.addShip(new Ship());
testing("Fleet", test => {
test.case("get average level", check => {
var fleet = new Fleet();
check.equals(fleet.getLevel(), 0);
fleet.ships[0].level.forceLevel(2);
fleet.ships[1].level.forceLevel(4);
fleet.ships[2].level.forceLevel(7);
check.equals(fleet.getLevel(), 4);
});
fleet.addShip(new Ship());
fleet.addShip(new Ship());
fleet.addShip(new Ship());
test.case("adds and removes ships", check => {
let fleet1 = new Fleet();
let fleet2 = new Fleet();
fleet.ships[0].level.forceLevel(2);
fleet.ships[1].level.forceLevel(4);
fleet.ships[2].level.forceLevel(7);
check.equals(fleet.getLevel(), 4);
});
let ship1 = fleet1.addShip();
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []);
test.case("adds and removes ships", check => {
let fleet1 = new Fleet();
let fleet2 = new Fleet();
let ship2 = new Ship();
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []);
let ship1 = fleet1.addShip();
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []);
fleet2.addShip(ship2);
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, [ship2]);
let ship2 = new Ship();
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []);
fleet1.addShip(ship2);
check.equals(fleet1.ships, [ship1, ship2]);
check.equals(fleet2.ships, []);
fleet2.addShip(ship2);
check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, [ship2]);
fleet1.removeShip(ship1, fleet2);
check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]);
fleet1.addShip(ship2);
check.equals(fleet1.ships, [ship1, ship2]);
check.equals(fleet2.ships, []);
fleet1.removeShip(ship1);
check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]);
fleet1.removeShip(ship1, fleet2);
check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]);
fleet1.removeShip(ship2);
check.equals(fleet1.ships, []);
check.equals(fleet2.ships, [ship1]);
});
fleet1.removeShip(ship1);
check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]);
test.case("changes location, only using jumps to travel between systems", check => {
let fleet = new Fleet();
let universe = new Universe();
let system1 = universe.addStar();
let system2 = universe.addStar();
let jump1 = system1.addLocation(StarLocationType.WARP);
let jump2 = system2.addLocation(StarLocationType.WARP);
jump1.setJumpDestination(jump2);
jump2.setJumpDestination(jump1);
let other1 = system1.addLocation(StarLocationType.PLANET);
universe.updateLocations();
fleet1.removeShip(ship2);
check.equals(fleet1.ships, []);
check.equals(fleet2.ships, [ship1]);
});
let result = fleet.move(other1);
check.in("cannot move from nowhere", check => {
check.equals(result, false);
check.equals(fleet.location, null);
});
test.case("changes location, only using jumps to travel between systems", check => {
let fleet = new Fleet();
let universe = new Universe();
let system1 = universe.addStar();
let system2 = universe.addStar();
let jump1 = system1.addLocation(StarLocationType.WARP);
let jump2 = system2.addLocation(StarLocationType.WARP);
jump1.setJumpDestination(jump2);
jump2.setJumpDestination(jump1);
let other1 = system1.addLocation(StarLocationType.PLANET);
universe.updateLocations();
fleet.setLocation(other1);
check.in("force set to other1", check => {
check.equals(fleet.location, other1.id);
});
result = fleet.move(jump2);
check.in("other1=>jump2", check => {
check.equals(result, false);
check.equals(fleet.location, other1.id);
});
result = fleet.move(jump1);
check.in("other1=>jump1", check => {
check.equals(result, true);
check.equals(fleet.location, jump1.id);
});
result = fleet.move(jump2);
check.in("jump1=>jump2", check => {
check.equals(result, true);
check.equals(fleet.location, jump2.id);
});
result = fleet.move(other1);
check.in("jump2=>other1", check => {
check.equals(result, false);
check.equals(fleet.location, jump2.id);
});
result = fleet.move(jump1);
check.in("jump2=>jump1", check => {
check.equals(result, true);
check.equals(fleet.location, jump1.id);
});
});
test.case("registers presence in locations, and keeps track of visited locations", check => {
let fleet = new Fleet();
let universe = new Universe();
let star = universe.addStar();
let loc1 = star.addLocation(StarLocationType.PLANET);
let loc2 = star.addLocation(StarLocationType.PLANET);
let loc3 = star.addLocation(StarLocationType.PLANET);
universe.updateLocations();
function checks(desc: string, fleets1: Fleet[], fleets2: Fleet[], fleets3: Fleet[], visited: RObjectId[]) {
check.in(desc, check => {
check.equals(loc1.fleets, fleets1, "loc1 fleets");
check.equals(loc2.fleets, fleets2, "loc2 fleets");
check.equals(loc3.fleets, fleets3, "loc3 fleets");
check.equals(fleet.visited, visited, "visited");
});
}
checks("initial", [], [], [], []);
fleet.setLocation(loc1);
checks("first move to loc1", [fleet], [], [], [loc1.id]);
fleet.setLocation(loc1);
checks("already in loc1", [fleet], [], [], [loc1.id]);
fleet.setLocation(loc2);
checks("first move to loc2", [], [fleet], [], [loc2.id, loc1.id]);
fleet.setLocation(loc3);
checks("first move to loc3", [], [], [fleet], [loc3.id, loc2.id, loc1.id]);
fleet.setLocation(loc2);
checks("go back to loc2", [], [fleet], [], [loc2.id, loc3.id, loc1.id]);
});
test.case("checks if a fleet is alive", check => {
let battle = new Battle();
let fleet = battle.fleets[0];
check.equals(fleet.isAlive(), false);
let ship1 = fleet.addShip();
check.equals(fleet.isAlive(), true);
let ship2 = fleet.addShip();
check.equals(fleet.isAlive(), true);
ship1.setDead();
check.equals(fleet.isAlive(), true);
ship2.setDead();
check.equals(fleet.isAlive(), false);
let ship3 = fleet.addShip();
check.equals(fleet.isAlive(), true);
let ship4 = fleet.addShip();
ship4.critical = true;
check.equals(fleet.isAlive(), true);
ship4.setDead();
check.equals(fleet.isAlive(), false);
});
let result = fleet.move(other1);
check.in("cannot move from nowhere", check => {
check.equals(result, false);
check.equals(fleet.location, null);
});
}
fleet.setLocation(other1);
check.in("force set to other1", check => {
check.equals(fleet.location, other1.id);
});
result = fleet.move(jump2);
check.in("other1=>jump2", check => {
check.equals(result, false);
check.equals(fleet.location, other1.id);
});
result = fleet.move(jump1);
check.in("other1=>jump1", check => {
check.equals(result, true);
check.equals(fleet.location, jump1.id);
});
result = fleet.move(jump2);
check.in("jump1=>jump2", check => {
check.equals(result, true);
check.equals(fleet.location, jump2.id);
});
result = fleet.move(other1);
check.in("jump2=>other1", check => {
check.equals(result, false);
check.equals(fleet.location, jump2.id);
});
result = fleet.move(jump1);
check.in("jump2=>jump1", check => {
check.equals(result, true);
check.equals(fleet.location, jump1.id);
});
});
test.case("registers presence in locations, and keeps track of visited locations", check => {
let fleet = new Fleet();
let universe = new Universe();
let star = universe.addStar();
let loc1 = star.addLocation(StarLocationType.PLANET);
let loc2 = star.addLocation(StarLocationType.PLANET);
let loc3 = star.addLocation(StarLocationType.PLANET);
universe.updateLocations();
function checks(desc: string, fleets1: Fleet[], fleets2: Fleet[], fleets3: Fleet[], visited: RObjectId[]) {
check.in(desc, check => {
check.equals(loc1.fleets, fleets1, "loc1 fleets");
check.equals(loc2.fleets, fleets2, "loc2 fleets");
check.equals(loc3.fleets, fleets3, "loc3 fleets");
check.equals(fleet.visited, visited, "visited");
});
}
checks("initial", [], [], [], []);
fleet.setLocation(loc1);
checks("first move to loc1", [fleet], [], [], [loc1.id]);
fleet.setLocation(loc1);
checks("already in loc1", [fleet], [], [], [loc1.id]);
fleet.setLocation(loc2);
checks("first move to loc2", [], [fleet], [], [loc2.id, loc1.id]);
fleet.setLocation(loc3);
checks("first move to loc3", [], [], [fleet], [loc3.id, loc2.id, loc1.id]);
fleet.setLocation(loc2);
checks("go back to loc2", [], [fleet], [], [loc2.id, loc3.id, loc1.id]);
});
test.case("checks if a fleet is alive", check => {
let battle = new Battle();
let fleet = battle.fleets[0];
check.equals(fleet.isAlive(), false);
let ship1 = fleet.addShip();
check.equals(fleet.isAlive(), true);
let ship2 = fleet.addShip();
check.equals(fleet.isAlive(), true);
ship1.setDead();
check.equals(fleet.isAlive(), true);
ship2.setDead();
check.equals(fleet.isAlive(), false);
let ship3 = fleet.addShip();
check.equals(fleet.isAlive(), true);
let ship4 = fleet.addShip();
ship4.critical = true;
check.equals(fleet.isAlive(), true);
ship4.setDead();
check.equals(fleet.isAlive(), false);
});
});

View file

@ -1,151 +1,156 @@
module TK.SpaceTac {
/**
* A fleet of ships, all belonging to the same player
*/
export class Fleet extends RObject {
// Fleet owner
player: Player
import { RObject, RObjectId } from "../common/RObject"
import { add, any, remove } from "../common/Tools"
import { Battle } from "./Battle"
import { Player } from "./Player"
import { Ship } from "./Ship"
import { StarLocation, StarLocationType } from "./StarLocation"
// Fleet name
name: string
/**
* A fleet of ships, all belonging to the same player
*/
export class Fleet extends RObject {
// Fleet owner
player: Player
// List of ships
ships: Ship[]
// Fleet name
name: string
// Current fleet location
location: RObjectId | null = null
// List of ships
ships: Ship[]
// Visited locations (ordered by last visited)
visited: RObjectId[] = []
// Current fleet location
location: RObjectId | null = null
// Current battle in which the fleet is engaged (null if not fighting)
battle: Battle | null = null
// Visited locations (ordered by last visited)
visited: RObjectId[] = []
// Amount of credits available
credits = 0
// Current battle in which the fleet is engaged (null if not fighting)
battle: Battle | null = null
// Create a fleet, bound to a player
constructor(player = new Player()) {
super();
// Amount of credits available
credits = 0
this.player = player;
this.name = player ? player.name : "Fleet";
this.ships = [];
}
// Create a fleet, bound to a player
constructor(player = new Player()) {
super();
jasmineToString(): string {
return `${this.name} [${this.ships.map(ship => ship.getName()).join(",")}]`;
}
this.player = player;
this.name = player ? player.name : "Fleet";
this.ships = [];
}
/**
* Set the owner player
*/
setPlayer(player: Player): void {
this.player = player;
}
jasmineToString(): string {
return `${this.name} [${this.ships.map(ship => ship.getName()).join(",")}]`;
}
/**
* Set a location as visited
*/
setVisited(location: StarLocation): void {
remove(this.visited, location.id);
this.visited.unshift(location.id);
}
/**
* Set the owner player
*/
setPlayer(player: Player): void {
this.player = player;
}
/**
* Move the fleet to another location, checking that the move is physically possible
*
* Returns true on success
*/
move(to: StarLocation): boolean {
if (!this.location) {
return false;
}
/**
* Set a location as visited
*/
setVisited(location: StarLocation): void {
remove(this.visited, location.id);
this.visited.unshift(location.id);
}
let source = to.universe.locations.get(this.location);
if (!source) {
return false;
}
if (source.star != to.star) {
// Need to jump, check conditions
if (source.type != StarLocationType.WARP || source.jump_dest != to) {
return false;
}
}
this.setLocation(to);
return true;
}
/**
* Set the current location of the fleet, without condition
*/
setLocation(location: StarLocation): void {
if (this.location) {
let previous = location.universe.locations.get(this.location);
if (previous) {
previous.removeFleet(this);
}
}
this.location = location.id;
this.setVisited(location);
location.addFleet(this);
}
/**
* Add a ship this fleet
*/
addShip(ship = new Ship(null, `${this.name} ${this.ships.length + 1}`)): Ship {
if (ship.fleet && ship.fleet != this) {
remove(ship.fleet.ships, ship);
}
add(this.ships, ship);
ship.fleet = this;
if (this.battle) {
this.battle.ships.add(ship);
}
return ship;
}
/**
* Remove the ship from this fleet, transferring it to another fleet
*/
removeShip(ship: Ship, fleet = new Fleet()): void {
if (ship.fleet === this) {
fleet.addShip(ship);
}
}
// Set the current battle
setBattle(battle: Battle | null): void {
this.battle = battle;
}
// Get the average level of this fleet
getLevel(): number {
if (this.ships.length === 0) {
return 0;
}
var sum = 0;
this.ships.forEach((ship: Ship) => {
sum += ship.level.get();
});
var avg = sum / this.ships.length;
return Math.floor(avg);
}
/**
* Check if the fleet is considered alive (at least one ship alive, and no critical ship dead)
*/
isAlive(): boolean {
if (any(this.ships, ship => ship.critical && !ship.alive)) {
return false;
} else {
return any(this.ships, ship => ship.alive);
}
}
/**
* Move the fleet to another location, checking that the move is physically possible
*
* Returns true on success
*/
move(to: StarLocation): boolean {
if (!this.location) {
return false;
}
let source = to.universe.locations.get(this.location);
if (!source) {
return false;
}
if (source.star != to.star) {
// Need to jump, check conditions
if (source.type != StarLocationType.WARP || source.jump_dest != to) {
return false;
}
}
this.setLocation(to);
return true;
}
/**
* Set the current location of the fleet, without condition
*/
setLocation(location: StarLocation): void {
if (this.location) {
let previous = location.universe.locations.get(this.location);
if (previous) {
previous.removeFleet(this);
}
}
this.location = location.id;
this.setVisited(location);
location.addFleet(this);
}
/**
* Add a ship this fleet
*/
addShip(ship = new Ship(null, `${this.name} ${this.ships.length + 1}`)): Ship {
if (ship.fleet && ship.fleet != this) {
remove(ship.fleet.ships, ship);
}
add(this.ships, ship);
ship.fleet = this;
if (this.battle) {
this.battle.ships.add(ship);
}
return ship;
}
/**
* Remove the ship from this fleet, transferring it to another fleet
*/
removeShip(ship: Ship, fleet = new Fleet()): void {
if (ship.fleet === this) {
fleet.addShip(ship);
}
}
// Set the current battle
setBattle(battle: Battle | null): void {
this.battle = battle;
}
// Get the average level of this fleet
getLevel(): number {
if (this.ships.length === 0) {
return 0;
}
var sum = 0;
this.ships.forEach((ship: Ship) => {
sum += ship.level.get();
});
var avg = sum / this.ships.length;
return Math.floor(avg);
}
/**
* Check if the fleet is considered alive (at least one ship alive, and no critical ship dead)
*/
isAlive(): boolean {
if (any(this.ships, ship => ship.critical && !ship.alive)) {
return false;
} else {
return any(this.ships, ship => ship.alive);
}
}
}

View file

@ -1,29 +1,34 @@
module TK.SpaceTac {
// Generator of balanced ships to form a fleet
export class FleetGenerator {
// Random generator to use
random: RandomGenerator;
import { RandomGenerator } from "../common/RandomGenerator";
import { range } from "../common/Tools";
import { Fleet } from "./Fleet";
import { ShipModel } from "./models/ShipModel";
import { Player } from "./Player";
import { ShipGenerator } from "./ShipGenerator";
constructor(random = RandomGenerator.global) {
this.random = random;
}
// Generator of balanced ships to form a fleet
export class FleetGenerator {
// Random generator to use
random: RandomGenerator;
/**
* Generate a fleet of a given level
*/
generate(level: number, player?: Player, ship_count = 3, upgrade = false): Fleet {
var fleet = new Fleet(player);
var ship_generator = new ShipGenerator(this.random);
constructor(random = RandomGenerator.global) {
this.random = random;
}
let models = this.random.sample(ShipModel.getDefaultCollection(), ship_count);
/**
* Generate a fleet of a given level
*/
generate(level: number, player?: Player, ship_count = 3, upgrade = false): Fleet {
var fleet = new Fleet(player);
var ship_generator = new ShipGenerator(this.random);
range(ship_count).forEach(i => {
var ship = ship_generator.generate(level, models[i] || null, upgrade);
ship.name = ship.model.name;
fleet.addShip(ship);
});
let models = this.random.sample(ShipModel.getDefaultCollection(), ship_count);
return fleet;
}
}
range(ship_count).forEach(i => {
var ship = ship_generator.generate(level, models[i] || null, upgrade);
ship.name = ship.model.name;
fleet.addShip(ship);
});
return fleet;
}
}

View file

@ -1,148 +1,154 @@
module TK.SpaceTac.Specs {
testing("GameSession", test => {
/**
* Compare two sessions
*/
function compare(session1: GameSession, session2: GameSession) {
test.check.equals(session1, session2);
}
import { SkewedRandomGenerator } from "../common/RandomGenerator";
import { RObjectContainer } from "../common/RObject";
import { testing } from "../common/Testing";
import { nn } from "../common/Tools";
import { Fleet } from "./Fleet";
import { GameSession } from "./GameSession";
import { StarLocation, StarLocationType } from "./StarLocation";
/**
* Apply deterministic game steps
*/
function applyGameSteps(session: GameSession): void {
var battle = nn(session.getBattle());
battle.advanceToNextShip();
// TODO Make some fixed moves (AI?)
battle.endBattle(battle.fleets[0]);
}
testing("GameSession", test => {
/**
* Compare two sessions
*/
function compare(session1: GameSession, session2: GameSession) {
test.check.equals(session1, session2);
}
test.case("serializes to a string", check => {
var session = new GameSession();
session.startQuickBattle();
/**
* Apply deterministic game steps
*/
function applyGameSteps(session: GameSession): void {
var battle = nn(session.getBattle());
battle.advanceToNextShip();
// TODO Make some fixed moves (AI?)
battle.endBattle(battle.fleets[0]);
}
// Dump and reload
var dumped = session.saveToString();
var loaded_session = GameSession.loadFromString(dumped);
test.case("serializes to a string", check => {
var session = new GameSession();
session.startQuickBattle();
// Check equality
compare(loaded_session, session);
// Dump and reload
var dumped = session.saveToString();
var loaded_session = GameSession.loadFromString(dumped);
// Apply game steps
applyGameSteps(session);
applyGameSteps(loaded_session);
// Check equality
compare(loaded_session, session);
// Check equality after game steps
compare(loaded_session, session);
});
// Apply game steps
applyGameSteps(session);
applyGameSteps(loaded_session);
test.case("generates a quick battle", check => {
var session = new GameSession();
session.startQuickBattle();
// Check equality after game steps
compare(loaded_session, session);
});
check.same(session.getBattle(), session.player.fleet.battle, "battle");
check.same(session.player.fleet, session.fleet, "fleet");
check.same(nn(session.getBattle()).fleets[0], session.fleet, "attacker fleet");
});
test.case("generates a quick battle", check => {
var session = new GameSession();
session.startQuickBattle();
test.case("applies battle outcome to bound player", check => {
let session = new GameSession();
check.equals(session.getBattle(), null);
check.same(session.getBattle(), session.player.fleet.battle, "battle");
check.same(session.player.fleet, session.fleet, "fleet");
check.same(nn(session.getBattle()).fleets[0], session.fleet, "attacker fleet");
});
let location1 = new StarLocation();
let location2 = new StarLocation(location1.star);
session.universe.locations = new RObjectContainer([location1, location2]);
test.case("applies battle outcome to bound player", check => {
let session = new GameSession();
check.equals(session.getBattle(), null);
// Victory case
location1.encounter = new Fleet();
session.player.fleet.setLocation(location1);
check.notequals(session.getBattle(), null);
check.notequals(location1.encounter, null);
let location1 = new StarLocation();
let location2 = new StarLocation(location1.star);
session.universe.locations = new RObjectContainer([location1, location2]);
let battle = nn(session.getBattle());
battle.endBattle(session.player.fleet);
session.setBattleEnded();
check.notequals(session.getBattle(), null);
check.equals(location1.encounter, null);
// Victory case
location1.encounter = new Fleet();
session.player.fleet.setLocation(location1);
check.notequals(session.getBattle(), null);
check.notequals(location1.encounter, null);
// Defeat case
location2.encounter = new Fleet();
session.player.fleet.setLocation(location2);
check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null);
let battle = nn(session.getBattle());
battle.endBattle(session.player.fleet);
session.setBattleEnded();
check.notequals(session.getBattle(), null);
check.equals(location1.encounter, null);
battle = nn(session.getBattle());
battle.endBattle(null);
session.setBattleEnded();
check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null);
});
// Defeat case
location2.encounter = new Fleet();
session.player.fleet.setLocation(location2);
check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null);
test.case("generates a new campaign", check => {
let session = new GameSession();
battle = nn(session.getBattle());
battle.endBattle(null);
session.setBattleEnded();
check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null);
});
session.startNewGame(false);
check.notequals(session.player, null);
check.equals(session.player.fleet.ships.length, 0);
check.equals(session.player.fleet.credits, 0);
check.equals(session.universe.stars.length, 50);
check.equals(session.getBattle(), null);
check.equals(session.start_location.shop, null);
check.equals(session.start_location.encounter, null);
check.equals(session.start_location.encounter_gen, true);
test.case("generates a new campaign", check => {
let session = new GameSession();
session.setCampaignFleet();
check.equals(session.player.fleet.ships.length, 2);
check.equals(session.player.fleet.credits, 0);
check.equals(session.player.fleet.location, session.start_location.id);
});
session.startNewGame(false);
check.notequals(session.player, null);
check.equals(session.player.fleet.ships.length, 0);
check.equals(session.player.fleet.credits, 0);
check.equals(session.universe.stars.length, 50);
check.equals(session.getBattle(), null);
check.equals(session.start_location.shop, null);
check.equals(session.start_location.encounter, null);
check.equals(session.start_location.encounter_gen, true);
test.case("can revert battle", check => {
let session = new GameSession();
let star = session.universe.addStar();
let loc1 = star.addLocation(StarLocationType.PLANET);
loc1.clearEncounter();
let loc2 = star.addLocation(StarLocationType.PLANET);
loc2.encounter_random = new SkewedRandomGenerator([0], true);
session.universe.updateLocations();
session.setCampaignFleet();
check.equals(session.player.fleet.ships.length, 2);
check.equals(session.player.fleet.credits, 0);
check.equals(session.player.fleet.location, session.start_location.id);
});
session.fleet.setLocation(loc1);
check.in("init in loc1", check => {
check.equals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc1.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), false, "visited");
});
test.case("can revert battle", check => {
let session = new GameSession();
let star = session.universe.addStar();
let loc1 = star.addLocation(StarLocationType.PLANET);
loc1.clearEncounter();
let loc2 = star.addLocation(StarLocationType.PLANET);
loc2.encounter_random = new SkewedRandomGenerator([0], true);
session.universe.updateLocations();
session.fleet.setLocation(loc2);
check.in("move to loc2", check => {
check.notequals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc2.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
});
let enemy = loc2.encounter;
session.revertBattle();
check.in("reverted", check => {
check.equals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc1.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
});
session.fleet.setLocation(loc2);
check.in("move to loc2 again", check => {
check.notequals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc2.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
check.same(nn(session.getBattle()).fleets[1], nn(enemy), "same enemy");
});
});
/*test.case("can generate lots of new games", check => {
range(20).forEach(() => {
let session = new GameSession();
session.startNewGame();
check.equals(session.universe.stars.length, 50);
});
});*/
session.fleet.setLocation(loc1);
check.in("init in loc1", check => {
check.equals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc1.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), false, "visited");
});
}
session.fleet.setLocation(loc2);
check.in("move to loc2", check => {
check.notequals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc2.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
});
let enemy = loc2.encounter;
session.revertBattle();
check.in("reverted", check => {
check.equals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc1.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
});
session.fleet.setLocation(loc2);
check.in("move to loc2 again", check => {
check.notequals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc2.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited");
check.same(nn(session.getBattle()).fleets[1], nn(enemy), "same enemy");
});
});
/*test.case("can generate lots of new games", check => {
range(20).forEach(() => {
let session = new GameSession();
session.startNewGame();
check.equals(session.universe.stars.length, 50);
});
});*/
});

View file

@ -1,205 +1,215 @@
module TK.SpaceTac {
/**
* A game session, binding a universe and a player
*
* This represents the current state of game
*/
export class GameSession {
// "Hopefully" unique session id
id: string
import { NAMESPACE } from ".."
import { iforeach } from "../common/Iterators"
import { RandomGenerator } from "../common/RandomGenerator"
import { Serializer } from "../common/Serializer"
import { Battle } from "./Battle"
import { Fleet } from "./Fleet"
import { FleetGenerator } from "./FleetGenerator"
import { PersonalityReactions } from "./PersonalityReactions"
import { Player } from "./Player"
import { StarLocation } from "./StarLocation"
import { Universe } from "./Universe"
// Game universe
universe: Universe
/**
* A game session, binding a universe and a player
*
* This represents the current state of game
*/
export class GameSession {
// "Hopefully" unique session id
id: string
// Current connected player
player: Player
// Game universe
universe: Universe
// Personality reactions
reactions: PersonalityReactions
// Current connected player
player: Player
// Starting location
start_location: StarLocation
// Personality reactions
reactions: PersonalityReactions
// Indicator that the session is the primary one
primary = true
// Starting location
start_location: StarLocation
// Indicator of spectator mode
spectator = false
// Indicator that the session is the primary one
primary = true
// Indicator that introduction has been watched
introduced = false
// Indicator of spectator mode
spectator = false
constructor() {
this.id = RandomGenerator.global.id(20);
this.universe = new Universe();
this.player = new Player();
this.reactions = new PersonalityReactions();
this.start_location = new StarLocation();
}
// Indicator that introduction has been watched
introduced = false
/**
* Get the currently played fleet
*/
get fleet(): Fleet {
return this.player.fleet;
}
constructor() {
this.id = RandomGenerator.global.id(20);
this.universe = new Universe();
this.player = new Player();
this.reactions = new PersonalityReactions();
this.start_location = new StarLocation();
}
/**
* Get an indicative description of the session (to help identify game saves)
*/
getDescription(): string {
let level = this.player.fleet.getLevel();
let ships = this.player.fleet.ships.length;
return `Level ${level} - ${ships} ships`;
}
/**
* Get the currently played fleet
*/
get fleet(): Fleet {
return this.player.fleet;
}
// Load a game state from a string
static loadFromString(serialized: string): GameSession {
var serializer = new Serializer(TK.SpaceTac);
return <GameSession>serializer.unserialize(serialized);
}
/**
* Get an indicative description of the session (to help identify game saves)
*/
getDescription(): string {
let level = this.player.fleet.getLevel();
let ships = this.player.fleet.ships.length;
return `Level ${level} - ${ships} ships`;
}
// Serializes the game state to a string
saveToString(): string {
var serializer = new Serializer(TK.SpaceTac);
return serializer.serialize(this);
}
// Load a game state from a string
static loadFromString(serialized: string): GameSession {
var serializer = new Serializer(NAMESPACE);
return <GameSession>serializer.unserialize(serialized);
}
/**
* Generate a real single player game (campaign)
*
* If *fleet* is false, the player fleet will be empty, and needs to be set with *setCampaignFleet*.
*/
startNewGame(fleet = true, story = false): void {
this.universe = new Universe();
this.universe.generate();
// Serializes the game state to a string
saveToString(): string {
var serializer = new Serializer(NAMESPACE);
return serializer.serialize(this);
}
this.start_location = this.universe.getStartLocation();
this.start_location.clearEncounter();
this.start_location.removeShop();
/**
* Generate a real single player game (campaign)
*
* If *fleet* is false, the player fleet will be empty, and needs to be set with *setCampaignFleet*.
*/
startNewGame(fleet = true, story = false): void {
this.universe = new Universe();
this.universe.generate();
this.player = new Player();
this.start_location = this.universe.getStartLocation();
this.start_location.clearEncounter();
this.start_location.removeShop();
this.reactions = new PersonalityReactions();
this.player = new Player();
if (fleet) {
this.setCampaignFleet(null, story);
}
}
this.reactions = new PersonalityReactions();
/**
* Set the initial campaign fleet, null for a default fleet
*
* If *story* is true, the main story arc will be started.
*/
setCampaignFleet(fleet: Fleet | null = null, story = true) {
if (fleet) {
this.player.setFleet(fleet);
} else {
let fleet_generator = new FleetGenerator();
this.player.fleet = fleet_generator.generate(1, this.player, 2);
}
this.player.fleet.setLocation(this.start_location);
if (story) {
this.player.missions.startMainStory(this.universe, this.player.fleet);
}
}
/**
* Start a new "quick battle" game
*/
startQuickBattle(with_ai: boolean = false, level?: number, shipcount?: number): void {
this.player = new Player();
this.universe = new Universe();
let battle = Battle.newQuickRandom(true, level || RandomGenerator.global.randInt(1, 10), shipcount);
this.player.setFleet(battle.fleets[0]);
this.player.setBattle(battle);
this.reactions = new PersonalityReactions();
}
/**
* Get currently played battle, null when none is in progress
*/
getBattle(): Battle | null {
return this.player.getBattle();
}
/**
* Get the main fleet's location
*/
getLocation(): StarLocation {
return this.universe.getLocation(this.player.fleet.location) || new StarLocation();
}
/**
* Set the end of current battle.
*
* This will reset the fleet, grant experience, and create loot.
*
* The battle will still be bound to the session (exitBattle or revertBattle should be called after).
*/
setBattleEnded() {
let battle = this.getBattle();
if (battle && battle.ended && battle.outcome) {
// Generate experience
battle.outcome.grantExperience(battle.fleets);
// Reset ships status
iforeach(battle.iships(), ship => ship.restoreInitialState());
// If the battle happened in a star location, keep it informed
let location = this.universe.getLocation(this.player.fleet.location);
if (location) {
location.resolveEncounter(battle.outcome);
}
}
}
/**
* Exit the current battle unconditionally, if any
*
* This does not apply retreat penalties, or battle outcome, only unbind the battle from current session
*/
exitBattle(): void {
this.player.setBattle(null);
}
/**
* Revert current battle, and put the player's fleet to its previous location, as if the battle never happened
*/
revertBattle(): void {
this.exitBattle();
let previous_location = this.universe.getLocation(this.fleet.visited[1]);
if (previous_location) {
this.fleet.setLocation(previous_location);
}
}
/**
* Returns true if the session has an universe to explore (campaign mode)
*/
hasUniverse(): boolean {
return this.universe.stars.length > 0;
}
/**
* Returns true if initial fleet creation has been done.
*/
isFleetCreated(): boolean {
return this.player.fleet.ships.length > 0;
}
/**
* Returns true if campaign introduction has been watched
*/
isIntroViewed(): boolean {
return this.introduced;
}
if (fleet) {
this.setCampaignFleet(null, story);
}
}
/**
* Set the initial campaign fleet, null for a default fleet
*
* If *story* is true, the main story arc will be started.
*/
setCampaignFleet(fleet: Fleet | null = null, story = true) {
if (fleet) {
this.player.setFleet(fleet);
} else {
let fleet_generator = new FleetGenerator();
this.player.fleet = fleet_generator.generate(1, this.player, 2);
}
this.player.fleet.setLocation(this.start_location);
if (story) {
this.player.missions.startMainStory(this.universe, this.player.fleet);
}
}
/**
* Start a new "quick battle" game
*/
startQuickBattle(with_ai: boolean = false, level?: number, shipcount?: number): void {
this.player = new Player();
this.universe = new Universe();
let battle = Battle.newQuickRandom(true, level || RandomGenerator.global.randInt(1, 10), shipcount);
this.player.setFleet(battle.fleets[0]);
this.player.setBattle(battle);
this.reactions = new PersonalityReactions();
}
/**
* Get currently played battle, null when none is in progress
*/
getBattle(): Battle | null {
return this.player.getBattle();
}
/**
* Get the main fleet's location
*/
getLocation(): StarLocation {
return this.universe.getLocation(this.player.fleet.location) || new StarLocation();
}
/**
* Set the end of current battle.
*
* This will reset the fleet, grant experience, and create loot.
*
* The battle will still be bound to the session (exitBattle or revertBattle should be called after).
*/
setBattleEnded() {
let battle = this.getBattle();
if (battle && battle.ended && battle.outcome) {
// Generate experience
battle.outcome.grantExperience(battle.fleets);
// Reset ships status
iforeach(battle.iships(), ship => ship.restoreInitialState());
// If the battle happened in a star location, keep it informed
let location = this.universe.getLocation(this.player.fleet.location);
if (location) {
location.resolveEncounter(battle.outcome);
}
}
}
/**
* Exit the current battle unconditionally, if any
*
* This does not apply retreat penalties, or battle outcome, only unbind the battle from current session
*/
exitBattle(): void {
this.player.setBattle(null);
}
/**
* Revert current battle, and put the player's fleet to its previous location, as if the battle never happened
*/
revertBattle(): void {
this.exitBattle();
let previous_location = this.universe.getLocation(this.fleet.visited[1]);
if (previous_location) {
this.fleet.setLocation(previous_location);
}
}
/**
* Returns true if the session has an universe to explore (campaign mode)
*/
hasUniverse(): boolean {
return this.universe.stars.length > 0;
}
/**
* Returns true if initial fleet creation has been done.
*/
isFleetCreated(): boolean {
return this.player.fleet.ships.length > 0;
}
/**
* Returns true if campaign introduction has been watched
*/
isIntroViewed(): boolean {
return this.introduced;
}
}

View file

@ -1,207 +1,225 @@
module TK.SpaceTac.Specs {
testing("MoveFireSimulator", test => {
import { iarray, imaterialize } from "../common/Iterators";
import { testing } from "../common/Testing";
import { nn } from "../common/Tools";
import { BaseAction } from "./actions/BaseAction";
import { MoveAction } from "./actions/MoveAction";
import { TriggerAction } from "./actions/TriggerAction";
import { ArenaLocationAngle } from "./ArenaLocation";
import { Battle } from "./Battle";
import { EndBattleDiff } from "./diffs/EndBattleDiff";
import { ProjectileFiredDiff } from "./diffs/ProjectileFiredDiff";
import { ShipActionUsedDiff } from "./diffs/ShipActionUsedDiff";
import { ShipDamageDiff } from "./diffs/ShipDamageDiff";
import { ShipDeathDiff } from "./diffs/ShipDeathDiff";
import { ShipMoveDiff } from "./diffs/ShipMoveDiff";
import { ShipValueDiff } from "./diffs/ShipValueDiff";
import { ApproachSimulationError, MoveFireSimulator } from "./MoveFireSimulator";
import { Ship } from "./Ship";
import { Target } from "./Target";
import { TestTools } from "./TestTools";
function simpleWeaponCase(distance = 10, ship_ap = 5, weapon_ap = 3, engine_distance = 5): [Ship, MoveFireSimulator, BaseAction] {
let ship = new Ship();
TestTools.setShipModel(ship, 100, 0, ship_ap);
TestTools.addEngine(ship, engine_distance);
let action = new TriggerAction("weapon", { power: weapon_ap, range: distance });
let simulator = new MoveFireSimulator(ship);
return [ship, simulator, action];
}
testing("MoveFireSimulator", test => {
test.case("finds a suitable engine to make an approach", check => {
let ship = new Ship();
let simulator = new MoveFireSimulator(ship);
check.equals(simulator.findEngine(), null, "no engine");
let engine1 = TestTools.addEngine(ship, 100);
engine1.configureCooldown(1, 1);
check.same(simulator.findEngine(), engine1, "one engine");
let engine2 = TestTools.addEngine(ship, 120);
engine2.configureCooldown(1, 1);
check.same(simulator.findEngine(), engine1, "two engines, choose first one");
ship.actions.storeUsage(engine1);
check.same(simulator.findEngine(), engine2, "first engine overheated, choose second one");
ship.actions.storeUsage(engine2);
check.equals(simulator.findEngine(), null, "both engines overheated");
});
function simpleWeaponCase(distance = 10, ship_ap = 5, weapon_ap = 3, engine_distance = 5): [Ship, MoveFireSimulator, BaseAction] {
let ship = new Ship();
TestTools.setShipModel(ship, 100, 0, ship_ap);
TestTools.addEngine(ship, engine_distance);
let action = new TriggerAction("weapon", { power: weapon_ap, range: distance });
let simulator = new MoveFireSimulator(ship);
return [ship, simulator, action];
}
test.case("fires directly when in range", check => {
let [ship, simulator, action] = simpleWeaponCase();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null));
test.case("finds a suitable engine to make an approach", check => {
let ship = new Ship();
let simulator = new MoveFireSimulator(ship);
check.equals(simulator.findEngine(), null, "no engine");
let engine1 = TestTools.addEngine(ship, 100);
engine1.configureCooldown(1, 1);
check.same(simulator.findEngine(), engine1, "one engine");
let engine2 = TestTools.addEngine(ship, 120);
engine2.configureCooldown(1, 1);
check.same(simulator.findEngine(), engine1, "two engines, choose first one");
ship.actions.storeUsage(engine1);
check.same(simulator.findEngine(), engine2, "first engine overheated, choose second one");
ship.actions.storeUsage(engine2);
check.equals(simulator.findEngine(), null, "both engines overheated");
});
check.same(result.success, true, 'success');
check.same(result.need_move, false, 'need_move');
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, true, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
test.case("fires directly when in range", check => {
let [ship, simulator, action] = simpleWeaponCase();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null));
check.equals(result.parts, [
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: true }
]);
});
check.same(result.success, true, 'success');
check.same(result.need_move, false, 'need_move');
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, true, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
test.case("can't fire when in range, but not enough AP", check => {
let [ship, simulator, action] = simpleWeaponCase(10, 2, 3);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, false, 'need_move');
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, false, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
check.equals(result.parts, [
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: true }
]);
});
check.equals(result.parts, [
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: false }
]);
});
test.case("can't fire when in range, but not enough AP", check => {
let [ship, simulator, action] = simpleWeaponCase(10, 2, 3);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, false, 'need_move');
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, false, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
test.case("moves straight to get within range", check => {
let [ship, simulator, action] = simpleWeaponCase();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 15, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, true, 'need_move');
check.same(result.can_end_move, true, 'can_end_move');
check.equals(result.move_location, new Target(ship.arena_x + 5, ship.arena_y, null));
check.equals(result.total_move_ap, 1);
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, true, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
check.equals(result.parts, [
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: false }
]);
});
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
check.equals(result.parts, [
{ action: move_action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 1, possible: true },
{ action: action, target: new Target(ship.arena_x + 15, ship.arena_y, null), ap: 3, possible: true }
]);
});
test.case("moves straight to get within range", check => {
let [ship, simulator, action] = simpleWeaponCase();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 15, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, true, 'need_move');
check.same(result.can_end_move, true, 'can_end_move');
check.equals(result.move_location, new Target(ship.arena_x + 5, ship.arena_y, null));
check.equals(result.total_move_ap, 1);
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, true, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
test.case("scans a circle for move targets", check => {
let simulator = new MoveFireSimulator(new Ship());
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
check.equals(result.parts, [
{ action: move_action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 1, possible: true },
{ action: action, target: new Target(ship.arena_x + 15, ship.arena_y, null), ap: 3, possible: true }
]);
});
let result = simulator.scanCircle(50, 30, 10, 1, 1);
check.equals(imaterialize(result), [
new Target(50, 30)
]);
test.case("scans a circle for move targets", check => {
let simulator = new MoveFireSimulator(new Ship());
result = simulator.scanCircle(50, 30, 10, 2, 1);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(60, 30)
]);
let result = simulator.scanCircle(50, 30, 10, 1, 1);
check.equals(imaterialize(result), [
new Target(50, 30)
]);
result = simulator.scanCircle(50, 30, 10, 2, 2);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(60, 30),
new Target(40, 30)
]);
result = simulator.scanCircle(50, 30, 10, 2, 1);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(60, 30)
]);
result = simulator.scanCircle(50, 30, 10, 3, 4);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(55, 30),
new Target(45, 30),
new Target(60, 30),
new Target(50, 40),
new Target(40, 30),
new Target(50, 20)
]);
});
result = simulator.scanCircle(50, 30, 10, 2, 2);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(60, 30),
new Target(40, 30)
]);
test.case("accounts for exclusion areas for the approach", check => {
let [ship, simulator, action] = simpleWeaponCase(100, 5, 1, 50);
ship.setArenaPosition(300, 200);
let battle = new Battle();
battle.fleets[0].addShip(ship);
let ship1 = battle.fleets[0].addShip();
let moveaction = nn(simulator.findEngine());
(<any>moveaction).safety_distance = 30;
battle.ship_separation = 30;
result = simulator.scanCircle(50, 30, 10, 3, 4);
check.equals(imaterialize(result), [
new Target(50, 30),
new Target(55, 30),
new Target(45, 30),
new Target(60, 30),
new Target(50, 40),
new Target(40, 30),
new Target(50, 20)
]);
});
check.same(simulator.getApproach(moveaction, Target.newFromLocation(350, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED);
check.same(simulator.getApproach(moveaction, Target.newFromLocation(400, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED);
check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(400, 200));
test.case("accounts for exclusion areas for the approach", check => {
let [ship, simulator, action] = simpleWeaponCase(100, 5, 1, 50);
ship.setArenaPosition(300, 200);
let battle = new Battle();
battle.fleets[0].addShip(ship);
let ship1 = battle.fleets[0].addShip();
let moveaction = nn(simulator.findEngine());
(<any>moveaction).safety_distance = 30;
battle.ship_separation = 30;
ship1.setArenaPosition(420, 200);
check.same(simulator.getApproach(moveaction, Target.newFromLocation(350, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED);
check.same(simulator.getApproach(moveaction, Target.newFromLocation(400, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED);
check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(400, 200));
check.patch(simulator, "scanCircle", () => iarray([
new Target(400, 200),
new Target(410, 200),
new Target(410, 230),
new Target(420, 210),
new Target(480, 260),
]));
check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(410, 230));
});
ship1.setArenaPosition(420, 200);
test.case("moves to get in range, even if not enough AP to fire", check => {
let [ship, simulator, action] = simpleWeaponCase(8, 3, 2, 5);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 18, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, true, 'need_move');
check.same(result.can_end_move, true, 'can_end_move');
check.equals(result.move_location, new Target(ship.arena_x + 10, ship.arena_y, null));
check.equals(result.total_move_ap, 2);
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, false, 'can_fire');
check.same(result.total_fire_ap, 2, 'total_fire_ap');
check.patch(simulator, "scanCircle", () => iarray([
new Target(400, 200),
new Target(410, 200),
new Target(410, 230),
new Target(420, 210),
new Target(480, 260),
]));
check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(410, 230));
});
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
check.equals(result.parts, [
{ action: move_action, target: new Target(ship.arena_x + 10, ship.arena_y, null), ap: 2, possible: true },
{ action: action, target: new Target(ship.arena_x + 18, ship.arena_y, null), ap: 2, possible: false }
]);
});
test.case("moves to get in range, even if not enough AP to fire", check => {
let [ship, simulator, action] = simpleWeaponCase(8, 3, 2, 5);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 18, ship.arena_y, null));
check.same(result.success, true, 'success');
check.same(result.need_move, true, 'need_move');
check.same(result.can_end_move, true, 'can_end_move');
check.equals(result.move_location, new Target(ship.arena_x + 10, ship.arena_y, null));
check.equals(result.total_move_ap, 2);
check.same(result.need_fire, true, 'need_fire');
check.same(result.can_fire, false, 'can_fire');
check.same(result.total_fire_ap, 2, 'total_fire_ap');
test.case("does nothing if trying to move in the same spot", check => {
let [ship, simulator, action] = simpleWeaponCase();
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
let result = simulator.simulateAction(move_action, new Target(ship.arena_x, ship.arena_y, null));
check.equals(result.success, false);
check.equals(result.need_move, false);
check.equals(result.need_fire, false);
check.equals(result.parts, []);
});
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
check.equals(result.parts, [
{ action: move_action, target: new Target(ship.arena_x + 10, ship.arena_y, null), ap: 2, possible: true },
{ action: action, target: new Target(ship.arena_x + 18, ship.arena_y, null), ap: 2, possible: false }
]);
});
test.case("does not move if already in range, even if in the safety margin", check => {
let [ship, simulator, action] = simpleWeaponCase(100);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 97, ship.arena_y, null), 5);
check.equals(result.success, true);
check.equals(result.need_move, false);
result = simulator.simulateAction(action, new Target(ship.arena_x + 101, ship.arena_y, null), 5);
check.equals(result.success, true);
check.equals(result.need_move, true);
check.equals(result.move_location, new Target(ship.arena_x + 6, ship.arena_y));
});
test.case("does nothing if trying to move in the same spot", check => {
let [ship, simulator, action] = simpleWeaponCase();
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
let result = simulator.simulateAction(move_action, new Target(ship.arena_x, ship.arena_y, null));
check.equals(result.success, false);
check.equals(result.need_move, false);
check.equals(result.need_fire, false);
check.equals(result.parts, []);
});
test.case("simulates the results on a fake battle, to provide a list of expected diffs", check => {
let battle = TestTools.createBattle();
let ship = battle.fleets[0].ships[0];
let enemy = battle.fleets[1].ships[0];
ship.setArenaPosition(100, 100);
enemy.setArenaPosition(300, 100);
TestTools.setShipModel(ship, 1, 1, 3);
TestTools.setShipModel(enemy, 2, 1);
let engine = TestTools.addEngine(ship, 80);
let weapon = TestTools.addWeapon(ship, 5, 1, 150);
let simulator = new MoveFireSimulator(ship);
let result = simulator.simulateAction(weapon, Target.newFromShip(enemy), 5);
let diffs = simulator.getExpectedDiffs(nn(ship.getBattle()), result);
check.equals(diffs, [
new ShipActionUsedDiff(ship, engine, Target.newFromLocation(155, 100)),
new ShipValueDiff(ship, "power", -1),
new ShipMoveDiff(ship, ship.location, new ArenaLocationAngle(155, 100), engine),
new ShipActionUsedDiff(ship, weapon, Target.newFromShip(enemy)),
new ShipValueDiff(ship, "power", -1),
new ProjectileFiredDiff(ship, weapon, Target.newFromShip(enemy)),
new ShipDamageDiff(enemy, 2, 1, 0, 5),
new ShipValueDiff(enemy, "shield", -1),
new ShipValueDiff(enemy, "hull", -2),
new ShipDeathDiff(battle, enemy),
new EndBattleDiff(battle.fleets[0], 0)
]);
test.case("does not move if already in range, even if in the safety margin", check => {
let [ship, simulator, action] = simpleWeaponCase(100);
let result = simulator.simulateAction(action, new Target(ship.arena_x + 97, ship.arena_y, null), 5);
check.equals(result.success, true);
check.equals(result.need_move, false);
result = simulator.simulateAction(action, new Target(ship.arena_x + 101, ship.arena_y, null), 5);
check.equals(result.success, true);
check.equals(result.need_move, true);
check.equals(result.move_location, new Target(ship.arena_x + 6, ship.arena_y));
});
check.equals(enemy.getValue("hull"), 2);
check.equals(enemy.getValue("hull"), 2);
});
});
}
test.case("simulates the results on a fake battle, to provide a list of expected diffs", check => {
let battle = TestTools.createBattle();
let ship = battle.fleets[0].ships[0];
let enemy = battle.fleets[1].ships[0];
ship.setArenaPosition(100, 100);
enemy.setArenaPosition(300, 100);
TestTools.setShipModel(ship, 1, 1, 3);
TestTools.setShipModel(enemy, 2, 1);
let engine = TestTools.addEngine(ship, 80);
let weapon = TestTools.addWeapon(ship, 5, 1, 150);
let simulator = new MoveFireSimulator(ship);
let result = simulator.simulateAction(weapon, Target.newFromShip(enemy), 5);
let diffs = simulator.getExpectedDiffs(nn(ship.getBattle()), result);
check.equals(diffs, [
new ShipActionUsedDiff(ship, engine, Target.newFromLocation(155, 100)),
new ShipValueDiff(ship, "power", -1),
new ShipMoveDiff(ship, ship.location, new ArenaLocationAngle(155, 100), engine),
new ShipActionUsedDiff(ship, weapon, Target.newFromShip(enemy)),
new ShipValueDiff(ship, "power", -1),
new ProjectileFiredDiff(ship, weapon, Target.newFromShip(enemy)),
new ShipDamageDiff(enemy, 2, 1, 0, 5),
new ShipValueDiff(enemy, "shield", -1),
new ShipValueDiff(enemy, "hull", -2),
new ShipDeathDiff(battle, enemy),
new EndBattleDiff(battle.fleets[0], 0)
]);
check.equals(enemy.getValue("hull"), 2);
check.equals(enemy.getValue("hull"), 2);
});
});

View file

@ -1,211 +1,220 @@
module TK.SpaceTac {
/**
* Error codes for approach simulation
*/
export enum ApproachSimulationError {
NO_MOVE_NEEDED,
NO_VECTOR_FOUND,
}
import { NAMESPACE } from ".."
import { ichainit, iforeach, imap, irepeat, istep } from "../common/Iterators"
import { cfilter, duplicate, first, minBy, nn } from "../common/Tools"
import { BaseAction } from "./actions/BaseAction"
import { MoveAction } from "./actions/MoveAction"
import { arenaDistance } from "./ArenaLocation"
import { Battle } from "./Battle"
import { BaseBattleDiff } from "./diffs/BaseBattleDiff"
import { Ship } from "./Ship"
import { Target } from "./Target"
/**
* A single action in the sequence result from the simulator
*/
export type MoveFirePart = {
action: BaseAction
target: Target
ap: number
possible: boolean
}
/**
* A simulation result
*/
export class MoveFireResult {
// Simulation success, false only if no route can be found
success = false
// Ideal successive parts to make the full move+fire
parts: MoveFirePart[] = []
// Simulation complete (both move and fire are possible)
complete = false
need_move = false
can_move = false
can_end_move = false
total_move_ap = 0
move_location = new Target(0, 0, null)
need_fire = false
can_fire = false
total_fire_ap = 0
fire_location = new Target(0, 0, null)
};
/**
* Utility to simulate a move+fire action.
*
* This is also a helper to bring a ship in range to fire a weapon.
*/
export class MoveFireSimulator {
ship: Ship;
constructor(ship: Ship) {
this.ship = ship;
}
/**
* Find an engine action, to make an approach
*
* This will return the first available engine, in the definition order
*/
findEngine(): MoveAction | null {
let actions = cfilter(this.ship.actions.listAll(), MoveAction);
return first(actions, action => this.ship.actions.getCooldown(action).canUse());
}
/**
* Check that a move action can reach a given destination
*/
canMoveTo(action: MoveAction, target: Target): boolean {
let checked = action.checkLocationTarget(this.ship, target);
return checked != null && checked.x == target.x && checked.y == target.y;
}
/**
* Get an iterator for scanning a circle
*/
scanCircle(x: number, y: number, radius: number, nr = 6, na = 30): Iterable<Target> {
let rcount = nr ? 1 / (nr - 1) : 0;
return ichainit(imap(istep(0, irepeat(rcount, nr - 1)), r => {
let angles = Math.max(1, Math.ceil(na * r));
return imap(istep(0, irepeat(2 * Math.PI / angles, angles - 1)), a => {
return new Target(x + r * radius * Math.cos(a), y + r * radius * Math.sin(a))
});
}));
}
/**
* Find the best approach location, to put a target in a given range.
*
* Return null if no approach vector was found.
*/
getApproach(action: MoveAction, target: Target, radius: number, margin = 0): Target | ApproachSimulationError {
let dx = target.x - this.ship.arena_x;
let dy = target.y - this.ship.arena_y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= radius) {
return ApproachSimulationError.NO_MOVE_NEEDED;
} else {
if (margin && radius > margin) {
radius -= margin;
}
let factor = (distance - radius) / distance;
let candidate = new Target(this.ship.arena_x + dx * factor, this.ship.arena_y + dy * factor);
if (this.canMoveTo(action, candidate)) {
return candidate;
} else {
let candidates: [number, Target][] = [];
iforeach(this.scanCircle(target.x, target.y, radius), candidate => {
if (this.canMoveTo(action, candidate)) {
candidates.push([candidate.getDistanceTo(this.ship.location), candidate]);
}
});
if (candidates.length) {
return minBy(candidates, ([distance, candidate]) => distance)[1];
} else {
return ApproachSimulationError.NO_VECTOR_FOUND;
}
}
}
}
/**
* Simulate a given action on a given valid target.
*/
simulateAction(action: BaseAction, target: Target, move_margin = 0): MoveFireResult {
let result = new MoveFireResult();
let ap = this.ship.getValue("power");
// Move or approach needed ?
let move_target: Target | null = null;
let move_action: MoveAction | null = null;
result.move_location = Target.newFromShip(this.ship);
if (action instanceof MoveAction) {
let corrected_target = action.applyReachableRange(this.ship, target, move_margin);
corrected_target = action.applyExclusion(this.ship, corrected_target);
if (corrected_target) {
result.need_move = target.getDistanceTo(this.ship.location) > 0;
move_target = corrected_target;
}
move_action = action;
} else {
move_action = this.findEngine();
if (move_action) {
let approach_radius = action.getRangeRadius(this.ship);
let approach = this.getApproach(move_action, target, approach_radius, move_margin);
if (approach instanceof Target) {
result.need_move = true;
move_target = approach;
} else if (approach != ApproachSimulationError.NO_MOVE_NEEDED) {
result.need_move = true;
result.can_move = false;
result.success = false;
return result;
}
}
}
if (move_target && arenaDistance(move_target, this.ship.location) < 0.000001) {
result.need_move = false;
}
// Check move AP
if (result.need_move && move_target && move_action) {
result.total_move_ap = move_action.getPowerUsage(this.ship, move_target);
result.can_move = ap > 0;
result.can_end_move = result.total_move_ap <= ap;
result.move_location = move_target;
// TODO Split in "this turn" part and "next turn" part if needed
result.parts.push({ action: move_action, target: move_target, ap: result.total_move_ap, possible: result.can_move });
ap -= result.total_move_ap;
}
// Check action AP
if (action instanceof MoveAction) {
result.success = result.need_move && result.can_move;
} else {
result.need_fire = true;
result.total_fire_ap = action.getPowerUsage(this.ship, target);
result.can_fire = result.total_fire_ap <= ap;
result.fire_location = target;
result.parts.push({ action: action, target: target, ap: result.total_fire_ap, possible: (!result.need_move || result.can_end_move) && result.can_fire });
result.success = true;
}
result.complete = (!result.need_move || result.can_end_move) && (!result.need_fire || result.can_fire);
return result;
}
/**
* Apply a move-fire simulation result, and predict the diffs it will apply on a battle
*
* The original battle passed as parameter will be duplicated, and not altered
*/
getExpectedDiffs(battle: Battle, simulation: MoveFireResult): BaseBattleDiff[] {
let sim_battle = duplicate(battle, TK.SpaceTac);
let sim_ship = nn(sim_battle.getShip(this.ship.id));
let results: BaseBattleDiff[] = [];
simulation.parts.forEach(part => {
let diffs = part.action.getDiffs(sim_ship, battle, part.target);
results = results.concat(diffs);
sim_battle.applyDiffs(diffs);
diffs = sim_battle.performChecks();
results = results.concat(diffs);
});
return results;
}
}
/**
* Error codes for approach simulation
*/
export enum ApproachSimulationError {
NO_MOVE_NEEDED,
NO_VECTOR_FOUND,
}
/**
* A single action in the sequence result from the simulator
*/
export type MoveFirePart = {
action: BaseAction
target: Target
ap: number
possible: boolean
}
/**
* A simulation result
*/
export class MoveFireResult {
// Simulation success, false only if no route can be found
success = false
// Ideal successive parts to make the full move+fire
parts: MoveFirePart[] = []
// Simulation complete (both move and fire are possible)
complete = false
need_move = false
can_move = false
can_end_move = false
total_move_ap = 0
move_location = new Target(0, 0, null)
need_fire = false
can_fire = false
total_fire_ap = 0
fire_location = new Target(0, 0, null)
};
/**
* Utility to simulate a move+fire action.
*
* This is also a helper to bring a ship in range to fire a weapon.
*/
export class MoveFireSimulator {
ship: Ship;
constructor(ship: Ship) {
this.ship = ship;
}
/**
* Find an engine action, to make an approach
*
* This will return the first available engine, in the definition order
*/
findEngine(): MoveAction | null {
let actions = cfilter(this.ship.actions.listAll(), MoveAction);
return first(actions, action => this.ship.actions.getCooldown(action).canUse());
}
/**
* Check that a move action can reach a given destination
*/
canMoveTo(action: MoveAction, target: Target): boolean {
let checked = action.checkLocationTarget(this.ship, target);
return checked != null && checked.x == target.x && checked.y == target.y;
}
/**
* Get an iterator for scanning a circle
*/
scanCircle(x: number, y: number, radius: number, nr = 6, na = 30): Iterable<Target> {
let rcount = nr ? 1 / (nr - 1) : 0;
return ichainit(imap(istep(0, irepeat(rcount, nr - 1)), r => {
let angles = Math.max(1, Math.ceil(na * r));
return imap(istep(0, irepeat(2 * Math.PI / angles, angles - 1)), a => {
return new Target(x + r * radius * Math.cos(a), y + r * radius * Math.sin(a))
});
}));
}
/**
* Find the best approach location, to put a target in a given range.
*
* Return null if no approach vector was found.
*/
getApproach(action: MoveAction, target: Target, radius: number, margin = 0): Target | ApproachSimulationError {
let dx = target.x - this.ship.arena_x;
let dy = target.y - this.ship.arena_y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= radius) {
return ApproachSimulationError.NO_MOVE_NEEDED;
} else {
if (margin && radius > margin) {
radius -= margin;
}
let factor = (distance - radius) / distance;
let candidate = new Target(this.ship.arena_x + dx * factor, this.ship.arena_y + dy * factor);
if (this.canMoveTo(action, candidate)) {
return candidate;
} else {
let candidates: [number, Target][] = [];
iforeach(this.scanCircle(target.x, target.y, radius), candidate => {
if (this.canMoveTo(action, candidate)) {
candidates.push([candidate.getDistanceTo(this.ship.location), candidate]);
}
});
if (candidates.length) {
return minBy(candidates, ([distance, candidate]) => distance)[1];
} else {
return ApproachSimulationError.NO_VECTOR_FOUND;
}
}
}
}
/**
* Simulate a given action on a given valid target.
*/
simulateAction(action: BaseAction, target: Target, move_margin = 0): MoveFireResult {
let result = new MoveFireResult();
let ap = this.ship.getValue("power");
// Move or approach needed ?
let move_target: Target | null = null;
let move_action: MoveAction | null = null;
result.move_location = Target.newFromShip(this.ship);
if (action instanceof MoveAction) {
let corrected_target = action.applyReachableRange(this.ship, target, move_margin);
corrected_target = action.applyExclusion(this.ship, corrected_target);
if (corrected_target) {
result.need_move = target.getDistanceTo(this.ship.location) > 0;
move_target = corrected_target;
}
move_action = action;
} else {
move_action = this.findEngine();
if (move_action) {
let approach_radius = action.getRangeRadius(this.ship);
let approach = this.getApproach(move_action, target, approach_radius, move_margin);
if (approach instanceof Target) {
result.need_move = true;
move_target = approach;
} else if (approach != ApproachSimulationError.NO_MOVE_NEEDED) {
result.need_move = true;
result.can_move = false;
result.success = false;
return result;
}
}
}
if (move_target && arenaDistance(move_target, this.ship.location) < 0.000001) {
result.need_move = false;
}
// Check move AP
if (result.need_move && move_target && move_action) {
result.total_move_ap = move_action.getPowerUsage(this.ship, move_target);
result.can_move = ap > 0;
result.can_end_move = result.total_move_ap <= ap;
result.move_location = move_target;
// TODO Split in "this turn" part and "next turn" part if needed
result.parts.push({ action: move_action, target: move_target, ap: result.total_move_ap, possible: result.can_move });
ap -= result.total_move_ap;
}
// Check action AP
if (action instanceof MoveAction) {
result.success = result.need_move && result.can_move;
} else {
result.need_fire = true;
result.total_fire_ap = action.getPowerUsage(this.ship, target);
result.can_fire = result.total_fire_ap <= ap;
result.fire_location = target;
result.parts.push({ action: action, target: target, ap: result.total_fire_ap, possible: (!result.need_move || result.can_end_move) && result.can_fire });
result.success = true;
}
result.complete = (!result.need_move || result.can_end_move) && (!result.need_fire || result.can_fire);
return result;
}
/**
* Apply a move-fire simulation result, and predict the diffs it will apply on a battle
*
* The original battle passed as parameter will be duplicated, and not altered
*/
getExpectedDiffs(battle: Battle, simulation: MoveFireResult): BaseBattleDiff[] {
let sim_battle = duplicate(battle, NAMESPACE);
let sim_ship = nn(sim_battle.getShip(this.ship.id));
let results: BaseBattleDiff[] = [];
simulation.parts.forEach(part => {
let diffs = part.action.getDiffs(sim_ship, battle, part.target);
results = results.concat(diffs);
sim_battle.applyDiffs(diffs);
diffs = sim_battle.performChecks();
results = results.concat(diffs);
});
return results;
}
}

View file

@ -1,13 +1,15 @@
module TK.SpaceTac.Specs {
testing("NameGenerator", test => {
test.case("generates unique names", check => {
var random = new SkewedRandomGenerator([0.48, 0.9, 0.1]);
var gen = new NameGenerator(["a", "b", "c"], random);
import { SkewedRandomGenerator } from "../common/RandomGenerator";
import { testing } from "../common/Testing";
import { NameGenerator } from "./NameGenerator";
check.equals(gen.getName(), "b");
check.equals(gen.getName(), "c");
check.equals(gen.getName(), "a");
check.equals(gen.getName(), null);
});
});
}
testing("NameGenerator", test => {
test.case("generates unique names", check => {
var random = new SkewedRandomGenerator([0.48, 0.9, 0.1]);
var gen = new NameGenerator(["a", "b", "c"], random);
check.equals(gen.getName(), "b");
check.equals(gen.getName(), "c");
check.equals(gen.getName(), "a");
check.equals(gen.getName(), null);
});
});

View file

@ -1,27 +1,28 @@
module TK.SpaceTac {
// A unique name generator
export class NameGenerator {
// List of available choices
private choices: string[];
import { RandomGenerator } from "../common/RandomGenerator";
import { acopy } from "../common/Tools";
// Random generator to use
private random: RandomGenerator;
// A unique name generator
export class NameGenerator {
// List of available choices
private choices: string[];
constructor(choices: string[], random: RandomGenerator = new RandomGenerator()) {
this.choices = acopy(choices);
this.random = random;
}
// Random generator to use
private random: RandomGenerator;
// Get a new unique name from available choices
getName(): string | null {
if (this.choices.length === 0) {
return null;
}
constructor(choices: string[], random: RandomGenerator = new RandomGenerator()) {
this.choices = acopy(choices);
this.random = random;
}
var index = this.random.randInt(0, this.choices.length - 1);
var result = this.choices[index];
this.choices.splice(index, 1);
return result;
}
// Get a new unique name from available choices
getName(): string | null {
if (this.choices.length === 0) {
return null;
}
var index = this.random.randInt(0, this.choices.length - 1);
var result = this.choices[index];
this.choices.splice(index, 1);
return result;
}
}

View file

@ -1,36 +1,34 @@
module TK.SpaceTac {
/**
* List of personality traits (may be used with "keyof").
*/
export interface IPersonalityTraits {
aggressive: number
funny: number
heroic: number
optimistic: number
}
/**
* A personality is a set of traits that defines how a character thinks and behaves
*
* Each trait is a number between -1 and 1
*
* In the game, a personality represents an artificial intelligence, and is transferable
* from one ship (body) to another. This is why a personality has a name
*/
export class Personality implements IPersonalityTraits {
// Name of this personality
name = ""
// Aggressive 1 / Poised -1
aggressive = 0
// Funny 1 / Serious -1
funny = 0
// Heroic 1 / Coward -1
heroic = 0
// Optimistic 1 / Pessimistic -1
optimistic = 0
}
/**
* List of personality traits (may be used with "keyof").
*/
export interface IPersonalityTraits {
aggressive: number
funny: number
heroic: number
optimistic: number
}
/**
* A personality is a set of traits that defines how a character thinks and behaves
*
* Each trait is a number between -1 and 1
*
* In the game, a personality represents an artificial intelligence, and is transferable
* from one ship (body) to another. This is why a personality has a name
*/
export class Personality implements IPersonalityTraits {
// Name of this personality
name = ""
// Aggressive 1 / Poised -1
aggressive = 0
// Funny 1 / Serious -1
funny = 0
// Heroic 1 / Coward -1
heroic = 0
// Optimistic 1 / Pessimistic -1
optimistic = 0
}

View file

@ -1,65 +1,70 @@
module TK.SpaceTac.Specs {
testing("PersonalityReactions", test => {
function apply(pool: ReactionPool): PersonalityReaction | null {
let reactions = new PersonalityReactions();
return reactions.check(new Player(), null, null, null, pool);
}
import { testing } from "../common/Testing";
import { Battle } from "./Battle";
import { ShipDamageDiff } from "./diffs/ShipDamageDiff";
import { BUILTIN_REACTION_POOL, PersonalityReaction, PersonalityReactionConversation, PersonalityReactions, ReactionPool } from "./PersonalityReactions";
import { Player } from "./Player";
import { Ship } from "./Ship";
class FakeReaction extends PersonalityReactionConversation {
ships: Ship[]
constructor(ships: Ship[]) {
super([]);
this.ships = ships;
}
static cons(ships: Ship[]): FakeReaction {
return new FakeReaction(ships);
}
}
testing("PersonalityReactions", test => {
function apply(pool: ReactionPool): PersonalityReaction | null {
let reactions = new PersonalityReactions();
return reactions.check(new Player(), null, null, null, pool);
}
test.case("fetches ships from conditions", check => {
let reaction = apply({});
check.equals(reaction, null);
class FakeReaction extends PersonalityReactionConversation {
ships: Ship[]
constructor(ships: Ship[]) {
super([]);
this.ships = ships;
}
static cons(ships: Ship[]): FakeReaction {
return new FakeReaction(ships);
}
}
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
test.case("fetches ships from conditions", check => {
let reaction = apply({});
check.equals(reaction, null);
reaction = apply({
a: [() => [s1, s2], 1, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s1, s2]));
})
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
test.case("applies weight on conditions", check => {
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
reaction = apply({
a: [() => [s1, s2], 1, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s1, s2]));
})
let reaction = apply({
a: [() => [s1], 1, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 0, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s1]));
test.case("applies weight on conditions", check => {
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
reaction = apply({
a: [() => [s1], 0, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 1, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s2]));
})
let reaction = apply({
a: [() => [s1], 1, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 0, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s1]));
test.case("checks for friendly fire", check => {
let condition = BUILTIN_REACTION_POOL['friendly_fire'][0];
let battle = new Battle();
let player = new Player();
battle.fleets[0].setPlayer(player);
let ship1a = battle.fleets[0].addShip();
let ship1b = battle.fleets[0].addShip();
let ship2a = battle.fleets[1].addShip();
let ship2b = battle.fleets[1].addShip();
reaction = apply({
a: [() => [s1], 0, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 1, [[() => 1, FakeReaction.cons]]],
});
check.equals(reaction, new FakeReaction([s2]));
})
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship1a, 50, 10)), [], "self shoot");
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship1b, 50, 10)), [ship1b, ship1a]);
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship2a, 50, 10)), [], "enemy shoot");
check.equals(condition(player, battle, ship2a, new ShipDamageDiff(ship2a, 50, 10)), [], "other player event");
})
})
}
test.case("checks for friendly fire", check => {
let condition = BUILTIN_REACTION_POOL['friendly_fire'][0];
let battle = new Battle();
let player = new Player();
battle.fleets[0].setPlayer(player);
let ship1a = battle.fleets[0].addShip();
let ship1b = battle.fleets[0].addShip();
let ship2a = battle.fleets[1].addShip();
let ship2b = battle.fleets[1].addShip();
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship1a, 50, 10)), [], "self shoot");
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship1b, 50, 10)), [ship1b, ship1a]);
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship2a, 50, 10)), [], "enemy shoot");
check.equals(condition(player, battle, ship2a, new ShipDamageDiff(ship2a, 50, 10)), [], "other player event");
})
})

View file

@ -1,106 +1,113 @@
module TK.SpaceTac {
// Reaction triggered
export type PersonalityReaction = PersonalityReactionConversation
import { RandomGenerator } from "../common/RandomGenerator"
import { difference, keys, nna } from "../common/Tools"
import { Battle } from "./Battle"
import { BaseBattleDiff } from "./diffs/BaseBattleDiff"
import { ShipDamageDiff } from "./diffs/ShipDamageDiff"
import { IPersonalityTraits } from "./Personality"
import { Player } from "./Player"
import { Ship } from "./Ship"
// Condition to check if a reaction may happen, returning involved ships (order is important)
export type ReactionCondition = (player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null) => Ship[]
// Reaction triggered
export type PersonalityReaction = PersonalityReactionConversation
// Reaction profile, giving a probability for types of personality, and an associated reaction constructor
export type ReactionProfile = [(traits: IPersonalityTraits) => number, (ships: Ship[]) => PersonalityReaction]
// Condition to check if a reaction may happen, returning involved ships (order is important)
export type ReactionCondition = (player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null) => Ship[]
// Reaction config (condition, chance, profiles)
export type ReactionConfig = [ReactionCondition, number, ReactionProfile[]]
// Reaction profile, giving a probability for types of personality, and an associated reaction constructor
export type ReactionProfile = [(traits: IPersonalityTraits) => number, (ships: Ship[]) => PersonalityReaction]
// Pool of reaction config
export type ReactionPool = { [code: string]: ReactionConfig }
// Reaction config (condition, chance, profiles)
export type ReactionConfig = [ReactionCondition, number, ReactionProfile[]]
/**
* Reactions to external events according to personalities.
*
* This allows for a more "alive" world, as characters tend to speak to react to events.
*
* This object will store the previous reactions to avoid too much recurrence, and should be global to a whole
* game session.
*/
export class PersonalityReactions {
done: string[] = []
random = RandomGenerator.global
// Pool of reaction config
export type ReactionPool = { [code: string]: ReactionConfig }
/**
* Check for a reaction.
*
* This will return a reaction to display, and add it to the done list
*/
check(player: Player, battle: Battle | null = null, ship: Ship | null = null, event: BaseBattleDiff | null = null, pool: ReactionPool = BUILTIN_REACTION_POOL): PersonalityReaction | null {
let codes = difference(keys(pool), this.done);
/**
* Reactions to external events according to personalities.
*
* This allows for a more "alive" world, as characters tend to speak to react to events.
*
* This object will store the previous reactions to avoid too much recurrence, and should be global to a whole
* game session.
*/
export class PersonalityReactions {
done: string[] = []
random = RandomGenerator.global
let candidates = nna(codes.map((code: string): [string, Ship[], ReactionProfile[]] | null => {
let [condition, chance, profiles] = pool[code];
if (this.random.random() <= chance) {
let involved = condition(player, battle, ship, event);
if (involved.length > 0) {
return [code, involved, profiles];
} else {
return null;
}
} else {
return null;
}
}));
/**
* Check for a reaction.
*
* This will return a reaction to display, and add it to the done list
*/
check(player: Player, battle: Battle | null = null, ship: Ship | null = null, event: BaseBattleDiff | null = null, pool: ReactionPool = BUILTIN_REACTION_POOL): PersonalityReaction | null {
let codes = difference(keys(pool), this.done);
if (candidates.length > 0) {
let [code, involved, profiles] = this.random.choice(candidates);
let primary = involved[0];
let weights = profiles.map(([evaluator, _]) => evaluator(primary.personality));
let action_number = this.random.weighted(weights);
if (action_number >= 0) {
this.done.push(code);
let reaction_constructor = profiles[action_number][1];
return reaction_constructor(involved);
} else {
return null;
}
} else {
return null;
}
}
}
/**
* One kind of personality reaction: saying something out loud
*/
export class PersonalityReactionConversation {
messages: { interlocutor: Ship, message: string }[]
constructor(messages: { interlocutor: Ship, message: string }[]) {
this.messages = messages;
}
}
/**
* Standard reaction pool
*/
export const BUILTIN_REACTION_POOL: ReactionPool = {
friendly_fire: [cond_friendly_fire, 1, [
[traits => 1, ships => new PersonalityReactionConversation([
{ interlocutor: ships[0], message: "Hey !!! Watch where you're shooting !" },
{ interlocutor: ships[1], message: "Sorry mate..." },
])]
]]
}
/**
* Check for a friendly fire condition (one of player's ships fired on another)
*/
function cond_friendly_fire(player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null): Ship[] {
if (battle && ship && event) {
if (event instanceof ShipDamageDiff && player.is(ship.fleet.player) && !ship.is(event.ship_id)) {
let hurt = battle.getShip(event.ship_id);
return (hurt && player.is(hurt.fleet.player)) ? [hurt, ship] : [];
} else {
return [];
}
let candidates = nna(codes.map((code: string): [string, Ship[], ReactionProfile[]] | null => {
let [condition, chance, profiles] = pool[code];
if (this.random.random() <= chance) {
let involved = condition(player, battle, ship, event);
if (involved.length > 0) {
return [code, involved, profiles];
} else {
return [];
return null;
}
} else {
return null;
}
}));
if (candidates.length > 0) {
let [code, involved, profiles] = this.random.choice(candidates);
let primary = involved[0];
let weights = profiles.map(([evaluator, _]) => evaluator(primary.personality));
let action_number = this.random.weighted(weights);
if (action_number >= 0) {
this.done.push(code);
let reaction_constructor = profiles[action_number][1];
return reaction_constructor(involved);
} else {
return null;
}
} else {
return null;
}
}
}
/**
* One kind of personality reaction: saying something out loud
*/
export class PersonalityReactionConversation {
messages: { interlocutor: Ship, message: string }[]
constructor(messages: { interlocutor: Ship, message: string }[]) {
this.messages = messages;
}
}
/**
* Standard reaction pool
*/
export const BUILTIN_REACTION_POOL: ReactionPool = {
friendly_fire: [cond_friendly_fire, 1, [
[traits => 1, ships => new PersonalityReactionConversation([
{ interlocutor: ships[0], message: "Hey !!! Watch where you're shooting !" },
{ interlocutor: ships[1], message: "Sorry mate..." },
])]
]]
}
/**
* Check for a friendly fire condition (one of player's ships fired on another)
*/
function cond_friendly_fire(player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null): Ship[] {
if (battle && ship && event) {
if (event instanceof ShipDamageDiff && player.is(ship.fleet.player) && !ship.is(event.ship_id)) {
let hurt = battle.getShip(event.ship_id);
return (hurt && player.is(hurt.fleet.player)) ? [hurt, ship] : [];
} else {
return [];
}
} else {
return [];
}
}

View file

@ -1,38 +1,41 @@
module TK.SpaceTac {
testing("Player", test => {
test.case("keeps track of visited locations", check => {
let player = new Player();
let universe = new Universe();
let star1 = universe.addStar();
let star2 = universe.addStar();
let loc1a = star1.addLocation(StarLocationType.PLANET);
let loc1b = star1.addLocation(StarLocationType.PLANET);
let loc2a = star2.addLocation(StarLocationType.PLANET);
let loc2b = star2.addLocation(StarLocationType.PLANET);
universe.updateLocations();
import { testing } from "../common/Testing";
import { Player } from "./Player";
import { StarLocationType } from "./StarLocation";
import { Universe } from "./Universe";
function checkVisited(s1 = false, s2 = false, v1a = false, v1b = false, v2a = false, v2b = false) {
check.same(player.hasVisitedSystem(star1), s1);
check.same(player.hasVisitedSystem(star2), s2);
check.same(player.hasVisitedLocation(loc1a), v1a);
check.same(player.hasVisitedLocation(loc1b), v1b);
check.same(player.hasVisitedLocation(loc2a), v2a);
check.same(player.hasVisitedLocation(loc2b), v2b);
}
testing("Player", test => {
test.case("keeps track of visited locations", check => {
let player = new Player();
let universe = new Universe();
let star1 = universe.addStar();
let star2 = universe.addStar();
let loc1a = star1.addLocation(StarLocationType.PLANET);
let loc1b = star1.addLocation(StarLocationType.PLANET);
let loc2a = star2.addLocation(StarLocationType.PLANET);
let loc2b = star2.addLocation(StarLocationType.PLANET);
universe.updateLocations();
checkVisited();
function checkVisited(s1 = false, s2 = false, v1a = false, v1b = false, v2a = false, v2b = false) {
check.same(player.hasVisitedSystem(star1), s1);
check.same(player.hasVisitedSystem(star2), s2);
check.same(player.hasVisitedLocation(loc1a), v1a);
check.same(player.hasVisitedLocation(loc1b), v1b);
check.same(player.hasVisitedLocation(loc2a), v2a);
check.same(player.hasVisitedLocation(loc2b), v2b);
}
player.fleet.setLocation(loc1b);
checkVisited(true, false, false, true, false, false);
checkVisited();
player.fleet.setLocation(loc1a);
checkVisited(true, false, true, true, false, false);
player.fleet.setLocation(loc1b);
checkVisited(true, false, false, true, false, false);
player.fleet.setLocation(loc2a);
checkVisited(true, true, true, true, true, false);
player.fleet.setLocation(loc1a);
checkVisited(true, false, true, true, false, false);
player.fleet.setLocation(loc2a);
checkVisited(true, true, true, true, true, false);
});
});
}
player.fleet.setLocation(loc2a);
checkVisited(true, true, true, true, true, false);
player.fleet.setLocation(loc2a);
checkVisited(true, true, true, true, true, false);
});
});

View file

@ -1,78 +1,84 @@
/// <reference path="../common/RObject.ts" />
import { RObject } from "../common/RObject";
import { contains, intersection } from "../common/Tools";
import { Battle } from "./Battle";
import { BattleCheats } from "./BattleCheats";
import { Fleet } from "./Fleet";
import { FleetGenerator } from "./FleetGenerator";
import { ActiveMissions } from "./missions/ActiveMissions";
import { Star } from "./Star";
import { StarLocation } from "./StarLocation";
module TK.SpaceTac {
/**
* One player (human or IA)
*/
export class Player extends RObject {
// Player's name
name: string
/**
* One player (human or IA)
*/
export class Player extends RObject {
// Player's name
name: string
// Bound fleet
fleet: Fleet
// Bound fleet
fleet: Fleet
// Active missions
missions = new ActiveMissions()
// Active missions
missions = new ActiveMissions()
// Create a player, with an empty fleet
constructor(name = "Player", fleet?: Fleet) {
super();
// Create a player, with an empty fleet
constructor(name = "Player", fleet?: Fleet) {
super();
this.name = name;
this.fleet = fleet || new Fleet(this);
this.name = name;
this.fleet = fleet || new Fleet(this);
this.fleet.setPlayer(this);
}
this.fleet.setPlayer(this);
}
// Create a quick random player, with a fleet, for testing purposes
static newQuickRandom(name: string, level = 1, shipcount = 4, upgrade = false): Player {
let player = new Player(name);
let generator = new FleetGenerator();
player.fleet = generator.generate(level, player, shipcount, upgrade);
return player;
}
// Create a quick random player, with a fleet, for testing purposes
static newQuickRandom(name: string, level = 1, shipcount = 4, upgrade = false): Player {
let player = new Player(name);
let generator = new FleetGenerator();
player.fleet = generator.generate(level, player, shipcount, upgrade);
return player;
}
/**
* Set the fleet for this player
*/
setFleet(fleet: Fleet): void {
this.fleet = fleet;
fleet.setPlayer(this);
}
/**
* Set the fleet for this player
*/
setFleet(fleet: Fleet): void {
this.fleet = fleet;
fleet.setPlayer(this);
}
/**
* Get a cheats object
*/
getCheats(): BattleCheats | null {
let battle = this.getBattle();
if (battle) {
return new BattleCheats(battle, this);
} else {
return null;
}
}
/**
* Return true if the player has visited at least one location in a given system.
*/
hasVisitedSystem(system: Star): boolean {
return intersection(this.fleet.visited, system.locations.map(loc => loc.id)).length > 0;
}
/**
* Return true if the player has visited a given star location.
*/
hasVisitedLocation(location: StarLocation): boolean {
return contains(this.fleet.visited, location.id);
}
// Get currently played battle, null when none is in progress
getBattle(): Battle | null {
return this.fleet.battle;
}
setBattle(battle: Battle | null): void {
this.fleet.setBattle(battle);
this.missions.checkStatus();
}
/**
* Get a cheats object
*/
getCheats(): BattleCheats | null {
let battle = this.getBattle();
if (battle) {
return new BattleCheats(battle, this);
} else {
return null;
}
}
/**
* Return true if the player has visited at least one location in a given system.
*/
hasVisitedSystem(system: Star): boolean {
return intersection(this.fleet.visited, system.locations.map(loc => loc.id)).length > 0;
}
/**
* Return true if the player has visited a given star location.
*/
hasVisitedLocation(location: StarLocation): boolean {
return contains(this.fleet.visited, location.id);
}
// Get currently played battle, null when none is in progress
getBattle(): Battle | null {
return this.fleet.battle;
}
setBattle(battle: Battle | null): void {
this.fleet.setBattle(battle);
this.missions.checkStatus();
}
}

View file

@ -1,45 +1,46 @@
module TK.SpaceTac.Specs {
testing("Range", test => {
test.case("can work with proportional values", check => {
var range = new Range(1, 5);
import { testing } from "../common/Testing";
import { IntegerRange } from "./Range";
function checkProportional(range: Range, value1: number, value2: number) {
check.equals(range.getProportional(value1), value2);
check.equals(range.getReverseProportional(value2), value1);
}
testing("Range", test => {
test.case("can work with proportional values", check => {
var range = new Range(1, 5);
checkProportional(range, 0, 1);
checkProportional(range, 1, 5);
checkProportional(range, 0.5, 3);
checkProportional(range, 0.4, 2.6);
function checkProportional(range: Range, value1: number, value2: number) {
check.equals(range.getProportional(value1), value2);
check.equals(range.getReverseProportional(value2), value1);
}
check.equals(range.getProportional(-0.25), 1);
check.equals(range.getProportional(1.8), 5);
checkProportional(range, 0, 1);
checkProportional(range, 1, 5);
checkProportional(range, 0.5, 3);
checkProportional(range, 0.4, 2.6);
check.equals(range.getReverseProportional(0), 0);
check.equals(range.getReverseProportional(6), 1);
});
});
check.equals(range.getProportional(-0.25), 1);
check.equals(range.getProportional(1.8), 5);
testing("IntegerRange", test => {
test.case("can work with proportional values", check => {
var range = new IntegerRange(1, 5);
check.equals(range.getReverseProportional(0), 0);
check.equals(range.getReverseProportional(6), 1);
});
});
check.equals(range.getProportional(0), 1);
check.equals(range.getProportional(0.1), 1);
check.equals(range.getProportional(0.2), 2);
check.equals(range.getProportional(0.45), 3);
check.equals(range.getProportional(0.5), 3);
check.equals(range.getProportional(0.75), 4);
check.equals(range.getProportional(0.8), 5);
check.equals(range.getProportional(0.99), 5);
check.equals(range.getProportional(1), 5);
testing("IntegerRange", test => {
test.case("can work with proportional values", check => {
var range = new IntegerRange(1, 5);
check.equals(range.getReverseProportional(1), 0);
check.equals(range.getReverseProportional(2), 0.2);
check.equals(range.getReverseProportional(3), 0.4);
check.equals(range.getReverseProportional(4), 0.6);
check.equals(range.getReverseProportional(5), 0.8);
});
});
}
check.equals(range.getProportional(0), 1);
check.equals(range.getProportional(0.1), 1);
check.equals(range.getProportional(0.2), 2);
check.equals(range.getProportional(0.45), 3);
check.equals(range.getProportional(0.5), 3);
check.equals(range.getProportional(0.75), 4);
check.equals(range.getProportional(0.8), 5);
check.equals(range.getProportional(0.99), 5);
check.equals(range.getProportional(1), 5);
check.equals(range.getReverseProportional(1), 0);
check.equals(range.getReverseProportional(2), 0.2);
check.equals(range.getReverseProportional(3), 0.4);
check.equals(range.getReverseProportional(4), 0.6);
check.equals(range.getReverseProportional(5), 0.8);
});
});

View file

@ -1,82 +1,80 @@
module TK.SpaceTac {
// Range of number values
export class Range {
// Minimal value
min = 0
// Range of number values
export class Range {
// Minimal value
min = 0
// Maximal value
max = 0
// Maximal value
max = 0
// Create a range of values
constructor(min: number, max: number | null = null) {
this.set(min, max);
}
// Create a range of values
constructor(min: number, max: number | null = null) {
this.set(min, max);
}
// Change the range
set(min: number, max: number | null = null) {
this.min = min;
if (max === null) {
this.max = this.min;
} else {
this.max = max;
}
}
// Get a proportional value (give 0.0-1.0 value to obtain a value in range)
getProportional(cursor: number): number {
if (cursor <= 0.0) {
return this.min;
} else if (cursor >= 1.0) {
return this.max;
} else {
return (this.max - this.min) * cursor + this.min;
}
}
// Get the value of the cursor that would give this proportional value (in 0.0-1.0 range)
getReverseProportional(expected: number): number {
if (expected <= this.min) {
return 0;
} else if (expected >= this.max) {
return 1;
} else {
return (expected - this.min) / (this.max - this.min);
}
}
// Check if a value is in the range
isInRange(value: number): boolean {
return value >= this.min && value <= this.max;
}
// Change the range
set(min: number, max: number | null = null) {
this.min = min;
if (max === null) {
this.max = this.min;
} else {
this.max = max;
}
}
// Range of integer values
//
// This differs from Range in that it adds space in proportional values to include the 'max'.
// Typically, using Range for integers will only yield 'max' for exactly 1.0 proportional, not for 0.999999.
// This fixes this behavior.
//
// As this rounds values to integer, the 'reverse' proportional is no longer a bijection.
export class IntegerRange extends Range {
getProportional(cursor: number): number {
if (cursor <= 0.0) {
return this.min;
} else if (cursor >= 1.0) {
return this.max;
} else {
return Math.floor((this.max - this.min + 1) * cursor + this.min);
}
}
getReverseProportional(expected: number): number {
if (expected <= this.min) {
return 0;
} else if (expected > this.max) {
return 1;
} else {
return (expected - this.min) * 1.0 / (this.max - this.min + 1);
}
}
// Get a proportional value (give 0.0-1.0 value to obtain a value in range)
getProportional(cursor: number): number {
if (cursor <= 0.0) {
return this.min;
} else if (cursor >= 1.0) {
return this.max;
} else {
return (this.max - this.min) * cursor + this.min;
}
}
// Get the value of the cursor that would give this proportional value (in 0.0-1.0 range)
getReverseProportional(expected: number): number {
if (expected <= this.min) {
return 0;
} else if (expected >= this.max) {
return 1;
} else {
return (expected - this.min) / (this.max - this.min);
}
}
// Check if a value is in the range
isInRange(value: number): boolean {
return value >= this.min && value <= this.max;
}
}
// Range of integer values
//
// This differs from Range in that it adds space in proportional values to include the 'max'.
// Typically, using Range for integers will only yield 'max' for exactly 1.0 proportional, not for 0.999999.
// This fixes this behavior.
//
// As this rounds values to integer, the 'reverse' proportional is no longer a bijection.
export class IntegerRange extends Range {
getProportional(cursor: number): number {
if (cursor <= 0.0) {
return this.min;
} else if (cursor >= 1.0) {
return this.max;
} else {
return Math.floor((this.max - this.min + 1) * cursor + this.min);
}
}
getReverseProportional(expected: number): number {
if (expected <= this.min) {
return 0;
} else if (expected > this.max) {
return 1;
} else {
return (expected - this.min) * 1.0 / (this.max - this.min + 1);
}
}
}

View file

@ -1,195 +1,210 @@
module TK.SpaceTac.Specs {
testing("Ship", test => {
test.case("creates a full name", check => {
let ship = new Ship();
check.equals(ship.getName(false), "Ship");
check.equals(ship.getName(true), "Level 1 Ship");
import { testing } from "../common/Testing";
import { nn } from "../common/Tools";
import { BaseAction } from "./actions/BaseAction";
import { ToggleAction } from "./actions/ToggleAction";
import { Battle } from "./Battle";
import { ShipActionToggleDiff } from "./diffs/ShipActionToggleDiff";
import { ShipAttributeDiff } from "./diffs/ShipAttributeDiff";
import { ShipDeathDiff } from "./diffs/ShipDeathDiff";
import { ShipEffectRemovedDiff } from "./diffs/ShipEffectAddedDiff";
import { ShipValueDiff } from "./diffs/ShipValueDiff";
import { AttributeEffect } from "./effects/AttributeEffect";
import { AttributeLimitEffect } from "./effects/AttributeLimitEffect";
import { StickyEffect } from "./effects/StickyEffect";
import { ShipModel } from "./models/ShipModel";
import { Ship } from "./Ship";
import { TestTools } from "./TestTools";
ship.model = new ShipModel("test", "Hauler");
check.equals(ship.getName(false), "Hauler");
check.equals(ship.getName(true), "Level 1 Hauler");
testing("Ship", test => {
test.case("creates a full name", check => {
let ship = new Ship();
check.equals(ship.getName(false), "Ship");
check.equals(ship.getName(true), "Level 1 Ship");
ship.name = "Titan-W12";
check.equals(ship.getName(false), "Titan-W12");
check.equals(ship.getName(true), "Level 1 Titan-W12");
ship.model = new ShipModel("test", "Hauler");
check.equals(ship.getName(false), "Hauler");
check.equals(ship.getName(true), "Level 1 Hauler");
ship.level.forceLevel(3);
check.equals(ship.getName(false), "Titan-W12");
check.equals(ship.getName(true), "Level 3 Titan-W12");
});
ship.name = "Titan-W12";
check.equals(ship.getName(false), "Titan-W12");
check.equals(ship.getName(true), "Level 1 Titan-W12");
test.case("moves in the arena", check => {
let ship = new Ship(null, "Test");
let engine = TestTools.addEngine(ship, 50);
ship.level.forceLevel(3);
check.equals(ship.getName(false), "Titan-W12");
check.equals(ship.getName(true), "Level 3 Titan-W12");
});
check.equals(ship.arena_x, 0);
check.equals(ship.arena_y, 0);
check.equals(ship.arena_angle, 0);
test.case("moves in the arena", check => {
let ship = new Ship(null, "Test");
let engine = TestTools.addEngine(ship, 50);
ship.setArenaFacingAngle(1.2);
ship.setArenaPosition(12, 50);
check.equals(ship.arena_x, 0);
check.equals(ship.arena_y, 0);
check.equals(ship.arena_angle, 0);
check.equals(ship.arena_x, 12);
check.equals(ship.arena_y, 50);
check.nears(ship.arena_angle, 1.2);
});
ship.setArenaFacingAngle(1.2);
ship.setArenaPosition(12, 50);
test.case("applies permanent effects of ship model on attributes", check => {
let model = new ShipModel();
let ship = new Ship(null, null, model);
check.equals(ship.arena_x, 12);
check.equals(ship.arena_y, 50);
check.nears(ship.arena_angle, 1.2);
});
check.patch(model, "getEffects", () => [
new AttributeEffect("power_capacity", 4),
new AttributeEffect("power_capacity", 5),
]);
test.case("applies permanent effects of ship model on attributes", check => {
let model = new ShipModel();
let ship = new Ship(null, null, model);
ship.updateAttributes();
check.equals(ship.getAttribute("power_capacity"), 9);
});
check.patch(model, "getEffects", () => [
new AttributeEffect("power_capacity", 4),
new AttributeEffect("power_capacity", 5),
]);
test.case("repairs hull and recharges shield", check => {
var ship = new Ship(null, "Test");
ship.updateAttributes();
check.equals(ship.getAttribute("power_capacity"), 9);
});
TestTools.setAttribute(ship, "hull_capacity", 120);
TestTools.setAttribute(ship, "shield_capacity", 150);
test.case("repairs hull and recharges shield", check => {
var ship = new Ship(null, "Test");
check.equals(ship.getValue("hull"), 0);
check.equals(ship.getValue("shield"), 0);
TestTools.setAttribute(ship, "hull_capacity", 120);
TestTools.setAttribute(ship, "shield_capacity", 150);
ship.restoreHealth();
check.equals(ship.getValue("hull"), 0);
check.equals(ship.getValue("shield"), 0);
check.equals(ship.getValue("hull"), 120);
check.equals(ship.getValue("shield"), 150);
});
ship.restoreHealth();
test.case("checks if a ship is able to play", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
ship.setValue("hull", 10);
check.equals(ship.getValue("hull"), 120);
check.equals(ship.getValue("shield"), 150);
});
check.equals(ship.isAbleToPlay(), false);
check.equals(ship.isAbleToPlay(false), true);
test.case("checks if a ship is able to play", check => {
let battle = new Battle();
let ship = battle.fleets[0].addShip();
ship.setValue("hull", 10);
ship.setValue("power", 5);
check.equals(ship.isAbleToPlay(), false);
check.equals(ship.isAbleToPlay(false), true);
check.equals(ship.isAbleToPlay(), true);
check.equals(ship.isAbleToPlay(false), true);
ship.setValue("power", 5);
ship.setDead();
check.equals(ship.isAbleToPlay(), true);
check.equals(ship.isAbleToPlay(false), true);
check.equals(ship.isAbleToPlay(), false);
check.equals(ship.isAbleToPlay(false), false);
});
ship.setDead();
test.case("checks if a ship is inside a given circle", check => {
let ship = new Ship();
ship.arena_x = 5;
ship.arena_y = 8;
check.equals(ship.isAbleToPlay(), false);
check.equals(ship.isAbleToPlay(false), false);
});
check.equals(ship.isInCircle(5, 8, 0), true);
check.equals(ship.isInCircle(5, 8, 1), true);
check.equals(ship.isInCircle(5, 7, 1), true);
check.equals(ship.isInCircle(6, 9, 1.7), true);
check.equals(ship.isInCircle(5, 8.1, 0), false);
check.equals(ship.isInCircle(5, 7, 0.9), false);
check.equals(ship.isInCircle(12, -4, 5), false);
});
test.case("checks if a ship is inside a given circle", check => {
let ship = new Ship();
ship.arena_x = 5;
ship.arena_y = 8;
test.case("restores as new at the end of battle", check => {
let ship = new Ship();
TestTools.setShipModel(ship, 10, 20, 5);
ship.setValue("hull", 5);
ship.setValue("shield", 15);
ship.setValue("power", 2);
ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("power_capacity", 3), 12));
ship.updateAttributes();
let action1 = new BaseAction("action1");
ship.actions.addCustom(action1);
let action2 = new ToggleAction("action2");
ship.actions.addCustom(action2);
ship.actions.toggle(action2, true);
let action3 = new ToggleAction("action3");
ship.actions.addCustom(action3);
check.equals(ship.isInCircle(5, 8, 0), true);
check.equals(ship.isInCircle(5, 8, 1), true);
check.equals(ship.isInCircle(5, 7, 1), true);
check.equals(ship.isInCircle(6, 9, 1.7), true);
check.equals(ship.isInCircle(5, 8.1, 0), false);
check.equals(ship.isInCircle(5, 7, 0.9), false);
check.equals(ship.isInCircle(12, -4, 5), false);
});
check.in("before", check => {
check.equals(ship.getValue("hull"), 5, "hull");
check.equals(ship.getValue("shield"), 15, "shield");
check.equals(ship.getValue("power"), 2, "power");
check.equals(ship.active_effects.count(), 1, "effects count");
check.equals(ship.getAttribute("power_capacity"), 3, "power capacity");
check.equals(ship.actions.isToggled(action2), true, "action 2 activation");
check.equals(ship.actions.isToggled(action3), false, "action 3 activation");
});
test.case("restores as new at the end of battle", check => {
let ship = new Ship();
TestTools.setShipModel(ship, 10, 20, 5);
ship.setValue("hull", 5);
ship.setValue("shield", 15);
ship.setValue("power", 2);
ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("power_capacity", 3), 12));
ship.updateAttributes();
let action1 = new BaseAction("action1");
ship.actions.addCustom(action1);
let action2 = new ToggleAction("action2");
ship.actions.addCustom(action2);
ship.actions.toggle(action2, true);
let action3 = new ToggleAction("action3");
ship.actions.addCustom(action3);
ship.restoreInitialState();
check.in("after", check => {
check.equals(ship.getValue("hull"), 10, "hull");
check.equals(ship.getValue("shield"), 20, "shield");
check.equals(ship.getValue("power"), 5, "power");
check.equals(ship.active_effects.count(), 0, "effects count");
check.equals(ship.getAttribute("power_capacity"), 5, "power capacity");
check.equals(ship.actions.isToggled(action2), false, "action 2 activation");
check.equals(ship.actions.isToggled(action3), false, "action 3 activation");
});
});
test.case("lists active effects", check => {
let ship = new Ship();
check.equals(ship.getEffects(), []);
let effect1 = new AttributeEffect("evasion", 4);
check.patch(ship.model, "getEffects", () => [effect1]);
check.equals(ship.getEffects(), [effect1]);
let effect2 = new AttributeLimitEffect("evasion", 2);
ship.active_effects.add(new StickyEffect(effect2, 4));
check.equals(ship.getEffects(), [effect1, effect2]);
});
test.case("gets a textual description of an attribute", check => {
let ship = new Ship();
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering");
check.patch(ship, "getUpgrades", () => [
{ code: "Base", effects: [new AttributeEffect("evasion", 3)] },
{ code: "Up1", effects: [new AttributeEffect("shield_capacity", 1)] },
{ code: "Up2", effects: [new AttributeEffect("shield_capacity", 1), new AttributeEffect("evasion", 1)] }
]);
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1");
ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("evasion", 3)));
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1\nSticky effect: limit to 3");
ship.active_effects.remove(ship.active_effects.list()[0]);
ship.active_effects.add(new AttributeEffect("evasion", -1));
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1\nActive effect: -1");
});
test.case("produces death diffs", check => {
let battle = TestTools.createBattle(1);
let ship = nn(battle.playing_ship);
check.equals(ship.getDeathDiffs(battle), [
new ShipValueDiff(ship, "hull", -1),
new ShipDeathDiff(battle, ship),
]);
let effect1 = ship.active_effects.add(new AttributeEffect("shield_capacity", 2));
let effect2 = ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("evasion", 1)));
let action1 = ship.actions.addCustom(new ToggleAction("weapon1", { power: 3 }));
let action2 = ship.actions.addCustom(new ToggleAction("weapon2", { power: 3 }));
ship.actions.toggle(action2, true);
check.equals(ship.getDeathDiffs(battle), [
new ShipEffectRemovedDiff(ship, effect1),
new ShipAttributeDiff(ship, "shield_capacity", {}, { cumulative: 2 }),
new ShipEffectRemovedDiff(ship, effect2),
new ShipAttributeDiff(ship, "evasion", {}, { limit: 1 }),
new ShipActionToggleDiff(ship, action2, false),
new ShipValueDiff(ship, "hull", -1),
new ShipDeathDiff(battle, ship),
]);
});
check.in("before", check => {
check.equals(ship.getValue("hull"), 5, "hull");
check.equals(ship.getValue("shield"), 15, "shield");
check.equals(ship.getValue("power"), 2, "power");
check.equals(ship.active_effects.count(), 1, "effects count");
check.equals(ship.getAttribute("power_capacity"), 3, "power capacity");
check.equals(ship.actions.isToggled(action2), true, "action 2 activation");
check.equals(ship.actions.isToggled(action3), false, "action 3 activation");
});
}
ship.restoreInitialState();
check.in("after", check => {
check.equals(ship.getValue("hull"), 10, "hull");
check.equals(ship.getValue("shield"), 20, "shield");
check.equals(ship.getValue("power"), 5, "power");
check.equals(ship.active_effects.count(), 0, "effects count");
check.equals(ship.getAttribute("power_capacity"), 5, "power capacity");
check.equals(ship.actions.isToggled(action2), false, "action 2 activation");
check.equals(ship.actions.isToggled(action3), false, "action 3 activation");
});
});
test.case("lists active effects", check => {
let ship = new Ship();
check.equals(ship.getEffects(), []);
let effect1 = new AttributeEffect("evasion", 4);
check.patch(ship.model, "getEffects", () => [effect1]);
check.equals(ship.getEffects(), [effect1]);
let effect2 = new AttributeLimitEffect("evasion", 2);
ship.active_effects.add(new StickyEffect(effect2, 4));
check.equals(ship.getEffects(), [effect1, effect2]);
});
test.case("gets a textual description of an attribute", check => {
let ship = new Ship();
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering");
check.patch(ship, "getUpgrades", () => [
{ code: "Base", effects: [new AttributeEffect("evasion", 3)] },
{ code: "Up1", effects: [new AttributeEffect("shield_capacity", 1)] },
{ code: "Up2", effects: [new AttributeEffect("shield_capacity", 1), new AttributeEffect("evasion", 1)] }
]);
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1");
ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("evasion", 3)));
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1\nSticky effect: limit to 3");
ship.active_effects.remove(ship.active_effects.list()[0]);
ship.active_effects.add(new AttributeEffect("evasion", -1));
check.equals(ship.getAttributeDescription("evasion"), "Damage points that may be evaded by maneuvering\n\nBase: +3\nUp2: +1\nActive effect: -1");
});
test.case("produces death diffs", check => {
let battle = TestTools.createBattle(1);
let ship = nn(battle.playing_ship);
check.equals(ship.getDeathDiffs(battle), [
new ShipValueDiff(ship, "hull", -1),
new ShipDeathDiff(battle, ship),
]);
let effect1 = ship.active_effects.add(new AttributeEffect("shield_capacity", 2));
let effect2 = ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("evasion", 1)));
let action1 = ship.actions.addCustom(new ToggleAction("weapon1", { power: 3 }));
let action2 = ship.actions.addCustom(new ToggleAction("weapon2", { power: 3 }));
ship.actions.toggle(action2, true);
check.equals(ship.getDeathDiffs(battle), [
new ShipEffectRemovedDiff(ship, effect1),
new ShipAttributeDiff(ship, "shield_capacity", {}, { cumulative: 2 }),
new ShipEffectRemovedDiff(ship, effect2),
new ShipAttributeDiff(ship, "evasion", {}, { limit: 1 }),
new ShipActionToggleDiff(ship, action2, false),
new ShipValueDiff(ship, "hull", -1),
new ShipDeathDiff(battle, ship),
]);
});
});

View file

@ -1,441 +1,462 @@
/// <reference path="../common/RObject.ts" />
module TK.SpaceTac {
/**
* A single ship in a fleet
*/
export class Ship extends RObject {
// Ship model
model: ShipModel
// Fleet this ship is a member of
fleet: Fleet
// Level of this ship
level = new ShipLevel()
// Name of the ship, null if unimportant
name: string | null
// Flag indicating if the ship is alive
alive: boolean
// Flag indicating that the ship is mission critical (escorted ship)
critical = false
// Position in the arena
arena_x: number
arena_y: number
// Facing direction in the arena
arena_angle: number
// Available actions
actions = new ActionList()
// Active effects (sticky, self or area)
active_effects = new RObjectContainer<BaseEffect>()
// Ship attributes
attributes = new ShipAttributes()
// Ship values
values = new ShipValues()
// Personality
personality = new Personality()
// Boolean set to true if the ship is currently playing its turn
playing = false
// Priority in current battle's play_order (used as sort key)
play_priority = 0
// Create a new ship inside a fleet
constructor(fleet: Fleet | null = null, name: string | null = null, model = new ShipModel()) {
super();
this.fleet = fleet || new Fleet();
this.name = name;
this.alive = true;
this.arena_x = 0;
this.arena_y = 0;
this.arena_angle = 0;
this.model = model;
this.updateAttributes();
this.actions.updateFromShip(this);
this.fleet.addShip(this);
}
/**
* Return the current location and angle of this ship
*/
get location(): ArenaLocationAngle {
return new ArenaLocationAngle(this.arena_x, this.arena_y, this.arena_angle);
}
/**
* Returns the name of this ship
*/
getName(level = true): string {
let name = this.name || this.model.name;
return level ? `Level ${this.level.get()} ${name}` : name;
}
// Returns true if the ship is able to play
// If *check_ap* is true, ap_current=0 will make this function return false
isAbleToPlay(check_ap: boolean = true): boolean {
var ap_checked = !check_ap || this.getValue("power") > 0;
return this.alive && ap_checked;
}
// Set position in the arena
// This does not consumes action points
setArenaPosition(x: number, y: number) {
this.arena_x = x;
this.arena_y = y;
}
// Set facing angle in the arena
setArenaFacingAngle(angle: number) {
this.arena_angle = angle;
}
// String repr
jasmineToString(): string {
return this.getName();
}
// Make an initiative throw, to resolve play order in a battle
throwInitiative(gen: RandomGenerator): void {
this.play_priority = gen.random() * this.attributes.initiative.get();
}
/**
* Return the player that plays this ship
*/
getPlayer(): Player {
return this.fleet.player;
}
/**
* Check if a player is playing this ship
*/
isPlayedBy(player: Player): boolean {
return player.is(this.fleet.player);
}
/**
* Get the battle this ship is currently engaged in
*/
getBattle(): Battle | null {
return this.fleet.battle;
}
/**
* Get the list of activated upgrades
*/
getUpgrades(): ShipUpgrade[] {
return this.model.getActivatedUpgrades(this.level.get(), this.level.getUpgrades());
}
/**
* Refresh the actions and attributes from the bound model
*/
refreshFromModel(): void {
this.updateAttributes();
this.actions.updateFromShip(this);
}
/**
* Change the ship model
*/
setModel(model: ShipModel): void {
this.model = model;
this.level.clearUpgrades();
this.refreshFromModel();
}
/**
* Toggle an upgrade
*/
activateUpgrade(upgrade: ShipUpgrade, on: boolean): void {
if (on && (upgrade.cost || 0) > this.getAvailableUpgradePoints()) {
return;
}
this.level.activateUpgrade(upgrade, on);
this.refreshFromModel();
}
/**
* Get the number of upgrade points available
*/
getAvailableUpgradePoints(): number {
let upgrades = this.getUpgrades();
return this.level.getUpgradePoints() - sum(upgrades.map(upgrade => upgrade.cost || 0));
}
/**
* Add an event to the battle log, if any
*/
addBattleEvent(event: BaseBattleDiff): void {
var battle = this.getBattle();
if (battle && battle.log) {
battle.log.add(event);
}
}
/**
* Get a ship value
*/
getValue(name: keyof ShipValues): number {
return this.values[name];
}
/**
* Set a ship value
*/
setValue(name: keyof ShipValues, value: number, relative = false): void {
if (relative) {
value += this.values[name];
}
this.values[name] = value;
}
/**
* Get a ship attribute's current value
*/
getAttribute(name: keyof ShipAttributes): number {
if (!this.attributes.hasOwnProperty(name)) {
console.error(`No such ship attribute: ${name}`);
return 0;
}
return this.attributes[name].get();
}
/**
* Initialize the action points counter
* This should be called once at the start of a battle
* If no value is provided, the attribute power_capacity will be used
*/
private initializePower(value: number | null = null): void {
if (value === null) {
value = this.getAttribute("power_capacity");
}
this.setValue("power", value);
}
/**
* Method called at the start of battle, to restore a pristine condition on the ship
*/
restoreInitialState() {
this.alive = true;
this.actions.updateFromShip(this);
this.active_effects = new RObjectContainer();
this.updateAttributes();
this.restoreHealth();
this.initializePower();
}
/**
* Check if the ship is inside a given circular area
*/
isInCircle(x: number, y: number, radius: number): boolean {
let dx = this.arena_x - x;
let dy = this.arena_y - y;
let distance = Math.sqrt(dx * dx + dy * dy);
return distance <= radius;
}
/**
* Get the distance to another ship
*/
getDistanceTo(other: Ship): number {
return Target.newFromShip(this).getDistanceTo(Target.newFromShip(other));
}
/**
* Get the diffs needed to apply changes to a ship value
*/
getValueDiffs(name: keyof ShipValues, value: number, relative = false): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
let current = this.values[name];
if (relative) {
value += current;
}
// TODO apply range limitations
if (current != value) {
result.push(new ShipValueDiff(this, name, value - current));
}
return result;
}
/**
* Produce diffs needed to put the ship in emergency stasis
*/
getDeathDiffs(battle: Battle): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
// Remove active effects
this.active_effects.list().forEach(effect => {
if (!(effect instanceof StickyEffect)) {
result.push(new ShipEffectRemovedDiff(this, effect));
}
result = result.concat(effect.getOffDiffs(this));
});
// Deactivate toggle actions
this.getToggleActions(true).forEach(action => {
result = result.concat(action.getSpecificDiffs(this, battle, Target.newFromShip(this)));
});
// Put all values to 0
keys(SHIP_VALUES).forEach(value => {
result = result.concat(this.getValueDiffs(value, 0));
});
// Mark as dead
result.push(new ShipDeathDiff(battle, this));
return result;
}
/**
* Set the death status on this ship
*/
setDead(): void {
let battle = this.getBattle();
if (battle) {
let events = this.getDeathDiffs(battle);
battle.applyDiffs(events);
} else {
console.error("Cannot set ship dead outside of battle", this);
}
}
/**
* Update attributes, taking into account model's permanent effects and active effects
*/
updateAttributes(): void {
// Reset attributes
keys(this.attributes).forEach(attr => this.attributes[attr].reset());
// Apply attribute effects
this.getEffects().forEach(effect => {
if (effect instanceof AttributeEffect) {
this.attributes[effect.attrcode].addModifier(effect.value);
} else if (effect instanceof AttributeMultiplyEffect) {
this.attributes[effect.attrcode].addModifier(undefined, effect.value);
} else if (effect instanceof AttributeLimitEffect) {
this.attributes[effect.attrcode].addModifier(undefined, undefined, effect.value);
}
});
}
/**
* Fully restore hull and shield, at their maximal capacity
*/
restoreHealth(): void {
if (this.alive) {
this.setValue("hull", this.getAttribute("hull_capacity"));
this.setValue("shield", this.getAttribute("shield_capacity"));
}
}
/**
* Get actions from the ship model
*/
getModelActions(): BaseAction[] {
return this.model.getActions(this.level.get(), this.level.getUpgrades());
}
/**
* Get permanent effects from the ship model
*/
getModelEffects(): BaseEffect[] {
return this.model.getEffects(this.level.get(), this.level.getUpgrades());
}
/**
* Iterator over all effects active for this ship.
*
* This combines the permanent effects from ship model, with sticky and area effects.
*/
getEffects(): BaseEffect[] {
return this.getModelEffects().concat(
this.active_effects.list().map(effect => (effect instanceof StickyEffect) ? effect.base : effect)
);
}
/**
* Iterator over toggle actions
*/
getToggleActions(only_active = false): ToggleAction[] {
let result = cfilter(this.actions.listAll(), ToggleAction);
if (only_active) {
result = result.filter(action => this.actions.isToggled(action));
}
return result;
}
/**
* Get the effects that this ship has on another ship (which may be herself)
*/
getAreaEffects(ship: Ship): BaseEffect[] {
let toggled = this.getToggleActions(true);
let effects = toggled.map(action => {
if (bool(action.filterImpactedShips(this, this.location, Target.newFromShip(ship), [ship]))) {
return action.effects;
} else {
return [];
}
});
return flatten(effects);
}
/**
* Get a textual description of an attribute, and the origin of its value
*/
getAttributeDescription(attribute: keyof ShipAttributes): string {
let result = SHIP_VALUES_DESCRIPTIONS[attribute];
let diffs: string[] = [];
let limits: string[] = [];
function addEffect(base: string, effect: BaseEffect) {
if (effect instanceof AttributeEffect && effect.attrcode == attribute) {
diffs.push(`${base}: ${effect.value > 0 ? "+" + effect.value.toString() : effect.value}`);
} else if (effect instanceof AttributeLimitEffect && effect.attrcode == attribute) {
limits.push(`${base}: limit to ${effect.value}`);
}
}
this.getUpgrades().forEach(upgrade => {
if (upgrade.effects) {
upgrade.effects.forEach(effect => addEffect(upgrade.code, effect));
}
});
this.active_effects.list().forEach(effect => {
if (effect instanceof StickyEffect) {
addEffect("Sticky effect", effect.base);
} else {
addEffect("Active effect", effect);
}
});
let sources = diffs.concat(limits).join("\n");
return sources ? (result + "\n\n" + sources) : result;
}
import { RandomGenerator } from "../common/RandomGenerator"
import { RObject, RObjectContainer } from "../common/RObject"
import { bool, cfilter, flatten, keys, sum } from "../common/Tools"
import { ActionList } from "./actions/ActionList"
import { BaseAction } from "./actions/BaseAction"
import { ToggleAction } from "./actions/ToggleAction"
import { ArenaLocationAngle } from "./ArenaLocation"
import { Battle } from "./Battle"
import { BaseBattleDiff } from "./diffs/BaseBattleDiff"
import { ShipDeathDiff } from "./diffs/ShipDeathDiff"
import { ShipEffectRemovedDiff } from "./diffs/ShipEffectAddedDiff"
import { ShipValueDiff } from "./diffs/ShipValueDiff"
import { AttributeEffect } from "./effects/AttributeEffect"
import { AttributeLimitEffect } from "./effects/AttributeLimitEffect"
import { AttributeMultiplyEffect } from "./effects/AttributeMultiplyEffect"
import { BaseEffect } from "./effects/BaseEffect"
import { StickyEffect } from "./effects/StickyEffect"
import { Fleet } from "./Fleet"
import { ShipModel, ShipUpgrade } from "./models/ShipModel"
import { Personality } from "./Personality"
import { Player } from "./Player"
import { ShipLevel } from "./ShipLevel"
import { ShipAttributes, ShipValues, SHIP_VALUES, SHIP_VALUES_DESCRIPTIONS } from "./ShipValue"
import { Target } from "./Target"
/**
* A single ship in a fleet
*/
export class Ship extends RObject {
// Ship model
model: ShipModel
// Fleet this ship is a member of
fleet: Fleet
// Level of this ship
level = new ShipLevel()
// Name of the ship, null if unimportant
name: string | null
// Flag indicating if the ship is alive
alive: boolean
// Flag indicating that the ship is mission critical (escorted ship)
critical = false
// Position in the arena
arena_x: number
arena_y: number
// Facing direction in the arena
arena_angle: number
// Available actions
actions = new ActionList()
// Active effects (sticky, self or area)
active_effects = new RObjectContainer<BaseEffect>()
// Ship attributes
attributes = new ShipAttributes()
// Ship values
values = new ShipValues()
// Personality
personality = new Personality()
// Boolean set to true if the ship is currently playing its turn
playing = false
// Priority in current battle's play_order (used as sort key)
play_priority = 0
// Create a new ship inside a fleet
constructor(fleet: Fleet | null = null, name: string | null = null, model = new ShipModel()) {
super();
this.fleet = fleet || new Fleet();
this.name = name;
this.alive = true;
this.arena_x = 0;
this.arena_y = 0;
this.arena_angle = 0;
this.model = model;
this.updateAttributes();
this.actions.updateFromShip(this);
this.fleet.addShip(this);
}
/**
* Return the current location and angle of this ship
*/
get location(): ArenaLocationAngle {
return new ArenaLocationAngle(this.arena_x, this.arena_y, this.arena_angle);
}
/**
* Returns the name of this ship
*/
getName(level = true): string {
let name = this.name || this.model.name;
return level ? `Level ${this.level.get()} ${name}` : name;
}
// Returns true if the ship is able to play
// If *check_ap* is true, ap_current=0 will make this function return false
isAbleToPlay(check_ap: boolean = true): boolean {
var ap_checked = !check_ap || this.getValue("power") > 0;
return this.alive && ap_checked;
}
// Set position in the arena
// This does not consumes action points
setArenaPosition(x: number, y: number) {
this.arena_x = x;
this.arena_y = y;
}
// Set facing angle in the arena
setArenaFacingAngle(angle: number) {
this.arena_angle = angle;
}
// String repr
jasmineToString(): string {
return this.getName();
}
// Make an initiative throw, to resolve play order in a battle
throwInitiative(gen: RandomGenerator): void {
this.play_priority = gen.random() * this.attributes.initiative.get();
}
/**
* Return the player that plays this ship
*/
getPlayer(): Player {
return this.fleet.player;
}
/**
* Check if a player is playing this ship
*/
isPlayedBy(player: Player): boolean {
return player.is(this.fleet.player);
}
/**
* Get the battle this ship is currently engaged in
*/
getBattle(): Battle | null {
return this.fleet.battle;
}
/**
* Get the list of activated upgrades
*/
getUpgrades(): ShipUpgrade[] {
return this.model.getActivatedUpgrades(this.level.get(), this.level.getUpgrades());
}
/**
* Refresh the actions and attributes from the bound model
*/
refreshFromModel(): void {
this.updateAttributes();
this.actions.updateFromShip(this);
}
/**
* Change the ship model
*/
setModel(model: ShipModel): void {
this.model = model;
this.level.clearUpgrades();
this.refreshFromModel();
}
/**
* Toggle an upgrade
*/
activateUpgrade(upgrade: ShipUpgrade, on: boolean): void {
if (on && (upgrade.cost || 0) > this.getAvailableUpgradePoints()) {
return;
}
this.level.activateUpgrade(upgrade, on);
this.refreshFromModel();
}
/**
* Get the number of upgrade points available
*/
getAvailableUpgradePoints(): number {
let upgrades = this.getUpgrades();
return this.level.getUpgradePoints() - sum(upgrades.map(upgrade => upgrade.cost || 0));
}
/**
* Add an event to the battle log, if any
*/
addBattleEvent(event: BaseBattleDiff): void {
var battle = this.getBattle();
if (battle && battle.log) {
battle.log.add(event);
}
}
/**
* Get a ship value
*/
getValue(name: keyof ShipValues): number {
return this.values[name];
}
/**
* Set a ship value
*/
setValue(name: keyof ShipValues, value: number, relative = false): void {
if (relative) {
value += this.values[name];
}
this.values[name] = value;
}
/**
* Get a ship attribute's current value
*/
getAttribute(name: keyof ShipAttributes): number {
if (!this.attributes.hasOwnProperty(name)) {
console.error(`No such ship attribute: ${name}`);
return 0;
}
return this.attributes[name].get();
}
/**
* Initialize the action points counter
* This should be called once at the start of a battle
* If no value is provided, the attribute power_capacity will be used
*/
private initializePower(value: number | null = null): void {
if (value === null) {
value = this.getAttribute("power_capacity");
}
this.setValue("power", value);
}
/**
* Method called at the start of battle, to restore a pristine condition on the ship
*/
restoreInitialState() {
this.alive = true;
this.actions.updateFromShip(this);
this.active_effects = new RObjectContainer();
this.updateAttributes();
this.restoreHealth();
this.initializePower();
}
/**
* Check if the ship is inside a given circular area
*/
isInCircle(x: number, y: number, radius: number): boolean {
let dx = this.arena_x - x;
let dy = this.arena_y - y;
let distance = Math.sqrt(dx * dx + dy * dy);
return distance <= radius;
}
/**
* Get the distance to another ship
*/
getDistanceTo(other: Ship): number {
return Target.newFromShip(this).getDistanceTo(Target.newFromShip(other));
}
/**
* Get the diffs needed to apply changes to a ship value
*/
getValueDiffs(name: keyof ShipValues, value: number, relative = false): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
let current = this.values[name];
if (relative) {
value += current;
}
// TODO apply range limitations
if (current != value) {
result.push(new ShipValueDiff(this, name, value - current));
}
return result;
}
/**
* Produce diffs needed to put the ship in emergency stasis
*/
getDeathDiffs(battle: Battle): BaseBattleDiff[] {
let result: BaseBattleDiff[] = [];
// Remove active effects
this.active_effects.list().forEach(effect => {
if (!(effect instanceof StickyEffect)) {
result.push(new ShipEffectRemovedDiff(this, effect));
}
result = result.concat(effect.getOffDiffs(this));
});
// Deactivate toggle actions
this.getToggleActions(true).forEach(action => {
result = result.concat(action.getSpecificDiffs(this, battle, Target.newFromShip(this)));
});
// Put all values to 0
keys(SHIP_VALUES).forEach(value => {
result = result.concat(this.getValueDiffs(value, 0));
});
// Mark as dead
result.push(new ShipDeathDiff(battle, this));
return result;
}
/**
* Set the death status on this ship
*/
setDead(): void {
let battle = this.getBattle();
if (battle) {
let events = this.getDeathDiffs(battle);
battle.applyDiffs(events);
} else {
console.error("Cannot set ship dead outside of battle", this);
}
}
/**
* Update attributes, taking into account model's permanent effects and active effects
*/
updateAttributes(): void {
// Reset attributes
keys(this.attributes).forEach(attr => this.attributes[attr].reset());
// Apply attribute effects
this.getEffects().forEach(effect => {
if (effect instanceof AttributeEffect) {
this.attributes[effect.attrcode].addModifier(effect.value);
} else if (effect instanceof AttributeMultiplyEffect) {
this.attributes[effect.attrcode].addModifier(undefined, effect.value);
} else if (effect instanceof AttributeLimitEffect) {
this.attributes[effect.attrcode].addModifier(undefined, undefined, effect.value);
}
});
}
/**
* Fully restore hull and shield, at their maximal capacity
*/
restoreHealth(): void {
if (this.alive) {
this.setValue("hull", this.getAttribute("hull_capacity"));
this.setValue("shield", this.getAttribute("shield_capacity"));
}
}
/**
* Get actions from the ship model
*/
getModelActions(): BaseAction[] {
return this.model.getActions(this.level.get(), this.level.getUpgrades());
}
/**
* Get permanent effects from the ship model
*/
getModelEffects(): BaseEffect[] {
return this.model.getEffects(this.level.get(), this.level.getUpgrades());
}
/**
* Iterator over all effects active for this ship.
*
* This combines the permanent effects from ship model, with sticky and area effects.
*/
getEffects(): BaseEffect[] {
return this.getModelEffects().concat(
this.active_effects.list().map(effect => (effect instanceof StickyEffect) ? effect.base : effect)
);
}
/**
* Iterator over toggle actions
*/
getToggleActions(only_active = false): ToggleAction[] {
let result = cfilter(this.actions.listAll(), ToggleAction);
if (only_active) {
result = result.filter(action => this.actions.isToggled(action));
}
return result;
}
/**
* Get the effects that this ship has on another ship (which may be herself)
*/
getAreaEffects(ship: Ship): BaseEffect[] {
let toggled = this.getToggleActions(true);
let effects = toggled.map(action => {
if (bool(action.filterImpactedShips(this, this.location, Target.newFromShip(ship), [ship]))) {
return action.effects;
} else {
return [];
}
});
return flatten(effects);
}
/**
* Get a textual description of an attribute, and the origin of its value
*/
getAttributeDescription(attribute: keyof ShipAttributes): string {
let result = SHIP_VALUES_DESCRIPTIONS[attribute];
let diffs: string[] = [];
let limits: string[] = [];
function addEffect(base: string, effect: BaseEffect) {
if (effect instanceof AttributeEffect && effect.attrcode == attribute) {
diffs.push(`${base}: ${effect.value > 0 ? "+" + effect.value.toString() : effect.value}`);
} else if (effect instanceof AttributeLimitEffect && effect.attrcode == attribute) {
limits.push(`${base}: limit to ${effect.value}`);
}
}
this.getUpgrades().forEach(upgrade => {
if (upgrade.effects) {
upgrade.effects.forEach(effect => addEffect(upgrade.code, effect));
}
});
this.active_effects.list().forEach(effect => {
if (effect instanceof StickyEffect) {
addEffect("Sticky effect", effect.base);
} else {
addEffect("Active effect", effect);
}
});
let sources = diffs.concat(limits).join("\n");
return sources ? (result + "\n\n" + sources) : result;
}
}

View file

@ -1,11 +1,13 @@
module TK.SpaceTac.Specs {
testing("ShipGenerator", test => {
test.case("can use ship model", check => {
var gen = new ShipGenerator();
var model = new ShipModel("test", "Test");
var ship = gen.generate(3, model, false);
check.same(ship.model, model);
check.same(ship.level.get(), 3);
});
});
}
import { testing } from "../common/Testing";
import { ShipModel } from "./models/ShipModel";
import { ShipGenerator } from "./ShipGenerator";
testing("ShipGenerator", test => {
test.case("can use ship model", check => {
var gen = new ShipGenerator();
var model = new ShipModel("test", "Test");
var ship = gen.generate(3, model, false);
check.same(ship.model, model);
check.same(ship.level.get(), 3);
});
});

View file

@ -1,49 +1,51 @@
module TK.SpaceTac {
/**
* Generator of random ship
*/
export class ShipGenerator {
// Random number generator used
random: RandomGenerator
import { RandomGenerator } from "../common/RandomGenerator";
import { ShipModel } from "./models/ShipModel";
import { Ship } from "./Ship";
constructor(random = RandomGenerator.global) {
this.random = random;
}
/**
* Generator of random ship
*/
export class ShipGenerator {
// Random number generator used
random: RandomGenerator
/**
* Generate a ship of a givel level.
*
* If *upgrade* is true, random levelling options will be chosen
*/
generate(level: number, model: ShipModel | null = null, upgrade = true): Ship {
if (!model) {
// Get a random model
model = ShipModel.getRandomModel(level, this.random);
}
constructor(random = RandomGenerator.global) {
this.random = random;
}
let result = new Ship(null, null, model);
result.level.forceLevel(level);
if (upgrade) {
let iteration = 0;
while (iteration < 100) {
iteration += 1;
let points = result.getAvailableUpgradePoints();
let upgrades = model.getAvailableUpgrades(result.level.get()).filter(upgrade => {
return (upgrade.cost || 0) <= points && !result.level.hasUpgrade(upgrade);
});
if (upgrades.length > 0) {
let upgrade = this.random.choice(upgrades);
result.activateUpgrade(upgrade, true);
} else {
break;
}
}
}
return result;
}
/**
* Generate a ship of a givel level.
*
* If *upgrade* is true, random levelling options will be chosen
*/
generate(level: number, model: ShipModel | null = null, upgrade = true): Ship {
if (!model) {
// Get a random model
model = ShipModel.getRandomModel(level, this.random);
}
let result = new Ship(null, null, model);
result.level.forceLevel(level);
if (upgrade) {
let iteration = 0;
while (iteration < 100) {
iteration += 1;
let points = result.getAvailableUpgradePoints();
let upgrades = model.getAvailableUpgrades(result.level.get()).filter(upgrade => {
return (upgrade.cost || 0) <= points && !result.level.hasUpgrade(upgrade);
});
if (upgrades.length > 0) {
let upgrade = this.random.choice(upgrades);
result.activateUpgrade(upgrade, true);
} else {
break;
}
}
}
return result;
}
}

View file

@ -1,70 +1,71 @@
module TK.SpaceTac.Specs {
testing("ShipLevel", test => {
test.case("level up from experience points", check => {
let level = new ShipLevel();
check.equals(level.get(), 1);
check.equals(level.getNextGoal(), 100);
check.equals(level.getUpgradePoints(), 0);
import { testing } from "../common/Testing";
import { ShipLevel } from "./ShipLevel";
level.addExperience(60); // 60
check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), false);
testing("ShipLevel", test => {
test.case("level up from experience points", check => {
let level = new ShipLevel();
check.equals(level.get(), 1);
check.equals(level.getNextGoal(), 100);
check.equals(level.getUpgradePoints(), 0);
level.addExperience(70); // 130
check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 2);
check.equals(level.getNextGoal(), 300);
check.equals(level.getUpgradePoints(), 3);
level.addExperience(60); // 60
check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), false);
level.addExperience(200); // 330
check.equals(level.get(), 2);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 3);
check.equals(level.getNextGoal(), 600);
check.equals(level.getUpgradePoints(), 5);
level.addExperience(70); // 130
check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 2);
check.equals(level.getNextGoal(), 300);
check.equals(level.getUpgradePoints(), 3);
level.addExperience(320); // 650
check.equals(level.get(), 3);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 4);
check.equals(level.getNextGoal(), 1000);
check.equals(level.getUpgradePoints(), 7);
});
level.addExperience(200); // 330
check.equals(level.get(), 2);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 3);
check.equals(level.getNextGoal(), 600);
check.equals(level.getUpgradePoints(), 5);
test.case("forces a given level", check => {
let level = new ShipLevel();
check.equals(level.get(), 1);
level.addExperience(320); // 650
check.equals(level.get(), 3);
check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 4);
check.equals(level.getNextGoal(), 1000);
check.equals(level.getUpgradePoints(), 7);
});
level.forceLevel(10);
check.equals(level.get(), 10);
});
test.case("forces a given level", check => {
let level = new ShipLevel();
check.equals(level.get(), 1);
test.case("manages upgrades", check => {
let up1 = { code: "test1" };
let up2 = { code: "test2" };
level.forceLevel(10);
check.equals(level.get(), 10);
});
let level = new ShipLevel();
check.equals(level.getUpgrades(), []);
check.equals(level.hasUpgrade(up1), false);
test.case("manages upgrades", check => {
let up1 = { code: "test1" };
let up2 = { code: "test2" };
level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), true);
let level = new ShipLevel();
check.equals(level.getUpgrades(), []);
check.equals(level.hasUpgrade(up1), false);
level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), true);
level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), true);
level.activateUpgrade(up1, false);
check.equals(level.getUpgrades(), []);
check.equals(level.hasUpgrade(up1), false);
level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), true);
level.activateUpgrade(up1, true);
level.activateUpgrade(up2, true);
check.equals(level.getUpgrades(), ["test1", "test2"]);
level.clearUpgrades();
check.equals(level.getUpgrades(), []);
});
});
}
level.activateUpgrade(up1, false);
check.equals(level.getUpgrades(), []);
check.equals(level.hasUpgrade(up1), false);
level.activateUpgrade(up1, true);
level.activateUpgrade(up2, true);
check.equals(level.getUpgrades(), ["test1", "test2"]);
level.clearUpgrades();
check.equals(level.getUpgrades(), []);
});
});

View file

@ -1,119 +1,121 @@
module TK.SpaceTac {
/**
* Level and experience system for a ship, with enabled upgrades.
*/
export class ShipLevel {
private level = 1
private experience = 0
private upgrades: string[] = []
import { imap, irange, isum } from "../common/Iterators";
import { acopy, add, contains, remove } from "../common/Tools";
import { ShipUpgrade } from "./models/ShipModel";
/**
* Get current level
*/
get(): number {
return this.level;
}
/**
* Level and experience system for a ship, with enabled upgrades.
*/
export class ShipLevel {
private level = 1
private experience = 0
private upgrades: string[] = []
/**
* Get the current experience points
*/
getExperience(): number {
return this.experience;
}
/**
* Get current level
*/
get(): number {
return this.level;
}
/**
* Get the activated upgrades
*/
getUpgrades(): string[] {
return acopy(this.upgrades);
}
/**
* Get the current experience points
*/
getExperience(): number {
return this.experience;
}
/**
* Get the next experience goal to reach, to gain one level
*/
getNextGoal(): number {
return isum(imap(irange(this.level), i => (i + 1) * 100));
}
/**
* Get the activated upgrades
*/
getUpgrades(): string[] {
return acopy(this.upgrades);
}
/**
* Force experience gain, to reach a given level
*/
forceLevel(level: number): void {
while (this.level < level) {
this.forceLevelUp();
}
}
/**
* Get the next experience goal to reach, to gain one level
*/
getNextGoal(): number {
return isum(imap(irange(this.level), i => (i + 1) * 100));
}
/**
* Force a level up
*/
forceLevelUp(): void {
let old_level = this.level;
this.addExperience(this.getNextGoal() - this.experience);
this.checkLevelUp();
if (old_level >= this.level) {
// security against infinite loops
throw new Error("No effective level up");
}
}
/**
* Check for level-up
*
* Returns true if level changed
*/
checkLevelUp(): boolean {
let changed = false;
while (this.experience >= this.getNextGoal()) {
this.level++;
changed = true;
}
return changed;
}
/**
* Add experience points
*/
addExperience(points: number): void {
this.experience += points;
}
/**
* Get upgrade points given by current level
*
* This does not deduce activated upgrades usage
*/
getUpgradePoints(): number {
return this.level > 1 ? (1 + 2 * (this.level - 1)) : 0;
}
/**
* (De)Activate an upgrade
*
* This does not check the upgrade points needed
*/
activateUpgrade(upgrade: ShipUpgrade, active: boolean): void {
if (active) {
add(this.upgrades, upgrade.code);
} else {
remove(this.upgrades, upgrade.code);
}
}
/**
* Check if an upgrade is active
*/
hasUpgrade(upgrade: ShipUpgrade): boolean {
return contains(this.upgrades, upgrade.code);
}
/**
* Clear all activated upgrades
*/
clearUpgrades(): void {
this.upgrades = [];
}
/**
* Force experience gain, to reach a given level
*/
forceLevel(level: number): void {
while (this.level < level) {
this.forceLevelUp();
}
}
/**
* Force a level up
*/
forceLevelUp(): void {
let old_level = this.level;
this.addExperience(this.getNextGoal() - this.experience);
this.checkLevelUp();
if (old_level >= this.level) {
// security against infinite loops
throw new Error("No effective level up");
}
}
/**
* Check for level-up
*
* Returns true if level changed
*/
checkLevelUp(): boolean {
let changed = false;
while (this.experience >= this.getNextGoal()) {
this.level++;
changed = true;
}
return changed;
}
/**
* Add experience points
*/
addExperience(points: number): void {
this.experience += points;
}
/**
* Get upgrade points given by current level
*
* This does not deduce activated upgrades usage
*/
getUpgradePoints(): number {
return this.level > 1 ? (1 + 2 * (this.level - 1)) : 0;
}
/**
* (De)Activate an upgrade
*
* This does not check the upgrade points needed
*/
activateUpgrade(upgrade: ShipUpgrade, active: boolean): void {
if (active) {
add(this.upgrades, upgrade.code);
} else {
remove(this.upgrades, upgrade.code);
}
}
/**
* Check if an upgrade is active
*/
hasUpgrade(upgrade: ShipUpgrade): boolean {
return contains(this.upgrades, upgrade.code);
}
/**
* Clear all activated upgrades
*/
clearUpgrades(): void {
this.upgrades = [];
}
}

View file

@ -1,47 +1,48 @@
module TK.SpaceTac {
testing("ShipAttribute", test => {
test.case("applies cumulative, multiplier and limit", check => {
let attribute = new ShipAttribute();
check.equals(attribute.get(), 0, "initial");
import { testing } from "../common/Testing";
import { ShipAttribute } from "./ShipValue";
attribute.addModifier(4);
check.in("+4", check => {
check.equals(attribute.get(), 4, "effective value");
});
testing("ShipAttribute", test => {
test.case("applies cumulative, multiplier and limit", check => {
let attribute = new ShipAttribute();
check.equals(attribute.get(), 0, "initial");
attribute.addModifier(2);
check.in("+4 +2", check => {
check.equals(attribute.get(), 6, "effective value");
});
attribute.addModifier(undefined, 20);
check.in("+4 +2 +20%", check => {
check.equals(attribute.get(), 7, "effective value");
});
attribute.addModifier(undefined, 5);
check.in("+4 +2 +20% +5%", check => {
check.equals(attribute.get(), 8, "effective value");
check.equals(attribute.getMaximal(), Infinity, "maximal value");
});
attribute.addModifier(undefined, undefined, 6);
check.in("+4 +2 +20% +5% lim6", check => {
check.equals(attribute.get(), 6, "effective value");
check.equals(attribute.getMaximal(), 6, "maximal value");
});
attribute.addModifier(undefined, undefined, 4);
check.in("+4 +2 +20% +5% lim6 lim4", check => {
check.equals(attribute.get(), 4, "effective value");
check.equals(attribute.getMaximal(), 4, "maximal value");
});
attribute.addModifier(undefined, undefined, 10);
check.in("+4 +2 +20% +5% lim6 lim4 lim10", check => {
check.equals(attribute.get(), 4, "effective value");
check.equals(attribute.getMaximal(), 4, "maximal value");
});
});
attribute.addModifier(4);
check.in("+4", check => {
check.equals(attribute.get(), 4, "effective value");
});
}
attribute.addModifier(2);
check.in("+4 +2", check => {
check.equals(attribute.get(), 6, "effective value");
});
attribute.addModifier(undefined, 20);
check.in("+4 +2 +20%", check => {
check.equals(attribute.get(), 7, "effective value");
});
attribute.addModifier(undefined, 5);
check.in("+4 +2 +20% +5%", check => {
check.equals(attribute.get(), 8, "effective value");
check.equals(attribute.getMaximal(), Infinity, "maximal value");
});
attribute.addModifier(undefined, undefined, 6);
check.in("+4 +2 +20% +5% lim6", check => {
check.equals(attribute.get(), 6, "effective value");
check.equals(attribute.getMaximal(), 6, "maximal value");
});
attribute.addModifier(undefined, undefined, 4);
check.in("+4 +2 +20% +5% lim6 lim4", check => {
check.equals(attribute.get(), 4, "effective value");
check.equals(attribute.getMaximal(), 4, "maximal value");
});
attribute.addModifier(undefined, undefined, 10);
check.in("+4 +2 +20% +5% lim6 lim4 lim10", check => {
check.equals(attribute.get(), 4, "effective value");
check.equals(attribute.getMaximal(), 4, "maximal value");
});
});
});

View file

@ -1,155 +1,155 @@
module TK.SpaceTac {
type ShipValuesMapping = {
[P in (keyof ShipValues | keyof ShipAttributes)]: string
}
import { min, remove, sum } from "../common/Tools"
export const SHIP_VALUES_DESCRIPTIONS: ShipValuesMapping = {
"initiative": "Capacity to play before others in a battle",
"hull": "Physical structure of the ship",
"shield": "Shield around the ship that may absorb damage",
"power": "Power available to supply the equipments",
"hull_capacity": "Maximal Hull value before the ship risks collapsing",
"shield_capacity": "Maximal Shield value to protect the hull from damage",
"power_capacity": "Maximal Power value to use equipment",
"evasion": "Damage points that may be evaded by maneuvering",
}
export const SHIP_VALUES_NAMES: ShipValuesMapping = {
"initiative": "initiative",
"hull": "hull",
"shield": "shield",
"power": "power",
"hull_capacity": "hull capacity",
"shield_capacity": "shield capacity",
"power_capacity": "power capacity",
"evasion": "evasion",
}
/**
* A ship attribute is a number resulting of a list of modifiers.
*/
export class ShipAttribute {
// Current value
private current = 0
// Modifiers
private cumulatives: number[] = []
private multipliers: number[] = []
private limits: number[] = []
/**
* Get the current value
*/
get(): number {
return this.current;
}
/**
* Get the maximal value enforced by limit modifiers, Infinity for unlimited
*/
getMaximal(): number {
if (this.limits.length > 0) {
return min(this.limits);
} else {
return Infinity;
}
}
/**
* Reset all modifiers
*/
reset(): void {
this.cumulatives = [];
this.multipliers = [];
this.limits = [];
this.update();
}
/**
* Add a modifier
*/
addModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") {
this.cumulatives.push(cumulative);
}
if (typeof multiplier != "undefined") {
this.multipliers.push(multiplier);
}
if (typeof limit != "undefined") {
this.limits.push(limit);
}
this.update();
}
/**
* Remove a modifier
*/
removeModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") {
remove(this.cumulatives, cumulative);
}
if (typeof multiplier != "undefined") {
remove(this.multipliers, multiplier);
}
if (typeof limit != "undefined") {
remove(this.limits, limit);
}
this.update();
}
/**
* Update the current value
*/
private update(): void {
let value = sum(this.cumulatives);
if (this.multipliers.length) {
value = Math.round(value * (1 + sum(this.multipliers) / 100));
}
if (this.limits.length) {
value = Math.min(value, min(this.limits));
}
this.current = value;
}
}
/**
* Set of ShipAttribute for a ship
*/
export class ShipAttributes {
// Initiative (capacity to play first)
initiative = new ShipAttribute()
// Maximal hull value
hull_capacity = new ShipAttribute()
// Maximal shield value
shield_capacity = new ShipAttribute()
// Damage evasion
evasion = new ShipAttribute()
// Maximal power value
power_capacity = new ShipAttribute()
}
/**
* Set of simple values for a ship
*/
export class ShipValues {
hull = 0
shield = 0
power = 0
}
/**
* Static attributes and values object for property queries
*/
export const SHIP_ATTRIBUTES = new ShipAttributes();
export const SHIP_VALUES = new ShipValues();
/**
* Type guards
*/
export function isShipValue(key: string): key is keyof ShipValues {
return SHIP_VALUES.hasOwnProperty(key);
}
export function isShipAttribute(key: string): key is keyof ShipAttributes {
return SHIP_ATTRIBUTES.hasOwnProperty(key);
}
type ShipValuesMapping = {
[P in (keyof ShipValues | keyof ShipAttributes)]: string
}
export const SHIP_VALUES_DESCRIPTIONS: ShipValuesMapping = {
"initiative": "Capacity to play before others in a battle",
"hull": "Physical structure of the ship",
"shield": "Shield around the ship that may absorb damage",
"power": "Power available to supply the equipments",
"hull_capacity": "Maximal Hull value before the ship risks collapsing",
"shield_capacity": "Maximal Shield value to protect the hull from damage",
"power_capacity": "Maximal Power value to use equipment",
"evasion": "Damage points that may be evaded by maneuvering",
}
export const SHIP_VALUES_NAMES: ShipValuesMapping = {
"initiative": "initiative",
"hull": "hull",
"shield": "shield",
"power": "power",
"hull_capacity": "hull capacity",
"shield_capacity": "shield capacity",
"power_capacity": "power capacity",
"evasion": "evasion",
}
/**
* A ship attribute is a number resulting of a list of modifiers.
*/
export class ShipAttribute {
// Current value
private current = 0
// Modifiers
private cumulatives: number[] = []
private multipliers: number[] = []
private limits: number[] = []
/**
* Get the current value
*/
get(): number {
return this.current;
}
/**
* Get the maximal value enforced by limit modifiers, Infinity for unlimited
*/
getMaximal(): number {
if (this.limits.length > 0) {
return min(this.limits);
} else {
return Infinity;
}
}
/**
* Reset all modifiers
*/
reset(): void {
this.cumulatives = [];
this.multipliers = [];
this.limits = [];
this.update();
}
/**
* Add a modifier
*/
addModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") {
this.cumulatives.push(cumulative);
}
if (typeof multiplier != "undefined") {
this.multipliers.push(multiplier);
}
if (typeof limit != "undefined") {
this.limits.push(limit);
}
this.update();
}
/**
* Remove a modifier
*/
removeModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") {
remove(this.cumulatives, cumulative);
}
if (typeof multiplier != "undefined") {
remove(this.multipliers, multiplier);
}
if (typeof limit != "undefined") {
remove(this.limits, limit);
}
this.update();
}
/**
* Update the current value
*/
private update(): void {
let value = sum(this.cumulatives);
if (this.multipliers.length) {
value = Math.round(value * (1 + sum(this.multipliers) / 100));
}
if (this.limits.length) {
value = Math.min(value, min(this.limits));
}
this.current = value;
}
}
/**
* Set of ShipAttribute for a ship
*/
export class ShipAttributes {
// Initiative (capacity to play first)
initiative = new ShipAttribute()
// Maximal hull value
hull_capacity = new ShipAttribute()
// Maximal shield value
shield_capacity = new ShipAttribute()
// Damage evasion
evasion = new ShipAttribute()
// Maximal power value
power_capacity = new ShipAttribute()
}
/**
* Set of simple values for a ship
*/
export class ShipValues {
hull = 0
shield = 0
power = 0
}
/**
* Static attributes and values object for property queries
*/
export const SHIP_ATTRIBUTES = new ShipAttributes();
export const SHIP_VALUES = new ShipValues();
/**
* Type guards
*/
export function isShipValue(key: string): key is keyof ShipValues {
return SHIP_VALUES.hasOwnProperty(key);
}
export function isShipAttribute(key: string): key is keyof ShipAttributes {
return SHIP_ATTRIBUTES.hasOwnProperty(key);
}

View file

@ -1,39 +1,44 @@
module TK.SpaceTac.Specs {
testing("Shop", test => {
test.case("generates secondary missions", check => {
let universe = new Universe();
universe.generate(4);
let start = universe.getStartLocation();
import { testing } from "../common/Testing";
import { Mission } from "./missions/Mission";
import { Player } from "./Player";
import { Shop } from "./Shop";
import { StarLocation } from "./StarLocation";
import { Universe } from "./Universe";
let shop = new Shop();
check.equals((<any>shop).missions.length, 0);
testing("Shop", test => {
test.case("generates secondary missions", check => {
let universe = new Universe();
universe.generate(4);
let start = universe.getStartLocation();
let result = shop.getMissions(start, 4);
check.equals(result.length, 4);
check.equals((<any>shop).missions.length, 4);
let shop = new Shop();
check.equals((<any>shop).missions.length, 0);
let oresult = shop.getMissions(start, 4);
check.equals(oresult, result);
let result = shop.getMissions(start, 4);
check.equals(result.length, 4);
check.equals((<any>shop).missions.length, 4);
result.forEach(mission => {
check.equals(mission.main, false);
});
});
let oresult = shop.getMissions(start, 4);
check.equals(oresult, result);
test.case("assigns missions to a fleet", check => {
let shop = new Shop();
let player = new Player();
let mission = new Mission(new Universe());
(<any>shop).missions = [mission];
check.equals(shop.getMissions(new StarLocation(), 1), [mission]);
check.equals(player.missions.secondary, []);
shop.acceptMission(mission, player);
check.equals((<any>shop).missions, []);
check.equals(player.missions.secondary, [mission]);
check.same(mission.fleet, player.fleet);
});
result.forEach(mission => {
check.equals(mission.main, false);
});
}
});
test.case("assigns missions to a fleet", check => {
let shop = new Shop();
let player = new Player();
let mission = new Mission(new Universe());
(<any>shop).missions = [mission];
check.equals(shop.getMissions(new StarLocation(), 1), [mission]);
check.equals(player.missions.secondary, []);
shop.acceptMission(mission, player);
check.equals((<any>shop).missions, []);
check.equals(player.missions.secondary, [mission]);
check.same(mission.fleet, player.fleet);
});
});

View file

@ -1,51 +1,56 @@
module TK.SpaceTac {
/**
* A shop is a place to buy/sell equipments
*/
export class Shop {
// Average level of equipment
private level: number
import { RandomGenerator } from "../common/RandomGenerator";
import { contains, remove } from "../common/Tools";
import { Mission } from "./missions/Mission";
import { MissionGenerator } from "./missions/MissionGenerator";
import { Player } from "./Player";
import { StarLocation } from "./StarLocation";
// Random generator
private random: RandomGenerator
/**
* A shop is a place to buy/sell equipments
*/
export class Shop {
// Average level of equipment
private level: number
// Available missions
private missions: Mission[] = []
// Random generator
private random: RandomGenerator
constructor(level = 1) {
this.level = level;
this.random = new RandomGenerator();
}
// Available missions
private missions: Mission[] = []
/**
* Get a list of available secondary missions
*/
getMissions(around: StarLocation, max_count = 3): Mission[] {
while (this.missions.length < max_count) {
let generator = new MissionGenerator(around.star.universe, around, this.random);
let mission = generator.generate();
this.missions.push(mission);
}
constructor(level = 1) {
this.level = level;
this.random = new RandomGenerator();
}
return this.missions;
}
/**
* Assign a mission to a fleet
*
* Returns true on success
*/
acceptMission(mission: Mission, player: Player): boolean {
if (contains(this.missions, mission)) {
if (player.missions.addSecondary(mission, player.fleet)) {
remove(this.missions, mission);
return true;
} else {
return false;
}
} else {
return false;
}
}
/**
* Get a list of available secondary missions
*/
getMissions(around: StarLocation, max_count = 3): Mission[] {
while (this.missions.length < max_count) {
let generator = new MissionGenerator(around.star.universe, around, this.random);
let mission = generator.generate();
this.missions.push(mission);
}
}
return this.missions;
}
/**
* Assign a mission to a fleet
*
* Returns true on success
*/
acceptMission(mission: Mission, player: Player): boolean {
if (contains(this.missions, mission)) {
if (player.missions.addSecondary(mission, player.fleet)) {
remove(this.missions, mission);
return true;
} else {
return false;
}
} else {
return false;
}
}
}

View file

@ -1,36 +1,39 @@
module TK.SpaceTac.Specs {
testing("Star", test => {
test.case("lists links to other stars", check => {
var universe = new Universe();
universe.stars.push(new Star(universe, 0, 0, "Star A"));
universe.stars.push(new Star(universe, 1, 0, "Star B"));
universe.stars.push(new Star(universe, 0, 1, "Star C"));
universe.stars.push(new Star(universe, 1, 1, "Star D"));
universe.addLink(universe.stars[0], universe.stars[1]);
universe.addLink(universe.stars[0], universe.stars[3]);
import { testing } from "../common/Testing";
import { Star } from "./Star";
import { StarLink } from "./StarLink";
import { Universe } from "./Universe";
var result = universe.stars[0].getLinks();
check.equals(result.length, 2);
check.equals(result[0], new StarLink(universe.stars[0], universe.stars[1]));
check.equals(result[1], new StarLink(universe.stars[0], universe.stars[3]));
testing("Star", test => {
test.case("lists links to other stars", check => {
var universe = new Universe();
universe.stars.push(new Star(universe, 0, 0, "Star A"));
universe.stars.push(new Star(universe, 1, 0, "Star B"));
universe.stars.push(new Star(universe, 0, 1, "Star C"));
universe.stars.push(new Star(universe, 1, 1, "Star D"));
universe.addLink(universe.stars[0], universe.stars[1]);
universe.addLink(universe.stars[0], universe.stars[3]);
check.equals(universe.stars[0].getLinkTo(universe.stars[1]), universe.starlinks[0]);
check.equals(universe.stars[0].getLinkTo(universe.stars[2]), null);
check.equals(universe.stars[0].getLinkTo(universe.stars[3]), universe.starlinks[1]);
check.equals(universe.stars[1].getLinkTo(universe.stars[0]), universe.starlinks[0]);
check.equals(universe.stars[1].getLinkTo(universe.stars[2]), null);
check.equals(universe.stars[1].getLinkTo(universe.stars[3]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[0]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[1]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[3]), null);
check.equals(universe.stars[3].getLinkTo(universe.stars[0]), universe.starlinks[1]);
check.equals(universe.stars[3].getLinkTo(universe.stars[1]), null);
check.equals(universe.stars[3].getLinkTo(universe.stars[2]), null);
var result = universe.stars[0].getLinks();
check.equals(result.length, 2);
check.equals(result[0], new StarLink(universe.stars[0], universe.stars[1]));
check.equals(result[1], new StarLink(universe.stars[0], universe.stars[3]));
let neighbors = universe.stars[0].getNeighbors();
check.equals(neighbors.length, 2);
check.contains(neighbors, universe.stars[1]);
check.contains(neighbors, universe.stars[3]);
});
});
}
check.equals(universe.stars[0].getLinkTo(universe.stars[1]), universe.starlinks[0]);
check.equals(universe.stars[0].getLinkTo(universe.stars[2]), null);
check.equals(universe.stars[0].getLinkTo(universe.stars[3]), universe.starlinks[1]);
check.equals(universe.stars[1].getLinkTo(universe.stars[0]), universe.starlinks[0]);
check.equals(universe.stars[1].getLinkTo(universe.stars[2]), null);
check.equals(universe.stars[1].getLinkTo(universe.stars[3]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[0]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[1]), null);
check.equals(universe.stars[2].getLinkTo(universe.stars[3]), null);
check.equals(universe.stars[3].getLinkTo(universe.stars[0]), universe.starlinks[1]);
check.equals(universe.stars[3].getLinkTo(universe.stars[1]), null);
check.equals(universe.stars[3].getLinkTo(universe.stars[2]), null);
let neighbors = universe.stars[0].getNeighbors();
check.equals(neighbors.length, 2);
check.contains(neighbors, universe.stars[1]);
check.contains(neighbors, universe.stars[3]);
});
});

View file

@ -1,198 +1,202 @@
module TK.SpaceTac {
// A star system
export class Star {
import { RandomGenerator } from "../common/RandomGenerator";
import { nna } from "../common/Tools";
import { StarLink } from "./StarLink";
import { StarLocation, StarLocationType } from "./StarLocation";
import { Universe } from "./Universe";
// Available names for star systems
static NAMES_POOL = [
"Alpha Prime",
"Bright Skies",
"Costan Sector",
"Duncan's Legacy",
"Ethiopea",
"Fringe Space",
"Gesurd Deep",
"Helios",
"Justice Enclave",
"Kovak Second",
"Lumen Circle",
"Manoa Society",
"Neptune's Record",
"Ominous Murmur",
"Pirate's Landing",
"Quasuc Effect",
"Roaring Thunder",
"Safe Passage",
"Time Holes",
"Unknown Territory",
"Vulcan Terror",
"Wings Aurora",
"Xenos Trading",
"Yu's Pride",
"Zoki's Hammer",
"Astral Tempest",
"Burned Star",
"Crystal Bride",
"Death Star",
"Ether Bending",
"Forgotten Realm",
"Galactic Ring",
"Hegemonia",
"Jorgon Trails",
"Kemini System",
"Light Rain",
"Moons Astride",
"Nubia's Sisters",
"Opium Hide",
"Paradise Quest",
"Quarter Horizon",
"Rising Dust",
"Silence of Forge",
"Titan Feet",
"Unicorn Fly",
"Violated Sanctuary",
"World's Repose",
"Xanthia's Travel",
"Yggdrasil",
"Zone of Ending",
];
// A star system
export class Star {
// Parent universe
universe: Universe;
// Available names for star systems
static NAMES_POOL = [
"Alpha Prime",
"Bright Skies",
"Costan Sector",
"Duncan's Legacy",
"Ethiopea",
"Fringe Space",
"Gesurd Deep",
"Helios",
"Justice Enclave",
"Kovak Second",
"Lumen Circle",
"Manoa Society",
"Neptune's Record",
"Ominous Murmur",
"Pirate's Landing",
"Quasuc Effect",
"Roaring Thunder",
"Safe Passage",
"Time Holes",
"Unknown Territory",
"Vulcan Terror",
"Wings Aurora",
"Xenos Trading",
"Yu's Pride",
"Zoki's Hammer",
"Astral Tempest",
"Burned Star",
"Crystal Bride",
"Death Star",
"Ether Bending",
"Forgotten Realm",
"Galactic Ring",
"Hegemonia",
"Jorgon Trails",
"Kemini System",
"Light Rain",
"Moons Astride",
"Nubia's Sisters",
"Opium Hide",
"Paradise Quest",
"Quarter Horizon",
"Rising Dust",
"Silence of Forge",
"Titan Feet",
"Unicorn Fly",
"Violated Sanctuary",
"World's Repose",
"Xanthia's Travel",
"Yggdrasil",
"Zone of Ending",
];
// Name of the system (unique in the universe)
name: string;
// Parent universe
universe: Universe;
// Location in the universe
x: number;
y: number;
// Name of the system (unique in the universe)
name: string;
// Radius of the star system
radius: number;
// Location in the universe
x: number;
y: number;
// List of points of interest
locations: StarLocation[];
// Radius of the star system
radius: number;
// Base level for encounters in this system
level: number;
// List of points of interest
locations: StarLocation[];
constructor(universe: Universe | null = null, x = 0, y = 0, name = "") {
this.universe = universe || new Universe();
this.x = x;
this.y = y;
this.radius = 0.1;
this.locations = [new StarLocation(this, StarLocationType.STAR, 0, 0)];
this.level = 1;
this.name = name;
}
// Base level for encounters in this system
level: number;
jasmineToString(): string {
return `Star ${this.name}`;
}
constructor(universe: Universe | null = null, x = 0, y = 0, name = "") {
this.universe = universe || new Universe();
this.x = x;
this.y = y;
this.radius = 0.1;
this.locations = [new StarLocation(this, StarLocationType.STAR, 0, 0)];
this.level = 1;
this.name = name;
}
/**
* Add a location of interest
*/
addLocation(type: StarLocationType): StarLocation {
let result = new StarLocation(this, type);
this.locations.push(result);
return result;
}
jasmineToString(): string {
return `Star ${this.name}`;
}
// Get the distance to another star
getDistanceTo(star: Star): number {
var dx = this.x - star.x;
var dy = this.y - star.y;
/**
* Add a location of interest
*/
addLocation(type: StarLocationType): StarLocation {
let result = new StarLocation(this, type);
this.locations.push(result);
return result;
}
return Math.sqrt(dx * dx + dy * dy);
}
// Get the distance to another star
getDistanceTo(star: Star): number {
var dx = this.x - star.x;
var dy = this.y - star.y;
// Generate the contents of this star system
generate(random = RandomGenerator.global): void {
var location_count = random.randInt(2 + Math.floor(this.level / 2), 3 + this.level);
if (this.name.length == 0) {
this.name = random.choice(Star.NAMES_POOL);
}
this.generateLocations(location_count, random);
}
return Math.sqrt(dx * dx + dy * dy);
}
// Generate points of interest (*count* doesn't include the star and warp locations)
generateLocations(count: number, random = RandomGenerator.global): void {
while (count--) {
this.generateOneLocation(StarLocationType.PLANET, this.locations, this.radius * 0.2, this.radius * 0.6, random);
}
}
// Generate a warp location to another star (to be bound later)
generateWarpLocationTo(other: Star, random = RandomGenerator.global): StarLocation {
let fav_phi = Math.atan2(other.y - this.y, other.x - this.x);
var warp = this.generateOneLocation(StarLocationType.WARP, this.locations, this.radius * 0.75, this.radius * 0.85, random, fav_phi);
return warp;
}
// Get all direct links to other stars
getLinks(all = this.universe.starlinks): StarLink[] {
var result: StarLink[] = [];
all.forEach(link => {
if (link.first === this || link.second === this) {
result.push(link);
}
});
return result;
}
// Get the link to another star, null if not found
getLinkTo(other: Star, all = this.universe.starlinks): StarLink | null {
var result: StarLink | null = null;
all.forEach(link => {
if (link.isLinking(this, other)) {
result = link;
}
});
return result;
}
// Get the warp location to another star, null if not found
getWarpLocationTo(other: Star): StarLocation | null {
var result: StarLocation | null = null;
this.locations.forEach(location => {
if (location.type == StarLocationType.WARP && location.jump_dest && location.jump_dest.star == other) {
result = location;
}
});
return result;
}
/**
* Get the neighboring star systems (single jump accessible)
*/
getNeighbors(all = this.universe.starlinks): Star[] {
return nna(this.getLinks(all).map(link => link.getPeer(this)));
}
// Check if a location is far enough from all other ones
private checkMinDistance(loc: StarLocation, others: StarLocation[]): boolean {
return others.every((iloc: StarLocation): boolean => {
return iloc.getDistanceTo(loc) > this.radius * 0.15;
});
}
// Generate a single location
private generateOneLocation(type: StarLocationType, others: StarLocation[], radius_min: number, radius_max: number, random: RandomGenerator, fav_phi: number | null = null): StarLocation {
do {
var phi = fav_phi ? (fav_phi + random.random() * 0.4 - 0.2) : (random.random() * Math.PI * 2);
var r = random.random() * (radius_max - radius_min) + radius_min;
var result = new StarLocation(this, type, r * Math.cos(phi), r * Math.sin(phi));
} while (!this.checkMinDistance(result, others));
this.locations.push(result);
return result;
}
// Generate the contents of this star system
generate(random = RandomGenerator.global): void {
var location_count = random.randInt(2 + Math.floor(this.level / 2), 3 + this.level);
if (this.name.length == 0) {
this.name = random.choice(Star.NAMES_POOL);
}
this.generateLocations(location_count, random);
}
// Generate points of interest (*count* doesn't include the star and warp locations)
generateLocations(count: number, random = RandomGenerator.global): void {
while (count--) {
this.generateOneLocation(StarLocationType.PLANET, this.locations, this.radius * 0.2, this.radius * 0.6, random);
}
}
// Generate a warp location to another star (to be bound later)
generateWarpLocationTo(other: Star, random = RandomGenerator.global): StarLocation {
let fav_phi = Math.atan2(other.y - this.y, other.x - this.x);
var warp = this.generateOneLocation(StarLocationType.WARP, this.locations, this.radius * 0.75, this.radius * 0.85, random, fav_phi);
return warp;
}
// Get all direct links to other stars
getLinks(all = this.universe.starlinks): StarLink[] {
var result: StarLink[] = [];
all.forEach(link => {
if (link.first === this || link.second === this) {
result.push(link);
}
});
return result;
}
// Get the link to another star, null if not found
getLinkTo(other: Star, all = this.universe.starlinks): StarLink | null {
var result: StarLink | null = null;
all.forEach(link => {
if (link.isLinking(this, other)) {
result = link;
}
});
return result;
}
// Get the warp location to another star, null if not found
getWarpLocationTo(other: Star): StarLocation | null {
var result: StarLocation | null = null;
this.locations.forEach(location => {
if (location.type == StarLocationType.WARP && location.jump_dest && location.jump_dest.star == other) {
result = location;
}
});
return result;
}
/**
* Get the neighboring star systems (single jump accessible)
*/
getNeighbors(all = this.universe.starlinks): Star[] {
return nna(this.getLinks(all).map(link => link.getPeer(this)));
}
// Check if a location is far enough from all other ones
private checkMinDistance(loc: StarLocation, others: StarLocation[]): boolean {
return others.every((iloc: StarLocation): boolean => {
return iloc.getDistanceTo(loc) > this.radius * 0.15;
});
}
// Generate a single location
private generateOneLocation(type: StarLocationType, others: StarLocation[], radius_min: number, radius_max: number, random: RandomGenerator, fav_phi: number | null = null): StarLocation {
do {
var phi = fav_phi ? (fav_phi + random.random() * 0.4 - 0.2) : (random.random() * Math.PI * 2);
var r = random.random() * (radius_max - radius_min) + radius_min;
var result = new StarLocation(this, type, r * Math.cos(phi), r * Math.sin(phi));
} while (!this.checkMinDistance(result, others));
this.locations.push(result);
return result;
}
}

View file

@ -1,38 +1,40 @@
module TK.SpaceTac.Specs {
testing("StarLink", test => {
test.case("checks link intersection", check => {
var star1 = new Star(null, 0, 0);
var star2 = new Star(null, 0, 1);
var star3 = new Star(null, 1, 0);
var star4 = new Star(null, 1, 1);
var link1 = new StarLink(star1, star2);
var link2 = new StarLink(star1, star3);
var link3 = new StarLink(star1, star4);
var link4 = new StarLink(star2, star3);
var link5 = new StarLink(star2, star4);
var link6 = new StarLink(star3, star4);
var links = [link1, link2, link3, link4, link5, link6];
links.forEach((first: StarLink) => {
links.forEach((second: StarLink) => {
if (first !== second) {
var expected = (first === link3 && second === link4) ||
(first === link4 && second === link3);
check.same(first.isCrossing(second), expected);
check.same(second.isCrossing(first), expected);
}
});
});
});
import { testing } from "../common/Testing";
import { Star } from "./Star";
import { StarLink } from "./StarLink";
test.case("gets the peer of a given sector", check => {
var star1 = new Star(null, 0, 0);
var star2 = new Star(null, 0, 1);
var star3 = new Star(null, 0, 1);
var link1 = new StarLink(star1, star2);
check.same(link1.getPeer(star1), star2);
check.same(link1.getPeer(star2), star1);
check.equals(link1.getPeer(star3), null);
});
testing("StarLink", test => {
test.case("checks link intersection", check => {
var star1 = new Star(null, 0, 0);
var star2 = new Star(null, 0, 1);
var star3 = new Star(null, 1, 0);
var star4 = new Star(null, 1, 1);
var link1 = new StarLink(star1, star2);
var link2 = new StarLink(star1, star3);
var link3 = new StarLink(star1, star4);
var link4 = new StarLink(star2, star3);
var link5 = new StarLink(star2, star4);
var link6 = new StarLink(star3, star4);
var links = [link1, link2, link3, link4, link5, link6];
links.forEach((first: StarLink) => {
links.forEach((second: StarLink) => {
if (first !== second) {
var expected = (first === link3 && second === link4) ||
(first === link4 && second === link3);
check.same(first.isCrossing(second), expected);
check.same(second.isCrossing(first), expected);
}
});
});
}
});
test.case("gets the peer of a given sector", check => {
var star1 = new Star(null, 0, 0);
var star2 = new Star(null, 0, 1);
var star3 = new Star(null, 0, 1);
var link1 = new StarLink(star1, star2);
check.same(link1.getPeer(star1), star2);
check.same(link1.getPeer(star2), star1);
check.equals(link1.getPeer(star3), null);
});
});

View file

@ -1,49 +1,49 @@
module TK.SpaceTac {
// An hyperspace link between two star systems
export class StarLink {
// Stars
first: Star;
second: Star;
import { Star } from "./Star";
constructor(first: Star, second: Star) {
this.first = first;
this.second = second;
}
// An hyperspace link between two star systems
export class StarLink {
// Stars
first: Star;
second: Star;
// Check if this links bounds the two stars together, in either way
isLinking(first: Star, second: Star) {
return (this.first === first && this.second === second) || (this.first === second && this.second === first);
}
constructor(first: Star, second: Star) {
this.first = first;
this.second = second;
}
// Get the length of a link
getLength(): number {
return this.first.getDistanceTo(this.second);
}
// Check if this links bounds the two stars together, in either way
isLinking(first: Star, second: Star) {
return (this.first === first && this.second === second) || (this.first === second && this.second === first);
}
// Check if this link crosses another
isCrossing(other: StarLink): boolean {
if (this.first === other.first || this.second === other.first || this.first === other.second || this.second === other.second) {
return false;
}
var ccw = (a: Star, b: Star, c: Star): boolean => {
return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
};
var cc1 = ccw(this.first, other.first, other.second);
var cc2 = ccw(this.second, other.first, other.second);
var cc3 = ccw(this.first, this.second, other.first);
var cc4 = ccw(this.first, this.second, other.second);
return cc1 !== cc2 && cc3 !== cc4;
}
// Get the length of a link
getLength(): number {
return this.first.getDistanceTo(this.second);
}
// Get the other side of the link, for a given side
getPeer(star: Star): Star | null {
if (star === this.first) {
return this.second;
} else if (star === this.second) {
return this.first;
} else {
return null;
}
}
// Check if this link crosses another
isCrossing(other: StarLink): boolean {
if (this.first === other.first || this.second === other.first || this.first === other.second || this.second === other.second) {
return false;
}
var ccw = (a: Star, b: Star, c: Star): boolean => {
return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
};
var cc1 = ccw(this.first, other.first, other.second);
var cc2 = ccw(this.second, other.first, other.second);
var cc3 = ccw(this.first, this.second, other.first);
var cc4 = ccw(this.first, this.second, other.second);
return cc1 !== cc2 && cc3 !== cc4;
}
// Get the other side of the link, for a given side
getPeer(star: Star): Star | null {
if (star === this.first) {
return this.second;
} else if (star === this.second) {
return this.first;
} else {
return null;
}
}
}

View file

@ -1,37 +1,41 @@
module TK.SpaceTac.Specs {
testing("StarLocation", test => {
test.case("removes generated encounters that lose", check => {
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0);
var fleet = new Fleet();
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
import { SkewedRandomGenerator } from "../common/RandomGenerator";
import { testing } from "../common/Testing";
import { nn } from "../common/Tools";
import { Fleet } from "./Fleet";
import { StarLocation, StarLocationType } from "./StarLocation";
check.notequals(location.encounter, null);
check.notequals(battle, null);
testing("StarLocation", test => {
test.case("removes generated encounters that lose", check => {
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0);
var fleet = new Fleet();
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
battle.endBattle(fleet);
check.notequals(location.encounter, null);
check.notequals(location.encounter, null);
check.notequals(battle, null);
location.resolveEncounter(nn(battle.outcome));
check.equals(location.encounter, null);
});
battle.endBattle(fleet);
check.notequals(location.encounter, null);
test.case("leaves generated encounters that win", check => {
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0);
var fleet = new Fleet();
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
location.resolveEncounter(nn(battle.outcome));
check.equals(location.encounter, null);
});
check.notequals(location.encounter, null);
check.notequals(battle, null);
test.case("leaves generated encounters that win", check => {
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0);
var fleet = new Fleet();
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
battle.endBattle(location.encounter);
check.notequals(location.encounter, null);
check.notequals(location.encounter, null);
check.notequals(battle, null);
location.resolveEncounter(nn(battle.outcome));
check.notequals(location.encounter, null);
});
});
}
battle.endBattle(location.encounter);
check.notequals(location.encounter, null);
location.resolveEncounter(nn(battle.outcome));
check.notequals(location.encounter, null);
});
});

View file

@ -1,181 +1,189 @@
/// <reference path="../common/RObject.ts" />
import { RandomGenerator } from "../common/RandomGenerator"
import { RObject } from "../common/RObject"
import { add, remove } from "../common/Tools"
import { Battle } from "./Battle"
import { BattleOutcome } from "./BattleOutcome"
import { Fleet } from "./Fleet"
import { FleetGenerator } from "./FleetGenerator"
import { Player } from "./Player"
import { Shop } from "./Shop"
import { Star } from "./Star"
import { Universe } from "./Universe"
module TK.SpaceTac {
export enum StarLocationType {
STAR,
WARP,
PLANET,
ASTEROID,
STATION
}
/**
* Point of interest in a star system
*/
export class StarLocation extends RObject {
// Parent star system
star: Star
// Type of location
type: StarLocationType
// Location in the star system
x: number
y: number
// Absolute location in the universe
universe_x: number
universe_y: number
// Destination for jump, if its a WARP location
jump_dest: StarLocation | null
// Fleets present at this location (excluding the encounter for now)
fleets: Fleet[] = []
// Enemy encounter
encounter: Fleet | null = null
encounter_gen = false
encounter_random = RandomGenerator.global
// Shop to buy/sell equipment
shop: Shop | null = null
constructor(star = new Star(), type: StarLocationType = StarLocationType.PLANET, x: number = 0, y: number = 0) {
super();
this.star = star;
this.type = type;
this.x = x;
this.y = y;
this.universe_x = this.star.x + this.x;
this.universe_y = this.star.y + this.y;
this.jump_dest = null;
}
/**
* Get the universe containing this location
*/
get universe(): Universe {
return this.star.universe;
}
/**
* Add a shop in this location
*/
addShop(level = this.star.level) {
this.shop = new Shop(level);
}
/**
* Remove a potential shop in this location
*/
removeShop(): void {
this.shop = null;
}
/**
* Add a fleet to the list of fleets present in this system
*/
addFleet(fleet: Fleet): void {
if (add(this.fleets, fleet)) {
this.enterLocation(fleet);
}
}
/**
* Remove a fleet from the list of fleets present in this system
*/
removeFleet(fleet: Fleet): void {
remove(this.fleets, fleet);
}
/**
* Check if the location is clear of encounter
*/
isClear(): boolean {
return this.encounter_gen && this.encounter === null;
}
// Set the jump destination of a WARP location
setJumpDestination(jump_dest: StarLocation): void {
if (this.type === StarLocationType.WARP) {
this.jump_dest = jump_dest;
}
}
// Call this when first probing a location to generate the possible encounter
// Returns the encountered fleet, null if no encounter happens
tryGenerateEncounter(): Fleet | null {
if (!this.encounter_gen) {
this.encounter_gen = true;
if (this.encounter_random.random() < 0.8) {
this.setupEncounter();
}
}
return this.encounter;
}
// Call this when entering a location to generate the possible encounter
// *fleet* is the player fleet, entering the location
// Returns the engaged battle, null if no encounter happens
enterLocation(fleet: Fleet): Battle | null {
let encounter = this.tryGenerateEncounter();
if (encounter) {
let battle = new Battle(fleet, encounter);
battle.start();
return battle;
} else {
return null;
}
}
// Get the distance to another location
getDistanceTo(other: StarLocation): number {
var dx = this.x - other.x;
var dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Clear an encounter, when the encountered fleet has been defeated
*/
clearEncounter() {
this.encounter_gen = true;
this.encounter = null;
}
/**
* Forces the setup of an encounter
*/
setupEncounter() {
this.encounter_gen = true;
let fleet_generator = new FleetGenerator(this.encounter_random);
let variations: [number, number][];
if (this.star.level == 1) {
variations = [[this.star.level, 2]];
} else if (this.star.level <= 3) {
variations = [[this.star.level, 2], [this.star.level - 1, 3]];
} else if (this.star.level <= 6) {
variations = [[this.star.level, 3], [this.star.level - 1, 4], [this.star.level + 1, 2]];
} else {
variations = [[this.star.level, 4], [this.star.level - 1, 5], [this.star.level + 1, 3], [this.star.level + 3, 2]];
}
let [level, enemies] = this.encounter_random.choice(variations);
this.encounter = fleet_generator.generate(level, new Player("Enemy"), enemies, true);
}
/**
* Resolves the encounter from a battle outcome
*/
resolveEncounter(outcome: BattleOutcome) {
if (this.encounter && outcome.winner && !this.encounter.is(outcome.winner)) {
this.clearEncounter();
}
}
}
export enum StarLocationType {
STAR,
WARP,
PLANET,
ASTEROID,
STATION
}
/**
* Point of interest in a star system
*/
export class StarLocation extends RObject {
// Parent star system
star: Star
// Type of location
type: StarLocationType
// Location in the star system
x: number
y: number
// Absolute location in the universe
universe_x: number
universe_y: number
// Destination for jump, if its a WARP location
jump_dest: StarLocation | null
// Fleets present at this location (excluding the encounter for now)
fleets: Fleet[] = []
// Enemy encounter
encounter: Fleet | null = null
encounter_gen = false
encounter_random = RandomGenerator.global
// Shop to buy/sell equipment
shop: Shop | null = null
constructor(star = new Star(), type: StarLocationType = StarLocationType.PLANET, x: number = 0, y: number = 0) {
super();
this.star = star;
this.type = type;
this.x = x;
this.y = y;
this.universe_x = this.star.x + this.x;
this.universe_y = this.star.y + this.y;
this.jump_dest = null;
}
/**
* Get the universe containing this location
*/
get universe(): Universe {
return this.star.universe;
}
/**
* Add a shop in this location
*/
addShop(level = this.star.level) {
this.shop = new Shop(level);
}
/**
* Remove a potential shop in this location
*/
removeShop(): void {
this.shop = null;
}
/**
* Add a fleet to the list of fleets present in this system
*/
addFleet(fleet: Fleet): void {
if (add(this.fleets, fleet)) {
this.enterLocation(fleet);
}
}
/**
* Remove a fleet from the list of fleets present in this system
*/
removeFleet(fleet: Fleet): void {
remove(this.fleets, fleet);
}
/**
* Check if the location is clear of encounter
*/
isClear(): boolean {
return this.encounter_gen && this.encounter === null;
}
// Set the jump destination of a WARP location
setJumpDestination(jump_dest: StarLocation): void {
if (this.type === StarLocationType.WARP) {
this.jump_dest = jump_dest;
}
}
// Call this when first probing a location to generate the possible encounter
// Returns the encountered fleet, null if no encounter happens
tryGenerateEncounter(): Fleet | null {
if (!this.encounter_gen) {
this.encounter_gen = true;
if (this.encounter_random.random() < 0.8) {
this.setupEncounter();
}
}
return this.encounter;
}
// Call this when entering a location to generate the possible encounter
// *fleet* is the player fleet, entering the location
// Returns the engaged battle, null if no encounter happens
enterLocation(fleet: Fleet): Battle | null {
let encounter = this.tryGenerateEncounter();
if (encounter) {
let battle = new Battle(fleet, encounter);
battle.start();
return battle;
} else {
return null;
}
}
// Get the distance to another location
getDistanceTo(other: StarLocation): number {
var dx = this.x - other.x;
var dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Clear an encounter, when the encountered fleet has been defeated
*/
clearEncounter() {
this.encounter_gen = true;
this.encounter = null;
}
/**
* Forces the setup of an encounter
*/
setupEncounter() {
this.encounter_gen = true;
let fleet_generator = new FleetGenerator(this.encounter_random);
let variations: [number, number][];
if (this.star.level == 1) {
variations = [[this.star.level, 2]];
} else if (this.star.level <= 3) {
variations = [[this.star.level, 2], [this.star.level - 1, 3]];
} else if (this.star.level <= 6) {
variations = [[this.star.level, 3], [this.star.level - 1, 4], [this.star.level + 1, 2]];
} else {
variations = [[this.star.level, 4], [this.star.level - 1, 5], [this.star.level + 1, 3], [this.star.level + 3, 2]];
}
let [level, enemies] = this.encounter_random.choice(variations);
this.encounter = fleet_generator.generate(level, new Player("Enemy"), enemies, true);
}
/**
* Resolves the encounter from a battle outcome
*/
resolveEncounter(outcome: BattleOutcome) {
if (this.encounter && outcome.winner && !this.encounter.is(outcome.winner)) {
this.clearEncounter();
}
}
}

Some files were not shown because too many files have changed in this diff Show more