mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-27 10:27:29 +08:00
Refactor runtime around world core
This commit is contained in:
@@ -11,6 +11,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) setWebUISessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
if s == nil || w == nil || strings.TrimSpace(s.token) == "" {
|
||||
return
|
||||
}
|
||||
sameSite := http.SameSiteLaxMode
|
||||
if s.shouldUseCrossSiteCookie(r) {
|
||||
sameSite = http.SameSiteNoneMode
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clawgo_webui_token",
|
||||
Value: s.token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesTLS(r),
|
||||
SameSite: sameSite,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -21,15 +40,7 @@ func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if s.token != "" && s.isBearerAuthorized(r) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clawgo_webui_token",
|
||||
Value: s.token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesTLS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
s.setWebUISessionCookie(w, r)
|
||||
}
|
||||
if s.tryServeWebUIDist(w, r, "/index.html") {
|
||||
return
|
||||
@@ -38,6 +49,19 @@ func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(webUIHTML))
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIAuthSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !s.isBearerAuthorized(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setWebUISessionCookie(w, r)
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -154,18 +178,144 @@ func detectWebUIVersion(webUIDir string) string {
|
||||
const webUIHTML = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>ClawGo WebUI</title>
|
||||
<style>body{font-family:system-ui;margin:20px;max-width:980px}textarea{width:100%;min-height:220px}#chatlog{white-space:pre-wrap;border:1px solid #ddd;padding:12px;min-height:180px}</style>
|
||||
<style>
|
||||
body{font-family:Georgia,"Times New Roman",serif;margin:0;background:linear-gradient(180deg,#f5efe2 0%,#e7dcc7 100%);color:#2f2419}
|
||||
.page{max-width:1180px;margin:0 auto;padding:24px}
|
||||
.hero{display:flex;justify-content:space-between;gap:20px;align-items:end;margin-bottom:24px;padding:20px 24px;border:1px solid #c6b89a;background:linear-gradient(135deg,#f7f0df 0%,#e2d2af 100%)}
|
||||
.hero h2{margin:0;font-size:34px;letter-spacing:.04em;text-transform:uppercase}
|
||||
.hero p{margin:6px 0 0 0;max-width:680px}
|
||||
.grid{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}
|
||||
.panel{border:1px solid #b7a785;background:rgba(255,251,242,.82);box-shadow:0 10px 24px rgba(89,61,29,.08);padding:16px}
|
||||
.panel h3{margin:0 0 12px 0;font-size:18px;text-transform:uppercase;letter-spacing:.06em}
|
||||
.panel h4{margin:12px 0 8px 0;font-size:14px;text-transform:uppercase;letter-spacing:.06em;color:#6b5439}
|
||||
textarea{width:100%;min-height:220px;background:#fffdf8;border:1px solid #c7b48d;padding:10px;color:#2f2419}
|
||||
input,button{font:inherit}
|
||||
input[type="text"],input:not([type]),#token,#session,#msg{background:#fffdf8;border:1px solid #c7b48d;padding:8px 10px;color:#2f2419}
|
||||
button{background:#2f2419;color:#f5efe2;border:none;padding:9px 14px;cursor:pointer}
|
||||
button.secondary{background:#8b6b42}
|
||||
button:disabled{opacity:.5;cursor:default}
|
||||
#chatlog,#worldlog,pre.snapshot{white-space:pre-wrap;border:1px solid #d1c4aa;padding:12px;background:#fffdf8;min-height:180px;overflow:auto}
|
||||
.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
|
||||
.stack{display:grid;gap:18px}
|
||||
.mini{font-size:13px;color:#6d5a42}
|
||||
.world-cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.world-stat{border:1px solid #d3c3a2;background:#fbf6ea;padding:10px}
|
||||
.world-stat strong{display:block;font-size:22px}
|
||||
.mapwrap{border:1px solid #d3c3a2;background:#fffdf8;padding:8px}
|
||||
#worldmap{width:100%;height:320px;display:block;background:radial-gradient(circle at top,#fffdf7 0%,#f0e5cf 100%)}
|
||||
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:#6d5a42;margin-top:8px}
|
||||
.legend span{display:inline-flex;align-items:center;gap:6px}
|
||||
.swatch{width:12px;height:12px;display:inline-block}
|
||||
.detailbox{border:1px solid #d3c3a2;background:#fbf6ea;padding:12px;min-height:84px;white-space:pre-wrap}
|
||||
.formgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.formgrid input{width:100%;box-sizing:border-box}
|
||||
.formgrid textarea{width:100%;min-height:90px;box-sizing:border-box}
|
||||
.chiprow{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
.chiprow button{padding:6px 10px;background:#8b6b42}
|
||||
@media (max-width: 900px){.grid{grid-template-columns:1fr}.world-cols{grid-template-columns:1fr}.hero{flex-direction:column;align-items:start}}
|
||||
</style>
|
||||
</head><body>
|
||||
<h2>ClawGo WebUI</h2>
|
||||
<p>Token: <input id="token" placeholder="gateway token" style="width:320px"/></p>
|
||||
<h3>Config (dynamic + hot reload)</h3>
|
||||
<button onclick="loadCfg()">Load Config</button>
|
||||
<button onclick="saveCfg()">Save + Reload</button>
|
||||
<textarea id="cfg"></textarea>
|
||||
<h3>Chat (supports media upload)</h3>
|
||||
<div>Session: <input id="session" value="webui:default"/> <input id="msg" placeholder="message" style="width:420px"/> <input id="file" type="file"/> <button onclick="sendChat()">Send</button></div>
|
||||
<div id="chatlog"></div>
|
||||
<div class="page">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h2>ClawGo World Console</h2>
|
||||
<p>主对话、任务、NPC 世界状态都在这里。当前页面直接消费 <code>/api/runtime</code> 和 <code>/api/world</code>。</p>
|
||||
</div>
|
||||
<div class="mini">Token: <input id="token" placeholder="gateway token" style="width:320px"/></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="stack">
|
||||
<div class="panel">
|
||||
<h3>Chat</h3>
|
||||
<div class="toolbar">
|
||||
<span>Session:</span>
|
||||
<input id="session" value="webui:default"/>
|
||||
<input id="msg" placeholder="message" style="min-width:280px;flex:1"/>
|
||||
<input id="file" type="file"/>
|
||||
<button onclick="sendChat()">Send</button>
|
||||
</div>
|
||||
<div id="chatlog"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Config</h3>
|
||||
<div class="toolbar">
|
||||
<button class="secondary" onclick="loadCfg()">Load Config</button>
|
||||
<button onclick="saveCfg()">Save + Reload</button>
|
||||
</div>
|
||||
<textarea id="cfg"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="panel">
|
||||
<h3>World</h3>
|
||||
<div class="toolbar">
|
||||
<button onclick="loadWorld()">Refresh World</button>
|
||||
<button class="secondary" onclick="tickWorld()">Advance Tick</button>
|
||||
</div>
|
||||
<div class="world-cols">
|
||||
<div class="world-stat"><span class="mini">World</span><strong id="world-id">-</strong></div>
|
||||
<div class="world-stat"><span class="mini">Tick</span><strong id="world-tick">-</strong></div>
|
||||
<div class="world-stat"><span class="mini">NPC Count</span><strong id="world-npcs">-</strong></div>
|
||||
<div class="world-stat"><span class="mini">Quests</span><strong id="world-quests">-</strong></div>
|
||||
</div>
|
||||
<h4>Map</h4>
|
||||
<div class="mapwrap">
|
||||
<svg id="worldmap" viewBox="0 0 640 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="legend">
|
||||
<span><i class="swatch" style="background:#2f2419"></i> Location</span>
|
||||
<span><i class="swatch" style="background:#b5482f"></i> Selected</span>
|
||||
<span><i class="swatch" style="background:#8b6b42"></i> Connection</span>
|
||||
<span><i class="swatch" style="background:#c96f3b"></i> NPC count</span>
|
||||
<span><i class="swatch" style="background:#497a63"></i> Entity count</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4>Location Detail</h4>
|
||||
<div id="locationdetail" class="detailbox">Click a location on the map to inspect it.</div>
|
||||
<div id="locationnpcs" class="chiprow"></div>
|
||||
<h4>NPC Detail</h4>
|
||||
<div id="npcdetail" class="detailbox">Click an NPC in a location to inspect it.</div>
|
||||
<div id="locationentities" class="chiprow"></div>
|
||||
<h4>Entity Detail</h4>
|
||||
<div id="entitydetail" class="detailbox">Click an entity in a location to inspect it.</div>
|
||||
<h4>Create Entity</h4>
|
||||
<div class="formgrid">
|
||||
<input id="entity-id" placeholder="entity id"/>
|
||||
<input id="entity-name" placeholder="display name"/>
|
||||
<input id="entity-type" placeholder="entity type"/>
|
||||
<input id="entity-location" placeholder="location id"/>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:10px">
|
||||
<button onclick="createEntity()">Create Entity</button>
|
||||
</div>
|
||||
<h4>Create Quest</h4>
|
||||
<div class="formgrid">
|
||||
<input id="quest-id" placeholder="quest id"/>
|
||||
<input id="quest-title" placeholder="quest title"/>
|
||||
<input id="quest-owner" placeholder="owner npc id"/>
|
||||
<input id="quest-status" placeholder="status (default: open)"/>
|
||||
<input id="quest-participants" placeholder="participants (comma separated)"/>
|
||||
<input id="quest-location" placeholder="anchor location"/>
|
||||
<textarea id="quest-summary" placeholder="quest summary" style="grid-column:1 / -1"></textarea>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:10px">
|
||||
<button onclick="createQuest()">Create Quest</button>
|
||||
</div>
|
||||
<h4>World Log</h4>
|
||||
<div id="worldlog"></div>
|
||||
<h4>Quest Board</h4>
|
||||
<div id="questlog"></div>
|
||||
<h4>Occupancy</h4>
|
||||
<div id="occupancylog"></div>
|
||||
<h4>Snapshot</h4>
|
||||
<pre id="worldsnapshot" class="snapshot"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let selectedLocation='';
|
||||
let selectedNPC='';
|
||||
let selectedEntity='';
|
||||
function authHeaders(extra){const h=Object.assign({},extra||{});const t=document.getElementById('token').value.trim();if(t)h['Authorization']='Bearer '+t;return h}
|
||||
async function loadCfg(){let r=await fetch('/api/config',{headers:authHeaders()});document.getElementById('cfg').value=await r.text()}
|
||||
async function saveCfg(){let j=JSON.parse(document.getElementById('cfg').value);let r=await fetch('/api/config',{method:'POST',headers:authHeaders({'Content-Type':'application/json'}),body:JSON.stringify(j)});alert(await r.text())}
|
||||
@@ -175,6 +325,295 @@ async function sendChat(){
|
||||
const payload={session:document.getElementById('session').value,message:document.getElementById('msg').value,media};
|
||||
let r=await fetch('/api/chat',{method:'POST',headers:authHeaders({'Content-Type':'application/json'}),body:JSON.stringify(payload)});let t=await r.text();
|
||||
document.getElementById('chatlog').textContent += '\nUSER> '+payload.message+(media?(' [file:'+media+']'):'')+'\nBOT> '+t+'\n';
|
||||
await loadWorld();
|
||||
}
|
||||
function renderNPCButtons(npcIDs){
|
||||
const host=document.getElementById('locationnpcs');
|
||||
if(!host)return;
|
||||
if(!npcIDs.length){
|
||||
host.innerHTML='';
|
||||
return;
|
||||
}
|
||||
host.innerHTML=npcIDs.map(function(id){
|
||||
const style=id===selectedNPC?' style="background:#b5482f"':'';
|
||||
return '<button'+style+' onclick="loadNPCDetail(\''+id.replace(/'/g,"'")+'\')">'+id+'</button>';
|
||||
}).join('');
|
||||
}
|
||||
function renderEntityButtons(entityIDs){
|
||||
const host=document.getElementById('locationentities');
|
||||
if(!host)return;
|
||||
if(!entityIDs.length){
|
||||
host.innerHTML='';
|
||||
return;
|
||||
}
|
||||
host.innerHTML=entityIDs.map(function(id){
|
||||
const style=id===selectedEntity?' style="background:#497a63"':'';
|
||||
return '<button'+style+' onclick="loadEntityDetail(\''+id.replace(/'/g,"'")+'\')">'+id+'</button>';
|
||||
}).join('');
|
||||
}
|
||||
function selectLocation(id,data){
|
||||
selectedLocation=id||'';
|
||||
const locations=(data&&data.locations)||{};
|
||||
const occupancy=(data&&data.occupancy)||{};
|
||||
const entityOccupancy=(data&&data.entity_occupancy)||{};
|
||||
const detail=document.getElementById('locationdetail');
|
||||
const target=document.getElementById('entity-location');
|
||||
if(target&&selectedLocation)target.value=selectedLocation;
|
||||
if(!detail)return;
|
||||
if(!selectedLocation||!locations[selectedLocation]){
|
||||
detail.textContent='Click a location on the map to inspect it.';
|
||||
renderNPCButtons([]);
|
||||
renderEntityButtons([]);
|
||||
return;
|
||||
}
|
||||
const loc=locations[selectedLocation]||{};
|
||||
const npcIDs=(occupancy[selectedLocation]||[]);
|
||||
const entityIDs=(entityOccupancy[selectedLocation]||[]);
|
||||
const npcs=npcIDs.join(', ')||'-';
|
||||
const entities=entityIDs.join(', ')||'-';
|
||||
const lines=[
|
||||
'ID: '+selectedLocation,
|
||||
'Name: '+(loc.name||selectedLocation),
|
||||
'Neighbors: '+((loc.neighbors||[]).join(', ')||'-'),
|
||||
'NPCs: '+npcs,
|
||||
'Entities: '+entities,
|
||||
'Description: '+(loc.description||'-'),
|
||||
];
|
||||
detail.textContent=lines.join('\n');
|
||||
renderNPCButtons(npcIDs);
|
||||
renderEntityButtons(entityIDs);
|
||||
const questLoc=document.getElementById('quest-location');
|
||||
if(questLoc&&selectedLocation)questLoc.value=selectedLocation;
|
||||
}
|
||||
async function loadNPCDetail(id){
|
||||
selectedNPC=id||'';
|
||||
const detail=document.getElementById('npcdetail');
|
||||
renderNPCButtons(selectedLocation?((JSON.parse(document.getElementById('worldsnapshot').textContent||'{}').occupancy||{})[selectedLocation]||[]):[]);
|
||||
if(!detail||!selectedNPC){
|
||||
if(detail)detail.textContent='Click an NPC in a location to inspect it.';
|
||||
return;
|
||||
}
|
||||
let r=await fetch('/api/runtime_admin',{
|
||||
method:'POST',
|
||||
headers:authHeaders({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify({action:'world_npc_get',id:selectedNPC}),
|
||||
});
|
||||
let text=await r.text();
|
||||
if(!r.ok){
|
||||
detail.textContent=text||'Failed to load NPC detail.';
|
||||
return;
|
||||
}
|
||||
let data={};
|
||||
try{data=JSON.parse(text)}catch(e){detail.textContent=text||'Failed to parse NPC detail.';return;}
|
||||
const item=data.item||{};
|
||||
const profile=item.profile||{};
|
||||
const state=item.state||{};
|
||||
const lines=[
|
||||
'NPC: '+(profile.name||profile.agent_id||selectedNPC),
|
||||
'ID: '+(profile.agent_id||selectedNPC),
|
||||
'Kind: '+(profile.kind||'npc'),
|
||||
'Persona: '+(profile.persona||'-'),
|
||||
'Faction: '+(profile.faction||'-'),
|
||||
'Location: '+(state.current_location||profile.home_location||'-'),
|
||||
'Status: '+(state.status||'-'),
|
||||
'Mood: '+(state.mood||'-'),
|
||||
'Traits: '+((profile.traits||[]).join(', ')||'-'),
|
||||
'Goals(short): '+(((state.goals||{}).short_term||[]).join(', ')||'-'),
|
||||
'Goals(long): '+(((state.goals||{}).long_term||profile.default_goals||[]).join(', ')||'-'),
|
||||
'Beliefs: '+(Object.keys(state.beliefs||{}).length?JSON.stringify(state.beliefs):'-'),
|
||||
'Relationships: '+(Object.keys(state.relationships||{}).length?JSON.stringify(state.relationships):'-'),
|
||||
'Inventory: '+(Object.keys(state.inventory||{}).length?JSON.stringify(state.inventory):'-'),
|
||||
'Memory: '+(state.private_memory_summary||'-'),
|
||||
];
|
||||
detail.textContent=lines.join('\n');
|
||||
renderNPCButtons(selectedLocation?((JSON.parse(document.getElementById('worldsnapshot').textContent||'{}').occupancy||{})[selectedLocation]||[]):[]);
|
||||
}
|
||||
async function loadEntityDetail(id){
|
||||
selectedEntity=id||'';
|
||||
const detail=document.getElementById('entitydetail');
|
||||
renderEntityButtons(selectedLocation?((JSON.parse(document.getElementById('worldsnapshot').textContent||'{}').entity_occupancy||{})[selectedLocation]||[]):[]);
|
||||
if(!detail||!selectedEntity){
|
||||
if(detail)detail.textContent='Click an entity in a location to inspect it.';
|
||||
return;
|
||||
}
|
||||
let r=await fetch('/api/runtime_admin',{
|
||||
method:'POST',
|
||||
headers:authHeaders({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify({action:'world_entity_get',id:selectedEntity}),
|
||||
});
|
||||
let text=await r.text();
|
||||
if(!r.ok){
|
||||
detail.textContent=text||'Failed to load entity detail.';
|
||||
return;
|
||||
}
|
||||
let data={};
|
||||
try{data=JSON.parse(text)}catch(e){detail.textContent=text||'Failed to parse entity detail.';return;}
|
||||
const item=data.item||{};
|
||||
const lines=[
|
||||
'Entity: '+(item.name||selectedEntity),
|
||||
'ID: '+(item.id||selectedEntity),
|
||||
'Type: '+(item.type||'-'),
|
||||
'Location: '+(item.location_id||'-'),
|
||||
'State: '+(Object.keys(item.state||{}).length?JSON.stringify(item.state,null,2):'-'),
|
||||
];
|
||||
detail.textContent=lines.join('\n');
|
||||
renderEntityButtons(selectedLocation?((JSON.parse(document.getElementById('worldsnapshot').textContent||'{}').entity_occupancy||{})[selectedLocation]||[]):[]);
|
||||
}
|
||||
function renderWorldMap(data){
|
||||
const svg=document.getElementById('worldmap');
|
||||
if(!svg)return;
|
||||
const locations=data.locations||{};
|
||||
const occupancy=data.occupancy||{};
|
||||
const entityOccupancy=data.entity_occupancy||{};
|
||||
const ids=Object.keys(locations).sort();
|
||||
if(!ids.length){svg.innerHTML='';return;}
|
||||
const width=640, height=320, radius=24, centerX=width/2, centerY=height/2, outer=Math.min(width,height)*0.34;
|
||||
const pos={};
|
||||
if(ids.length===1){pos[ids[0]]={x:centerX,y:centerY}}
|
||||
else{
|
||||
ids.forEach(function(id,idx){
|
||||
const angle=(Math.PI*2*idx/ids.length)-(Math.PI/2);
|
||||
pos[id]={x:centerX+Math.cos(angle)*outer,y:centerY+Math.sin(angle)*(outer*0.68)};
|
||||
});
|
||||
}
|
||||
let html='';
|
||||
const drawn={};
|
||||
ids.forEach(function(id){
|
||||
const loc=locations[id]||{};
|
||||
(loc.neighbors||[]).forEach(function(nb){
|
||||
const key=[id,nb].sort().join('::');
|
||||
if(drawn[key]||!pos[nb])return;
|
||||
drawn[key]=true;
|
||||
html+='<line x1="'+pos[id].x+'" y1="'+pos[id].y+'" x2="'+pos[nb].x+'" y2="'+pos[nb].y+'" stroke="#8b6b42" stroke-width="2" opacity="0.75"/>';
|
||||
});
|
||||
});
|
||||
ids.forEach(function(id){
|
||||
const p=pos[id], npcs=(occupancy[id]||[]), ents=(entityOccupancy[id]||[]);
|
||||
const fill=id===selectedLocation?'#b5482f':'#2f2419';
|
||||
html+='<circle data-loc="'+id+'" cx="'+p.x+'" cy="'+p.y+'" r="'+radius+'" fill="'+fill+'" style="cursor:pointer"/>';
|
||||
html+='<circle cx="'+(p.x+radius-6)+'" cy="'+(p.y-radius+8)+'" r="10" fill="#c96f3b"/>';
|
||||
html+='<text x="'+(p.x+radius-6)+'" y="'+(p.y-radius+12)+'" text-anchor="middle" font-size="10" fill="#fff">'+npcs.length+'</text>';
|
||||
html+='<circle cx="'+(p.x-radius+6)+'" cy="'+(p.y-radius+8)+'" r="10" fill="#497a63"/>';
|
||||
html+='<text x="'+(p.x-radius+6)+'" y="'+(p.y-radius+12)+'" text-anchor="middle" font-size="10" fill="#fff">'+ents.length+'</text>';
|
||||
html+='<text data-loc="'+id+'" x="'+p.x+'" y="'+(p.y+4)+'" text-anchor="middle" font-size="12" fill="#fff" style="cursor:pointer">'+id+'</text>';
|
||||
}
|
||||
svg.innerHTML=html;
|
||||
svg.querySelectorAll('[data-loc]').forEach(function(node){
|
||||
node.addEventListener('click',function(){
|
||||
selectLocation(node.getAttribute('data-loc'),data);
|
||||
renderWorldMap(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderWorld(world){
|
||||
const data=world&&world.world?world.world:world||{};
|
||||
document.getElementById('world-id').textContent=data.world_id||'-';
|
||||
document.getElementById('world-tick').textContent=(data.tick??'-');
|
||||
document.getElementById('world-npcs').textContent=(data.npc_count??(data.active_npcs?data.active_npcs.length:'-'));
|
||||
const questCount=(data.active_quests?Object.keys(data.active_quests).length:(data.quests?data.quests.length:0));
|
||||
document.getElementById('world-quests').textContent=questCount;
|
||||
const events=data.recent_events||[];
|
||||
document.getElementById('worldlog').textContent=events.length?events.map(function(e){return ('['+(e.tick??'-')+'] '+(e.type||'')+' '+(e.actor_id||'')+' '+(e.content||'')).trim()}).join('\n'):'No recent world events.';
|
||||
const quests=data.active_quests?Object.values(data.active_quests):(data.quests||[]);
|
||||
document.getElementById('questlog').textContent=quests.length?quests.map(function(q){return (q.title||q.id)+' ['+(q.status||'open')+']'}).join('\n'):'No active quests.';
|
||||
const occupancy=data.occupancy||{};
|
||||
const entityOccupancy=data.entity_occupancy||{};
|
||||
const locs=Array.from(new Set([...Object.keys(occupancy),...Object.keys(entityOccupancy)])).sort();
|
||||
document.getElementById('occupancylog').textContent=locs.length?locs.map(function(loc){return loc+': npcs='+(((occupancy[loc]||[]).join(','))||'-')+' | entities='+(((entityOccupancy[loc]||[]).join(','))||'-')}).join('\n'):'No occupancy data.';
|
||||
document.getElementById('worldsnapshot').textContent=JSON.stringify(data,null,2);
|
||||
renderWorldMap(data);
|
||||
if(selectedLocation&&!(data.locations||{})[selectedLocation])selectedLocation='';
|
||||
if(selectedNPC){
|
||||
const occ=data.occupancy||{};
|
||||
const allNPCs=Object.keys(occ).reduce(function(acc,key){return acc.concat(occ[key]||[])},[]);
|
||||
if(allNPCs.indexOf(selectedNPC)===-1)selectedNPC='';
|
||||
}
|
||||
if(selectedEntity){
|
||||
const entOcc=data.entity_occupancy||{};
|
||||
const allEntities=Object.keys(entOcc).reduce(function(acc,key){return acc.concat(entOcc[key]||[])},[]);
|
||||
if(allEntities.indexOf(selectedEntity)===-1)selectedEntity='';
|
||||
}
|
||||
selectLocation(selectedLocation,data);
|
||||
if(selectedNPC)loadNPCDetail(selectedNPC);
|
||||
else document.getElementById('npcdetail').textContent='Click an NPC in a location to inspect it.';
|
||||
if(selectedEntity)loadEntityDetail(selectedEntity);
|
||||
else document.getElementById('entitydetail').textContent='Click an entity in a location to inspect it.';
|
||||
}
|
||||
async function loadWorld(){
|
||||
let r=await fetch('/api/world?limit=20',{headers:authHeaders()});
|
||||
let j=await r.json();
|
||||
renderWorld(j);
|
||||
}
|
||||
async function tickWorld(){
|
||||
let r=await fetch('/api/runtime_admin?action=world_tick&source=webui',{headers:authHeaders()});
|
||||
try{let j=await r.json();console.log(j)}catch(e){}
|
||||
await loadWorld();
|
||||
}
|
||||
async function createEntity(){
|
||||
const payload={
|
||||
action:'world_entity_create',
|
||||
entity_id:document.getElementById('entity-id').value.trim(),
|
||||
name:document.getElementById('entity-name').value.trim(),
|
||||
entity_type:document.getElementById('entity-type').value.trim(),
|
||||
location_id:document.getElementById('entity-location').value.trim(),
|
||||
};
|
||||
if(!payload.entity_id||!payload.name||!payload.entity_type||!payload.location_id){
|
||||
alert('entity id, name, type, and location are required');
|
||||
return;
|
||||
}
|
||||
let r=await fetch('/api/runtime_admin',{
|
||||
method:'POST',
|
||||
headers:authHeaders({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
let text=await r.text();
|
||||
if(!r.ok){
|
||||
alert(text||'create entity failed');
|
||||
return;
|
||||
}
|
||||
document.getElementById('entity-id').value='';
|
||||
document.getElementById('entity-name').value='';
|
||||
document.getElementById('entity-type').value='';
|
||||
await loadWorld();
|
||||
}
|
||||
async function createQuest(){
|
||||
const participants=document.getElementById('quest-participants').value.split(',').map(function(v){return v.trim()}).filter(Boolean);
|
||||
const summary=document.getElementById('quest-summary').value.trim();
|
||||
const locationID=document.getElementById('quest-location').value.trim();
|
||||
const payload={
|
||||
action:'world_quest_create',
|
||||
id:document.getElementById('quest-id').value.trim(),
|
||||
title:document.getElementById('quest-title').value.trim(),
|
||||
owner_npc_id:document.getElementById('quest-owner').value.trim(),
|
||||
status:document.getElementById('quest-status').value.trim()||'open',
|
||||
participants:participants,
|
||||
summary:summary,
|
||||
};
|
||||
if(locationID){
|
||||
payload.summary=(summary?summary+' ':'')+'[location:'+locationID+']';
|
||||
}
|
||||
if(!payload.id&&!payload.title){
|
||||
alert('quest id or title is required');
|
||||
return;
|
||||
}
|
||||
let r=await fetch('/api/runtime_admin',{
|
||||
method:'POST',
|
||||
headers:authHeaders({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
let text=await r.text();
|
||||
if(!r.ok){
|
||||
alert(text||'create quest failed');
|
||||
return;
|
||||
}
|
||||
document.getElementById('quest-id').value='';
|
||||
document.getElementById('quest-title').value='';
|
||||
document.getElementById('quest-owner').value='';
|
||||
document.getElementById('quest-status').value='';
|
||||
document.getElementById('quest-participants').value='';
|
||||
document.getElementById('quest-summary').value='';
|
||||
await loadWorld();
|
||||
}
|
||||
loadCfg();
|
||||
loadWorld();
|
||||
</script></body></html>`
|
||||
|
||||
Reference in New Issue
Block a user