Files
2026-11-03 12:41:09 -05:00

139 lines
7.0 KiB
Lua
Executable File

-- Read-only offline UI. Injected APIs; no registrations, network or map writes.
return function(hearth, api, journal)
local R = {}
local esc = api.formspec_escape
local function session(player)
return player and hearth.sessions and hearth.sessions[player:get_player_name()]
end
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function position(p)
return type(p)=="table" and finite(p.x) and finite(p.y) and finite(p.z)
and math.abs(p.x)<31000 and math.abs(p.y)<31000 and math.abs(p.z)<31000
end
-- UTF-8 boundaries without relying on Lua 5.3's optional utf8 library.
-- At most 8000 code points / 32000 bytes enter a body; each page is 4000.
local function clip(value, limit, skip)
local s = type(value)=="string" and value or ""
local i, count, first = 1, 0, nil
skip = skip or 0
while i<=#s and count<skip+limit do
if count==skip then first=i end
local b=s:byte(i)
i=i+(b<128 and 1 or b<224 and 2 or b<240 and 3 or 4)
count=count+1
end
local out=first and s:sub(first,i-1) or ""
return out:gsub("[%z\1-\8\11-\31\127]", ""), i<=#s
end
local function safe(value, limit) return esc((clip(value,limit or 512))) end
local function record(value)
local B=hearth.botany
return B and type(B.get)=="function" and B.get(value) or nil
end
function R.resolve(value)
if type(value)~="string" or #value>120 then return nil end
value=value:match("^%s*(.-)%s*$")
-- Explicit IDs or exact canonical Latin names only. No node names, URLs,
-- substrings, common-name guessing or network fallback in chat input.
local B=hearth.botany
if not B or type(B.list)~="function" then return nil end
for _,r in ipairs(B.list()) do
if value==r.id or value==r.latin then return record(r.id) end
end
end
function R.look(player)
if not player or not player.get_pos or not player.get_look_dir or not player.get_properties
or not api.raycast or not api.get_node_or_nil then return nil end
local p,d=player:get_pos(),player:get_look_dir()
if not position(p) or not position(d) then return nil end
local height=(player:get_properties() or {}).eye_height or 1.55
if not finite(height) or height<0 or height>3 then return nil end
local length=math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)
if length<0.001 then return nil end
local eye={x=p.x,y=p.y+height,z=p.z}
local finish={x=eye.x+d.x/length*6,y=eye.y+d.y/length*6,z=eye.z+d.z/length*6}
for hit in api.raycast(eye,finish,false,false) do
if hit.type=="node" then
local at=hit.intersection_point
if not position(hit.under) or not position(at)
or (at.x-eye.x)^2+(at.y-eye.y)^2+(at.z-eye.z)^2>36.000001 then return nil end
local node=api.get_node_or_nil(hit.under)
-- Stop at the first surface, even if unknown/unloaded. Never see through it.
if not node or node.name=="ignore" then return nil end
local B=hearth.botany
local id=B and B.kind and B.kind(node.name)
return id and record(id) or nil
end
end
end
local function effects(r)
local E=hearth.species_effects
local lines={"Current effects are game policy, not measured Wikipedia constants. Natural-history tags are not all modeled."}
local rows=E and E.describe and E.describe(r) or {}
if #rows==0 then lines[#lines+1]="Effect descriptions unavailable in this runtime." end
for i,row in ipairs(rows) do
if i>16 then break end
lines[#lines+1]=(clip(row.title,80)).." / "..(row.status=="simulated" and "SIMULATED" or "DESCRIPTIVE (not simulated)")
.."\n"..(clip(row.game,900)).."\nEvidence: "..(clip(row.note,600))
.."\n"..(clip(row.source_url,512)).."\n"..(clip(row.evidence_url,512))
end
return table.concat(lines,"\n\n")
end
function R.show(player, value, tab, page)
local s=session(player)
local r=record(value)
if not s or not r then return false,"Unknown species. Use an exact catalogue ID or Latin name." end
tab=tab==2 and 2 or 1
local a=hearth.wiki_articles and hearth.wiki_articles[r.id]
local intro=type(a)=="table" and a.kind=="wikipedia_intro"
local missing=type(a)=="table" and a.kind=="original_notes"
local title=intro and "Wikipedia excerpt (offline)" or "No Wikipedia article cached (offline)"
local body=tab==2 and effects(r) or ((intro or missing) and a.text
or "No article is available in this runtime. No replacement Wikipedia text has been invented.")
local bounded,cut=clip(body,8000)
local second=clip(bounded,4000,4000)
local pages=second~="" and 2 or 1
page=type(page)=="number" and page==page and math.max(1,math.min(pages,math.floor(page))) or 1
s.species_reader={id=r.id,tab=tab,page=page}
local provenance={}
if intro or missing then
for _,pair in ipairs({{"Source title",a.title},{"Attribution",a.attribution},{"License",a.license},
{"License URL",a.license_url},{"Canonical URL",a.url},{"Permanent revision",a.revision_url},
{"History / contributors",a.history_url},{"Revision UTC",a.revision_timestamp},
{"Retrieved UTC",a.retrieved},{"Changes",a.changes}}) do
provenance[#provenance+1]=pair[1]..": "..(clip(pair[2],512))
end
end
local f=(journal and journal(14,12) or "formspec_version[6]size[14,12]bgcolor[#1e1e2e;true]")
.."label[0.6,0.5;"..safe(r.common.." ("..r.latin..")").."]"
.."label[0.6,1;"..safe(title).."]"
.."tabheader[0.6,1.8;reader_tab;Article,Current effects;"..tab..";false;false]"
-- Empty field names make these textareas read-only but copyable.
.."textarea[0.6,2.1;12.8,4.7;;;"..safe(clip(bounded,4000,(page-1)*4000),4000).."]"
.."label[0.6,6.9;Page "..page.." / "..pages..(cut and " / display limited to 8000 characters" or "").."]"
.."textarea[0.6,7.3;12.8,2.65;;;"..safe(table.concat(provenance,"\n"),6000).."]"
.."textarea[0.6,10;12.8,0.7;;;"..safe("Introduction only, not the full wiki. Plain text; URLs are copyable, never opened automatically. Not identification, foraging, food preparation or medical advice.").."]"
..(page>1 and "button[0.6,11;2,0.6;reader_previous;Previous]" or "")
..(page<pages and "button[2.8,11;2,0.6;reader_next;Next]" or "")
.."button[6,11;3,0.6;reader_botany;Botanical guide]button_exit[10,11;3.3,0.6;reader_close;Close]"
api.show_formspec(player:get_player_name(),"hearth:species",f)
return true
end
function R.handle(player, fields)
local s=session(player)
local state=s and s.species_reader
if not state then return false end
if fields.quit or fields.reader_close then s.species_reader=nil; return true end
if fields.reader_botany then
s.species_reader=nil
if hearth.botanical_guide then hearth.botanical_guide(player,s.botany_query,s.botany_page) end
elseif fields.reader_tab=="1" or fields.reader_tab=="2" then
R.show(player,state.id,tonumber(fields.reader_tab),1)
elseif fields.reader_next then R.show(player,state.id,state.tab,state.page+1)
elseif fields.reader_previous then R.show(player,state.id,state.tab,state.page-1) end
-- Client-provided text, IDs, URLs and page numbers are never used.
return true
end
return R
end