Moon Logic deprecated

by mk-fg

Adds Lua-programmable circuit network combinator. Based on Sandboxed LuaCombinator and LuaCombinator2 mods. Probably won't work in multiplayer games.

Content
2 years ago
1.0 - 1.1
4.97K
Circuit network

g [showcase] How to make a state machine

2 years ago
(updated 2 years ago)

Many times, you need to ingest data from multiple combinators, which requires code to be executed across multiple game ticks, or MLC code executions. In this case, I'm pulling stuff from Logistics Signals, Crafting Combinator, and a few other sources: https://imgur.com/TexKMKp .

You could build a bunch of if statements, trying to figure out from the input which step you are on, but there's a cleaner way: the state machine. Here's an example you can use for inspiration on how to set yours up. I use a global variable State so it is persistent from run to run. Then each run executes a function with the same name as the State from the table main. The minDelay parameter lets you slow down the delay between each step so you have time to examine variables and wire data. (If you're wondering about Recipes, I put it on _api because it's a pretty big table, and it slows down the whole game if it has to keep updating the environment window. This also gives me a place to share it across all MLCs. I could have used _api calls directly, but I want to use the crafting combinator to set assembler recipes, so I wanted to use their signal names. You can create loops which span many game ticks by having one State set the State to a previous State (see nextItem, nextRecipe in the example). Just be sure there's an exit condition that sets State to something outside the "loop" when it should stop iterating.

local main = {}
local minDelay = 300
if _api.Recipes == nil then _api.Recipes = {} end
local Recipes = _api.Recipes
if State == nil then State = "init" end
local function merge(from, to, prefix, filterout)
    prefix = prefix or ""
    to = to or {}
    filterout = filterout or 'signal%-at'
    for k, v in pairs(from) do
        if not filterout or not string.match(k, filterout) then
            to[prefix .. k] = v
        end
    end
    return to
end
local function clone(org, prefix)
    return merge(org, {}, prefix)
end
local function filterout(tbl, pattern)
    pattern = pattern or ".*"
    for k in pairs(tbl) do if string.match(k, pattern) then tbl[k] = nil end end
end
local function count(tbl, pattern)
    pattern = pattern or ".*"
    local count = 0
    for k in pairs(tbl) do if string.match(k, pattern) then count = count + 1 end end
    return count
end
local function first(tbl)
    local key, value
    for k, v in pairs(tbl) do key, value = k, v; break end
    return key, value
end
function main.init()
    out['red/signal-at'] = 1
    delay = 3
    State = 'read1'
end
function main.read1()
    Player = clone(red)
    Construction = clone(green)
    out['red/signal-at'] = 2
    delay = 3
    State = 'read2'
end
function main.read2()
    Unfulfilled = clone(red)
    Science = clone(green)
    out = {}
    Priority = {}
    if count(Player) > 0 then Priority = Player
    elseif count(Construction) > 0 then Priority = Construction
    elseif count(Unfulfilled) > 0 then Priority = Unfulfilled
    elseif count(Science) > 0 then Priority = Science
    end
    Priority = clone(Priority)
    ItemsToPopulate = {}
    Item = nil
    --find any needed items that haven't been cached yet
    for item in pairs(Priority) do
        if Recipes[item] == nil then
            ItemsToPopulate[item] = 1
            Item = item
        end
    end
    --if all items are cached, update one that was updated the longest ago
    -- to catch updates due to tech advances, for instance
    if Item == nil then
        local firstUpdate = game.tick
        for item in pairs(Recipes) do
            if Recipes[item] == nil or Recipes[item].updated < firstUpdate then
                firstUpdate = Recipes[item].updated
                Item = item
            end
        end
    end
    out['red/signal-at'] = 3
    out['green/'..Item] = 1
    delay = 5
    State = 'read3'
end
function main.nextItem()
    Item = first(ItemsToPopulate)
    if Item == nil then
        State = 'calculateNeeded'
        return
    end
    out = {}
    out['red/signal-at'] = 3
    out['green/'..Item] = 1
    delay = 5
    State = 'read3'
end
function main.read3()
    Recipes[Item] = {recipes = {}, usage = clone(red), updated = game.tick}
    for k in pairs(green) do
        if k ~= 'signal-at' then
            Recipes[Item].recipes[k] = {}
        end
    end
    for item in pairs(Recipes[Item].usage) do
        if Recipes[item] == nil and not string.match(item, 'recipe%-time') then
            ItemsToPopulate[item] = 1
        end
    end
    State = 'nextRecipe'
end
function main.nextRecipe()
    Recipe = nil
    for k in pairs(Recipes[Item].recipes) do
        if Recipes[Item].recipes[k].ingredients == nil then
            Recipe = k
            break
        end
    end
    if Recipe == nil then
        ItemsToPopulate[Item] = nil
        State = 'nextItem'
        return
    end
    out = {}
    out['green/'..Recipe] = 1
    out['red/signal-at'] = 4
    delay = 5
    State = 'read4'
end
function main.read4()
    Recipes[Item].recipes[Recipe].ingredients = clone(red)
    Recipes[Item].recipes[Recipe].products = clone(green)
    for item in pairs(Recipes[Item].recipes[Recipe].ingredients) do
        if Recipes[item] == nil and not string.match(item, 'recipe%-time') then
            ItemsToPopulate[item] = 1
        end
    end
    for item in pairs(Recipes[Item].recipes[Recipe].products) do
        if Recipes[item] == nil and not string.match(item, 'recipe%-time') then
            ItemsToPopulate[item] = 1
        end
    end
    out = {}
    out['green/'..Recipe] = 1
    out['red/signal-at'] = 5
    delay = 5
    State = 'read5'
end
function main.read5()
    Recipes[Item].recipes[Recipe].machines = clone(red)
    State = 'nextRecipe'
end
function main.calculateNeeded()
    State = 'wait'
end
function main.wait()
    out = {}
    State = "init"
    delay = 300
end
main[State]()
if delay < minDelay then delay = minDelay end
2 years ago

it slows down the whole game if it has to keep updating the environment window.

Hm, wonder if it should have an option to not update on every combinator code run in some way.
Like maybe have a button to open it in a frozen state or manually update on another press if it's already open...

Should avoid this kind of issue, I think, though not sure if maybe there's a better way to do it.

2 years ago
(updated 2 years ago)

Also, in my experience with state machines, laying-out explicit state transition map and conditions is very useful, and you usually have to do it anyway, either in some kind of diagram, code comment or just write it down in the code, otherwise it's always wrong for anything non-trivial :)

E.g. something like this:

local state_map = {
  init = function() return 'read1', 3 end,
  read1 = function() return 'read2', 3 end,
  read2 = function() return 'read3', 5 end,
  read3 = function() return 'nextRecipe' end,
  nextRecipe = function(recipe_selected)
    if not recipe_selected
      then return 'nextItem'
      else return 'nextRecipe', 5 end end,
  nextItem = function(item_selected)
    if not item_selected
      then return 'calculateNeeded'
      else return 'read3', 5 end end,
  read4 = function() return 'read5', 5 end,
  read5 = function() return 'nextRecipe' end,
  calculateNeeded = function() return 'wait' end,
  wait = function() return 'init', 300 end,
}

local function state_next(...)
  local state0 = State
  State, delay = state_map[state0](...)
  ---- Logging state changes tend to be very useful with state machines
  -- print((' -- mlc[%s] state: %s -> %s'):format(uid, state0, State))
  if (delay or 0) < 1 then return main[State]() end
end

That'd also make actual methods more obvious and less boilerplatey, allow for states to change within same tick, add logging or whatever other stuff in there easily, etc.

function main.nextRecipe()
  Recipe = nil
  for name in pairs(Recipes[Item].recipes) do
    if not Recipes[Item].recipes[k].ingredients
      then Recipe = name; break end end
  if not Recipe then
    ItemsToPopulate[Item] = nil
    return state_next()
  end
  out = {['green/'..Recipe]=1, ['red/signal-at']=4}
  return state_next('recipe_selected')
end

Though of couse there're infinite ways to express same thing, mostly up to personal style/preferences.
Just wanted to point it out as an option and a particular way it can be written down in lua.

2 years ago

Like maybe have a button to open it in a frozen state or manually update on another press if it's already open...

Added shift-click on variables-window button to open it paused in 0.0.76, and a dedicated Pause/Unpause button there next to Close in 0.0.77.

2 years ago

Sweet, thanks for the updates. I was dropping under 10 UPS as that table grew to full size.

For me, separating the state changes from the rest of the code makes it harder to follow, having to scroll up and down to see what comes next. I'd prefer to write the whole thing with coroutines so I could name the functions around logical tasks and yield() when I need to wait for a tick or 5, but that has been disabled. So this approach let's me see the flow in roughly the same code order.

I do agree there's still some boilerplate that could be removed. I'll probably add a function next(address, data, nextState, delay) to consolidate the repetition at the end of each state function in one place. And good idea on allowing multiple state transitions in a tick... I'll call main[State] in a loop while delay is 0 or nil.

2 years ago
(updated 2 years ago)

After the update, if I use the Pause button with a large nested table in the Environment View, UPS increases to 60, so good fix. FPS stays down in the 20s though. As soon as I close the environment window, FPS increases back up to 60. No big deal, just mentioning it in case there's a simple fix. My guess is it's trying to render the whole area of the textbox even though 95%+ of it is clipped out of the bounds of the scrollbox.

2 years ago
(updated 2 years ago)

Yeah, something like b = {}; for n = 1, 100000 do b[n] = n end easily reproduces the issue.

Can potentially be addressed by cutting text before putting it into that scrollbox and adding custom pagination controls.
But I think it's probably a niche use-case to have that much data, and inspecting it in such narrow box would be a pain anyway, so maybe better not to have a lot of data that you can't really read through there anyway.

And guess storing stuff in _api.global would be an easy way to do that with lua itself.
Actually, if _api.Recipes == nil then _api.Recipes = {} end above should probably use something like _api.global.sm1_recipes, to (a) use actual globals that persist in a savegame, and (b) have some prefix so that anything in there won't clash with this mod's stuff.

Should also be easy to introduce some special hidden = {} variable in mlc env that is specifically omitted during printing, but again feel like it might be a bit too uncommon problem to worry about, as presumably by the time you get state machines processing megs of data, you're already deep into "making your own mod" territory and using _api stuff a lot anyway, so not really worth adding more special magic over.

2 years ago

Should also be easy to introduce some special hidden = {} variable in mlc env that is specifically omitted during printing

Had a random idea to maybe just hide all top-level vars prefixed with "__" in there - should be easier to use than some special container table, and anything that avoid needing _api should be good too.

2 years ago

hide all top-level vars prefixed with "__" in there

Added in 0.0.78 with very simple logic - just a special top-level var prefix allowing to create that kind of "big data" containers that presumably you don't really want to look at often, or ever.

New response