Mirror tested canonical source 439a74ca52125c2bb1654363c0fcff16b47a8297
This commit is contained in:
parent
0e88737e9c
commit
06c012c941
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
test-results
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env*
|
||||||
|
test-results/
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm test && npm run build
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY server ./server
|
||||||
|
USER node
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s CMD wget -qO- http://127.0.0.1:8080/health || exit 1
|
||||||
|
CMD ["node","server/index.mjs"]
|
||||||
26
README.md
26
README.md
@ -1,3 +1,25 @@
|
|||||||
# dnd-character-deploy
|
# Arrowstack Character
|
||||||
|
|
||||||
Deployment mirror of the private Arrowstack Character PWA. Keycloak credentials are injected at runtime.
|
Mobile-first Vue 3 / TypeScript character-sheet PWA. Pinia manages state, Dexie stores characters in IndexedDB, Zod validates saves and JSON imports. A small Express gateway protects the application with a confidential Keycloak OIDC client using authorization code flow, PKCE, state and nonce verification.
|
||||||
|
|
||||||
|
## v0.1
|
||||||
|
|
||||||
|
Create/edit multiple characters, abilities, skill/expertise and save proficiency, HP/temp HP, armour class, speed, manually entered attacks, spellbook/preparation, generic resources, rest preview, inventory, notes, d20 rolls, account-separated local saves, JSON import/export. No copyrighted spell or rule compendium is bundled. No server character storage or cross-device sync yet.
|
||||||
|
|
||||||
|
Short rest resets declared short-rest resources. Long rest also resets long-rest resources, restores HP and clears temporary HP. Hit dice and class-specific rest rules are manual. These are configurable sheet operations, not a complete edition-specific rules engine.
|
||||||
|
|
||||||
|
## Authentication and offline behavior
|
||||||
|
|
||||||
|
Use existing Arrowstack accounts in the dev realm. Any authenticated realm user can use the app; no control-plane role is required. Keycloak tokens and the confidential client secret stay server-side. The encrypted 12-hour session uses a Secure, HttpOnly, SameSite=Lax, host-only cookie. Protected HTML requires a session; unauthenticated /api/session returns 401. Static JS/CSS and PWA metadata are public, but contain no character data or secrets.
|
||||||
|
|
||||||
|
After successful sign-in, the worker caches the app shell and immutable build assets for offline use. The browser retains only the last subject/name/expiry, not OIDC tokens. Local offline access expires with the session after at most 12 hours. Revocation cannot be checked while offline; reconnecting rechecks the gateway session. Character storage is namespaced by Keycloak subject, not encrypted at rest. Treat the device as trusted. Sign-out removes offline identity and cached shell while preserving per-account character backups. Sign-out ends the app session; the Arrowstack SSO session may still exist.
|
||||||
|
|
||||||
|
Clearing site data removes saves. Export JSON backups before changing devices or clearing browser storage.
|
||||||
|
|
||||||
|
## Build and deploy
|
||||||
|
|
||||||
|
`npm ci`, `npm test`, `npm run build`. Docker executes tests and type-check/build, then runs as an unprivileged node user on 8080. No Gitea Actions builder is required.
|
||||||
|
|
||||||
|
Runtime configuration: APP_ORIGIN=https://dnd.arrowstack.dev, KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET. Provision confidential client `dnd-character` with standard authorization code flow only, exact redirect `https://dnd.arrowstack.dev/auth/callback`, PKCE S256. Use Arrowstack's secret handoff to inject credentials directly into Coolify. /health exposes status/version/revision, never secrets. Canonical source is private in Admin/dnd-character-pwa. For now Coolify builds the public Admin/dnd-character-deploy mirror main branch over HTTPS; runtime secrets are never committed. See deployment.md for resource IDs and release steps. Redeploy a prior mirror commit through Coolify to roll back; runtime has no database migrations.
|
||||||
|
|
||||||
|
Local production smoke test needs the same HTTPS origin and Keycloak configuration. Do not add an authentication bypass to production. Vite development uses its proxy for /auth and /api.
|
||||||
|
|||||||
27
deployment.md
Normal file
27
deployment.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Arrowstack Character deployment
|
||||||
|
|
||||||
|
Production: https://dnd.arrowstack.dev
|
||||||
|
Canonical source: private Admin/dnd-character-pwa.
|
||||||
|
Temporary deployment mirror: public Admin/dnd-character-deploy, main branch.
|
||||||
|
|
||||||
|
Coolify application: vfzhl6jql5vrzevm6yk1pvav (dnd-character).
|
||||||
|
Project: 9ax6f4a6yn1h6gi7jgiihjhs; production environment; server: t6bzdsemktk6j3fxuzjlzkjg.
|
||||||
|
Build: repository Dockerfile, HTTPS mirror clone, port 8080.
|
||||||
|
Health: http://127.0.0.1:8080/health. Use IPv4 explicitly: BusyBox wget with localhost failed against the IPv4 listener.
|
||||||
|
Keycloak: confidential dnd-character client, dev realm, exact callback https://dnd.arrowstack.dev/auth/callback. Runtime issuer/client credentials are handed directly to Coolify. Never commit credentials or character data.
|
||||||
|
|
||||||
|
## Release steps
|
||||||
|
|
||||||
|
1. Change the canonical private source; run npm ci, npm test and npm run build.
|
||||||
|
2. Copy the tested source and lockfile into the deployment mirror. Exclude node_modules, dist, .env files, test output and credentials. Retain private canonical provenance in commit messages.
|
||||||
|
3. Set the runtime SOURCE_COMMIT to the mirror revision being deployed.
|
||||||
|
4. Check active deployment jobs, deploy the application once, inspect the terminal build/runtime status, and verify /health and the Keycloak redirect on the live origin.
|
||||||
|
5. Roll back by pinning a prior known-good mirror commit and deploying it. There are no server character database migrations.
|
||||||
|
|
||||||
|
The mirror is a temporary fallback authorized by the user. The private repository deploy key workflow failed at git.arrowstack.dev port 22 with Permission denied (publickey); investigate the Gitea SSH/deploy-key listener before removing the mirror. Do not make canonical source public.
|
||||||
|
|
||||||
|
Earlier inline-source test applications cbnkwtdve2fzumnxnz0jqupb and cdr9ivftpqqzdakmfuq0r5yz are inactive and have no domain. They are not production targets.
|
||||||
|
|
||||||
|
## Verification boundaries
|
||||||
|
|
||||||
|
Seven focused tests cover HP/temp HP, proficiency/expertise, declared rest resets, JSON validation, IndexedDB account separation/persistence, encrypted session integrity/expiry and fail-closed missing secrets. Production TypeScript checks and Vite build also run in the Docker image build. Authenticated browser creation/reload and installed iPhone offline behavior require a signed-in device smoke test; build and health alone do not prove those interactions.
|
||||||
1
index.html
Normal file
1
index.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#162720"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/icon-192.png"><title>Character · Arrowstack</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
2533
package-lock.json
generated
Normal file
2533
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
package.json
Normal file
1
package.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"dnd-character-pwa","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc --noEmit && vite build","start":"node server/index.mjs","test":"node --import tsx --test tests/*.test.ts"},"dependencies":{"vue":"^3.5.22","pinia":"^3.0.3","dexie":"^4.2.0","zod":"^4.1.11","express":"^5.1.0","openid-client":"^6.8.1","jose":"^6.1.0"},"devDependencies":{"vite":"^7.1.9","@vitejs/plugin-vue":"^6.0.1","typescript":"^5.9.3","vue-tsc":"^3.1.0","tsx":"^4.20.6","fake-indexeddb":"^6.2.4"}}
|
||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
1
public/manifest.webmanifest
Normal file
1
public/manifest.webmanifest
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"id":"/","name":"Arrowstack Character","short_name":"Character","start_url":"/","scope":"/","display":"standalone","background_color":"#f4f0e8","theme_color":"#162720","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
|
||||||
4
public/sw.js
Normal file
4
public/sw.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
const CACHE='character-shell-v1';
|
||||||
|
self.addEventListener('activate',e=>e.waitUntil((async()=>{for(const key of await caches.keys())if(key.startsWith('character-shell-')&&key!==CACHE)await caches.delete(key);await self.clients.claim()})()));
|
||||||
|
self.addEventListener('message',e=>{if(e.data==='CLEAR_AUTH')e.waitUntil(caches.delete(CACHE));if(e.data==='CACHE_SHELL')e.waitUntil((async()=>{const r=await fetch('/',{credentials:'same-origin',redirect:'manual'});if(r.ok&&r.headers.get('content-type')?.includes('text/html')){const html=await r.clone().text(),cache=await caches.open(CACHE);await cache.put('/',r);for(const match of html.matchAll(/(?:src|href)="(\/assets\/[^" ]+)"/g)){const response=await fetch(match[1]);if(response.ok)await cache.put(match[1],response)}}})())});
|
||||||
|
self.addEventListener('fetch',e=>{const url=new URL(e.request.url);if(url.origin!==self.location.origin||e.request.method!=='GET'||url.pathname.startsWith('/api/')||url.pathname.startsWith('/auth/'))return;if(e.request.mode==='navigate'&&url.pathname==='/'){e.respondWith((async()=>{try{const r=await fetch(e.request);if(r.ok&&!r.redirected&&r.headers.get('content-type')?.includes('text/html'))await(await caches.open(CACHE)).put('/',r.clone());return r}catch{return await caches.match('/')||new Response('Connect to the internet to sign in.',{status:503})}})());return}if(url.pathname.startsWith('/assets/'))e.respondWith((async()=>{const hit=await caches.match(e.request);if(hit)return hit;const r=await fetch(e.request);if(r.ok)await(await caches.open(CACHE)).put(e.request,r.clone());return r})())});
|
||||||
3
server/auth.mjs
Normal file
3
server/auth.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import {createHash} from 'node:crypto';
|
||||||
|
import {EncryptJWT,jwtDecrypt} from 'jose';
|
||||||
|
export function sessionCodec(secret,clientId){if(!secret||secret.length<16)throw new Error('A confidential Keycloak client secret is required');const key=createHash('sha256').update(`character-session-v1:${clientId}:${secret}`).digest();return {seal:async(payload,seconds=43200)=>new EncryptJWT(payload).setProtectedHeader({alg:'dir',enc:'A256GCM'}).setIssuedAt().setExpirationTime(`${seconds}s`).encrypt(key),open:async(token)=>{try{return (await jwtDecrypt(token,key,{keyManagementAlgorithms:['dir'],contentEncryptionAlgorithms:['A256GCM']})).payload}catch{return null}}}}
|
||||||
26
server/index.mjs
Normal file
26
server/index.mjs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as oidc from 'openid-client';
|
||||||
|
import {sessionCodec} from './auth.mjs';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
const origin=process.env.APP_ORIGIN,issuer=process.env.KEYCLOAK_ISSUER,clientId=process.env.KEYCLOAK_CLIENT_ID,secret=process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
|
if(!origin||!issuer||!clientId)throw new Error('APP_ORIGIN and Keycloak configuration are required');
|
||||||
|
if(!origin.startsWith('https://')||!issuer.startsWith('https://'))throw new Error('HTTPS is required');
|
||||||
|
const codec=sessionCodec(secret,clientId);const app=express();app.disable('x-powered-by');app.set('trust proxy',1);app.use(express.urlencoded({extended:false,limit:'2kb'}));
|
||||||
|
let config;async function provider(){return config??=await oidc.discovery(new URL(issuer),clientId,secret)}
|
||||||
|
const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`);
|
||||||
|
const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''};
|
||||||
|
app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()});
|
||||||
|
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.1.0',revision:process.env.SOURCE_COMMIT||'unknown'}));
|
||||||
|
app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}});
|
||||||
|
app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow)return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);res.redirect('/')}catch(e){next(e)}});
|
||||||
|
app.use(async(req,res,next)=>{req.identity=await codec.open(readCookie(req,'__Host-character-session'));next()});
|
||||||
|
app.get('/api/session',(req,res)=>req.identity?res.json({sub:req.identity.sub,name:req.identity.name,expiresAt:req.identity.exp*1000}):res.status(401).json({error:'Sign in required'}));
|
||||||
|
app.post('/auth/logout',(req,res)=>{if(req.get('origin')!==origin)return res.sendStatus(403);cookie(res,'__Host-character-session','',0);cookie(res,'__Host-character-flow','',0);res.redirect('/auth/signed-out')});
|
||||||
|
app.get('/auth/signed-out',(_,res)=>res.type('html').send('<!doctype html><meta name="viewport" content="width=device-width"><title>Signed out</title><h1>Signed out of Character</h1><p>Your character saves remain on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>'));
|
||||||
|
const dist=fileURLToPath(new URL('../dist/',import.meta.url));
|
||||||
|
app.use((req,res,next)=>{if(['/sw.js','/manifest.webmanifest','/icon-192.png','/icon-512.png'].includes(req.path)||req.path.startsWith('/assets/'))return next();if(!req.identity)return res.redirect('/auth/login');next()});
|
||||||
|
app.use(express.static(dist,{index:false,setHeaders:res=>res.set('Cache-Control','private, no-cache')}));
|
||||||
|
app.get('/',(_,res)=>res.sendFile(`${dist}/index.html`));
|
||||||
|
app.use((req,res)=>res.sendStatus(404));
|
||||||
|
app.use((error,req,res,next)=>{console.error('Request failed:',error.code||error.name);res.status(503).send('Sign-in could not be completed. Please try /auth/login again.');});
|
||||||
|
app.listen(Number(process.env.PORT||8080),'0.0.0.0',()=>console.log('Character PWA listening on 8080'));
|
||||||
70
src/App.vue
Normal file
70
src/App.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed,onMounted,ref,watch} from 'vue';
|
||||||
|
import {useCharacters} from './store';
|
||||||
|
import {abilities,modifier,proficiency,signed,skills,skillBonus,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model';
|
||||||
|
const store=useCharacters(),activeId=ref(''),tab=ref('Sheet'),drawer=ref(''),error=ref(''),message=ref(''),user=ref<{sub:string,name:string,expiresAt:number}|null>(null),loading=ref(true),offline=ref(!navigator.onLine);
|
||||||
|
const c=computed(()=>store.all.find(x=>x.id===activeId.value));
|
||||||
|
const draft=ref<Character|null>(null),hpAmount=ref(1),restType=ref<'short-rest'|'long-rest'>('short-rest'),rollResult=ref(''),selectedAction=ref<Character['actions'][number]|null>(null);
|
||||||
|
const restChanges=computed(()=>c.value?restPreview(c.value,restType.value):[]);
|
||||||
|
const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({name:'',current:1,max:1,reset:'long-rest' as 'short-rest'|'long-rest'|'manual'}),spellDraft=ref({name:'',level:1,prepared:true,notes:''});
|
||||||
|
watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true});
|
||||||
|
async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}}
|
||||||
|
onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate()});setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)});
|
||||||
|
function create(){draft.value=newCharacter();drawer.value='Character'}
|
||||||
|
function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}}
|
||||||
|
function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}}
|
||||||
|
function hp(amount:number){if(c.value)changeHP(c.value,amount)}
|
||||||
|
function addAction(){if(c.value&&actionDraft.value.name.trim()){c.value.actions.push({...actionDraft.value,id:crypto.randomUUID()});actionDraft.value={name:'',bonus:0,damage:'',notes:''};drawer.value=''}}
|
||||||
|
function addResource(){if(c.value&&resourceDraft.value.name.trim()&&resourceDraft.value.max>=0&&resourceDraft.value.current>=0&&resourceDraft.value.current<=resourceDraft.value.max){c.value.resources.push({...resourceDraft.value,id:crypto.randomUUID()});resourceDraft.value={name:'',current:1,max:1,reset:'long-rest'};drawer.value=''}else message.value='Remaining uses must be between zero and maximum.'}
|
||||||
|
function addSpell(){if(c.value&&spellDraft.value.name.trim()){c.value.spells.push({...spellDraft.value,id:crypto.randomUUID()});spellDraft.value={name:'',level:1,prepared:true,notes:''};drawer.value=''}}
|
||||||
|
function rest(type:'short-rest'|'long-rest'){restType.value=type;drawer.value='Rest'}
|
||||||
|
function confirmRest(){if(c.value)applyRest(c.value,restType.value);drawer.value='';message.value='Rest applied. Your sheet is saved.'}
|
||||||
|
function die(sides:number){const range=4294967296-(4294967296%sides);let n:number;do{n=crypto.getRandomValues(new Uint32Array(1))[0]}while(n>=range);return n%sides+1}
|
||||||
|
function roll(bonus=0,mode=0){const a=die(20),b=mode?die(20):a,v=mode===1?Math.max(a,b):mode===-1?Math.min(a,b):a;rollResult.value=`${v+bonus} · d20 ${v} ${signed(bonus)}${mode?` (${a}, ${b})`:''}`}
|
||||||
|
function exportCharacter(){if(!c.value)return;const blob=new Blob([JSON.stringify(c.value,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=`${c.value.name.replace(/[^a-z0-9]/gi,'-')}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||||
|
async function importCharacter(event:Event){const input=event.target as HTMLInputElement;try{const file=input.files?.[0];if(!file)return;if(file.size>2_000_000)throw new Error();const valid=characterSchema.parse(JSON.parse(await file.text()));valid.id=crypto.randomUUID();store.all.push(valid);activeId.value=valid.id;store.persist(valid);message.value='Character imported as a new copy.'}catch{message.value='Could not import. Choose a valid character JSON backup under 2 MB.'}input.value=''}
|
||||||
|
async function logout(){await store.flush();localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');for(const key of await caches.keys())if(key.startsWith('character-shell-'))await caches.delete(key);user.value=null;const form=document.createElement('form');form.method='POST';form.action='/auth/logout';document.body.appendChild(form);form.submit()}
|
||||||
|
async function remove(){if(c.value&&confirm(`Delete ${c.value.name} from this device? Export a backup first.`)){await store.remove(c.value.id);activeId.value=store.all[0]?.id||''}}
|
||||||
|
function openAction(action:Character['actions'][number]){selectedAction.value=action;rollResult.value='';drawer.value='Roll'}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="loading" class="gate"><span class="sigil">✦</span><h1>Opening your adventure…</h1></div>
|
||||||
|
<div v-else-if="!user||error" class="gate"><span class="sigil">✦</span><p class="eyebrow">ARROWSTACK · CHARACTER</p><h1>Your next adventure<br>starts here.</h1><p>{{error||'Sign in to open your character sheet.'}}</p><a class="button primary" href="/auth/login">Sign in with Arrowstack →</a></div>
|
||||||
|
<div v-else class="app-shell">
|
||||||
|
<header class="topbar"><a class="brand" href="/">✦ <span>CHARACTER<span class="brand-sub">BY ARROWSTACK</span></span></a><span class="status">{{offline?'Offline session':store.saved?'Saved on device':'Saving…'}}</span><button class="quiet" @click="drawer='Account'" aria-label="Account and settings">{{user.name.split(' ')[0]}}</button></header>
|
||||||
|
<main>
|
||||||
|
<div class="character-switch"><select v-if="store.all.length" v-model="activeId" aria-label="Choose character"><option v-for="character in store.all" :value="character.id">{{character.name}}</option></select><span v-else>Your character collection</span><button class="quiet" @click="create">+ New character</button></div>
|
||||||
|
<p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p>
|
||||||
|
<section v-if="!c" class="empty"><span class="sigil">✦</span><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section>
|
||||||
|
<template v-else>
|
||||||
|
<section class="hero"><div class="monogram">{{c.name.slice(0,1).toUpperCase()}}<span>✦</span></div><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section>
|
||||||
|
<template v-if="tab==='Sheet'">
|
||||||
|
<div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label">HIT POINTS <span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div>
|
||||||
|
<div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div>
|
||||||
|
<div class="two-column"><section class="panel"><div class="section-title"><h2>Ready for your turn</h2><button class="quiet" @click="tab='Actions'">All actions ↗</button></div><div v-if="!c.actions.length" class="hint">Add your weapons and actions to keep them close at hand.<button class="quiet" @click="drawer='Add action'">+ Add action</button></div><button class="action-row" v-for="action in c.actions.slice(0,4)" @click="openAction(action)"><span class="action-symbol">↗</span><span><b>{{action.name}}</b><small>{{action.damage||'Custom action'}}</small></span><strong>{{signed(action.bonus)}}</strong></button></section><section class="panel"><div class="section-title"><h2>Resources</h2><button class="quiet" @click="drawer='Add resource'">+ Add</button></div><p class="hint" v-if="!c.resources.length">Spell slots, class features and item charges — tracked your way.</p><div class="resource" v-for="r in c.resources"><div><b>{{r.name}}</b><small>{{r.current}} / {{r.max}} · {{r.reset.replace('-',' ')}}</small></div><div class="counter"><button @click="r.current=Math.max(0,r.current-1)" :disabled="r.current===0" :aria-label="`Spend ${r.name}`">−</button><strong>{{r.current}}</strong><button @click="r.current=Math.min(r.max,r.current+1)" :disabled="r.current===r.max" :aria-label="`Restore ${r.name}`">+</button></div></div></section></div>
|
||||||
|
<section class="panel"><div class="section-title"><h2>Skills & saving throws</h2><span class="chip">Proficiency {{signed(proficiency(c.level))}}</span></div><div class="save-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a])+(c.saves.includes(a)?proficiency(c.level):0));drawer='Dice'"><span>{{c.saves.includes(a)?'●':'○'}} {{a.toUpperCase()}}</span><b>{{signed(modifier(c.abilities[a])+(c.saves.includes(a)?proficiency(c.level):0))}}</b></button></div><div class="skill-grid"><button v-for="(a,name) in skills" @click="roll(skillBonus(c,String(name)));drawer='Dice'"><span>{{c.skills[name]==='expert'?'◆':c.skills[name]==='proficient'?'●':'○'}} {{name}}</span><b>{{signed(skillBonus(c,String(name)))}}</b></button></div></section>
|
||||||
|
<div class="rest-bar"><span>Take a moment to recover.</span><button @click="rest('short-rest')">Short rest</button><button class="primary" @click="rest('long-rest')">Long rest ☾</button></div>
|
||||||
|
</template>
|
||||||
|
<section class="panel" v-if="tab==='Actions'"><div class="section-title"><h2>Actions</h2><button @click="drawer='Add action'">+ Add action</button></div><p class="hint" v-if="!c.actions.length">Add attacks, features and custom actions with your own modifiers.</p><div class="list-entry" v-for="action in c.actions"><button class="action-row" @click="openAction(action)"><span><b>{{action.name}}</b><small>{{action.damage}} · {{action.notes}}</small></span><strong>{{signed(action.bonus)}}</strong></button><button class="quiet" @click="c.actions=c.actions.filter(a=>a.id!==action.id)" :aria-label="`Remove ${action.name}`">Remove</button></div></section>
|
||||||
|
<section class="panel" v-if="tab==='Spells'"><div class="section-title"><h2>Spellbook</h2><button @click="drawer='Add spell'">+ Add spell</button></div><p class="hint">Record your own spells. Track spell slots with resources on the Sheet tab.</p><div class="spell-row" v-for="spell in [...c.spells].sort((a,b)=>a.level-b.level)"><label><input type="checkbox" v-model="spell.prepared"><span><b>{{spell.name}}</b><small>{{spell.level?'Level '+spell.level:'Cantrip'}} · {{spell.prepared?'Prepared':'Not prepared'}}</small><p>{{spell.notes}}</p></span></label><button class="quiet" @click="c.spells=c.spells.filter(s=>s.id!==spell.id)">Remove</button></div></section>
|
||||||
|
<section class="panel" v-if="tab==='Gear'"><div class="section-title"><h2>Gear & inventory</h2><span class="chip">Saved automatically</span></div><p class="hint">Equipment, currency, attunement and anything you picked up along the way.</p><textarea v-model="c.inventory" rows="14" placeholder="Longbow, 20 arrows…" maxlength="12000"></textarea></section>
|
||||||
|
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2>Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters are saved on this device, separately for each Arrowstack account. Export backups to move them to another device.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
|
||||||
|
</template>
|
||||||
|
<footer>✦ ARROWSTACK CHARACTER <span>v0.1 · Made for game night</span></footer>
|
||||||
|
</main>
|
||||||
|
<nav class="bottom-nav" v-if="c"><button v-for="(name,index) in ['Sheet','Actions','Spells','Gear','More']" :class="{active:tab===name}" @click="tab=name"><span>{{['◈','↗','✦','▣','•••'][index]}}</span>{{name}}</button></nav>
|
||||||
|
<div v-if="drawer" class="overlay" @click.self="drawer=''"><section class="drawer" role="dialog" aria-modal="true" :aria-label="drawer"><div class="section-title"><h2>{{drawer==='Rest'?(restType==='long-rest'?'Long rest':'Short rest'):drawer}}</h2><button class="close" @click="drawer=''" aria-label="Close dialog">×</button></div>
|
||||||
|
<template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters are stored on this device, separately for each Arrowstack account.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template>
|
||||||
|
<form v-if="drawer==='Character'&&draft" @submit.prevent="saveDraft"><label>Name<input v-model="draft.name" required maxlength="120"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120"></label><label>Background<input v-model="draft.background" maxlength="120"></label></div><h3>Ability scores</h3><div class="form-grid three"><label v-for="a in abilities">{{a.toUpperCase()}}<input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label><label>AC<input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label>Speed (ft)<input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div><h3>Saving throw proficiency</h3><div class="check-row"><label v-for="a in abilities"><input type="checkbox" :value="a" v-model="draft.saves">{{a.toUpperCase()}}</label></div><h3>Skill proficiency</h3><div class="form-grid"><label v-for="(a,name) in skills">{{name}}<select v-model="draft.skills[name]"><option value="none">None</option><option value="proficient">Proficient</option><option value="expert">Expertise</option></select></label></div><button class="primary wide">Save character</button></form>
|
||||||
|
<template v-if="drawer==='Hit points'&&c"><p class="big-number">{{c.hp.current}} <small>/ {{c.hp.max}}</small></p><div class="quick-hp"><button v-for="n in [-10,-5,-1,1,5,10]" @click="hp(n)">{{signed(n)}}</button></div><label>Amount<input type="number" v-model.number="hpAmount" min="1" max="9999"></label><div class="form-grid"><button class="danger-text" @click="hp(-Math.max(0,Math.trunc(hpAmount||0)))">Take damage</button><button class="primary" @click="hp(Math.max(0,Math.trunc(hpAmount||0)))">Heal</button></div><label>Temporary HP<input type="number" :value="c.hp.temp" @change="c.hp.temp=Math.max(0,Math.min(9999,Math.trunc(Number(($event.target as HTMLInputElement).value)||0)))" min="0" max="9999"></label><p class="hint">Damage uses temporary HP first. Healing cannot exceed your maximum.</p></template>
|
||||||
|
<form v-if="drawer==='Add action'" @submit.prevent="addAction"><label>Name<input v-model="actionDraft.name" required maxlength="120"></label><div class="form-grid"><label>Attack bonus<input type="number" v-model.number="actionDraft.bonus" min="-50" max="100" required></label><label>Damage<input v-model="actionDraft.damage" placeholder="1d8 + 4 piercing" maxlength="120"></label></div><label>Notes<textarea v-model="actionDraft.notes" maxlength="12000"></textarea></label><button class="primary wide">Add action</button></form>
|
||||||
|
<form v-if="drawer==='Add resource'" @submit.prevent="addResource"><label>Name<input v-model="resourceDraft.name" placeholder="Level 1 spell slots" required maxlength="120"></label><div class="form-grid"><label>Remaining<input type="number" v-model.number="resourceDraft.current" min="0" :max="resourceDraft.max" required></label><label>Maximum<input type="number" v-model.number="resourceDraft.max" min="0" max="9999" required></label></div><label>Recovery<select v-model="resourceDraft.reset"><option value="short-rest">Short or long rest</option><option value="long-rest">Long rest</option><option value="manual">Manual only</option></select></label><button class="primary wide">Add resource</button></form>
|
||||||
|
<template v-if="drawer==='Manage resources'&&c"><div class="resource" v-for="r in c.resources"><b>{{r.name}}</b><button class="quiet danger-text" @click="c.resources=c.resources.filter(x=>x.id!==r.id)">Remove</button></div><button @click="drawer='Add resource'">+ Add resource</button></template>
|
||||||
|
<form v-if="drawer==='Add spell'" @submit.prevent="addSpell"><label>Name<input v-model="spellDraft.name" required maxlength="120"></label><label>Level (0 = cantrip)<input type="number" v-model.number="spellDraft.level" min="0" max="9" required></label><label>Spell notes<textarea v-model="spellDraft.notes" rows="6" maxlength="12000" placeholder="Casting time, range, components, duration, effects…"></textarea></label><button class="primary wide">Add spell</button></form>
|
||||||
|
<template v-if="drawer==='Rest'&&c"><p class="hint">Review before applying your rest.</p><div class="preview-row" v-if="restType==='long-rest'"><span>Hit points</span><b>{{c.hp.current}} → {{c.hp.max}}</b></div><div class="preview-row" v-if="restType==='long-rest'"><span>Temporary HP</span><b>{{c.hp.temp}} → 0</b></div><div class="preview-row" v-for="r in restChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><p class="hint">Manual resources, notes and gear remain as entered. Hit dice and class-specific rest rules are not automated yet.</p><button class="primary wide" @click="confirmRest">Confirm rest</button></template>
|
||||||
|
<template v-if="drawer==='Roll'&&selectedAction"><h3>{{selectedAction.name}}</h3><p class="hint">Attack {{signed(selectedAction.bonus)}} · {{selectedAction.damage}}</p><p>{{selectedAction.notes}}</p><div class="roll-buttons"><button @click="roll(selectedAction.bonus,-1)">Disadvantage</button><button class="primary" @click="roll(selectedAction.bonus)">Roll d20</button><button @click="roll(selectedAction.bonus,1)">Advantage</button></div><p class="roll-result" role="status">{{rollResult}}</p></template>
|
||||||
|
<template v-if="drawer==='Dice'"><p class="roll-result" role="status">{{rollResult}}</p><p class="hint">Includes the modifier shown on your sheet.</p></template>
|
||||||
|
</section></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
src/db.ts
Normal file
8
src/db.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Dexie, {type Table} from 'dexie';
|
||||||
|
import {characterSchema,type Character} from './model';
|
||||||
|
type Row={key:string,owner:string,character:Character};
|
||||||
|
export class CharacterDB extends Dexie {characters!:Table<Row,string>;constructor(name='arrowstack-character-v1'){super(name);this.version(1).stores({characters:'key,owner'})}}
|
||||||
|
export const db=new CharacterDB();
|
||||||
|
export async function loadCharacters(owner:string,database=db){return (await database.characters.where('owner').equals(owner).toArray()).map(r=>characterSchema.parse(r.character))}
|
||||||
|
export async function saveCharacter(owner:string,c:Character,database=db){const character=characterSchema.parse(c);await database.characters.put({key:`${owner}:${c.id}`,owner,character})}
|
||||||
|
export async function deleteCharacter(owner:string,id:string,database=db){await database.characters.delete(`${owner}:${id}`)}
|
||||||
2
src/main.ts
Normal file
2
src/main.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import {createApp} from 'vue';import {createPinia} from 'pinia';import App from './App.vue';import './style.css';
|
||||||
|
createApp(App).use(createPinia()).mount('#app');
|
||||||
20
src/model.ts
Normal file
20
src/model.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
export const abilities=['str','dex','con','int','wis','cha'] as const;
|
||||||
|
export type Ability=typeof abilities[number];
|
||||||
|
const score=z.number().int().min(1).max(30);
|
||||||
|
const counter=z.number().int().min(0).max(9999);
|
||||||
|
const text=z.string().max(12000);
|
||||||
|
export const resourceSchema=z.object({id:z.string(),name:text,current:counter,max:counter,reset:z.enum(['short-rest','long-rest','manual'])}).refine(r=>r.current<=r.max,'Current cannot exceed maximum');
|
||||||
|
export const actionSchema=z.object({id:z.string(),name:text,bonus:z.number().int().min(-50).max(100),damage:text,notes:text});
|
||||||
|
export const spellSchema=z.object({id:z.string(),name:text,level:z.number().int().min(0).max(9),prepared:z.boolean(),notes:text});
|
||||||
|
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),inventory:text,notes:text});
|
||||||
|
export type Character=z.infer<typeof characterSchema>;
|
||||||
|
export const skills:Record<string,Ability>={'Acrobatics':'dex','Animal Handling':'wis','Arcana':'int','Athletics':'str','Deception':'cha','History':'int','Insight':'wis','Intimidation':'cha','Investigation':'int','Medicine':'wis','Nature':'int','Perception':'wis','Performance':'cha','Persuasion':'cha','Religion':'int','Sleight of Hand':'dex','Stealth':'dex','Survival':'wis'};
|
||||||
|
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],inventory:'',notes:''}}
|
||||||
|
export const modifier=(n:number)=>Math.floor((n-10)/2);
|
||||||
|
export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
|
||||||
|
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;
|
||||||
|
export function skillBonus(c:Character,name:string){return modifier(c.abilities[skills[name]])+proficiency(c.level)*(c.skills[name]==='expert'?2:c.skills[name]==='proficient'?1:0)}
|
||||||
|
export function changeHP(c:Character,amount:number){if(amount<0){const absorbed=Math.min(c.hp.temp,-amount);c.hp.temp-=absorbed;c.hp.current=Math.max(0,c.hp.current+amount+absorbed)}else c.hp.current=Math.min(c.hp.max,c.hp.current+amount)}
|
||||||
|
export function restPreview(c:Character,type:'short-rest'|'long-rest'){return c.resources.filter(r=>r.reset===type||(type==='long-rest'&&r.reset==='short-rest')).filter(r=>r.current!==r.max)}
|
||||||
|
export function applyRest(c:Character,type:'short-rest'|'long-rest'){for(const r of restPreview(c,type))r.current=r.max;if(type==='long-rest'){c.hp.current=c.hp.max;c.hp.temp=0}}
|
||||||
5
src/store.ts
Normal file
5
src/store.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import {defineStore} from 'pinia';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
import {loadCharacters,saveCharacter,deleteCharacter} from './db';
|
||||||
|
import type {Character} from './model';
|
||||||
|
export const useCharacters=defineStore('characters',()=>{const all=ref<Character[]>([]),owner=ref(''),saveError=ref(''),saved=ref(true);let queue=Promise.resolve();async function open(sub:string){owner.value=sub;all.value=await loadCharacters(sub)}function persist(c:Character){saved.value=false;const snapshot=JSON.parse(JSON.stringify(c));const sub=owner.value;queue=queue.then(()=>saveCharacter(sub,snapshot)).then(()=>{saved.value=true;saveError.value=''}).catch(()=>{saveError.value='Could not save on this device. Export a backup before closing.'})}async function remove(id:string){await queue;await deleteCharacter(owner.value,id);all.value=all.value.filter(c=>c.id!==id)}async function flush(){await queue}return {all,owner,saved,saveError,open,persist,remove,flush}});
|
||||||
2
src/style.css
Normal file
2
src/style.css
Normal file
File diff suppressed because one or more lines are too long
5
tests/auth.test.ts
Normal file
5
tests/auth.test.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
|
// @ts-ignore server module is plain JavaScript
|
||||||
|
import {sessionCodec} from '../server/auth.mjs';
|
||||||
|
test('encrypted sessions reject tampering, expiry and other client keys',async()=>{const codec=sessionCodec('a-confidential-client-secret','character'),token=await codec.seal({sub:'alice'});assert.equal((await codec.open(token)).sub,'alice');assert.equal(await codec.open(token.slice(0,-4)+'aaaa'),null);assert.equal(await sessionCodec('another-confidential-secret','character').open(token),null);assert.equal(await codec.open(await codec.seal({sub:'alice'},-1)),null);assert.equal(await codec.open(''),null)});
|
||||||
|
test('missing secrets fail closed',()=>{assert.throws(()=>sessionCodec('','character'))});
|
||||||
9
tests/character.test.ts
Normal file
9
tests/character.test.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
|
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema} from '../src/model.ts';
|
||||||
|
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
|
||||||
|
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
|
||||||
|
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
|
||||||
|
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
|
||||||
|
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
|
||||||
|
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});
|
||||||
1
tsconfig.json
Normal file
1
tsconfig.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"lib":["ES2022","DOM","DOM.Iterable"],"skipLibCheck":true,"types":["vite/client"],"allowImportingTsExtensions":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue","vite.config.ts"]}
|
||||||
3
vite.config.ts
Normal file
3
vite.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
export default defineConfig({plugins:[vue()],server:{proxy:{'/api':'http://localhost:8080','/auth':'http://localhost:8080'}}});
|
||||||
Loading…
x
Reference in New Issue
Block a user