Compare commits

..
6 Commits
Author SHA1 Message Date
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
5 changed files with 79 additions and 54 deletions
+4
View File
@@ -2,6 +2,10 @@
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.
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 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.
## Release flow ## Release flow
+1 -1
View File
@@ -1 +1 @@
0.1.19 0.1.22
+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.19 version: 0.1.22
appVersion: "0.1.19" appVersion: "0.1.22"
+61 -51
View File
@@ -16,6 +16,9 @@
button { justify-self: start; cursor: pointer; } button { justify-self: start; cursor: pointer; }
#status { min-height: 1.5em; } #status { min-height: 1.5em; }
.card { border: 1px solid; border-radius: .5rem; padding: 1rem; margin-block: 1rem; } .card { border: 1px solid; border-radius: .5rem; padding: 1rem; margin-block: 1rem; }
dl { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; gap: .35rem 1rem; }
dt { font-weight: 700; }
dd { margin: 0; }
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; } .row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.row button { margin-block: .4rem; } .row button { margin-block: .4rem; }
#transcript { min-height: 5rem; max-height: 28rem; overflow: auto; } #transcript { min-height: 5rem; max-height: 28rem; overflow: auto; }
@@ -26,6 +29,7 @@
<body> <body>
<main> <main>
<h1>En värld du formar</h1> <h1>En värld du formar</h1>
<p>Spelet är text- och dialogbaserat och använder inga bilder. Miljöer beskrivs med ljud, dofter, temperatur, beröring och orientering, inte bara med synintryck.</p>
<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>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> <p id="status" role="status" aria-live="polite"></p>
<section id="auth" aria-labelledby="auth-heading"> <section id="auth" aria-labelledby="auth-heading">
@@ -47,30 +51,24 @@
</form> </form>
</section> </section>
<section id="world-setup" aria-labelledby="world-setup-heading" hidden> <section id="world-setup" aria-labelledby="world-setup-heading" hidden>
<h2 id="world-setup-heading">Skapa din startvärld</h2> <h2 id="world-setup-heading">Hitta din startplats</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> <p>Du behöver inte beskriva världen. Startplatsen formas utifrån din biografi. Människor upptäcks senare när du utforskar.</p>
<form id="world-idea-form"> <button id="start-world-button" type="button">Börja spela</button>
<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>
<section id="game" hidden> <section id="game" hidden>
<div class="row"><h2 id="world-heading"></h2><button id="logout" type="button">Logga ut</button></div> <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"> <section class="card" aria-labelledby="place-heading">
<h3 id="place-heading"></h3><p id="place-description"></p> <h3 id="place-heading"></h3><p id="place-description"></p>
<h4>Platsens sinnesintryck</h4>
<dl>
<dt>Utseende beskrivet i ord</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="npc-heading"></h3><p id="npc-description"></p>
<h3 id="player-heading"></h3><p id="player-biography"></p> <h3 id="player-heading"></h3><p id="player-biography"></p>
<h3>Ekonomi och ägodelar</h3> <h3>Ekonomi och ägodelar</h3>
@@ -83,7 +81,7 @@
<p id="offer-affordability"></p> <p id="offer-affordability"></p>
<button id="buy-offer" type="button">Köp</button> <button id="buy-offer" type="button">Köp</button>
</section> </section>
<section class="card" aria-labelledby="conversation-heading"> <section class="card" id="conversation-card" aria-labelledby="conversation-heading">
<h3 id="conversation-heading">Samtal</h3> <h3 id="conversation-heading">Samtal</h3>
<ol id="transcript" aria-label="Sparat samtal"></ol> <ol id="transcript" aria-label="Sparat samtal"></ol>
<form id="message-form"> <form id="message-form">
@@ -126,17 +124,50 @@
async function openGame() { async function openGame() {
gameState = await request('/api/world'); gameState = await request('/api/world');
if (!gameState.character) { showCharacter(); return; } if (!gameState.character) { showCharacter(); return; }
if (!gameState.world) { showWorldSetup(); return; } if (!gameState.world) {
showWorldSetup();
try {
status.textContent = 'Hittar en startplats som passar din biografi…';
gameState = await request('/api/world/start', {method:'POST'});
if (!gameState.world) throw new Error('world_start_not_created');
} catch (error) {
status.textContent = `Kunde inte hitta en startplats: ${error.message}`;
return;
}
}
const world = gameState.world; const world = gameState.world;
auth.hidden = true; characterSection.hidden = true; game.hidden = false; auth.hidden = true; characterSection.hidden = true; game.hidden = false;
worldSetup.hidden = true; worldSetup.hidden = true;
document.querySelector('#world-heading').textContent = world.name; document.querySelector('#world-heading').textContent = world.name;
document.querySelector('#world-heading').focus();
document.querySelector('#place-heading').textContent = world.location.name; document.querySelector('#place-heading').textContent = world.location.name;
document.querySelector('#place-description').textContent = world.location.description; document.querySelector('#place-description').textContent = world.location.description;
document.querySelector('#npc-heading').textContent = world.npc.name; const senses = world.location.senses ?? {};
document.querySelector('#npc-description').textContent = world.npc.biography; for (const [id, key] of [
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`; ['sense-visual', 'visual'],
document.querySelector('#message-label').firstChild.textContent = `Skriv till ${world.npc.name} `; ['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');
if (world.npc) {
document.querySelector('#npc-heading').textContent = world.npc.name;
document.querySelector('#npc-description').textContent = world.npc.biography;
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`;
document.querySelector('#message-label').firstChild.textContent = `Skriv till ${world.npc.name} `;
conversation.hidden = false;
renderConversation(await request('/api/npc/conversation'), world.npc.name);
document.querySelector('#place-status').textContent = '';
} else {
document.querySelector('#npc-heading').textContent = '';
document.querySelector('#npc-description').textContent = '';
document.querySelector('#place-status').textContent = 'Du har inte mött någon här ännu. Vilka du möter beror på vart du tar dig i världen.';
conversation.hidden = true;
}
document.querySelector('#player-heading').textContent = `${gameState.character.name}, ${gameState.character.age} år`; document.querySelector('#player-heading').textContent = `${gameState.character.name}, ${gameState.character.age} år`;
document.querySelector('#player-biography').textContent = gameState.character.biography; document.querySelector('#player-biography').textContent = gameState.character.biography;
document.querySelector('#balance').textContent = (world.balance_sek ?? 0).toLocaleString('sv-SE'); document.querySelector('#balance').textContent = (world.balance_sek ?? 0).toLocaleString('sv-SE');
@@ -154,7 +185,6 @@
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('#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`); document.querySelector('#buy-offer').setAttribute('aria-label', `Köp ${world.offer.name} för ${world.offer.price_sek} kronor`);
} }
renderConversation(await request('/api/npc/conversation'), world.npc.name);
} }
document.querySelector('#auth-form').addEventListener('submit', async (event) => { document.querySelector('#auth-form').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
@@ -164,36 +194,16 @@
status.textContent = intent === 'register' ? 'Skapar konto…' : 'Loggar in…'; status.textContent = intent === 'register' ? 'Skapar konto…' : 'Loggar in…';
try { try {
await request(`/api/${intent}`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({username:data.get('username'),password:data.get('password')}) }); await request(`/api/${intent}`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({username:data.get('username'),password:data.get('password')}) });
form.reset(); await openGame(); status.textContent = 'Du är inloggad.'; form.reset(); await openGame();
if (gameState?.world || !gameState?.character) status.textContent = 'Du är inloggad.';
} catch (error) { status.textContent = `Kunde inte logga in: ${error.message}`; } } catch (error) { status.textContent = `Kunde inte logga in: ${error.message}`; }
}); });
document.querySelector('#character-form').addEventListener('submit', async (event) => { document.querySelector('#character-form').addEventListener('submit', async (event) => {
event.preventDefault(); const data = new FormData(event.currentTarget); status.textContent = 'Sparar din karaktär…'; 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.'; } 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}`;} catch(error){status.textContent=`Kunde inte spara karaktären: ${error.message}`;}
}); });
document.querySelector('#world-idea-form').addEventListener('submit', async (event) => { document.querySelector('#start-world-button').addEventListener('click', openGame);
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}`; }
});
document.querySelector('#message-form').addEventListener('submit', async (event) => { document.querySelector('#message-form').addEventListener('submit', async (event) => {
event.preventDefault(); const form=event.currentTarget; const field=form.elements.message; const message=field.value.trim(); if(!message)return; event.preventDefault(); 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…'; status.textContent='Din samtalspartner funderar på sitt svar…';
+11
View File
@@ -4,12 +4,23 @@ 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 '<h1>En värld du formar</h1>' index.html
grep -q 'text- och dialogbaserat och använder inga bilder' 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 -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 'Köp på platsen' index.html
grep -q 'Platsens sinnesintryck' index.html
grep -q 'Orientering i rummet' index.html
grep -q "'sense-sound', 'sound'" index.html
grep -q 'id="world-heading" tabindex="-1"' index.html
grep -q "document.querySelector('#world-heading').focus()" index.html
grep -q "request('/api/purchases'" index.html grep -q "request('/api/purchases'" 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
echo 'The start flow must not ask for a world description or preview a person.' >&2
exit 1
fi
grep -q 'location = /healthz' nginx/default.conf grep -q 'location = /healthz' nginx/default.conf
echo 'Frontend source checks passed.' echo 'Frontend source checks passed.'