Compare commits

..
23 Commits
Author SHA1 Message Date
Gitea Actions c392dfe39f Release 0.1.25 [auto-version-bump] 2026-10-15 08:51:06 +02:00
Ein Anderssono 5a44d3d4f4 Show external service traces in admin player view
Build and publish frontend / publish (push) Successful in 45s
2026-10-15 08:50:48 +02:00
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
Gitea Actions 1d60387a9b Release 0.1.21 [auto-version-bump] 2026-10-14 22:49:40 +02:00
Ein Anderssono 4502e2406d Add multisensory accessible place details
Build and publish frontend / publish (push) Successful in 41s
2026-10-14 22:49:18 +02:00
Gitea Actions 92741baec7 Release 0.1.20 [auto-version-bump] 2026-10-14 14:07:15 +02:00
Ein Anderssono f4dcb0de1a Remove world description from character start
Build and publish frontend / publish (push) Successful in 48s
2026-10-14 14:06:50 +02:00
Gitea Actions b82975444a Release 0.1.19 [auto-version-bump] 2026-10-14 12:14:15 +02:00
Ein Anderssono a3c31bf340 Add accessible purchase interaction
Build and publish frontend / publish (push) Successful in 49s
2026-10-14 12:13:47 +02:00
Gitea Actions 9447f9b0be Release 0.1.18 [auto-version-bump] 2026-10-14 10:02:32 +02:00
Ein Anderssono 1e9e111745 Keep healthy frontend pod during releases
Build and publish frontend / publish (push) Successful in 42s
2026-10-14 10:02:15 +02:00
Gitea Actions 4fbd3ceabd Release 0.1.17 [auto-version-bump] 2026-10-14 09:59:18 +02:00
Ein Anderssono 85250d62e7 Bake frontend version as root during image build
Build and publish frontend / publish (push) Successful in 39s
2026-10-14 09:58:57 +02:00
Ein Anderssono 3d1328301a Copy game page into frontend image
Build and publish frontend / publish (push) Failing after 6s
2026-10-14 09:58:14 +02:00
Gitea Actions 7cd062ee1f Release 0.1.16 [auto-version-bump] 2026-10-14 09:50:31 +02:00
Ein Anderssono 305a568899 Create temporary Helm registry credentials file
Build and publish frontend / publish (push) Successful in 32s
2026-10-14 09:50:18 +02:00
Gitea Actions 01cbc4f759 Release 0.1.15 [auto-version-bump] 2026-10-14 09:49:11 +02:00
Ein Anderssono 9082596000 Authenticate Helm chart publication to Harbor
Build and publish frontend / publish (push) Failing after 24s
2026-10-14 09:48:53 +02:00
9 changed files with 466 additions and 138 deletions
+15 -1
View File
@@ -124,8 +124,22 @@ jobs:
helm lint deploy/helm/ai-spel-frontend
helm package deploy/helm/ai-spel-frontend --destination "$publish_dir" \
--version "$version" --app-version "$version"
registry_config="$RUNNER_TEMP/helm/registry.json"
mkdir -p "$(dirname "$registry_config")"
printf '{}' > "$registry_config"
printf '%s' "$HARBOR_PASSWORD" | helm registry login harbor.k3s.k0.nu \
--username "$HARBOR_USERNAME" --password-stdin \
--registry-config "$registry_config"
helm push "$publish_dir/ai-spel-frontend-$version.tgz" \
oci://harbor.k3s.k0.nu/ai-spel
oci://harbor.k3s.k0.nu/ai-spel --registry-config "$registry_config"
chart_metadata="$(helm show chart oci://harbor.k3s.k0.nu/ai-spel/ai-spel-frontend \
--version "$version" --registry-config "$registry_config")"
chart_version="$(printf '%s\n' "$chart_metadata" | awk '$1 == "version:" { print $2 }')"
chart_app_version="$(printf '%s\n' "$chart_metadata" | awk '$1 == "appVersion:" { gsub(/"/, "", $2); print $2 }')"
[[ "$chart_version" == "$version" && "$chart_app_version" == "$version" ]] || {
echo "Published frontend chart version mismatch: chart=$chart_version app=$chart_app_version expected=$version"
exit 1
}
helm template ai-spel-frontend "$publish_dir/ai-spel-frontend-$version.tgz" \
--namespace ai-spel > "$publish_dir/frontend-manifest.yaml"
+4 -4
View File
@@ -1,11 +1,11 @@
FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG FRONTEND_VERSION
USER root
RUN test -n "$FRONTEND_VERSION" \
&& sed -i "s/__FRONTEND_VERSION__/${FRONTEND_VERSION}/g" /usr/share/nginx/html/index.html
RUN test -n "$FRONTEND_VERSION"
COPY index.html /usr/share/nginx/html/index.html
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
USER root
RUN sed -i "s/__FRONTEND_VERSION__/${FRONTEND_VERSION}/g" /usr/share/nginx/html/index.html
USER 101
EXPOSE 8080
+9 -1
View File
@@ -2,6 +2,14 @@
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.
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.
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.
## Release flow
@@ -10,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.
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.
+1 -1
View File
@@ -1 +1 @@
0.1.14
0.1.25
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator
type: application
version: 0.1.14
appVersion: "0.1.14"
version: 0.1.25
appVersion: "0.1.25"
@@ -7,6 +7,13 @@ metadata:
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
minReadySeconds: {{ .Values.deployment.minReadySeconds }}
progressDeadlineSeconds: {{ .Values.deployment.progressDeadlineSeconds }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: {{ .Values.deployment.maxUnavailable }}
maxSurge: {{ .Values.deployment.maxSurge }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
@@ -40,11 +47,15 @@ spec:
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
{{- toYaml .Values.resources | nindent 12 }}
+6
View File
@@ -1,4 +1,10 @@
replicaCount: 1
deployment:
# Keep the old single replica available until its replacement passes readiness.
maxUnavailable: 0
maxSurge: 1
minReadySeconds: 5
progressDeadlineSeconds: 600
image:
repository: harbor.k3s.k0.nu/ai-spel/frontend
pullPolicy: IfNotPresent
+391 -123
View File
@@ -6,170 +6,438 @@
<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; }
#admin-content { display: grid; gap: 1rem; }
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#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; }
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>
<div class="row"><button type="submit">Godkänn och börja spela</button><button id="discard-world-draft" type="button">Gör ett nytt förslag</button></div>
</form>
</section>
<section id="game" hidden>
<div class="row"><h2 id="world-heading"></h2><button id="logout" type="button">Logga ut</button></div>
<section class="card" aria-labelledby="place-heading">
<h3 id="place-heading"></h3><p id="place-description"></p>
<h3 id="npc-heading"></h3><p id="npc-description"></p>
<h3 id="player-heading"></h3><p id="player-biography"></p>
</section>
<section class="card" aria-labelledby="conversation-heading">
<h3 id="conversation-heading">Samtal</h3>
<ol id="transcript" aria-label="Sparat samtal"></ol>
<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 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 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\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;
renderConversation(await request('/api/npc/conversation'), world.npc.name);
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;
}
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']) form.elements[key].value = proposal[key];
form.hidden = false; status.textContent='Förslaget är klart. Granska och redigera det innan du godkänner.';
} catch(error) { status.textContent=`Kunde inte skapa ett förslag: ${error.message}`; }
});
document.querySelector('#discard-world-draft').addEventListener('click', () => {
document.querySelector('#world-draft-form').hidden = true; status.textContent='Förslaget har inte sparats. Ändra din idé och försök igen.';
});
document.querySelector('#world-draft-form').addEventListener('submit', async (event) => {
event.preventDefault(); const data = new FormData(event.currentTarget); status.textContent='Sparar din godkända värld…';
try {
await request('/api/world', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(data.entries()))});
await openGame(); status.textContent='Din värld har sparats.';
} catch(error) { status.textContent=`Kunde inte spara världen: ${error.message}`; }
form.reset();
if (location.pathname === '/admin') await openAdmin(); else 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}`;}
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.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.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>
+23 -2
View File
@@ -3,11 +3,32 @@ set -euo pipefail
test -s 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 "request('/version')" index.html
grep -q 'id="api-version"' index.html
grep -Fq "credentials: 'same-origin'" index.html
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
grep -q 'id="message" name="message"' index.html
grep -q "event.key === 'Enter' && !event.shiftKey" index.html
grep -q "request('/api/character/draft'" index.html
grep -q "request('/api/world/draft'" index.html
grep -q "request('/api/dialogue'" 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 'entry.data.external_call' index.html
grep -q 'Externt tjänsteanrop' index.html
grep -q "request('/api/world/start'" index.html
grep -q "request('/api/character'" index.html
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
fi
echo 'Frontend source checks passed.'