carousel.love/live.lua

393 lines
13 KiB
Lua
Raw Normal View History

2022-11-27 22:06:11 +00:00
-- A general architecture for free-wheeling, live programs:
-- on startup:
-- scan both the app directory and the save directory for files with numeric prefixes
2022-12-26 08:27:24 +00:00
-- from the numeric prefix in file 'head', obtain a manifest
-- load all files (which must start with a numeric prefix) from the manifest
2022-11-27 22:06:11 +00:00
--
-- 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:
2022-12-26 08:27:24 +00:00
-- save the message's value to a new, smallest unused numeric prefix
2022-11-27 22:06:11 +00:00
-- execute the value
2022-12-26 08:27:24 +00:00
-- if there's an error, go back to the previous value of the same
-- definition if one exists
2022-11-27 22:06:11 +00:00
--
2022-12-26 08:27:24 +00:00
-- 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.
2022-11-27 22:06:11 +00:00
-- namespace for these functions
live = {}
-- state for these functions
Live = {}
-- a namespace of frameworky callbacks
-- these will be modified live
on = {}
2023-04-16 17:42:27 +00:00
-- === on startup, load the version at head
2022-11-27 22:06:11 +00:00
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.freeze_all_existing_definitions()
2022-11-27 22:06:11 +00:00
live.load_files_so_far()
Live.previous_read = 0
2022-11-27 22:06:11 +00:00
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
2022-11-27 22:06:11 +00:00
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)))
2022-11-27 22:06:11 +00:00
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
2022-12-01 03:26:46 +00:00
if file:match('^%d') then
table.insert(files, file)
end
2022-11-27 22:06:11 +00:00
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)
2023-01-09 20:41:49 +00:00
if root == 'fwmanifest' then
2022-11-27 22:06:11 +00:00
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
2023-01-09 20:41:49 +00:00
if root ~= 'fwmanifest' then
2022-11-27 22:06:11 +00:00
assert(index == #result+1)
table.insert(result, root)
end
end
end
end
return result
end
function live.load_everything_in_manifest()
2022-11-28 02:07:10 +00:00
local files_to_load = {}
for k,v in pairs(Live.manifest) do
2023-01-03 02:21:55 +00:00
-- 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
2022-11-27 22:06:11 +00:00
local root, index = k, v
local filename = live.versioned_filename(index, root)
2022-11-28 02:07:10 +00:00
table.insert(files_to_load, filename)
2022-11-27 22:06:11 +00:00
end
end
2022-11-28 02:07:10 +00:00
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
2022-11-28 02:07:10 +00:00
end
2022-11-27 22:06:11 +00:00
end
2023-01-03 02:21:55 +00:00
PARENT = 'fw_parent'
APP = 'fw_app'
2023-01-03 02:21:55 +00:00
2022-11-27 22:06:11 +00:00
function live.versioned_filename(index, root)
return ('%04d-%s'):format(index, root)
end
function live.versioned_manifest(index)
return ('%04d-fwmanifest'):format(index)
2022-11-27 22:06:11 +00:00
end
2023-04-16 17:42:27 +00:00
-- === on each frame, check for messages and alter the app as needed
2022-11-27 22:06:11 +00:00
function live.update(dt)
if Current_time - Live.previous_read > 0.1 then
2022-12-26 08:27:24 +00:00
local buf = live.receive_from_driver()
2022-11-27 22:06:11 +00:00
if buf then
live.run(buf)
2022-12-24 04:30:35 +00:00
if on.code_change then on.code_change() end
2022-11-27 22:06:11 +00:00
end
Live.previous_read = Current_time
2022-11-27 22:06:11 +00:00
end
end
-- look for a message from outside, and return nil if there's nothing
2022-12-26 08:27:24 +00:00
function live.receive_from_driver()
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
2022-11-27 22:06:11 +00:00
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())
2023-02-05 05:19:05 +00:00
os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
2022-11-27 22:06:11 +00:00
return result
end
2022-12-26 08:27:24 +00:00
function live.send_to_driver(msg)
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w')
2022-11-27 22:06:11 +00:00
if f == nil then return end
f:write(msg)
f:close()
print('=>'..color(0, --[[green]]2))
print(msg)
print(reset_terminal())
end
2023-01-08 03:11:50 +00:00
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
2022-11-27 22:06:11 +00:00
-- 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)
2022-12-26 08:27:24 +00:00
local cmd = live.get_cmd_from_buffer(buf)
2022-11-27 22:06:11 +00:00
assert(cmd)
print('command is '..cmd)
if cmd == 'QUIT' then
love.event.quit(1)
elseif cmd == 'RESTART' then
restart()
2022-11-27 22:06:11 +00:00
elseif cmd == 'MANIFEST' then
Live.manifest[APP] = love.filesystem.getIdentity() -- doesn't need to be persisted, but no harm if it does..
2022-12-26 08:27:24 +00:00
live.send_to_driver(json.encode(Live.manifest))
2022-11-27 22:06:11 +00:00
elseif cmd == 'DELETE' then
2023-04-10 05:31:12 +00:00
local definition_name = buf:match('^%S+%s+(%S+)')
Live.manifest[definition_name] = nil
live.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil`
local next_filename = live.versioned_filename(Live.next_version, definition_name)
2022-11-27 22:06:11 +00:00
love.filesystem.write(next_filename, '')
2023-04-10 05:31:12 +00:00
table.insert(Live.history, definition_name)
2023-04-09 17:24:23 +00:00
live.roll_forward()
2022-11-27 22:06:11 +00:00
elseif cmd == 'GET' then
2023-04-10 05:31:12 +00:00
local definition_name = buf:match('^%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 = {}
2023-04-10 05:31:12 +00:00
for definition_name in buf:gmatch('%s+(%S+)') do
print(definition_name)
local val, _ = live.get_binding(definition_name)
2023-01-08 04:48:22 +00:00
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)
2022-11-27 22:06:11 +00:00
-- other commands go here
else
2023-04-10 05:31:12 +00:00
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
2023-04-10 05:31:12 +00:00
local next_filename = live.versioned_filename(Live.next_version, definition_name)
2022-11-27 22:06:11 +00:00
love.filesystem.write(next_filename, buf)
2023-04-10 05:31:12 +00:00
table.insert(Live.history, definition_name)
Live.manifest[definition_name] = Live.next_version
2023-04-09 17:24:23 +00:00
live.roll_forward()
2022-11-27 22:06:11 +00:00
local status, err = live.eval(buf)
if not status then
2023-04-09 17:24:23 +00:00
live.roll_back()
2022-11-27 22:06:11 +00:00
-- throw an error
2022-12-26 08:27:24 +00:00
live.send_to_driver('ERROR '..tostring(err))
return
2022-11-27 22:06:11 +00:00
end
-- run all tests
Test_errors = {}
2023-01-24 04:12:23 +00:00
App.run_tests(record_error_by_test)
live.send_to_driver(json.encode(Test_errors))
end
end
2023-04-09 17:24:23 +00:00
-- update Live.Head and record the new Live.Manifest (which caller has already modified)
function live.roll_forward()
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
end
-- update app.Head and reload app.Manifest appropriately
function live.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))
end
2022-12-26 08:27:24 +00:00
function live.get_cmd_from_buffer(buf)
return buf:match('^%s*(%S+)')
end
2022-11-27 22:06:11 +00:00
function live.get_binding(name)
if Live.manifest[name] then
return love.filesystem.read(live.versioned_filename(Live.manifest[name], name))
2022-11-27 22:06:11 +00:00
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
2023-04-15 17:12:32 +00:00
-- run (pcall) failed -> false, error message
2022-11-27 22:06:11 +00:00
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
2023-04-16 17:42:27 +00:00
-- === infrastructure for performing safety checks on any new definition
2023-04-15 17:14:42 +00:00
-- 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
2022-11-27 22:06:11 +00:00
-- 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
2023-01-21 04:07:34 +00:00
local stack_trace = debug.traceback('Error: '..msg, --[[stack frame]]2):gsub('\n[^\n]+$', '')
2022-11-27 22:06:11 +00:00
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
2022-12-26 08:27:24 +00:00
live.send_run_time_error_to_driver(stack_trace)
2022-11-27 22:06:11 +00:00
local buf
repeat
2022-12-26 08:27:24 +00:00
buf = live.receive_from_driver()
2023-01-23 05:53:58 +00:00
love.timer.sleep(0.001)
2022-11-27 22:06:11 +00:00
until buf
if buf == 'QUIT' then
return true
end
live.run(buf)
end