-- 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)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