Browse Source

Neural net is In!

It's super slow. I suspect it's not learning properly.
dhasenan 3 years ago
parent
commit
02bceb9da5

+ 133
- 41
source/app.d View File

@@ -1,22 +1,122 @@
1 1
 module app;
2 2
 
3
-import cosmic.ai;
4 3
 import cosmic.base;
5 4
 import cosmic.blocks;
6 5
 import cosmic.drawing;
7
-import cosmic.random;
8
-import cosmic.simple;
6
+import cosmic.ai.base;
7
+import cosmic.ai.nn;
8
+import cosmic.ai.random;
9
+import cosmic.ai.simple;
9 10
 
11
+import std.experimental.logger;
10 12
 import std.stdio;
11 13
 
12
-void main()
14
+int main(string[] args)
13 15
 {
14
-    auto board = new Board;
15
-    board.runGame(new Simple, new Random);
16
-    //mockup(board);
17
-    auto svg = new SvgSink();
18
-    board.draw(svg);
19
-    svg.write("board.svg");
16
+    globalLogLevel = LogLevel.info;
17
+    import std.getopt;
18
+    import etc.linux.memoryerror;
19
+    registerMemoryErrorHandler();
20
+
21
+    string command = "run";
22
+    string aia = "simple", aib = "random";
23
+    uint count = 1000;
24
+    string datafile = "learning.dat";
25
+    string nnfile = "nn.dat";
26
+    bool learn = true;
27
+
28
+    auto opts = getopt(
29
+            args,
30
+            "command|cmd", "what to do", &command,
31
+            "a", "which AI to use for player A", &aia,
32
+            "b", "which AI to use for player B", &aib,
33
+            "count|c", "how many games to play", &count,
34
+            "datafile|d", "where to read/store learning data", &datafile,
35
+            "nnfile|n", "where to read/store neural network data", &nnfile,
36
+            "learn", "whether to learn from playing", &learn);
37
+
38
+    if (opts.helpWanted)
39
+    {
40
+        defaultGetoptPrinter("Cosmic Blocks AI!!1", opts.options);
41
+        return 1;
42
+    }
43
+
44
+    if (command == "init")
45
+    {
46
+        auto nn = createNeuralNetwork(21, 11);
47
+        nn.serialize(nnfile);
48
+        return 0;
49
+    }
50
+
51
+    if (command == "bake")
52
+    {
53
+        bakeNeuralNetwork(nnfile, datafile);
54
+        return 0;
55
+    }
56
+
57
+    if (command == "script")
58
+    {
59
+        foreach (generation; 0..100)
60
+        {
61
+            NN.learningData = File(datafile, "ab");
62
+            auto a = new Neural();
63
+            auto b = new Neural();
64
+            auto board = new Board;
65
+            foreach (i; 0..count)
66
+            {
67
+                board.clear;
68
+                runGame(board, a, b);
69
+                infof("finished gen %s game %s; winner: %s", generation, i, board.winners);
70
+            }
71
+            SvgSink sink = new SvgSink;
72
+            board.draw(sink);
73
+            import std.format : format;
74
+            import std.file;
75
+            sink.write(format("board_gen%s.svg", generation));
76
+            infof("generation %s finished", generation);
77
+            NN.learningData.flush;
78
+            NN.learningData.close;
79
+            bakeNeuralNetwork(nnfile, datafile);
80
+            //std.file.remove(datafile);
81
+        }
82
+    }
83
+
84
+    AI create(string name)
85
+    {
86
+        switch (name)
87
+        {
88
+            case "nn":
89
+                return new Neural();
90
+            case "random":
91
+                return new Random();
92
+            case "simple":
93
+                return new Simple();
94
+            default:
95
+                throw new Exception("unrecognized ai name " ~ name);
96
+        }
97
+    }
98
+
99
+    if (command == "run")
100
+    {
101
+        NN.learningData = File(datafile, "ab");
102
+        auto a = create(aia);
103
+        auto b = create(aib);
104
+        auto board = new Board;
105
+        foreach (i; 0..count)
106
+        {
107
+            board.clear;
108
+            runGame(board, a, b);
109
+            tracef("finished game %s; winner: %s", i, board.winners);
110
+        }
111
+        SvgSink sink = new SvgSink;
112
+        board.draw(sink);
113
+        sink.write("board.svg");
114
+        NN.learningData.flush;
115
+        NN.learningData.close;
116
+        return 0;
117
+    }
118
+
119
+    return 0;
20 120
 }
