Browse Source

Dependencies update

Michaël Lemaire 7 months ago
parent
commit
b69db4e796

+ 0 - 3
.gitmodules

@@ -1,3 +0,0 @@
-[submodule "src/common"]
-	path = src/common
-	url = https://code.thunderk.net/michael/tscommon.git

+ 2 - 1
TODO.md

@@ -4,7 +4,6 @@ To-Do-list
 Phaser 3 migration
 ------------------
 
-* Restore fullscreen mode (and add a fullscreen incentive before the menu)
 * Fix valuebar requiring to be in root display list
 * Restore unit tests about boundaries (in UITools)
 
@@ -96,6 +95,7 @@ Artificial Intelligence
 Common UI
 ---------
 
+* Add a fullscreen incentive at game start
 * Fix calling setHoverClick several times on the same button not working as expected
 * Fix tooltip remaining when the hovered object is hidden by animations
 * If ProgressiveMessage animation performance is bad, show the text directly
@@ -108,6 +108,7 @@ Common UI
 Technical
 ---------
 
+* Use tk-serializer package (may need to switch to webpack)
 * Fix tooltips and input events on mobile
 * Pause timers when the game is paused (at least animation timers)
 * Pack sounds

+ 5 - 3
activate_node

@@ -2,13 +2,15 @@
 # Usage:
 #  source activate_node
 
+vdir="./.venv"
+expected="10.15.3"
+
 if [ \! -f "./activate_node" ]
 then
     echo "Not in project directory"
     exit 1
 fi
 
-vdir="./.venv"
-test -x "${vdir}/bin/nodeenv" || ( virtualenv -p python3 "${vdir}" && "${vdir}/bin/pip" install --upgrade nodeenv )
-test -e "${vdir}/node/bin/activate" || "${vdir}/bin/nodeenv" --node=10.3.0 --force "${vdir}/node"
+test -x "${vdir}/bin/nodeenv" || ( python3 -m venv "${vdir}" && "${vdir}/bin/pip" install --upgrade nodeenv )
+test "$(${vdir}/node/bin/nodejs --version)" = "v${expected}" || "${vdir}/bin/nodeenv" --node=${expected} --force "${vdir}/node"
 source "${vdir}/node/bin/activate"

File diff suppressed because it is too large
+ 859 - 2385
package-lock.json


+ 16 - 16
package.json

@@ -18,29 +18,29 @@
   "author": "Michael Lemaire",
   "license": "MIT",
   "devDependencies": {
-    "@types/jasmine": "^2.8.8",
+    "@types/jasmine": "^3.3.12",
     "babel-polyfill": "6.26.0",
-    "codecov": "^3.0.2",
+    "codecov": "^3.3.0",
     "gamefroot-texture-packer": "github:Gamefroot/Gamefroot-Texture-Packer#f3687111afc94f80ea8f2877c188fb8e2004e8ff",
-    "glob": "^7.1.2",
-    "glob-watcher": "^5.0.1",
-    "jasmine": "^3.1.0",
-    "karma": "^2.0.2",
+    "glob": "^7.1.3",
+    "glob-watcher": "^5.0.3",
+    "jasmine": "^3.4.0",
+    "karma": "^4.1.0",
     "karma-coverage": "^1.1.2",
-    "karma-jasmine": "^1.1.2",
+    "karma-jasmine": "^2.0.1",
     "karma-phantomjs-launcher": "1.0.4",
     "karma-spec-reporter": "^0.0.32",
-    "live-server": "1.2.0",
-    "remap-istanbul": "^0.11.1",
-    "runjs": "^4.3.2",
-    "shelljs": "^0.8.2",
-    "typescript": "^2.9.1",
-    "uglify-js": "^3.4.0"
+    "live-server": "1.2.1",
+    "remap-istanbul": "^0.13.0",
+    "runjs": "^4.4.2",
+    "shelljs": "^0.8.3",
+    "typescript": "^3.4.5",
+    "uglify-js": "^3.5.11"
   },
   "dependencies": {
-    "jasmine-core": "^3.1.0",
-    "parse": "^1.11.1",
-    "phaser": "^3.10.1",
+    "jasmine-core": "^3.4.0",
+    "parse": "^2.4.0",
+    "phaser": "^3.16.2",
     "process-pool": "^0.3.5"
   }
 }

