139 lines
7.0 KiB
Lua
Executable File
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
|