21 121
 
22 122
 void mockup(Board board)
@@ -33,52 +133,46 @@ void mockup(Board board)
33 133
 
34 134
 void runGame(Board board, AI aia, AI aib)
35 135
 {
36
-    board.recalculateAccessibility;
37
-    foreach (block; board.blocks)
38
-    {
39
-        if (block.access != Player.none)
40
-        {
41
-            writefln("block %s accessible to %s", block.pos, block.access);
42
-        }
43
-    }
136
+    Board[] pastBoards;
44 137
     aia.init(board, Player.a);
45 138
     aib.init(board, Player.b);
46 139
     uint moves = 0;
47 140
     uint collisions = 0;
48 141
     while (true)
49 142
     {
143
+        board.tick;
50 144
         moves++;
51
-        if (moves > 300 || collisions > 10)
145
+        if (moves > 100 || collisions > 10)
52 146
         {
53
-            writefln("draw due to too many moves");
147
+            tracef("draw due to too many moves");
54 148
             break;
55 149
         }
56 150
         auto ma = aia.nextMove();
57
-        writefln("Player A plays: %s", ma);
151
+        tracef("Player A (%s) plays: %s", aia, ma);
58 152
         if (ma.forfeit)
59 153
         {
60
-            writeln("Player A forfeits");
154
+            //writeln("Player A forfeits");
61 155
             break;
62 156
         }
63 157
         assert(board[ma.pos].type is null, "tried to overwrite existing");
64 158
         if (board.winners != Player.none)
65 159
         {
66
-            writefln("winner: %s", board.winners);
160
+            tracef("winner: %s", board.winners);
67 161
             break;
68 162
         }
69 163
 
70 164
         auto mb = aib.nextMove();
71
-        writefln("Player B plays: %s", mb);
165
+        tracef("Player B (%s) plays: %s", aib, mb);
72 166
         if (mb.forfeit)
73 167
         {
74
-            writeln("Player B forfeits");
168
+            //writeln("Player B forfeits");
75 169
             break;
76 170
         }
77 171
         assert(board[mb.pos].type is null, "tried to overwrite existing");
78 172
 
79 173
         if (mb.pos == ma.pos)
80 174
         {
81
-            writefln("collision!");
175
+            tracef("collision!");
82 176
             board[ma.pos].type = BlockType.collision5;
83 177
             collisions++;
84 178
         }
@@ -88,26 +182,24 @@ void runGame(Board board, AI aia, AI aib)
88 182
             board[ma.pos].play(Player.a, ma.type, moves);
89 183
             board[mb.pos].play(Player.b, mb.type, moves);
90 184
         }
91
-        board.tick;
92 185
         if (board.winners != Player.none)
93 186
         {
94
-            writefln("winner: %s", board.winners);
187
+            tracef("winner: %s", board.winners);
95 188
             break;
96 189
         }
190
+        pastBoards ~= board.dup;
97 191
     }
98
-}
99
-
100
-Player winners(Board board)
101
-{
102
-    board.recalculateAccessibility;
103
-    Player w = Player.none;
104
-    if (board[board.startA].access & Player.b)
105
-    {
106
-        w |= Player.b;
107
-    }
108
-    if (board[board.startB].access & Player.a)
192
+    if (pastBoards.length > 0 && board.winners != Player.none)
109 193
     {
110
-        w |= Player.a;
194
+        foreach (i, b; pastBoards[1..$])
195
+        {
196
+            auto score = cast(int)(pastBoards.length - i);
197
+            if (board.winners == Player.a)
198
+            {
199
+                score *= -1;
200
+            }
201
+            nn.learn(b, score);
202
+        }
111 203
     }
112
-    return w;
113 204
 }
205
+

+ 2
- 0
source/cosmic/ai/base.d View File

@@ -38,4 +38,6 @@ abstract class AI
38 38
     }
39 39
 
40 40
     abstract Move nextMove();
41
+
42
+    void finish(Player winner) {}
41 43
 }

+ 201
- 0
source/cosmic/ai/nn.d View File

