dune-tools/character-builder/backend/src/builds/builds.service.ts
Vantz Stockwell 98a1792106 Add Dune Awakening character builder + initial project scaffolding
- character-builder/: Vue 3 + NestJS + Valkey app for planning house, class,
  character XP, 5 spec tracks, faction standing, and skill trees. Shareable
  via short link (POST /api/builds → 8-char nanoid).
- character-builder/data/: parsed JSON tables (character XP through L200,
  5 specs to L100, 2 faction standing tables, 5 class skill trees).
- character-builder/scripts/extract.py: parser that regenerates data/*.json
  from the gitignored sample-data/*.html snapshots.
- Dockerfile + docker-compose.yml: two-container deploy (app + Valkey).
- specialization-calculator/: pre-existing single-file XP/quest calculator,
  carried into the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:30:37 -04:00

56 lines
1.8 KiB
TypeScript

import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { customAlphabet } from 'nanoid';
import type Redis from 'ioredis';
import { VALKEY } from '../valkey.provider';
// URL-safe, unambiguous-ish alphabet (no 0/O/1/I/l). 8 chars = 38^8 ≈ 4.4e12 codes.
const nanoid = customAlphabet(
'23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz',
8,
);
const MAX_BYTES = 16 * 1024; // 16 KB cap per shared build
const TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year
@Injectable()
export class BuildsService {
constructor(@Inject(VALKEY) private readonly redis: Redis) {}
async save(payload: unknown): Promise<{ code: string }> {
const json = JSON.stringify(payload);
if (!json || json === 'null') {
throw new BadRequestException('empty build');
}
if (Buffer.byteLength(json, 'utf8') > MAX_BYTES) {
throw new BadRequestException(`build too large (>${MAX_BYTES} bytes)`);
}
// Up to 5 retries on collision.
for (let i = 0; i < 5; i++) {
const code = nanoid();
const key = `build:${code}`;
const ok = await this.redis.set(key, json, 'EX', TTL_SECONDS, 'NX');
if (ok === 'OK') return { code };
}
throw new BadRequestException('failed to allocate code');
}
async load(code: string): Promise<unknown> {
if (!/^[2-9A-HJ-NP-Za-km-z]{8}$/.test(code)) {
throw new BadRequestException('invalid code');
}
const json = await this.redis.get(`build:${code}`);
if (!json) throw new NotFoundException('build not found');
// refresh TTL so popular builds stay alive
await this.redis.expire(`build:${code}`, TTL_SECONDS);
try {
return JSON.parse(json);
} catch {
throw new BadRequestException('corrupt build');
}
}
}