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 .venv
coverage node_modules
/node_modules .rts2_cache_*
/out/assets .coverage
/out/app.* /dist/
/out/tests.*
/out/dependencies.js
/graphics/**/*.blend?*
/graphics/**/output.png
/typings/
*.log
*.tsbuildinfo

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 # source activate_node
vdir="./.venv" vdir="./.venv"
expected="10.15.3" expected="12.13.0"
if [ \! -f "./activate_node" ] if [ \! -f "./activate_node" ]
then 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", "name": "spacetac",
"version": "0.1.0", "version": "0.1.0",
"description": "A tactical RPG set in space", "description": "A tactical RPG set in space",
"main": "out/build.js", "main": "dist/spacetac.umd.js",
"scripts": { "scripts": {
"build": "run build", "build": "microbundle build -f modern,umd",
"test": "run ci", "test": "jest",
"start": "run continuous" "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": { "repository": {
"type": "git", "type": "git",
"url": "https://code.thunderk.net/michael/spacetac.git" "url": "https://code.thunderk.net/games/spacetac.git"
}, },
"author": "Michael Lemaire", "author": {
"license": "MIT", "name": "Michaël Lemaire",
"url": "https://thunderk.net"
},
"license": "ISC",
"devDependencies": { "devDependencies": {
"@types/jasmine": "^3.3.12", "@types/jasmine": "^3.3.12",
"codecov": "^3.4.0", "@types/parse": "^2.9.0",
"gamefroot-texture-packer": "github:Gamefroot/Gamefroot-Texture-Packer#f3687111afc94f80ea8f2877c188fb8e2004e8ff", "gamefroot-texture-packer": "github:Gamefroot/Gamefroot-Texture-Packer#f3687111afc94f80ea8f2877c188fb8e2004e8ff",
"glob": "^7.1.4", "glob": "^7.1.4",
"glob-watcher": "^5.0.3", "glob-watcher": "^5.0.3",
@ -27,20 +37,31 @@
"karma-coverage": "^1.1.2", "karma-coverage": "^1.1.2",
"karma-jasmine": "^2.0.1", "karma-jasmine": "^2.0.1",
"karma-spec-reporter": "^0.0.32", "karma-spec-reporter": "^0.0.32",
"live-server": "1.2.1",
"process-pool": "^0.3.5", "process-pool": "^0.3.5",
"remap-istanbul": "^0.13.0",
"runjs": "^4.4.2", "runjs": "^4.4.2",
"shelljs": "^0.8.3", "shelljs": "^0.8.3",
"terser": "^3.17.0", "tk-base": "^0.2.5"
"typescript": "^3.5.0-rc"
}, },
"dependencies": { "dependencies": {
"parse": "^2.4.0", "parse": "^2.4.0",
"phaser": "^3.17.0" "phaser": "^3.20.1"
}, },
"dependenciesMap": { "dependenciesMap": {
"parse": "dist/parse.min.js", "parse": "dist/parse.min.js",
"phaser": "dist/phaser.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 {
class FakeStorage { data: any = {}
data: any = {} getItem(name: string) {
getItem(name: string) { return this.data[name];
return this.data[name]; }
} setItem(name: string, value: string) {
setItem(name: string, value: string) { this.data[name] = value;
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);
});
});
} }
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"/> /// <reference path="../node_modules/phaser/types/phaser.d.ts"/>
declare var global: any; import { RandomGenerator } from "./common/RandomGenerator"
declare var module: any; 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 * Main class to bootstrap the whole game
(<any>window).describe = (<any>window).describe || function () { }; */
} else { export class MainUI extends Phaser.Game {
if (typeof global != "undefined") { // Current game session
// In node, does not extend Phaser classes session: GameSession
var handler = { session_token: string | null
get(target: any, name: any): any {
return new Proxy(function () { }, handler);
}
}
global.Phaser = new Proxy(function () { }, handler);
}
if (typeof module != "undefined") { // Audio manager
module.exports = { TK }; audio = new AudioManager(this)
}
} // Game options
options = new GameOptions(this)
module TK.SpaceTac {
/** // Storage used
* Main class to bootstrap the whole game storage: Storage
*/
export class MainUI extends Phaser.Game { // Debug mode
// Current game session debug = false
session: GameSession
session_token: string | null // Current scaling
scaling = 1
// Audio manager
audio = new UI.Audio(this) constructor(private testmode = false) {
super({
// Game options width: 1920,
options = new UI.GameOptions(this) height: 1080,
type: testmode ? Phaser.CANVAS : Phaser.WEBGL, // cannot really use HEADLESS because of bugs
// Storage used backgroundColor: '#000000',
storage: Storage parent: '-space-tac',
disableContextMenu: true,
// Debug mode scale: {
debug = false mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
// Current scaling },
scaling = 1 });
constructor(private testmode = false) { this.storage = localStorage;
super({
width: 1920, this.session = new GameSession();
height: 1080, this.session_token = null;
type: testmode ? Phaser.CANVAS : Phaser.WEBGL, // cannot really use HEADLESS because of bugs
backgroundColor: '#000000', if (!testmode) {
parent: '-space-tac', this.events.on("blur", () => {
disableContextMenu: true, this.scene.scenes.forEach(scene => this.scene.pause(scene));
scale: { });
mode: Phaser.Scale.FIT, this.events.on("focus", () => {
autoCenter: Phaser.Scale.CENTER_BOTH this.scene.scenes.forEach(scene => this.scene.resume(scene));
}, });
});
this.scene.add('boot', Boot);
this.storage = localStorage; this.scene.add('loading', AssetLoading);
this.scene.add('mainmenu', MainMenu);
this.session = new GameSession(); this.scene.add('router', Router);
this.session_token = null; this.scene.add('battle', BattleView);
this.scene.add('intro', IntroView);
if (!testmode) { this.scene.add('creation', FleetCreationView);
this.events.on("blur", () => { this.scene.add('universe', UniverseMapView);
this.scene.scenes.forEach(scene => this.scene.pause(scene));
}); this.goToScene('boot');
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;
}
}
} }
}
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 {
class TestState { counter = 0
counter = 0 }
}
class TestDiff extends Diff<TestState> { class TestDiff extends Diff<TestState> {
private value: number private value: number
constructor(value = 1) { constructor(value = 1) {
super(); super();
this.value = value; this.value = value;
} }
apply(state: TestState) { apply(state: TestState) {
state.counter += this.value; state.counter += this.value;
} }
getReverse() { getReverse() {
return new TestDiff(-this.value); return new TestDiff(-this.value);
} }
} }
testing("DiffLog", test => { testing("DiffLog", test => {
test.case("stores sequential events", check => { test.case("stores sequential events", check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
check.equals(log.count(), 0); check.equals(log.count(), 0);
check.equals(log.get(0), null); check.equals(log.get(0), null);
check.equals(log.get(1), null); check.equals(log.get(1), null);
check.equals(log.get(2), null); check.equals(log.get(2), null);
log.add(new TestDiff(2)); log.add(new TestDiff(2));
check.equals(log.count(), 1); check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2)); check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), null); check.equals(log.get(1), null);
check.equals(log.get(2), null); check.equals(log.get(2), null);
log.add(new TestDiff(-4)); log.add(new TestDiff(-4));
check.equals(log.count(), 2); check.equals(log.count(), 2);
check.equals(log.get(0), new TestDiff(2)); check.equals(log.get(0), new TestDiff(2));
check.equals(log.get(1), new TestDiff(-4)); check.equals(log.get(1), new TestDiff(-4));
check.equals(log.get(2), null); check.equals(log.get(2), null);
log.clear(1); log.clear(1);
check.equals(log.count(), 1); check.equals(log.count(), 1);
check.equals(log.get(0), new TestDiff(2)); check.equals(log.get(0), new TestDiff(2));
log.clear(); log.clear();
check.equals(log.count(), 0); check.equals(log.count(), 0);
}) })
}) })
testing("DiffLogClient", test => { testing("DiffLogClient", test => {
test.case("adds diffs to the log", check => { test.case("adds diffs to the log", check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
let state = new TestState(); let state = new TestState();
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
check.equals(client.atEnd(), true, "client is empty, should be at end"); check.equals(client.atEnd(), true, "client is empty, should be at end");
check.equals(log.count(), 0, "log is empty initially"); check.equals(log.count(), 0, "log is empty initially");
check.equals(state.counter, 0, "initial state is 0"); check.equals(state.counter, 0, "initial state is 0");
client.add(new TestDiff(3)); client.add(new TestDiff(3));
check.equals(client.atEnd(), true, "client still at end"); check.equals(client.atEnd(), true, "client still at end");
check.equals(log.count(), 1, "diff added to log"); check.equals(log.count(), 1, "diff added to log");
check.equals(state.counter, 3, "diff applied to state"); check.equals(state.counter, 3, "diff applied to state");
client.add(new TestDiff(2), false); client.add(new TestDiff(2), false);
check.equals(client.atEnd(), false, "client lapsing behind"); check.equals(client.atEnd(), false, "client lapsing behind");
check.equals(log.count(), 2, "diff added to log"); check.equals(log.count(), 2, "diff added to log");
check.equals(state.counter, 3, "diff not applied to state"); check.equals(state.counter, 3, "diff not applied to state");
}) })
test.case("initializes at current state (end of log)", check => { test.case("initializes at current state (end of log)", check => {
let state = new TestState(); let state = new TestState();
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
log.add(new TestDiff(7)); log.add(new TestDiff(7));
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
check.equals(client.atStart(), false); check.equals(client.atStart(), false);
check.equals(client.atEnd(), true); check.equals(client.atEnd(), true);
check.equals(state.counter, 0); check.equals(state.counter, 0);
client.forward(); client.forward();
check.equals(state.counter, 0); check.equals(state.counter, 0);
client.backward(); client.backward();
check.equals(state.counter, -7); check.equals(state.counter, -7);
}) })
test.case("moves forward or backward in the log", check => { test.case("moves forward or backward in the log", check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
let state = new TestState(); let state = new TestState();
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
log.add(new TestDiff(7)); log.add(new TestDiff(7));
log.add(new TestDiff(-2)); log.add(new TestDiff(-2));
log.add(new TestDiff(4)); log.add(new TestDiff(4));
check.equals(state.counter, 0, "initial state is 0"); check.equals(state.counter, 0, "initial state is 0");
check.equals(client.atStart(), true, "client is at start"); check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.forward(); client.forward();
check.equals(state.counter, 7, "0+7 => 7"); check.equals(state.counter, 7, "0+7 => 7");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.forward(); client.forward();
check.equals(state.counter, 5, "7-2 => 5"); check.equals(state.counter, 5, "7-2 => 5");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.forward(); client.forward();
check.equals(state.counter, 9, "5+4 => 9"); check.equals(state.counter, 9, "5+4 => 9");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end"); check.equals(client.atEnd(), true, "client is at end");
client.forward(); client.forward();
check.equals(state.counter, 9, "at end, still 9"); check.equals(state.counter, 9, "at end, still 9");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client is at end"); check.equals(client.atEnd(), true, "client is at end");
client.backward(); client.backward();
check.equals(state.counter, 5, "9-4=>5"); check.equals(state.counter, 5, "9-4=>5");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.backward(); client.backward();
check.equals(state.counter, 7, "5+2=>7"); check.equals(state.counter, 7, "5+2=>7");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.backward(); client.backward();
check.equals(state.counter, 0, "7-7=>0"); check.equals(state.counter, 0, "7-7=>0");
check.equals(client.atStart(), true, "client is back at start"); check.equals(client.atStart(), true, "client is back at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.backward(); client.backward();
check.equals(state.counter, 0, "at start, still 0"); check.equals(state.counter, 0, "at start, still 0");
check.equals(client.atStart(), true, "client is at start"); check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
}) })
test.case("jumps to start or end of the log", check => { test.case("jumps to start or end of the log", check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
let state = new TestState(); let state = new TestState();
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
client.add(new TestDiff(7)); client.add(new TestDiff(7));
log.add(new TestDiff(-2)); log.add(new TestDiff(-2));
log.add(new TestDiff(4)); log.add(new TestDiff(4));
check.equals(state.counter, 7, "initial state is 7"); check.equals(state.counter, 7, "initial state is 7");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), false, "client is not at end"); check.equals(client.atEnd(), false, "client is not at end");
client.jumpToEnd(); client.jumpToEnd();
check.equals(state.counter, 9, "7-2+4=>9"); check.equals(state.counter, 9, "7-2+4=>9");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end"); check.equals(client.atEnd(), true, "client at end");
client.jumpToEnd(); client.jumpToEnd();
check.equals(state.counter, 9, "still 9"); check.equals(state.counter, 9, "still 9");
check.equals(client.atStart(), false, "client is not at start"); check.equals(client.atStart(), false, "client is not at start");
check.equals(client.atEnd(), true, "client at end"); check.equals(client.atEnd(), true, "client at end");
client.jumpToStart(); client.jumpToStart();
check.equals(state.counter, 0, "9-4+2-7=>0"); check.equals(state.counter, 0, "9-4+2-7=>0");
check.equals(client.atStart(), true, "client is at start"); check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end"); check.equals(client.atEnd(), false, "client at not end");
client.jumpToStart(); client.jumpToStart();
check.equals(state.counter, 0, "still 0"); check.equals(state.counter, 0, "still 0");
check.equals(client.atStart(), true, "client is at start"); check.equals(client.atStart(), true, "client is at start");
check.equals(client.atEnd(), false, "client at not end"); check.equals(client.atEnd(), false, "client at not end");
}) })
test.case("truncate the log", check => { test.case("truncate the log", check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
let state = new TestState(); let state = new TestState();
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
client.add(new TestDiff(7)); client.add(new TestDiff(7));
client.add(new TestDiff(3)); client.add(new TestDiff(3));
client.add(new TestDiff(5)); client.add(new TestDiff(5));
check.in("initial state", check => { check.in("initial state", check => {
check.equals(state.counter, 15, "state=15"); check.equals(state.counter, 15, "state=15");
check.equals(log.count(), 3, "count=3"); check.equals(log.count(), 3, "count=3");
}); });
client.backward(); client.backward();
check.in("after backward", check => { check.in("after backward", check => {
check.equals(state.counter, 10, "state=10"); check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 3, "count=3"); check.equals(log.count(), 3, "count=3");
}); });
client.truncate(); client.truncate();
check.in("after truncate", check => { check.in("after truncate", check => {
check.equals(state.counter, 10, "state=10"); check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2"); check.equals(log.count(), 2, "count=2");
}); });
client.truncate(); client.truncate();
check.in("after another truncate", check => { check.in("after another truncate", check => {
check.equals(state.counter, 10, "state=10"); check.equals(state.counter, 10, "state=10");
check.equals(log.count(), 2, "count=2"); check.equals(log.count(), 2, "count=2");
}); });
}) })
test.acase("plays the log continuously", async check => { test.acase("plays the log continuously", async check => {
let log = new DiffLog<TestState>(); let log = new DiffLog<TestState>();
let state = new TestState(); let state = new TestState();
let client = new DiffLogClient(state, log); let client = new DiffLogClient(state, log);
let inter: number[] = []; let inter: number[] = [];
let promise = client.play(diff => { let promise = client.play(diff => {
inter.push((<any>diff).value); inter.push((<any>diff).value);
return Promise.resolve(); return Promise.resolve();
}); });
log.add(new TestDiff(5)); log.add(new TestDiff(5));
log.add(new TestDiff(-1)); log.add(new TestDiff(-1));
log.add(new TestDiff(2)); log.add(new TestDiff(2));
client.stop(false); client.stop(false);
await promise; await promise;
check.equals(state.counter, 6); check.equals(state.counter, 6);
check.equals(inter, [5, -1, 2]); 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 * Base class for a single diff.
* *
* This allows for repeatable, serializable and revertable state modifications. * This represents an atomic change of the state, that can be applied, or reverted.
*/ */
module TK { export class Diff<T> {
/** /**
* Base class for a single diff. * Apply the diff on a given state
* *
* This represents an atomic change of the state, that can be applied, or reverted. * By default it does nothing
*/ */
export class Diff<T> { apply(state: T): void {
/** }
* Apply the diff on a given state
*
* By default it does nothing
*/
apply(state: T): void {
}
/** /**
* Reverts the diff from a given state * Reverts the diff from a given state
* *
* By default it applies the reverse event * By default it applies the reverse event
*/ */
revert(state: T): void { revert(state: T): void {
this.getReverse().apply(state); this.getReverse().apply(state);
} }
/** /**
* Get the reverse event * Get the reverse event
* *
* By default it returns a stub event that does nothing * By default it returns a stub event that does nothing
*/ */
protected getReverse(): Diff<T> { protected getReverse(): Diff<T> {
return new Diff<T>(); return new Diff<T>();
} }
} }
/** /**
* Collection of sequential diffs * Collection of sequential diffs
*/ */
export class DiffLog<T> { export class DiffLog<T> {
private diffs: Diff<T>[] = [] private diffs: Diff<T>[] = []
/** /**
* Add a single diff at the end of the log * Add a single diff at the end of the log
*/ */
add(diff: Diff<T>): void { add(diff: Diff<T>): void {
this.diffs.push(diff); this.diffs.push(diff);
} }
/** /**
* Get the diff at a specific index * Get the diff at a specific index
*/ */
get(idx: number): Diff<T> | null { get(idx: number): Diff<T> | null {
return this.diffs[idx] || null; return this.diffs[idx] || null;
} }
/** /**
* Return the total count of diffs * Return the total count of diffs
*/ */
count(): number { count(): number {
return this.diffs.length; return this.diffs.length;
} }
/** /**
* Clean all stored diffs, starting at a given index * Clean all stored diffs, starting at a given index
* *
* The caller should be sure that no log client is beyond the cut index. * The caller should be sure that no log client is beyond the cut index.
*/ */
clear(start = 0): void { clear(start = 0): void {
this.diffs = this.diffs.slice(0, start); this.diffs = this.diffs.slice(0, start);
} }
} }
/** /**
* Client for a DiffLog, able to go forward or backward in the log, applying diffs as needed * Client for a DiffLog, able to go forward or backward in the log, applying diffs as needed
*/ */
export class DiffLogClient<T> { export class DiffLogClient<T> {
private state: T private state: T
private log: DiffLog<T> private log: DiffLog<T>
private cursor = -1 private cursor = -1
private playing = false private playing = false
private stopping = false private stopping = false
private paused = false private paused = false
private timer = Timer.global private timer = Timer.global
constructor(state: T, log: DiffLog<T>) { constructor(state: T, log: DiffLog<T>) {
this.state = state; this.state = state;
this.log = log; this.log = log;
this.cursor = log.count() - 1; this.cursor = log.count() - 1;
} }
/** /**
* Returns true if the log is currently playing * Returns true if the log is currently playing
*/ */
isPlaying(): boolean { isPlaying(): boolean {
return this.playing && !this.paused && !this.stopping; return this.playing && !this.paused && !this.stopping;
} }
/** /**
* Get the current diff pointed at * Get the current diff pointed at
*/ */
getCurrent(): Diff<T> | null { getCurrent(): Diff<T> | null {
return this.log.get(this.cursor); return this.log.get(this.cursor);
} }
/** /**
* Push a diff to the underlying log, applying it immediately if required * Push a diff to the underlying log, applying it immediately if required
*/ */
add(diff: Diff<T>, apply = true): void { add(diff: Diff<T>, apply = true): void {
this.log.add(diff); this.log.add(diff);
if (apply) { if (apply) {
this.jumpToEnd(); this.jumpToEnd();
} }
} }
/** /**
* Apply the underlying log continuously, until *stop* is called * 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 * 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> { async play(after_apply?: (diff: Diff<T>) => Promise<void>): Promise<void> {
if (this.playing) { if (this.playing) {
console.error("DiffLogClient already playing", this); console.error("DiffLogClient already playing", this);
return; return;
} }
this.playing = true; this.playing = true;
this.stopping = false; this.stopping = false;
while (this.playing) { while (this.playing) {
if (!this.paused) { if (!this.paused) {
let diff = this.forward(); let diff = this.forward();
if (diff && after_apply) { if (diff && after_apply) {
await after_apply(diff); await after_apply(diff);
} }
} }
if (this.atEnd()) { if (this.atEnd()) {
if (this.stopping) { if (this.stopping) {
break; break;
} else { } else {
await this.timer.sleep(50); await this.timer.sleep(50);
} }
} }
} }
} }
/** /**
* Stop the previous *play* * Stop the previous *play*
*/ */
stop(immediate = true): void { stop(immediate = true): void {
if (!this.playing) { if (!this.playing) {
console.error("DiffLogClient not playing", this); console.error("DiffLogClient not playing", this);
return; return;
} }
if (immediate) { if (immediate) {
this.playing = false; this.playing = false;
} }
this.stopping = true; this.stopping = true;
} }
/** /**
* Make a step backward in time (revert one diff) * Make a step backward in time (revert one diff)
*/ */
backward(): Diff<T> | null { backward(): Diff<T> | null {
if (!this.atStart()) { if (!this.atStart()) {
this.cursor -= 1; this.cursor -= 1;
this.paused = true; this.paused = true;
let diff = this.log.get(this.cursor + 1); let diff = this.log.get(this.cursor + 1);
if (diff) { if (diff) {
diff.revert(this.state); diff.revert(this.state);
} }
return diff; return diff;
} else { } else {
return null; return null;
} }
} }
/** /**
* Make a step forward in time (apply one diff) * Make a step forward in time (apply one diff)
*/ */
forward(): Diff<T> | null { forward(): Diff<T> | null {
if (!this.atEnd()) { if (!this.atEnd()) {
this.cursor += 1; this.cursor += 1;
if (this.atEnd()) { if (this.atEnd()) {
this.paused = false; this.paused = false;
} }
let diff = this.log.get(this.cursor); let diff = this.log.get(this.cursor);
if (diff) { if (diff) {
diff.apply(this.state); diff.apply(this.state);
} }
return diff; return diff;
} else { } else {
return null; return null;
} }
} }
/** /**
* Jump to the start of the log * Jump to the start of the log
* *
* This will rewind all applied event * This will rewind all applied event
*/ */
jumpToStart() { jumpToStart() {
while (!this.atStart()) { while (!this.atStart()) {
this.backward(); this.backward();
} }
} }
/** /**
* Jump to the end of the log * Jump to the end of the log
* *
* This will apply all remaining event * This will apply all remaining event
*/ */
jumpToEnd() { jumpToEnd() {
while (!this.atEnd()) { while (!this.atEnd()) {
this.forward(); this.forward();
} }
} }
/** /**
* Check if we are currently at the start of the log * Check if we are currently at the start of the log
*/ */
atStart(): boolean { atStart(): boolean {
return this.cursor < 0; return this.cursor < 0;
} }
/** /**
* Check if we are currently at the end of the log * Check if we are currently at the end of the log
*/ */
atEnd(): boolean { atEnd(): boolean {
return this.cursor >= this.log.count() - 1; return this.cursor >= this.log.count() - 1;
} }
/** /**
* Truncate all diffs after the current position * Truncate all diffs after the current position
* *
* This is useful when using the log to "undo" something * This is useful when using the log to "undo" something
*/ */
truncate(): void { truncate(): void {
this.log.clear(this.cursor + 1); this.log.clear(this.cursor + 1);
} }
}
} }

View file

@ -1,241 +1,239 @@
module TK { testing("Iterators", test => {
testing("Iterators", test => { function checkit<T>(check: TestContext, base_iterator: Iterable<T>, values: T[], infinite = false) {
function checkit<T>(check: TestContext, base_iterator: Iterable<T>, values: T[], infinite = false) { function checker(check: TestContext) {
function checker(check: TestContext) { let iterator = base_iterator[Symbol.iterator]();
let iterator = base_iterator[Symbol.iterator](); values.forEach((value, idx) => {
values.forEach((value, idx) => { let state = iterator.next();
let state = iterator.next(); check.equals(state.done, false, `index ${idx} not done`);
check.equals(state.done, false, `index ${idx} not done`); check.equals(state.value, value, `index ${idx} value`);
check.equals(state.value, value, `index ${idx} value`); });
}); if (!infinite) {
if (!infinite) { range(3).forEach(oidx => {
range(3).forEach(oidx => { let state = iterator.next();
let state = iterator.next(); check.equals(state.done, true, `index ${values.length + oidx} done`);
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]);
}); });
}
}
test.case("constructs an iterator from an array", check => { check.in("first iteration", checker);
checkit(check, iarray([]), []); check.in("second iteration", checker);
checkit(check, iarray([1, 2, 3]), [1, 2, 3]); }
});
test.case("constructs an iterator from a single value", check => { test.case("constructs an iterator from a recurrent formula", check => {
checkit(check, isingle(1), [1]); checkit(check, irecur(1, x => x + 2), [1, 3, 5], true);
checkit(check, isingle("a"), ["a"]); checkit(check, irecur(4, x => x ? x - 1 : null), [4, 3, 2, 1, 0]);
}); });
test.case("repeats a value", check => { test.case("constructs an iterator from an array", check => {
checkit(check, irepeat("a"), ["a", "a", "a", "a"], true); checkit(check, iarray([]), []);
checkit(check, irepeat("a", 3), ["a", "a", "a"]); checkit(check, iarray([1, 2, 3]), [1, 2, 3]);
}); });
test.case("calls a function for each yielded value", check => { test.case("constructs an iterator from a single value", check => {
let iterator = iarray([1, 2, 3]); checkit(check, isingle(1), [1]);
let result: number[] = []; checkit(check, isingle("a"), ["a"]);
iforeach(iterator, bound(result, "push")); });
check.equals(result, [1, 2, 3]);
result = []; test.case("repeats a value", check => {
iforeach(iterator, i => { checkit(check, irepeat("a"), ["a", "a", "a", "a"], true);
result.push(i); checkit(check, irepeat("a", 3), ["a", "a", "a"]);
if (i == 2) { });
return null;
} else {
return undefined;
}
});
check.equals(result, [1, 2]);
result = []; test.case("calls a function for each yielded value", check => {
iforeach(iterator, i => { let iterator = iarray([1, 2, 3]);
result.push(i); let result: number[] = [];
return i; iforeach(iterator, bound(result, "push"));
}, 2); check.equals(result, [1, 2, 3]);
check.equals(result, [1, 2]);
});
test.case("finds the first item passing a predicate", check => { result = [];
check.equals(ifirst(iarray(<number[]>[]), i => i % 2 == 0), null); iforeach(iterator, i => {
check.equals(ifirst(iarray([1, 2, 3]), i => i % 2 == 0), 2); result.push(i);
check.equals(ifirst(iarray([1, 3, 5]), i => i % 2 == 0), null); if (i == 2) {
}); return null;
} else {
test.case("finds the first item mapping to a value", check => { return undefined;
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);
});
}); });
} 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. * Empty iterator
*
* 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.
*/ */
module TK { export const IATEND: Iterator<any> = {
/** next: function () {
* Empty iterator return { done: true, value: undefined };
*/ }
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);
} }
/**
* 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 {
export class TestRObject extends RObject { x: number
x: number constructor(x = RandomGenerator.global.random()) {
constructor(x = RandomGenerator.global.random()) { super();
super(); this.x = x;
this.x = x; }
} };
};
testing("RObject", test => { testing("RObject", test => {
test.setup(function () { test.setup(function () {
(<any>RObject)._next_id = 0; (<any>RObject)._next_id = 0;
}) })
test.case("gets a sequential id", check => { test.case("gets a sequential id", check => {
let o1 = new TestRObject(); let o1 = new TestRObject();
check.equals(o1.id, 0); check.equals(o1.id, 0);
let o2 = new TestRObject(); let o2 = new TestRObject();
let o3 = new TestRObject(); let o3 = new TestRObject();
check.equals(o2.id, 1); check.equals(o2.id, 1);
check.equals(o3.id, 2); check.equals(o3.id, 2);
check.equals(rid(o3), 2); check.equals(rid(o3), 2);
check.equals(rid(o3.id), 2); check.equals(rid(o3.id), 2);
}) })
test.case("checks object identity", check => { test.case("checks object identity", check => {
let o1 = new TestRObject(1); let o1 = new TestRObject(1);
let o2 = new TestRObject(1); let o2 = new TestRObject(1);
let o3 = duplicate(o1, TK.Specs); let o3 = duplicate(o1, TK.Specs);
check.equals(o1.is(o1), true, "o1 is o1"); check.equals(o1.is(o1), true, "o1 is o1");
check.equals(o1.is(o2), false, "o1 is not o2"); check.equals(o1.is(o2), false, "o1 is not o2");
check.equals(o1.is(o3), true, "o1 is o3"); check.equals(o1.is(o3), true, "o1 is o3");
check.equals(o1.is(null), false, "o1 is not null"); check.equals(o1.is(null), false, "o1 is not null");
check.equals(o2.is(o1), false, "o2 is not o1"); check.equals(o2.is(o1), false, "o2 is not o1");
check.equals(o2.is(o2), true, "o2 is o2"); check.equals(o2.is(o2), true, "o2 is o2");
check.equals(o2.is(o3), false, "o2 is not o3"); check.equals(o2.is(o3), false, "o2 is not o3");
check.equals(o2.is(null), false, "o2 is not null"); check.equals(o2.is(null), false, "o2 is not null");
check.equals(o3.is(o1), true, "o3 is o1"); check.equals(o3.is(o1), true, "o3 is o1");
check.equals(o3.is(o2), false, "o3 is not o2"); check.equals(o3.is(o2), false, "o3 is not o2");
check.equals(o3.is(o3), true, "o3 is o3"); check.equals(o3.is(o3), true, "o3 is o3");
check.equals(o3.is(null), false, "o3 is not null"); check.equals(o3.is(null), false, "o3 is not null");
}) })
test.case("resets global id on unserialize", check => { test.case("resets global id on unserialize", check => {
let o1 = new TestRObject(); let o1 = new TestRObject();
check.equals(o1.id, 0); check.equals(o1.id, 0);
let o2 = new TestRObject(); let o2 = new TestRObject();
check.equals(o2.id, 1); check.equals(o2.id, 1);
let serializer = new Serializer(TK.Specs); let serializer = new Serializer(TK.Specs);
let packed = serializer.serialize({ objs: [o1, o2] }); let packed = serializer.serialize({ objs: [o1, o2] });
(<any>RObject)._next_id = 0; (<any>RObject)._next_id = 0;
check.equals(new TestRObject().id, 0); check.equals(new TestRObject().id, 0);
let unpacked = serializer.unserialize(packed); let unpacked = serializer.unserialize(packed);
check.equals(unpacked, { objs: [o1, o2] }); check.equals(unpacked, { objs: [o1, o2] });
check.equals(new TestRObject().id, 2); check.equals(new TestRObject().id, 2);
serializer.unserialize(packed); serializer.unserialize(packed);
check.equals(new TestRObject().id, 3); check.equals(new TestRObject().id, 3);
}) })
}) })
testing("RObjectContainer", test => { testing("RObjectContainer", test => {
test.case("stored objects and get them by their id", check => { test.case("stored objects and get them by their id", check => {
let o1 = new TestRObject(); let o1 = new TestRObject();
let store = new RObjectContainer([o1]); let store = new RObjectContainer([o1]);
let o2 = new TestRObject(); let o2 = new TestRObject();
check.same(store.get(o1.id), o1); check.same(store.get(o1.id), o1);
check.equals(store.get(o2.id), null); check.equals(store.get(o2.id), null);
store.add(o2); store.add(o2);
check.same(store.get(o1.id), o1); check.same(store.get(o1.id), o1);
check.same(store.get(o2.id), o2); check.same(store.get(o2.id), o2);
}) })
test.case("lists contained objects", check => { test.case("lists contained objects", check => {
let store = new RObjectContainer<TestRObject>(); let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject()); let o1 = store.add(new TestRObject());
let o2 = 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(); let objects = store.list();
check.equals(objects.length, 2, "list length=2"); check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1"); check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2"); check.contains(objects, o2, "list contains o2");
objects = imaterialize(store.iterator()); objects = imaterialize(store.iterator());
check.equals(objects.length, 2, "list length=2"); check.equals(objects.length, 2, "list length=2");
check.contains(objects, o1, "list contains o1"); check.contains(objects, o1, "list contains o1");
check.contains(objects, o2, "list contains o2"); check.contains(objects, o2, "list contains o2");
let ids = store.ids(); let ids = store.ids();
check.equals(ids.length, 2, "ids length=2"); check.equals(ids.length, 2, "ids length=2");
check.contains(ids, o1.id, "list contains o1.id"); check.contains(ids, o1.id, "list contains o1.id");
check.contains(ids, o2.id, "list contains o2.id"); check.contains(ids, o2.id, "list contains o2.id");
}) })
test.case("removes objects", check => { test.case("removes objects", check => {
let store = new RObjectContainer<TestRObject>(); let store = new RObjectContainer<TestRObject>();
let o1 = store.add(new TestRObject()); let o1 = store.add(new TestRObject());
let o2 = store.add(new TestRObject()); let o2 = store.add(new TestRObject());
check.in("initial", check => { check.in("initial", check => {
check.equals(store.count(), 2, "count=2"); check.equals(store.count(), 2, "count=2");
check.same(store.get(o1.id), o1, "o1 present"); check.same(store.get(o1.id), o1, "o1 present");
check.same(store.get(o2.id), o2, "o2 present"); check.same(store.get(o2.id), o2, "o2 present");
}); });
store.remove(o1); store.remove(o1);
check.in("removed o1", check => { check.in("removed o1", check => {
check.equals(store.count(), 1, "count=1"); check.equals(store.count(), 1, "count=1");
check.same(store.get(o1.id), null, "o1 missing"); check.same(store.get(o1.id), null, "o1 missing");
check.same(store.get(o2.id), o2, "o2 present"); check.same(store.get(o2.id), o2, "o2 present");
}); });
}) })
}) })
}

View file

@ -1,100 +1,101 @@
module TK { import { iarray } from "./Iterators";
export type RObjectId = number import { values } from "./Tools";
/** export type RObjectId = number
* 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. * Returns the id of an object
* */
* Objects extending this class will have an automatic unique ID, and may be tracked from an RObjectContainer. export function rid(obj: RObject | RObjectId): number {
*/ return (obj instanceof RObject) ? obj.id : obj;
export class RObject { }
readonly id: RObjectId = RObject._next_id++
private static _next_id = 0 /**
* Referenced objects, with unique ID.
postUnserialize() { *
if (this.id >= RObject._next_id) { * Objects extending this class will have an automatic unique ID, and may be tracked from an RObjectContainer.
RObject._next_id = this.id + 1; */
} export class RObject {
} readonly id: RObjectId = RObject._next_id++
private static _next_id = 0
/**
* Check that two objects are the same (only by comparing their ID) postUnserialize() {
*/ if (this.id >= RObject._next_id) {
is(other: RObject | RObjectId | null): boolean { RObject._next_id = this.id + 1;
if (other === null) { }
return false; }
} else if (other instanceof RObject) {
return this.id === other.id; /**
} else { * Check that two objects are the same (only by comparing their ID)
return this.id === other; */
} is(other: RObject | RObjectId | null): boolean {
} if (other === null) {
} return false;
} else if (other instanceof RObject) {
/** return this.id === other.id;
* Container to track referenced objects } else {
*/ return this.id === other;
export class RObjectContainer<T extends RObject> { }
private objects: { [index: number]: T } = {} }
}
constructor(objects: T[] = []) {
objects.forEach(obj => this.add(obj)); /**
} * Container to track referenced objects
*/
/** export class RObjectContainer<T extends RObject> {
* Add an object to the container private objects: { [index: number]: T } = {}
*/
add(object: T): T { constructor(objects: T[] = []) {
this.objects[object.id] = object; objects.forEach(obj => this.add(obj));
return object; }
}
/**
/** * Add an object to the container
* Remove an object from the container */
*/ add(object: T): T {
remove(object: T): void { this.objects[object.id] = object;
delete this.objects[object.id]; return object;
} }
/** /**
* Get an object from the container by its id * Remove an object from the container
*/ */
get(id: RObjectId): T | null { remove(object: T): void {
return this.objects[id] || null; delete this.objects[object.id];
} }
/** /**
* Count the number of objects * Get an object from the container by its id
*/ */
count(): number { get(id: RObjectId): T | null {
return this.list().length; return this.objects[id] || null;
} }
/** /**
* Get all contained ids (list) * Count the number of objects
*/ */
ids(): RObjectId[] { count(): number {
return this.list().map(obj => obj.id); return this.list().length;
} }
/** /**
* Get all contained objects (list) * Get all contained ids (list)
*/ */
list(): T[] { ids(): RObjectId[] {
return values(this.objects); return this.list().map(obj => obj.id);
} }
/** /**
* Get all contained objects (iterator) * Get all contained objects (list)
*/ */
iterator(): Iterable<T> { list(): T[] {
return iarray(this.list()); 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 => {
testing("RandomGenerator", test => { test.case("produces floats", check => {
test.case("produces floats", check => { var gen = new RandomGenerator();
var gen = new RandomGenerator();
var i = 100; var i = 100;
while (i--) { while (i--) {
var value = gen.random(); var value = gen.random();
check.greaterorequal(value, 0); check.greaterorequal(value, 0);
check.greater(1, value); check.greater(1, value);
} }
}); });
test.case("produces integers", check => { test.case("produces integers", check => {
var gen = new RandomGenerator(); var gen = new RandomGenerator();
var i = 100; var i = 100;
while (i--) { while (i--) {
var value = gen.randInt(5, 12); var value = gen.randInt(5, 12);
check.equals(Math.round(value), value); check.equals(Math.round(value), value);
check.greater(value, 4); check.greater(value, 4);
check.greater(13, value); check.greater(13, value);
} }
}); });
test.case("chooses from an array", check => { test.case("chooses from an array", check => {
var gen = new RandomGenerator(); var gen = new RandomGenerator();
check.equals(gen.choice([5]), 5); check.equals(gen.choice([5]), 5);
var i = 100; var i = 100;
while (i--) { while (i--) {
var value = gen.choice(["test", "thing"]); var value = gen.choice(["test", "thing"]);
check.contains(["thing", "test"], value); check.contains(["thing", "test"], value);
} }
}); });
test.case("samples from an array", check => { test.case("samples from an array", check => {
var gen = new RandomGenerator(); var gen = new RandomGenerator();
var i = 100; var i = 100;
while (i-- > 1) { while (i-- > 1) {
var input = [1, 2, 3, 4, 5]; var input = [1, 2, 3, 4, 5];
var sample = gen.sample(input, i % 5 + 1); var sample = gen.sample(input, i % 5 + 1);
check.same(sample.length, i % 5 + 1); check.same(sample.length, i % 5 + 1);
sample.forEach((num, idx) => { sample.forEach((num, idx) => {
check.contains(input, num); check.contains(input, num);
check.notcontains(sample.filter((ival, iidx) => iidx != idx), num); check.notcontains(sample.filter((ival, iidx) => iidx != idx), num);
}); });
} }
}); });
test.case("choose from weighted ranges", check => { test.case("choose from weighted ranges", check => {
let gen = new RandomGenerator(); let gen = new RandomGenerator();
check.equals(gen.weighted([]), -1); check.equals(gen.weighted([]), -1);
check.equals(gen.weighted([1]), 0); check.equals(gen.weighted([1]), 0);
check.equals(gen.weighted([0, 1, 0]), 1); check.equals(gen.weighted([0, 1, 0]), 1);
check.equals(gen.weighted([0, 12, 0]), 1); check.equals(gen.weighted([0, 12, 0]), 1);
gen = new SkewedRandomGenerator([0, 0.5, 0.7, 0.8, 0.9999]); 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]), 0);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 1); 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]), 3); check.equals(gen.weighted([4, 3, 0, 2, 1]), 3);
check.equals(gen.weighted([4, 3, 0, 2, 1]), 4); check.equals(gen.weighted([4, 3, 0, 2, 1]), 4);
}); });
test.case("generates ids", check => { test.case("generates ids", check => {
let gen = new SkewedRandomGenerator([0, 0.4, 0.2, 0.1, 0.3, 0.8]); let gen = new SkewedRandomGenerator([0, 0.4, 0.2, 0.1, 0.3, 0.8]);
check.equals(gen.id(6, "abcdefghij"), "aecbdi"); check.equals(gen.id(6, "abcdefghij"), "aecbdi");
}); });
test.case("can be skewed", check => { test.case("can be skewed", check => {
var gen = new SkewedRandomGenerator([0, 0.5, 0.2, 0.9]); var gen = new SkewedRandomGenerator([0, 0.5, 0.2, 0.9]);
check.equals(gen.random(), 0); check.equals(gen.random(), 0);
check.equals(gen.random(), 0.5); check.equals(gen.random(), 0.5);
check.equals(gen.randInt(4, 8), 5); check.equals(gen.randInt(4, 8), 5);
check.equals(gen.random(), 0.9); check.equals(gen.random(), 0.9);
var value = gen.random(); var value = gen.random();
check.greaterorequal(value, 0); check.greaterorequal(value, 0);
check.greater(1, value); check.greater(1, value);
gen = new SkewedRandomGenerator([0.7], true); gen = new SkewedRandomGenerator([0.7], true);
check.equals(gen.random(), 0.7); check.equals(gen.random(), 0.7);
check.equals(gen.random(), 0.7); 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 { import { range, sum } from "./Tools";
/*
* Random generator.
*/
export class RandomGenerator {
static global: RandomGenerator = new RandomGenerator();
postUnserialize() { /*
this.random = Math.random; * Random generator.
} */
export class RandomGenerator {
static global: RandomGenerator = new RandomGenerator();
/** postUnserialize() {
* Get a random number in the (0.0 included -> 1.0 excluded) range this.random = Math.random;
*/ }
random = Math.random;
/** /**
* Get a random number in the (*from* included -> *to* included) range * Get a random number in the (0.0 included -> 1.0 excluded) range
*/ */
randInt(from: number, to: number): number { random = Math.random;
return Math.floor(this.random() * (to - from + 1)) + from;
}
/** /**
* Choose a random item in an array * Get a random number in the (*from* included -> *to* included) range
*/ */
choice<T>(input: T[]): T { randInt(from: number, to: number): number {
return input[this.randInt(0, input.length - 1)]; return Math.floor(this.random() * (to - from + 1)) + from;
} }
/** /**
* Choose a random sample of items from an array * Choose a random item in an array
*/ */
sample<T>(input: T[], count: number): T[] { choice<T>(input: T[]): T {
var minput = input.slice(); return input[this.randInt(0, input.length - 1)];
var result: T[] = []; }
while (count--) {
var idx = this.randInt(0, minput.length - 1);
result.push(minput[idx]);
minput.splice(idx, 1);
}
return result;
}
/** /**
* Get a random boolean (coin toss) * Choose a random sample of items from an array
*/ */
bool(): boolean { sample<T>(input: T[], count: number): T[] {
return this.randInt(0, 1) == 0; 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 * Get a random boolean (coin toss)
*/ */
weighted(weights: number[]): number { bool(): boolean {
if (weights.length == 0) { return this.randInt(0, 1) == 0;
return -1; }
}
let total = sum(weights); /**
if (total == 0) { * Get the range in which the number falls, ranges being weighted
return 0; */
} else { weighted(weights: number[]): number {
let cumul = 0; if (weights.length == 0) {
weights = weights.map(weight => { return -1;
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("");
}
} }
/* let total = sum(weights);
* Random generator that produces a series of fixed numbers before going back to random ones. if (total == 0) {
*/ return 0;
export class SkewedRandomGenerator extends RandomGenerator { } else {
i = 0; let cumul = 0;
suite: number[]; weights = weights.map(weight => {
loop: boolean; cumul += weight / total;
return cumul;
constructor(suite: number[], loop = false) { });
super(); let r = this.random();
for (let i = 0; i < weights.length; i++) {
this.suite = suite; if (r < weights[i]) {
this.loop = loop; return i;
}
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;
} }
}
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 {
export class TestSerializerObj1 { a: number;
a: number; constructor(a = 0) {
constructor(a = 0) { this.a = a;
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 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 { function isObject(value: any): boolean {
return value instanceof Object && !Array.isArray(value); return value instanceof Object && !Array.isArray(value);
} }
/** /**
* A typescript object serializer. * A typescript object serializer.
*/ */
export class Serializer { export class Serializer {
namespace: any; namespace: { [name: string]: (...args: any) => any };
ignored: string[] = []; ignored: string[] = [];
constructor(namespace: any = TK) { constructor(namespace: { [name: string]: (...args: any) => any } = {}) {
this.namespace = namespace; this.namespace = namespace;
} }
/** /**
* Add a class to the ignore list * Add a class to the ignore list
*/ */
addIgnoredClass(name: string) { addIgnoredClass(name: string) {
this.ignored.push(name); this.ignored.push(name);
} }
/** /**
* Construct an object from a constructor name * Construct an object from a constructor name
*/ */
constructObject(ctype: string): Object { constructObject(ctype: string): Object {
if (ctype == "Object") { if (ctype == "Object") {
return {}; return {};
} else { } else {
let cl = this.namespace[ctype]; let cl = this.namespace[ctype];
if (cl) { if (cl) {
return Object.create(cl.prototype); return Object.create(cl.prototype);
} else { } else {
cl = (<any>TK)[ctype]; console.error("Can't find class", ctype);
if (cl) { return {};
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[] = [];
* Serialize an object to a string var stats: any = {};
*/ crawl(obj, value => {
serialize(obj: any): string { if (isObject(value)) {
// Collect objects var vtype = classname(value);
var objects: Object[] = []; if (vtype != "" && this.ignored.indexOf(vtype) < 0) {
var stats: any = {}; stats[vtype] = (stats[vtype] || 0) + 1;
crawl(obj, value => { add(objects, value);
if (isObject(value)) { return value;
var vtype = classname(value); } else {
if (vtype != "" && this.ignored.indexOf(vtype) < 0) { return STOP_CRAWLING;
stats[vtype] = (stats[vtype] || 0) + 1; }
add(objects, value); } else {
return value; return value;
} else { }
return TK.STOP_CRAWLING; });
} //console.log("Serialize stats", stats);
} else {
return value; // 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) => {
//console.log("Serialize stats", stats); if (key != "$f" && isObject(value) && !value.hasOwnProperty("$c") && !value.hasOwnProperty("$i")) {
return { $i: objects.indexOf(value) };
// Serialize objects list, transforming deeper objects to links } else {
var fobjects = objects.map(value => <Object>{ $c: classname(value), $f: merge({}, value) }); return 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);
* Unserialize a string to an object
*/ // Reconstruct objects
unserialize(data: string): any { var objects = fobjects.map((objdata: any) => merge(this.constructObject(objdata['$c']), objdata['$f']));
// Unserialize objects list
var fobjects = JSON.parse(data); // Reconnect links
crawl(objects, value => {
// Reconstruct objects if (value instanceof Object && value.hasOwnProperty('$i')) {
var objects = fobjects.map((objdata: any) => merge(this.constructObject(objdata['$c']), objdata['$f'])); return objects[value['$i']];
} else {
// Reconnect links return value;
crawl(objects, value => { }
if (value instanceof Object && value.hasOwnProperty('$i')) { }, true);
return objects[value['$i']];
} else { // Post unserialize hooks
return value; crawl(objects[0], value => {
} if (value instanceof Object && typeof value.postUnserialize == "function") {
}, true); value.postUnserialize();
}
// Post unserialize hooks });
crawl(objects[0], value => {
if (value instanceof Object && typeof value.postUnserialize == "function") { // First object was the root
value.postUnserialize(); return objects[0];
} }
});
// 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 function testing(desc: string, body: (test: TestSuite) => void) {
export type FakeClock = { forward: (milliseconds: number) => void } if (typeof describe != "undefined") {
export type Mock<F extends Function> = { func: F, getCalls: () => any[][], reset: () => void } describe(desc, () => {
beforeEach(() => jasmine.addMatchers(CUSTOM_MATCHERS));
/** let test = new TestSuite(desc);
* Main test suite descriptor body(test);
*/ });
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); * 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();
} }
/** return {
* Test suite (group of test cases) func: <any>spy,
*/ getCalls: () => spy.calls.all().map(info => info.args),
export class TestSuite { reset: () => spy.calls.reset()
private desc: string }
constructor(desc: string) { }
this.desc = desc;
}
/** /**
* Add a setup step for each case of the suite * Create a mock function
*/ */
setup(body: Function, cleanup?: Function): void { mockfunc<F extends Function>(name = "mock", replacement?: F): Mock<F> {
beforeEach(() => body()); let spy = jasmine.createSpy(name, <any>replacement);
if (cleanup) {
afterEach(() => cleanup());
}
}
/** if (replacement) {
* Add an asynchronous setup step for each case of the suite spy = spy.and.callThrough();
*/
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();
}
} }
/** return {
* A test context, with assertion helpers func: <any>spy,
*/ getCalls: () => spy.calls.all().map(info => info.args),
export class TestContext { reset: () => spy.calls.reset()
info: string[]; }
}
constructor(info: string[] = []) { /**
this.info = info; * 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") {
* Create a sub context (adds information for all assertions done with this context) expect(mock.getCalls().length).toEqual(calls, this.message());
*/ } else {
sub(info: string): TestContext { expect(mock.getCalls()).toEqual(calls, this.message());
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));
}
} }
const CUSTOM_MATCHERS = { if (reset) {
toEqual: function (util: any, customEqualityTesters: any) { mock.reset();
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;
}
};
}
} }
} }
/**
* 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 => {
testing("Timer", test => { let clock = test.clock();
let clock = test.clock();
test.case("schedules and cancels future calls", check => { test.case("schedules and cancels future calls", check => {
let timer = new Timer(); let timer = new Timer();
let called: any[] = []; let called: any[] = [];
let callback = (item: any) => called.push(item); let callback = (item: any) => called.push(item);
let s1 = timer.schedule(50, () => callback(1)); let s1 = timer.schedule(50, () => callback(1));
let s2 = timer.schedule(150, () => callback(2)); let s2 = timer.schedule(150, () => callback(2));
let s3 = timer.schedule(250, () => callback(3)); let s3 = timer.schedule(250, () => callback(3));
check.equals(called, []); check.equals(called, []);
clock.forward(100); clock.forward(100);
check.equals(called, [1]); check.equals(called, [1]);
timer.cancel(s1); timer.cancel(s1);
check.equals(called, [1]); check.equals(called, [1]);
clock.forward(100); clock.forward(100);
check.equals(called, [1, 2]); check.equals(called, [1, 2]);
timer.cancel(s3); timer.cancel(s3);
clock.forward(100); clock.forward(100);
check.equals(called, [1, 2]); check.equals(called, [1, 2]);
clock.forward(1000); clock.forward(1000);
check.equals(called, [1, 2]); check.equals(called, [1, 2]);
}); });
test.case("may cancel all scheduled", check => { test.case("may cancel all scheduled", check => {
let timer = new Timer(); let timer = new Timer();
let called: any[] = []; let called: any[] = [];
let callback = (item: any) => called.push(item); let callback = (item: any) => called.push(item);
timer.schedule(150, () => callback(1)); timer.schedule(150, () => callback(1));
timer.schedule(50, () => callback(2)); timer.schedule(50, () => callback(2));
timer.schedule(500, () => callback(3)); 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(50, () => callback(4));
timer.schedule(150, () => callback(5)); 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 => { test.case("may switch to synchronous mode", check => {
let timer = new Timer(true); let timer = new Timer(true);
let called: any[] = []; let called: any[] = [];
let callback = (item: any) => called.push(item); let callback = (item: any) => called.push(item);
timer.schedule(50, () => callback(1)); timer.schedule(50, () => callback(1));
check.equals(called, [1]); check.equals(called, [1]);
}); });
test.acase("sleeps asynchronously", async check => { test.acase("sleeps asynchronously", async check => {
let timer = new Timer(); let timer = new Timer();
let x = 1; let x = 1;
let promise = timer.sleep(500).then(() => { let promise = timer.sleep(500).then(() => {
x++; 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);
});
}); });
} 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 { import { add, remove } from "./Tools";
/**
* Timing utility.
*
* This extends the standard setTimeout feature.
*/
export class Timer {
// Global timer shared by the whole project
static global = new Timer();
// 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) { private scheduled: any[] = [];
this.sync = sync;
}
/** constructor(sync = false) {
* Get the current timestamp in milliseconds this.sync = sync;
*/ }
static nowMs(): number {
return (new Date()).getTime();
}
/** /**
* Get the elapsed time in milliseconds since a previous timestamp * Get the current timestamp in milliseconds
*/ */
static fromMs(previous: number): number { static nowMs(): number {
return this.nowMs() - previous; return (new Date()).getTime();
} }
/** /**
* Return true if the timer is synchronous * Get the elapsed time in milliseconds since a previous timestamp
*/ */
isSynchronous() { static fromMs(previous: number): number {
return this.sync; return this.nowMs() - previous;
} }
/** /**
* Cancel all scheduled calls. * Return true if the timer is synchronous
* */
* If lock=true, no further scheduling will be allowed. isSynchronous() {
*/ return this.sync;
cancelAll(lock = false) { }
this.locked = lock;
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 = [];
/** scheduled.forEach(handle => clearTimeout(handle));
* 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). * Cancel a scheduled callback.
*/ */
schedule(delay: number, callback: Function): any { cancel(scheduled: any) {
if (this.locked) { if (remove(this.scheduled, scheduled)) {
return null; clearTimeout(scheduled);
} 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 = [];
}
} }
}
/**
* 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 => {
testing("Toggle", test => { let on_calls = 0;
let on_calls = 0; let off_calls = 0;
let off_calls = 0; let clock = test.clock();
let clock = test.clock();
test.setup(function () { test.setup(function () {
on_calls = 0; on_calls = 0;
off_calls = 0; off_calls = 0;
}); });
function newToggle(): Toggle { function newToggle(): Toggle {
return new Toggle(() => on_calls++, () => off_calls++); return new Toggle(() => on_calls++, () => off_calls++);
} }
function checkstate(on: number, off: number) { function checkstate(on: number, off: number) {
test.check.same(on_calls, on); test.check.same(on_calls, on);
test.check.same(off_calls, off); test.check.same(off_calls, off);
on_calls = 0; on_calls = 0;
off_calls = 0; off_calls = 0;
} }
test.case("toggles on and off", check => { test.case("toggles on and off", check => {
let toggle = newToggle(); let toggle = newToggle();
let client = toggle.manipulate("test"); let client = toggle.manipulate("test");
checkstate(0, 0); checkstate(0, 0);
let result = client(true); let result = client(true);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
result = client(true); result = client(true);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(10000000); clock.forward(10000000);
checkstate(0, 0); checkstate(0, 0);
result = client(false); result = client(false);
check.equals(result, false); check.equals(result, false);
checkstate(0, 1); checkstate(0, 1);
result = client(false); result = client(false);
check.equals(result, false); check.equals(result, false);
checkstate(0, 0); checkstate(0, 0);
clock.forward(10000000); clock.forward(10000000);
checkstate(0, 0); checkstate(0, 0);
result = client(true); result = client(true);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
let client2 = toggle.manipulate("test2"); let client2 = toggle.manipulate("test2");
result = client2(true); result = client2(true);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
result = client(false); result = client(false);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
result = client2(false); result = client2(false);
check.equals(result, false); check.equals(result, false);
checkstate(0, 1); checkstate(0, 1);
}) })
test.case("switches between on and off", check => { test.case("switches between on and off", check => {
let toggle = newToggle(); let toggle = newToggle();
let client = toggle.manipulate("test"); let client = toggle.manipulate("test");
checkstate(0, 0); checkstate(0, 0);
let result = client(); let result = client();
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
result = client(); result = client();
check.equals(result, false); check.equals(result, false);
checkstate(0, 1); checkstate(0, 1);
result = client(); result = client();
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
let client2 = toggle.manipulate("test2"); let client2 = toggle.manipulate("test2");
checkstate(0, 0); checkstate(0, 0);
result = client2(); result = client2();
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
result = client(); result = client();
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
result = client2(); result = client2();
check.equals(result, false); check.equals(result, false);
checkstate(0, 1); checkstate(0, 1);
}) })
test.case("toggles on for a given time", check => { test.case("toggles on for a given time", check => {
let toggle = newToggle(); let toggle = newToggle();
let client = toggle.manipulate("test"); let client = toggle.manipulate("test");
checkstate(0, 0); checkstate(0, 0);
let result = client(100); let result = client(100);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(60); clock.forward(60);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(60); clock.forward(60);
check.equals(toggle.isOn(), false); check.equals(toggle.isOn(), false);
checkstate(0, 1); checkstate(0, 1);
result = client(100); result = client(100);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
result = client(200); result = client(200);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(150); clock.forward(150);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(150); clock.forward(150);
check.equals(toggle.isOn(), false); check.equals(toggle.isOn(), false);
checkstate(0, 1); checkstate(0, 1);
let client2 = toggle.manipulate("test2"); let client2 = toggle.manipulate("test2");
result = client(100); result = client(100);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
result = client2(200); result = client2(200);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(150); clock.forward(150);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
checkstate(0, 0); checkstate(0, 0);
clock.forward(150); clock.forward(150);
check.equals(toggle.isOn(), false); check.equals(toggle.isOn(), false);
checkstate(0, 1); checkstate(0, 1);
result = client(100); result = client(100);
check.equals(result, true); check.equals(result, true);
checkstate(1, 0); checkstate(1, 0);
result = client(true); result = client(true);
check.equals(result, true); check.equals(result, true);
checkstate(0, 0); checkstate(0, 0);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
clock.forward(2000); clock.forward(2000);
check.equals(toggle.isOn(), true); check.equals(toggle.isOn(), true);
checkstate(0, 0); checkstate(0, 0);
}) })
}) })
}

View file

@ -1,93 +1,94 @@
module TK { import { Timer } from "./Timer"
/** import { add, contains, remove } from "./Tools"
* 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
/** /**
* A toggle between two states (on and off). * Client for Toggle object, allowing to manipulate it
* *
* A toggle will be on if at least one ToggleClient requires it to be on. * *state* may be:
*/ * - a boolean to require on or off
export class Toggle { * - a number to require on for the duration in milliseconds
private on: Function * - undefined to switch between on and off (based on the client state, not the toggle state)
private off: Function *
private status = false * The function returns the actual state after applying the requirement
private clients: string[] = [] */
private scheduled: { [client: string]: any } = {} export type ToggleClient = (state?: boolean | number) => boolean
private timer = Timer.global
constructor(on: Function = () => null, off: Function = () => null) { /**
this.on = on; * A toggle between two states (on and off).
this.off = 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);
} }
} else if (typeof state == "number") {
/** if (state > 0) {
* Check if the current state is on this.start(client);
*/ this.scheduled[client] = this.timer.schedule(state, () => this.stop(client));
isOn(): boolean { } else {
return this.status; this.stop(client);
}
/**
* 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 (!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) {
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.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})`);
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));
});
});
} }
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 { import { ArenaLocation, IArenaLocation } from "./ArenaLocation";
/**
* 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 * Abstract grid for the arena where the battle takes place
* *
* This grid is composed of regular hexagons where all vertices are at a same distance "unit" of the hexagon center * The grid is used to snap arena coordinates for ships and targets
*/ */
export class HexagonalArenaGrid implements IArenaGrid { export interface IArenaGrid {
private yunit: number; snap(loc: IArenaLocation): IArenaLocation;
}
constructor(private unit: number, private yfactor = Math.sqrt(0.75)) {
this.yunit = unit * yfactor; /**
} * Hexagonal unbounded arena grid
*
snap(loc: IArenaLocation): IArenaLocation { * This grid is composed of regular hexagons where all vertices are at a same distance "unit" of the hexagon center
let yr = Math.round(loc.y / this.yunit); */
let xr: number; export class HexagonalArenaGrid implements IArenaGrid {
if (yr % 2 == 0) { private yunit: number;
xr = Math.round(loc.x / this.unit);
} else { constructor(private unit: number, private yfactor = Math.sqrt(0.75)) {
xr = Math.round((loc.x - 0.5 * this.unit) / this.unit) + 0.5; this.yunit = unit * yfactor;
} }
return new ArenaLocation((xr * this.unit) || 0, (yr * this.yunit) || 0);
} 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 => {
testing("ArenaLocation", test => { test.case("gets distance and angle between two locations", check => {
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(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);
check.nears(arenaAngle({ x: 0, y: 0 }, { x: 1, y: 1 }), Math.PI / 4); })
})
test.case("computes an angular difference", check => { test.case("computes an angular difference", check => {
check.equals(angularDifference(0.5, 1.5), 1.0); check.equals(angularDifference(0.5, 1.5), 1.0);
check.nears(angularDifference(0.5, 1.5 + Math.PI * 6), 1.0); check.nears(angularDifference(0.5, 1.5 + Math.PI * 6), 1.0);
check.same(angularDifference(0.5, -0.5), -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(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);
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 => { test.case("converts between degrees and radians", check => {
check.nears(degrees(Math.PI / 2), 90); check.nears(degrees(Math.PI / 2), 90);
check.nears(radians(45), Math.PI / 4); check.nears(radians(45), Math.PI / 4);
}); });
}); });
}

View file

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

View file

@ -1,381 +1,379 @@
module TK.SpaceTac { testing("Battle", test => {
testing("Battle", test => { test.case("defines play order by initiative throws", check => {
test.case("defines play order by initiative throws", check => { var fleet1 = new Fleet();
var fleet1 = new Fleet(); var fleet2 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship1 = new Ship(fleet1, "F1S1"); TestTools.setAttribute(ship1, "initiative", 2);
TestTools.setAttribute(ship1, "initiative", 2); var ship2 = new Ship(fleet1, "F1S2");
var ship2 = new Ship(fleet1, "F1S2"); TestTools.setAttribute(ship2, "initiative", 4);
TestTools.setAttribute(ship2, "initiative", 4); var ship3 = new Ship(fleet1, "F1S3");
var ship3 = new Ship(fleet1, "F1S3"); TestTools.setAttribute(ship3, "initiative", 1);
TestTools.setAttribute(ship3, "initiative", 1); var ship4 = new Ship(fleet2, "F2S1");
var ship4 = new Ship(fleet2, "F2S1"); TestTools.setAttribute(ship4, "initiative", 8);
TestTools.setAttribute(ship4, "initiative", 8); var ship5 = new Ship(fleet2, "F2S2");
var ship5 = new Ship(fleet2, "F2S2"); TestTools.setAttribute(ship5, "initiative", 2);
TestTools.setAttribute(ship5, "initiative", 2);
var battle = new Battle(fleet1, fleet2);
var battle = new Battle(fleet1, fleet2); check.equals(battle.play_order.length, 0);
check.equals(battle.play_order.length, 0);
var gen = new SkewedRandomGenerator([1.0, 0.1, 1.0, 0.2, 0.6]);
var gen = new SkewedRandomGenerator([1.0, 0.1, 1.0, 0.2, 0.6]); battle.throwInitiative(gen);
battle.throwInitiative(gen);
check.equals(battle.play_order.length, 5);
check.equals(battle.play_order.length, 5); check.equals(battle.play_order, [ship1, ship4, ship5, ship3, ship2]);
check.equals(battle.play_order, [ship1, ship4, ship5, ship3, ship2]); });
});
test.case("places ships on lines, facing the arena center", check => {
test.case("places ships on lines, facing the arena center", check => { var fleet1 = new Fleet();
var fleet1 = new Fleet(); var fleet2 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "F1S1");
var ship1 = new Ship(fleet1, "F1S1"); var ship2 = new Ship(fleet1, "F1S2");
var ship2 = new Ship(fleet1, "F1S2"); var ship3 = new Ship(fleet1, "F1S3");
var ship3 = new Ship(fleet1, "F1S3"); var ship4 = new Ship(fleet2, "F2S1");
var ship4 = new Ship(fleet2, "F2S1"); var ship5 = new Ship(fleet2, "F2S2");
var ship5 = new Ship(fleet2, "F2S2");
var battle = new Battle(fleet1, fleet2, 1000, 500);
var battle = new Battle(fleet1, fleet2, 1000, 500); battle.placeShips();
battle.placeShips();
check.nears(ship1.arena_x, 250);
check.nears(ship1.arena_x, 250); check.nears(ship1.arena_y, 150);
check.nears(ship1.arena_y, 150); check.nears(ship1.arena_angle, 0);
check.nears(ship1.arena_angle, 0);
check.nears(ship2.arena_x, 250);
check.nears(ship2.arena_x, 250); check.nears(ship2.arena_y, 250);
check.nears(ship2.arena_y, 250); check.nears(ship2.arena_angle, 0);
check.nears(ship2.arena_angle, 0);
check.nears(ship3.arena_x, 250);
check.nears(ship3.arena_x, 250); check.nears(ship3.arena_y, 350);
check.nears(ship3.arena_y, 350); check.nears(ship3.arena_angle, 0);
check.nears(ship3.arena_angle, 0);
check.nears(ship4.arena_x, 750);
check.nears(ship4.arena_x, 750); check.nears(ship4.arena_y, 300);
check.nears(ship4.arena_y, 300); check.nears(ship4.arena_angle, Math.PI);
check.nears(ship4.arena_angle, Math.PI);
check.nears(ship5.arena_x, 750);
check.nears(ship5.arena_x, 750); check.nears(ship5.arena_y, 200);
check.nears(ship5.arena_y, 200); check.nears(ship5.arena_angle, Math.PI);
check.nears(ship5.arena_angle, Math.PI); });
});
test.case("advances to next ship in play order", check => {
test.case("advances to next ship in play order", check => { var fleet1 = new Fleet();
var fleet1 = new Fleet(); var fleet2 = new Fleet();
var fleet2 = new Fleet();
var ship1 = new Ship(fleet1, "ship1");
var ship1 = new Ship(fleet1, "ship1"); var ship2 = new Ship(fleet1, "ship2");
var ship2 = new Ship(fleet1, "ship2"); var ship3 = new Ship(fleet2, "ship3");
var ship3 = new Ship(fleet2, "ship3");
var battle = new Battle(fleet1, fleet2);
var battle = new Battle(fleet1, fleet2); battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
battle.ships.list().forEach(ship => TestTools.setShipModel(ship, 10, 0));
// Check empty play_order case
// Check empty play_order case check.equals(battle.playing_ship, null);
check.equals(battle.playing_ship, null); battle.advanceToNextShip();
battle.advanceToNextShip(); check.equals(battle.playing_ship, null);
check.equals(battle.playing_ship, null);
// Force play order
// Force play order iforeach(battle.iships(), ship => TestTools.setAttribute(ship, "initiative", 1));
iforeach(battle.iships(), ship => TestTools.setAttribute(ship, "initiative", 1)); var gen = new SkewedRandomGenerator([0.1, 0.2, 0.0]);
var gen = new SkewedRandomGenerator([0.1, 0.2, 0.0]); battle.throwInitiative(gen);
battle.throwInitiative(gen); check.equals(battle.playing_ship, null);
check.equals(battle.playing_ship, null);
battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship2);
check.same(battle.playing_ship, ship2);
battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship1);
check.same(battle.playing_ship, ship1);
battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship3);
check.same(battle.playing_ship, ship3);
battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship2);
check.same(battle.playing_ship, ship2);
// A dead ship is skipped
// A dead ship is skipped ship1.setDead();
ship1.setDead(); battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship3);
check.same(battle.playing_ship, ship3);
// Playing ship dies
// Playing ship dies ship3.setDead();
ship3.setDead(); battle.advanceToNextShip();
battle.advanceToNextShip(); check.same(battle.playing_ship, ship2);
check.same(battle.playing_ship, ship2); });
});
test.case("handles the suicide case (playing ship dies because of its action)", check => {
test.case("handles the suicide case (playing ship dies because of its action)", check => { let battle = TestTools.createBattle(3, 1);
let battle = TestTools.createBattle(3, 1); let [ship1, ship2, ship3, ship4] = battle.play_order;
let [ship1, ship2, ship3, ship4] = battle.play_order; ship1.setArenaPosition(0, 0);
ship1.setArenaPosition(0, 0); ship2.setArenaPosition(0, 0);
ship2.setArenaPosition(0, 0); ship3.setArenaPosition(1000, 1000);
ship3.setArenaPosition(1000, 1000); ship4.setArenaPosition(1000, 1000);
ship4.setArenaPosition(1000, 1000); let weapon = TestTools.addWeapon(ship1, 8000, 0, 50, 100);
let weapon = TestTools.addWeapon(ship1, 8000, 0, 50, 100);
check.in("initially", check => {
check.in("initially", check => { check.same(battle.playing_ship, ship1, "playing ship");
check.same(battle.playing_ship, ship1, "playing ship"); check.equals(battle.ships.list().filter(ship => ship.alive), [ship1, ship2, ship3, ship4], "alive ships");
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");
});
})
}); });
}
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 { import { iarray, ichainit, ifilter, iforeach, imap, imaterialize } from "../common/Iterators"
/** import { RandomGenerator } from "../common/RandomGenerator"
* A turn-based battle between fleets import { RObjectContainer, RObjectId } from "../common/RObject"
*/ import { bool, flatten } from "../common/Tools"
export class Battle { import { EndTurnAction } from "./actions/EndTurnAction"
// Grid for the arena import { AIWorker } from "./ai/AIWorker"
grid?: IArenaGrid 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 // Battle outcome, if the battle has ended
stats: BattleStats outcome: BattleOutcome | null = null
// Log of all battle events // Statistics
log: BattleLog stats: BattleStats
// List of fleets engaged in battle // Log of all battle events
fleets: Fleet[] log: BattleLog
// Container of all engaged ships // List of fleets engaged in battle
ships: RObjectContainer<Ship> fleets: Fleet[]
// List of playing ships, sorted by their initiative throw // Container of all engaged ships
play_order: Ship[] ships: RObjectContainer<Ship>
play_index = -1
// Current battle "cycle" (one cycle is one turn done for all ships in the play order) // List of playing ships, sorted by their initiative throw
cycle = 0 play_order: Ship[]
play_index = -1
// List of deployed drones // Current battle "cycle" (one cycle is one turn done for all ships in the play order)
drones = new RObjectContainer<Drone>() cycle = 0
// Size of the battle area // List of deployed drones
width: number drones = new RObjectContainer<Drone>()
height: number
border = 50
ship_separation = 100
// Indicator that an AI is playing // Size of the battle area
ai_playing = false 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) { // Indicator that an AI is playing
this.grid = new HexagonalArenaGrid(50); ai_playing = false
this.fleets = [fleet1, fleet2]; constructor(fleet1 = new Fleet(new Player("Attacker")), fleet2 = new Fleet(new Player("Defender")), width = 1808, height = 948) {
this.ships = new RObjectContainer(fleet1.ships.concat(fleet2.ships)); this.grid = new HexagonalArenaGrid(50);
this.play_order = [];
this.width = width;
this.height = height;
this.log = new BattleLog(); this.fleets = [fleet1, fleet2];
this.stats = new BattleStats(); this.ships = new RObjectContainer(fleet1.ships.concat(fleet2.ships));
this.play_order = [];
this.width = width;
this.height = height;
this.fleets.forEach((fleet: Fleet) => { this.log = new BattleLog();
fleet.setBattle(this); this.stats = new BattleStats();
});
}
postUnserialize() { this.fleets.forEach((fleet: Fleet) => {
this.ai_playing = false; fleet.setBattle(this);
} });
}
/** postUnserialize() {
* Property is true if the battle has ended this.ai_playing = false;
*/ }
get ended(): boolean {
return bool(this.outcome);
}
/** /**
* Apply a list of diffs to the game state, and add them to the log. * Property is true if the battle has ended
* */
* This should be the main way to modify the game state. get ended(): boolean {
*/ return bool(this.outcome);
applyDiffs(diffs: BaseBattleDiff[]): void { }
let client = new BattleLogClient(this, this.log);
diffs.forEach(diff => client.add(diff));
}
/** /**
* Create a quick random battle, for testing purposes, or quick skirmish * Apply a list of diffs to the game state, and add them to the log.
*/ *
static newQuickRandom(start = true, level = 1, shipcount = 5): Battle { * This should be the main way to modify the game state.
let player1 = Player.newQuickRandom("Player", level, shipcount, true); */
let player2 = Player.newQuickRandom("Enemy", level, shipcount, true); 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) { * Create a quick random battle, for testing purposes, or quick skirmish
result.start(); */
} static newQuickRandom(start = true, level = 1, shipcount = 5): Battle {
return result; let player1 = Player.newQuickRandom("Player", level, shipcount, true);
} let player2 = Player.newQuickRandom("Enemy", level, shipcount, true);
/** let result = new Battle(player1.fleet, player2.fleet);
* Get the currently playing ship if (start) {
*/ result.start();
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();
}
} }
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 => {
testing("BattleCheats", test => { test.case("wins a battle", check => {
test.case("wins a battle", check => { let battle = Battle.newQuickRandom();
let battle = Battle.newQuickRandom(); let cheats = new BattleCheats(battle, battle.fleets[0].player);
let cheats = new BattleCheats(battle, battle.fleets[0].player);
cheats.win(); cheats.win();
check.equals(battle.ended, true, "ended"); check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[0].id, "winner"); 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(any(battle.fleets[1].ships, ship => ship.alive), false, "all enemies dead");
}) })
test.case("loses a battle", check => { test.case("loses a battle", check => {
let battle = Battle.newQuickRandom(); let battle = Battle.newQuickRandom();
let cheats = new BattleCheats(battle, battle.fleets[0].player); let cheats = new BattleCheats(battle, battle.fleets[0].player);
cheats.lose(); cheats.lose();
check.equals(battle.ended, true, "ended"); check.equals(battle.ended, true, "ended");
check.same(nn(battle.outcome).winner, battle.fleets[1].id, "winner"); 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(any(battle.fleets[0].ships, ship => ship.alive), false, "all allies dead");
}) })
}) })
}

View file

@ -1,40 +1,43 @@
module TK.SpaceTac { import { iforeach } from "../common/Iterators";
/** import { first } from "../common/Tools";
* Cheat helpers for current battle import { Battle } from "./Battle";
* import { Player } from "./Player";
* May be used from the console to help development
*/
export class BattleCheats {
battle: Battle
player: Player
constructor(battle: Battle, player: Player) { /**
this.battle = battle; * Cheat helpers for current battle
this.player = player; *
} * May be used from the console to help development
*/
export class BattleCheats {
battle: Battle
player: Player
/** constructor(battle: Battle, player: Player) {
* Make player win the current battle this.battle = battle;
*/ this.player = player;
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 * Make player win the current battle
*/ */
lose(): void { win(): void {
iforeach(this.battle.iships(), ship => { iforeach(this.battle.iships(), ship => {
if (this.player.is(ship.fleet.player)) { if (!this.player.is(ship.fleet.player)) {
ship.setDead(); ship.setDead();
} }
}); });
this.battle.endBattle(first(this.battle.fleets, fleet => !this.player.is(fleet.player))); 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 => {
testing("BattleChecks", test => { test.case("detects victory conditions", check => {
test.case("detects victory conditions", check => { let battle = new Battle();
let battle = new Battle(); let ship1 = battle.fleets[0].addShip();
let ship1 = battle.fleets[0].addShip(); let ship2 = battle.fleets[1].addShip();
let ship2 = battle.fleets[1].addShip(); let checks = new BattleChecks(battle);
let checks = new BattleChecks(battle); check.equals(checks.checkVictory(), [], "no victory");
check.equals(checks.checkVictory(), [], "no victory");
battle.cycle = 5; battle.cycle = 5;
ship1.setDead(); ship1.setDead();
check.equals(checks.checkVictory(), [new EndBattleDiff(battle.fleets[1], 5)], "victory"); check.equals(checks.checkVictory(), [new EndBattleDiff(battle.fleets[1], 5)], "victory");
}) })
test.case("fixes ship values", check => { test.case("fixes ship values", check => {
let battle = new Battle(); let battle = new Battle();
let ship1 = battle.fleets[0].addShip(); let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip(); let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle); let checks = new BattleChecks(battle);
check.equals(checks.checkShipValues(), [], "no value to fix"); check.equals(checks.checkShipValues(), [], "no value to fix");
ship1.setValue("hull", -4); ship1.setValue("hull", -4);
TestTools.setAttribute(ship2, "shield_capacity", 48); TestTools.setAttribute(ship2, "shield_capacity", 48);
ship2.setValue("shield", 60); ship2.setValue("shield", 60);
check.equals(checks.checkShipValues(), [ check.equals(checks.checkShipValues(), [
new ShipValueDiff(ship1, "hull", 4), new ShipValueDiff(ship1, "hull", 4),
new ShipValueDiff(ship2, "shield", -12), new ShipValueDiff(ship2, "shield", -12),
], "fixed values"); ], "fixed values");
}) })
test.case("marks ships as dead, except the playing one", check => { test.case("marks ships as dead, except the playing one", check => {
let battle = TestTools.createBattle(1, 2); let battle = TestTools.createBattle(1, 2);
let [ship1, ship2, ship3] = battle.play_order; let [ship1, ship2, ship3] = battle.play_order;
let checks = new BattleChecks(battle); let checks = new BattleChecks(battle);
check.equals(checks.checkDeadShips(), [], "no ship to mark as dead"); 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(); let result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship2)], "ship2 marked as dead"); check.equals(result, [new ShipDeathDiff(battle, ship2)], "ship2 marked as dead");
battle.applyDiffs(result); battle.applyDiffs(result);
result = checks.checkDeadShips(); result = checks.checkDeadShips();
check.equals(result, [new ShipDeathDiff(battle, ship3)], "ship3 marked as dead"); check.equals(result, [new ShipDeathDiff(battle, ship3)], "ship3 marked as dead");
battle.applyDiffs(result); battle.applyDiffs(result);
result = checks.checkDeadShips(); result = checks.checkDeadShips();
check.equals(result, [], "ship1 left playing"); check.equals(result, [], "ship1 left playing");
}) })
test.case("fixes area effects", check => { test.case("fixes area effects", check => {
let battle = new Battle(); let battle = new Battle();
let ship1 = battle.fleets[0].addShip(); let ship1 = battle.fleets[0].addShip();
let ship2 = battle.fleets[1].addShip(); let ship2 = battle.fleets[1].addShip();
let checks = new BattleChecks(battle); let checks = new BattleChecks(battle);
check.in("initial state", check => { check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff"); check.equals(checks.checkAreaEffects(), [], "effects diff");
}); });
let effect1 = ship1.active_effects.add(new StickyEffect(new BaseEffect("e1"))); let effect1 = ship1.active_effects.add(new StickyEffect(new BaseEffect("e1")));
let effect2 = ship1.active_effects.add(new BaseEffect("e2")); let effect2 = ship1.active_effects.add(new BaseEffect("e2"));
let effect3 = ship1.active_effects.add(new BaseEffect("e3")); let effect3 = ship1.active_effects.add(new BaseEffect("e3"));
check.patch(battle, "getAreaEffects", (): [Ship, BaseEffect][] => [[ship1, effect3]]); check.patch(battle, "getAreaEffects", (): [Ship, BaseEffect][] => [[ship1, effect3]]);
check.in("sticky+obsolete+missing", check => { check.in("sticky+obsolete+missing", check => {
check.equals(checks.checkAreaEffects(), [ check.equals(checks.checkAreaEffects(), [
new ShipEffectRemovedDiff(ship1, effect2), new ShipEffectRemovedDiff(ship1, effect2),
new ShipEffectAddedDiff(ship2, effect3) new ShipEffectAddedDiff(ship2, effect3)
], "effects diff"); ], "effects diff");
}); });
}) })
test.case("applies vigilance actions", check => { test.case("applies vigilance actions", check => {
let battle = new Battle(); let battle = new Battle();
let ship1 = battle.fleets[0].addShip(); let ship1 = battle.fleets[0].addShip();
ship1.setArenaPosition(100, 100); ship1.setArenaPosition(100, 100);
TestTools.setShipModel(ship1, 10, 0, 5); TestTools.setShipModel(ship1, 10, 0, 5);
let ship2 = battle.fleets[1].addShip(); let ship2 = battle.fleets[1].addShip();
ship2.setArenaPosition(1000, 1000); ship2.setArenaPosition(1000, 1000);
TestTools.setShipModel(ship2, 10); TestTools.setShipModel(ship2, 10);
TestTools.setShipPlaying(battle, ship1); TestTools.setShipPlaying(battle, ship1);
let vig1 = ship1.actions.addCustom(new VigilanceAction("Vig1", { radius: 100, filter: ActionTargettingFilter.ENEMIES }, { intruder_effects: [new DamageEffect(1)] })); 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 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)] })); let vig3 = ship1.actions.addCustom(new VigilanceAction("Vig3", { radius: 100, filter: ActionTargettingFilter.ALLIES }, { intruder_effects: [new DamageEffect(3)] }));
battle.applyOneAction(vig1.id); battle.applyOneAction(vig1.id);
battle.applyOneAction(vig2.id); battle.applyOneAction(vig2.id);
battle.applyOneAction(vig3.id); battle.applyOneAction(vig3.id);
let checks = new BattleChecks(battle); let checks = new BattleChecks(battle);
check.in("initial state", check => { check.in("initial state", check => {
check.equals(checks.checkAreaEffects(), [], "effects diff"); check.equals(checks.checkAreaEffects(), [], "effects diff");
}); });
ship2.setArenaPosition(100, 160); ship2.setArenaPosition(100, 160);
check.in("ship2 moved in range", check => { check.in("ship2 moved in range", check => {
check.equals(checks.checkAreaEffects(), [ check.equals(checks.checkAreaEffects(), [
new ShipEffectAddedDiff(ship2, vig1.effects[0]), new ShipEffectAddedDiff(ship2, vig1.effects[0]),
new VigilanceAppliedDiff(ship1, vig1, ship2), new VigilanceAppliedDiff(ship1, vig1, ship2),
new ShipDamageDiff(ship2, 1, 0), new ShipDamageDiff(ship2, 1, 0),
new ShipValueDiff(ship2, "hull", -1), new ShipValueDiff(ship2, "hull", -1),
], "effects diff"); ], "effects diff");
}); });
}) })
}) })
}

View file

@ -1,164 +1,174 @@
module TK.SpaceTac { import { ifirst, iforeach, imaterialize } from "../common/Iterators";
/** import { RObjectContainer } from "../common/RObject";
* List of checks to apply at the end of an action, to ensure a correct battle state import { any, first, flatten, keys } from "../common/Tools";
* import { Battle } from "./Battle";
* This is useful when the list of effects simulated by an action was missing something import { BaseBattleDiff } from "./diffs/BaseBattleDiff";
* import { EndBattleDiff } from "./diffs/EndBattleDiff";
* To fix the state, new diffs will be applied import { ShipEffectAddedDiff, ShipEffectRemovedDiff } from "./diffs/ShipEffectAddedDiff";
*/ import { ShipValueDiff } from "./diffs/ShipValueDiff";
export class BattleChecks { import { StickyEffect } from "./effects/StickyEffect";
constructor(private battle: Battle) { import { Ship } from "./Ship";
} import { SHIP_VALUES } from "./ShipValue";
/** /**
* Apply all the checks * List of checks to apply at the end of an action, to ensure a correct battle state
*/ *
apply(): BaseBattleDiff[] { * This is useful when the list of effects simulated by an action was missing something
let all: BaseBattleDiff[] = []; *
let diffs: BaseBattleDiff[]; * To fix the state, new diffs will be applied
let loops = 0; */
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) { do {
//console.log("Battle checks diffs", diffs); diffs = this.checkAll();
this.battle.applyDiffs(diffs);
all = all.concat(diffs);
}
loops += 1; if (diffs.length > 0) {
if (loops >= 1000) { //console.log("Battle checks diffs", diffs);
console.error("Battle checks stuck in infinite loop", diffs); this.battle.applyDiffs(diffs);
break; all = all.concat(diffs);
} }
} while (diffs.length > 0);
return all; loops += 1;
} if (loops >= 1000) {
console.error("Battle checks stuck in infinite loop", diffs);
break;
}
} while (diffs.length > 0);
/** return all;
* 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[] = [];
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 (this.battle.ended) {
if (diffs.length) { return diffs;
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)));
}
} }
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
* Log of diffs that change the state of a battle */
*/ export class BattleLog extends DiffLog<Battle> {
export class BattleLog extends DiffLog<Battle> { }
}
/**
/** * Client for a battle log
* Client for a battle log */
*/ export class BattleLogClient extends DiffLogClient<Battle> {
export class BattleLogClient extends DiffLogClient<Battle> {
}
} }

View file

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

View file

@ -1,34 +1,36 @@
module TK.SpaceTac { import { RObjectId } from "../common/RObject";
/** import { flatten, sum } from "../common/Tools";
* Result of an ended battle import { Fleet } from "./Fleet";
*
* This stores the winner, and the retrievable loot
*/
export class BattleOutcome {
// Indicates if the battle is a draw (no winner)
draw: boolean
// 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) { // Victorious fleet
this.winner = winner ? winner.id : null; winner: RObjectId | null
this.draw = winner ? false : true;
}
/** constructor(winner: Fleet | null) {
* Grant experience to participating fleets this.winner = winner ? winner.id : null;
*/ this.draw = winner ? false : true;
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)); * Grant experience to participating fleets
let difficulty = sum(enemies.map(enemy => 100 + enemy.level.getExperience())); */
fleet.ships.forEach(ship => { grantExperience(fleets: Fleet[]) {
ship.level.addExperience(Math.floor(difficulty * winfactor)); fleets.forEach(fleet => {
ship.level.checkLevelUp(); 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 => {
testing("BattleStats", test => { test.case("collects stats", check => {
test.case("collects stats", check => { let stats = new BattleStats();
let stats = new BattleStats(); check.equals(stats.stats, {});
check.equals(stats.stats, {});
stats.addStat("Test", 1, true); stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [1, 0] }); check.equals(stats.stats, { Test: [1, 0] });
stats.addStat("Test", 1, true); stats.addStat("Test", 1, true);
check.equals(stats.stats, { Test: [2, 0] }); check.equals(stats.stats, { Test: [2, 0] });
stats.addStat("Test", 1, false); stats.addStat("Test", 1, false);
check.equals(stats.stats, { Test: [2, 1] }); check.equals(stats.stats, { Test: [2, 1] });
stats.addStat("Other Test", 10, true); stats.addStat("Other Test", 10, true);
check.equals(stats.stats, { Test: [2, 1], "Other Test": [10, 0] }); check.equals(stats.stats, { Test: [2, 1], "Other Test": [10, 0] });
}) })
test.case("collects damage dealt", check => { test.case("collects damage dealt", check => {
let stats = new BattleStats(); let stats = new BattleStats();
let battle = new Battle(); let battle = new Battle();
let attacker = battle.fleets[0].addShip(); let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip(); let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]); stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {}); check.equals(stats.stats, {});
battle.log.add(new ShipDamageDiff(defender, 1, 3, 2)); battle.log.add(new ShipDamageDiff(defender, 1, 3, 2));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [0, 1], "Damage shielded": [0, 3], "Damage evaded": [0, 2] }); 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)); battle.log.add(new ShipDamageDiff(attacker, 2, 1, 3));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 1], "Damage shielded": [1, 3], "Damage evaded": [3, 2] }); 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)); battle.log.add(new ShipDamageDiff(defender, 1, 1, 1));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Damage taken": [2, 2], "Damage shielded": [1, 4], "Damage evaded": [3, 3] }); check.equals(stats.stats, { "Damage taken": [2, 2], "Damage shielded": [1, 4], "Damage evaded": [3, 3] });
}) })
test.case("collects distance moved", check => { test.case("collects distance moved", check => {
let stats = new BattleStats(); let stats = new BattleStats();
let battle = new Battle(); let battle = new Battle();
let attacker = battle.fleets[0].addShip(); let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip(); let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]); stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {}); check.equals(stats.stats, {});
battle.log.add(new ShipMoveDiff(attacker, new ArenaLocationAngle(0, 0), new ArenaLocationAngle(10, 0))); battle.log.add(new ShipMoveDiff(attacker, new ArenaLocationAngle(0, 0), new ArenaLocationAngle(10, 0)));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 0] }); check.equals(stats.stats, { "Move distance (km)": [10, 0] });
battle.log.add(new ShipMoveDiff(defender, new ArenaLocationAngle(10, 5), new ArenaLocationAngle(10, 63))); battle.log.add(new ShipMoveDiff(defender, new ArenaLocationAngle(10, 5), new ArenaLocationAngle(10, 63)));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Move distance (km)": [10, 58] }); check.equals(stats.stats, { "Move distance (km)": [10, 58] });
}) })
test.case("collects deployed drones", check => { test.case("collects deployed drones", check => {
let stats = new BattleStats(); let stats = new BattleStats();
let battle = new Battle(); let battle = new Battle();
let attacker = battle.fleets[0].addShip(); let attacker = battle.fleets[0].addShip();
let defender = battle.fleets[1].addShip(); let defender = battle.fleets[1].addShip();
stats.processLog(battle.log, battle.fleets[0]); stats.processLog(battle.log, battle.fleets[0]);
check.equals(stats.stats, {}); check.equals(stats.stats, {});
battle.log.add(new DroneDeployedDiff(new Drone(attacker))); battle.log.add(new DroneDeployedDiff(new Drone(attacker)));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 0] }); check.equals(stats.stats, { "Drones deployed": [1, 0] });
battle.log.add(new DroneDeployedDiff(new Drone(defender))); battle.log.add(new DroneDeployedDiff(new Drone(defender)));
stats.processLog(battle.log, battle.fleets[0], true); stats.processLog(battle.log, battle.fleets[0], true);
check.equals(stats.stats, { "Drones deployed": [1, 1] }); check.equals(stats.stats, { "Drones deployed": [1, 1] });
}) })
}) })
}