@@ -0,0 +1,201 @@
1
+module cosmic.ai.nn;
2
+
3
+import cosmic.ai.base;
4
+import cosmic.base;
5
+import cosmic.blocks;
6
+import vectorflow;
7
+import std.experimental.logger;
8
+import std.random;
9
+import std.file : exists, isFile;
10
+import std.stdio : File;
11
+
12
+class Neural : AI
13
+{
14
+    private static Mt19937 rnd;
15
+    static this()
16
+    {
17
+        rnd.seed(unpredictableSeed);
18
+    }
19
+
20
+    uint maxConsideredMoves = 5;
21
+    private int multiplier = 1;
22
+
23
+    override void init(Board board, Player player)
24
+    {
25
+        super.init(board, player);
26
+        if (nn is NN.init)
27
+        {
28
+            nn = NN(board.width, board.height);
29
+        }
30
+        if (player == Player.b)
31
+        {
32
+            multiplier = -1;
33
+        }
34
+    }
35
+
36
+    auto possibleMoves()
37
+    {
38
+        import std.algorithm, std.range;
39
+
40
+        auto emptySpaces = board.grid.data
41
+            .filter!(x => x.type is null)
42
+            .map!(x => x.pos)
43
+            .filter!(p => p.x >= board.startA.x - 1 && p.x <= board.startB.x + 1)
44
+            .filter!(p => p.y >= 2 && p.y <= board.height - 2)
45
+            ;
46
+        return cartesianProduct(emptySpaces, inventory)
47
+            .map!(x => Move(x[0], x[1]));
48
+    }
49
+
50
+    override Move nextMove()
51
+    {
52
+        import std.container.rbtree;
53
+        import std.stdio;
54
+
55
+        auto tmpBoard = board.dup;
56
+        auto tree = new RedBlackTree!ScoreMove;
57
+        uint count = 0;
58
+        foreach (move; possibleMoves)
59
+        {
60
+            count++;
61
+            tmpBoard[move.pos].type = move.type;
62
+            tmpBoard.recalculateAccessibility;
63
+            auto score = nn.classify(tmpBoard);
64
+            tmpBoard[move.pos].type = null;
65
+            auto sm = ScoreMove(move, score * multiplier, uniform(0, 1024, rnd));
66
+            tree.insert(sm);
67
+            if (tree.length > maxConsideredMoves)
68
+            {
69
+                tree.removeFront;
70
+            }
71
+        }
72
+        // We cleared all the new state from the tmpBoard, so we can store it for later learning.
73
+        if (tree.length == 0)
74
+        {
75
+            return Move.doForfeit;
76
+        }
77
+        tracef("nn: picked a move out of %s possibilites", count);
78
+        // Pick a random winner
79
+        if (tree.length == 1) return tree.front.move;
80
+        auto n = uniform(0, tree.length - 1, rnd);
81
+        foreach (i; 0..n) tree.removeFront;
82
+        return tree.front.move;
83
+    }
84
+
85
+    override void finish(Player winner)
86
+    {
87
+    }
88
+
89
+}
90
+
91
+static NN nn;
92
+
93
+struct NN
94
+{
95
+    static NeuralNet n;
96
+    static File learningData;
97
+    float[] buf;
98
+
99
+    static void load(string filename)
100
+    {
101
+        if (filename.exists && filename.isFile)
102
+        {
103
+            n = NeuralNet.deserialize(filename);
104
+        }
105
+    }
106
+
107
+    this(int width, int height)
108
+    {
109
+        buf = new float[width * height];
110
+        if (n is NeuralNet.init)
111
+        {
112
+            n = createNeuralNetwork(width, height);
113
+        }
114
+    }
115
+
116
+    void learn(Board board, int score)
117
+    {
118
+        board.serialize(buf);
119
+        ulong len = buf.length;
120
+        learningData.rawWrite((cast(ubyte*)&len)[0..len.sizeof]);
121
+        learningData.rawWrite((cast(ubyte*)&score)[0..score.sizeof]);
122
+        learningData.rawWrite(buf);
123
+        learningData.flush;
124
+    }
125
+
126
+    float classify(Board board)
127
+    {
128
+        board.serialize(buf);
129
+        auto v = n.predict(buf);
130
+        return v[0];
131
+    }
132
+}
133
+
134
+struct LearningInfo
135
+{
136
+    float label;
137
+    float[] features;
138
+}
139
+
140
+void bakeNeuralNetwork(string nnfile, string datafile)
141
+{
142
+    NeuralNet n;
143
+    try
144
+    {
145
+        infof("reading neural network from file %s", nnfile);
146
+        n = NeuralNet.deserialize(nnfile);
147
+        infof("finished reading neural network");
148
+    }
149
+    catch (Exception e)
150
+    {
151
+        infof("building new network due to error: %s", e.msg);
152
+        n = createNeuralNetwork(21, 11);
153
+    }
154
+
155
+    LearningInfo[] infos;
156
+    import std.file : read;
157
+    auto bytes = cast(ubyte[])datafile.read;
158
+    assert(bytes.ptr !is null, "read nothing from file??");
159
+    while (bytes.length > 12)
160
+    {
161
+        ulong length;
162
+        int score;
163
+        (cast(ubyte*)&length)[0..length.sizeof] = bytes[0..length.sizeof];
164
+        bytes = bytes[length.sizeof .. $];
165
+        (cast(ubyte*)&score)[0..score.sizeof] = bytes[0..score.sizeof];
166
+        bytes = bytes[score.sizeof .. $];
167
+        assert(length < 1000, "length of learning info too long");
168
+        auto features = (cast(float*)bytes.ptr)[0..length * float.sizeof];
169
+        infos ~= LearningInfo(cast(float)score, features);
170
+        bytes = bytes[length * float.sizeof .. $];
171
+    }
172
+
173
+    n.learn(infos, "multinomial", new ADAM(100, 0.0001, 200), true, 4);
174
+    n.serialize(nnfile);
175
+}
176
+
177
+NeuralNet createNeuralNetwork(int width, int height)
178
+{
179
+    return NeuralNet()
180
+        // First stack is inputs
181
+        .stack(DenseData(width * height))
182
+        // This is our first hidden layer
183
+        .stack(Linear(500))
184
+        .stack(DropOut(0.3))
185
+        .stack(Linear(1))  // We want one output
186
+        ;
187
+}
188
+
189
+struct ScoreMove
190
+{
191
+    Move move;
192
+    float score;
193
+    int rnd;
194
+
195
+    int opCmp(const ref ScoreMove other) inout
196
+    {
197
+        if (score < other.score) return -1;
198
+        if (score > other.score) return 1;
199
+        return rnd - other.rnd;
200
+    }
201
+}

