@@ -6,208 +6,438 @@
< meta name = "color-scheme" content = "light dark" >
< title > Svensk livssimulator< / title >
< style >
: root { font : 1 rem / 1.55 system-ui , sans-serif ; }
body { max-width : 52 rem ; margin : 0 auto ; padding : 1.25 rem ; }
h1 , h2 , h3 { 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 : 40 rem ; }
label { display : grid ; gap : .2 rem ; font-weight : 600 ; }
input , textarea , button { font : inherit ; padding : .6 rem ; }
: root { font : 1 rem / 1.6 system-ui , sans-serif ; }
body { max-width : 52 rem ; margin : 0 auto ; padding : 1 rem ; }
h1 , h2 { line-height : 1.2 ; }
form { display : grid ; gap : .75 rem ; max-width : 32 rem ; }
label { display : grid ; gap : .25 rem ; font-weight : 600 ; }
input , textarea , button { font : inherit ; padding : .65 rem ; }
button { justify-self : start ; cursor : pointer ; }
# status { min-height : 1.5 em ; }
. card { border : 1 px solid ; border-radius : .5 rem ; padding : 1 rem ; margin-block : 1 rem ; }
. row { display : flex ; flex-wrap : wrap ; gap : .75 rem ; align-items : center ; }
. row button { margin-block : .4 rem ; }
# transcript { min-height : 5 rem ; max-height : 28 rem ; overflow : auto ; }
# transcript li { margin-block : .6 rem ; }
# dialogue { list-style : none ; padding : 0 ; margin : 0 ; }
# dialogue li { margin-block : 1 rem ; white-space : pre-wrap ; overflow-wrap : anywhere ; }
# dialogue li [ data-role = "user" ] { font-weight : 650 ; }
# dialogue li [ data-role = "assistant" ] { padding-inline-start : .75 rem ; border-inline-start : .2 rem solid currentColor ; }
# message { width : 100 % ; min-height : 3.5 rem ; box-sizing : border-box ; }
# 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 ; }
footer { margin-top : 2 rem ; border-top : 1 px solid ; padding-top : .5 rem ; color : GrayText ; font-size : .875 rem ; }
< / style >
< / head >
< body >
< 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" >
< 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" >
< 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 >
< 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 >
< / section >
< section id = "new-character " aria-labelledby = "character-headin g" hidden >
< h2 id = "character-heading" > Vem är du? < / h2 >
< p > Du väljer själv bakgrund. Karaktären ska vara vuxen, men du behöver inte välja yrke eller startpaket. < / p >
< 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" > Hitta din startplats< / h2 >
< p > Du behöver inte beskriva världen. Startplatsen formas utifrån din biografi. Människor upptäcks senare när du utforskar.< / p >
< button id = "start-world-button" type = "button" > Börja spela< / button >
< / 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 >
< p id = "place-status" role = "status" > < / p >
< h3 id = "npc-heading" > < / h3 > < p id = "npc-description" > < / p >
< h3 id = "player-heading" > < / h3 > < p id = "player-biography" > < / p >
< h3 > Ekonomi och ägodelar< / h3 >
< p > Saldo: < span id = "balance" > < / span > kr< / p >
< ul id = "inventory" aria-label = "Dina ägodelar" > < / ul >
< / section >
< section class = "card" id = "shop" aria-labelledby = "shop-heading" hidden >
< h3 id = "shop-heading" > Köp på platsen< / h3 >
< p > < span id = "offer-description" > < / span > < / p >
< p id = "offer-affordability" > < / p >
< button id = "buy-offer" type = "button" > Köp< / button >
< / section >
< section class = "card" id = "conversation-card" aria-labelledby = "conversation-heading" >
< h3 id = "conversation-heading" > Samtal< / h3 >
< ol id = "transcript" aria-label = "Sparat samtal" > < / ol >
< section id = "play " aria-label = "Spelets dialo g" hidden >
< h1 class = "sr-only" > Dialog < / h1 >
< ol id = "dialogue" aria-label = "Dialoghistorik" role = "log" aria-live = "polite" aria-relevant = "additions text" > < / ol >
< form id = "message-form" >
< label id = "message-label" > Skriv ett meddelande < textarea name = "message" required maxlength = "4000" rows = "3" > < / textarea > < / label >
< button type = "submit" > Skicka < / button >
< label for = "message" id = "message-label" > Skriv ett meddelande< / label >
< 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 >
< / 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 >
< / 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 >
const status = document . querySelector ( '#status' ) ;
const auth = document . querySelector ( '#auth' ) ;
const characterSection = document . querySelector ( '#new-character ' ) ;
const worldSetup = document . querySelector ( '#world-setup ' ) ;
const game = document . querySelector ( '#game ' ) ;
const play = document . querySelector ( '#play ' ) ;
const adminView = document . querySelector ( '#admin-view ' ) ;
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 submitBusy = false ;
let pendingDialogue = null ;
let adminOffset = 0 ;
const adminPageSize = 50 ;
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 ( ) ;
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
return body ;
}
request ( '/version' ) . then ( ( { version } ) => {
document . querySelector ( '#api-version' ) . textContent = version ;
} ) . catch ( ( ) => {
document . querySelector ( '#api-version' ) . textContent = 'otillgänglig' ;
function appendLine ( role , content ) {
const line = document . createElement ( 'li' ) ;
line . dataset . role = role ;
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 ) ;
if ( entry . data . external _call && Object . keys ( entry . data . external _call ) . length > 0 ) {
const call = entry . data . external _call ;
const outcome = call . error || ` HTTP ${ call . response _status ? ? 'okänt' } ` ;
adminJson ( ` Externt tjänsteanrop · ${ call . service || 'okänd tjänst' } · ${ outcome } · ${ call . duration _ms ? ? '?' } ms ` , call ) ;
}
}
}
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 ; }
function showCharacter ( ) { auth . hidden = true ; characterSection . hidden = false ; worldSetup . hidden = true ; game . hidden = true ; }
function showWorldSetup ( ) { auth . hidden = true ; characterSection . hidden = true ; worldSetup . hidden = false ; game . hidden = true ; }
function renderConversation ( lines , npcName ) {
const transcript = document . querySelector ( '#transcript' ) ;
transcript . replaceChildren ( ... lines . map ( ( line ) => {
const entry = document . createElement ( 'li ') ;
entry . textContent = ` ${ line . role === 'user' ? 'Du' : npcName } : ${ line . content } ` ;
return entry ;
} ) ) ;
worldDraft = proposal . draft ;
const senses = worldDraft . location _senses ;
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”. ` ) ;
} catch ( error ) {
appendLine ( 'assistant' , ` Förslaget kunde inte tas fram: ${ error . message } . Skriv “försök igen” när du vill försöka på nytt. ` ) ;
}
setStage ( 'world-draft ') ;
messageField . disabled = false ;
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 ( ) {
gameState = await request ( '/api/world' ) ;
if ( ! gameState . character ) { showCharacter ( ) ; return ; }
if ( ! gameState . world ) {
showWorldSetup ( ) ;
try {
status . textContent = 'Hittar en startplats som passar din biografi…' ;
gameState = await request ( '/api/world/start' , { method : 'POST' } ) ;
if ( ! gameState . world ) throw new Error ( 'world_start_not_created' ) ;
} catch ( error ) {
status . textContent = ` Kunde inte hitta en startplats: ${ error . message } ` ;
showPlay ( ) ;
if ( ! gameState . character ) {
transcript . replaceChildren ( ) ;
appendInitialPrompt ( ) ;
return ;
}
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 ;
}
const world = gameState . world ;
auth . hidden = true ; characterSection . hidden = true ; game . hidden = false ;
worldSetup . hidden = true ;
document . querySelector ( '#world-heading' ) . textContent = world . name ;
document . querySelector ( '#place-heading' ) . textContent = world . location . name ;
document . querySelector ( '#place-description' ) . textContent = world . location . description ;
const conversation = document . querySelector ( '#conversation-card' ) ;
if ( world . npc ) {
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 } ` ;
conversation . hidden = false ;
renderConversation ( await request ( '/api/npc/conversation' ) , world . npc . name ) ;
document . querySelector ( '#place-status' ) . textContent = '' ;
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 {
document . querySelector ( '#npc-heading' ) . textContent = '' ;
document . querySelector ( '#npc-description' ) . textContent = '' ;
document . querySelector ( '#place-status' ) . textContent = 'Du har inte mött någon här ännu. Vilka du möter beror på vart du tar dig i världen.' ;
conversation . hidden = true ;
}
document . querySelector ( '#player-heading' ) . textContent = ` ${ gameState . character . name } , ${ gameState . character . age } år ` ;
document . querySelector ( '#player-biography' ) . textContent = gameState . character . biography ;
document . querySelector ( '#balance' ) . textContent = ( world . balance _sek ? ? 0 ) . toLocaleString ( 'sv-SE' ) ;
const inventory = document . querySelector ( '#inventory' ) ;
inventory . replaceChildren ( ... ( world . inventory ? ? [ ] ) . map ( ( item ) => {
const entry = document . createElement ( 'li' ) ;
entry . textContent = ` ${ item . name } : ${ item . quantity } ` ;
return entry ;
} ) ) ;
const shop = document . querySelector ( '#shop' ) ;
shop . hidden = ! world . offer ;
if ( world . offer ) {
document . querySelector ( '#offer-description' ) . textContent = ` ${ world . offer . name } — ${ world . offer . price _sek . toLocaleString ( 'sv-SE' ) } kr ` ;
document . querySelector ( '#buy-offer' ) . disabled = ( world . balance _sek ? ? 0 ) < world . offer . price _sek ;
document . querySelector ( '#offer-affordability' ) . textContent = ( world . balance _sek ? ? 0 ) < world . offer . price _sek ? 'Du har inte tillräckligt saldo för det här köpet.' : '' ;
document . querySelector ( '#buy-offer' ) . setAttribute ( 'aria-label' , ` Köp ${ world . offer . name } för ${ world . offer . price _sek } kronor ` ) ;
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 ) => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const data = new FormData ( form ) ;
const intent = event . submitter ? . value || 'login' ;
status . textContent = intent === 'register' ? 'Skapar konto…' : 'Loggar in…' ;
try {
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 ( ) ;
if ( gameState ? . world || ! gameState ? . character ) status . textContent = 'Du är inloggad.' ;
} catch ( error ) { status . textContent = ` Kunde inte logga in: ${ error . message } ` ; }
} ) ;
document . querySelector ( '#character-form' ) . addEventListener ( 'submit' , async ( event ) => {
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 ( ) ; if ( gameState ? . world ) status . textContent = 'Karaktären har sparats och du har börjat på en ny plats.' ; }
catch ( error ) { status . textContent = ` Kunde inte spara karaktären: ${ error . message } ` ; }
} ) ;
document . querySelector ( '#start-world-button' ) . addEventListener ( 'click' , openGame ) ;
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 ;
status . textContent = 'Din samtalspartner funderar på sitt svar…' ;
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.' ; }
catch ( error ) { status . textContent = ` Samtalet kunde inte skickas: ${ error . message } ` ; }
} ) ;
document . querySelector ( '#buy-offer' ) . addEventListener ( 'click' , async ( ) => {
if ( ! gameState ? . world ? . offer ) return ;
const world = gameState . world ;
const storageKey = ` pending-purchase: ${ world . id } : ${ world . offer . id } ` ;
const idempotencyKey = sessionStorage . getItem ( storageKey ) || crypto . randomUUID ( ) ;
sessionStorage . setItem ( storageKey , idempotencyKey ) ;
const button = document . querySelector ( '#buy-offer' ) ;
button . disabled = true ;
status . textContent = 'Genomför köpet…' ;
try {
await request ( '/api/purchases' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { offer _id : world . offer . id , expected _world _version : world . version , idempotency _key : idempotencyKey } ) } ) ;
sessionStorage . removeItem ( storageKey ) ;
} catch ( error ) {
button . disabled = false ;
status . textContent = ` Köpet kunde inte genomföras: ${ error . message } ` ;
return ;
await request ( ` /api/ ${ intent } ` , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { username : data . get ( 'username' ) , password : data . get ( 'password' ) } ) } ) ;
form . reset ( ) ;
if ( location . pathname === '/admin' ) await openAdmin ( ) ; else await openGame ( ) ;
} catch ( error ) {
window . alert ( ` Kunde inte logga in: ${ error . message } ` ) ;
}
try {
await openGame ( ) ; status . textContent = 'Köpet är sparat i din värld.' ;
} catch ( error ) { status . textContent = 'Köpet är sparat. Världen kunde inte läsas in just nu; ladda om sidan för att visa det.' ; }
} ) ;
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 } ` ; } } ) ;
request ( '/api/me' ) . then ( async ( ) => { await openGame ( ) ; status . textContent = 'Din sparade spelvärld har lästs in.' ; } ) . catch ( ( ) => { showAuth ( ) ; } ) ;
document . querySelector ( '#message-form ' ) . addEventListener ( 'submit ' , async ( event ) => {
event . preventDefault ( ) ;
if ( submitBusy || messageField . disabled ) return ;
const message = messageField . value . trim ( ) ;
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 . querySelectorAll ( '[data-admin-view]' ) . forEach ( button => button . addEventListener ( 'click' , async ( ) => {
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 >
< / body >
< / html >