264 lines
10 KiB
Lua
264 lines
10 KiB
Lua
-- Headless entry point. Examples:
|
|
-- lua warren.lua --seed 42 --auto 50
|
|
-- lua warren.lua --seed 7 --auto 30 --save run.sav
|
|
-- lua warren.lua --seed 3 --class ranger --auto 50
|
|
-- lua warren.lua --load run.sav
|
|
-- lua warren.lua --meta legacy.sav --seed 3 --auto 200
|
|
-- lua warren.lua --meta legacy.sav --buy vigor
|
|
-- lua warren.lua --meta legacy.sav --report
|
|
-- lua warren.lua --meta legacy.sav --seed 3 --auto 200 --mutators famine,nightblind
|
|
-- lua warren.lua --seed 3 --auto 200 --morgue run.morgue
|
|
-- lua warren.lua --seed 3 --auto 200 --verify
|
|
-- lua warren.lua --challenge 42 --auto 200
|
|
-- lua warren.lua --meta legacy.sav --export-csv runs.csv
|
|
-- lua warren.lua --meta legacy.sav --export-compendium bestiary.csv
|
|
-- lua warren.lua --seed 3 --class fighter --play
|
|
-- lua warren.lua --load run.sav --play
|
|
|
|
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
|
|
package.path = scriptDir .. "/src/?.lua;" .. scriptDir .. "/../src/?.lua;" .. package.path
|
|
|
|
local Game = require("game")
|
|
local Meta = require("meta")
|
|
local Compendium = require("compendium")
|
|
local Graves = require("graves")
|
|
local Ascension = require("ascension")
|
|
local Morgue = require("morgue")
|
|
local Leaderboard = require("leaderboard")
|
|
local Replay = require("replay")
|
|
local Challenges = require("challenges")
|
|
local Analytics = require("analytics")
|
|
local Titles = require("titles")
|
|
local Export = require("export")
|
|
local Streaks = require("streaks")
|
|
|
|
local function splitCommaList(text)
|
|
local list = {}
|
|
for part in text:gmatch("[^,]+") do
|
|
list[#list + 1] = part
|
|
end
|
|
return list
|
|
end
|
|
|
|
local function parseArgs(argv)
|
|
local opts = { seed = os.time() & 0xFFFF, auto = 0 }
|
|
local i = 1
|
|
while i <= #argv do
|
|
local flag = argv[i]
|
|
if flag == "--seed" then opts.seed = math.tointeger(tonumber(argv[i + 1])); i = i + 2
|
|
elseif flag == "--auto" then opts.auto = math.tointeger(tonumber(argv[i + 1])); i = i + 2
|
|
elseif flag == "--save" then opts.save = argv[i + 1]; i = i + 2
|
|
elseif flag == "--load" then opts.load = argv[i + 1]; i = i + 2
|
|
elseif flag == "--class" then opts.class = argv[i + 1]; i = i + 2
|
|
elseif flag == "--meta" then opts.metaPath = argv[i + 1]; i = i + 2
|
|
elseif flag == "--buy" then opts.buy = argv[i + 1]; i = i + 2
|
|
elseif flag == "--report" then opts.report = true; i = i + 1
|
|
elseif flag == "--morgue" then opts.morguePath = argv[i + 1]; i = i + 2
|
|
elseif flag == "--verify" then opts.verify = true; i = i + 1
|
|
elseif flag == "--mutators" then opts.mutators = splitCommaList(argv[i + 1]); i = i + 2
|
|
elseif flag == "--challenge" then opts.challengeDay = math.tointeger(tonumber(argv[i + 1])); i = i + 2
|
|
elseif flag == "--export-csv" then opts.exportPath = argv[i + 1]; i = i + 2
|
|
elseif flag == "--export-compendium" then opts.exportCompendiumPath = argv[i + 1]; i = i + 2
|
|
elseif flag == "--play" then opts.play = true; i = i + 1
|
|
else i = i + 1 end
|
|
end
|
|
return opts
|
|
end
|
|
|
|
-- Interactive terminal loop: renders the current frame, reads one line of
|
|
-- input, and hands it straight to Game:manualStep - the same command
|
|
-- interpreter (see commands.lua) a scripted test drives without a real
|
|
-- terminal attached. "quit"/"exit"/"q" leave the run exactly where it
|
|
-- stands rather than ending it, so a save taken right after --play still
|
|
-- captures whatever the last accepted command left behind. Runs until the
|
|
-- player dies, wins, quits, or stdin closes (a redirected script of
|
|
-- commands hitting EOF ends the loop the same way an interactive quit
|
|
-- would).
|
|
local function playLoop(game)
|
|
io.write(game:render(), "\n")
|
|
while game.player:isAlive() and not game.won do
|
|
io.write("> ")
|
|
io.flush()
|
|
local line = io.read("l")
|
|
if not line then break end
|
|
local command = line:match("^%s*(.-)%s*$")
|
|
if command == "quit" or command == "exit" or command == "q" then
|
|
break
|
|
elseif command ~= "" then
|
|
local _, message = game:manualStep(command)
|
|
if message then io.write(message, "\n") end
|
|
io.write(game:render(), "\n")
|
|
end
|
|
end
|
|
if game.won then
|
|
io.write("you escape with the Amulet of Warren - you win!\n")
|
|
elseif not game.player:isAlive() then
|
|
io.write("you have died.\n")
|
|
end
|
|
end
|
|
|
|
local function reportMeta(meta)
|
|
local toNext = Titles.toNext(meta.renown)
|
|
local titleLine = toNext and string.format("%s (%d renown to next title)", Titles.forRenown(meta.renown), toNext)
|
|
or string.format("%s (top of the ladder)", Titles.forRenown(meta.renown))
|
|
io.write(string.format("renown %d | runs %d | wins %d | best depth %d | win rate %.0f%% | title %s\n",
|
|
meta.renown, meta.totalRuns, meta.wins, meta.bestDepth, Analytics.winRate(meta) * 100, titleLine))
|
|
local streakLength, streakOutcome = Streaks.current(meta.runHistory)
|
|
io.write(string.format("streak: %d %s in a row | best win streak %d\n",
|
|
streakLength, streakOutcome == nil and "runs" or (streakOutcome and "wins" or "losses"),
|
|
Streaks.bestWinStreak(meta.runHistory)))
|
|
for _, def in ipairs(Meta.PERKS) do
|
|
io.write(string.format(" perk %-12s %s (%d renown)\n",
|
|
def.key, Meta.isOwned(meta, def.key) and "[owned]" or "[locked]", def.cost))
|
|
end
|
|
io.write("challenges:\n")
|
|
for _, preset in ipairs(Challenges.CATALOG) do
|
|
io.write(" " .. Challenges.describe(preset) .. "\n")
|
|
end
|
|
for _, ach in ipairs(Meta.ACHIEVEMENTS) do
|
|
io.write(string.format(" achievement %-14s %s\n",
|
|
ach.key, meta.achievements[ach.key] and "[unlocked]" or "[locked]"))
|
|
end
|
|
for _, def in ipairs(Ascension.MUTATORS) do
|
|
io.write(string.format(" mutator %-12s %s (+%d%% renown) %s\n",
|
|
def.key, def.desc, def.renownBonus, Ascension.isUnlocked(meta, def.key) and "[unlocked]" or "[locked]"))
|
|
end
|
|
io.write("compendium:\n")
|
|
for _, entry in ipairs(Compendium.entries(meta.compendium or Compendium.new())) do
|
|
if entry.seen then
|
|
io.write(string.format(" %-14s slain %-3d %s\n", entry.name, entry.kills, entry.flavor or ""))
|
|
else
|
|
io.write(string.format(" %-14s [undiscovered]\n", entry.key))
|
|
end
|
|
end
|
|
local board = meta.leaderboard or Leaderboard.new()
|
|
io.write("leaderboard:\n")
|
|
for rank, entry in ipairs(Leaderboard.top(board)) do
|
|
io.write(string.format(" #%-2d %-6d %-10s depth %-2d turn %-5d seed %s %s\n",
|
|
rank, entry.score, entry.className, entry.depth, entry.turn, tostring(entry.seed),
|
|
entry.won and "(won)" or ""))
|
|
end
|
|
if #board > 0 then
|
|
io.write(string.format(" average score %.1f | favorite class %s\n",
|
|
Analytics.averageScore(board), Analytics.favoriteClass(board) or "none"))
|
|
end
|
|
end
|
|
|
|
local opts = parseArgs(arg)
|
|
|
|
local challengePreset
|
|
if opts.challengeDay then
|
|
challengePreset, opts.seed = Challenges.forDay(opts.challengeDay)
|
|
opts.class = challengePreset.class
|
|
opts.mutators = challengePreset.mutators
|
|
io.write(string.format("challenge: %s, seed %d\n", Challenges.describe(challengePreset), opts.seed))
|
|
end
|
|
|
|
local meta = opts.metaPath and Meta.load(opts.metaPath) or nil
|
|
|
|
if meta and opts.buy then
|
|
local success, message = Meta.purchase(meta, opts.buy)
|
|
io.write(message, "\n")
|
|
if not success then os.exit(1) end
|
|
end
|
|
|
|
local game = opts.load and Game.load(opts.load)
|
|
or Game.new(opts.seed, { class = opts.class, meta = meta, graves = meta and meta.graves, mutators = opts.mutators })
|
|
|
|
for _ = 1, opts.auto do
|
|
if not game.player:isAlive() then break end
|
|
game:autoStep()
|
|
end
|
|
|
|
if opts.play then
|
|
playLoop(game)
|
|
else
|
|
io.write(game:render(), "\n")
|
|
end
|
|
|
|
if meta and opts.auto > 0 and (not game.player:isAlive() or game.won) then
|
|
local stats = game:metaStats()
|
|
local gained = Meta.renownFor(stats)
|
|
local unlocked = Meta.recordRun(meta, stats)
|
|
io.write(string.format("run recorded: +%d renown (total %d)\n", gained, meta.renown))
|
|
for _, name in ipairs(unlocked) do
|
|
io.write("achievement unlocked: " .. name .. "\n")
|
|
end
|
|
local report = Morgue.build(game, meta)
|
|
local score = Morgue.score(report)
|
|
local percentile = Analytics.percentile(meta.leaderboard, score)
|
|
local rank = Leaderboard.record(meta.leaderboard, {
|
|
score = score, className = report.className, depth = report.deepestDepth,
|
|
turn = report.turn, won = report.won, seed = game.seed,
|
|
})
|
|
if rank then
|
|
io.write(string.format("leaderboard: rank #%d with score %d (beat %.0f%% of prior runs)\n",
|
|
rank, score, percentile * 100))
|
|
end
|
|
end
|
|
|
|
if meta and opts.auto > 0 and not game.player:isAlive() then
|
|
Graves.record(meta, game:graveEntry())
|
|
io.write(string.format("a grave is left at depth %d\n", game.deepestDepth))
|
|
end
|
|
|
|
if meta then
|
|
Meta.save(meta, opts.metaPath)
|
|
end
|
|
|
|
if opts.exportPath then
|
|
local file = io.open(opts.exportPath, "w")
|
|
file:write(Export.leaderboardCsv(meta and meta.leaderboard or Leaderboard.new()))
|
|
file:close()
|
|
io.write("leaderboard exported to " .. opts.exportPath .. "\n")
|
|
end
|
|
|
|
if opts.exportCompendiumPath then
|
|
local file = io.open(opts.exportCompendiumPath, "w")
|
|
file:write(Export.compendiumCsv(meta and meta.compendium or Compendium.new()))
|
|
file:close()
|
|
io.write("compendium exported to " .. opts.exportCompendiumPath .. "\n")
|
|
end
|
|
|
|
if opts.report then
|
|
reportMeta(meta or Meta.new())
|
|
end
|
|
|
|
if opts.verify and not opts.load then
|
|
if meta then
|
|
io.write("replay skipped: verification only covers meta-free runs (see replay.lua)\n")
|
|
else
|
|
local report = Morgue.build(game)
|
|
local matches, mismatchField, score =
|
|
Replay.verify(opts.seed, { class = opts.class, mutators = opts.mutators }, opts.auto, report)
|
|
if matches then
|
|
io.write(string.format("replay verified: seed %d reproduces this run (score %d)\n", opts.seed, score))
|
|
else
|
|
io.write(string.format("replay mismatch at '%s': seed %d replayed to a different outcome (score %d)\n",
|
|
mismatchField, opts.seed, score))
|
|
end
|
|
end
|
|
end
|
|
|
|
if opts.save then
|
|
game:save(opts.save)
|
|
io.write("saved to " .. opts.save .. "\n")
|
|
end
|
|
|
|
if opts.morguePath and (game.won or not game.player:isAlive()) then
|
|
local report = Morgue.build(game, meta)
|
|
local file = io.open(opts.morguePath, "w")
|
|
file:write(Morgue.format(report))
|
|
file:close()
|
|
io.write(string.format("morgue written to %s (score %d)\n", opts.morguePath, Morgue.score(report)))
|
|
end
|
|
|
|
if challengePreset and opts.auto > 0 then
|
|
local satisfied, reason = Challenges.satisfiedBy(challengePreset, game)
|
|
if satisfied then
|
|
io.write("challenge honored: every requirement was active for this run\n")
|
|
else
|
|
io.write("challenge broken: " .. reason .. "\n")
|
|
end
|
|
end
|