+ 201
- 0
source/cosmic/ai/nnsplay.d View File

@@ -0,0 +1,201 @@
1
+/**
2
+  * A neural network that attempts to answer directly which nodes will be in a positive solution.
3
+  */
4
+module cosmic.ai.nn;
5
+/+
6
+
7
+import cosmic.ai.base;
8
+import cosmic.base;
9
+import cosmic.blocks;
10
+import vectorflow;
11
+import std.experimental.logger;
12
+import std.random;
13
+import std.file : exists, isFile;
14
+import std.stdio : File;
15
+
16
+class NeuralSplay : AI
17
+{
18
+    private static Mt19937 rnd;
19
+    static this()
20
+    {
21
+        rnd.seed(unpredictableSeed);
22
+    }
23
+
24
+    uint maxConsideredMoves = 5;
25
+    private int multiplier = 1;
26
+
27
+    override void init(Board board, Player player)
28
+    {
29
+        super.init(board, player);
30
+        if (nn is NN.init)
31
+        {
32
+            nn = NN(board.width, board.height);
33
+        }
34
+        if (player == Player.b)
35
+        {
36
+            multiplier = -1;
37
+        }
38
+    }
39
+
40
+    auto possibleMoves()
41
+    {
42
+        import std.algorithm, std.range;
43
+
44
+        auto emptySpaces = board.grid.data
45
+            .filter!(x => x.type is null)
46
+            .map!(x => x.pos)
47
+            .filter!(p => p.x >= board.startA.x - 1 && p.x <= board.startB.x + 1)
48
+            .filter!(p => p.y >= 2 && p.y <= board.height - 2)
49
+            ;
50
+        return cartesianProduct(emptySpaces, inventory)
51
+            .map!(x => Move(x[0], x[1]));
52
+    }
53
+
54
+    override Move nextMove()
55
+    {
56
+        import std.container.rbtree;
57
+        import std.stdio;
58
+
59
+        auto buf = toNNInput(board);
60
+        auto result = nn.predict(buf);
61
+        // Take the top 5 values...
62
+        auto best = new RedBlackTree!ScoreMove;
63
+        foreach (i, score; result)
64
+        {
65
+            auto move = getMove(i);
66
+            if (!canMakeMove(move))
67
+            {
68
+                continue;
69
+            }
70
+            if (best.length < maxConsideredMoves)
71
+            {
72
+                // We just need something here.
73
+                best.insert(move);
74
+            }
75
+            if (score > best.back.score)
76
+            {
77
+                best.removeBack();
78
+                best.insert(ScoreMove(score, move));
79
+            }
80
+        }
81
+        if (best.length == 0)
82
+        {
83
+            return Move.doForfeit;
84
+        }
85
+        tracef("nn: picked a move out of %s possibilites", count);
86
+        // Pick a random winner
87
+        if (best.length == 1) return best.front.move;
88
+        auto n = uniform(0, best.length - 1, rnd);
89
+        foreach (i; 0..n) best.removeFront;
90
+        return best.front.move;
91
+    }
92
+
93
+    bool canMakeMove(Move move)
94
+    {
95
+        if (move.pos.x < 0 || move.pos.y < 0) return false;
96
+        if (move.pos.x >= board.width || move.pos.y >= board.height) return false;
97
+        if (board[move.pos].type !is null) return false;
98
+        // TODO inventory
99
+        return true;
100
+    }
101
+
102
+    Move getMove(size_t i)
103
+    {
104
+        // The result is the prediction about what is in the winning path.
105
+        // It's a flattened 3d array of floats, |BlockType.all| × width × height.
106
+        // Each row contains height × |BlockType.all| elements.
107
+        auto block = BlockType.all.length * board.height;
108
+        auto x = i / block;
109
+        auto rem = x % block;
110
+        auto y = rem / BlockType.all.length;
111
+        auto type = rem % BlockType.all.length;
112
+        return Move(Pos(x, y), BlockType.all[type]);
113
+    }
114
+
115
+    override void finish(Player winner)
116
+    {
117
+    }
118
+
119
+    static NeuralNet n;
120
+    static string learningFile = "learning_splay.dat";
121
+    static string brainFile = "nn_splay.dat";
122
+
123
+    static NeuralNet load()
124
+    {
125
+        if (n !is null) return n;
126
+        if (filename.exists && filename.isFile)
127
+        {
128
+            n = NeuralNet.deserialize(filename);
129
+        }
130
+        else
131
+        {
132
+            n = NeuralNet()
133
+                // First stack is inputs
134
+                .stack(DenseData(width * height * BlockType.all.length))
135
+                // This is our first hidden layer
136
+                .stack(Linear(500))
137
+                .stack(DropOut(0.3))
138
+                .stack(Linear(width * height * BlockType.all.length))
139
+                ;
140
+        }
141
+    }
142
+}
143
+
144
+static NN nn;
145
+
146
+struct NN
147
+{
148
+    static NeuralNet n;
149
+    static File learningData;
150
+    float[] buf;
151
+
152
+    static void load(string filename)
153
+    {
154
+        if (filename.exists && filename.isFile)
155
+        {
156
+            n = NeuralNet.deserialize(filename);
157
+        }
158
+    }
159
+
160
+    this(int width, int height)
161
+    {
162
+        buf = new float[width * height];
163
+        if (n is NeuralNet.init)
164
+        {
165
+            n = createNeuralNetwork(width, height);
166
+        }
167
+    }
168
+
169
+    static void learn(Board board, int score)
170
+    {
171
+        board.serialize(buf);
172
+        ulong len = buf.length;
173
+        learningData.rawWrite((cast(ubyte*)&len)[0..len.sizeof]);
174
+        learningData.rawWrite((cast(ubyte*)&score)[0..score.sizeof]);
175
+        learningData.rawWrite(buf);
176
+        learningData.flush;
177
+    }
178
+
179
+    float classify(Board board)
180
+    {
181
+        board.serialize(buf);
182
+        auto v = n.predict(buf);
183
+        return v[0];
184
+    }
185
+}
186
+
187
+struct ScoreMove
188
+{
189
+    Move move;
190
+    float score;
191
+    int rnd;
192
+
193
+    int opCmp(const ref ScoreMove other) inout
194
+    {
195
+        if (score < other.score) return -1;
196
+        if (score > other.score) return 1;
197
+        return rnd - other.rnd;
198
+    }
199
+}
200
+
201
++/

