JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Shunting Actions

May 15, 2025 • [designluaobjectstesting]


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.

Concerns
While the locomotive is at a siding, there can be more cars present than will fit in the siding: we could pull three cars from the 5-siding and move to the 3-siding which already had 3 cars on it. We can do that … but we must then pull at least 3 cars away.

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:

  1. Produce a list of the combined cars of loco and siding;
  2. Partition the list into the loco’s share and the siding’s share;
  3. Possibly check for validity here;
  4. Give the newly partitioned shares to the loco and siding.

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.

Fini

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.

Small Issue
I just noticed that I am not consistent in my use of 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!