|
|
|
@@ -6,223 +6,312 @@
|
|
|
|
|
<meta name="color-scheme" content="light dark">
|
|
|
|
|
<title>Svensk livssimulator</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { font: 1rem/1.55 system-ui, sans-serif; }
|
|
|
|
|
body { max-width: 52rem; margin: 0 auto; padding: 1.25rem; }
|
|
|
|
|
h1, h2, h3 { line-height: 1.2; }
|
|
|
|
|
footer { border-top: 1px solid; margin-top: 2rem; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
|
|
|
|
form { display: grid; gap: .75rem; max-width: 40rem; }
|
|
|
|
|
label { display: grid; gap: .2rem; font-weight: 600; }
|
|
|
|
|
input, textarea, button { font: inherit; padding: .6rem; }
|
|
|
|
|
:root { font: 1rem/1.6 system-ui, sans-serif; }
|
|
|
|
|
body { max-width: 52rem; margin: 0 auto; padding: 1rem; }
|
|
|
|
|
h1, h2 { line-height: 1.2; }
|
|
|
|
|
form { display: grid; gap: .75rem; max-width: 32rem; }
|
|
|
|
|
label { display: grid; gap: .25rem; font-weight: 600; }
|
|
|
|
|
input, textarea, button { font: inherit; padding: .65rem; }
|
|
|
|
|
button { justify-self: start; cursor: pointer; }
|
|
|
|
|
#status { min-height: 1.5em; }
|
|
|
|
|
.card { border: 1px solid; border-radius: .5rem; padding: 1rem; margin-block: 1rem; }
|
|
|
|
|
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
|
|
|
|
|
.row button { margin-block: .4rem; }
|
|
|
|
|
#transcript { min-height: 5rem; max-height: 28rem; overflow: auto; }
|
|
|
|
|
#transcript li { margin-block: .6rem; }
|
|
|
|
|
#dialogue { list-style: none; padding: 0; margin: 0; }
|
|
|
|
|
#dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
|
|
#dialogue li[data-role="user"] { font-weight: 650; }
|
|
|
|
|
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
|
|
|
|
|
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
|
|
|
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
|
|
|
[hidden] { display: none !important; }
|
|
|
|
|
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
|
|
|
|
</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 konto</h2>
|
|
|
|
|
<h1 id="auth-heading">Svensk livssimulator</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-heading" 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">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>
|
|
|
|
|
<h3>Förslag på startläge</h3>
|
|
|
|
|
<p>Startpengar och varan är bara förslag. De sparas först när du redigerat och godkänt dem.</p>
|
|
|
|
|
<label>Startsaldo i kronor <input name="starting_balance_sek" type="number" min="0" step="1" required></label>
|
|
|
|
|
<label>En vara som går att köpa här <input name="offer_name" required maxlength="120"></label>
|
|
|
|
|
<label>Pris i hela kronor <input name="offer_price_sek" type="number" min="1" step="1" required></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>
|
|
|
|
|
<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" aria-labelledby="conversation-heading">
|
|
|
|
|
<h3 id="conversation-heading">Samtal</h3>
|
|
|
|
|
<ol id="transcript" aria-label="Sparat samtal"></ol>
|
|
|
|
|
<section id="play" aria-label="Spelets dialog" 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>
|
|
|
|
|
</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 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;
|
|
|
|
|
|
|
|
|
|
async function request(path, 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;
|
|
|
|
|
auth.hidden = false;
|
|
|
|
|
document.querySelector('#version-footer').hidden = false;
|
|
|
|
|
}
|
|
|
|
|
function showPlay() {
|
|
|
|
|
auth.hidden = true;
|
|
|
|
|
play.hidden = false;
|
|
|
|
|
document.querySelector('#version-footer').hidden = true;
|
|
|
|
|
messageField.focus();
|
|
|
|
|
}
|
|
|
|
|
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\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nLjud: ${senses.sound}\nDoft: ${senses.smell}\nLuft och temperatur: ${senses.air_and_temperature}\nBeröring och ytor: ${senses.touch_and_surfaces}\nOrientering: ${senses.spatial_orientation}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ä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(); 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;
|
|
|
|
|
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;
|
|
|
|
|
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`);
|
|
|
|
|
showPlay();
|
|
|
|
|
if (!gameState.character) {
|
|
|
|
|
transcript.replaceChildren();
|
|
|
|
|
appendInitialPrompt();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
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\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}\n\nSkriv ä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(); 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(); 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','starting_balance_sek','offer_name','offer_price_sek']) 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 {
|
|
|
|
|
const approval = Object.fromEntries(data.entries());
|
|
|
|
|
approval.starting_balance_sek = Number(approval.starting_balance_sek);
|
|
|
|
|
approval.offer_price_sek = Number(approval.offer_price_sek);
|
|
|
|
|
await request('/api/world', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(approval)});
|
|
|
|
|
await openGame(); status.textContent='Din värld har sparats.';
|
|
|
|
|
} catch(error) { status.textContent=`Kunde inte spara världen: ${error.message}`; }
|
|
|
|
|
form.reset();
|
|
|
|
|
await openGame();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
window.alert(`Kunde inte logga in: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
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…';
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (submitBusy || messageField.disabled) return;
|
|
|
|
|
const message = messageField.value.trim();
|
|
|
|
|
if (!message) return;
|
|
|
|
|
submitBusy = true;
|
|
|
|
|
messageField.value = '';
|
|
|
|
|
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);
|
|
|
|
|
if (stage === 'biography') await handleBiography(message);
|
|
|
|
|
else await handleGameMessage(message);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
status.textContent = `Köpet kunde inte genomföras: ${error.message}`;
|
|
|
|
|
return;
|
|
|
|
|
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
|
|
|
|
|
} finally {
|
|
|
|
|
submitBusy = false;
|
|
|
|
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
|
|
|
|
}
|
|
|
|
|
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(); });
|
|
|
|
|
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(openGame).catch(showAuth);
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|