Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d60387a9b | ||
|
|
4502e2406d | ||
|
|
92741baec7 | ||
|
|
f4dcb0de1a | ||
|
|
b82975444a | ||
|
|
a3c31bf340 | ||
|
|
9447f9b0be | ||
|
|
1e9e111745 | ||
|
|
4fbd3ceabd | ||
|
|
85250d62e7 | ||
|
|
3d1328301a | ||
|
|
7cd062ee1f | ||
|
|
305a568899 | ||
|
|
01cbc4f759 | ||
|
|
9082596000 |
@@ -124,8 +124,22 @@ jobs:
|
|||||||
helm lint deploy/helm/ai-spel-frontend
|
helm lint deploy/helm/ai-spel-frontend
|
||||||
helm package deploy/helm/ai-spel-frontend --destination "$publish_dir" \
|
helm package deploy/helm/ai-spel-frontend --destination "$publish_dir" \
|
||||||
--version "$version" --app-version "$version"
|
--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" \
|
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" \
|
helm template ai-spel-frontend "$publish_dir/ai-spel-frontend-$version.tgz" \
|
||||||
--namespace ai-spel > "$publish_dir/frontend-manifest.yaml"
|
--namespace ai-spel > "$publish_dir/frontend-manifest.yaml"
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||||
|
|
||||||
ARG FRONTEND_VERSION
|
ARG FRONTEND_VERSION
|
||||||
USER root
|
RUN test -n "$FRONTEND_VERSION"
|
||||||
RUN test -n "$FRONTEND_VERSION" \
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
&& sed -i "s/__FRONTEND_VERSION__/${FRONTEND_VERSION}/g" /usr/share/nginx/html/index.html
|
|
||||||
|
|
||||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
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
|
USER 101
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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.14
|
version: 0.1.21
|
||||||
appVersion: "0.1.14"
|
appVersion: "0.1.21"
|
||||||
@@ -7,6 +7,13 @@ metadata:
|
|||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
minReadySeconds: {{ .Values.deployment.minReadySeconds }}
|
||||||
|
progressDeadlineSeconds: {{ .Values.deployment.progressDeadlineSeconds }}
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: {{ .Values.deployment.maxUnavailable }}
|
||||||
|
maxSurge: {{ .Values.deployment.maxSurge }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
@@ -40,11 +47,15 @@ spec:
|
|||||||
port: http
|
port: http
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
port: http
|
port: http
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
deployment:
|
||||||
|
# Keep the old single replica available until its replacement passes readiness.
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
minReadySeconds: 5
|
||||||
|
progressDeadlineSeconds: 600
|
||||||
image:
|
image:
|
||||||
repository: harbor.k3s.k0.nu/ai-spel/frontend
|
repository: harbor.k3s.k0.nu/ai-spel/frontend
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|||||||
+101
-39
@@ -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; }
|
||||||
@@ -47,29 +50,37 @@
|
|||||||
</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>
|
|
||||||
<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>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="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>
|
||||||
|
<p>Saldo: <span id="balance"></span> kr</p>
|
||||||
|
<ul id="inventory" aria-label="Dina ägodelar"></ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="card" aria-labelledby="conversation-heading">
|
<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>
|
<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">
|
||||||
@@ -112,20 +123,67 @@
|
|||||||
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;
|
||||||
|
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');
|
||||||
|
if (world.npc) {
|
||||||
document.querySelector('#npc-heading').textContent = world.npc.name;
|
document.querySelector('#npc-heading').textContent = world.npc.name;
|
||||||
document.querySelector('#npc-description').textContent = world.npc.biography;
|
document.querySelector('#npc-description').textContent = world.npc.biography;
|
||||||
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`;
|
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`;
|
||||||
document.querySelector('#message-label').firstChild.textContent = `Skriv till ${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;
|
||||||
renderConversation(await request('/api/npc/conversation'), world.npc.name);
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.querySelector('#auth-form').addEventListener('submit', async (event) => {
|
document.querySelector('#auth-form').addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -135,39 +193,43 @@
|
|||||||
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']) 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}`; }
|
|
||||||
});
|
|
||||||
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…';
|
||||||
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.'; }
|
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}`;}
|
catch(error){status.textContent=`Samtalet kunde inte skickas: ${error.message}`;}
|
||||||
});
|
});
|
||||||
|
document.querySelector('#buy-offer').addEventListener('click', async () => {
|
||||||
|
if (!gameState?.world?.offer) return;
|
||||||
|
const world = gameState.world;
|
||||||
|
const storageKey = `pending-purchase:${world.id}:${world.offer.id}`;
|
||||||
|
const idempotencyKey = sessionStorage.getItem(storageKey) || crypto.randomUUID();
|
||||||
|
sessionStorage.setItem(storageKey, idempotencyKey);
|
||||||
|
const button = document.querySelector('#buy-offer');
|
||||||
|
button.disabled = true;
|
||||||
|
status.textContent = 'Genomför köpet…';
|
||||||
|
try {
|
||||||
|
await request('/api/purchases', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({offer_id:world.offer.id,expected_world_version:world.version,idempotency_key:idempotencyKey})});
|
||||||
|
sessionStorage.removeItem(storageKey);
|
||||||
|
} catch(error) {
|
||||||
|
button.disabled = false;
|
||||||
|
status.textContent = `Köpet kunde inte genomföras: ${error.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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('#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(); });
|
request('/api/me').then(async () => { await openGame(); status.textContent='Din sparade spelvärld har lästs in.'; }).catch(() => { showAuth(); });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ 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 '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/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.'
|
||||||
|
|||||||
Reference in New Issue
Block a user