Yesterday we coded up a somewhat sensibly-structured “plan” for swapping the two nearest cars. Today we write some ugly code, with an excellent result.
Here’s our code from yesterday that produces a text plan:
function Locomotive:plan_one_step()
return self:plan_inversion_at_1()
end
function Locomotive:plan_inversion_at_1()
local get_larger = "m1 p1 home"
local cache_it = "m2 p0 home"
local get_smaller = "m1 p1 home"
local attach_larger = "m2 p2 home"
local put_away = "m1 p0 home"
return self:assemble(get_larger, cache_it, get_smaller, attach_larger, put_away)
end
function Locomotive:assemble(...)
return table.concat({...}, ' ')
end
This is still just a sketch: I’ll feel free to change it as seems right, but it is a somewhat sensible sequence of code that identifies separate small steps that, if they’re right, will swap the two end items of the cars in the 5-siding.
But would this sequence work? We don’t really know, we’re just creating strings. What we need is for our plan to consist, not of strings, but of operations that actually manipulate the locomotive and sidings to move things around.
There are at least two ways we could do this. One would be to take the strings that we have, parse them out and then do some kind of big if scheme to do whatever we deem “m1” to mean, and so on.
Another approach would be to have the real plan consist of a list of operations that can be executed directly, and that happen to display themselves as “m1” and such when we ask them to. That list would be convenient for the code to execute, and convenient for our tests to examine, since the string sequence is really easy to predict and compare.
The second way is the way I plan to try.
So far, we seem to have only two real operations: move, represented by ‘m’ for move and an integer representing the place to move to, 1 for the 5-siding, 2 for the first 3-siding, 3 for the third. And home
is really just a move as well, to the siding where the locomotive lives. I’m not sure if we’ll treat that as #4 or what, but it’s basically just a move.
And we have ‘p’, which considers all the cars at the current place, and takes an integer number of them from the place and attaches them to the train. My convention is that when we get to 1, 2, or 3, all the cars are considered to be part of that location, so the train always needs to pull at least one unless it wants to pull away empty.
I am guessing that we should have an Operation object, which is a single operation, either a move or a pull (or maybe “home”, depending on what we decide later.)
But first, we’ll put some functions into Locomotive that manipulate the sidings’ and locomotive’s cars lists. Naturally we’ll test these functions.
This small step makes sense in the larger scheme because our Operations, if and when we create them, will surely call methods like these on the Locomotive, to make things happen.
function Tests:test_pull_2()
siding5 = Siding(5, "21345")
siding31 = Siding(3)
siding32 = Siding(3)
loco = Locomotive(siding5, siding31, siding32)
loco:move(1)
loco:pull(2)
assert_equals(loco:siding_car_string(1), "345")
assert_equals(loco:car_string(), "21")
end
From the starting position we’ll move to 1, pull 2 and therefore leave the last three cars in 1 and have the first two, in order, attached to the locomotive.
We need two new methods on Locomotive to serve our test: move
and pull
. I think that from an internal viewpoint, what move
should do is set the locomotive to remember that it is at the provided position, and then let the pull
do the work.
A related concern is that if the train has more than 3 cars on it, they overlap the switches and it can’t move to another location. (The way the Inglenook online game is set up, it may be possible to switch between 2 and 3 in that state: I’d have to try it to be sure.) Anyway, point is there are constraints to be dealt with. We will of course try to remember all those and write tests for them.
Anyway we need move
and pull
. I stub them to get the test to run:
function Locomotive:move(siding_number)
end
function Locomotive:pull(car_count)
end
That’s enough to show me that I forgot to put self:
in front of the asserts. That’s a change from yesterday: the assertions are now methods on Tests, not global functions. I think the framework is better that way, as it doesn’t create any global functions but I do have to get used to it.
With that fixed, I get the results I expect: the assertions fail:
[05:48] Inglenook 20250515: test_pull_2: 21345 was expected to be 345 ()
[05:48] Inglenook 20250515: test_pull_2: was expected to be 21 ()
[05:48] Inglenook 20250515: Tests: correct 15, wrong 2
I see an extra space in the message and I’m not sure that I love the empty parens when we have no special message on the assertion. We’ll try to remember to deal with that later: I don’t want to distract myself from the main mission, getting the tests all running.
For the move, we’ll just remember where we’re at:
function Locomotive:move(siding_number)
self.at = siding_number
end
And for the pull … must think a bit … this took longer than I’d like:
function Locomotive:pull(car_count)
local siding = self:get_siding(self.at)
combined_cars = {}
for i, car in self.cars do
table.insert(combined_cars, car)
end
for i, car in siding.cars do
table.insert(combined_cars, car)
end
self.cars = {}
for i = 1, car_count do
table.insert(self.cars, combined_cars[i])
table.remove(siding.cars, 1)
end
end
The code above is far from bullet-proof. If anything, it’s bullet-prone. What does it do? Well, it creates a list of all the cars involved, the loco’s cars first and then the siding’s. Then it clears the loco’s cars and moves as many cars as the pull calls for into the loco, …
And then it does the wrong thing, removing cars from the siding. It happens to work because we have no cars in the siding.
Let me toss that method and do it another way. I’ll make the test harder:
function Tests:test_pull_2()
siding5 = Siding(5, "21345")
siding31 = Siding(3)
siding32 = Siding(3)
loco = Locomotive(siding5, siding31, siding32)
loco:move(1)
loco:pull(3)
self:assert_equals(loco:siding_car_string(1), "45")
self:assert_equals(loco:car_string(), "213")
end
Now I think my test fails with the existing code. It doesn’t. Here’s one that should:
function Tests:test_two_pulls()
siding5 = Siding(5, "21345")
siding31 = Siding(3)
siding32 = Siding(3)
loco = Locomotive(siding5, siding31, siding32)
loco:move(1)
loco:pull(2)
loco:move(1)
loco:pull(3)
self:assert_equals(loco:siding_car_string(1), "45")
self:assert_equals(loco:car_string(), "213")
end
When we aree at a siding, we want to treat the loco’s current cars and the siding’s current cars as a single collection, which we divide between the two. The test above removes too many, with the result being an empty string:
[06:28] Inglenook 20250515: test_two_pulls: was expected to be 45 ()
Another note for the testing thing: it would be nice to show delimiters for strings.
OK another try at pull
. I’m not proud of this but it is passing all the tests:
function Locomotive:pull(car_count)
local siding = self:get_siding(self.at)
combined_cars = {}
for i, car in self.cars do
table.insert(combined_cars, car)
end
for i, car in siding.cars do
table.insert(combined_cars, car)
end
self.cars = {}
for i = 1, car_count do
table.insert(self.cars, combined_cars[1])
table.remove(combined_cars, 1)
end
siding.cars = {}
for i, car in combined_cars do
table.insert(siding.cars, car)
end
end
Looking at this code tells me what it might want to be, something like this:
But that is not for this session. I am tired and want caffeine and food. Let’s see where we are and safe this article before I break something important.
I think that the move
and pull
methods actually work correctly if used correctly. They definitely can be misused, for example to put more cars into a 3-siding than would fit, so they need some checking. But used legally, they work.
The pull
method is truly awful. I really want Python-style table functions, and one thing might be to write them. One way to do that might be to provide a new object, a car collection (is that a train?) that understands concatenation and splitting.
But, I think that used correctly, it works.
Does that mean that we could actually reverse the first two items?
I can’t resist writing that test.
function Tests:test_reverse_two_cars()
siding5 = Siding(5, "21345")
siding31 = Siding(3)
siding32 = Siding(3)
loco = Locomotive(siding5, siding31, siding32)
loco:move(1)
loco:pull(1)
loco:move(2)
loco:pull(0)
loco:move(1)
loco: pull(1)
loco:move(2)
loco:pull(2)
loco:move(1)
loco:pull(0)
self:assert_equals(loco:siding_car_string(1), "12345", "sorted!")
self:assert_equals(loco:car_string(), "", "loco", "empty")
end
That’s a lot of moving and pulling but it works! So my ugly method does seem to produce the right result.
In principle, I think we have what we need to solve any puzzle that has room in the sidings, and that starts with all the cars in the 5-siding.
local
. Python habits bleeding over, since Python defaults to the current scope. That and the colons is going to give me headaches for a while. Fixed the ones I noticed.This is actually very good progress. And at least all the ugly is in one place. That give us a chance to improve it relatively easily. Far better than having the ugly spread all over.
But that is for next time. Until then, safe paths!