+ 2
- 0
source/cosmic/ai/random.d View File

@@ -4,6 +4,7 @@ import cosmic.ai.base;
4 4
 import cosmic.base;
5 5
 import cosmic.blocks;
6 6
 import std.algorithm;
7
+import std.experimental.logger;
7 8
 import std.random;
8 9
 import std.range;
9 10
 
@@ -25,6 +26,7 @@ class Random : AI
25 26
             .walkLength;
26 27
         if (len == 0)
27 28
         {
29
+            tracef("nowhere to go from here; giving up");
28 30
             return Move.doForfeit;
29 31
         }
30 32
 

+ 3
- 3
source/cosmic/ai/simple.d View File

@@ -50,7 +50,7 @@ class Simple : AI
50 50
     {
51 51
         // Find the open position closest to your opponent's position
52 52
         import std.algorithm, std.array;
53
-        infof("close gap for player %s", player);
53
+        //infof("close gap for player %s", player);
54 54
         auto options = board.blocks
55 55
             .filter!(x => (x.access & player) == player)
56 56
             .filter!(x => x.type is null)
@@ -59,7 +59,7 @@ class Simple : AI
59 59
         if (options.length == 0)
60 60
         {
61 61
             // TODO try to use a remove
62
-            info("failed to find a move: no open spaces");
62
+            //info("failed to find a move: no open spaces");
63 63
             return Move.doForfeit;
64 64
         }
65 65
         foreach (option; options)
@@ -81,7 +81,7 @@ class Simple : AI
81 81
                 return m;
82 82
             }
