343 lines
11 KiB
Lua
343 lines
11 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
|
|
-- from the numeric prefix in file 'head', obtain a manifest
|
|
-- load all files (which must start with a numeric prefix) from the manifest
|
|
--
|
|
-- 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, however, the app must:
|
|
-- save the message's value to a new, smallest unused numeric prefix
|
|
-- execute the value
|
|
-- if there's an error, go back to the previous value of the same
|
|
-- definition if one exists
|
|
--
|
|
-- 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 the version at head
|
|
|
|
function live.initialize(arg)
|
|
-- version control
|
|
Live.head = 0
|
|
Live.next_version = 1
|
|
Live.history = {} -- array of filename roots corresponding to each numeric prefix
|
|
Live.manifest = {} -- mapping from roots to numeric prefixes as of version Live.head
|
|
live.load_files_so_far()
|
|
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())
|
|
local files = {}
|
|
live.append_files_with_numeric_prefix('', files)
|
|
table.sort(files)
|
|
live.check_integrity(files)
|
|
live.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files)
|
|
table.sort(files)
|
|
live.check_integrity(files)
|
|
Live.history = live.load_history(files)
|
|
Live.next_version = #Live.history + 1
|
|
local head_string = love.filesystem.read('head')
|
|
Live.head = tonumber(head_string)
|
|
if Live.head > 0 then
|
|
Live.manifest = json.decode(love.filesystem.read(live.versioned_manifest(Live.head)))
|
|
end
|
|
live.load_everything_in_manifest()
|
|
end
|
|
|
|
function live.append_files_with_numeric_prefix(dir, files)
|
|
for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do
|
|
if file:match('^%d') then
|
|
table.insert(files, file)
|
|
end
|
|
end
|
|
end
|
|
|
|
function live.check_integrity(files)
|
|
local manifest_found, file_found = false, false
|
|
local expected_index = 1
|
|
for _,file in ipairs(files) do
|
|
for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do
|
|
-- only runs once
|
|
local index = tonumber(numeric_prefix)
|
|
-- skip files without numeric prefixes
|
|
if index ~= nil then
|
|
if index < expected_index then
|
|
print(index, expected_index)
|
|
end
|
|
assert(index >= expected_index)
|
|
if index > expected_index then
|
|
assert(index == expected_index+1)
|
|
assert(manifest_found and file_found)
|
|
expected_index = index
|
|
manifest_found, file_found = false, false
|
|
end
|
|
assert(index == expected_index)
|
|
if root == 'manifest' then
|
|
assert(not manifest_found)
|
|
manifest_found = true
|
|
else
|
|
assert(not file_found)
|
|
file_found = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function live.load_history(files)
|
|
local result = {}
|
|
for _,file in ipairs(files) do
|
|
for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do
|
|
-- only runs once
|
|
local index = tonumber(numeric_prefix)
|
|
-- skip
|
|
if index ~= nil then
|
|
if root ~= 'manifest' then
|
|
assert(index == #result+1)
|
|
table.insert(result, root)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
function live.load_everything_in_manifest()
|
|
local files_to_load = {}
|
|
for k,v in pairs(Live.manifest) do
|
|
-- Most keys in the manifest are definitions. If we need to store any
|
|
-- metadata we'll do it in keys starting with a specific prefix.
|
|
if not starts_with(k, 'fw_') then
|
|
local root, index = k, v
|
|
local filename = live.versioned_filename(index, root)
|
|
table.insert(files_to_load, filename)
|
|
end
|
|
end
|
|
table.sort(files_to_load)
|
|
for _,filename in ipairs(files_to_load) do
|
|
local buf = love.filesystem.read(filename)
|
|
assert(buf and buf ~= '')
|
|
local status, err = live.eval(buf)
|
|
if status == nil then
|
|
error(('error loading %s from manifest: %s'):format(filename, err))
|
|
end
|
|
end
|
|
end
|
|
|
|
PARENT = 'fw_parent'
|
|
APP = 'fw_app'
|
|
|
|
function live.versioned_filename(index, root)
|
|
return ('%04d-%s'):format(index, root)
|
|
end
|
|
|
|
function live.versioned_manifest(index)
|
|
return ('%04d-fwmanifest'):format(index)
|
|
end
|
|
|
|
-- ========= 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())
|
|
-- we can't unlink files, so just clear them
|
|
local clear = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app', 'w')
|
|
clear:close()
|
|
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 == 'MANIFEST' then
|
|
Live.manifest[APP] = love.filesystem.getIdentity() -- doesn't need to be persisted, but no harm if it does..
|
|
live.send_to_driver(json.encode(Live.manifest))
|
|
elseif cmd == 'DELETE' then
|
|
local binding = buf:match('^%S+%s+(%S+)')
|
|
Live.manifest[binding] = nil
|
|
live.eval(binding..' = nil')
|
|
local next_filename = live.versioned_filename(Live.next_version, binding)
|
|
love.filesystem.write(next_filename, '')
|
|
table.insert(Live.history, binding)
|
|
Live.manifest[PARENT] = Live.head
|
|
local manifest_filename = live.versioned_manifest(Live.next_version)
|
|
love.filesystem.write(manifest_filename, json.encode(Live.manifest))
|
|
Live.head = Live.next_version
|
|
love.filesystem.write('head', tostring(Live.head))
|
|
Live.next_version = Live.next_version + 1
|
|
elseif cmd == 'GET' then
|
|
local binding = buf:match('^%S+%s+(%S+)')
|
|
local val, _ = live.get_binding(binding)
|
|
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 binding in buf:gmatch('%s+(%S+)') do
|
|
print(binding)
|
|
local val, _ = live.get_binding(binding)
|
|
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
|
|
-- other commands go here
|
|
else
|
|
local binding = cmd
|
|
local next_filename = live.versioned_filename(Live.next_version, binding)
|
|
love.filesystem.write(next_filename, buf)
|
|
table.insert(Live.history, binding)
|
|
Live.manifest[binding] = Live.next_version
|
|
Live.manifest[PARENT] = Live.head
|
|
local manifest_filename = live.versioned_manifest(Live.next_version)
|
|
love.filesystem.write(manifest_filename, json.encode(Live.manifest))
|
|
Live.head = Live.next_version
|
|
love.filesystem.write('head', tostring(Live.head))
|
|
Live.next_version = Live.next_version + 1
|
|
local status, err = live.eval(buf)
|
|
if not status then
|
|
-- roll back
|
|
Live.head = Live.manifest[PARENT]
|
|
love.filesystem.write('head', tostring(Live.head))
|
|
local previous_manifest_filename = live.versioned_manifest(Live.head)
|
|
Live.manifest = json.decode(love.filesystem.read(previous_manifest_filename))
|
|
-- throw an error
|
|
live.send_to_driver('ERROR '..tostring(err))
|
|
return
|
|
end
|
|
live.send_to_driver('ok')
|
|
end
|
|
end
|
|
|
|
function live.get_cmd_from_buffer(buf)
|
|
return buf:match('^%s*(%S+)')
|
|
end
|
|
|
|
function live.get_binding(name)
|
|
if Live.manifest[name] then
|
|
return love.filesystem.read(live.versioned_filename(Live.manifest[name], name))
|
|
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 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
|
|
|
|
-- ========= 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: ' .. tostring(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()
|
|
until buf
|
|
if buf == 'QUIT' then
|
|
return true
|
|
end
|
|
live.run(buf)
|
|
end
|