322 lines
10 KiB
Lua
322 lines
10 KiB
Lua
-- A general architecture for free-wheeling, live programs:
|
|
-- on startup:
|
|
-- scan both the app directory and the save directory for files with numeric prefixes
|
|
-- load files in order
|
|
--
|
|
-- then start drawing frames on screen and reacting to events
|
|
--
|
|
-- events from keyboard and mouse are handled as the app desires
|
|
--
|
|
-- on incoming messages to a specific file, the app must:
|
|
-- determine the definition name from the first word
|
|
-- execute the value, returning any errors
|
|
-- look up the filename for the definition or define a new filename for it
|
|
-- save the message's value to the filename
|
|
--
|
|
-- if a game encounters a run-time error, send it to the driver and await
|
|
-- further instructions. The app will go unresponsive in the meantime, that
|
|
-- is expected. To shut it down cleanly, type C-q in the driver.
|
|
|
|
-- namespace for these functions
|
|
live = {}
|
|
-- state for these functions
|
|
Live = {}
|
|
|
|
-- a namespace of frameworky callbacks
|
|
-- these will be modified live
|
|
on = {}
|
|
|
|
-- === on startup, load all files with numeric prefix
|
|
|
|
function live.initialize(arg)
|
|
live.freeze_all_existing_definitions()
|
|
|
|
-- version control
|
|
Live.filenames_to_load = {} -- filenames in order of numeric prefix
|
|
Live.filename = {} -- map from definition name to filename (including numeric prefix)
|
|
Live.final_prefix = 0
|
|
live.load_files_so_far()
|
|
|
|
-- some hysteresis
|
|
Live.previous_read = 0
|
|
|
|
if on.load then on.load() end
|
|
end
|
|
|
|
function live.load_files_so_far()
|
|
print('new edits will go to ' .. love.filesystem.getSaveDirectory())
|
|
-- if necessary, copy files from repo to save dir
|
|
if io.open(love.filesystem.getSaveDirectory()..'/0000-freewheeling-start') == nil then
|
|
print('copying all definitions from repo to save dir')
|
|
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
|
|
for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do
|
|
-- only runs once
|
|
local buf = love.filesystem.read(filename)
|
|
print('copying', filename)
|
|
love.filesystem.write(filename, buf)
|
|
end
|
|
end
|
|
end
|
|
-- load files from save dir
|
|
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
|
|
for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do
|
|
-- only runs once
|
|
if tonumber(numeric_prefix) > 0 then -- skip 0000
|
|
Live.filename[root] = filename
|
|
table.insert(Live.filenames_to_load, filename)
|
|
Live.final_prefix = math.max(Live.final_prefix, tonumber(numeric_prefix))
|
|
end
|
|
end
|
|
end
|
|
table.sort(Live.filenames_to_load)
|
|
live.load_all(files)
|
|
end
|
|
|
|
function live.load_all()
|
|
for _,filename in ipairs(Live.filenames_to_load) do
|
|
--? print('loading', filename)
|
|
local buf = love.filesystem.read(filename)
|
|
assert(buf and buf ~= '')
|
|
local status, err = live.eval(buf)
|
|
if not status then
|
|
return err
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
APP = 'fw_app'
|
|
|
|
-- === on each frame, check for messages and alter the app as needed
|
|
|
|
function live.update(dt)
|
|
if Current_time - Live.previous_read > 0.1 then
|
|
local buf = live.receive_from_driver()
|
|
if buf then
|
|
live.run(buf)
|
|
if on.code_change then on.code_change() end
|
|
end
|
|
Live.previous_read = Current_time
|
|
end
|
|
end
|
|
|
|
-- look for a message from outside, and return nil if there's nothing
|
|
function live.receive_from_driver()
|
|
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
|
|
if f == nil then return nil end
|
|
local result = f:read('*a')
|
|
f:close()
|
|
if result == '' then return nil end -- empty file == no message
|
|
print('<='..color(--[[bold]]1, --[[blue]]4))
|
|
print(result)
|
|
print(reset_terminal())
|
|
os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
|
|
return result
|
|
end
|
|
|
|
function live.send_to_driver(msg)
|
|
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w')
|
|
if f == nil then return end
|
|
f:write(msg)
|
|
f:close()
|
|
print('=>'..color(0, --[[green]]2))
|
|
print(msg)
|
|
print(reset_terminal())
|
|
end
|
|
|
|
function live.send_run_time_error_to_driver(msg)
|
|
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w')
|
|
if f == nil then return end
|
|
f:write(msg)
|
|
f:close()
|
|
print('=>'..color(0, --[[red]]1))
|
|
print(msg)
|
|
print(reset_terminal())
|
|
end
|
|
|
|
-- args:
|
|
-- format: 0 for normal, 1 for bold
|
|
-- color: 0-15
|
|
function color(format, color)
|
|
return ('\027[%d;%dm'):format(format, 30+color)
|
|
end
|
|
|
|
function reset_terminal()
|
|
return '\027[m'
|
|
end
|
|
|
|
-- define or undefine top-level bindings
|
|
function live.run(buf)
|
|
local cmd = live.get_cmd_from_buffer(buf)
|
|
assert(cmd)
|
|
print('command is '..cmd)
|
|
if cmd == 'QUIT' then
|
|
love.event.quit(1)
|
|
elseif cmd == 'RESTART' then
|
|
restart()
|
|
elseif cmd == 'MANIFEST' then
|
|
Live.filename[APP] = love.filesystem.getIdentity()
|
|
live.send_to_driver(json.encode(Live.filename))
|
|
elseif cmd == 'DELETE' then
|
|
local definition_name = buf:match('^%s*%S+%s+(%S+)')
|
|
if Live.filename[definition_name] then
|
|
local index = table.find(Live.filenames_to_load, Live.filename[definition_name])
|
|
table.remove(Live.filenames_to_load, index)
|
|
live.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil`
|
|
love.filesystem.remove(Live.filename[definition_name])
|
|
Live.filename[definition_name] = nil
|
|
end
|
|
elseif cmd == 'GET' then
|
|
local definition_name = buf:match('^%s*%S+%s+(%S+)')
|
|
local val, _ = live.get_binding(definition_name)
|
|
if val then
|
|
live.send_to_driver(val)
|
|
else
|
|
live.send_to_driver('ERROR no such value')
|
|
end
|
|
elseif cmd == 'GET*' then
|
|
-- batch version of GET
|
|
local result = {}
|
|
for definition_name in buf:gmatch('%s+(%S+)') do
|
|
print(definition_name)
|
|
local val, _ = live.get_binding(definition_name)
|
|
if val then
|
|
table.insert(result, val)
|
|
end
|
|
end
|
|
local delimiter = '\n==fw: definition boundary==\n'
|
|
live.send_to_driver(table.concat(result, delimiter)..delimiter) -- send a final delimiter to simplify the driver's task
|
|
elseif cmd == 'DEFAULT_MAP' then
|
|
local contents = love.filesystem.read('default_map')
|
|
if contents == nil then contents = '{}' end
|
|
live.send_to_driver(contents)
|
|
-- other commands go here
|
|
else
|
|
local definition_name = cmd
|
|
if Live.frozen_definitions[definition_name] then
|
|
live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.')
|
|
return
|
|
end
|
|
local status, err = live.eval(buf)
|
|
if not status then
|
|
-- throw an error
|
|
live.send_to_driver('ERROR '..tostring(err))
|
|
return
|
|
end
|
|
-- eval succeeded without errors; persist the definition
|
|
local filename = Live.filename[definition_name]
|
|
if filename == nil then
|
|
Live.final_prefix = Live.final_prefix+1
|
|
filename = ('%04d-%s'):format(Live.final_prefix, definition_name)
|
|
table.insert(Live.filenames_to_load, filename)
|
|
Live.filename[definition_name] = filename
|
|
end
|
|
love.filesystem.write(filename, buf)
|
|
-- run all tests
|
|
Test_errors = {}
|
|
App.run_tests(record_error_by_test)
|
|
live.send_to_driver(json.encode(Test_errors))
|
|
end
|
|
end
|
|
|
|
function live.get_cmd_from_buffer(buf)
|
|
return buf:match('^%s*(%S+)')
|
|
end
|
|
|
|
function live.get_binding(name)
|
|
if Live.filename[name] then
|
|
return love.filesystem.read(Live.filename[name])
|
|
end
|
|
end
|
|
|
|
function table.find(h, x)
|
|
for k,v in pairs(h) do
|
|
if v == x then
|
|
return k
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Wrapper for Lua's weird evaluation model.
|
|
-- Lua is persnickety about expressions vs statements, so we need to do some
|
|
-- extra work to get the result of an evaluation.
|
|
-- return values:
|
|
-- all well -> true, ...
|
|
-- load failed -> nil, error message
|
|
-- run (pcall) failed -> false, error message
|
|
function live.eval(buf)
|
|
-- We assume a program is either correct with 'return' prefixed xor not.
|
|
-- Is this correct? Who knows! But the Lua REPL does this as well.
|
|
local f = load('return '..buf, 'REPL')
|
|
if f then
|
|
return pcall(f)
|
|
end
|
|
local f, err = load(buf, 'REPL')
|
|
if f then
|
|
return pcall(f)
|
|
else
|
|
return nil, err
|
|
end
|
|
end
|
|
|
|
-- === infrastructure for performing safety checks on any new definition
|
|
|
|
-- Everything that exists before we start loading the live files is frozen and
|
|
-- can't be edited live.
|
|
function live.freeze_all_existing_definitions()
|
|
Live.frozen_definitions = {on=true} -- special case for version 1
|
|
local done = {}
|
|
done[Live.frozen_definitions]=true
|
|
live.freeze_all_existing_definitions_in(_G, {}, done)
|
|
end
|
|
|
|
function live.freeze_all_existing_definitions_in(tab, scopes, done)
|
|
-- track duplicates to avoid cycles like _G._G, _G._G._G, etc.
|
|
if done[tab] then return end
|
|
done[tab] = true
|
|
for name,binding in pairs(tab) do
|
|
local full_name = live.full_name(scopes, name)
|
|
--? print(full_name)
|
|
Live.frozen_definitions[full_name] = true
|
|
if type(binding) == 'table' and full_name ~= 'package' then -- var 'package' contains copies of all modules, but not the best name; rely on people to not modify package.loaded.io.open, etc.
|
|
table.insert(scopes, name)
|
|
live.freeze_all_existing_definitions_in(binding, scopes, done)
|
|
table.remove(scopes)
|
|
end
|
|
end
|
|
end
|
|
|
|
function live.full_name(scopes, name)
|
|
local ns = table.concat(scopes, '.')
|
|
if #ns == 0 then return name end
|
|
return ns..'.'..name
|
|
end
|
|
|
|
-- === on error, pause the app and wait for messages
|
|
|
|
-- return nil to continue the event loop, non-nil to quit
|
|
function live.handle_error(err)
|
|
local msg = tostring(err)
|
|
-- draw a pause indicator on screen
|
|
love.graphics.setColor(1,0,0)
|
|
love.graphics.rectangle('fill', 10,10, 3,10)
|
|
love.graphics.rectangle('fill', 16,10, 3,10)
|
|
love.graphics.present()
|
|
-- print stack trace here just in case we ran the app through a terminal
|
|
local stack_trace = debug.traceback('Error: '..msg, --[[stack frame]]2):gsub('\n[^\n]+$', '')
|
|
print(stack_trace)
|
|
print('Look in the driver for options to investigate further.')
|
|
print("(You probably can't close the app window at this point. If you don't have the driver set up, you might need to force-quit.)")
|
|
-- send stack trace to driver and wait for a response
|
|
live.send_run_time_error_to_driver(stack_trace)
|
|
local buf
|
|
repeat
|
|
buf = live.receive_from_driver()
|
|
love.timer.sleep(0.001)
|
|
until buf
|
|
if buf == 'QUIT' then
|
|
return true
|
|
end
|
|
live.run(buf)
|
|
end
|