83 83
         }
84
-        info("nothing gets us closer; doing something random");
84
+        //info("nothing gets us closer; doing something random");
85 85
         Move m = {pos: options[0].pos, type: inventory[0]};
86 86
         return m;
87 87
     }

+ 7
- 0
source/cosmic/base.d View File

@@ -49,6 +49,13 @@ struct Grid(T)
49 49
     {
50 50
         data[x + y * width] = value;
51 51
     }
52
+
53
+    typeof(this) dup()
54
+    {
55
+        auto next = this;
56
+        next.data = data.dup;
57
+        return next;
58
+    }
52 59
 }
53 60
 
54 61
 struct Pos

+ 46
- 18
source/cosmic/blocks.d View File

@@ -10,6 +10,7 @@ final class Board
10 10
     Pos startA, startB;
11 11
     Grid!Block grid;
12 12
     alias grid this;
13
+    Player winners;
13 14
 
14 15
     this()
15 16
     {
@@ -19,6 +20,13 @@ final class Board
19 20
     this(int width, int height)
20 21
     {
21 22
         grid = Grid!Block(width, height);
23
+        clear();
24
+        recalculateAccessibility;
25
+    }
26
+
27
+    void clear()
28
+    {
29
+        winners = Player.none;
22 30
         foreach (x; 0..width)
23 31
         {
24 32
             foreach (y; 0..height)
@@ -26,14 +34,29 @@ final class Board
26 34
                 grid[x, y] = Block(null, Pos(x, y));
27 35
             }
28 36
         }
29
-
30 37
         // Starting positions
31 38
         auto halfHeight = height / 2;
32 39
         startA = Pos(halfHeight - 1, halfHeight);
33 40
         startB = Pos(width - halfHeight, halfHeight);
34 41
         grid[startA].type = BlockType.source;
35 42
         grid[startB].type = BlockType.source;
36
-        recalculateAccessibility;
43
+    }
44
+
45
+    Board dup()
46
+    {
47
+        auto d = new Board(grid.width, grid.height);
48
+        d.grid.data[] = grid.data[];
49
+        return d;
50
+    }
51
+
52
+    void serialize(ref float[] buf)
53
+    {
54
+        buf.length = grid.data.length;
55
+        import std.algorithm;
56
+        foreach (i, b; grid.data)
57
+        {
58
+            buf[i] = b.type is null ? 0f : cast(float)b.type.id;
59
+        }
37 60
     }
38 61
 
39 62
     string colorA = "red", colorB = "yellow", colorBoth = "orange", colorBg = "#E3EAEA";
@@ -48,12 +71,14 @@ final class Board
48 71
                 grid[x, y].tick;
49 72
             }
50 73
         }
51
-        /*
52
-        foreach (ref block; blocks)
74
+        if ((this[startA].access & Player.b) == Player.b)
75
+        {
76
+            winners |= Player.b;
77
+        }
78
+        if ((this[startB].access & Player.a) == Player.a)
53 79
         {
54
-            block.tick;
80
+            winners |= Player.a;
55 81
         }
56
-        */
57 82
     }
