JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Shunt Something

May 14, 2025 • [designluaobjectsshunting]


I think I’m ready to actually code up some shunting. Lots of design thinking here, and a tiny bit of code.

Tool-wise, I have two small frameworks, one for creating classes and one for testing, plus three classes, Locomotive, Siding, and Car, and a suite of tests called Tests. In a real IDE, I would have each of those things in a separate tab, and would not even have the class creation or testing framework open at all unless I was modifying them. In Sublime Text, all of that is in one file, and it’s 275 lines long, far too long to be scrolling around all the time. I’d like to have some automated way of merging things back together before compiling. A Sublime plugin could probably do it, but now is not the time.

Preview

Here, just in case you want to read the end of the mystery first, are today’s new test and the code that makes it pass.

Test:

function Tests:test_first_inversion()
   siding5 = Siding(5, "21345")
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   plan = loco:plan_one_step()
   assert_equals(plan, "m1 p1 home m2 p0 home m1 p1 home m2 p2 home m1 p0 home")
end

Code:

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

It took some thinking and explaining to get there. Here are the details:

Details

Now is the time for more shunting. I think I’m ready to do actual shunting (in the model, not in visible moves of prims, not yet). Here’s my plan, roughly:

  1. Devise plans for swapping cars in positions 1:2, 2:3, 3:4, and 4:5;
  2. With five numbered cars in the 5-siding, loop over the cars, swapping inversions as needed;
  3. Later, probably not today, devise sequences for moving the unnumbered blocking cars out of the 5-siding into the 3-sidings in a standard form.

With #1 and #2 in place, we can solve any sequence of cars with no blockers (and an assumed structure of blocks in the 3-sidings). With #3, we can solve any Inglenook problem.

Here’s what I’ve figured out. Suppose we have this structure in the sidings:

21345
 xx
  x

The way we’ll solve this is to pull the 2 over to the second 3-siding and leave it, then go back and pick up the 1, then back to pick up the 2 and take them both back to the 5-siding.

With this problem:

13245
 xx
  x

The solution is almost the same: Pull (the 1 and) the 3 over to the second 3-siding and drop it off, then go back and pick up the 2 (train is now 12), then back to pick up the 3 (now 123) and take them back to the 5-siding.

So when the inversion is in one of the first two positions, we pull out the high value and cache it in a 3-siding, then go back for the low value, then back to the 3-siding, pick up the high, take everything back to 5-siding.

What about inversions in the third or fourth position? That’s where we do the famous mathematical trick, reducing the problem to one previously solved. We pull two cars out of the 5-siding and park them in the third 3-siding, which has room for them. Now the inversion is in either the first position or the second position in the 5-siding. We solve it, then go back to the 3-siding for the first two cars and put them in the 5-siding. It goes like this:

12354
 xx
  x

Move two:

354
 xx
12x

Now we know how to solve this, it’s the same as 132. Move the 5 to the second 3-siding …

34
5xx
12x

Grab the 4, pick up the 5, move both to 5-siding …

345
 xx
12x

Go back to the third 3-siding for the 12

12345
 xx
  x

Solved!

OK, that’s the good news. But the devil, they say, is in the details. Let’s see what our overall algorithm is. It must be something like this:

  1. Arrange the 3-sidings to have two unnumbered cars in the first and one in the second. Details to be worked out.
  2. While there are inversions …
  3. If there are no inversions in the first two positions, move two cars to the second 3-siding.
  4. Pull the first element of the inversion, the high value, over to the first 3-siding.
  5. Pull the second element of the inversion, the low value, over to the second 3-siding, pick up the high value, and move them both back to the 5-siding.
  6. If there are two cars in the second 3-siding, bring them back to the 5-siding.
  7. … Repeat

Now, as written, there are details left out, like stopping as soon as there are no inversions, and it seems that it would be wise not to do step 5 until there are no inversions in the last three, although it will be amusing to see it thrashing.

Idea: If we had the plan written out in some form, we might see something like:

move 2 cars from second 3-siding to 5-siding
move 2 cars from 5-siding to second 3-siding

A clever program might see those operations and cancel them out. There might be other such cases. We’ll try to keep that in mind … but I think the best plan is to make it work, make the code decent, and then see about making it more efficient. Writing clever code all in one go is risky and leads to more debugging than I like.

OK, that’s more planning than I’d usually do, but I wanted you to understand where we might be going. I say “might” because if we learn something along the way, we’ll adapt our plans.

Let’s take a look at our tests now and see if we need to normalize things to make progress easier. Often it’s best to smooth the ground before we start building new things, and I think today is one of those days. Remember, for example, we have that odd thing with the look up of the siding by index:

function Locomotive:take(siding_number, count)
   local siding = self:get_siding(siding_number)
   local taken = siding:pull(count)
   self:append(taken)
end

function Locomotive:get_siding(siding_number)
   local ss = {self.s5, self.s31, self.s32}
   return ss[siding_number]
end

Let’s put the sidings directly into an array. That may break a test. I could look but when it breaks, I’ll look then.

Cleaning Up Tests (Skip if you wish)

Change this:

local Locomotive = class()
function Locomotive:init(s5, s31, s32)
   self.s5 = s5
   self.s31 = s31
   self.s32 = s32
   self.limit = 3
   self.cars = {}
end

To this:

local Locomotive = class()
function Locomotive:init(s5, s31, s32)
   self.sidings = {s5, s31, s32}
   self.limit = 3
   self.cars = {}
end

We also have this:

function Locomotive:add(siding)
   if siding.length == 5 then
      self.s5 = siding
   elseif self.s31 == nil then
      self.s31 = siding
   else
      self.s32 = siding
   end
end

