@@ -6,170 +6,433 @@
< meta name = "color-scheme" content = "light dark" >
< meta name = "color-scheme" content = "light dark" >
< title > Svensk livssimulator< / title >
< title > Svensk livssimulator< / title >
< style >
< style >
: root { font : 1 rem / 1.55 system-ui , sans-serif ; }
: root { font : 1 rem / 1.6 system-ui , sans-serif ; }
body { max-width : 52 rem ; margin : 0 auto ; padding : 1.25 rem ; }
body { max-width : 52 rem ; margin : 0 auto ; padding : 1 rem ; }
h1 , h2 , h3 { line-height : 1.2 ; }
h1 , h2 { line-height : 1.2 ; }
footer { border-top : 1 px solid ; margin-top : 2 rem ; padding-top : .5 rem ; color : GrayText ; font-size : .875 rem ; }
form { display : grid ; gap : .75 rem ; max-width : 32 rem ; }
form { display : grid ; gap : .75 rem ; max-width : 40 rem ; }
label { display : grid ; gap : .25 rem ; font-weight : 600 ; }
label { display : grid ; gap : .2 rem ; font-weight : 600 ; }
input , textarea , button { font : inherit ; padding : .65 rem ; }
input , textarea , button { font : inherit ; padding : .6 rem ; }
button { justify-self : start ; cursor : pointer ; }
button { justify-self : start ; cursor : pointer ; }
# status { min-height : 1.5 em ; }
# dialogue { list-style : none ; padding : 0 ; margin : 0 ; }
. card { border : 1 px solid ; border-radius : .5 rem ; padding : 1 rem ; margin-block : 1 rem ; }
# dialogue li { margin-block : 1 rem ; white-space : pre-wrap ; overflow-wrap : anywhere ; }
. row { display : flex ; flex-wrap : wrap ; gap : .75 rem ; align-items : center ; }
# dialogue li [ data-role = "user" ] { font-weight : 650 ; }
. row button { margin-block : .4 rem ; }
# dialogue li [ data-role = "assistant" ] { padding-inline-start : .75 rem ; border-inline-start : .2 rem solid currentColor ; }
# transcript { min-height : 5 rem ; max-height : 28 rem ; overflow : auto ; }
# message { width : 100 % ; min-height : 3.5 rem ; box-sizing : border-box ; }
# transcript li { margin-block : .6 rem ; }
# admin-content { display : grid ; gap : 1 rem ; }
# admin-content section , # admin-content article { border : 1 px solid currentColor ; border-radius : .35 rem ; padding : .75 rem ; }
# admin-content pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
# admin-toolbar { display : flex ; flex-wrap : wrap ; gap : .5 rem ; margin-block : 1 rem ; }
# player-search { display : flex ; flex-wrap : wrap ; align-items : end ; gap : .5 rem ; }
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ) ; white-space : nowrap ; border : 0 ; }
[ hidden ] { display : none !important ; }
[ hidden ] { display : none !important ; }
footer { margin-top : 2 rem ; border-top : 1 px solid ; padding-top : .5 rem ; color : GrayText ; font-size : .875 rem ; }
< / style >
< / style >
< / head >
< / head >
< body >
< body >
< main >
< main >
< h1 > En värld du formar< / h1 >
< p > Skapa en vuxen person och forma en egen vardag. AI-förslag sparas inte förrän du har granskat och godkänt dem.< / p >
< p id = "status" role = "status" aria-live = "polite" > < / p >
< section id = "auth" aria-labelledby = "auth-heading" >
< section id = "auth" aria-labelledby = "auth-heading" >
< h2 id = "auth-heading" > Logga in eller skapa kon to< / h2 >
< h1 id = "auth-heading" > Svensk livssimula tor < / h1 >
< p > Skapa en vuxen person och utforska en bestående värld genom dialog. Inga bilder används.< / p >
< form id = "auth-form" >
< form id = "auth-form" >
< label > Användarnamn < input name = "username" required minlength = "3" maxlength = "40" autocomplete = "username" > < / label >
< label > Användarnamn < input name = "username" required minlength = "3" maxlength = "40" autocomplete = "username" > < / label >
< label > Lösenord < input name = "password" type = "password" required minlength = "12" maxlength = "256" autocomplete = "current-password" > < / label >
< label > Lösenord < input name = "password" type = "password" required minlength = "12" maxlength = "256" autocomplete = "current-password" > < / label >
< div class = "row" > < button type = "submit" name = "intent" value = "login" > Logga in< / button > < button type = "submit" name = "intent" value = "register" > Skapa konto< / button > < / div >
< div > < button type = "submit" name = "intent" value = "login" > Logga in< / button > < button type = "submit" name = "intent" value = "register" > Skapa konto< / button > < / div >
< / form >
< / form >
< / section >
< / section >
< section id = "new-character " aria-labelledby = "character-headin g" hidden >
< section id = "play " aria-label = "Spelets dialo g" hidden >
< h2 id = "character-heading" > Vem är du? < / h2 >
< h1 class = "sr-only" > Dialog < / h1 >
< p > Du väljer själv bakgrund. Karaktären ska vara vuxen, men du behöver inte välja yrke eller startpaket. < / p >
< ol id = "dialogue" aria-label = "Dialoghistorik" role = "log" aria-live = "polite" aria-relevant = "additions text" > < / ol >
< form id = "character-form" >
< label > Namn < input name = "name" required maxlength = "120" autocomplete = "name" > < / label >
< label > Ålder (18 år eller äldre) < input name = "age" type = "number" min = "18" required value = "30" > < / label >
< label > Biografi < textarea name = "biography" required maxlength = "20000" rows = "6" placeholder = "Berätta fritt om personens liv och nuläge." > < / textarea > < / label >
< button type = "submit" > Börja spela< / button >
< / form >
< / section >
< section id = "world-setup" aria-labelledby = "world-setup-heading" hidden >
< h2 id = "world-setup-heading" > Skapa din startvärld< / h2 >
< p > Beskriv vad du vill börja med. AI:n föreslår en plats och en person; du kan redigera allt innan världen sparas.< / p >
< form id = "world-idea-form" >
< label > Din idé < textarea name = "idea" required maxlength = "4000" rows = "4" placeholder = "Till exempel: en småstad vid kusten där min karaktär just har flyttat in." > < / textarea > < / label >
< button type = "submit" > Skapa ett förslag< / button >
< / form >
< form id = "world-draft-form" hidden >
< label > Världens namn < input name = "name" required maxlength = "120" > < / label >
< label > Plats < input name = "location_name" required maxlength = "120" > < / label >
< label > Beskrivning av platsen < textarea name = "location_description" required maxlength = "2000" rows = "4" > < / textarea > < / label >
< label > Person du kan möta < input name = "npc_name" required maxlength = "120" > < / label >
< label > Om personen < textarea name = "npc_biography" required maxlength = "2000" rows = "4" > < / textarea > < / label >
< div class = "row" > < button type = "submit" > Godkänn och börja spela< / button > < button id = "discard-world-draft" type = "button" > Gör ett nytt förslag< / button > < / div >
< / form >
< / section >
< section id = "game" hidden >
< div class = "row" > < h2 id = "world-heading" > < / h2 > < button id = "logout" type = "button" > Logga ut< / button > < / div >
< section class = "card" aria-labelledby = "place-heading" >
< h3 id = "place-heading" > < / h3 > < p id = "place-description" > < / p >
< h3 id = "npc-heading" > < / h3 > < p id = "npc-description" > < / p >
< h3 id = "player-heading" > < / h3 > < p id = "player-biography" > < / p >
< / section >
< section class = "card" aria-labelledby = "conversation-heading" >
< h3 id = "conversation-heading" > Samtal< / h3 >
< ol id = "transcript" aria-label = "Sparat samtal" > < / ol >
< form id = "message-form" >
< form id = "message-form" >
< label id = "message-label" > Skriv ett meddelande < textarea name = "message" required maxlength = "4000" rows = "3" > < / textarea > < / label >
< label for = "message" id = "message-label" > Skriv ett meddelande< / label >
< button type = "submit" > Skicka < / button >
< textarea id = "message" name = "message" maxlength = "4000" aria-describedby = "message-help" autocomplete = "off" > < / textarea >
< span id = "message-help" class = "sr-only" > Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.< / span >
< / form >
< / form >
< / section >
< / section >
< section id = "admin-view" aria-labelledby = "admin-heading" hidden >
< h1 id = "admin-heading" > Administration< / h1 >
< p > Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.< / p >
< nav id = "admin-toolbar" aria-label = "Administrationsvyer" >
< button type = "button" data-admin-view = "players" > Spelare< / button >
< button type = "button" data-admin-view = "prompts" > Prompter< / button >
< button type = "button" data-admin-view = "rules" > Luau-regler< / button >
< button type = "button" data-admin-view = "audit" > Revisionslogg< / button >
< button type = "button" id = "admin-logout" > Logga ut< / button >
< / nav >
< div id = "admin-content" aria-live = "polite" aria-busy = "false" > < / div >
< / section >
< / section >
< / main >
< / main >
< footer > Frontend < span id = "frontend-version" > __FRONTEND_VERSION__< / span > · API < span id = "api-version" > hämtar version…< / span > < / footer >
< footer id = "version-footer" > Frontend < span id = "frontend-version" > __FRONTEND_VERSION__< / span > · API < span id = "api-version" > hämtar version…< / span > < / footer >
< script >
< script >
const status = document . querySelector ( '#status' ) ;
const auth = document . querySelector ( '#auth' ) ;
const auth = document . querySelector ( '#auth' ) ;
const characterSection = document . querySelector ( '#new-character ' ) ;
const play = document . querySelector ( '#play ' ) ;
const worldSetup = document . querySelector ( '#world-setup ' ) ;
const adminView = document . querySelector ( '#admin-view ' ) ;
const game = document . querySelector ( '#game ' ) ;
const adminContent = document . querySelector ( '#admin-content ' ) ;
const transcript = document . querySelector ( '#dialogue' ) ;
const messageField = document . querySelector ( '#message' ) ;
const messageLabel = document . querySelector ( '#message-label' ) ;
let stage = 'auth' ;
let characterDraft = null ;
let worldDraft = null ;
let gameState = null ;
let gameState = null ;
let submitBusy = false ;
let pendingDialogue = null ;
let adminOffset = 0 ;
const adminPageSize = 50 ;
async function request ( path , options = { } ) {
async function request ( path , options = { } ) {
const response = await fetch ( path , { credentials : 'same-origin' , ... options } ) ;
const response = await fetch ( path , { credentials : 'same-origin' , ... options } ) ;
const body = response . status === 204 ? null : await response . json ( ) ;
const body = response . status === 204 ? null : await response . json ( ) ;
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
return body ;
return body ;
}
}
request ( '/version' ) . then ( ( { version } ) => {
function appendLine ( role , content ) {
document . querySelector ( '#api-version' ) . textContent = version ;
const line = document . createElement ( 'li' ) ;
} ) . catch ( ( ) => {
line . dataset . role = role ;
document . querySelector ( '#api-version' ) . textContent = 'otillgänglig' ;
line . textContent = ` ${ role === 'user' ? 'Du' : 'Berättelsen' } : ${ content } ` ;
transcript . append ( line ) ;
line . scrollIntoView ( { block : 'end' } ) ;
return line ;
}
function setStage ( next ) {
stage = next ;
messageField . disabled = false ;
messageField . value = '' ;
messageLabel . textContent = next === 'biography'
? 'Berätta fritt om personen du vill spela'
: next === 'world-start'
? 'Skriv “försök igen” för att försöka skapa startplatsen'
: next === 'world-draft'
? 'Ändra startförslaget eller skriv “godkänn”'
: 'Skriv vad du vill göra eller fråga' ;
messageField . focus ( ) ;
}
function showAuth ( ) {
stage = 'auth' ;
play . hidden = true ;
adminView . hidden = true ;
auth . hidden = false ;
document . querySelector ( '#auth-heading' ) . textContent = location . pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator' ;
document . querySelector ( '#version-footer' ) . hidden = false ;
}
function showPlay ( ) {
auth . hidden = true ;
adminView . hidden = true ;
play . hidden = false ;
document . querySelector ( '#version-footer' ) . hidden = true ;
messageField . focus ( ) ;
}
function showAdmin ( ) {
auth . hidden = true ;
play . hidden = true ;
adminView . hidden = false ;
document . querySelector ( '#version-footer' ) . hidden = true ;
}
function adminElement ( tag , text , parent = adminContent ) {
const element = document . createElement ( tag ) ;
if ( text !== undefined ) element . textContent = text ;
parent . append ( element ) ;
return element ;
}
function adminJson ( title , value , parent = adminContent ) {
const details = adminElement ( 'details' , undefined , parent ) ;
adminElement ( 'summary' , title , details ) ;
adminElement ( 'pre' , JSON . stringify ( value , null , 2 ) , details ) ;
return details ;
}
async function loadAdminPlayers ( query = '' , offset = 0 ) {
adminOffset = offset ;
adminContent . replaceChildren ( ) ;
const search = adminElement ( 'form' ) ;
search . id = 'player-search' ;
const label = adminElement ( 'label' , 'Sök efter användarnamn eller karaktär' , search ) ;
const field = document . createElement ( 'input' ) ;
field . type = 'search' ; field . name = 'q' ; field . value = query ; field . maxLength = 100 ;
label . append ( field ) ;
const submit = adminElement ( 'button' , 'Sök' , search ) ; submit . type = 'submit' ;
search . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminPlayers ( field . value , 0 ) ; } ) ;
const result = await request ( ` /api/admin/players?q= ${ encodeURIComponent ( query ) } &limit= ${ adminPageSize } &offset= ${ offset } ` ) ;
adminElement ( 'p' , ` ${ result . total } spelare. Visar ${ result . offset + 1 } – ${ Math . min ( result . total , result . offset + result . players . length ) } . ` , adminContent ) ;
const list = adminElement ( 'ul' ) ;
for ( const player of result . players ) {
const item = adminElement ( 'li' , undefined , list ) ;
const button = adminElement ( 'button' , ` ${ player . username } · ${ player . character _name || 'ingen karaktär' } · världsversion ${ player . world _version ? ? 'saknas' } ` , item ) ;
button . type = 'button' ; button . addEventListener ( 'click' , ( ) => openAdminPlayer ( player . account _id ) ) ;
}
const paging = adminElement ( 'div' ) ;
const previous = adminElement ( 'button' , 'Föregående sida' , paging ) ;
previous . type = 'button' ; previous . disabled = offset <= 0 ; previous . addEventListener ( 'click' , ( ) => loadAdminPlayers ( query , Math . max ( 0 , offset - adminPageSize ) ) ) ;
const next = adminElement ( 'button' , 'Nästa sida' , paging ) ;
next . type = 'button' ; next . disabled = offset + result . players . length >= result . total ; next . addEventListener ( 'click' , ( ) => loadAdminPlayers ( query , offset + adminPageSize ) ) ;
}
async function openAdminPlayer ( accountId ) {
adminContent . replaceChildren ( ) ;
const back = adminElement ( 'button' , 'Tillbaka till spelare' ) ; back . type = 'button' ; back . addEventListener ( 'click' , ( ) => loadAdminPlayers ( '' , adminOffset ) ) ;
adminElement ( 'h2' , 'Spelarprofil' ) ;
const profile = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } ` ) ;
adminJson ( ` ${ profile . username } · profil och biografi ` , profile ) ;
const trace = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } /trace?limit=200 ` ) ;
const entries = [
... trace . events . map ( event => ( { kind : 'Världshändelse' , at : event . recorded _at , order : event . commit _sequence , data : event } ) ) ,
... trace . ai _interactions . map ( interaction => ( { kind : ` AI ${ interaction . prompt _id } · ${ interaction . outcome } ` , at : interaction . requested _at , order : interaction . correlation _id , data : interaction } ) )
] . sort ( ( a , b ) => String ( a . at ) . localeCompare ( String ( b . at ) ) ) ;
adminElement ( 'h2' , 'Kronologiskt spelarspår' ) ;
adminElement ( 'p' , 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.' ) ;
for ( const [ index , entry ] of entries . entries ( ) ) {
const status = entry . data . execution _mode || entry . data . metadata ? . decision || entry . data . outcome || 'status okänd' ;
adminJson ( ` ${ index + 1 } . ${ entry . kind } · ${ status } · ${ entry . at } · ${ entry . order } ` , entry . data ) ;
}
}
async function loadAdminPrompts ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/prompts' ) ;
adminElement ( 'h2' , 'Prompttillgångar' ) ;
for ( const prompt of result . prompts ) adminJson ( ` ${ prompt . id } · version ${ prompt . version } ` , prompt . yaml ) ;
}
async function loadAdminRules ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/rules' ) ;
adminElement ( 'h2' , 'Luau-regler' ) ;
for ( const rule of result . rules ) adminJson ( ` ${ rule . rule _key } · v ${ rule . version } · ${ rule . active ? 'aktiv' : 'inaktiv' } · ${ rule . source _hash } ` , { source : rule . source , validation _results : rule . validation _results , created _at : rule . created _at } ) ;
}
async function loadAdminAudit ( ) {
adminContent . replaceChildren ( ) ;
const result = await request ( '/api/admin/audit?limit=200' ) ;
adminElement ( 'h2' , 'Revisionslogg' ) ;
for ( const entry of result . entries ) adminJson ( ` ${ entry . created _at } · ${ entry . action } · ${ entry . admin _account _id || entry . operator _identity } ` , entry ) ;
}
async function openAdmin ( ) {
showAdmin ( ) ;
adminContent . setAttribute ( 'aria-busy' , 'true' ) ;
try { await loadAdminPlayers ( ) ; }
catch ( error ) { adminContent . replaceChildren ( ) ; adminElement ( 'p' , ` Administrationsvyn kunde inte öppnas: ${ error . message } ` ) ; }
finally { adminContent . setAttribute ( 'aria-busy' , 'false' ) ; }
}
function appendInitialPrompt ( ) {
appendLine ( 'assistant' , 'Välkommen. Skriv fritt om personen du vill spela. Jag föreslår en vuxen biografi som du kan ändra innan du godkänner den.' ) ;
setStage ( 'biography' ) ;
}
async function proposeWorld ( message ) {
stage = 'world-start' ;
messageField . disabled = true ;
appendLine ( 'user' , message ) ;
appendLine ( 'assistant' , 'Jag tar fram ett förslag på startplats och startläge…' ) ;
try {
const proposal = await request ( '/api/world/draft' , {
method : 'POST' , headers : { 'content-type' : 'application/json' } ,
body : JSON . stringify ( { input : message , current _draft : worldDraft } ) ,
} ) ;
} ) ;
function showAuth ( ) { auth . hidden = false ; characterSection . hidden = true ; worldSetup . hidden = true ; game . hidden = true ; }
worldDraft = proposal . draft ;
function showCharacter ( ) { auth . hidden = true ; characterSection . hidden = false ; worldSetup . hidden = true ; game . hidden = true ; }
const senses = worldDraft . location _senses ;
function showWorldSetup ( ) { auth . hidden = true ; characterSection . hidden = true ; worldSetup . hidden = false ; game . hidden = true ; }
appendLine ( 'assistant' , ` ${ proposal . message } \n \n Värld: ${ worldDraft . name } \n Startplats: ${ worldDraft . location _name } . ${ worldDraft . location _description } \n Ljud: ${ senses . sound } \n Doft: ${ senses . smell } \n Luft och temperatur: ${ senses . air _and _temperature } \n Beröring och ytor: ${ senses . touch _and _surfaces } \n Orientering: ${ senses . spatial _orientation } \n Startbelopp: ${ worldDraft . starting _balance _sek } kronor. ${ worldDraft . offer _name } kostar ${ worldDraft . offer _price _sek } kronor. \n \n Skriv ändringar eller “godkänn”. ` ) ;
function renderConversation ( lines , npcName ) {
} catch ( error ) {
const transcript = document . querySelector ( '#transcript' ) ;
appendLine ( 'assistant' , ` Förslaget kunde inte tas fram: ${ error . message } . Skriv “försök igen” när du vill försöka på nytt. ` ) ;
transcript . replaceChildren ( ... lines . map ( ( line ) => {
}
const entry = document . createElement ( 'li ') ;
setStage ( 'world-draft ') ;
entry . textContent = ` ${ line . role === 'user' ? 'Du' : npcName } : ${ line . content } ` ;
messageField . disabled = false ;
return entry ;
messageField . focus ( ) ;
} ) ) ;
}
async function approveWorldDraft ( message ) {
if ( ! worldDraft ) {
appendLine ( 'assistant' , 'Det finns inget världsförslag att godkänna. Skriv “försök igen” först.' ) ;
return ;
}
appendLine ( 'user' , message ) ;
messageField . disabled = true ;
try {
gameState = await request ( '/api/world/start' , {
method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( worldDraft ) ,
} ) ;
worldDraft = null ;
await loadSavedDialogue ( ) ;
setStage ( 'game' ) ;
appendLine ( 'assistant' , 'Du kan fråga om platsen, dina saker eller din ekonomi, vänta, handla, prata med någon som finns här eller skriva vart du vill gå.' ) ;
} catch ( error ) {
appendLine ( 'assistant' , ` Världsförslaget kunde inte sparas: ${ error . message } . Förslaget finns kvar så att du kan försöka igen. ` ) ;
}
messageField . disabled = false ;
messageField . focus ( ) ;
}
async function loadSavedDialogue ( ) {
const lines = await request ( '/api/dialogue' ) ;
transcript . replaceChildren ( ) ;
for ( const line of lines ) appendLine ( line . role === 'user' ? 'user' : 'assistant' , line . content ) ;
if ( lines . length === 0 && gameState ? . world ) {
const place = gameState . world . location ;
const senses = place . senses || { } ;
appendLine ( 'assistant' , ` Du börjar på ${ place . name } . ${ place . description } Ljud: ${ senses . sound || 'Inget särskilt ljud beskrivet.' } Doft: ${ senses . smell || 'Ingen särskild doft beskriven.' } Luft och temperatur: ${ senses . air _and _temperature || 'Inte beskrivet.' } Beröring och ytor: ${ senses . touch _and _surfaces || 'Inte beskrivet.' } Orientering: ${ senses . spatial _orientation || 'Inte beskrivet.' } ` ) ;
}
}
}
async function openGame ( ) {
async function openGame ( ) {
gameState = await request ( '/api/world' ) ;
gameState = await request ( '/api/world' ) ;
if ( ! gameState . character ) { showCharacter ( ) ; return ; }
showPlay ( ) ;
if ( ! gameState . world ) { showWorldSetup ( ) ; return ; }
if ( ! gameState . character ) {
const world = gameState . world ;
transcript . replaceChildren ( ) ;
auth . hidden = true ; characterSection . hidden = true ; game . hidden = false ;
appendInitialPrompt ( ) ;
worldSetup . hidden = true ;
return ;
document . querySelector ( '#world-heading' ) . textContent = world . name ;
document . querySelector ( '#place-heading' ) . textContent = world . location . name ;
document . querySelector ( '#place-description' ) . textContent = world . location . description ;
document . querySelector ( '#npc-heading' ) . textContent = world . npc . name ;
document . querySelector ( '#npc-description' ) . textContent = world . npc . biography ;
document . querySelector ( '#conversation-heading' ) . textContent = ` Samtal med ${ world . npc . name } ` ;
document . querySelector ( '#message-label' ) . firstChild . textContent = ` Skriv till ${ world . npc . name } ` ;
document . querySelector ( '#player-heading' ) . textContent = ` ${ gameState . character . name } , ${ gameState . character . age } år ` ;
document . querySelector ( '#player-biography' ) . textContent = gameState . character . biography ;
renderConversation ( await request ( '/api/npc/conversation' ) , world . npc . name ) ;
}
}
characterDraft = null ;
if ( ! gameState . world ) {
worldDraft = null ;
appendLine ( 'assistant' , 'Nu föreslår jag en startplats som passar biografin. Du kan ändra förslaget innan något sparas. Skriv “börja”.' ) ;
setStage ( 'world-draft' ) ;
return ;
}
await loadSavedDialogue ( ) ;
setStage ( 'game' ) ;
}
async function handleLogout ( ) {
await request ( '/api/logout' , { method : 'POST' } ) ;
characterDraft = null ;
gameState = null ;
transcript . replaceChildren ( ) ;
showAuth ( ) ;
}
async function handleBiography ( message ) {
if ( /^(godkänn|jag godkänner|godkänn biografin)[.!]?$/i . test ( message . trim ( ) ) ) {
if ( ! characterDraft ) {
appendLine ( 'assistant' , 'Jag har inget biografiutkast att godkänna än. Berätta först om personen du vill spela.' ) ;
return ;
}
appendLine ( 'user' , message ) ;
messageField . disabled = true ;
appendLine ( 'assistant' , 'Sparar den godkända karaktären…' ) ;
try {
await request ( '/api/character' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( characterDraft ) } ) ;
characterDraft = null ;
await openGame ( ) ;
} catch ( error ) {
if ( error . message === 'character_already_exists' ) {
await openGame ( ) ;
} else {
appendLine ( 'assistant' , ` Karaktären kunde inte sparas: ${ error . message } . Utkastet finns kvar så att du kan försöka igen. ` ) ;
setStage ( 'biography' ) ;
}
}
return ;
}
appendLine ( 'user' , message ) ;
messageField . disabled = true ;
try {
const proposal = await request ( '/api/character/draft' , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' } ,
body : JSON . stringify ( { input : message , current _draft : characterDraft } ) ,
} ) ;
characterDraft = proposal . draft ;
appendLine ( 'assistant' , ` ${ proposal . message } \n \n Förslag: ${ characterDraft . name } , ${ characterDraft . age } år. \n ${ characterDraft . biography } \n \n Skriv ändringar eller “godkänn”. ` ) ;
} catch ( error ) {
appendLine ( 'assistant' , ` Jag kunde inte ta fram ett biografiförslag: ${ error . message } . Försök igen när tjänsten svarar. ` ) ;
}
messageField . disabled = false ;
messageField . focus ( ) ;
}
async function handleGameMessage ( message ) {
if ( /^logga ut[.!]?$/i . test ( message . trim ( ) ) ) {
appendLine ( 'user' , message ) ;
await handleLogout ( ) ;
return ;
}
if ( stage === 'world-start' ) {
if ( /^försök igen[.!]?$/i . test ( message . trim ( ) ) ) {
await proposeWorld ( message ) ;
} else {
appendLine ( 'user' , message ) ;
appendLine ( 'assistant' , 'Startplatsen är inte skapad än. Skriv “försök igen” när du vill försöka på nytt.' ) ;
}
return ;
}
if ( stage === 'world-draft' ) {
if ( /^godkänn[.!]?$/i . test ( message . trim ( ) ) ) await approveWorldDraft ( message ) ;
else await proposeWorld ( message ) ;
return ;
}
const idempotencyKey = pendingDialogue ? . message === message
? pendingDialogue . idempotencyKey
: crypto . randomUUID ( ) ;
pendingDialogue = { message , idempotencyKey } ;
appendLine ( 'user' , message ) ;
messageField . disabled = true ;
try {
let result ;
for ( let attempt = 0 ; attempt < 2 ; attempt += 1 ) {
try {
result = await request ( '/api/dialogue' , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' } ,
body : JSON . stringify ( { message , expected _world _version : gameState . world . version , idempotency _key : idempotencyKey } ) ,
} ) ;
break ;
} catch ( error ) {
if ( error . message !== 'world_version_conflict' || attempt > 0 ) throw error ;
gameState = await request ( '/api/world' ) ;
if ( ! gameState . world ) throw new Error ( 'world_not_available' ) ;
}
}
gameState . world . version = result . world _version ;
gameState . world . world _time = result . world _time ;
pendingDialogue = null ;
appendLine ( 'assistant' , result . reply ) ;
} catch ( error ) {
messageField . value = message ;
appendLine ( 'assistant' , ` Meddelandet kunde inte behandlas: ${ error . message } . Texten ligger kvar i inmatningen; skicka igen för att försöka på nytt. ` ) ;
}
messageField . disabled = false ;
messageField . focus ( ) ;
}
document . querySelector ( '#auth-form' ) . addEventListener ( 'submit' , async ( event ) => {
document . querySelector ( '#auth-form' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( ) ;
event . preventDefault ( ) ;
const form = event . currentTarget ;
const form = event . currentTarget ;
const data = new FormData ( form ) ;
const data = new FormData ( form ) ;
const intent = event . submitter ? . value || 'login' ;
const intent = event . submitter ? . value || 'login' ;
status . textContent = intent === 'register' ? 'Skapar konto…' : 'Loggar in…' ;
try {
try {
await request ( ` /api/ ${ intent } ` , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { username : data . get ( 'username' ) , password : data . get ( 'password' ) } ) } ) ;
await request ( ` /api/ ${ intent } ` , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { username : data . get ( 'username' ) , password : data . get ( 'password' ) } ) } ) ;
form . reset ( ) ; await openGame ( ) ; status . textContent = 'Du är inloggad.' ;
form . reset ( ) ;
} catch ( error ) { status . textContent = ` Kunde inte logga in: ${ error . message } ` ; }
if ( location . pathname === '/admin' ) await openAdmin ( ) ; else await openGame ( ) ;
} ) ;
} catch ( error ) {
document . querySelector ( '#character-form' ) . addEventListener ( 'submit' , async ( event ) => {
window . alert ( ` Kunde inte logga in: ${ error . message } ` ) ;
event . preventDefault ( ) ; const data = new FormData ( event . currentTarget ) ; status . textContent = 'Sparar din karaktär…' ;
}
try { await request ( '/api/character' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { name : data . get ( 'name' ) , age : Number ( data . get ( 'age' ) ) , biography : data . get ( 'biography' ) } ) } ) ; await openGame ( ) ; status . textContent = 'Karaktären har sparats.' ; }
catch ( error ) { status . textContent = ` Kunde inte spara karaktären: ${ error . message } ` ; }
} ) ;
document . querySelector ( '#world-idea-form' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( ) ; const data = new FormData ( event . currentTarget ) ; status . textContent = 'Skapar ett redigerbart förslag…' ;
try {
const proposal = await request ( '/api/world/draft' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { idea : data . get ( 'idea' ) } ) } ) ;
const form = document . querySelector ( '#world-draft-form' ) ;
for ( const key of [ 'name' , 'location_name' , 'location_description' , 'npc_name' , 'npc_biography' ] ) form . elements [ key ] . value = proposal [ key ] ;
form . hidden = false ; status . textContent = 'Förslaget är klart. Granska och redigera det innan du godkänner.' ;
} catch ( error ) { status . textContent = ` Kunde inte skapa ett förslag: ${ error . message } ` ; }
} ) ;
document . querySelector ( '#discard-world-draft' ) . addEventListener ( 'click' , ( ) => {
document . querySelector ( '#world-draft-form' ) . hidden = true ; status . textContent = 'Förslaget har inte sparats. Ändra din idé och försök igen.' ;
} ) ;
document . querySelector ( '#world-draft-form' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( ) ; const data = new FormData ( event . currentTarget ) ; status . textContent = 'Sparar din godkända värld…' ;
try {
await request ( '/api/world' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( Object . fromEntries ( data . entries ( ) ) ) } ) ;
await openGame ( ) ; status . textContent = 'Din värld har sparats.' ;
} catch ( error ) { status . textContent = ` Kunde inte spara världen: ${ error . message } ` ; }
} ) ;
} ) ;
document . querySelector ( '#message-form' ) . addEventListener ( 'submit' , async ( event ) => {
document . querySelector ( '#message-form' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( ) ; const form = event . currentTarget ; const field = form . elements . message ; const message = field . value . trim ( ) ; if ( ! message ) return ;
event . preventDefault ( ) ;
status . textContent = 'Din samtalspartner funderar på sitt svar…' ;
if ( submitBusy || messageField . disabled ) return ;
try { const result = await request ( '/api/npc/messages' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { message , idempotency _key : crypto . randomUUID ( ) } ) } ) ; field . value = '' ; await openGame ( ) ; status . textContent = 'Samtalet har sparats.' ; }
const message = messageField . value . trim ( ) ;
catch ( error ) { status . textContent = ` Samtalet kunde inte skickas: ${ error . message } ` ; }
if ( ! message ) return ;
submitBusy = true ;
messageField . value = '' ;
try {
if ( stage === 'biography' ) await handleBiography ( message ) ;
else await handleGameMessage ( message ) ;
} catch ( error ) {
appendLine ( 'assistant' , ` Det gick inte att genomföra begäran: ${ error . message } ` ) ;
} finally {
submitBusy = false ;
if ( ! play . hidden && ! messageField . disabled ) messageField . focus ( ) ;
}
} ) ;
} ) ;
document . querySelector ( '#logout' ) . addEventListener ( 'click' , async ( ) => { try { await request ( '/api/logout' , { method : 'POST' } ) ; showAuth ( ) ; status . textContent = 'Du har loggat ut.' ; } catch ( error ) { status . textContent = ` Kunde inte logga ut: ${ error . message } ` ; } } ) ;
document . querySelectorAll ( '[data-admin-view]' ) . forEach ( button => button . addEventListener ( 'click' , async ( ) => {
request ( '/api/me' ) . then ( async ( ) => { await openGame ( ) ; status . textContent = 'Din sparade spelvärld har lästs in.' ; } ) . catch ( ( ) => { showAuth ( ) ; } ) ;
adminContent . setAttribute ( 'aria-busy' , 'true' ) ;
try {
if ( button . dataset . adminView === 'players' ) await loadAdminPlayers ( ) ;
else if ( button . dataset . adminView === 'prompts' ) await loadAdminPrompts ( ) ;
else if ( button . dataset . adminView === 'rules' ) await loadAdminRules ( ) ;
else await loadAdminAudit ( ) ;
} catch ( error ) { adminContent . replaceChildren ( ) ; adminElement ( 'p' , ` Vyn kunde inte läsas: ${ error . message } ` ) ; }
finally { adminContent . setAttribute ( 'aria-busy' , 'false' ) ; }
} ) ) ;
document . querySelector ( '#admin-logout' ) . addEventListener ( 'click' , handleLogout ) ;
messageField . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Enter' && ! event . shiftKey && ! event . isComposing ) {
event . preventDefault ( ) ;
document . querySelector ( '#message-form' ) . requestSubmit ( ) ;
}
} ) ;
request ( '/version' ) . then ( ( { version } ) => {
document . querySelector ( '#api-version' ) . textContent = version ;
} ) . catch ( ( ) => {
document . querySelector ( '#api-version' ) . textContent = 'otillgänglig' ;
} ) ;
request ( '/api/me' ) . then ( ( ) => location . pathname === '/admin' ? openAdmin ( ) : openGame ( ) ) . catch ( showAuth ) ;
< / script >
< / script >
< / body >
< / body >
< / html >
< / html >