58 83
 
59 84
     void decayCollisions()
@@ -265,12 +290,14 @@ abstract class BlockType
265 290
     bool canCircle() @property { return circled != this; }
266 291
 
267 292
     string name;
293
+    char id;
268 294
     const Pos[] offsets;
269 295
     alias downstreamOffsets = offsets;
270 296
 
271
-    this(string nmae, const Pos[] offsets)
297
+    this(string name, char id, const Pos[] offsets)
272 298
     {
273 299
         this.name = name;
300
+        this.id = id;
274 301
         this.offsets = offsets;
275 302
     }
276 303
 
@@ -372,7 +399,7 @@ class BlockMine : BlockType
372 399
 {
373 400
     this()
374 401
     {
375
-        super("mine", null);
402
+        super("mine", 'm', null);
376 403
     }
377 404
 
378 405
     override void draw(SvgSink sink, Pos pos)
@@ -388,7 +415,7 @@ class BlockOX : BlockType
388 415
 {
389 416
     this()
390 417
     {
391
-        super("ox", [
418
+        super("ox", 'y', [
392 419
             Pos(-2, -2),
393 420
             Pos(-2,  2),
394 421
             Pos( 2, -2),
@@ -407,7 +434,7 @@ class BlockX : BlockType
407 434
 {
408 435
     this()
409 436
     {
410
-        super("x", [
437
+        super("x", 'x', [
411 438
             Pos(-1, -1),
412 439
             Pos(-1,  1),
413 440
             Pos( 1, -1),
@@ -427,7 +454,7 @@ class BlockPlus : BlockType
427 454
 {
428 455
     this()
429 456
     {
430
-        super("+", [
457
+        super("+", '+', [
431 458
             Pos(-1,  0),
432 459
             Pos( 1,  0),
433 460
             Pos( 0, -1),
@@ -447,7 +474,7 @@ class BlockOPlus : BlockType
447 474
 {
448 475
     this()
449 476
     {
450
-        super("o+", [
477
+        super("o+", '=', [
451 478
             Pos(-2,  0),
452 479
             Pos( 2,  0),
453 480
             Pos( 0, -2),
@@ -466,7 +493,7 @@ class BlockStar : BlockType
466 493
 {
467 494
     this()
468 495
     {
469
-        super("*", [
496
+        super("*", '*', [
470 497
             Pos(-1,  0),
471 498
             Pos( 1,  0),
472 499
             Pos( 0, -1),
@@ -491,7 +518,7 @@ class BlockOStar : BlockType
491 518
 {
492 519
     this()
493 520
     {
494
-        super("*", [
521
+        super("*", '/', [
495 522
             Pos(-2,  0),
496 523
             Pos( 2,  0),
497 524
             Pos( 0, -2),
@@ -516,7 +543,8 @@ class Arrow : BlockType
516 543
 
517 544
     this(string name, Pos toward)
518 545
     {
519
-        super(name, [toward]);
546
+        char id = cast(char)('A' + ((toward.x + 1) * 3 + toward.y + 1));
547
+        super(name, id, [toward]);
520 548
         this.toward = toward;
521 549
     }
522 550
 
@@ -553,7 +581,7 @@ class Collision : BlockType
553 581
     BlockType next;
554 582
     this(uint stage, BlockType next = null)
555 583
     {
556
-        super("collision" ~ stage.to!string, []);
584
+        super("collision" ~ stage.to!string, cast(char)('0' + stage), []);
557 585
         this.stage = stage;
558 586
         this.next = next;
559 587
         if (stage == 1)
@@ -568,7 +596,7 @@ class Collision : BlockType
568 596
 
569 597
     override void tick(ref Block block)
570 598
     {
571
-        infof("Collision tick: %s -> %s", this, next);
599
+        //infof("Collision tick: %s -> %s", this, next);
572 600
         block.type = next;
573 601
         block.placed = Player.none;
574 602
         block.turn = 0;
@@ -598,7 +626,7 @@ class BlockSource : BlockType
598 626
 {
599 627
     this()
600 628
     {
601
-        super("source", [
629
+        super("source", 'o', [
602 630
             Pos(-1,  0),
603 631
             Pos( 1,  0),
604 632
             Pos( 0, -1),