View file

@ -1,64 +1,70 @@
module TK.SpaceTac { import { any, iteritems } from "../common/Tools";
/** import { BattleLog } from "./BattleLog";
* Statistics collection over a battle import { BaseBattleShipDiff } from "./diffs/BaseBattleDiff";
*/ import { DroneDeployedDiff } from "./diffs/DroneDeployedDiff";
export class BattleStats { import { ShipDamageDiff } from "./diffs/ShipDamageDiff";
stats: { [name: string]: [number, number] } = {} import { ShipMoveDiff } from "./diffs/ShipMoveDiff";
import { Fleet } from "./Fleet";
/** /**
* Add a value to the collector * Statistics collection over a battle
*/ */
addStat(name: string, value: number, attacker: boolean) { export class BattleStats {
if (!this.stats[name]) { stats: { [name: string]: [number, number] } = {}
this.stats[name] = [0, 0];
}
if (attacker) { /**
this.stats[name] = [this.stats[name][0] + value, this.stats[name][1]]; * Add a value to the collector
} else { */
this.stats[name] = [this.stats[name][0], this.stats[name][1] + value]; addStat(name: string, value: number, attacker: boolean) {
} if (!this.stats[name]) {
} this.stats[name] = [0, 0];
/**
* 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);
}
}
}
}
} }
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 => {
testing("Cooldown", test => { test.case("applies overheat and cooldown", check => {
test.case("applies overheat and cooldown", check => { let cooldown = new Cooldown();
let cooldown = new Cooldown(); check.equals(cooldown.canUse(), true);
check.equals(cooldown.canUse(), true);
cooldown.use(); cooldown.use();
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
cooldown.configure(2, 3); cooldown.configure(2, 3);
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
cooldown.use(); cooldown.use();
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
cooldown.use(); cooldown.use();
check.equals(cooldown.canUse(), false); check.equals(cooldown.canUse(), false);
cooldown.cool(); cooldown.cool();
check.equals(cooldown.canUse(), false); check.equals(cooldown.canUse(), false);
cooldown.cool(); cooldown.cool();
check.equals(cooldown.canUse(), false); check.equals(cooldown.canUse(), false);
cooldown.cool(); cooldown.cool();
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
cooldown.configure(1, 0); cooldown.configure(1, 0);
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
cooldown.use(); cooldown.use();
check.equals(cooldown.canUse(), false); check.equals(cooldown.canUse(), false);
cooldown.cool(); cooldown.cool();
check.equals(cooldown.canUse(), true); check.equals(cooldown.canUse(), true);
}); });
}); });
}

View file

@ -1,93 +1,91 @@
module TK.SpaceTac { /**
/** * Cooldown system for equipments
* Cooldown system for equipments */
*/ export class Cooldown {
export class Cooldown { // Number of uses in the current turn
// Number of uses in the current turn uses = 0
uses = 0
// Accumulated heat to dissipate (number of turns) // Accumulated heat to dissipate (number of turns)
heat = 0 heat = 0
// Maximum number of uses allowed per turn before overheating (0 for unlimited) // Maximum number of uses allowed per turn before overheating (0 for unlimited)
overheat = 0 overheat = 0
// Number of "end turn" needed to cooldown when overheated // Number of "end turn" needed to cooldown when overheated
cooling = 1 cooling = 1
constructor(overheat = 0, cooling = 1) { constructor(overheat = 0, cooling = 1) {
this.configure(overheat, cooling); this.configure(overheat, cooling);
} }
toString(): string { toString(): string {
return `Overheat ${this.overheat} / Cooldown ${this.cooling}`; return `Overheat ${this.overheat} / Cooldown ${this.cooling}`;
} }
/** /**
* Check if the equipment can be used in regards to heat * Check if the equipment can be used in regards to heat
*/ */
canUse(): boolean { canUse(): boolean {
return this.heat == 0; return this.heat == 0;
} }
/** /**
* Check if the equipment would overheat if used * Check if the equipment would overheat if used
*/ */
willOverheat(): boolean { willOverheat(): boolean {
return this.overheat > 0 && this.uses + 1 >= this.overheat; return this.overheat > 0 && this.uses + 1 >= this.overheat;
} }
/** /**
* Check the number of uses before overheating * Check the number of uses before overheating
*/ */
getRemainingUses(): number { getRemainingUses(): number {
if (this.overheat) { if (this.overheat) {
return (this.heat > 0) ? 0 : (this.overheat - this.uses); return (this.heat > 0) ? 0 : (this.overheat - this.uses);
} else { } else {
return Infinity; 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;
}
} }
}
/**
* 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 => {
testing("Drone", test => { test.case("applies area effects when deployed", check => {
test.case("applies area effects when deployed", check => { let battle = TestTools.createBattle();
let battle = TestTools.createBattle(); let ship = nn(battle.playing_ship);
let ship = nn(battle.playing_ship); TestTools.setShipModel(ship, 100, 0, 10);
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)] });
let weapon = new DeployDroneAction("testdrone", { power: 2 }, { deploy_distance: 300, drone_radius: 30, drone_effects: [new AttributeEffect("evasion", 15)] }); ship.actions.addCustom(weapon);
ship.actions.addCustom(weapon); let engine = TestTools.addEngine(ship, 1000);
let engine = TestTools.addEngine(ship, 1000);
TestTools.actionChain(check, battle, [ TestTools.actionChain(check, battle, [
[ship, weapon, Target.newFromLocation(150, 50)], // deploy out of effects radius [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(110, 50)], // move out of effects radius
[ship, engine, Target.newFromLocation(130, 50)], // move in effects radius [ship, engine, Target.newFromLocation(130, 50)], // move in effects radius
[ship, weapon, Target.newFromShip(ship)], // recall [ship, weapon, Target.newFromShip(ship)], // recall
[ship, weapon, Target.newFromLocation(130, 70)], // deploy in effects radius [ship, weapon, Target.newFromLocation(130, 70)], // deploy in effects radius
], [ ], [
check => { check => {
check.equals(ship.active_effects.count(), 0, "active effects"); check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 10, "power"); check.equals(ship.getValue("power"), 10, "power");
check.equals(battle.drones.count(), 0, "drone count"); check.equals(battle.drones.count(), 0, "drone count");
}, },
check => { check => {
check.equals(ship.active_effects.count(), 0, "active effects"); check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power"); check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 1, "drone count"); check.equals(battle.drones.count(), 1, "drone count");
}, },
check => { check => {
check.equals(ship.active_effects.count(), 0, "active effects"); check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 7, "power"); check.equals(ship.getValue("power"), 7, "power");
check.equals(battle.drones.count(), 1, "drone count"); check.equals(battle.drones.count(), 1, "drone count");
}, },
check => { check => {
check.equals(ship.active_effects.count(), 1, "active effects"); check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power"); check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count"); check.equals(battle.drones.count(), 1, "drone count");
}, },
check => { check => {
check.equals(ship.active_effects.count(), 0, "active effects"); check.equals(ship.active_effects.count(), 0, "active effects");
check.equals(ship.getValue("power"), 8, "power"); check.equals(ship.getValue("power"), 8, "power");
check.equals(battle.drones.count(), 0, "drone count"); check.equals(battle.drones.count(), 0, "drone count");
}, },
check => { check => {
check.equals(ship.active_effects.count(), 1, "active effects"); check.equals(ship.active_effects.count(), 1, "active effects");
check.equals(ship.getValue("power"), 6, "power"); check.equals(ship.getValue("power"), 6, "power");
check.equals(battle.drones.count(), 1, "drone count"); check.equals(battle.drones.count(), 1, "drone count");
}, },
]); ]);
}); });
test.case("builds a textual description", check => { test.case("builds a textual description", check => {
let drone = new Drone(new Ship()); let drone = new Drone(new Ship());
check.equals(drone.getDescription(), "While deployed:\n• do nothing"); check.equals(drone.getDescription(), "While deployed:\n• do nothing");
drone.effects = [ drone.effects = [
new DamageEffect(5), new DamageEffect(5),
new AttributeEffect("evasion", 1) new AttributeEffect("evasion", 1)
] ]
check.equals(drone.getDescription(), "While deployed:\n• do 5 damage\n• evasion +1"); check.equals(drone.getDescription(), "While deployed:\n• do 5 damage\n• evasion +1");
}); });
}); });
}

View file

@ -1,63 +1,70 @@
module TK.SpaceTac { import { ifilter, imaterialize } from "../common/Iterators"
/** import { RObject, RObjectId } from "../common/RObject"
* Drones are static objects that apply effects in a circular zone around themselves. import { DeployDroneAction } from "./actions/DeployDroneAction"
*/ import { ArenaLocation } from "./ArenaLocation"
export class Drone extends RObject { import { Battle } from "./Battle"
// ID of the owning ship import { BaseEffect } from "./effects/BaseEffect"
owner: RObjectId 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 // Code of the drone
x = 0 code: string
y = 0
radius = 0
// Effects to apply // Location in arena
effects: BaseEffect[] = [] x = 0
y = 0
radius = 0
// Action that triggered that drone // Effects to apply
parent: DeployDroneAction | null = null; effects: BaseEffect[] = []
constructor(owner: Ship, code = "drone") { // Action that triggered that drone
super(); parent: DeployDroneAction | null = null;
this.owner = owner.id; constructor(owner: Ship, code = "drone") {
this.code = code; super();
}
/** this.owner = owner.id;
* Return the current location of the drone this.code = code;
*/ }
get location(): ArenaLocation {
return new ArenaLocation(this.x, this.y);
}
/** /**
* Get a textual description of this drone * Return the current location of the drone
*/ */
getDescription(): string { get location(): ArenaLocation {
let effects = this.effects.map(effect => "• " + effect.getDescription()).join("\n"); return new ArenaLocation(this.x, this.y);
if (effects.length == 0) { }
effects = "• do nothing";
}
return `While deployed:\n${effects}`;
}
/** /**
* Check if a location is in range * Get a textual description of this drone
*/ */
isInRange(x: number, y: number): boolean { getDescription(): string {
return Target.newFromLocation(x, y).getDistanceTo(this) <= this.radius; let effects = this.effects.map(effect => "• " + effect.getDescription()).join("\n");
} if (effects.length == 0) {
effects = "• do nothing";
/**
* 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);
}
} }
} 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 { import { testing } from "../common/Testing";
testing("ExclusionAreas", test => { import { ArenaLocationAngle } from "./ArenaLocation";
test.case("constructs from a ship or battle", check => { import { Battle } from "./Battle";
let battle = new Battle(); import { ExclusionAreas } from "./ExclusionAreas";
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);
let exclusion = ExclusionAreas.fromBattle(battle); testing("ExclusionAreas", test => {
check.equals(exclusion.hard_border, 17); test.case("constructs from a ship or battle", check => {
check.equals(exclusion.effective_obstacle, 31); let battle = new Battle();
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]); 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); let exclusion = ExclusionAreas.fromBattle(battle);
check.equals(exclusion.hard_border, 17); check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 120); check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]); check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromBattle(battle, [ship2], 10); exclusion = ExclusionAreas.fromBattle(battle, [ship1], 120);
check.equals(exclusion.hard_border, 17); check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31); check.equals(exclusion.effective_obstacle, 120);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]); check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromShip(ship1); exclusion = ExclusionAreas.fromBattle(battle, [ship2], 10);
check.equals(exclusion.hard_border, 17); check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31); check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]); check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]);
exclusion = ExclusionAreas.fromShip(ship2, 99); exclusion = ExclusionAreas.fromShip(ship1);
check.equals(exclusion.hard_border, 17); check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 99); check.equals(exclusion.effective_obstacle, 31);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5)]); check.equals(exclusion.obstacles, [new ArenaLocationAngle(43, 89)]);
exclusion = ExclusionAreas.fromShip(ship2, 10, false); exclusion = ExclusionAreas.fromShip(ship2, 99);
check.equals(exclusion.hard_border, 17); check.equals(exclusion.hard_border, 17);
check.equals(exclusion.effective_obstacle, 31); check.equals(exclusion.effective_obstacle, 99);
check.equals(exclusion.obstacles, [new ArenaLocationAngle(12, 5), new ArenaLocationAngle(43, 89)]); 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 { import { ifilter, imap, imaterialize } from "../common/Iterators"
/** import { cmp, contains, sorted } from "../common/Tools"
* Helper for working with exclusion areas (areas where a ship cannot go) import { arenaDistance, ArenaLocation } from "./ArenaLocation"
* import { Battle } from "./Battle"
* There are three types of exclusion: import { Ship } from "./Ship"
* - Hard border exclusion, that prevents a ship from being too close to the battle edges import { Target } from "./Target"
* - 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[] = []
constructor(width: number, height: number) { /**
this.xmin = 0; * Helper for working with exclusion areas (areas where a ship cannot go)
this.xmax = width - 1; *
this.ymin = 0; * There are three types of exclusion:
this.ymax = height - 1; * - Hard border exclusion, that prevents a ship from being too close to the battle edges
this.active = width > 0 && height > 0; * - 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[] = []
/** constructor(width: number, height: number) {
* Build an exclusion helper from a battle. this.xmin = 0;
*/ this.xmax = width - 1;
static fromBattle(battle: Battle, ignore_ships: Ship[] = [], soft_distance = 0): ExclusionAreas { this.ymin = 0;
let result = new ExclusionAreas(battle.width, battle.height); this.ymax = height - 1;
result.hard_border = battle.border; this.active = width > 0 && height > 0;
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;
}
/** /**
* Build an exclusion helper for a ship. * Build an exclusion helper from a battle.
* */
* If *ignore_self* is True, the ship will itself not be included in exclusion areas. static fromBattle(battle: Battle, ignore_ships: Ship[] = [], soft_distance = 0): ExclusionAreas {
*/ let result = new ExclusionAreas(battle.width, battle.height);
static fromShip(ship: Ship, soft_distance = 0, ignore_self = true): ExclusionAreas { result.hard_border = battle.border;
let battle = ship.getBattle(); result.hard_obstacle = battle.ship_separation;
if (battle) { let obstacles = imap(ifilter(battle.iships(true), ship => !contains(ignore_ships, ship)), ship => ship.location);
return ExclusionAreas.fromBattle(battle, ignore_self ? [ship] : [], soft_distance); result.configure(imaterialize(obstacles), soft_distance);
} else { return result;
return new ExclusionAreas(0, 0); }
}
}
/** /**
* Configure the areas for next check calls. * Build an exclusion helper for a ship.
*/ *
configure(obstacles: ArenaLocation[], soft_distance: number) { * If *ignore_self* is True, the ship will itself not be included in exclusion areas.
this.obstacles = obstacles; */
this.effective_obstacle = Math.max(soft_distance, this.hard_obstacle); 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);
* Keep a location outside exclusion areas, when coming from a source. } else {
* return new ExclusionAreas(0, 0);
* 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);
}
} }
}
/**
* 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 { import { RObjectId } from "../common/RObject";
testing("Fleet", test => { import { testing } from "../common/Testing";
test.case("get average level", check => { import { Battle } from "./Battle";
var fleet = new Fleet(); import { Fleet } from "./Fleet";
check.equals(fleet.getLevel(), 0); import { Ship } from "./Ship";
import { StarLocationType } from "./StarLocation";
import { Universe } from "./Universe";
fleet.addShip(new Ship()); testing("Fleet", test => {
fleet.addShip(new Ship()); test.case("get average level", check => {
fleet.addShip(new Ship()); var fleet = new Fleet();
check.equals(fleet.getLevel(), 0);
fleet.ships[0].level.forceLevel(2); fleet.addShip(new Ship());
fleet.ships[1].level.forceLevel(4); fleet.addShip(new Ship());
fleet.ships[2].level.forceLevel(7); fleet.addShip(new Ship());
check.equals(fleet.getLevel(), 4);
});
test.case("adds and removes ships", check => { fleet.ships[0].level.forceLevel(2);
let fleet1 = new Fleet(); fleet.ships[1].level.forceLevel(4);
let fleet2 = new Fleet(); fleet.ships[2].level.forceLevel(7);
check.equals(fleet.getLevel(), 4);
});
let ship1 = fleet1.addShip(); test.case("adds and removes ships", check => {
check.equals(fleet1.ships, [ship1]); let fleet1 = new Fleet();
check.equals(fleet2.ships, []); let fleet2 = new Fleet();
let ship2 = new Ship(); let ship1 = fleet1.addShip();
check.equals(fleet1.ships, [ship1]); check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []); check.equals(fleet2.ships, []);
fleet2.addShip(ship2); let ship2 = new Ship();
check.equals(fleet1.ships, [ship1]); check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, [ship2]); check.equals(fleet2.ships, []);
fleet1.addShip(ship2); fleet2.addShip(ship2);
check.equals(fleet1.ships, [ship1, ship2]); check.equals(fleet1.ships, [ship1]);
check.equals(fleet2.ships, []); check.equals(fleet2.ships, [ship2]);
fleet1.removeShip(ship1, fleet2); fleet1.addShip(ship2);
check.equals(fleet1.ships, [ship2]); check.equals(fleet1.ships, [ship1, ship2]);
check.equals(fleet2.ships, [ship1]); check.equals(fleet2.ships, []);
fleet1.removeShip(ship1); fleet1.removeShip(ship1, fleet2);
check.equals(fleet1.ships, [ship2]); check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]); check.equals(fleet2.ships, [ship1]);
fleet1.removeShip(ship2); fleet1.removeShip(ship1);
check.equals(fleet1.ships, []); check.equals(fleet1.ships, [ship2]);
check.equals(fleet2.ships, [ship1]); check.equals(fleet2.ships, [ship1]);
});
test.case("changes location, only using jumps to travel between systems", check => { fleet1.removeShip(ship2);
let fleet = new Fleet(); check.equals(fleet1.ships, []);
let universe = new Universe(); check.equals(fleet2.ships, [ship1]);
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();
let result = fleet.move(other1); test.case("changes location, only using jumps to travel between systems", check => {
check.in("cannot move from nowhere", check => { let fleet = new Fleet();
check.equals(result, false); let universe = new Universe();
check.equals(fleet.location, null); 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); let result = fleet.move(other1);
check.in("force set to other1", check => { check.in("cannot move from nowhere", check => {
check.equals(fleet.location, other1.id); check.equals(result, false);
}); check.equals(fleet.location, null);
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);
});
}); });
}
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 { import { RObject, RObjectId } from "../common/RObject"
/** import { add, any, remove } from "../common/Tools"
* A fleet of ships, all belonging to the same player import { Battle } from "./Battle"
*/ import { Player } from "./Player"
export class Fleet extends RObject { import { Ship } from "./Ship"
// Fleet owner import { StarLocation, StarLocationType } from "./StarLocation"
player: Player
// 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 // Fleet name
ships: Ship[] name: string
// Current fleet location // List of ships
location: RObjectId | null = null ships: Ship[]
// Visited locations (ordered by last visited) // Current fleet location
visited: RObjectId[] = [] location: RObjectId | null = null
// Current battle in which the fleet is engaged (null if not fighting) // Visited locations (ordered by last visited)
battle: Battle | null = null visited: RObjectId[] = []
// Amount of credits available // Current battle in which the fleet is engaged (null if not fighting)
credits = 0 battle: Battle | null = null
// Create a fleet, bound to a player // Amount of credits available
constructor(player = new Player()) { credits = 0
super();
this.player = player; // Create a fleet, bound to a player
this.name = player ? player.name : "Fleet"; constructor(player = new Player()) {
this.ships = []; super();
}
jasmineToString(): string { this.player = player;
return `${this.name} [${this.ships.map(ship => ship.getName()).join(",")}]`; this.name = player ? player.name : "Fleet";
} this.ships = [];
}
/** jasmineToString(): string {
* Set the owner player return `${this.name} [${this.ships.map(ship => ship.getName()).join(",")}]`;
*/ }
setPlayer(player: Player): void {
this.player = player;
}
/** /**
* Set a location as visited * Set the owner player
*/ */
setVisited(location: StarLocation): void { setPlayer(player: Player): void {
remove(this.visited, location.id); this.player = player;
this.visited.unshift(location.id); }
}
/** /**
* Move the fleet to another location, checking that the move is physically possible * Set a location as visited
* */
* Returns true on success setVisited(location: StarLocation): void {
*/ remove(this.visited, location.id);
move(to: StarLocation): boolean { this.visited.unshift(location.id);
if (!this.location) { }
return false;
}
let source = to.universe.locations.get(this.location); /**
if (!source) { * Move the fleet to another location, checking that the move is physically possible
return false; *
} * Returns true on success
*/
if (source.star != to.star) { move(to: StarLocation): boolean {
// Need to jump, check conditions if (!this.location) {
if (source.type != StarLocationType.WARP || source.jump_dest != to) { return false;
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);
}
}
} }
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 { import { RandomGenerator } from "../common/RandomGenerator";
// Generator of balanced ships to form a fleet import { range } from "../common/Tools";
export class FleetGenerator { import { Fleet } from "./Fleet";
// Random generator to use import { ShipModel } from "./models/ShipModel";
random: RandomGenerator; import { Player } from "./Player";
import { ShipGenerator } from "./ShipGenerator";
constructor(random = RandomGenerator.global) { // Generator of balanced ships to form a fleet
this.random = random; export class FleetGenerator {
} // Random generator to use
random: RandomGenerator;
/** constructor(random = RandomGenerator.global) {
* Generate a fleet of a given level this.random = random;
*/ }
generate(level: number, player?: Player, ship_count = 3, upgrade = false): Fleet {
var fleet = new Fleet(player);
var ship_generator = new ShipGenerator(this.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 => { let models = this.random.sample(ShipModel.getDefaultCollection(), ship_count);
var ship = ship_generator.generate(level, models[i] || null, upgrade);
ship.name = ship.model.name;
fleet.addShip(ship);
});
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 { import { SkewedRandomGenerator } from "../common/RandomGenerator";
testing("GameSession", test => { import { RObjectContainer } from "../common/RObject";
/** import { testing } from "../common/Testing";
* Compare two sessions import { nn } from "../common/Tools";
*/ import { Fleet } from "./Fleet";
function compare(session1: GameSession, session2: GameSession) { import { GameSession } from "./GameSession";
test.check.equals(session1, session2); import { StarLocation, StarLocationType } from "./StarLocation";
}
/** testing("GameSession", test => {
* Apply deterministic game steps /**
*/ * Compare two sessions
function applyGameSteps(session: GameSession): void { */
var battle = nn(session.getBattle()); function compare(session1: GameSession, session2: GameSession) {
battle.advanceToNextShip(); test.check.equals(session1, session2);
// TODO Make some fixed moves (AI?) }
battle.endBattle(battle.fleets[0]);
}
test.case("serializes to a string", check => { /**
var session = new GameSession(); * Apply deterministic game steps
session.startQuickBattle(); */
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 test.case("serializes to a string", check => {
var dumped = session.saveToString(); var session = new GameSession();
var loaded_session = GameSession.loadFromString(dumped); session.startQuickBattle();
// Check equality // Dump and reload
compare(loaded_session, session); var dumped = session.saveToString();
var loaded_session = GameSession.loadFromString(dumped);
// Apply game steps // Check equality
applyGameSteps(session); compare(loaded_session, session);
applyGameSteps(loaded_session);
// Check equality after game steps // Apply game steps
compare(loaded_session, session); applyGameSteps(session);
}); applyGameSteps(loaded_session);
test.case("generates a quick battle", check => { // Check equality after game steps
var session = new GameSession(); compare(loaded_session, session);
session.startQuickBattle(); });
check.same(session.getBattle(), session.player.fleet.battle, "battle"); test.case("generates a quick battle", check => {
check.same(session.player.fleet, session.fleet, "fleet"); var session = new GameSession();
check.same(nn(session.getBattle()).fleets[0], session.fleet, "attacker fleet"); session.startQuickBattle();
});
test.case("applies battle outcome to bound player", check => { check.same(session.getBattle(), session.player.fleet.battle, "battle");
let session = new GameSession(); check.same(session.player.fleet, session.fleet, "fleet");
check.equals(session.getBattle(), null); check.same(nn(session.getBattle()).fleets[0], session.fleet, "attacker fleet");
});
let location1 = new StarLocation(); test.case("applies battle outcome to bound player", check => {
let location2 = new StarLocation(location1.star); let session = new GameSession();
session.universe.locations = new RObjectContainer([location1, location2]); check.equals(session.getBattle(), null);
// Victory case let location1 = new StarLocation();
location1.encounter = new Fleet(); let location2 = new StarLocation(location1.star);
session.player.fleet.setLocation(location1); session.universe.locations = new RObjectContainer([location1, location2]);
check.notequals(session.getBattle(), null);
check.notequals(location1.encounter, null);
let battle = nn(session.getBattle()); // Victory case
battle.endBattle(session.player.fleet); location1.encounter = new Fleet();
session.setBattleEnded(); session.player.fleet.setLocation(location1);
check.notequals(session.getBattle(), null); check.notequals(session.getBattle(), null);
check.equals(location1.encounter, null); check.notequals(location1.encounter, null);
// Defeat case let battle = nn(session.getBattle());
location2.encounter = new Fleet(); battle.endBattle(session.player.fleet);
session.player.fleet.setLocation(location2); session.setBattleEnded();
check.notequals(session.getBattle(), null); check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null); check.equals(location1.encounter, null);
battle = nn(session.getBattle()); // Defeat case
battle.endBattle(null); location2.encounter = new Fleet();
session.setBattleEnded(); session.player.fleet.setLocation(location2);
check.notequals(session.getBattle(), null); check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null); check.notequals(location2.encounter, null);
});
test.case("generates a new campaign", check => { battle = nn(session.getBattle());
let session = new GameSession(); battle.endBattle(null);
session.setBattleEnded();
check.notequals(session.getBattle(), null);
check.notequals(location2.encounter, null);
});
session.startNewGame(false); test.case("generates a new campaign", check => {
check.notequals(session.player, null); let session = new GameSession();
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);
session.setCampaignFleet(); session.startNewGame(false);
check.equals(session.player.fleet.ships.length, 2); check.notequals(session.player, null);
check.equals(session.player.fleet.credits, 0); check.equals(session.player.fleet.ships.length, 0);
check.equals(session.player.fleet.location, session.start_location.id); 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 => { session.setCampaignFleet();
let session = new GameSession(); check.equals(session.player.fleet.ships.length, 2);
let star = session.universe.addStar(); check.equals(session.player.fleet.credits, 0);
let loc1 = star.addLocation(StarLocationType.PLANET); check.equals(session.player.fleet.location, session.start_location.id);
loc1.clearEncounter(); });
let loc2 = star.addLocation(StarLocationType.PLANET);
loc2.encounter_random = new SkewedRandomGenerator([0], true);
session.universe.updateLocations();
session.fleet.setLocation(loc1); test.case("can revert battle", check => {
check.in("init in loc1", check => { let session = new GameSession();
check.equals(session.getBattle(), null, "bound battle"); let star = session.universe.addStar();
check.equals(session.fleet.location, loc1.id, "fleet location"); let loc1 = star.addLocation(StarLocationType.PLANET);
check.equals(session.player.hasVisitedLocation(loc2), false, "visited"); loc1.clearEncounter();
}); let loc2 = star.addLocation(StarLocationType.PLANET);
loc2.encounter_random = new SkewedRandomGenerator([0], true);
session.universe.updateLocations();
session.fleet.setLocation(loc2); session.fleet.setLocation(loc1);
check.in("move to loc2", check => { check.in("init in loc1", check => {
check.notequals(session.getBattle(), null, "bound battle"); check.equals(session.getBattle(), null, "bound battle");
check.equals(session.fleet.location, loc2.id, "fleet location"); check.equals(session.fleet.location, loc1.id, "fleet location");
check.equals(session.player.hasVisitedLocation(loc2), true, "visited"); check.equals(session.player.hasVisitedLocation(loc2), false, "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(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 { import { NAMESPACE } from ".."
/** import { iforeach } from "../common/Iterators"
* A game session, binding a universe and a player import { RandomGenerator } from "../common/RandomGenerator"
* import { Serializer } from "../common/Serializer"
* This represents the current state of game import { Battle } from "./Battle"
*/ import { Fleet } from "./Fleet"
export class GameSession { import { FleetGenerator } from "./FleetGenerator"
// "Hopefully" unique session id import { PersonalityReactions } from "./PersonalityReactions"
id: string 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 // Game universe
player: Player universe: Universe
// Personality reactions // Current connected player
reactions: PersonalityReactions player: Player
// Starting location // Personality reactions
start_location: StarLocation reactions: PersonalityReactions
// Indicator that the session is the primary one // Starting location
primary = true start_location: StarLocation
// Indicator of spectator mode // Indicator that the session is the primary one
spectator = false primary = true
// Indicator that introduction has been watched // Indicator of spectator mode
introduced = false spectator = false
constructor() { // Indicator that introduction has been watched
this.id = RandomGenerator.global.id(20); introduced = false
this.universe = new Universe();
this.player = new Player();
this.reactions = new PersonalityReactions();
this.start_location = new StarLocation();
}
/** constructor() {
* Get the currently played fleet this.id = RandomGenerator.global.id(20);
*/ this.universe = new Universe();
get fleet(): Fleet { this.player = new Player();
return this.player.fleet; this.reactions = new PersonalityReactions();
} this.start_location = new StarLocation();
}
/** /**
* Get an indicative description of the session (to help identify game saves) * Get the currently played fleet
*/ */
getDescription(): string { get fleet(): Fleet {
let level = this.player.fleet.getLevel(); return this.player.fleet;
let ships = this.player.fleet.ships.length; }
return `Level ${level} - ${ships} ships`;
}
// Load a game state from a string /**
static loadFromString(serialized: string): GameSession { * Get an indicative description of the session (to help identify game saves)
var serializer = new Serializer(TK.SpaceTac); */
return <GameSession>serializer.unserialize(serialized); 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 // Load a game state from a string
saveToString(): string { static loadFromString(serialized: string): GameSession {
var serializer = new Serializer(TK.SpaceTac); var serializer = new Serializer(NAMESPACE);
return serializer.serialize(this); return <GameSession>serializer.unserialize(serialized);
} }
/** // Serializes the game state to a string
* Generate a real single player game (campaign) saveToString(): string {
* var serializer = new Serializer(NAMESPACE);
* If *fleet* is false, the player fleet will be empty, and needs to be set with *setCampaignFleet*. return serializer.serialize(this);
*/ }
startNewGame(fleet = true, story = false): void {
this.universe = new Universe();
this.universe.generate();
this.start_location = this.universe.getStartLocation(); /**
this.start_location.clearEncounter(); * Generate a real single player game (campaign)
this.start_location.removeShop(); *
* 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.reactions = new PersonalityReactions();
this.setCampaignFleet(null, story);
}
}
/** if (fleet) {
* Set the initial campaign fleet, null for a default fleet this.setCampaignFleet(null, story);
*
* 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;
}
} }
}
/**
* 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 { import { iarray, imaterialize } from "../common/Iterators";
testing("MoveFireSimulator", test => { 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] { testing("MoveFireSimulator", test => {
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("finds a suitable engine to make an approach", check => { function simpleWeaponCase(distance = 10, ship_ap = 5, weapon_ap = 3, engine_distance = 5): [Ship, MoveFireSimulator, BaseAction] {
let ship = new Ship(); let ship = new Ship();
let simulator = new MoveFireSimulator(ship); TestTools.setShipModel(ship, 100, 0, ship_ap);
check.equals(simulator.findEngine(), null, "no engine"); TestTools.addEngine(ship, engine_distance);
let engine1 = TestTools.addEngine(ship, 100); let action = new TriggerAction("weapon", { power: weapon_ap, range: distance });
engine1.configureCooldown(1, 1); let simulator = new MoveFireSimulator(ship);
check.same(simulator.findEngine(), engine1, "one engine"); return [ship, simulator, action];
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");
});
test.case("fires directly when in range", check => { test.case("finds a suitable engine to make an approach", check => {
let [ship, simulator, action] = simpleWeaponCase(); let ship = new Ship();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null)); 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'); test.case("fires directly when in range", check => {
check.same(result.need_move, false, 'need_move'); let [ship, simulator, action] = simpleWeaponCase();
check.same(result.need_fire, true, 'need_fire'); let result = simulator.simulateAction(action, new Target(ship.arena_x + 5, ship.arena_y, null));
check.same(result.can_fire, true, 'can_fire');
check.same(result.total_fire_ap, 3, 'total_fire_ap');
check.equals(result.parts, [ check.same(result.success, true, 'success');
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: true } 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 => { check.equals(result.parts, [
let [ship, simulator, action] = simpleWeaponCase(10, 2, 3); { action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: true }
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, [ test.case("can't fire when in range, but not enough AP", check => {
{ action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: false } 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 => { check.equals(result.parts, [
let [ship, simulator, action] = simpleWeaponCase(); { action: action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 3, possible: false }
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');
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0]; test.case("moves straight to get within range", check => {
check.equals(result.parts, [ let [ship, simulator, action] = simpleWeaponCase();
{ action: move_action, target: new Target(ship.arena_x + 5, ship.arena_y, null), ap: 1, possible: true }, let result = simulator.simulateAction(action, new Target(ship.arena_x + 15, ship.arena_y, null));
{ action: action, target: new Target(ship.arena_x + 15, ship.arena_y, null), ap: 3, possible: true } 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 move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
let simulator = new MoveFireSimulator(new Ship()); 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); test.case("scans a circle for move targets", check => {
check.equals(imaterialize(result), [ let simulator = new MoveFireSimulator(new Ship());
new Target(50, 30)
]);
result = simulator.scanCircle(50, 30, 10, 2, 1); let result = simulator.scanCircle(50, 30, 10, 1, 1);
check.equals(imaterialize(result), [ check.equals(imaterialize(result), [
new Target(50, 30), new Target(50, 30)
new Target(60, 30) ]);
]);
result = simulator.scanCircle(50, 30, 10, 2, 2); result = simulator.scanCircle(50, 30, 10, 2, 1);
check.equals(imaterialize(result), [ check.equals(imaterialize(result), [
new Target(50, 30), new Target(50, 30),
new Target(60, 30), new Target(60, 30)
new Target(40, 30) ]);
]);
result = simulator.scanCircle(50, 30, 10, 3, 4); result = simulator.scanCircle(50, 30, 10, 2, 2);
check.equals(imaterialize(result), [ check.equals(imaterialize(result), [
new Target(50, 30), new Target(50, 30),
new Target(55, 30), new Target(60, 30),
new Target(45, 30), new Target(40, 30)
new Target(60, 30), ]);
new Target(50, 40),
new Target(40, 30),
new Target(50, 20)
]);
});
test.case("accounts for exclusion areas for the approach", check => { result = simulator.scanCircle(50, 30, 10, 3, 4);
let [ship, simulator, action] = simpleWeaponCase(100, 5, 1, 50); check.equals(imaterialize(result), [
ship.setArenaPosition(300, 200); new Target(50, 30),
let battle = new Battle(); new Target(55, 30),
battle.fleets[0].addShip(ship); new Target(45, 30),
let ship1 = battle.fleets[0].addShip(); new Target(60, 30),
let moveaction = nn(simulator.findEngine()); new Target(50, 40),
(<any>moveaction).safety_distance = 30; new Target(40, 30),
battle.ship_separation = 30; new Target(50, 20)
]);
});
check.same(simulator.getApproach(moveaction, Target.newFromLocation(350, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED); test.case("accounts for exclusion areas for the approach", check => {
check.same(simulator.getApproach(moveaction, Target.newFromLocation(400, 200), 100), ApproachSimulationError.NO_MOVE_NEEDED); let [ship, simulator, action] = simpleWeaponCase(100, 5, 1, 50);
check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(400, 200)); 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([ ship1.setArenaPosition(420, 200);
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));
});
test.case("moves to get in range, even if not enough AP to fire", check => { check.patch(simulator, "scanCircle", () => iarray([
let [ship, simulator, action] = simpleWeaponCase(8, 3, 2, 5); new Target(400, 200),
let result = simulator.simulateAction(action, new Target(ship.arena_x + 18, ship.arena_y, null)); new Target(410, 200),
check.same(result.success, true, 'success'); new Target(410, 230),
check.same(result.need_move, true, 'need_move'); new Target(420, 210),
check.same(result.can_end_move, true, 'can_end_move'); new Target(480, 260),
check.equals(result.move_location, new Target(ship.arena_x + 10, ship.arena_y, null)); ]));
check.equals(result.total_move_ap, 2); check.equals(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100), new Target(410, 230));
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');
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0]; test.case("moves to get in range, even if not enough AP to fire", check => {
check.equals(result.parts, [ let [ship, simulator, action] = simpleWeaponCase(8, 3, 2, 5);
{ action: move_action, target: new Target(ship.arena_x + 10, ship.arena_y, null), ap: 2, possible: true }, let result = simulator.simulateAction(action, new Target(ship.arena_x + 18, ship.arena_y, null));
{ action: action, target: new Target(ship.arena_x + 18, ship.arena_y, null), ap: 2, possible: false } 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 move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
let [ship, simulator, action] = simpleWeaponCase(); check.equals(result.parts, [
let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0]; { action: move_action, target: new Target(ship.arena_x + 10, ship.arena_y, null), ap: 2, possible: true },
let result = simulator.simulateAction(move_action, new Target(ship.arena_x, ship.arena_y, null)); { action: action, target: new Target(ship.arena_x + 18, ship.arena_y, null), ap: 2, possible: false }
check.equals(result.success, false); ]);
check.equals(result.need_move, false); });
check.equals(result.need_fire, false);
check.equals(result.parts, []);
});
test.case("does not move if already in range, even if in the safety margin", check => { test.case("does nothing if trying to move in the same spot", check => {
let [ship, simulator, action] = simpleWeaponCase(100); let [ship, simulator, action] = simpleWeaponCase();
let result = simulator.simulateAction(action, new Target(ship.arena_x + 97, ship.arena_y, null), 5); let move_action = ship.actions.listAll().filter(action => action instanceof MoveAction)[0];
check.equals(result.success, true); let result = simulator.simulateAction(move_action, new Target(ship.arena_x, ship.arena_y, null));
check.equals(result.need_move, false); check.equals(result.success, false);
result = simulator.simulateAction(action, new Target(ship.arena_x + 101, ship.arena_y, null), 5); check.equals(result.need_move, false);
check.equals(result.success, true); check.equals(result.need_fire, false);
check.equals(result.need_move, true); check.equals(result.parts, []);
check.equals(result.move_location, new Target(ship.arena_x + 6, ship.arena_y)); });
});
test.case("simulates the results on a fake battle, to provide a list of expected diffs", check => { test.case("does not move if already in range, even if in the safety margin", check => {
let battle = TestTools.createBattle(); let [ship, simulator, action] = simpleWeaponCase(100);
let ship = battle.fleets[0].ships[0]; let result = simulator.simulateAction(action, new Target(ship.arena_x + 97, ship.arena_y, null), 5);
let enemy = battle.fleets[1].ships[0]; check.equals(result.success, true);
ship.setArenaPosition(100, 100); check.equals(result.need_move, false);
enemy.setArenaPosition(300, 100); result = simulator.simulateAction(action, new Target(ship.arena_x + 101, ship.arena_y, null), 5);
TestTools.setShipModel(ship, 1, 1, 3); check.equals(result.success, true);
TestTools.setShipModel(enemy, 2, 1); check.equals(result.need_move, true);
let engine = TestTools.addEngine(ship, 80); check.equals(result.move_location, new Target(ship.arena_x + 6, ship.arena_y));
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); test.case("simulates the results on a fake battle, to provide a list of expected diffs", check => {
check.equals(enemy.getValue("hull"), 2); 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 { import { NAMESPACE } from ".."
/** import { ichainit, iforeach, imap, irepeat, istep } from "../common/Iterators"
* Error codes for approach simulation import { cfilter, duplicate, first, minBy, nn } from "../common/Tools"
*/ import { BaseAction } from "./actions/BaseAction"
export enum ApproachSimulationError { import { MoveAction } from "./actions/MoveAction"
NO_MOVE_NEEDED, import { arenaDistance } from "./ArenaLocation"
NO_VECTOR_FOUND, 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 * Error codes for approach simulation
*/ */
export type MoveFirePart = { export enum ApproachSimulationError {
action: BaseAction NO_MOVE_NEEDED,
target: Target NO_VECTOR_FOUND,
ap: number }
possible: boolean
} /**
* A single action in the sequence result from the simulator
/** */
* A simulation result export type MoveFirePart = {
*/ action: BaseAction
export class MoveFireResult { target: Target
// Simulation success, false only if no route can be found ap: number
success = false possible: boolean
// Ideal successive parts to make the full move+fire }
parts: MoveFirePart[] = []
// Simulation complete (both move and fire are possible) /**
complete = false * A simulation result
*/
need_move = false export class MoveFireResult {
can_move = false // Simulation success, false only if no route can be found
can_end_move = false success = false
total_move_ap = 0 // Ideal successive parts to make the full move+fire
move_location = new Target(0, 0, null) parts: MoveFirePart[] = []
// Simulation complete (both move and fire are possible)
need_fire = false complete = false
can_fire = false
total_fire_ap = 0 need_move = false
fire_location = new Target(0, 0, null) can_move = false
}; can_end_move = false
total_move_ap = 0
/** move_location = new Target(0, 0, null)
* Utility to simulate a move+fire action.
* need_fire = false
* This is also a helper to bring a ship in range to fire a weapon. can_fire = false
*/ total_fire_ap = 0
export class MoveFireSimulator { fire_location = new Target(0, 0, null)
ship: Ship; };
constructor(ship: Ship) { /**
this.ship = ship; * Utility to simulate a move+fire action.
} *
* This is also a helper to bring a ship in range to fire a weapon.
/** */
* Find an engine action, to make an approach export class MoveFireSimulator {
* ship: Ship;
* This will return the first available engine, in the definition order
*/ constructor(ship: Ship) {
findEngine(): MoveAction | null { this.ship = ship;
let actions = cfilter(this.ship.actions.listAll(), MoveAction); }
return first(actions, action => this.ship.actions.getCooldown(action).canUse());
} /**
* Find an engine action, to make an approach
/** *
* Check that a move action can reach a given destination * This will return the first available engine, in the definition order
*/ */
canMoveTo(action: MoveAction, target: Target): boolean { findEngine(): MoveAction | null {
let checked = action.checkLocationTarget(this.ship, target); let actions = cfilter(this.ship.actions.listAll(), MoveAction);
return checked != null && checked.x == target.x && checked.y == target.y; return first(actions, action => this.ship.actions.getCooldown(action).canUse());
} }
/** /**
* Get an iterator for scanning a circle * Check that a move action can reach a given destination
*/ */
scanCircle(x: number, y: number, radius: number, nr = 6, na = 30): Iterable<Target> { canMoveTo(action: MoveAction, target: Target): boolean {
let rcount = nr ? 1 / (nr - 1) : 0; let checked = action.checkLocationTarget(this.ship, target);
return ichainit(imap(istep(0, irepeat(rcount, nr - 1)), r => { return checked != null && checked.x == target.x && checked.y == target.y;
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)) /**
}); * 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 => {
* Find the best approach location, to put a target in a given range. let angles = Math.max(1, Math.ceil(na * r));
* return imap(istep(0, irepeat(2 * Math.PI / angles, angles - 1)), a => {
* Return null if no approach vector was found. return new Target(x + r * radius * Math.cos(a), y + r * radius * Math.sin(a))
*/ });
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); /**
* Find the best approach location, to put a target in a given range.
if (distance <= radius) { *
return ApproachSimulationError.NO_MOVE_NEEDED; * Return null if no approach vector was found.
} else { */
if (margin && radius > margin) { getApproach(action: MoveAction, target: Target, radius: number, margin = 0): Target | ApproachSimulationError {
radius -= margin; let dx = target.x - this.ship.arena_x;
} let dy = target.y - this.ship.arena_y;
let factor = (distance - radius) / distance; let distance = Math.sqrt(dx * dx + dy * dy);
let candidate = new Target(this.ship.arena_x + dx * factor, this.ship.arena_y + dy * factor);
if (this.canMoveTo(action, candidate)) { if (distance <= radius) {
return candidate; return ApproachSimulationError.NO_MOVE_NEEDED;
} else { } else {
let candidates: [number, Target][] = []; if (margin && radius > margin) {
iforeach(this.scanCircle(target.x, target.y, radius), candidate => { radius -= margin;
if (this.canMoveTo(action, candidate)) { }
candidates.push([candidate.getDistanceTo(this.ship.location), candidate]); 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;
if (candidates.length) { } else {
return minBy(candidates, ([distance, candidate]) => distance)[1]; let candidates: [number, Target][] = [];
} else { iforeach(this.scanCircle(target.x, target.y, radius), candidate => {
return ApproachSimulationError.NO_VECTOR_FOUND; if (this.canMoveTo(action, candidate)) {
} candidates.push([candidate.getDistanceTo(this.ship.location), candidate]);
} }
} });
}
if (candidates.length) {
/** return minBy(candidates, ([distance, candidate]) => distance)[1];
* Simulate a given action on a given valid target. } else {
*/ return ApproachSimulationError.NO_VECTOR_FOUND;
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; * Simulate a given action on a given valid target.
result.move_location = Target.newFromShip(this.ship); */
if (action instanceof MoveAction) { simulateAction(action: BaseAction, target: Target, move_margin = 0): MoveFireResult {
let corrected_target = action.applyReachableRange(this.ship, target, move_margin); let result = new MoveFireResult();
corrected_target = action.applyExclusion(this.ship, corrected_target); let ap = this.ship.getValue("power");
if (corrected_target) {
result.need_move = target.getDistanceTo(this.ship.location) > 0; // Move or approach needed ?
move_target = corrected_target; let move_target: Target | null = null;
} let move_action: MoveAction | null = null;
move_action = action; result.move_location = Target.newFromShip(this.ship);
} else { if (action instanceof MoveAction) {
move_action = this.findEngine(); let corrected_target = action.applyReachableRange(this.ship, target, move_margin);
if (move_action) { corrected_target = action.applyExclusion(this.ship, corrected_target);
let approach_radius = action.getRangeRadius(this.ship); if (corrected_target) {
let approach = this.getApproach(move_action, target, approach_radius, move_margin); result.need_move = target.getDistanceTo(this.ship.location) > 0;
if (approach instanceof Target) { move_target = corrected_target;
result.need_move = true; }
move_target = approach; move_action = action;
} else if (approach != ApproachSimulationError.NO_MOVE_NEEDED) { } else {
result.need_move = true; move_action = this.findEngine();
result.can_move = false; if (move_action) {
result.success = false; let approach_radius = action.getRangeRadius(this.ship);
return result; let approach = this.getApproach(move_action, target, approach_radius, move_margin);
} if (approach instanceof Target) {
} result.need_move = true;
} move_target = approach;
if (move_target && arenaDistance(move_target, this.ship.location) < 0.000001) { } else if (approach != ApproachSimulationError.NO_MOVE_NEEDED) {
result.need_move = false; result.need_move = true;
} result.can_move = false;
result.success = false;
// Check move AP return result;
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; if (move_target && arenaDistance(move_target, this.ship.location) < 0.000001) {
result.move_location = move_target; result.need_move = false;
// 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 });
// Check move AP
ap -= result.total_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;
// Check action AP result.can_end_move = result.total_move_ap <= ap;
if (action instanceof MoveAction) { result.move_location = move_target;
result.success = result.need_move && result.can_move; // TODO Split in "this turn" part and "next turn" part if needed
} else { result.parts.push({ action: move_action, target: move_target, ap: result.total_move_ap, possible: result.can_move });
result.need_fire = true;
result.total_fire_ap = action.getPowerUsage(this.ship, target); ap -= result.total_move_ap;
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 }); // Check action AP
result.success = true; if (action instanceof MoveAction) {
} result.success = result.need_move && result.can_move;
} else {
result.complete = (!result.need_move || result.can_end_move) && (!result.need_fire || result.can_fire); result.need_fire = true;
result.total_fire_ap = action.getPowerUsage(this.ship, target);
return result; 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;
* 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 result.complete = (!result.need_move || result.can_end_move) && (!result.need_fire || result.can_fire);
*/
getExpectedDiffs(battle: Battle, simulation: MoveFireResult): BaseBattleDiff[] { return result;
let sim_battle = duplicate(battle, TK.SpaceTac); }
let sim_ship = nn(sim_battle.getShip(this.ship.id));
let results: BaseBattleDiff[] = []; /**
simulation.parts.forEach(part => { * Apply a move-fire simulation result, and predict the diffs it will apply on a battle
let diffs = part.action.getDiffs(sim_ship, battle, part.target); *
results = results.concat(diffs); * The original battle passed as parameter will be duplicated, and not altered
sim_battle.applyDiffs(diffs); */
getExpectedDiffs(battle: Battle, simulation: MoveFireResult): BaseBattleDiff[] {
diffs = sim_battle.performChecks(); let sim_battle = duplicate(battle, NAMESPACE);
results = results.concat(diffs); let sim_ship = nn(sim_battle.getShip(this.ship.id));
}); let results: BaseBattleDiff[] = [];
return results; 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 { import { SkewedRandomGenerator } from "../common/RandomGenerator";
testing("NameGenerator", test => { import { testing } from "../common/Testing";
test.case("generates unique names", check => { import { NameGenerator } from "./NameGenerator";
var random = new SkewedRandomGenerator([0.48, 0.9, 0.1]);
var gen = new NameGenerator(["a", "b", "c"], random);
check.equals(gen.getName(), "b"); testing("NameGenerator", test => {
check.equals(gen.getName(), "c"); test.case("generates unique names", check => {
check.equals(gen.getName(), "a"); var random = new SkewedRandomGenerator([0.48, 0.9, 0.1]);
check.equals(gen.getName(), null); 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 { import { RandomGenerator } from "../common/RandomGenerator";
// A unique name generator import { acopy } from "../common/Tools";
export class NameGenerator {
// List of available choices
private choices: string[];
// Random generator to use // A unique name generator
private random: RandomGenerator; export class NameGenerator {
// List of available choices
private choices: string[];
constructor(choices: string[], random: RandomGenerator = new RandomGenerator()) { // Random generator to use
this.choices = acopy(choices); private random: RandomGenerator;
this.random = random;
}
// Get a new unique name from available choices constructor(choices: string[], random: RandomGenerator = new RandomGenerator()) {
getName(): string | null { this.choices = acopy(choices);
if (this.choices.length === 0) { this.random = random;
return null; }
}
var index = this.random.randInt(0, this.choices.length - 1); // Get a new unique name from available choices
var result = this.choices[index]; getName(): string | null {
this.choices.splice(index, 1); if (this.choices.length === 0) {
return result; 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").
* List of personality traits (may be used with "keyof"). */
*/ export interface IPersonalityTraits {
export interface IPersonalityTraits { aggressive: number
aggressive: number funny: number
funny: number heroic: number
heroic: number optimistic: number
optimistic: number }
}
/**
/** * A personality is a set of traits that defines how a character thinks and behaves
* A personality is a set of traits that defines how a character thinks and behaves *
* * Each trait is a number between -1 and 1
* Each trait is a number between -1 and 1 *
* * In the game, a personality represents an artificial intelligence, and is transferable
* 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
* from one ship (body) to another. This is why a personality has a name */
*/ export class Personality implements IPersonalityTraits {
export class Personality implements IPersonalityTraits { // Name of this personality
// Name of this personality name = ""
name = ""
// Aggressive 1 / Poised -1
// Aggressive 1 / Poised -1 aggressive = 0
aggressive = 0
// Funny 1 / Serious -1
// Funny 1 / Serious -1 funny = 0
funny = 0
// Heroic 1 / Coward -1
// Heroic 1 / Coward -1 heroic = 0
heroic = 0
// Optimistic 1 / Pessimistic -1
// Optimistic 1 / Pessimistic -1 optimistic = 0
optimistic = 0
}
} }

View file

@ -1,65 +1,70 @@
module TK.SpaceTac.Specs { import { testing } from "../common/Testing";
testing("PersonalityReactions", test => { import { Battle } from "./Battle";
function apply(pool: ReactionPool): PersonalityReaction | null { import { ShipDamageDiff } from "./diffs/ShipDamageDiff";
let reactions = new PersonalityReactions(); import { BUILTIN_REACTION_POOL, PersonalityReaction, PersonalityReactionConversation, PersonalityReactions, ReactionPool } from "./PersonalityReactions";
return reactions.check(new Player(), null, null, null, pool); import { Player } from "./Player";
} import { Ship } from "./Ship";
class FakeReaction extends PersonalityReactionConversation { testing("PersonalityReactions", test => {
ships: Ship[] function apply(pool: ReactionPool): PersonalityReaction | null {
constructor(ships: Ship[]) { let reactions = new PersonalityReactions();
super([]); return reactions.check(new Player(), null, null, null, pool);
this.ships = ships; }
}
static cons(ships: Ship[]): FakeReaction {
return new FakeReaction(ships);
}
}
test.case("fetches ships from conditions", check => { class FakeReaction extends PersonalityReactionConversation {
let reaction = apply({}); ships: Ship[]
check.equals(reaction, null); constructor(ships: Ship[]) {
super([]);
this.ships = ships;
}
static cons(ships: Ship[]): FakeReaction {
return new FakeReaction(ships);
}
}
let s1 = new Ship(null, "S1"); test.case("fetches ships from conditions", check => {
let s2 = new Ship(null, "S2"); let reaction = apply({});
check.equals(reaction, null);
reaction = apply({ let s1 = new Ship(null, "S1");
a: [() => [s1, s2], 1, [[() => 1, FakeReaction.cons]]], let s2 = new Ship(null, "S2");
});
check.equals(reaction, new FakeReaction([s1, s2]));
})
test.case("applies weight on conditions", check => { reaction = apply({
let s1 = new Ship(null, "S1"); a: [() => [s1, s2], 1, [[() => 1, FakeReaction.cons]]],
let s2 = new Ship(null, "S2"); });
check.equals(reaction, new FakeReaction([s1, s2]));
})
let reaction = apply({ test.case("applies weight on conditions", check => {
a: [() => [s1], 1, [[() => 1, FakeReaction.cons]]], let s1 = new Ship(null, "S1");
b: [() => [s2], 0, [[() => 1, FakeReaction.cons]]], let s2 = new Ship(null, "S2");
});
check.equals(reaction, new FakeReaction([s1]));
reaction = apply({ let reaction = apply({
a: [() => [s1], 0, [[() => 1, FakeReaction.cons]]], a: [() => [s1], 1, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 1, [[() => 1, FakeReaction.cons]]], b: [() => [s2], 0, [[() => 1, FakeReaction.cons]]],
}); });
check.equals(reaction, new FakeReaction([s2])); check.equals(reaction, new FakeReaction([s1]));
})
test.case("checks for friendly fire", check => { reaction = apply({
let condition = BUILTIN_REACTION_POOL['friendly_fire'][0]; a: [() => [s1], 0, [[() => 1, FakeReaction.cons]]],
let battle = new Battle(); b: [() => [s2], 1, [[() => 1, FakeReaction.cons]]],
let player = new Player(); });
battle.fleets[0].setPlayer(player); check.equals(reaction, new FakeReaction([s2]));
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"); test.case("checks for friendly fire", check => {
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship1b, 50, 10)), [ship1b, ship1a]); let condition = BUILTIN_REACTION_POOL['friendly_fire'][0];
check.equals(condition(player, battle, ship1a, new ShipDamageDiff(ship2a, 50, 10)), [], "enemy shoot"); let battle = new Battle();
check.equals(condition(player, battle, ship2a, new ShipDamageDiff(ship2a, 50, 10)), [], "other player event"); 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 { import { RandomGenerator } from "../common/RandomGenerator"
// Reaction triggered import { difference, keys, nna } from "../common/Tools"
export type PersonalityReaction = PersonalityReactionConversation 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) // Reaction triggered
export type ReactionCondition = (player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null) => Ship[] export type PersonalityReaction = PersonalityReactionConversation
// Reaction profile, giving a probability for types of personality, and an associated reaction constructor // Condition to check if a reaction may happen, returning involved ships (order is important)
export type ReactionProfile = [(traits: IPersonalityTraits) => number, (ships: Ship[]) => PersonalityReaction] export type ReactionCondition = (player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleDiff | null) => Ship[]
// Reaction config (condition, chance, profiles) // Reaction profile, giving a probability for types of personality, and an associated reaction constructor
export type ReactionConfig = [ReactionCondition, number, ReactionProfile[]] export type ReactionProfile = [(traits: IPersonalityTraits) => number, (ships: Ship[]) => PersonalityReaction]
// Pool of reaction config // Reaction config (condition, chance, profiles)
export type ReactionPool = { [code: string]: ReactionConfig } export type ReactionConfig = [ReactionCondition, number, ReactionProfile[]]
/** // Pool of reaction config
* Reactions to external events according to personalities. export type ReactionPool = { [code: string]: ReactionConfig }
*
* 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
/** /**
* Check for a reaction. * Reactions to external events according to personalities.
* *
* This will return a reaction to display, and add it to the done list * This allows for a more "alive" world, as characters tend to speak to react to events.
*/ *
check(player: Player, battle: Battle | null = null, ship: Ship | null = null, event: BaseBattleDiff | null = null, pool: ReactionPool = BUILTIN_REACTION_POOL): PersonalityReaction | null { * This object will store the previous reactions to avoid too much recurrence, and should be global to a whole
let codes = difference(keys(pool), this.done); * 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]; * Check for a reaction.
if (this.random.random() <= chance) { *
let involved = condition(player, battle, ship, event); * This will return a reaction to display, and add it to the done list
if (involved.length > 0) { */
return [code, involved, profiles]; check(player: Player, battle: Battle | null = null, ship: Ship | null = null, event: BaseBattleDiff | null = null, pool: ReactionPool = BUILTIN_REACTION_POOL): PersonalityReaction | null {
} else { let codes = difference(keys(pool), this.done);
return null;
}
} else {
return null;
}
}));
if (candidates.length > 0) { let candidates = nna(codes.map((code: string): [string, Ship[], ReactionProfile[]] | null => {
let [code, involved, profiles] = this.random.choice(candidates); let [condition, chance, profiles] = pool[code];
let primary = involved[0]; if (this.random.random() <= chance) {
let weights = profiles.map(([evaluator, _]) => evaluator(primary.personality)); let involved = condition(player, battle, ship, event);
let action_number = this.random.weighted(weights); if (involved.length > 0) {
if (action_number >= 0) { return [code, involved, profiles];
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 { } 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 { import { testing } from "../common/Testing";
testing("Player", test => { import { Player } from "./Player";
test.case("keeps track of visited locations", check => { import { StarLocationType } from "./StarLocation";
let player = new Player(); import { Universe } from "./Universe";
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();
function checkVisited(s1 = false, s2 = false, v1a = false, v1b = false, v2a = false, v2b = false) { testing("Player", test => {
check.same(player.hasVisitedSystem(star1), s1); test.case("keeps track of visited locations", check => {
check.same(player.hasVisitedSystem(star2), s2); let player = new Player();
check.same(player.hasVisitedLocation(loc1a), v1a); let universe = new Universe();
check.same(player.hasVisitedLocation(loc1b), v1b); let star1 = universe.addStar();
check.same(player.hasVisitedLocation(loc2a), v2a); let star2 = universe.addStar();
check.same(player.hasVisitedLocation(loc2b), v2b); 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();
checkVisited(true, false, false, true, false, false);
player.fleet.setLocation(loc1a); player.fleet.setLocation(loc1b);
checkVisited(true, false, true, true, false, false); checkVisited(true, false, false, true, false, false);
player.fleet.setLocation(loc2a); player.fleet.setLocation(loc1a);
checkVisited(true, true, true, true, true, false); checkVisited(true, false, true, true, false, false);
player.fleet.setLocation(loc2a); player.fleet.setLocation(loc2a);
checkVisited(true, true, true, true, true, false); 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)
* One player (human or IA) */
*/ export class Player extends RObject {
export class Player extends RObject { // Player's name
// Player's name name: string
name: string
// Bound fleet // Bound fleet
fleet: Fleet fleet: Fleet
// Active missions // Active missions
missions = new ActiveMissions() missions = new ActiveMissions()
// Create a player, with an empty fleet // Create a player, with an empty fleet
constructor(name = "Player", fleet?: Fleet) { constructor(name = "Player", fleet?: Fleet) {
super(); super();
this.name = name; this.name = name;
this.fleet = fleet || new Fleet(this); 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 // Create a quick random player, with a fleet, for testing purposes
static newQuickRandom(name: string, level = 1, shipcount = 4, upgrade = false): Player { static newQuickRandom(name: string, level = 1, shipcount = 4, upgrade = false): Player {
let player = new Player(name); let player = new Player(name);
let generator = new FleetGenerator(); let generator = new FleetGenerator();
player.fleet = generator.generate(level, player, shipcount, upgrade); player.fleet = generator.generate(level, player, shipcount, upgrade);
return player; return player;
} }
/** /**
* Set the fleet for this player * Set the fleet for this player
*/ */
setFleet(fleet: Fleet): void { setFleet(fleet: Fleet): void {
this.fleet = fleet; this.fleet = fleet;
fleet.setPlayer(this); fleet.setPlayer(this);
} }
/** /**
* Get a cheats object * Get a cheats object
*/ */
getCheats(): BattleCheats | null { getCheats(): BattleCheats | null {
let battle = this.getBattle(); let battle = this.getBattle();
if (battle) { if (battle) {
return new BattleCheats(battle, this); return new BattleCheats(battle, this);
} else { } else {
return null; 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();
}
} }
}
/**
* 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 { import { testing } from "../common/Testing";
testing("Range", test => { import { IntegerRange } from "./Range";
test.case("can work with proportional values", check => {
var range = new Range(1, 5);
function checkProportional(range: Range, value1: number, value2: number) { testing("Range", test => {
check.equals(range.getProportional(value1), value2); test.case("can work with proportional values", check => {
check.equals(range.getReverseProportional(value2), value1); var range = new Range(1, 5);
}
checkProportional(range, 0, 1); function checkProportional(range: Range, value1: number, value2: number) {
checkProportional(range, 1, 5); check.equals(range.getProportional(value1), value2);
checkProportional(range, 0.5, 3); check.equals(range.getReverseProportional(value2), value1);
checkProportional(range, 0.4, 2.6); }
check.equals(range.getProportional(-0.25), 1); checkProportional(range, 0, 1);
check.equals(range.getProportional(1.8), 5); checkProportional(range, 1, 5);
checkProportional(range, 0.5, 3);
checkProportional(range, 0.4, 2.6);
check.equals(range.getReverseProportional(0), 0); check.equals(range.getProportional(-0.25), 1);
check.equals(range.getReverseProportional(6), 1); check.equals(range.getProportional(1.8), 5);
});
});
testing("IntegerRange", test => { check.equals(range.getReverseProportional(0), 0);
test.case("can work with proportional values", check => { check.equals(range.getReverseProportional(6), 1);
var range = new IntegerRange(1, 5); });
});
check.equals(range.getProportional(0), 1); testing("IntegerRange", test => {
check.equals(range.getProportional(0.1), 1); test.case("can work with proportional values", check => {
check.equals(range.getProportional(0.2), 2); var range = new IntegerRange(1, 5);
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.getProportional(0), 1);
check.equals(range.getReverseProportional(2), 0.2); check.equals(range.getProportional(0.1), 1);
check.equals(range.getReverseProportional(3), 0.4); check.equals(range.getProportional(0.2), 2);
check.equals(range.getReverseProportional(4), 0.6); check.equals(range.getProportional(0.45), 3);
check.equals(range.getReverseProportional(5), 0.8); 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
// Range of number values export class Range {
export class Range { // Minimal value
// Minimal value min = 0
min = 0
// Maximal value // Maximal value
max = 0 max = 0
// Create a range of values // Create a range of values
constructor(min: number, max: number | null = null) { constructor(min: number, max: number | null = null) {
this.set(min, max); this.set(min, max);
} }
// Change the range // Change the range
set(min: number, max: number | null = null) { set(min: number, max: number | null = null) {
this.min = min; this.min = min;
if (max === null) { if (max === null) {
this.max = this.min; this.max = this.min;
} else { } else {
this.max = max; 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;
}
} }
}
// Get a proportional value (give 0.0-1.0 value to obtain a value in range)
// Range of integer values getProportional(cursor: number): number {
// if (cursor <= 0.0) {
// This differs from Range in that it adds space in proportional values to include the 'max'. return this.min;
// Typically, using Range for integers will only yield 'max' for exactly 1.0 proportional, not for 0.999999. } else if (cursor >= 1.0) {
// This fixes this behavior. return this.max;
// } else {
// As this rounds values to integer, the 'reverse' proportional is no longer a bijection. return (this.max - this.min) * cursor + this.min;
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 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 { import { testing } from "../common/Testing";
testing("Ship", test => { import { nn } from "../common/Tools";
test.case("creates a full name", check => { import { BaseAction } from "./actions/BaseAction";
let ship = new Ship(); import { ToggleAction } from "./actions/ToggleAction";
check.equals(ship.getName(false), "Ship"); import { Battle } from "./Battle";
check.equals(ship.getName(true), "Level 1 Ship"); 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"); testing("Ship", test => {
check.equals(ship.getName(false), "Hauler"); test.case("creates a full name", check => {
check.equals(ship.getName(true), "Level 1 Hauler"); let ship = new Ship();
check.equals(ship.getName(false), "Ship");
check.equals(ship.getName(true), "Level 1 Ship");
ship.name = "Titan-W12"; ship.model = new ShipModel("test", "Hauler");
check.equals(ship.getName(false), "Titan-W12"); check.equals(ship.getName(false), "Hauler");
check.equals(ship.getName(true), "Level 1 Titan-W12"); check.equals(ship.getName(true), "Level 1 Hauler");
ship.level.forceLevel(3); ship.name = "Titan-W12";
check.equals(ship.getName(false), "Titan-W12"); check.equals(ship.getName(false), "Titan-W12");
check.equals(ship.getName(true), "Level 3 Titan-W12"); check.equals(ship.getName(true), "Level 1 Titan-W12");
});
test.case("moves in the arena", check => { ship.level.forceLevel(3);
let ship = new Ship(null, "Test"); check.equals(ship.getName(false), "Titan-W12");
let engine = TestTools.addEngine(ship, 50); check.equals(ship.getName(true), "Level 3 Titan-W12");
});
check.equals(ship.arena_x, 0); test.case("moves in the arena", check => {
check.equals(ship.arena_y, 0); let ship = new Ship(null, "Test");
check.equals(ship.arena_angle, 0); let engine = TestTools.addEngine(ship, 50);
ship.setArenaFacingAngle(1.2); check.equals(ship.arena_x, 0);
ship.setArenaPosition(12, 50); check.equals(ship.arena_y, 0);
check.equals(ship.arena_angle, 0);
check.equals(ship.arena_x, 12); ship.setArenaFacingAngle(1.2);
check.equals(ship.arena_y, 50); ship.setArenaPosition(12, 50);
check.nears(ship.arena_angle, 1.2);
});
test.case("applies permanent effects of ship model on attributes", check => { check.equals(ship.arena_x, 12);
let model = new ShipModel(); check.equals(ship.arena_y, 50);
let ship = new Ship(null, null, model); check.nears(ship.arena_angle, 1.2);
});
check.patch(model, "getEffects", () => [ test.case("applies permanent effects of ship model on attributes", check => {
new AttributeEffect("power_capacity", 4), let model = new ShipModel();
new AttributeEffect("power_capacity", 5), let ship = new Ship(null, null, model);
]);
ship.updateAttributes(); check.patch(model, "getEffects", () => [
check.equals(ship.getAttribute("power_capacity"), 9); new AttributeEffect("power_capacity", 4),
}); new AttributeEffect("power_capacity", 5),
]);
test.case("repairs hull and recharges shield", check => { ship.updateAttributes();
var ship = new Ship(null, "Test"); check.equals(ship.getAttribute("power_capacity"), 9);
});
TestTools.setAttribute(ship, "hull_capacity", 120); test.case("repairs hull and recharges shield", check => {
TestTools.setAttribute(ship, "shield_capacity", 150); var ship = new Ship(null, "Test");
check.equals(ship.getValue("hull"), 0); TestTools.setAttribute(ship, "hull_capacity", 120);
check.equals(ship.getValue("shield"), 0); 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); ship.restoreHealth();
check.equals(ship.getValue("shield"), 150);
});
test.case("checks if a ship is able to play", check => { check.equals(ship.getValue("hull"), 120);
let battle = new Battle(); check.equals(ship.getValue("shield"), 150);
let ship = battle.fleets[0].addShip(); });
ship.setValue("hull", 10);
check.equals(ship.isAbleToPlay(), false); test.case("checks if a ship is able to play", check => {
check.equals(ship.isAbleToPlay(false), true); 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); ship.setValue("power", 5);
check.equals(ship.isAbleToPlay(false), true);
ship.setDead(); check.equals(ship.isAbleToPlay(), true);
check.equals(ship.isAbleToPlay(false), true);
check.equals(ship.isAbleToPlay(), false); ship.setDead();
check.equals(ship.isAbleToPlay(false), false);
});
test.case("checks if a ship is inside a given circle", check => { check.equals(ship.isAbleToPlay(), false);
let ship = new Ship(); check.equals(ship.isAbleToPlay(false), false);
ship.arena_x = 5; });
ship.arena_y = 8;
check.equals(ship.isInCircle(5, 8, 0), true); test.case("checks if a ship is inside a given circle", check => {
check.equals(ship.isInCircle(5, 8, 1), true); let ship = new Ship();
check.equals(ship.isInCircle(5, 7, 1), true); ship.arena_x = 5;
check.equals(ship.isInCircle(6, 9, 1.7), true); ship.arena_y = 8;
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("restores as new at the end of battle", check => { check.equals(ship.isInCircle(5, 8, 0), true);
let ship = new Ship(); check.equals(ship.isInCircle(5, 8, 1), true);
TestTools.setShipModel(ship, 10, 20, 5); check.equals(ship.isInCircle(5, 7, 1), true);
ship.setValue("hull", 5); check.equals(ship.isInCircle(6, 9, 1.7), true);
ship.setValue("shield", 15); check.equals(ship.isInCircle(5, 8.1, 0), false);
ship.setValue("power", 2); check.equals(ship.isInCircle(5, 7, 0.9), false);
ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("power_capacity", 3), 12)); check.equals(ship.isInCircle(12, -4, 5), false);
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.in("before", check => { test.case("restores as new at the end of battle", check => {
check.equals(ship.getValue("hull"), 5, "hull"); let ship = new Ship();
check.equals(ship.getValue("shield"), 15, "shield"); TestTools.setShipModel(ship, 10, 20, 5);
check.equals(ship.getValue("power"), 2, "power"); ship.setValue("hull", 5);
check.equals(ship.active_effects.count(), 1, "effects count"); ship.setValue("shield", 15);
check.equals(ship.getAttribute("power_capacity"), 3, "power capacity"); ship.setValue("power", 2);
check.equals(ship.actions.isToggled(action2), true, "action 2 activation"); ship.active_effects.add(new StickyEffect(new AttributeLimitEffect("power_capacity", 3), 12));
check.equals(ship.actions.isToggled(action3), false, "action 3 activation"); 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("before", check => {
check.equals(ship.getValue("hull"), 5, "hull");
check.in("after", check => { check.equals(ship.getValue("shield"), 15, "shield");
check.equals(ship.getValue("hull"), 10, "hull"); check.equals(ship.getValue("power"), 2, "power");
check.equals(ship.getValue("shield"), 20, "shield"); check.equals(ship.active_effects.count(), 1, "effects count");
check.equals(ship.getValue("power"), 5, "power"); check.equals(ship.getAttribute("power_capacity"), 3, "power capacity");
check.equals(ship.active_effects.count(), 0, "effects count"); check.equals(ship.actions.isToggled(action2), true, "action 2 activation");
check.equals(ship.getAttribute("power_capacity"), 5, "power capacity"); check.equals(ship.actions.isToggled(action3), false, "action 3 activation");
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),
]);
});
}); });
}
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" /> import { RandomGenerator } from "../common/RandomGenerator"
import { RObject, RObjectContainer } from "../common/RObject"
module TK.SpaceTac { import { bool, cfilter, flatten, keys, sum } from "../common/Tools"
/** import { ActionList } from "./actions/ActionList"
* A single ship in a fleet import { BaseAction } from "./actions/BaseAction"
*/ import { ToggleAction } from "./actions/ToggleAction"
export class Ship extends RObject { import { ArenaLocationAngle } from "./ArenaLocation"
// Ship model import { Battle } from "./Battle"
model: ShipModel import { BaseBattleDiff } from "./diffs/BaseBattleDiff"
import { ShipDeathDiff } from "./diffs/ShipDeathDiff"
// Fleet this ship is a member of import { ShipEffectRemovedDiff } from "./diffs/ShipEffectAddedDiff"
fleet: Fleet import { ShipValueDiff } from "./diffs/ShipValueDiff"
import { AttributeEffect } from "./effects/AttributeEffect"
// Level of this ship import { AttributeLimitEffect } from "./effects/AttributeLimitEffect"
level = new ShipLevel() import { AttributeMultiplyEffect } from "./effects/AttributeMultiplyEffect"
import { BaseEffect } from "./effects/BaseEffect"
// Name of the ship, null if unimportant import { StickyEffect } from "./effects/StickyEffect"
name: string | null import { Fleet } from "./Fleet"
import { ShipModel, ShipUpgrade } from "./models/ShipModel"
// Flag indicating if the ship is alive import { Personality } from "./Personality"
alive: boolean import { Player } from "./Player"
import { ShipLevel } from "./ShipLevel"
// Flag indicating that the ship is mission critical (escorted ship) import { ShipAttributes, ShipValues, SHIP_VALUES, SHIP_VALUES_DESCRIPTIONS } from "./ShipValue"
critical = false import { Target } from "./Target"
// Position in the arena /**
arena_x: number * A single ship in a fleet
arena_y: number */
export class Ship extends RObject {
// Facing direction in the arena // Ship model
arena_angle: number model: ShipModel
// Available actions // Fleet this ship is a member of
actions = new ActionList() fleet: Fleet
// Active effects (sticky, self or area) // Level of this ship
active_effects = new RObjectContainer<BaseEffect>() level = new ShipLevel()
// Ship attributes // Name of the ship, null if unimportant
attributes = new ShipAttributes() name: string | null
// Ship values // Flag indicating if the ship is alive
values = new ShipValues() alive: boolean
// Personality // Flag indicating that the ship is mission critical (escorted ship)
personality = new Personality() critical = false
// Boolean set to true if the ship is currently playing its turn // Position in the arena
playing = false arena_x: number
arena_y: number
// Priority in current battle's play_order (used as sort key)
play_priority = 0 // Facing direction in the arena
arena_angle: number
// Create a new ship inside a fleet
constructor(fleet: Fleet | null = null, name: string | null = null, model = new ShipModel()) { // Available actions
super(); actions = new ActionList()
this.fleet = fleet || new Fleet(); // Active effects (sticky, self or area)
this.name = name; active_effects = new RObjectContainer<BaseEffect>()
this.alive = true;
// Ship attributes
this.arena_x = 0; attributes = new ShipAttributes()
this.arena_y = 0;
this.arena_angle = 0; // Ship values
values = new ShipValues()
this.model = model;
// Personality
this.updateAttributes(); personality = new Personality()
this.actions.updateFromShip(this);
// Boolean set to true if the ship is currently playing its turn
this.fleet.addShip(this); playing = false
}
// Priority in current battle's play_order (used as sort key)
/** play_priority = 0
* Return the current location and angle of this ship
*/ // Create a new ship inside a fleet
get location(): ArenaLocationAngle { constructor(fleet: Fleet | null = null, name: string | null = null, model = new ShipModel()) {
return new ArenaLocationAngle(this.arena_x, this.arena_y, this.arena_angle); super();
}
this.fleet = fleet || new Fleet();
/** this.name = name;
* Returns the name of this ship this.alive = true;
*/
getName(level = true): string { this.arena_x = 0;
let name = this.name || this.model.name; this.arena_y = 0;
return level ? `Level ${this.level.get()} ${name}` : name; this.arena_angle = 0;
}
this.model = model;
// Returns true if the ship is able to play
// If *check_ap* is true, ap_current=0 will make this function return false this.updateAttributes();
isAbleToPlay(check_ap: boolean = true): boolean { this.actions.updateFromShip(this);
var ap_checked = !check_ap || this.getValue("power") > 0;
return this.alive && ap_checked; this.fleet.addShip(this);
} }
// Set position in the arena /**
// This does not consumes action points * Return the current location and angle of this ship
setArenaPosition(x: number, y: number) { */
this.arena_x = x; get location(): ArenaLocationAngle {
this.arena_y = y; return new ArenaLocationAngle(this.arena_x, this.arena_y, this.arena_angle);
} }
// Set facing angle in the arena /**
setArenaFacingAngle(angle: number) { * Returns the name of this ship
this.arena_angle = angle; */
} getName(level = true): string {
let name = this.name || this.model.name;
// String repr return level ? `Level ${this.level.get()} ${name}` : name;
jasmineToString(): string { }
return this.getName();
} // Returns true if the ship is able to play
// If *check_ap* is true, ap_current=0 will make this function return false
// Make an initiative throw, to resolve play order in a battle isAbleToPlay(check_ap: boolean = true): boolean {
throwInitiative(gen: RandomGenerator): void { var ap_checked = !check_ap || this.getValue("power") > 0;
this.play_priority = gen.random() * this.attributes.initiative.get(); return this.alive && ap_checked;
} }
/** // Set position in the arena
* Return the player that plays this ship // This does not consumes action points
*/ setArenaPosition(x: number, y: number) {
getPlayer(): Player { this.arena_x = x;
return this.fleet.player; this.arena_y = y;
} }
/** // Set facing angle in the arena
* Check if a player is playing this ship setArenaFacingAngle(angle: number) {
*/ this.arena_angle = angle;
isPlayedBy(player: Player): boolean { }
return player.is(this.fleet.player);
} // String repr
jasmineToString(): string {
/** return this.getName();
* Get the battle this ship is currently engaged in }
*/
getBattle(): Battle | null { // Make an initiative throw, to resolve play order in a battle
return this.fleet.battle; throwInitiative(gen: RandomGenerator): void {
} this.play_priority = gen.random() * this.attributes.initiative.get();
}
/**
* Get the list of activated upgrades /**
*/ * Return the player that plays this ship
getUpgrades(): ShipUpgrade[] { */
return this.model.getActivatedUpgrades(this.level.get(), this.level.getUpgrades()); getPlayer(): Player {
} return this.fleet.player;
}
/**
* Refresh the actions and attributes from the bound model /**
*/ * Check if a player is playing this ship
refreshFromModel(): void { */
this.updateAttributes(); isPlayedBy(player: Player): boolean {
this.actions.updateFromShip(this); return player.is(this.fleet.player);
} }
/** /**
* Change the ship model * Get the battle this ship is currently engaged in
*/ */
setModel(model: ShipModel): void { getBattle(): Battle | null {
this.model = model; return this.fleet.battle;
this.level.clearUpgrades(); }
this.refreshFromModel();
} /**
* Get the list of activated upgrades
/** */
* Toggle an upgrade getUpgrades(): ShipUpgrade[] {
*/ return this.model.getActivatedUpgrades(this.level.get(), this.level.getUpgrades());
activateUpgrade(upgrade: ShipUpgrade, on: boolean): void { }
if (on && (upgrade.cost || 0) > this.getAvailableUpgradePoints()) {
return; /**
} * Refresh the actions and attributes from the bound model
this.level.activateUpgrade(upgrade, on); */
this.refreshFromModel(); refreshFromModel(): void {
} this.updateAttributes();
this.actions.updateFromShip(this);
/** }
* Get the number of upgrade points available
*/ /**
getAvailableUpgradePoints(): number { * Change the ship model
let upgrades = this.getUpgrades(); */
return this.level.getUpgradePoints() - sum(upgrades.map(upgrade => upgrade.cost || 0)); setModel(model: ShipModel): void {
} this.model = model;
this.level.clearUpgrades();
/** this.refreshFromModel();
* Add an event to the battle log, if any }
*/
addBattleEvent(event: BaseBattleDiff): void { /**
var battle = this.getBattle(); * Toggle an upgrade
if (battle && battle.log) { */
battle.log.add(event); activateUpgrade(upgrade: ShipUpgrade, on: boolean): void {
} if (on && (upgrade.cost || 0) > this.getAvailableUpgradePoints()) {
} return;
/**
* 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;
}
} }
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 { import { testing } from "../common/Testing";
testing("ShipGenerator", test => { import { ShipModel } from "./models/ShipModel";
test.case("can use ship model", check => { import { ShipGenerator } from "./ShipGenerator";
var gen = new ShipGenerator();
var model = new ShipModel("test", "Test"); testing("ShipGenerator", test => {
var ship = gen.generate(3, model, false); test.case("can use ship model", check => {
check.same(ship.model, model); var gen = new ShipGenerator();
check.same(ship.level.get(), 3); 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 { import { RandomGenerator } from "../common/RandomGenerator";
/** import { ShipModel } from "./models/ShipModel";
* Generator of random ship import { Ship } from "./Ship";
*/
export class ShipGenerator {
// Random number generator used
random: RandomGenerator
constructor(random = RandomGenerator.global) { /**
this.random = random; * Generator of random ship
} */
export class ShipGenerator {
// Random number generator used
random: RandomGenerator
/** constructor(random = RandomGenerator.global) {
* Generate a ship of a givel level. this.random = random;
* }
* 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); /**
* Generate a ship of a givel level.
result.level.forceLevel(level); *
if (upgrade) { * If *upgrade* is true, random levelling options will be chosen
let iteration = 0; */
while (iteration < 100) { generate(level: number, model: ShipModel | null = null, upgrade = true): Ship {
iteration += 1; if (!model) {
// Get a random model
let points = result.getAvailableUpgradePoints(); model = ShipModel.getRandomModel(level, this.random);
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;
}
} }
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 { import { testing } from "../common/Testing";
testing("ShipLevel", test => { import { ShipLevel } from "./ShipLevel";
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(60); // 60 testing("ShipLevel", test => {
check.equals(level.get(), 1); test.case("level up from experience points", check => {
check.equals(level.checkLevelUp(), false); let level = new ShipLevel();
check.equals(level.get(), 1);
check.equals(level.getNextGoal(), 100);
check.equals(level.getUpgradePoints(), 0);
level.addExperience(70); // 130 level.addExperience(60); // 60
check.equals(level.get(), 1); check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), true); check.equals(level.checkLevelUp(), false);
check.equals(level.get(), 2);
check.equals(level.getNextGoal(), 300);
check.equals(level.getUpgradePoints(), 3);
level.addExperience(200); // 330 level.addExperience(70); // 130
check.equals(level.get(), 2); check.equals(level.get(), 1);
check.equals(level.checkLevelUp(), true); check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 3); check.equals(level.get(), 2);
check.equals(level.getNextGoal(), 600); check.equals(level.getNextGoal(), 300);
check.equals(level.getUpgradePoints(), 5); check.equals(level.getUpgradePoints(), 3);
level.addExperience(320); // 650 level.addExperience(200); // 330
check.equals(level.get(), 3); check.equals(level.get(), 2);
check.equals(level.checkLevelUp(), true); check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 4); check.equals(level.get(), 3);
check.equals(level.getNextGoal(), 1000); check.equals(level.getNextGoal(), 600);
check.equals(level.getUpgradePoints(), 7); check.equals(level.getUpgradePoints(), 5);
});
test.case("forces a given level", check => { level.addExperience(320); // 650
let level = new ShipLevel(); check.equals(level.get(), 3);
check.equals(level.get(), 1); check.equals(level.checkLevelUp(), true);
check.equals(level.get(), 4);
check.equals(level.getNextGoal(), 1000);
check.equals(level.getUpgradePoints(), 7);
});
level.forceLevel(10); test.case("forces a given level", check => {
check.equals(level.get(), 10); let level = new ShipLevel();
}); check.equals(level.get(), 1);
test.case("manages upgrades", check => { level.forceLevel(10);
let up1 = { code: "test1" }; check.equals(level.get(), 10);
let up2 = { code: "test2" }; });
let level = new ShipLevel(); test.case("manages upgrades", check => {
check.equals(level.getUpgrades(), []); let up1 = { code: "test1" };
check.equals(level.hasUpgrade(up1), false); let up2 = { code: "test2" };
level.activateUpgrade(up1, true); let level = new ShipLevel();
check.equals(level.getUpgrades(), ["test1"]); check.equals(level.getUpgrades(), []);
check.equals(level.hasUpgrade(up1), true); check.equals(level.hasUpgrade(up1), false);
level.activateUpgrade(up1, true); level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), ["test1"]); check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), true); check.equals(level.hasUpgrade(up1), true);
level.activateUpgrade(up1, false); level.activateUpgrade(up1, true);
check.equals(level.getUpgrades(), []); check.equals(level.getUpgrades(), ["test1"]);
check.equals(level.hasUpgrade(up1), false); check.equals(level.hasUpgrade(up1), true);
level.activateUpgrade(up1, true); level.activateUpgrade(up1, false);
level.activateUpgrade(up2, true); check.equals(level.getUpgrades(), []);
check.equals(level.getUpgrades(), ["test1", "test2"]); check.equals(level.hasUpgrade(up1), false);
level.clearUpgrades();
check.equals(level.getUpgrades(), []); 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 { import { imap, irange, isum } from "../common/Iterators";
/** import { acopy, add, contains, remove } from "../common/Tools";
* Level and experience system for a ship, with enabled upgrades. import { ShipUpgrade } from "./models/ShipModel";
*/
export class ShipLevel {
private level = 1
private experience = 0
private upgrades: string[] = []
/** /**
* Get current level * Level and experience system for a ship, with enabled upgrades.
*/ */
get(): number { export class ShipLevel {
return this.level; private level = 1
} private experience = 0
private upgrades: string[] = []
/** /**
* Get the current experience points * Get current level
*/ */
getExperience(): number { get(): number {
return this.experience; return this.level;
} }
/** /**
* Get the activated upgrades * Get the current experience points
*/ */
getUpgrades(): string[] { getExperience(): number {
return acopy(this.upgrades); return this.experience;
} }
/** /**
* Get the next experience goal to reach, to gain one level * Get the activated upgrades
*/ */
getNextGoal(): number { getUpgrades(): string[] {
return isum(imap(irange(this.level), i => (i + 1) * 100)); return acopy(this.upgrades);
} }
/** /**
* Force experience gain, to reach a given level * Get the next experience goal to reach, to gain one level
*/ */
forceLevel(level: number): void { getNextGoal(): number {
while (this.level < level) { return isum(imap(irange(this.level), i => (i + 1) * 100));
this.forceLevelUp(); }
}
}
/** /**
* Force a level up * Force experience gain, to reach a given level
*/ */
forceLevelUp(): void { forceLevel(level: number): void {
let old_level = this.level; while (this.level < level) {
this.forceLevelUp();
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 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 { import { testing } from "../common/Testing";
testing("ShipAttribute", test => { import { ShipAttribute } from "./ShipValue";
test.case("applies cumulative, multiplier and limit", check => {
let attribute = new ShipAttribute();
check.equals(attribute.get(), 0, "initial");
attribute.addModifier(4); testing("ShipAttribute", test => {
check.in("+4", check => { test.case("applies cumulative, multiplier and limit", check => {
check.equals(attribute.get(), 4, "effective value"); let attribute = new ShipAttribute();
}); check.equals(attribute.get(), 0, "initial");
attribute.addModifier(2); attribute.addModifier(4);
check.in("+4 +2", check => { check.in("+4", check => {
check.equals(attribute.get(), 6, "effective value"); check.equals(attribute.get(), 4, "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(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 { import { min, remove, sum } from "../common/Tools"
type ShipValuesMapping = {
[P in (keyof ShipValues | keyof ShipAttributes)]: string
}
export const SHIP_VALUES_DESCRIPTIONS: ShipValuesMapping = { type ShipValuesMapping = {
"initiative": "Capacity to play before others in a battle", [P in (keyof ShipValues | keyof ShipAttributes)]: string
"hull": "Physical structure of the ship", }
"shield": "Shield around the ship that may absorb damage",
"power": "Power available to supply the equipments", export const SHIP_VALUES_DESCRIPTIONS: ShipValuesMapping = {
"hull_capacity": "Maximal Hull value before the ship risks collapsing", "initiative": "Capacity to play before others in a battle",
"shield_capacity": "Maximal Shield value to protect the hull from damage", "hull": "Physical structure of the ship",
"power_capacity": "Maximal Power value to use equipment", "shield": "Shield around the ship that may absorb damage",
"evasion": "Damage points that may be evaded by maneuvering", "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",
export const SHIP_VALUES_NAMES: ShipValuesMapping = { "power_capacity": "Maximal Power value to use equipment",
"initiative": "initiative", "evasion": "Damage points that may be evaded by maneuvering",
"hull": "hull", }
"shield": "shield",
"power": "power", export const SHIP_VALUES_NAMES: ShipValuesMapping = {
"hull_capacity": "hull capacity", "initiative": "initiative",
"shield_capacity": "shield capacity", "hull": "hull",
"power_capacity": "power capacity", "shield": "shield",
"evasion": "evasion", "power": "power",
} "hull_capacity": "hull capacity",
"shield_capacity": "shield capacity",
/** "power_capacity": "power capacity",
* A ship attribute is a number resulting of a list of modifiers. "evasion": "evasion",
*/ }
export class ShipAttribute {
// Current value /**
private current = 0 * A ship attribute is a number resulting of a list of modifiers.
*/
// Modifiers export class ShipAttribute {
private cumulatives: number[] = [] // Current value
private multipliers: number[] = [] private current = 0
private limits: number[] = []
// Modifiers
/** private cumulatives: number[] = []
* Get the current value private multipliers: number[] = []
*/ private limits: number[] = []
get(): number {
return this.current; /**
} * Get the current value
*/
/** get(): number {
* Get the maximal value enforced by limit modifiers, Infinity for unlimited return this.current;
*/ }
getMaximal(): number {
if (this.limits.length > 0) { /**
return min(this.limits); * Get the maximal value enforced by limit modifiers, Infinity for unlimited
} else { */
return Infinity; getMaximal(): number {
} if (this.limits.length > 0) {
} return min(this.limits);
} else {
/** return Infinity;
* Reset all modifiers }
*/ }
reset(): void {
this.cumulatives = []; /**
this.multipliers = []; * Reset all modifiers
this.limits = []; */
this.update(); reset(): void {
} this.cumulatives = [];
this.multipliers = [];
/** this.limits = [];
* Add a modifier this.update();
*/ }
addModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") { /**
this.cumulatives.push(cumulative); * Add a modifier
} */
if (typeof multiplier != "undefined") { addModifier(cumulative?: number, multiplier?: number, limit?: number): void {
this.multipliers.push(multiplier); if (typeof cumulative != "undefined") {
} this.cumulatives.push(cumulative);
if (typeof limit != "undefined") { }
this.limits.push(limit); if (typeof multiplier != "undefined") {
} this.multipliers.push(multiplier);
this.update(); }
} if (typeof limit != "undefined") {
this.limits.push(limit);
/** }
* Remove a modifier this.update();
*/ }
removeModifier(cumulative?: number, multiplier?: number, limit?: number): void {
if (typeof cumulative != "undefined") { /**
remove(this.cumulatives, cumulative); * Remove a modifier
} */
if (typeof multiplier != "undefined") { removeModifier(cumulative?: number, multiplier?: number, limit?: number): void {
remove(this.multipliers, multiplier); if (typeof cumulative != "undefined") {
} remove(this.cumulatives, cumulative);
if (typeof limit != "undefined") { }
remove(this.limits, limit); if (typeof multiplier != "undefined") {
} remove(this.multipliers, multiplier);
this.update(); }
} if (typeof limit != "undefined") {
remove(this.limits, limit);
/** }
* Update the current value this.update();
*/ }
private update(): void {
let value = sum(this.cumulatives); /**
if (this.multipliers.length) { * Update the current value
value = Math.round(value * (1 + sum(this.multipliers) / 100)); */
} private update(): void {
if (this.limits.length) { let value = sum(this.cumulatives);
value = Math.min(value, min(this.limits)); if (this.multipliers.length) {
} value = Math.round(value * (1 + sum(this.multipliers) / 100));
this.current = value; }
} 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() * Set of ShipAttribute for a ship
// Maximal hull value */
hull_capacity = new ShipAttribute() export class ShipAttributes {
// Maximal shield value // Initiative (capacity to play first)
shield_capacity = new ShipAttribute() initiative = new ShipAttribute()
// Damage evasion // Maximal hull value
evasion = new ShipAttribute() hull_capacity = new ShipAttribute()
// Maximal power value // Maximal shield value
power_capacity = new ShipAttribute() shield_capacity = new ShipAttribute()
} // Damage evasion
evasion = new ShipAttribute()
/** // Maximal power value
* Set of simple values for a ship power_capacity = new ShipAttribute()
*/ }
export class ShipValues {
hull = 0 /**
shield = 0 * Set of simple values for a ship
power = 0 */
} export class ShipValues {
hull = 0
/** shield = 0
* Static attributes and values object for property queries power = 0
*/ }
export const SHIP_ATTRIBUTES = new ShipAttributes();
export const SHIP_VALUES = new ShipValues(); /**
* Static attributes and values object for property queries
/** */
* Type guards export const SHIP_ATTRIBUTES = new ShipAttributes();
*/ export const SHIP_VALUES = new ShipValues();
export function isShipValue(key: string): key is keyof ShipValues {
return SHIP_VALUES.hasOwnProperty(key); /**
} * Type guards
export function isShipAttribute(key: string): key is keyof ShipAttributes { */
return SHIP_ATTRIBUTES.hasOwnProperty(key); 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 { import { testing } from "../common/Testing";
testing("Shop", test => { import { Mission } from "./missions/Mission";
test.case("generates secondary missions", check => { import { Player } from "./Player";
let universe = new Universe(); import { Shop } from "./Shop";
universe.generate(4); import { StarLocation } from "./StarLocation";
let start = universe.getStartLocation(); import { Universe } from "./Universe";
let shop = new Shop(); testing("Shop", test => {
check.equals((<any>shop).missions.length, 0); test.case("generates secondary missions", check => {
let universe = new Universe();
universe.generate(4);
let start = universe.getStartLocation();
let result = shop.getMissions(start, 4); let shop = new Shop();
check.equals(result.length, 4); check.equals((<any>shop).missions.length, 0);
check.equals((<any>shop).missions.length, 4);
let oresult = shop.getMissions(start, 4); let result = shop.getMissions(start, 4);
check.equals(oresult, result); check.equals(result.length, 4);
check.equals((<any>shop).missions.length, 4);
result.forEach(mission => { let oresult = shop.getMissions(start, 4);
check.equals(mission.main, false); check.equals(oresult, result);
});
});
test.case("assigns missions to a fleet", check => { result.forEach(mission => {
let shop = new Shop(); check.equals(mission.main, false);
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);
});
}); });
} });
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 { import { RandomGenerator } from "../common/RandomGenerator";
/** import { contains, remove } from "../common/Tools";
* A shop is a place to buy/sell equipments import { Mission } from "./missions/Mission";
*/ import { MissionGenerator } from "./missions/MissionGenerator";
export class Shop { import { Player } from "./Player";
// Average level of equipment import { StarLocation } from "./StarLocation";
private level: number
// 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 // Random generator
private missions: Mission[] = [] private random: RandomGenerator
constructor(level = 1) { // Available missions
this.level = level; private missions: Mission[] = []
this.random = new RandomGenerator();
}
/** constructor(level = 1) {
* Get a list of available secondary missions this.level = level;
*/ this.random = new RandomGenerator();
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; /**
} * Get a list of available secondary missions
*/
/** getMissions(around: StarLocation, max_count = 3): Mission[] {
* Assign a mission to a fleet while (this.missions.length < max_count) {
* let generator = new MissionGenerator(around.star.universe, around, this.random);
* Returns true on success let mission = generator.generate();
*/ this.missions.push(mission);
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;
}
}
} }
}
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 { import { testing } from "../common/Testing";
testing("Star", test => { import { Star } from "./Star";
test.case("lists links to other stars", check => { import { StarLink } from "./StarLink";
var universe = new Universe(); import { Universe } from "./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]);
var result = universe.stars[0].getLinks(); testing("Star", test => {
check.equals(result.length, 2); test.case("lists links to other stars", check => {
check.equals(result[0], new StarLink(universe.stars[0], universe.stars[1])); var universe = new Universe();
check.equals(result[1], new StarLink(universe.stars[0], universe.stars[3])); 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]); var result = universe.stars[0].getLinks();
check.equals(universe.stars[0].getLinkTo(universe.stars[2]), null); check.equals(result.length, 2);
check.equals(universe.stars[0].getLinkTo(universe.stars[3]), universe.starlinks[1]); check.equals(result[0], new StarLink(universe.stars[0], universe.stars[1]));
check.equals(universe.stars[1].getLinkTo(universe.stars[0]), universe.starlinks[0]); check.equals(result[1], new StarLink(universe.stars[0], universe.stars[3]));
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(universe.stars[0].getLinkTo(universe.stars[1]), universe.starlinks[0]);
check.equals(neighbors.length, 2); check.equals(universe.stars[0].getLinkTo(universe.stars[2]), null);
check.contains(neighbors, universe.stars[1]); check.equals(universe.stars[0].getLinkTo(universe.stars[3]), universe.starlinks[1]);
check.contains(neighbors, universe.stars[3]); 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 { import { RandomGenerator } from "../common/RandomGenerator";
// A star system import { nna } from "../common/Tools";
export class Star { import { StarLink } from "./StarLink";
import { StarLocation, StarLocationType } from "./StarLocation";
import { Universe } from "./Universe";
// Available names for star systems // A star system
static NAMES_POOL = [ export class Star {
"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",
];
// Parent universe // Available names for star systems
universe: Universe; 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) // Parent universe
name: string; universe: Universe;
// Location in the universe // Name of the system (unique in the universe)
x: number; name: string;
y: number;
// Radius of the star system // Location in the universe
radius: number; x: number;
y: number;
// List of points of interest // Radius of the star system
locations: StarLocation[]; radius: number;
// Base level for encounters in this system // List of points of interest
level: number; locations: StarLocation[];
constructor(universe: Universe | null = null, x = 0, y = 0, name = "") { // Base level for encounters in this system
this.universe = universe || new Universe(); level: number;
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;
}
jasmineToString(): string { constructor(universe: Universe | null = null, x = 0, y = 0, name = "") {
return `Star ${this.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;
}
/** jasmineToString(): string {
* Add a location of interest return `Star ${this.name}`;
*/ }
addLocation(type: StarLocationType): StarLocation {
let result = new StarLocation(this, type);
this.locations.push(result);
return result;
}
// Get the distance to another star /**
getDistanceTo(star: Star): number { * Add a location of interest
var dx = this.x - star.x; */
var dy = this.y - star.y; 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 return Math.sqrt(dx * dx + dy * dy);
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) // Generate the contents of this star system
generateLocations(count: number, random = RandomGenerator.global): void { generate(random = RandomGenerator.global): void {
while (count--) { var location_count = random.randInt(2 + Math.floor(this.level / 2), 3 + this.level);
this.generateOneLocation(StarLocationType.PLANET, this.locations, this.radius * 0.2, this.radius * 0.6, random); if (this.name.length == 0) {
} this.name = random.choice(Star.NAMES_POOL);
}
// 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;
}
} }
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 { import { testing } from "../common/Testing";
testing("StarLink", test => { import { Star } from "./Star";
test.case("checks link intersection", check => { import { StarLink } from "./StarLink";
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 => { testing("StarLink", test => {
var star1 = new Star(null, 0, 0); test.case("checks link intersection", check => {
var star2 = new Star(null, 0, 1); var star1 = new Star(null, 0, 0);
var star3 = new Star(null, 0, 1); var star2 = new Star(null, 0, 1);
var link1 = new StarLink(star1, star2); var star3 = new Star(null, 1, 0);
var star4 = new Star(null, 1, 1);
check.same(link1.getPeer(star1), star2); var link1 = new StarLink(star1, star2);
check.same(link1.getPeer(star2), star1); var link2 = new StarLink(star1, star3);
check.equals(link1.getPeer(star3), null); 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 { import { Star } from "./Star";
// An hyperspace link between two star systems
export class StarLink {
// Stars
first: Star;
second: Star;
constructor(first: Star, second: Star) { // An hyperspace link between two star systems
this.first = first; export class StarLink {
this.second = second; // Stars
} first: Star;
second: Star;
// Check if this links bounds the two stars together, in either way constructor(first: Star, second: Star) {
isLinking(first: Star, second: Star) { this.first = first;
return (this.first === first && this.second === second) || (this.first === second && this.second === first); this.second = second;
} }
// Get the length of a link // Check if this links bounds the two stars together, in either way
getLength(): number { isLinking(first: Star, second: Star) {
return this.first.getDistanceTo(this.second); return (this.first === first && this.second === second) || (this.first === second && this.second === first);
} }
// Check if this link crosses another // Get the length of a link
isCrossing(other: StarLink): boolean { getLength(): number {
if (this.first === other.first || this.second === other.first || this.first === other.second || this.second === other.second) { return this.first.getDistanceTo(this.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 // Check if this link crosses another
getPeer(star: Star): Star | null { isCrossing(other: StarLink): boolean {
if (star === this.first) { if (this.first === other.first || this.second === other.first || this.first === other.second || this.second === other.second) {
return this.second; return false;
} else if (star === this.second) {
return this.first;
} else {
return null;
}
}
} }
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 { import { SkewedRandomGenerator } from "../common/RandomGenerator";
testing("StarLocation", test => { import { testing } from "../common/Testing";
test.case("removes generated encounters that lose", check => { import { nn } from "../common/Tools";
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0); import { Fleet } from "./Fleet";
var fleet = new Fleet(); import { StarLocation, StarLocationType } from "./StarLocation";
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
check.notequals(location.encounter, null); testing("StarLocation", test => {
check.notequals(battle, null); 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)); battle.endBattle(fleet);
check.equals(location.encounter, null); check.notequals(location.encounter, null);
});
test.case("leaves generated encounters that win", check => { location.resolveEncounter(nn(battle.outcome));
var location = new StarLocation(undefined, StarLocationType.PLANET, 0, 0); check.equals(location.encounter, null);
var fleet = new Fleet(); });
fleet.addShip();
location.encounter_random = new SkewedRandomGenerator([0]);
var battle = nn(location.enterLocation(fleet));
check.notequals(location.encounter, null); test.case("leaves generated encounters that win", check => {
check.notequals(battle, null); 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)); battle.endBattle(location.encounter);
check.notequals(location.encounter, null); 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 {
export enum StarLocationType { STAR,
STAR, WARP,
WARP, PLANET,
PLANET, ASTEROID,
ASTEROID, STATION
STATION }
}
/**
/** * Point of interest in a star system
* Point of interest in a star system */
*/ export class StarLocation extends RObject {
export class StarLocation extends RObject { // Parent star system
// Parent star system star: Star
star: Star
// Type of location
// Type of location type: StarLocationType
type: StarLocationType
// Location in the star system
// Location in the star system x: number
x: number y: number
y: number
// Absolute location in the universe
// Absolute location in the universe universe_x: number
universe_x: number universe_y: number
universe_y: number
// Destination for jump, if its a WARP location
// Destination for jump, if its a WARP location jump_dest: StarLocation | null
jump_dest: StarLocation | null
// Fleets present at this location (excluding the encounter for now)
// Fleets present at this location (excluding the encounter for now) fleets: Fleet[] = []
fleets: Fleet[] = []
// Enemy encounter
// Enemy encounter encounter: Fleet | null = null
encounter: Fleet | null = null encounter_gen = false
encounter_gen = false encounter_random = RandomGenerator.global
encounter_random = RandomGenerator.global
// Shop to buy/sell equipment
// Shop to buy/sell equipment shop: Shop | null = null
shop: Shop | null = null
constructor(star = new Star(), type: StarLocationType = StarLocationType.PLANET, x: number = 0, y: number = 0) {
constructor(star = new Star(), type: StarLocationType = StarLocationType.PLANET, x: number = 0, y: number = 0) { super();
super();
this.star = star;
this.star = star; this.type = type;
this.type = type; this.x = x;
this.x = x; this.y = y;
this.y = y; this.universe_x = this.star.x + this.x;
this.universe_x = this.star.x + this.x; this.universe_y = this.star.y + this.y;
this.universe_y = this.star.y + this.y; this.jump_dest = null;
this.jump_dest = null; }
}
/**
/** * Get the universe containing this location
* Get the universe containing this location */
*/ get universe(): Universe {
get universe(): Universe { return this.star.universe;
return this.star.universe; }
}
/**
/** * Add a shop in this location
* Add a shop in this location */
*/ addShop(level = this.star.level) {
addShop(level = this.star.level) { this.shop = new Shop(level);
this.shop = new Shop(level); }
}
/**
/** * Remove a potential shop in this location
* Remove a potential shop in this location */
*/ removeShop(): void {
removeShop(): void { this.shop = null;
this.shop = null; }
}
/**
/** * Add a fleet to the list of fleets present in this system
* Add a fleet to the list of fleets present in this system */
*/ addFleet(fleet: Fleet): void {
addFleet(fleet: Fleet): void { if (add(this.fleets, fleet)) {
if (add(this.fleets, fleet)) { this.enterLocation(fleet);
this.enterLocation(fleet); }
} }
}
/**
/** * Remove a fleet from the list of fleets present in this system
* Remove a fleet from the list of fleets present in this system */
*/ removeFleet(fleet: Fleet): void {
removeFleet(fleet: Fleet): void { remove(this.fleets, fleet);
remove(this.fleets, fleet); }
}
/**
/** * Check if the location is clear of encounter
* Check if the location is clear of encounter */
*/ isClear(): boolean {
isClear(): boolean { return this.encounter_gen && this.encounter === null;
return this.encounter_gen && this.encounter === null; }
}
// Set the jump destination of a WARP location
// Set the jump destination of a WARP location setJumpDestination(jump_dest: StarLocation): void {
setJumpDestination(jump_dest: StarLocation): void { if (this.type === StarLocationType.WARP) {
if (this.type === StarLocationType.WARP) { this.jump_dest = jump_dest;
this.jump_dest = jump_dest; }
} }
}
// Call this when first probing a location to generate the possible encounter
// Call this when first probing a location to generate the possible encounter // Returns the encountered fleet, null if no encounter happens
// Returns the encountered fleet, null if no encounter happens tryGenerateEncounter(): Fleet | null {
tryGenerateEncounter(): Fleet | null { if (!this.encounter_gen) {
if (!this.encounter_gen) { this.encounter_gen = true;
this.encounter_gen = true;
if (this.encounter_random.random() < 0.8) {
if (this.encounter_random.random() < 0.8) { this.setupEncounter();
this.setupEncounter(); }
} }
}
return this.encounter;
return this.encounter; }
}
// Call this when entering a location to generate the possible encounter
// Call this when entering a location to generate the possible encounter // *fleet* is the player fleet, entering the location
// *fleet* is the player fleet, entering the location // Returns the engaged battle, null if no encounter happens
// Returns the engaged battle, null if no encounter happens enterLocation(fleet: Fleet): Battle | null {
enterLocation(fleet: Fleet): Battle | null { let encounter = this.tryGenerateEncounter();
let encounter = this.tryGenerateEncounter(); if (encounter) {
if (encounter) { let battle = new Battle(fleet, encounter);
let battle = new Battle(fleet, encounter); battle.start();
battle.start(); return battle;
return battle; } else {
} else { return null;
return null; }
} }
}
// Get the distance to another location
// Get the distance to another location getDistanceTo(other: StarLocation): number {
getDistanceTo(other: StarLocation): number { var dx = this.x - other.x;
var dx = this.x - other.x; var dy = this.y - other.y;
var dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
return Math.sqrt(dx * dx + dy * dy); }
}
/**
/** * Clear an encounter, when the encountered fleet has been defeated
* Clear an encounter, when the encountered fleet has been defeated */
*/ clearEncounter() {
clearEncounter() { this.encounter_gen = true;
this.encounter_gen = true; this.encounter = null;
this.encounter = null; }
}
/**
/** * Forces the setup of an encounter
* Forces the setup of an encounter */
*/ setupEncounter() {
setupEncounter() { this.encounter_gen = true;
this.encounter_gen = true;
let fleet_generator = new FleetGenerator(this.encounter_random);
let fleet_generator = new FleetGenerator(this.encounter_random); let variations: [number, number][];
let variations: [number, number][]; if (this.star.level == 1) {
if (this.star.level == 1) { variations = [[this.star.level, 2]];
variations = [[this.star.level, 2]]; } else if (this.star.level <= 3) {
} else if (this.star.level <= 3) { variations = [[this.star.level, 2], [this.star.level - 1, 3]];
variations = [[this.star.level, 2], [this.star.level - 1, 3]]; } else if (this.star.level <= 6) {
} else if (this.star.level <= 6) { variations = [[this.star.level, 3], [this.star.level - 1, 4], [this.star.level + 1, 2]];
variations = [[this.star.level, 3], [this.star.level - 1, 4], [this.star.level + 1, 2]]; } else {
} else { variations = [[this.star.level, 4], [this.star.level - 1, 5], [this.star.level + 1, 3], [this.star.level + 3, 2]];
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);
let [level, enemies] = this.encounter_random.choice(variations); this.encounter = fleet_generator.generate(level, new Player("Enemy"), enemies, true);
this.encounter = fleet_generator.generate(level, new Player("Enemy"), enemies, true); }
}
/**
/** * Resolves the encounter from a battle outcome
* Resolves the encounter from a battle outcome */
*/ resolveEncounter(outcome: BattleOutcome) {
resolveEncounter(outcome: BattleOutcome) { if (this.encounter && outcome.winner && !this.encounter.is(outcome.winner)) {
if (this.encounter && outcome.winner && !this.encounter.is(outcome.winner)) { this.clearEncounter();
this.clearEncounter(); }
} }
}
}
} }

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