+ 15 - 33
src/MainUI.ts

@@ -41,15 +41,18 @@ module TK.SpaceTac {
         // Current scaling
         scaling = 1
 
-        constructor(headless: boolean = false) {
+        constructor(private testmode = false) {
             super({
                 width: 1920,
                 height: 1080,
-                type: headless ? Phaser.HEADLESS : Phaser.AUTO,
+                type: Phaser.AUTO,  // cannot really use HEADLESS because of bugs
                 backgroundColor: '#000000',
                 parent: '-space-tac',
                 disableContextMenu: true,
-                "render.autoResize": true,
+                scale: {
+                    mode: Phaser.Scale.FIT,
+                    autoCenter: Phaser.Scale.CENTER_BOTH
+                },
             });
 
             this.storage = localStorage;
@@ -57,7 +60,7 @@ module TK.SpaceTac {
             this.session = new GameSession();
             this.session_token = null;
 
-            if (!headless) {
+            if (!testmode) {
                 this.events.on("blur", () => {
                     this.scene.scenes.forEach(scene => this.scene.pause(scene));
                 });
@@ -74,34 +77,17 @@ module TK.SpaceTac {
                 this.scene.add('creation', UI.FleetCreationView);
                 this.scene.add('universe', UI.UniverseMapView);
 
-                this.resizeToFitWindow();
-                window.addEventListener("resize", () => this.resizeToFitWindow());
-
                 this.goToScene('boot');
             }
         }
 
-        resize(width: number, height: number, scaling?: number) {
-            super.resize(width, height);
-
-            this.scaling = scaling ? scaling : 1;
-            cfilter(this.scene.scenes, UI.BaseView).forEach(scene => scene.resize());
-        }
-
-        resizeToFitWindow() {
-            let width = window.innerWidth;
-            let height = window.innerHeight;
-            let scale = Math.min(width / 1920, height / 1080);
-            this.resize(1920 * scale, 1080 * scale, scale);
-        }
-
         boot() {
             super.boot();
             this.options = new UI.GameOptions(this);
         }
 
-        get headless(): boolean {
-            return this.config.renderType == Phaser.HEADLESS;
+        get isTesting(): boolean {
+            return this.testmode;
         }
 
         /**
@@ -124,7 +110,7 @@ module TK.SpaceTac {
             if (active) {
                 let scene = this.scene.getScene(active);
                 return (scene instanceof UI.BaseView) ? scene : null;
-            } else if (this.headless) {
+            } else if (this.isTesting) {
                 return this.scene.scenes[0];
             } else {
                 return null;
@@ -237,9 +223,7 @@ module TK.SpaceTac {
          * Check if the game is currently fullscreen
          */
         isFullscreen(): boolean {
-            // FIXME
-            return false;
-            //return this.scale.isFullScreen;
+            return this.scale.isFullscreen;
         }
 
         /**
@@ -248,15 +232,13 @@ module TK.SpaceTac {
          * Returns true if the result is fullscreen
          */
         toggleFullscreen(active: boolean | null = null): boolean {
-            // FIXME
-            /*if (active === false || (active !== true && this.isFullscreen())) {
-                this.scale.stopFullScreen();
+            if (active === false || (active !== true && this.isFullscreen())) {
+                this.scale.stopFullscreen();
                 return false;
             } else {
-                this.scale.startFullScreen(true);
+                this.scale.startFullscreen();
                 return true;
-            }*/
-            return false;
+            }
         }
     }
 }

+ 0 - 1
src/common

@@ -1 +0,0 @@
-Subproject commit 1425cb08935dd996a4c7a644ab793ff3b8355c9b

+ 232 - 0
src/common/DiffLog.spec.ts

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

+ 249 - 0
src/common/DiffLog.ts

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

+ 207 - 0
src/common/Iterators.spec.ts

@@ -0,0 +1,207 @@
+module TK {
+    testing("Iterators", test => {
+        function checkit<T>(base_iterator: Iterator<T>, ...values: (T | null)[]) {
+            let iterator = base_iterator;
+            values.forEach(value => {
+                let [head, tail] = iterator();
+                test.check.equals(head, value);
+                iterator = tail;
+            });
+
+            // second iteration to check for repeatability
+            iterator = base_iterator;
+            values.forEach(value => {
+                let [head, tail] = iterator();
+                test.check.equals(head, value);
+                iterator = tail;
+            });
+        }
+
+        function checkarray<T>(iterator: Iterator<T>, values: T[]) {
+            test.check.equals(imaterialize(iterator), values);
+
+            // second iteration to check for repeatability
+            test.check.equals(imaterialize(iterator), values);
+        }
+
+        test.case("calls a function for each yielded value", check => {
+            let iterator = iarray([1, 2, 3]);
+            let result: number[] = [];
+            iforeach(iterator, bound(result, "push"));
+            check.equals(result, [1, 2, 3]);
+
+            result = [];
+            iforeach(iterator, i => {
+                result.push(i);
+                if (i == 2) {
+                    return null;
+                } else {
+                    return undefined;
+                }
+            });
+            check.equals(result, [1, 2]);
+
+            result = [];
+            iforeach(iterator, i => {
+                result.push(i);
+                return i;
+            }, 2);
+            check.equals(result, [1, 2]);
+        });
+
+        test.case("creates an iterator from an array", check => {
+            checkit(iarray([]), null, null, null);
+            checkit(iarray([1, 2, 3]), 1, 2, 3, null, null, null);
+        });
+
+        test.case("creates an iterator from a single value", check => {
+            checkarray(isingle(1), [1]);
+            checkarray(isingle("a"), ["a"]);
+        });
+
+        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 => {
+            checkarray(irange(4), [0, 1, 2, 3]);
+            checkarray(irange(4, 1), [1, 2, 3, 4]);
+            checkarray(irange(5, 3, 2), [3, 5, 7, 9, 11]);
+            checkit(irange(), 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
+        });
+
+        test.case("uses a step iterator to scan numbers", check => {
+            checkit(istep(), 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
+            checkit(istep(3), 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);
+            checkarray(istep(3, irepeat(1, 4)), [3, 4, 5, 6, 7]);
+            checkarray(istep(8, IEMPTY), [8]);
+            checkit(istep(1, irange()), 1, 1, 2, 4, 7, 11, 16);
+        });
+
+        test.case("skips a number of values", check => {
+            checkarray(iskip(irange(7), 3), [3, 4, 5, 6]);
+            checkarray(iskip(irange(7), 12), []);
+            checkarray(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 iterators", check => {
+            checkarray(ichain(), []);
+            checkarray(ichain(irange(3)), [0, 1, 2]);
+            checkarray(ichain(iarray([1, 2]), iarray([]), iarray([3, 4, 5])), [1, 2, 3, 4, 5]);
+        });
+
+        test.case("chains iterator of iterators", check => {
+            checkarray(ichainit(IEMPTY), []);
+            checkarray(ichainit(iarray([iarray([1, 2, 3]), iarray([]), iarray([4, 5])])), [1, 2, 3, 4, 5]);
+        });
+
+        test.case("repeats a value", check => {
+            checkit(irepeat("a"), "a", "a", "a", "a");
+            checkarray(irepeat("a", 3), ["a", "a", "a"]);
+        });
+
+        test.case("loops an iterator", check => {
+            checkarray(iloop(irange(3), 2), [0, 1, 2, 0, 1, 2]);
+            checkit(iloop(irange(1)), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
+
+            let onloop = check.mockfunc("onloop");
+            let iterator = iloop(irange(2), 3, onloop.func);
+            function next() {
+                let value;
+                [value, iterator] = iterator();
+                return value;
+            }
+            check.equals(next(), 0);
+            check.called(onloop, 0);
+            check.equals(next(), 1);
+            check.called(onloop, 0);
+            check.equals(next(), 0);
+            check.called(onloop, 1);
+            check.equals(next(), 1);
+            check.called(onloop, 0);
+            check.equals(next(), 0);
+            check.called(onloop, 1);
+            check.equals(next(), 1);
+            check.called(onloop, 0);
+            check.equals(next(), null);
+            check.called(onloop, 0);
+        });
+
+        test.case("maps an iterator", check => {
+            checkarray(imap(IEMPTY, i => i * 2), []);
+            checkarray(imap(irange(3), i => i * 2), [0, 2, 4]);
+        });
+
+        test.case("filters an iterator", check => {
+            checkarray(imap(IEMPTY, i => i % 3 == 0), []);
+            checkarray(ifilter(irange(12), i => i % 3 == 0), [0, 3, 6, 9]);
+        });
+
+        test.case("combines iterators", check => {
+            let iterator = icombine(iarray([1, 2, 3]), iarray(["a", "b"]));
+            checkarray(iterator, [[1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]]);
+        });
+
+        test.case("zips iterators", check => {
+            checkarray(izip(IEMPTY, IEMPTY), []);
+            checkarray(izip(iarray([1, 2, 3]), iarray(["a", "b"])), [[1, "a"], [2, "b"]]);
+
+            checkarray(izipg(IEMPTY, IEMPTY), []);
+            checkarray(izipg(iarray([1, 2, 3]), iarray(["a", "b"])), <[number | null, string | null][]>[[1, "a"], [2, "b"], [3, null]]);
+        });
+
+        test.case("partitions iterators", check => {
+            let [it1, it2] = ipartition(IEMPTY, () => true);
+            checkarray(it1, []);
+            checkarray(it2, []);
+
+            [it1, it2] = ipartition(irange(5), i => i % 2 == 0);
+            checkarray(it1, [0, 2, 4]);
+            checkarray(it2, [1, 3]);
+        });
+
+        test.case("returns unique items", check => {
+            checkarray(iunique(IEMPTY), []);
+            checkarray(iunique(iarray([5, 3, 2, 3, 4, 5])), [5, 3, 2, 4]);
+            checkarray(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);
+        });
+    });
+}

+ 363 - 0
src/common/Iterators.ts

@@ -0,0 +1,363 @@
+/**
+ * Lazy iterators to work on dynamic data sets without materializing them.
+ * 
+ * They allow to work on infinite streams of values, with limited memory consumption.
+ * 
+ * Functions in this file that do not return an Iterator are "materializing", meaning that they
+ * may consume iterators up to the end, and will not work well on infinite iterators.
+ */
+module TK {
+    /**
+     * An iterator is a function without side effect, that returns the current value
+     * and an iterator over the next values.
+     */
+    export type Iterator<T> = () => [T | null, Iterator<T>];
+
+    function _getIEND(): [null, Iterator<any>] {
+        return [null, _getIEND];
+    }
+
+    /**
+     * IEND is a return value for iterators, indicating end of iteration.
+     */
+    export const IEND: [null, Iterator<any>] = [null, _getIEND];
+
+    /**
+     * Empty iterator, returning IEND
+     */
+    export const IEMPTY = () => IEND;
+
+    /**
+     * Equivalent of Array.forEach for lazy iterators.
+     * 
+     * If the callback returns *stopper*, the iteration is stopped.
+     */
+    export function iforeach<T>(iterator: Iterator<T>, callback: (_: T) => any, stopper: any = null) {
+        let value: T | null;
+        [value, iterator] = iterator();
+        while (value !== null) {
+            let returned = callback(value);
+            if (returned === stopper) {
+                return;
+            }
+            [value, iterator] = iterator();
+        }
+    }
+
+    /**
+     * Get an iterator on an array
+     * 
+     * The iterator will yield the next value each time it is called, then null when the array's end is reached.
+     */
+    export function iarray<T>(array: T[], offset = 0): Iterator<T> {
+        return () => {
+            if (offset < array.length) {
+                return [array[offset], iarray(array, offset + 1)];
+            } else {
+                return IEND;
+            }
+        }
+    }
+
+    /**
+     * Get an iterator yielding a single value
+     */
+    export function isingle<T>(value: T): Iterator<T> {
+        return iarray([value]);
+    }
+
+    /**
+     * Returns the first item passing a predicate
+     */
+    export function ifirst<T>(iterator: Iterator<T>, predicate: (item: T) => boolean): T | null {
+        let result: T | null = null;
+        iforeach(iterator, item => {
+            if (predicate(item)) {
+                result = item;
+                return null;
+            } else {
+                return undefined;
+            }
+        });
+        return result;
+    }
+
+    /**
+     * Returns the first non-null result of a value-yielding predicate, applied to each iterator element
+     */
+    export function ifirstmap<T1, T2>(iterator: Iterator<T1>, predicate: (item: T1) => T2 | null): T2 | null {
+        let result: T2 | null = null;
+        iforeach(iterator, item => {
+            let mapped = predicate(item);
+            if (mapped) {
+                result = mapped;
+                return null;
+            } else {
+                return undefined;
+            }
+        });
+        return result;
+    }
+
+    /**
+     * Materialize an array from consuming an iterator
+     * 
+     * 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>(iterator: Iterator<T>, limit = 1000000): T[] {
+        let result: T[] = [];
+        iforeach(iterator, value => {
+            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): Iterator<number> {
+        return () => (count != 0) ? [start, irange(count - 1, start + step, step)] : IEND;
+    }
+
+    /**
+     * 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 = irepeat(1)): Iterator<number> {
+        return () => {
+            let [value, iterator] = step();
+            return [start, value === null ? IEMPTY : istep(start + value, iterator)];
+        }
+    }
+
+    /**
+     * Skip a given number of values from an iterator, discarding them.
+     */
+    export function iskip<T>(iterator: Iterator<T>, count = 1): Iterator<T> {
+        let value: T | null;
+        while (count--) {
+            [value, iterator] = iterator();
+        }
+        return iterator;
+    }
+
+    /**
+     * Return the value at a given position in the iterator
+     */
+    export function iat<T>(iterator: Iterator<T>, position: number): T | null {
+        if (position < 0) {
+            return null;
+        } else {
+            if (position > 0) {
+                iterator = iskip(iterator, position);
+            }
+            return iterator()[0];
+        }
+    }
+
+    /**
+     * Chain an iterator of iterators.
+     * 
+     * This will yield values from the first yielded iterator, then the second one, and so on...
+     */
+    export function ichainit<T>(iterators: Iterator<Iterator<T>>): Iterator<T> {
+        return function () {
+            let [iterators_head, iterators_tail] = iterators();
+            if (iterators_head == null) {
+                return IEND;
+            } else {
+                let [head, tail] = iterators_head();
+                while (head == null) {
+                    [iterators_head, iterators_tail] = iterators_tail();
+                    if (iterators_head == null) {
+                        break;
+                    }
+                    [head, tail] = iterators_head();
+                }
+                return [head, ichain(tail, ichainit(iterators_tail))];
+            }
+        }
+    }
+
+    /**
+     * Chain iterators.
+     * 
+     * This will yield values from the first iterator, then the second one, and so on...
+     */
+    export function ichain<T>(...iterators: Iterator<T>[]): Iterator<T> {
+        if (iterators.length == 0) {
+            return IEMPTY;
+        } else {
+            return ichainit(iarray(iterators));
+        }
+    }
+
+    /**
+     * Wrap an iterator, calling *onstart* when the first value of the wrapped iterator is yielded.
+     */
+    function ionstart<T>(iterator: Iterator<T>, onstart: Function): Iterator<T> {
+        return () => {
+            let [head, tail] = iterator();
+            if (head !== null) {
+                onstart();
+            }
+            return [head, tail];
+        }
+    }
+
+    /**
+     * Iterator that repeats the same value.
+     */
+    export function irepeat<T>(value: T, count = -1): Iterator<T> {
+        return iloop(iarray([value]), count);
+    }
+
+    /**
+     * 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: Iterator<T>, count = -1, onloop?: Function): Iterator<T> {
+        if (count == 0) {
+            return IEMPTY;
+        } else {
+            let next = onloop ? ionstart(base, onloop) : base;
+            return ichainit(() => [base, iarray([iloop(next, count - 1)])]);
+        }
+    }
+
+    /**
+     * Iterator version of "map".
+     */
+    export function imap<T1, T2>(iterator: Iterator<T1>, mapfunc: (_: T1) => T2): Iterator<T2> {
+        return () => {
+            let [head, tail] = iterator();
+            if (head === null) {
+                return IEND;
+            } else {
+                return [mapfunc(head), imap(tail, mapfunc)];
+            }
+        }
+    }
+
+    /**
+     * Iterator version of "reduce".
+     */
+    export function ireduce<T>(iterator: Iterator<T>, reduce: (item1: T, item2: T) => T, init: T): T {
+        let result = init;
+        iforeach(iterator, item => {
+            result = reduce(result, item);
+        });
+        return result;
+    }
+
+    /**
+     * Iterator version of "filter".
+     */
+    export function ifilter<T>(iterator: Iterator<T>, filterfunc: (_: T) => boolean): Iterator<T> {
+        return () => {
+            let [value, iter] = iterator();
+            while (value !== null && !filterfunc(value)) {
+                [value, iter] = iter();
+            }
+            return [value, ifilter(iter, filterfunc)];
+        }
+    }
+
+    /**
+     * Combine two iterators.
+     * 
+     * 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: Iterator<T1>, it2: Iterator<T2>): Iterator<[T1, T2]> {
+        return ichainit(imap(it1, v1 => imap(it2, (v2): [T1, T2] => [v1, v2])));
+    }
+
+    /**
+     * Advance two iterators 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: Iterator<T1>, it2: Iterator<T2>): Iterator<[T1, T2]> {
+        return () => {
+            let [val1, nit1] = it1();
+            let [val2, nit2] = it2();
+            if (val1 !== null && val2 !== null) {
+                return [[val1, val2], izip(nit1, nit2)];
+            } else {
+                return IEND;
+            }
+        }
+    }
+
+    /**
+     * Advance two iterators at the same time, yielding item pairs (greedy version)
+     * 
+     * Iteration will stop when both iterators are consumed, returning partial couples (null in the peer) if needed.
+     */
+    export function izipg<T1, T2>(it1: Iterator<T1>, it2: Iterator<T2>): Iterator<[T1 | null, T2 | null]> {
+        return () => {
+            let [val1, nit1] = it1();
+            let [val2, nit2] = it2();
+            if (val1 === null && val2 === null) {
+                return IEND;
+            } else {
+                return [[val1, val2], izipg(nit1, nit2)];
+            }
+        }
+    }
+
+    /**
+     * Partition in two iterators, one with values that pass the predicate, the other with values that don't
+     */
+    export function ipartition<T>(it: Iterator<T>, predicate: (item: T) => boolean): [Iterator<T>, Iterator<T>] {
+        return [ifilter(it, predicate), ifilter(it, x => !predicate(x))];
+    }
+
+    /**
+     * 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>(it: Iterator<T>, limit = 1000000): Iterator<T> {
+        function internal(it: Iterator<T>, limit: number, done: T[]): Iterator<T> {
+            let [value, iterator] = it();
+            while (value !== null && contains(done, value)) {
+                [value, iterator] = iterator();
+            }
+            if (value === null) {
+                return IEMPTY;
+            } else if (limit <= 0) {
+                throw new Error("Unique count limit on iterator");
+            } else {
+                let head = value;
+                return () => [head, internal(it, limit - 1, done.concat([head]))];
+            }
+        }
+        return internal(it, limit, []);
+    }
+
+    /**
+     * Common reduce shortcuts
+     */
+    export const isum = (iterator: Iterator<number>) => ireduce(iterator, (a, b) => a + b, 0);
+    export const icat = (iterator: Iterator<string>) => ireduce(iterator, (a, b) => a + b, "");
+    export const imin = (iterator: Iterator<number>) => ireduce(iterator, Math.min, Infinity);
+    export const imax = (iterator: Iterator<number>) => ireduce(iterator, Math.max, -Infinity);
+}

+ 3 - 0
src/common/README.md

@@ -0,0 +1,3 @@
+# tscommon
+
+Typescript tools shared between projects

+ 127 - 0
src/common/RObject.spec.ts

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

+ 100 - 0
src/common/RObject.ts

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

+ 92 - 0
src/common/RandomGenerator.spec.ts

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

+ 114 - 0
src/common/RandomGenerator.ts

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

+ 104 - 0
src/common/Serializer.spec.ts

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

+ 111 - 0
src/common/Serializer.ts

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

+ 330 - 0
src/common/Testing.ts

@@ -0,0 +1,330 @@
+/**
+ * Various testing functions.
+ */
+module TK {
+    export type FakeClock = { forward: (milliseconds: number) => void }
+    export type Mock<F extends Function> = { func: F, getCalls: () => any[][], reset: () => void }
+
+    /**
+     * Main test suite descriptor
+     */
+    export function testing(desc: string, body: (test: TestSuite) => void) {
+        if (typeof describe != "undefined") {
+            describe(desc, () => {
+                beforeEach(() => jasmine.addMatchers(CUSTOM_MATCHERS));
+
+                let test = new TestSuite(desc);
+                body(test);
+            });
+        }
+    }
+
+    /**
+     * 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?: Function): void {
+            beforeEach((done) => body().then(done));
+            if (cleanup) {
+                afterEach(() => 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 {
+                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 = {
+        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;
+                }
+            };
+        }
+    }
+}

+ 117 - 0
src/common/Timer.spec.ts

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

+ 100 - 0
src/common/Timer.ts

@@ -0,0 +1,100 @@
+module TK {
+    /**
+     * Timing utility.
+     * 
+     * This extends the standard setTimeout feature.
+     */
+    export class Timer {
+        // Global timer shared by the whole project
+        static global = new Timer();
+
+        // Global synchronous timer for unit tests
+        static synchronous = new Timer(true);
+
+        private sync = false;
+
+        private locked = false;
+
+        private scheduled: any[] = [];
+
+        constructor(sync = false) {
+            this.sync = sync;
+        }
+
+        /**
+         * Get the current timestamp in milliseconds
+         */
+        static nowMs(): number {
+            return (new Date()).getTime();
+        }
+
+        /**
+         * Get the elapsed time in milliseconds since a previous timestamp
+         */
+        static fromMs(previous: number): number {
+            return this.nowMs() - previous;
+        }
+
+        /**
+         * Return true if the timer is synchronous
+         */
+        isSynchronous() {
+            return this.sync;
+        }
+
+        /**
+         * Cancel all scheduled calls.
+         * 
+         * If lock=true, no further scheduling will be allowed.
+         */
+        cancelAll(lock = false) {
+            this.locked = lock;
+
+            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).
+         */
+        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 = [];
+        }
+    }
+}

+ 158 - 0
src/common/Toggle.spec.ts

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

+ 93 - 0
src/common/Toggle.ts

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

+ 517 - 0
src/common/Tools.spec.ts

@@ -0,0 +1,517 @@
+module TK.Specs {
+    testing("Tools", test => {
+        test.case("returns boolean equivalent", check => {
+            check.same(bool(null), false, "null");
+            check.same(bool(undefined), false, "undefined");
+
+            check.same(bool(false), false, "false");
+            check.same(bool(true), true, "true");
+
+            check.same(bool(0), false, "0");
+            check.same(bool(1), true, "1");
+            check.same(bool(-1), true, "-1");
+
+            check.same(bool(""), false, "\"\"");
+            check.same(bool(" "), true, "\" \"");
+            check.same(bool("abc"), true, "\"abc\"");
+
+            check.same(bool([]), false, "[]");
+            check.same(bool([1]), true, "[1]");
+            check.same(bool([null, "a"]), true, "[null, \"a\"]");
+
+            check.same(bool({}), false, "{}");
+            check.same(bool({ a: 5 }), true, "{a: 5}");
+            check.same(bool(new Timer()), true, "new Timer()");
+        });
+
+        test.case("coalesces to a default value", check => {
+            check.equals(coalesce(0, 2), 0);
+            check.equals(coalesce(5, 2), 5);
+            check.equals(coalesce(null, 2), 2);
+            check.equals(coalesce(undefined, 2), 2);
+
+            check.equals(coalesce("", "a"), "");
+            check.equals(coalesce("b", "a"), "b");
+            check.equals(coalesce(null, "a"), "a");
+            check.equals(coalesce(undefined, "a"), "a");
+        });
+
+        test.case("do basic function composition", check => {
+            check.equals(fmap((x: number) => x * 2, x => x + 1)(3), 8);
+        });
+
+        test.case("function composes a fallback value for null results", check => {
+            let f = nnf(-1, x => x > 3 ? null : x);
+            check.equals(f(2), 2);
+            check.equals(f(3), 3);
+            check.equals(f(4), -1);
+        });
+
+        test.case("checks for null value", check => {
+            let value: number | null = 5;
+            check.equals(nn(value), 5);
+            value = 0;
+            check.equals(nn(value), 0);
+            value = null;
+            check.throw(() => nn(value), "Null value");
+        });
+
+        test.case("removes null values from arrays", check => {
+            let value: (number | null)[] = [];
+            check.equals(nna(value), []);
+            value = [1, 2];
+            check.equals(nna(value), [1, 2]);
+            value = [1, null, 3];
+            check.equals(nna(value), [1, 3]);
+        });
+
+        test.case("cast-checks objects", check => {
+            let obj = new RObject();
+            check.equals(as(RObject, obj), obj);
+            check.throw(() => as(Timer, obj), new Error("Bad cast"));
+        });
+
+        test.case("compare values", check => {
+            check.equals(cmp(8, 5), 1);
+            check.same(cmp(5, 8), -1);
+            check.equals(cmp(5, 5), 0);
+
+            check.equals(cmp("c", "b"), 1);
+            check.same(cmp("b", "c"), -1);
+            check.equals(cmp("b", "b"), 0);
+
+            check.same(cmp(8, 5, true), -1);
+            check.equals(cmp(5, 8, true), 1);
+            check.equals(cmp(5, 5, true), 0);
+        });
+
+        test.case("clamp values in a range", check => {
+            check.equals(clamp(5, 3, 8), 5);
+            check.equals(clamp(1, 3, 8), 3);
+            check.equals(clamp(10, 3, 8), 8);
+        });
+
+        test.case("interpolates values linearly", check => {
+            check.equals(lerp(0, 0, 4), 0);
+            check.equals(lerp(0.5, 0, 4), 2);
+            check.equals(lerp(1, 0, 4), 4);
+            check.equals(lerp(2, 0, 4), 8);
+            check.same(lerp(-1, 0, 4), -4);
+            check.equals(lerp(0.5, 3, 4), 3.5);
+            check.equals(lerp(0.5, 3, 3), 3);
+            check.equals(lerp(0.5, 3, 2), 2.5);
+        });
+
+        test.case("duplicates objects", check => {
+            check.equals(duplicate(null), null);
+            check.equals(duplicate(5), 5);
+            check.equals(duplicate("test"), "test");
+            check.equals(duplicate({ a: 4 }), { a: 4 });
+            check.equals(duplicate([1, "test"]), [1, "test"]);
+            check.equals(duplicate(new TestSerializerObj1(6), <any>TK.Specs), new TestSerializerObj1(6));
+            let original = new TestRObject(4);
+            check.equals(duplicate(original, <any>TK.Specs), original);
+            check.notsame(duplicate(original, <any>TK.Specs), original);
+        });
+
+        test.case("copies arrays", check => {
+            var array = [1, 2, "test", null, { "a": 5 }];
+            var copied = acopy(array);
+
+            check.equals(copied, array);
+            check.notsame(copied, array);
+            check.same(copied[4], array[4]);
+
+            check.equals(array[2], "test");
+            check.equals(copied[2], "test");
+            array[2] = "notest";
+            check.equals(array[2], "notest");
+            check.equals(copied[2], "test");
+            copied[2] = "ok";
+            check.equals(array[2], "notest");
+            check.equals(copied[2], "ok");
+
+            check.equals(array.length, 5);
+            check.equals(copied.length, 5);
+            remove(copied, 2);
+            check.equals(array.length, 5);
+            check.equals(copied.length, 4);
+        });
+
+        test.case("iterates through sorted arrays", check => {
+            var result: number[] = [];
+            itersorted([1, 2, 3], item => item, item => result.push(item));
+            check.equals(result, [1, 2, 3]);
+
+            result = [];
+            itersorted([1, 2, 3], item => -item, item => result.push(item));
+            check.equals(result, [3, 2, 1]);
+        });
+
+        test.case("checks if an array contains an item", check => {
+            check.equals(contains([], 5), false);
+
+            check.equals(contains([3, 5, 8], 5), true);
+            check.equals(contains([3, 5, 8], 4), false);
+
+            check.equals(contains([5, 5, 5], 5), true);
+
+            check.equals(contains([3, null, 8], null), true);
+
+            check.equals(contains(["a", "b"], "b"), true);
+            check.equals(contains(["a", "b"], "c"), false);
+        });
+
+        test.case("capitalizes strings", check => {
+            check.equals(capitalize("test"), "Test");
+            check.equals(capitalize("test second"), "Test second");
+        });
+
+        test.case("produces range of numbers", check => {
+            check.equals(range(-1), []);
+            check.equals(range(0), []);
+            check.equals(range(1), [0]);
+            check.equals(range(2), [0, 1]);
+