Compare commits

...
6 Commits
Author SHA1 Message Date
Gitea Actions e29414e7c0 Release 0.1.24 [auto-version-bump] 2026-10-15 08:30:02 +02:00
Ein Anderssono 17604e736b Add accessible admin tracing interface
Build and publish frontend / publish (push) Successful in 46s
2026-10-15 08:29:40 +02:00
Gitea Actions c462ef1871 Release 0.1.23 [auto-version-bump] 2026-10-15 01:24:42 +02:00
Ein Anderssono 94eef5d998 Use dialogue-only approved onboarding
Build and publish frontend / publish (push) Successful in 40s
2026-10-15 01:24:20 +02:00
Gitea Actions 4c36c5e421 Release 0.1.22 [auto-version-bump] 2026-10-14 23:00:16 +02:00
Ein Anderssono 370af29c47 Clarify text and dialogue based accessibility
Build and publish frontend / publish (push) Successful in 50s
2026-10-14 22:59:49 +02:00
5 changed files with 415 additions and 203 deletions
+7 -3
View File
@@ -2,9 +2,13 @@
This repository contains the static Swedish web client for the AI-driven life simulator. The intended deployment is a small Nginx pod in the `ai-spel` namespace. The existing Rust service remains authoritative for accounts, game state, and all `/api/...` routes. This repository contains the static Swedish web client for the AI-driven life simulator. The intended deployment is a small Nginx pod in the `ai-spel` namespace. The existing Rust service remains authoritative for accounts, game state, and all `/api/...` routes.
Players provide a character biography, not a world description. The client asks the backend to create a biography-based starting place. It does not preview a person the player may meet; encounters belong to later world exploration. After login, character creation and play use one dialogue history and one text input. The player writes a free biography, receives a temporary AI draft, can request revisions, and approves it by writing `godkänn`. The player then receives a proposed start world and place based on the biography. That draft can also be revised in dialogue and is saved only after a second explicit `godkänn`. Drafts stay in the browser until approval and are lost if the page is reloaded first. The backend creates the character and initializes the world only after their respective approvals.
Place descriptions are available as text with separate visual, sound, smell, air/temperature, touch/surface, and spatial-orientation cues so important scene information does not depend on sight. The current product has no image input or image interpretation flow; these cues are generated as part of the place proposal. During play the UI has no persistent location, task, inventory, economy, status, or purchase panels. The player asks for information or writes an action in the same input. Rust validates state changes; the AI can only propose a typed intent. A direct request to travel may establish a new place and an encounter when Rust commits that movement.
The separate `/admin` route provides an authenticated, read-only operations view for player search/profile/trace, YAML prompt assets, generated Luau rules with validation results, and the append-only audit log. All privileged data comes from the Rust `/api/admin/...` API; the browser never connects directly to PostgreSQL. Backend endpoints require `accounts.is_admin=true`.
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced, and Enter submits the input while Shift+Enter inserts a newline; no separate send button is required.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration. The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
@@ -14,6 +18,6 @@ The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`.
CI needs repository Action secrets named `HARBOR_USERNAME` and `HARBOR_PASSWORD`. The Harbor pull secret `harbor-pull` is already present in the Kubernetes namespace. Argo CD uses the existing `ai-spel` project and Harbor OCI credentials. CI needs repository Action secrets named `HARBOR_USERNAME` and `HARBOR_PASSWORD`. The Harbor pull secret `harbor-pull` is already present in the Kubernetes namespace. Argo CD uses the existing `ai-spel` project and Harbor OCI credentials.
The first Argo Application is described in `deploy/argocd/application.yaml`. The backend ingress must be switched to the frontend Service before public traffic uses this pod. That cutover is intentionally coordinated with a backend release so the API routes and login session remain on the same host. The first Argo Application is described in `deploy/argocd/application.yaml`. The backend ingress routes `/api`, `/version`, and health checks to Rust while serving the static client at `/`. Both services share the same origin so the session cookie remains same-origin.
Builds, checks, and runtime verification happen in the pipeline and Kubernetes. Do not run the app, tests, container images, or binaries locally. Builds, checks, and runtime verification happen in the pipeline and Kubernetes. Do not run the app, tests, container images, or binaries locally.
+1 -1
View File
@@ -1 +1 @@
0.1.21 0.1.24
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator description: Static web frontend for the Swedish life simulator
type: application type: application
version: 0.1.21 version: 0.1.24
appVersion: "0.1.21" appVersion: "0.1.24"
+380 -179
View File
@@ -6,232 +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: 1rem/1.55 system-ui, sans-serif; } :root { font: 1rem/1.6 system-ui, sans-serif; }
body { max-width: 52rem; margin: 0 auto; padding: 1.25rem; } body { max-width: 52rem; margin: 0 auto; padding: 1rem; }
h1, h2, h3 { line-height: 1.2; } h1, h2 { 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: 32rem; }
form { display: grid; gap: .75rem; max-width: 40rem; } label { display: grid; gap: .25rem; font-weight: 600; }
label { display: grid; gap: .2rem; font-weight: 600; } input, textarea, button { font: inherit; padding: .65rem; }
input, textarea, button { font: inherit; padding: .6rem; }
button { justify-self: start; cursor: pointer; } button { justify-self: start; cursor: pointer; }
#status { min-height: 1.5em; } #dialogue { list-style: none; padding: 0; margin: 0; }
.card { border: 1px solid; border-radius: .5rem; padding: 1rem; margin-block: 1rem; } #dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
dl { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; gap: .35rem 1rem; } #dialogue li[data-role="user"] { font-weight: 650; }
dt { font-weight: 700; } #dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
dd { margin: 0; } #message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; } #admin-content { display: grid; gap: 1rem; }
.row button { margin-block: .4rem; } #admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#transcript { min-height: 5rem; max-height: 28rem; overflow: auto; } #admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#transcript li { margin-block: .6rem; } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
.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; } [hidden] { display: none !important; }
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
</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 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"> <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-heading" hidden> <section id="play" aria-label="Spelets dialog" 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">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" tabindex="-1"></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>
<h4>Platsens sinnesintryck</h4>
<dl>
<dt>Synintryck</dt><dd id="sense-visual"></dd>
<dt>Ljud</dt><dd id="sense-sound"></dd>
<dt>Dofter</dt><dd id="sense-smell"></dd>
<dt>Luft och temperatur</dt><dd id="sense-air"></dd>
<dt>Beröring och ytor</dt><dd id="sense-touch"></dd>
<dt>Orientering i rummet</dt><dd id="sense-orientation"></dd>
</dl>
<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>
<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\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”.`);
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) { if (!gameState.character) {
showWorldSetup(); transcript.replaceChildren();
try { appendInitialPrompt();
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}`;
return; 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; await loadSavedDialogue();
auth.hidden = true; characterSection.hidden = true; game.hidden = false; setStage('game');
worldSetup.hidden = true;
document.querySelector('#world-heading').textContent = world.name;
document.querySelector('#world-heading').focus();
document.querySelector('#place-heading').textContent = world.location.name;
document.querySelector('#place-description').textContent = world.location.description;
const senses = world.location.senses ?? {};
for (const [id, key] of [
['sense-visual', 'visual'],
['sense-sound', 'sound'],
['sense-smell', 'smell'],
['sense-air', 'air_and_temperature'],
['sense-touch', 'touch_and_surfaces'],
['sense-orientation', 'spatial_orientation'],
]) {
document.querySelector(`#${id}`).textContent = senses[key] || 'Inget särskilt intryck beskrivet.';
} }
const conversation = document.querySelector('#conversation-card'); async function handleLogout() {
if (world.npc) { await request('/api/logout', {method: 'POST'});
document.querySelector('#npc-heading').textContent = world.npc.name; characterDraft = null;
document.querySelector('#npc-description').textContent = world.npc.biography; gameState = null;
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`; transcript.replaceChildren();
document.querySelector('#message-label').firstChild.textContent = `Skriv till ${world.npc.name} `; showAuth();
conversation.hidden = false; }
renderConversation(await request('/api/npc/conversation'), world.npc.name); async function handleBiography(message) {
document.querySelector('#place-status').textContent = ''; 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 { } else {
document.querySelector('#npc-heading').textContent = ''; appendLine('assistant', `Karaktären kunde inte sparas: ${error.message}. Utkastet finns kvar så att du kan försöka igen.`);
document.querySelector('#npc-description').textContent = ''; setStage('biography');
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`);
} }
} }
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) => { 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(); form.reset();
if (gameState?.world || !gameState?.character) status.textContent = 'Du är inloggad.'; if (location.pathname === '/admin') await openAdmin(); else await openGame();
} 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) { } catch (error) {
button.disabled = false; window.alert(`Kunde inte logga in: ${error.message}`);
status.textContent = `Köpet kunde inte genomföras: ${error.message}`;
return;
} }
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}`;} }); document.querySelector('#message-form').addEventListener('submit', async (event) => {
request('/api/me').then(async () => { await openGame(); status.textContent='Din sparade spelvärld har lästs in.'; }).catch(() => { showAuth(); }); 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> </script>
</body> </body>
</html> </html>
+19 -12
View File
@@ -3,23 +3,30 @@ set -euo pipefail
test -s index.html test -s index.html
grep -q '<html lang="sv">' index.html grep -q '<html lang="sv">' index.html
grep -q '<h1>En värld du formar</h1>' index.html
grep -q 'id="frontend-version">__FRONTEND_VERSION__' index.html grep -q 'id="frontend-version">__FRONTEND_VERSION__' index.html
grep -q "request('/version')" index.html grep -q "request('/version')" index.html
grep -q 'id="api-version"' index.html
grep -Fq "credentials: 'same-origin'" index.html grep -Fq "credentials: 'same-origin'" index.html
grep -q 'Köp på platsen' index.html grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
grep -q 'Platsens sinnesintryck' index.html grep -q 'id="message" name="message"' index.html
grep -q 'Orientering i rummet' index.html grep -q "event.key === 'Enter' && !event.shiftKey" index.html
grep -q "'sense-sound', 'sound'" index.html grep -q "request('/api/character/draft'" index.html
grep -q 'id="world-heading" tabindex="-1"' index.html grep -q "request('/api/world/draft'" index.html
grep -q "document.querySelector('#world-heading').focus()" index.html grep -q "request('/api/dialogue'" index.html
grep -q "request('/api/purchases'" index.html grep -q 'id="admin-view"' index.html
grep -q '/api/admin/players?' index.html
grep -q "request('/api/admin/prompts'" index.html
grep -q "request('/api/admin/rules'" index.html
grep -q '/api/admin/audit?limit=200' index.html
grep -q "request('/api/world/start'" index.html grep -q "request('/api/world/start'" index.html
if grep -Eq 'world/draft|npc_name|Person du kan möta|Beskriv vad du vill börja med' index.html; then grep -q "request('/api/character'" index.html
echo 'The start flow must not ask for a world description or preview a person.' >&2 grep -q 'location = /healthz' nginx/default.conf
if grep -Eq 'id="(new-character|world-setup|shop|buy-offer|balance|inventory|place-heading|npc-heading)"|Platsens sinnesintryck|Ekonomi och ägodelar|Köp på platsen|id="status"|id="logout"' index.html; then
echo 'The active player UI must not contain permanent data panels, forms, or action buttons.' >&2
exit 1
fi
if sed -n '/<section id="play"/,/^ <\/section>/p' index.html | grep -q '<button'; then
echo 'The game input must not require a separate send or action button.' >&2
exit 1 exit 1
fi fi
grep -q 'location = /healthz' nginx/default.conf
echo 'Frontend source checks passed.' echo 'Frontend source checks passed.'