I wrote a lot about what I want to get in the end. He told how it can be used, but left one simple question unanswered. Why am I convinced that all this (well, okay, almost all of this) works? I have a secret weapon! And today I want to talk about it. The project that I am writing is complicated. I am developing a universal model, potentially suitable for describing any board games. There is nothing to think about developing such a project from scratch, launching it and checking whether it works. Moreover, there is nothing to launch yet. There is neither a controller, nor any sort of a stray view, under which this model could be launched. But I have to check and debug the written code right now! Then, when the controller and the presentation appear, it will be simply impossible to debug it all, in its entirety! ')
I am not the first to face such a problem, and the method of its solution has long been invented . To test my code, I use QUnit , but of course, this is not the only such solution in the JavaScript world. I do not adhere to the TDD methodology, in the sense that I do not try to preface writing code with tests, but I try to cover the entire model code with tests as much as possible. This helps me to solve the following tasks:
Finding and fixing stupid bugs and typos in the code
Check compatibility of solutions used with various platforms (browsers)
Regression testing (changing something, I have to be sure that I didn't break anything)
Minimum documentation (tests fix ways to use model interfaces)
The approach has already managed to justify itself
At the very beginning of development, when with JavaScript I was still very “on you”, I took Jocly code as a basis . Now, I have to get rid of a lot of what was written then, but at that time, I had to start somewhere. I well (as time showed, not well enough) understood the task, but knew the language very poorly. Here is one of the examples of the code of those times:
Search for an element in an array
if ([].indexOf) { Model.find = function(array, value) { return array.indexOf(value); } } else { Model.find = function(array, value) { for (var i = 0; i < array.length; i++) { if (array[i] === value) return i; } return-1; } }
Yes, premature optimization. If the arrays support " indexOf ", use it, otherwise, we search manually, in a loop. Since from the very beginning I built the model in such a way as to work only with numerical values, after some time, I decided to optimize something else:
Arrays of integer values
if (typeofInt32Array !== "undefined") { Model.int32Array = function(array) { var a = newInt32Array(array.length); a.set(array); return a; } } else { Model.int32Array = function(array) { return array; } }
The logic is the same. Those who can - use numeric arrays, the rest use what they can. For a while, it all worked perfectly. On those browsers that I used. But at one point, I launched my tests on IE11. And the creation of Microsoft, and not slow to strike. Tests did not work. Everything resulted in this fix. I don’t want to say that this code is much better (now it has already been rewritten), but if I didn’t run the tests regularly and on different platforms, I wouldn’t have known about the problem! Unit tests really work.
As I develop tests, I move from simple code to more complex code. Before checking the complex logic of the course generation (this is the main thing from what the model does), I must make sure that all the parts used by it are working correctly. All classes used in my model can be ranked by increasing “complexity”:
ZrfPiece - description of the figure (object on the board)
ZrfDesign - description of the board topology and game rules
ZrfMove - description of the course that changes the state of the game
ZrfMoveGenerator - a generator of possible moves "by pattern"
ZrfBoard - game state storage and generator of all allowable moves
The ZrfPiece class is so simple that testing it does not even require a full-fledged game design . However, it has some not obvious features that need to be verified. For example, the logic of creating a new object when changing the type, the owner or some of the attributes of the shape.
All this is elementary checked.
QUnit.test( "Piece", function( assert ) { var design = Model.Game.getDesign(); design.addPlayer("White", []); design.addPlayer("Black", []); design.addPiece("Man", 0); design.addPiece("King", 1); var man = Model.Game.createPiece(0, 1); assert.equal( man.toString(), "White Man", "White Man"); var king = man.promote(1); assert.ok( king !== man, "Promoted Man"); assert.equal( king.toString(), "White King", "White King"); assert.equal( man.getValue(0), null, "Non existent value"); var piece = man.setValue(0, true); assert.ok( piece !== man, "Non mutable pieces"); assert.ok( piece.getValue(0) === true, "Existent value"); piece = piece.setValue(0, false); assert.ok( piece.getValue(0) === false, "Reset value"); var p = piece.setValue(0, false); assert.equal( piece, p, "Value not changed"); Model.Game.design = undefined; });
We manually create the most minimal “design” (two players, two types of figures and no hint of a board) and manually perform all the checks we are interested in. After that, calmly use the ZrfPiece , not expecting any tricks from him. Even if it later turns out that you forgot to check something, just add a few more checks. Next we test more complex code:
ZrfDesign is 99% navigation on the game board. Her and check. We create the design again manually (now with a small board), after which we run the most typical test cases. And do not forget, at the end, clear the created design! So he did not break the other tests.
By the way, right now it turned out
I was very wrong when I considered the design of the game singleton ! Not to mention the server version, which simply needs to be able to work with several different models of games at the same time, there is another interesting case. While working on the simplest game bots , I remembered a wonderful game .
Mines are scattered across the field, but how to lure the enemy on them, provided that he knows about them? After all, there is absolutely no reason for him to simply lose a piece by standing on a “mined” field. The problem is solved simply. The bot with which we play can get a slightly different game design. The board, the rules of the shapes - everything will be the same, with one small exception. He will know nothing about the mines.
In fact, this is the only adequate way to implement games with incomplete information, such as Kriegspiel or Luzhanqi , of course, if we don’t want the computer to see all our figures, and we don’t have it. Anyway, I'm working on it now. Unit tests help me again with this! When performing such extensive refactoring, it is vital to know that nothing fell apart!
Further, the tests are becoming more and more high-level. Generation of a separate move according to a pattern by the ZrfMoveGenerator class, applying a turn to the game state and, finally, generating a set of moves by a certain position:
Fight of several figures by the woman
QUnit.test( "King's capturing chain", function( assert ) { Model.Game.InitGame(); var design = Model.Game.getDesign(); var board = Model.Game.getInitBoard(); board.clear(); assert.equal( board.moves.length, 0, "No board moves"); design.setup("White", "King", Model.Game.stringToPos("d4")); design.setup("Black", "Man", Model.Game.stringToPos("c4")); design.setup("Black", "Man", Model.Game.stringToPos("a6")); design.setup("Black", "Man", Model.Game.stringToPos("f8")); board.generate(); assert.equal( board.moves.length, 2, "2 moves generated"); assert.equal( board.moves[0].toString(), "d4 - a4 - a8 - g8 x c4 x a6 x f8", "d4 - a4 - a8 - g8 x c4 x a6 x f8"); assert.equal( board.moves[1].toString(), "d4 - a4 - a8 - h8 x c4 x a6 x f8", "d4 - a4 - a8 - h8 x c4 x a6 x f8"); Model.Game.design = undefined; Model.Game.board = undefined; });
With all the brevity of the test, it is almost a full game! In any case, one move from it. Here both the composite moves and the move by the “sliding” figure and the capture priority and even the majority rule , implemented as a game extension and prescribing the capture of the maximum possible number of enemy pieces, are tested! This small test covers almost all the functionality of the model. And when something breaks, we see it, and immediately correct it .
One more thing unit-tests help is refactoring! At some point, we decided that Underscorewould be used in the project. This wonderful library helps to write code in a functional style , making it more concise and maintainable. To make it clearer, I will give one example of the “life” of the project.
Functional programming is the more useful the more complex the task. If the code is very simple, rewriting it in a functional style will do little. But if the task is a bit more complicated, the advantages of the functional approach become more obvious.
If moving a stone creates a new row, the player gets the right to capture any of the opponent’s stones.
When a player is left with just three stones, his stones turn into “flying”. These stones can move (“fly”) not only to one of the neighboring ones, but generally to any free cell on the board.
I marked the key word. What does "a player can capture any opponent's stone"? If the enemy has N pieces on the board, then exactly the same number of times we are obliged to duplicate each turn, leading to the construction of a “row”. These moves will differ only in the figure taken! In Zillions of Games, this is exactly what is being done. And this incredibly complicates the implementation of the game! But there is still the rule of "flying" stones ...
There is another solution. We can form only one move, listing in it all the positions of the potential take. Of course, this does not mean that we will take all the stones, not at all! Only one of these will be taken. The move will become non-deterministic. The same with the movements. If a “flying” stone can build a “row”, it turns out the Cartesian product of all effective moves to the many positions occupied by enemy figures.
I came up with a good way by which the user interface can work with such moves, but for AI bots it is not applicable! AI should receive strictly determined moves on an input! This means that there must be a mechanism that turns non-deterministic moves into deterministic moves.
Here is the first version of what I once wrote
var getIx = function(x, ix, mx) { if (ix > x.length) { x = []; returnnull; } if (ix == x.length) { c.push(0); return0; } var r = x[ix]; if (r >= mx) { if (ix + 1 >= x.length) { x = []; returnnull; } for (var i = 0; i <= ix; i++) { x[ix] = 0; } x[ix + 1]++; } return r; } ZrfMove.prototype.determinate = function() { var r = []; for (var x = [0]; x.length > 0; x[0]++) { var m = Model.Game.createMove(); var ix = 0; for (var i inthis.actions) { var k = 0; var fp = this.actions[i][0]; if (fp !== null) { k = getIx(x, ix++, fp.length); if (k === null) { break; } fp = [ fp[k] ]; } var tp = this.actions[i][1]; if (tp !== null) { k = getIx(x, ix++, tp.length); if (k === null) { break; } tp = [ tp[k] ]; } var pc = this.actions[i][2]; if (pc !== null) { k = getIx(x, ix++, pc.length); if (k === null) { break; } pc = [ pc[k] ]; } var pn = this.actions[i][3]; m.actions.push([fp, tp, pc, pn]); } r.push(m); } return r; }
60 lines of completely incomprehensible and absolutely unsupported code! Most likely, it does not even work! I never tested it.
Instead, I rewrote it.
ZrfMove.prototype.getControlList = function() { return _.chain(this.actions) .map(function (action) { return _.chain(_.range(3)) .map(function (ix) { if (action[ix] === null) { return0; } else { return action[ix].length; } }) .filter(function (n) { return n > 1; }) .value(); }) .flatten() .map(function (n) { return _.range(n); }) .cartesian() .value(); } ZrfMove.prototype.determinate = function() { var c = this.getControlList(); if (c.length > 1) { return _.chain(c) .map(function (l) { var r = new ZrfMove(); var pos = 0; _.each(this.actions, function (action) { var x = []; _.each(_.range(3), function (ix) { pos = pushItem(this, action[ix], l, pos); }, x); x.push(action[3]); if (isValidAction(x)) { this.actions.push(x); } }, r); return r; }, this) .filter(isValidMove) .value(); } else { return [ this ]; } }
The code is longer and, at first glance, does not look more understandable. But let's take a closer look at it. For a start, let's try to understand the problem. The course description ( ZrfMove ) consists of a set of actions ( actions ), each of which is a tuple of four elements:
Starting Position ( from )
End position ( to )
Figure
Partial stroke number ( num )
Since the transformation of the figures in the "Mill" is absent and the composite moves are not used, only the first two of these values are important for us. They are enough to describe any action performed:
Adding a piece to the board (reset) - from == null && to! = Null
Deleting a shape (capture) - from! = Null && to == null
Moving the shape - from! = Null && to! = Null && from! = To
But this is only half the battle! In fact, both from and to (and even piece , but it's not about it) are also arrays! If the move is deterministic, each of these arrays contains exactly one element. The presence of more values in any of them means the possibility of choice (which we must deal with).
Non-deterministic move
var m = [ [ [0], [1, 2] ], [ [3, 4, 5], null ] ]; //
There is a movement of the figure from position 0 to any of the two positions ( 1 or 2 ) and the capture of one opponent's figure from positions 3 , 4 or 5 . To begin with, you can choose the size of all “non-deterministic” positions (containing more than one element):
The rest of the task is a little more complicated. It is necessary to choose “non-determined” positions from the initial variant of the move, in accordance with the existing cheat sheet. I will not bother with this reader, the problem is purely technical. The most important thing is that the use of the functional approach allowed us to divide the rather complex task into parts that can be solved and debugged separately.
Of course, the use of the functional approach is not always associated with solving such puzzles. Usually, everything is somewhat simpler. As a typical example, I can cite the maximal-captures module, which implements an option inherited from Zillions of Games, which takes the maximum number of pieces in games of the checkers family.
It was
Model.Game.PostActions = function(board) { PostActions(board); if (mode !== 0) { var moves = []; var mx = 0; var mk = 0; for (var i in board.moves) { var vl = 0; var kv = 0; for (var j in board.moves[i].actions) { var fp = board.moves[i].actions[j][0]; var tp = board.moves[i].actions[j][1]; if (tp === null) { var piece = board.getPiece(fp[0]); if (piece !== null) { if (piece.type > 0) { kv++; } vl++; } } } if (vl > mx) { mx = vl; } if (kv > mk) { mk = kv; } } for (var i in board.moves) { var vl = 0; var kv = 0; for (var j in board.moves[i].actions) { var fp = board.moves[i].actions[j][0]; var tp = board.moves[i].actions[j][1]; if (tp === null) { var piece = board.getPiece(fp[0]); if (piece !== null) { if (piece.type > 0) { kv++; } vl++; } } } if ((mode === 2) && (mk > 0)) { if (kv == mk) { moves.push(board.moves[i]); } } else { if (vl == mx) { moves.push(board.moves[i]); } } } board.moves = moves; } }
Both options work well (at the time of refactoring, the code was already covered with tests), but the functional version is shorter, easier to understand and assembled from unified blocks. To support him, of course, much easier.
In conclusion of the article, I want to voice a few principles by which I try to be guided in my work. I, in any case, do not want to make a dogma of them, but they help me.
Not a day without a line
Work on the project should not be interrupted! In any case, for some long time. The longer the break, the more difficult it is to return to work. Work takes much less time and effort if you do it every day. At least a little! This does not mean that it is necessary to “squeeze” the code “through I can’t” out of myself (it’s not long to burn out). If the project is complex and interesting, you can always find a job that suits your mood.
Morning code, evening tests
Yes, yes, I know, this goes in full section with the TDD methodology. But who said that I stick to it? Unit-test- s (very) are useful, even if you do not put them at the forefront! Any code, both simple and complex, should be covered with tests as far as possible! At the same time, it is desirable to move from simple to complex, testing more complex functionality after there is no doubt about the performance of the one on which it is built. Do not delete tests until they have lost their relevance! On the contrary, we must try to run them as often as possible, in various environments. I found some serious and very non-trivial errors in this way!
Do no harm
Any changes should not break the code already covered with tests! No need to commit code with broken tests. Even if you dealt with this code all day! Even if very tired to deal with the problem "here and now"! Not working code is garbage. This is a bomb that can explode at any time! If you do not have the strength to deal with it, it is better to completely remove it, then to rewrite it again.
Never give up
Do not be afraid to rewrite the code again and again! A small refactoring, or even a complete rewriting of the project from scratch - this is not a reason for panic! This is an opportunity to solve the problem better.
If you can't win honestly - just win.
Good code solves the problem. Good code is understandable and accompany. That's all! No need to turn inside out just to “fit” it to the ideology of the PLO , OP or something else! Using some possibilities of the language or its environment, one should not think about fashion, but only about the usefulness of these “features” for the project. Behind the fashion, you still can’t keep up!
Of course, I still have room to grow. I do not see this problem. My understanding of the language is changing (I hope for the better), and with it the code is also changing. And unit tests just help me with that.