Let’s just see who’s using this and make them stop. This test gets revised. Remove the add, see if we stay good.

function Tests:test_add_siding()
   siding5 = Siding(5)
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   assert_equals(loco.s5, siding5, "siding 5")
   assert_equals(loco.s31, siding31, "siding 31")
   assert_equals(loco.s32, siding32, "siding 32")
end

I’m not terribly surprised to have something break. I am used to modifying code and letting the tests tell me where changes are needed.

Inglenook 20250514 [script:inglenook_20250514] Script run-time error
runtime error
lua_script:107: attempt to index nil with 'pull'
lua_script:107 function take
lua_script:255 function test_take
lua_script:179 function run_tests
lua_script:140 function state_entry
lua_script:261

LOL, I forgot to fix this:

function Locomotive:get_siding(siding_number)
   local ss = {self.s5, self.s31, self.s32}
   return ss[siding_number]
end
function Locomotive:get_siding(siding_number)
   return self.sidings[siding_number]
end

Three assertions fail, these three:

function Tests:test_add_siding()
   siding5 = Siding(5)
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   assert_equals(loco.s5, siding5, "siding 5")
   assert_equals(loco.s31, siding31, "siding 31")
   assert_equals(loco.s32, siding32, "siding 32")
end

That’s really useless now, we don’t even have s5 and all that. Remove that test. All the rest run.

Final Decisions for Next Small Step

Now we really have to figure out a next step, the next test to write. Let’s think a bit about our “algorithm”. We’ll assume we’re at the state where there is an inversion in the first three cars. There are two cases, either the first pair has an inversion or it does not and the second pair does. (Both are possible: 432 would do it, but the first has priority, because why not?)

(There is probably a clever way to convert 432 to 234 without all the work we’ll do with the current scheme but my mission is to get to some solution and then make it better.)

The actual operation is different between 243 and 432. To fix the 432 case we move the 4 off, drop it, then drag the three to it, but in the 243 case we drag the 2 and 4 off, drop the 4 off, then drag 23 over to the 4, pick up the 4, and put all three back.

I would like to find a way to express those two cases the same way, so that it just takes a deeper bite and carries on.

That’s too much. I could probably figure it out but let’s do the simple case and then improve it. Our current scheme seems to be to produce a textual plan that we can test, which we will then execute. Let’s do that with this new case, and invent a bit more of our plan language as needed.

The New Test … Almost

function Tests:test_first_inversion()
   siding5 = Siding(5, "21345")
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   plan = loco.plan_one_step()
   assert_equals(plan, "m1 p1 m2 p0 m1 p1 m2 p2 m1 p0 mh")
end

Let me expand on that a bit. I’m assuming that when we move to a siding, we push in far enough that we are connected to the whole train that’s in there. So we specify the total number of cars we want to pull out. So “p0” means to leave everything there and come away with no cars. I think we can avoid having a drop command at all, we’ll just specify the number of cars we want in the train at that point.

Which will lead to an interesting end condition: when we think we’re done the plan will include p5 and we can check at that point to see if the train is valid.

I think we should specify the moves to home. We have to go home to move from siding 1 to siding 2 and so on. Expand the test:

The New Test … Better

function Tests:test_first_inversion()
   siding5 = Siding(5, "21345")
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   plan = loco.plan_one_step()
   assert_equals(plan, "m1 p1 mh m2 p0 mh m1 p1 mh m2 p2 mh m1 p0 mh")
end

I don’t like the mh not standing out. Let’s have it compile “home” instead of “mh”.

The New Test … Ready!

function Tests:test_first_inversion()
   siding5 = Siding(5, "21345")
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   plan = loco.plan_one_step()
   assert_equals(plan, "m1 p1 home m2 p0 home m1 p1 home m2 p2 home m1 p0 home")
end

Now I’m going to use “fake it till you make it here”, at least at first, but I’ll try to put a little structure into the implementation even so.

The New Code (Finally!)

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

I’m figuring there will be other plan methods, but for now there’s just one. Before we’re done, we’ll be checking and selecting with plan to run. The plan inversion method breaks down the operation into separate steps with possibly reasonable names and the steps to do that thing. And then I had to write assemble because otherwise I would have to space or not space.

However. I don’t think this will last long. It’s certainly possible to do our execute command by parsing this string. But I think it’ll be better to build a set of fundamental operations that do things, build an array of those, and have them have a display format that produces our test string. Why? Because the string is for my personal convenience and an array of operations to do will be better for the actual program. So we’ll keep those ideas separate.

This plan_inversion_at_1 is still somewhat fake but I think we can see how it is becoming less so, as we have identified some specific operation steps that the locomotive could actually perform.

There is another fake test, left over from a day or so ago, this one:

function Tests:test_pull_one_car()
   siding5 = Siding(5, "21345")
   siding31 = Siding(3)
   siding32 = Siding(3)
   loco = Locomotive(siding5, siding31, siding32)
   plan = loco:fetch(2, 1)
   assert_equals(plan, "m2 p1 mh", "check plan")
end

I think the fetch notion has served its purpose, getting me off the dime to produce an operation string. But we don’t need it any more: our new test is better, leads further toward real code. Remove this test and the fetch method.

Wrapping Up

I think we’re done for the morning. Lots of words to explain my thinking, plus a small bit of progress toward a well-structured program. This may seem slow to you, but most of the slowness is that I’m explaining my every thought. The actual coding this morning was perhaps 15 minutes, counting looking for all the places where I wrote . and should have written :.

Next time, I think we’ll do a bit of generalizing to handle the second nearby-inversion case. And maybe we’ll move toward a more executable form, with the strings just being used for display.

Until then, safe paths to you!