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>
This commit is contained in:
Vantz Stockwell 2026-05-23 07:30:37 -04:00
commit 98a1792106
48 changed files with 9805 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# raw HTML snapshots from dune.gaming.tools — not committed (large, regenerable)
sample-data/
# node
**/node_modules/
**/dist/
**/.vite/
# editor / OS
.DS_Store
*.log
.idea/
.vscode/
# python tooling artifacts
__pycache__/
*.pyc

31
README.md Normal file
View file

@ -0,0 +1,31 @@
# dune-tools
Fan-made tooling for Dune Awakening.
## Projects
- **[character-builder/](character-builder/)** — Vue 3 + NestJS + Valkey app to
plan a character (house, class, character XP, 5 specialization tracks, faction
standing, skill trees). Shareable via short link. Runs as two Docker
containers.
- **[specialization-calculator/](specialization-calculator/)** — single-file
HTML calculator for spec-track XP / quests / days.
## Source data
The character builder is built from saved HTML snapshots of
[dune.gaming.tools](https://dune.gaming.tools) that live in `sample-data/`.
That directory is **gitignored** (~19 MB of raw HTML).
To regenerate `character-builder/data/*.json`:
1. Save the relevant pages (right-click → "Save Page As → Web Page Complete")
into `sample-data/`.
2. From `character-builder/`, run `uv run python3 scripts/extract.py`.
The runtime JSON (`character-builder/data/*.json`) **is** committed so the app
builds without the raw HTML.
## Disclaimer
Unofficial fan project. Not affiliated with Funcom or any rights holder.

View file

@ -0,0 +1,5 @@
**/node_modules
**/dist
**/.DS_Store
.git
*.log

View file

@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
# ---------- frontend build ----------
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json ./
RUN npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ---------- backend build ----------
FROM node:22-alpine AS backend-build
WORKDIR /app/backend
COPY backend/package.json ./
RUN npm install --no-audit --no-fund
COPY backend/ ./
RUN npm run build
# ---------- runtime ----------
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Backend prod deps only.
COPY backend/package.json ./
RUN npm install --omit=dev --no-audit --no-fund
# Compiled backend, built frontend, baked data.
COPY --from=backend-build /app/backend/dist ./dist
COPY --from=frontend-build /app/frontend/dist ./public
COPY data ./data
ENV STATIC_ROOT=/app/public
ENV DATA_ROOT=/app/data
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,56 @@
# Dune Awakening — Character Builder
Plan your character across House, Class, Character XP, Specializations, Faction
Standing, and Skill Trees. Shareable via short link.
## Stack
- **Frontend**: Vue 3 + Vite + TypeScript
- **Backend**: NestJS (Node 22, TypeScript)
- **Storage**: Valkey (Redis-compatible) for build sharing
- **Data**: extracted from `../sample-data/*.html` into `./data/*.json` via `scripts/extract.py`
Two containers: `app` (NestJS serving the SPA + API) and `valkey`. Wire-up in
`docker-compose.yml`.
## Run
```sh
docker compose up --build
```
Then open <http://localhost:8080>.
## Update data
If you save new HTML snapshots into `../sample-data/`, re-run the extractor:
```sh
uv run python3 scripts/extract.py
```
This regenerates `./data/*.json`, which the container picks up on next build.
## API
- `POST /api/builds` — body is a build payload (any JSON ≤16 KB). Returns
`{ code }` (8-char URL-safe id).
- `GET /api/builds/:code` — returns `{ code, build }`.
- `GET /api/data/:file` — serves the static JSON tables (allow-listed).
Builds are stored in Valkey with a rolling 1-year TTL (refreshed on each
fetch), so popular shared builds don't expire.
## Dev (without Docker)
In two terminals:
```sh
# backend
cd backend && npm install && VALKEY_URL=redis://localhost:6379 npm run start:dev
# frontend
cd frontend && npm install && npm run dev
```
The Vite dev server proxies `/api/*` to `localhost:3000`.

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,27 @@
{
"name": "dune-character-builder-backend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/serve-static": "^4.0.2",
"ioredis": "^5.4.2",
"nanoid": "^3.3.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { BuildsModule } from './builds/builds.module';
import { DataModule } from './data/data.module';
const staticRoot = process.env.STATIC_ROOT || join(__dirname, '..', 'public');
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: staticRoot,
exclude: ['/api/(.*)'],
}),
BuildsModule,
DataModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,18 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { BuildsService } from './builds.service';
@Controller('builds')
export class BuildsController {
constructor(private readonly builds: BuildsService) {}
@Post()
create(@Body() body: unknown) {
return this.builds.save(body);
}
@Get(':code')
async fetch(@Param('code') code: string) {
const build = await this.builds.load(code);
return { code, build };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BuildsController } from './builds.controller';
import { BuildsService } from './builds.service';
import { ValkeyProvider } from '../valkey.provider';
@Module({
controllers: [BuildsController],
providers: [BuildsService, ValkeyProvider],
})
export class BuildsModule {}

View file

@ -0,0 +1,56 @@
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');
}
}
}

View file

@ -0,0 +1,49 @@
import {
BadRequestException,
Controller,
Get,
Header,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream, existsSync } from 'fs';
import { join } from 'path';
const DATA_ROOT = process.env.DATA_ROOT || join(__dirname, '..', '..', 'data');
// Allow-list of known filenames — guards against path traversal.
const ALLOWED = new Set<string>([
'index.json',
'character-xp.json',
'spec-combat.json',
'spec-crafting.json',
'spec-exploration.json',
'spec-gathering.json',
'spec-sabotage.json',
'faction-atreides.json',
'faction-harkonnen.json',
'skills-benegesserit.json',
'skills-mentat.json',
'skills-planetologist.json',
'skills-swordmaster.json',
'skills-trooper.json',
]);
@Controller('data')
export class DataController {
@Get(':file')
@Header('Content-Type', 'application/json; charset=utf-8')
@Header('Cache-Control', 'public, max-age=3600')
one(@Param('file') file: string, @Res() res: Response) {
if (!ALLOWED.has(file)) {
throw new BadRequestException('unknown data file');
}
const path = join(DATA_ROOT, file);
if (!existsSync(path)) {
throw new NotFoundException();
}
createReadStream(path).pipe(res);
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { DataController } from './data.controller';
@Module({
controllers: [DataController],
})
export class DataModule {}

View file

@ -0,0 +1,12 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api', { exclude: [] });
const port = Number(process.env.PORT) || 3000;
await app.listen(port, '0.0.0.0');
console.log(`[dune-character-builder] listening on :${port}`);
}
bootstrap();

View file

@ -0,0 +1,19 @@
import { Logger, Provider } from '@nestjs/common';
import Redis from 'ioredis';
export const VALKEY = Symbol('VALKEY');
export const ValkeyProvider: Provider = {
provide: VALKEY,
useFactory: (): Redis => {
const url = process.env.VALKEY_URL || 'redis://valkey:6379';
const logger = new Logger('Valkey');
const client = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: 3,
});
client.on('connect', () => logger.log(`connected ${url}`));
client.on('error', (err) => logger.warn(`error: ${err.message}`));
return client;
},
};

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"outDir": "./dist",
"baseUrl": "./",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"noImplicitAny": false,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"removeComments": true,
"incremental": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
{
"header": [
"Tier",
"Name",
"Required Rep",
"Cumulative"
],
"tiers": [
{
"tier": 0,
"name": "Outsider",
"standingRequired": 0,
"totalStanding": 0
},
{
"tier": 1,
"name": "Mercenary",
"standingRequired": 99,
"totalStanding": 99
},
{
"tier": 2,
"name": "Recruit",
"standingRequired": 150,
"totalStanding": 249
},
{
"tier": 3,
"name": "Contractor",
"standingRequired": 250,
"totalStanding": 499
},
{
"tier": 4,
"name": "Agent",
"standingRequired": 500,
"totalStanding": 999
},
{
"tier": 5,
"name": "House Operator",
"standingRequired": 1000,
"totalStanding": 1999
},
{
"tier": 6,
"name": "-",
"standingRequired": 225,
"totalStanding": 2224
},
{
"tier": 7,
"name": "-",
"standingRequired": 300,
"totalStanding": 2524
},
{
"tier": 8,
"name": "-",
"standingRequired": 375,
"totalStanding": 2899
},
{
"tier": 9,
"name": "-",
"standingRequired": 450,
"totalStanding": 3349
},
{
"tier": 10,
"name": "-",
"standingRequired": 525,
"totalStanding": 3874
},
{
"tier": 11,
"name": "-",
"standingRequired": 600,
"totalStanding": 4474
},
{
"tier": 12,
"name": "-",
"standingRequired": 675,
"totalStanding": 5149
},
{
"tier": 13,
"name": "-",
"standingRequired": 750,
"totalStanding": 5899
},
{
"tier": 14,
"name": "-",
"standingRequired": 825,
"totalStanding": 6724
},
{
"tier": 15,
"name": "-",
"standingRequired": 900,
"totalStanding": 7624
},
{
"tier": 16,
"name": "-",
"standingRequired": 975,
"totalStanding": 8599
},
{
"tier": 17,
"name": "-",
"standingRequired": 1050,
"totalStanding": 9649
},
{
"tier": 18,
"name": "-",
"standingRequired": 1125,
"totalStanding": 10774
},
{
"tier": 19,
"name": "-",
"standingRequired": 1200,
"totalStanding": 11974
},
{
"tier": 20,
"name": "Envoy",
"standingRequired": 500,
"totalStanding": 12474
}
]
}

View file

@ -0,0 +1,136 @@
{
"header": [
"Tier",
"Name",
"Required Rep",
"Cumulative"
],
"tiers": [
{
"tier": 0,
"name": "Outsider",
"standingRequired": 0,
"totalStanding": 0
},
{
"tier": 1,
"name": "Mercenary",
"standingRequired": 99,
"totalStanding": 99
},
{
"tier": 2,
"name": "Recruit",
"standingRequired": 150,
"totalStanding": 249
},
{
"tier": 3,
"name": "Contractor",
"standingRequired": 250,
"totalStanding": 499
},
{
"tier": 4,
"name": "Agent",
"standingRequired": 500,
"totalStanding": 999
},
{
"tier": 5,
"name": "House Operator",
"standingRequired": 1000,
"totalStanding": 1999
},
{
"tier": 6,
"name": "-",
"standingRequired": 225,
"totalStanding": 2224
},
{
"tier": 7,
"name": "-",
"standingRequired": 300,
"totalStanding": 2524
},
{
"tier": 8,
"name": "-",
"standingRequired": 375,
"totalStanding": 2899
},
{
"tier": 9,
"name": "-",
"standingRequired": 450,
"totalStanding": 3349
},
{
"tier": 10,
"name": "-",
"standingRequired": 525,
"totalStanding": 3874
},
{
"tier": 11,
"name": "-",
"standingRequired": 600,
"totalStanding": 4474
},
{
"tier": 12,
"name": "-",
"standingRequired": 675,
"totalStanding": 5149
},
{
"tier": 13,
"name": "-",
"standingRequired": 750,
"totalStanding": 5899
},
{
"tier": 14,
"name": "-",
"standingRequired": 825,
"totalStanding": 6724
},
{
"tier": 15,
"name": "-",
"standingRequired": 900,
"totalStanding": 7624
},
{
"tier": 16,
"name": "-",
"standingRequired": 975,
"totalStanding": 8599
},
{
"tier": 17,
"name": "-",
"standingRequired": 1050,
"totalStanding": 9649
},
{
"tier": 18,
"name": "-",
"standingRequired": 1125,
"totalStanding": 10774
},
{
"tier": 19,
"name": "-",
"standingRequired": 1200,
"totalStanding": 11974
},
{
"tier": 20,
"name": "Enforcer",
"standingRequired": 500,
"totalStanding": 12474
}
]
}

View file

@ -0,0 +1,51 @@
{
"xp": {
"character": "character-xp.json",
"combat": "spec-combat.json",
"crafting": "spec-crafting.json",
"exploration": "spec-exploration.json",
"gathering": "spec-gathering.json",
"sabotage": "spec-sabotage.json"
},
"factions": {
"atreides": "faction-atreides.json",
"harkonnen": "faction-harkonnen.json"
},
"skills": [
{
"id": "benegesserit",
"name": "Bene Gesserit",
"file": "skills-benegesserit.json",
"nodes": 22,
"edges": 23
},
{
"id": "mentat",
"name": "Mentat",
"file": "skills-mentat.json",
"nodes": 22,
"edges": 22
},
{
"id": "planetologist",
"name": "Planetologist",
"file": "skills-planetologist.json",
"nodes": 20,
"edges": 10
},
{
"id": "swordmaster",
"name": "Swordmaster",
"file": "skills-swordmaster.json",
"nodes": 22,
"edges": 22
},
{
"id": "trooper",
"name": "Trooper",
"file": "skills-trooper.json",
"nodes": 22,
"edges": 22
}
]
}

View file

@ -0,0 +1,342 @@
{
"id": "benegesserit",
"name": "Bene Gesserit",
"nodes": [
{
"tag": "Skills.Spice.BinduDodge",
"id": "BinduDodge",
"name": "Bindu Dodge",
"kind": "Spice",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreebindudodge_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-bindudodge"
},
{
"tag": "Skills.Ability.BinduNerveStrike",
"id": "BinduNerveStrike",
"name": "Prana-Bindu Strikes",
"kind": "Ability",
"row": 2,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconabilitybindunervestrike_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-bindunervestrike"
},
{
"tag": "Skills.Ability.WeirdingStep",
"id": "WeirdingStep",
"name": "Weirding Step",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilityweirdingstep_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-weirdingstep"
},
{
"tag": "Skills.Attribute.WeirdingWay2",
"id": "WeirdingWay2",
"name": "Short Blade Damage",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillbrawler_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weirdingway2"
},
{
"tag": "Skills.Perk.Backstabber",
"id": "Backstabber",
"name": "Manipulate Instability",
"kind": "Perk",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkbackstabber_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-backstabber"
},
{
"tag": "Skills.Attribute.WeirdingWay1",
"id": "WeirdingWay1",
"name": "Blade Damage",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillbrawler_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weirdingway1"
},
{
"tag": "Skills.Ability.Hypersprint",
"id": "Hypersprint",
"name": "Bindu Sprint",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconabilitydash_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-hypersprint"
},
{
"tag": "Skills.Spice.VoiceSplash",
"id": "VoiceSplash",
"name": "Screech",
"kind": "Spice",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreescreech_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-voicesplash"
},
{
"tag": "Skills.Perk.VoiceAnalysis",
"id": "VoiceAnalysis",
"name": "Rapid Register",
"kind": "Perk",
"row": 2,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconskilltreevoiceanalysis_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-voiceanalysis"
},
{
"tag": "Skills.Ability.VoiceStop",
"id": "VoiceStop",
"name": "Stop",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilityvoicestop_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-voicestop"
},
{
"tag": "Skills.Ability.Blindspot",
"id": "Blindspot",
"name": "Ignore",
"kind": "Ability",
"row": 4,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconabilityblindspot_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-blindspot"
},
{
"tag": "Skills.Attribute.Manipulation1",
"id": "Manipulation1",
"name": "Voice Training",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreebenegesseritcooldown_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-manipulation1"
},
{
"tag": "Skills.Ability.VoiceCompel",
"id": "VoiceCompel",
"name": "Compel",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconabilitythevoicecompel_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-voicecompel"
},
{
"tag": "Skills.Ability.LitanyAgainstFear",
"id": "LitanyAgainstFear",
"name": "Litany Against Fear",
"kind": "Ability",
"row": 1,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconabilitylitanyagainstfear_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-litanyagainstfear"
},
{
"tag": "Skills.Perk.BinduStability",
"id": "BinduStability",
"name": "Prana-Bindu Stability",
"kind": "Perk",
"row": 2,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreebindustability_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-bindustability"
},
{
"tag": "Skills.Perk.MetabolizePoison",
"id": "MetabolizePoison",
"name": "Metabolize Poison",
"kind": "Perk",
"row": 2,
"col": 4,
"maxPoints": 1,
"icon": "t_ui_iconskilltreemetabolizeposion_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-metabolizepoison"
},
{
"tag": "Skills.Attribute.SelfControl3",
"id": "SelfControl3",
"name": "Vitality",
"kind": "Attribute",
"row": 3,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemaxhpbonus_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-selfcontrol3"
},
{
"tag": "Skills.Attribute.SelfControl4",
"id": "SelfControl4",
"name": "Self-Healing",
"kind": "Attribute",
"row": 3,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillmaxhealth_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-selfcontrol4"
},
{
"tag": "Skills.Attribute.SelfControl5",
"id": "SelfControl5",
"name": "Poison Tolerance",
"kind": "Attribute",
"row": 3,
"col": 5,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributepoisondefense_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-selfcontrol5"
},
{
"tag": "Skills.Perk.RegenCap",
"id": "RegenCap",
"name": "Trauma Recovery",
"kind": "Perk",
"row": 4,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkhealingfactor_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-regencap"
},
{
"tag": "Skills.Attribute.SelfControl2",
"id": "SelfControl2",
"name": "Sun Tolerance",
"kind": "Attribute",
"row": 4,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributesundefense_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-selfcontrol2"
},
{
"tag": "Skills.Attribute.SelfControl1",
"id": "SelfControl1",
"name": "Recovery",
"kind": "Attribute",
"row": 5,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillhealingmultiplier_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-selfcontrol1"
}
],
"edges": [
{
"from": "Skills.Ability.BinduNerveStrike",
"to": "Skills.Spice.BinduDodge"
},
{
"from": "Skills.Ability.WeirdingStep",
"to": "Skills.Spice.BinduDodge"
},
{
"from": "Skills.Ability.BinduNerveStrike",
"to": "Skills.Attribute.WeirdingWay2"
},
{
"from": "Skills.Ability.BinduNerveStrike",
"to": "Skills.Perk.Backstabber"
},
{
"from": "Skills.Ability.WeirdingStep",
"to": "Skills.Attribute.WeirdingWay2"
},
{
"from": "Skills.Ability.WeirdingStep",
"to": "Skills.Attribute.WeirdingWay1"
},
{
"from": "Skills.Attribute.WeirdingWay2",
"to": "Skills.Perk.Backstabber"
},
{
"from": "Skills.Attribute.WeirdingWay1",
"to": "Skills.Attribute.WeirdingWay2"
},
{
"from": "Skills.Ability.Hypersprint",
"to": "Skills.Perk.Backstabber"
},
{
"from": "Skills.Attribute.WeirdingWay1",
"to": "Skills.Perk.Backstabber"
},
{
"from": "Skills.Ability.Hypersprint",
"to": "Skills.Attribute.WeirdingWay1"
},
{
"from": "Skills.Ability.LitanyAgainstFear",
"to": "Skills.Perk.BinduStability"
},
{
"from": "Skills.Ability.LitanyAgainstFear",
"to": "Skills.Perk.MetabolizePoison"
},
{
"from": "Skills.Attribute.SelfControl3",
"to": "Skills.Perk.BinduStability"
},
{
"from": "Skills.Attribute.SelfControl4",
"to": "Skills.Perk.BinduStability"
},
{
"from": "Skills.Attribute.SelfControl4",
"to": "Skills.Perk.MetabolizePoison"
},
{
"from": "Skills.Attribute.SelfControl5",
"to": "Skills.Perk.MetabolizePoison"
},
{
"from": "Skills.Attribute.SelfControl3",
"to": "Skills.Perk.RegenCap"
},
{
"from": "Skills.Attribute.SelfControl4",
"to": "Skills.Perk.RegenCap"
},
{
"from": "Skills.Attribute.SelfControl2",
"to": "Skills.Attribute.SelfControl4"
},
{
"from": "Skills.Attribute.SelfControl2",
"to": "Skills.Attribute.SelfControl5"
},
{
"from": "Skills.Attribute.SelfControl1",
"to": "Skills.Perk.RegenCap"
},
{
"from": "Skills.Attribute.SelfControl1",
"to": "Skills.Attribute.SelfControl2"
}
]
}

View file

@ -0,0 +1,338 @@
{
"id": "mentat",
"name": "Mentat",
"nodes": [
{
"tag": "Skills.Perk.ShieldWeakpoint",
"id": "ShieldWeakpoint",
"name": "Shield Overcharge",
"kind": "Perk",
"row": 1,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeshieldovercharge_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-shieldweakpoint"
},
{
"tag": "Skills.Perk.ExploitWeakness",
"id": "ExploitWeakness",
"name": "Exploit Weakness",
"kind": "Perk",
"row": 2,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreespiceeffectexploitweakness_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-exploitweakness"
},
{
"tag": "Skills.Attribute.MentalCalculus5",
"id": "MentalCalculus5",
"name": "Rifle Damage",
"kind": "Attribute",
"row": 2,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagebonusscattergun_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-mentalcalculus5"
},
{
"tag": "Skills.Attribute.MentalCalculus3",
"id": "MentalCalculus3",
"name": "Tailoring",
"kind": "Attribute",
"row": 3,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributerepairefficiency_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-mentalcalculus3"
},
{
"tag": "Skills.Perk.HeadShots",
"id": "HeadShots",
"name": "Marksman",
"kind": "Perk",
"row": 3,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkmarksman_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-headshots"
},
{
"tag": "Skills.Attribute.MentalCalculus4",
"id": "MentalCalculus4",
"name": "Pistol Damage",
"kind": "Attribute",
"row": 3,
"col": 5,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagebonusgun_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-mentalcalculus4"
},
{
"tag": "Skills.Attribute.MentalCalculus1",
"id": "MentalCalculus1",
"name": "Garment Keeper",
"kind": "Attribute",
"row": 4,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributerepair_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-mentalcalculus1"
},
{
"tag": "Skills.Attribute.MentalCalculus2",
"id": "MentalCalculus2",
"name": "Ranged Damage",
"kind": "Attribute",
"row": 4,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamage_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-mentalcalculus2"
},
{
"tag": "Skills.Ability.TurretSeeker",
"id": "TurretSeeker",
"name": "The Sentinel",
"kind": "Ability",
"row": 5,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconabilityturretseeker_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-turretseeker"
},
{
"tag": "Skills.Ability.HunterSeeker",
"id": "HunterSeeker",
"name": "Hunter-Seeker",
"kind": "Ability",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_icongadgethunterseeker_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-hunterseeker"
},
{
"tag": "Skills.Perk.PoisonTooth",
"id": "PoisonTooth",
"name": "Poison Tooth",
"kind": "Perk",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreepoisontooth_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-poisontooth"
},
{
"tag": "Skills.Ability.StunDart",
"id": "StunDart",
"name": "Stunner",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilitystunnerdart_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-stundart"
},
{
"tag": "Skills.Attribute.Assassination2",
"id": "Assassination2",
"name": "Assassin&#39;s Shot",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamage_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-assassination2"
},
{
"tag": "Skills.Ability.PoisonMine",
"id": "PoisonMine",
"name": "Poison Mine",
"kind": "Ability",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconabilitypoisonmine_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-poisonmine"
},
{
"tag": "Skills.Attribute.Assassination1",
"id": "Assassination1",
"name": "Headshot Damage",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributeheadshotbonus_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-assassination1"
},
{
"tag": "Skills.Ability.PoisonCapsuleLauncher",
"id": "PoisonCapsuleLauncher",
"name": "Poison Capsule",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_icongadgetpoisoncapsulelauncher_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-poisoncapsulelauncher"
},
{
"tag": "Skills.Ability.PortableGenerator",
"id": "PortableGenerator",
"name": "Source of Power",
"kind": "Ability",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconabilityportablegenerator_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-portablegenerator"
},
{
"tag": "Skills.Ability.SuspensorMine_Reduction",
"id": "SuspensorMine_Reduction",
"name": "Anti-gravity Mine",
"kind": "Ability",
"row": 2,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_icongadgetreductionremotemine_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensormine_reduction"
},
{
"tag": "Skills.Perk.IronWill",
"id": "IronWill",
"name": "Iron Will",
"kind": "Perk",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeironwill_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-ironwill"
},
{
"tag": "Skills.Ability.SuspensorMine_Amplification",
"id": "SuspensorMine_Amplification",
"name": "Gravity Mine",
"kind": "Ability",
"row": 4,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_icongadgetamplificationremotemine_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensormine_amplification"
},
{
"tag": "Skills.Ability.SolidoDecoy",
"id": "SolidoDecoy",
"name": "Solido Decoy",
"kind": "Ability",
"row": 4,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilitysolidodecoy_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-solidodecoy"
},
{
"tag": "Skills.Ability.SuspensorWall",
"id": "SuspensorWall",
"name": "Shield Wall",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconabilitysuspensorwall_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorwall"
}
],
"edges": [
{
"from": "Skills.Perk.ExploitWeakness",
"to": "Skills.Perk.ShieldWeakpoint"
},
{
"from": "Skills.Attribute.MentalCalculus5",
"to": "Skills.Perk.ShieldWeakpoint"
},
{
"from": "Skills.Attribute.MentalCalculus3",
"to": "Skills.Perk.ExploitWeakness"
},
{
"from": "Skills.Perk.ExploitWeakness",
"to": "Skills.Perk.HeadShots"
},
{
"from": "Skills.Attribute.MentalCalculus5",
"to": "Skills.Perk.HeadShots"
},
{
"from": "Skills.Attribute.MentalCalculus4",
"to": "Skills.Attribute.MentalCalculus5"
},
{
"from": "Skills.Attribute.MentalCalculus1",
"to": "Skills.Attribute.MentalCalculus3"
},
{
"from": "Skills.Attribute.MentalCalculus1",
"to": "Skills.Perk.HeadShots"
},
{
"from": "Skills.Attribute.MentalCalculus2",
"to": "Skills.Perk.HeadShots"
},
{
"from": "Skills.Attribute.MentalCalculus2",
"to": "Skills.Attribute.MentalCalculus4"
},
{
"from": "Skills.Ability.TurretSeeker",
"to": "Skills.Attribute.MentalCalculus1"
},
{
"from": "Skills.Ability.TurretSeeker",
"to": "Skills.Attribute.MentalCalculus2"
},
{
"from": "Skills.Ability.HunterSeeker",
"to": "Skills.Perk.PoisonTooth"
},
{
"from": "Skills.Ability.HunterSeeker",
"to": "Skills.Ability.StunDart"
},
{
"from": "Skills.Attribute.Assassination2",
"to": "Skills.Perk.PoisonTooth"
},
{
"from": "Skills.Ability.PoisonMine",
"to": "Skills.Perk.PoisonTooth"
},
{
"from": "Skills.Ability.StunDart",
"to": "Skills.Attribute.Assassination2"
},
{
"from": "Skills.Ability.StunDart",
"to": "Skills.Attribute.Assassination1"
},
{
"from": "Skills.Ability.PoisonMine",
"to": "Skills.Attribute.Assassination2"
},
{
"from": "Skills.Attribute.Assassination1",
"to": "Skills.Attribute.Assassination2"
},
{
"from": "Skills.Ability.PoisonCapsuleLauncher",
"to": "Skills.Ability.PoisonMine"
},
{
"from": "Skills.Ability.PoisonCapsuleLauncher",
"to": "Skills.Attribute.Assassination1"
}
]
}

View file

@ -0,0 +1,268 @@
{
"id": "planetologist",
"name": "Planetologist",
"nodes": [
{
"tag": "Skills.Perk.BatteryExpert",
"id": "BatteryExpert",
"name": "Conservation of Energy",
"kind": "Perk",
"row": 1,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkbatteryexpert_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-batteryexpert"
},
{
"tag": "Skills.Attribute.Scientist5",
"id": "Scientist5",
"name": "Compaction",
"kind": "Attribute",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributespiceyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist5"
},
{
"tag": "Skills.Science.m_PowerMax",
"id": "m_PowerMax",
"name": "Overcharge",
"kind": "Science",
"row": 2,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-science-m_powermax"
},
{
"tag": "Skills.Attribute.Scientist4",
"id": "Scientist4",
"name": "Deep Analysis",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist4"
},
{
"tag": "Skills.Attribute.Scientist2",
"id": "Scientist2",
"name": "Dew Gathering",
"kind": "Attribute",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributewatheryield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist2"
},
{
"tag": "Skills.Attribute.Scientist3",
"id": "Scientist3",
"name": "Rerouting",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillpowerefficiency_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist3"
},
{
"tag": "Skills.Attribute.Scientist1",
"id": "Scientist1",
"name": "Cutteray Mining",
"kind": "Attribute",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist1"
},
{
"tag": "Skills.Attribute.Explorer5",
"id": "Explorer5",
"name": "Spice Surveyor",
"kind": "Attribute",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeattributespice_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-explorer5"
},
{
"tag": "Skills.Attribute.Explorer3",
"id": "Explorer3",
"name": "Scanner Mastery",
"kind": "Attribute",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributescanningbonus_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-explorer3"
},
{
"tag": "Skills.Attribute.Explorer4",
"id": "Explorer4",
"name": "Stillsuit Seals",
"kind": "Attribute",
"row": 2,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillhydration_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-explorer4"
},
{
"tag": "Skills.Attribute.Explorer1",
"id": "Explorer1",
"name": "Cartographer",
"kind": "Attribute",
"row": 4,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeskillobservation_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-explorer1"
},
{
"tag": "Skills.Attribute.Explorer2",
"id": "Explorer2",
"name": "Mountaineer",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillclimber_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-explorer2"
},
{
"tag": "Skills.Ability.SuspensorPad",
"id": "SuspensorPad",
"name": "Suspensor Pad",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconabilitysuspensorpad_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorpad"
},
{
"tag": "Skills.Spice.VehicleHeat",
"id": "VehicleHeat",
"name": "Heat Management",
"kind": "Spice",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeheatmanagement_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-vehicleheat"
},
{
"tag": "Skills.Attribute.Driver5",
"id": "Driver5",
"name": "Fuel Efficient Pilot",
"kind": "Attribute",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributevehicle_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver5"
},
{
"tag": "Skills.Attribute.Driver6",
"id": "Driver6",
"name": "Sandcrawler Yield",
"kind": "Attribute",
"row": 2,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributespiceyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver6"
},
{
"tag": "Skills.Attribute.Driver4",
"id": "Driver4",
"name": "Vehicle Scanning",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributescanningbonus_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver4"
},
{
"tag": "Skills.Attribute.Driver2",
"id": "Driver2",
"name": "Fuel Efficient Driver",
"kind": "Attribute",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributevehicle_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver2"
},
{
"tag": "Skills.Attribute.Driver3",
"id": "Driver3",
"name": "Vehicle Mining",
"kind": "Attribute",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver3"
},
{
"tag": "Skills.Attribute.Driver1",
"id": "Driver1",
"name": "Vehicle Repair",
"kind": "Attribute",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributerepairefficiency_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-driver1"
}
],
"edges": [
{
"from": "Skills.Attribute.Scientist5",
"to": "Skills.Perk.BatteryExpert"
},
{
"from": "Skills.Perk.BatteryExpert",
"to": "Skills.Science.m_PowerMax"
},
{
"from": "Skills.Attribute.Scientist4",
"to": "Skills.Attribute.Scientist5"
},
{
"from": "Skills.Attribute.Scientist2",
"to": "Skills.Attribute.Scientist5"
},
{
"from": "Skills.Attribute.Scientist4",
"to": "Skills.Science.m_PowerMax"
},
{
"from": "Skills.Attribute.Scientist3",
"to": "Skills.Science.m_PowerMax"
},
{
"from": "Skills.Attribute.Scientist2",
"to": "Skills.Attribute.Scientist4"
},
{
"from": "Skills.Attribute.Scientist3",
"to": "Skills.Attribute.Scientist4"
},
{
"from": "Skills.Attribute.Scientist1",
"to": "Skills.Attribute.Scientist2"
},
{
"from": "Skills.Attribute.Scientist1",
"to": "Skills.Attribute.Scientist3"
}
]
}

View file

@ -0,0 +1,338 @@
{
"id": "swordmaster",
"name": "Swordmaster",
"nodes": [
{
"tag": "Skills.Spice.ParryBoost",
"id": "ParryBoost",
"name": "Precise Parry",
"kind": "Spice",
"row": 1,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreepreciseparry_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-parryboost"
},
{
"tag": "Skills.Ability.Whirlwind",
"id": "Whirlwind",
"name": "Eye of the Storm",
"kind": "Ability",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconabilitywhirlwind_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-whirlwind"
},
{
"tag": "Skills.Ability.RiposteBreak",
"id": "RiposteBreak",
"name": "Foil",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilitybreakingreposte_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-ripostebreak"
},
{
"tag": "Skills.Attribute.Blade2",
"id": "Blade2",
"name": "Long Blade Damage",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillbrawler_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-blade2"
},
{
"tag": "Skills.Perk.MeleeChain",
"id": "MeleeChain",
"name": "Dance of Blades",
"kind": "Perk",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkbladechaining_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-meleechain"
},
{
"tag": "Skills.Ability.RiposteInjure",
"id": "RiposteInjure",
"name": "Retaliate",
"kind": "Ability",
"row": 4,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilityinjuringreposte_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-riposteinjure"
},
{
"tag": "Skills.Attribute.Blade1",
"id": "Blade1",
"name": "Blade Damage",
"kind": "Attribute",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillbrawler_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-blade1"
},
{
"tag": "Skills.Perk.ThriveOnDanger",
"id": "ThriveOnDanger",
"name": "Thrive on Danger",
"kind": "Perk",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreethriveondanger_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-thriveondanger"
},
{
"tag": "Skills.Attribute.Resolve2",
"id": "Resolve2",
"name": "Solid Stance",
"kind": "Attribute",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributepoisedefense_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-resolve2"
},
{
"tag": "Skills.Attribute.UnstoppableAttacks",
"id": "UnstoppableAttacks",
"name": "Confidence",
"kind": "Attribute",
"row": 2,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagemitigation_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-unstoppableattacks"
},
{
"tag": "Skills.Attribute.Resolve1",
"id": "Resolve1",
"name": "Bleed Tolerance",
"kind": "Attribute",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillmaxhealth_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-resolve1"
},
{
"tag": "Skills.Perk.ToughLunge",
"id": "ToughLunge",
"name": "Reckless Lunge",
"kind": "Perk",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreetoughlunge_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-toughlunge"
},
{
"tag": "Skills.Ability.DeflectionSlow",
"id": "DeflectionSlow",
"name": "Deflection",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconabilitydeflection_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-deflectionslow"
},
{
"tag": "Skills.Spice.ShadowStrike",
"id": "ShadowStrike",
"name": "Prescient Strike",
"kind": "Spice",
"row": 1,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconskilltreeprescientstrike_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-shadowstrike"
},
{
"tag": "Skills.Attribute.Aggression3",
"id": "Aggression3",
"name": "General Conditioning",
"kind": "Attribute",
"row": 2,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributestamina_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-aggression3"
},
{
"tag": "Skills.Attribute.Aggression4",
"id": "Aggression4",
"name": "Desert Conditioning",
"kind": "Attribute",
"row": 2,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributewatherdefense_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-aggression4"
},
{
"tag": "Skills.Ability.CripplingStrike",
"id": "CripplingStrike",
"name": "Crippling Strike",
"kind": "Ability",
"row": 3,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconabilitycripplingstrike_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-cripplingstrike"
},
{
"tag": "Skills.Perk.SprintStamina",
"id": "SprintStamina",
"name": "Disciplined Breathing",
"kind": "Perk",
"row": 3,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkrunner_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-sprintstamina"
},
{
"tag": "Skills.Ability.BattleCry",
"id": "BattleCry",
"name": "Inspiration",
"kind": "Ability",
"row": 3,
"col": 5,
"maxPoints": 3,
"icon": "t_ui_iconabilitybattlecry_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-battlecry"
},
{
"tag": "Skills.Attribute.Aggression1",
"id": "Aggression1",
"name": "Field Medicine",
"kind": "Attribute",
"row": 4,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillhealingmultiplier_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-aggression1"
},
{
"tag": "Skills.Attribute.Aggression2",
"id": "Aggression2",
"name": "Optimized Hydration",
"kind": "Attribute",
"row": 4,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributewatherbonus_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-aggression2"
},
{
"tag": "Skills.Ability.KneeCharge",
"id": "KneeCharge",
"name": "Knee Charge",
"kind": "Ability",
"row": 5,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconabilitykneecharge_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-kneecharge"
}
],
"edges": [
{
"from": "Skills.Ability.Whirlwind",
"to": "Skills.Spice.ParryBoost"
},
{
"from": "Skills.Ability.RiposteBreak",
"to": "Skills.Spice.ParryBoost"
},
{
"from": "Skills.Ability.Whirlwind",
"to": "Skills.Attribute.Blade2"
},
{
"from": "Skills.Ability.Whirlwind",
"to": "Skills.Perk.MeleeChain"
},
{
"from": "Skills.Ability.RiposteBreak",
"to": "Skills.Attribute.Blade2"
},
{
"from": "Skills.Ability.RiposteBreak",
"to": "Skills.Ability.RiposteInjure"
},
{
"from": "Skills.Attribute.Blade2",
"to": "Skills.Perk.MeleeChain"
},
{
"from": "Skills.Ability.RiposteInjure",
"to": "Skills.Attribute.Blade2"
},
{
"from": "Skills.Attribute.Blade1",
"to": "Skills.Perk.MeleeChain"
},
{
"from": "Skills.Ability.RiposteInjure",
"to": "Skills.Attribute.Blade1"
},
{
"from": "Skills.Attribute.Aggression3",
"to": "Skills.Spice.ShadowStrike"
},
{
"from": "Skills.Attribute.Aggression4",
"to": "Skills.Spice.ShadowStrike"
},
{
"from": "Skills.Ability.CripplingStrike",
"to": "Skills.Attribute.Aggression3"
},
{
"from": "Skills.Attribute.Aggression3",
"to": "Skills.Perk.SprintStamina"
},
{
"from": "Skills.Attribute.Aggression4",
"to": "Skills.Perk.SprintStamina"
},
{
"from": "Skills.Ability.BattleCry",
"to": "Skills.Attribute.Aggression4"
},
{
"from": "Skills.Ability.CripplingStrike",
"to": "Skills.Attribute.Aggression1"
},
{
"from": "Skills.Attribute.Aggression1",
"to": "Skills.Perk.SprintStamina"
},
{
"from": "Skills.Attribute.Aggression2",
"to": "Skills.Perk.SprintStamina"
},
{
"from": "Skills.Ability.BattleCry",
"to": "Skills.Attribute.Aggression2"
},
{
"from": "Skills.Ability.KneeCharge",
"to": "Skills.Attribute.Aggression1"
},
{
"from": "Skills.Ability.KneeCharge",
"to": "Skills.Attribute.Aggression2"
}
]
}

View file

@ -0,0 +1,338 @@
{
"id": "trooper",
"name": "Trooper",
"nodes": [
{
"tag": "Skills.Ability.EnergyCapsule",
"id": "EnergyCapsule",
"name": "Energy Capsule",
"kind": "Ability",
"row": 1,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilityenergycapsule_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-energycapsule"
},
{
"tag": "Skills.Attribute.Weaponry5",
"id": "Weaponry5",
"name": "Heavy Weapon Damage",
"kind": "Attribute",
"row": 2,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagebonusheavyweapon_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry5"
},
{
"tag": "Skills.Attribute.Weaponry6",
"id": "Weaponry6",
"name": "Gunsmith",
"kind": "Attribute",
"row": 2,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributerepairefficiency_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry6"
},
{
"tag": "Skills.Perk.HeavyWeaponNaib",
"id": "HeavyWeaponNaib",
"name": "Heavy Weapon Agility",
"kind": "Perk",
"row": 3,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkheavyweaponnaib_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-heavyweaponnaib"
},
{
"tag": "Skills.Attribute.Weaponry3",
"id": "Weaponry3",
"name": "Scattergun Damage",
"kind": "Attribute",
"row": 3,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagebonusrifle_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry3"
},
{
"tag": "Skills.Attribute.Weaponry4",
"id": "Weaponry4",
"name": "Field Maintenance",
"kind": "Attribute",
"row": 3,
"col": 5,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributerepair_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry4"
},
{
"tag": "Skills.Attribute.Weaponry2",
"id": "Weaponry2",
"name": "Disruptor Damage",
"kind": "Attribute",
"row": 4,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamagebonussmg_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry2"
},
{
"tag": "Skills.Perk.BodyShots",
"id": "BodyShots",
"name": "Center of Mass",
"kind": "Perk",
"row": 4,
"col": 4,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkcentralaim_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-bodyshots"
},
{
"tag": "Skills.Attribute.Weaponry1",
"id": "Weaponry1",
"name": "Ranged Damage",
"kind": "Attribute",
"row": 5,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamage_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry1"
},
{
"tag": "Skills.Ability.SuspensorBlast",
"id": "SuspensorBlast",
"name": "Suspensor Blast",
"kind": "Ability",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconabilitysuspensorblast_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorblast"
},
{
"tag": "Skills.Perk.DeathFromAbove",
"id": "DeathFromAbove",
"name": "Death from Above",
"kind": "Perk",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeperkdeathfromabove_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-deathfromabove"
},
{
"tag": "Skills.Ability.CollapseGrenade",
"id": "CollapseGrenade",
"name": "Collapse Grenade",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_iconabilitycollapsegrenade_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-collapsegrenade"
},
{
"tag": "Skills.Attribute.SuspensorTech1",
"id": "SuspensorTech1",
"name": "Suspensor Efficiency",
"kind": "Attribute",
"row": 3,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_iconskilltreeskillpowerefficiency_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-suspensortech1"
},
{
"tag": "Skills.Perk.SuspensorDash",
"id": "SuspensorDash",
"name": "Suspensor Dash",
"kind": "Perk",
"row": 4,
"col": 1,
"maxPoints": 1,
"icon": "t_ui_iconskilltreesuspensordash_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-suspensordash"
},
{
"tag": "Skills.Ability.SuspensorGrenade_Amplification",
"id": "SuspensorGrenade_Amplification",
"name": "Gravity Field",
"kind": "Ability",
"row": 4,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_icongadgetamplificationgrenade_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorgrenade_amplification"
},
{
"tag": "Skills.Ability.SuspensorGrenade_Reduction",
"id": "SuspensorGrenade_Reduction",
"name": "Anti-gravity Field",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_icongadgetreductionsuspensorgrenade_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorgrenade_reduction"
},
{
"tag": "Skills.Spice.GadgetReload",
"id": "GadgetReload",
"name": "Reflexive Reload",
"kind": "Spice",
"row": 1,
"col": 2,
"maxPoints": 1,
"icon": "t_ui_iconskilltreereflexivereload_d.webp",
"url": "https://dune.gaming.tools/skills/skills-spice-gadgetreload"
},
{
"tag": "Skills.Ability.AssaultSeeker",
"id": "AssaultSeeker",
"name": "Assault Seeker",
"kind": "Ability",
"row": 2,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_iconabilityassaultseeker_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-assaultseeker"
},
{
"tag": "Skills.Ability.MagneticAttractor",
"id": "MagneticAttractor",
"name": "Attractor Field",
"kind": "Ability",
"row": 2,
"col": 3,
"maxPoints": 1,
"icon": "t_ui_icongadgetshigmultitoolmagneticattractor_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-magneticattractor"
},
{
"tag": "Skills.Ability.FragGrenade",
"id": "FragGrenade",
"name": "Explosive Grenade",
"kind": "Ability",
"row": 4,
"col": 1,
"maxPoints": 3,
"icon": "t_ui_icongadgetfraggrenades_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-fraggrenade"
},
{
"tag": "Skills.Perk.TrooperCooldowns",
"id": "TrooperCooldowns",
"name": "Battle Hardened",
"kind": "Perk",
"row": 4,
"col": 3,
"maxPoints": 3,
"icon": "t_ui_iconskilltreetroopercooldown_d.webp",
"url": "https://dune.gaming.tools/skills/skills-perk-troopercooldowns"
},
{
"tag": "Skills.Ability.CablePull",
"id": "CablePull",
"name": "Shigawire Claw",
"kind": "Ability",
"row": 5,
"col": 2,
"maxPoints": 3,
"icon": "t_ui_icongadgetshigmultitoolsardaukarpull_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-cablepull"
}
],
"edges": [
{
"from": "Skills.Ability.EnergyCapsule",
"to": "Skills.Attribute.Weaponry5"
},
{
"from": "Skills.Ability.EnergyCapsule",
"to": "Skills.Attribute.Weaponry6"
},
{
"from": "Skills.Attribute.Weaponry5",
"to": "Skills.Perk.HeavyWeaponNaib"
},
{
"from": "Skills.Attribute.Weaponry3",
"to": "Skills.Attribute.Weaponry5"
},
{
"from": "Skills.Attribute.Weaponry3",
"to": "Skills.Attribute.Weaponry6"
},
{
"from": "Skills.Attribute.Weaponry4",
"to": "Skills.Attribute.Weaponry6"
},
{
"from": "Skills.Attribute.Weaponry2",
"to": "Skills.Perk.HeavyWeaponNaib"
},
{
"from": "Skills.Attribute.Weaponry2",
"to": "Skills.Attribute.Weaponry3"
},
{
"from": "Skills.Attribute.Weaponry3",
"to": "Skills.Perk.BodyShots"
},
{
"from": "Skills.Attribute.Weaponry4",
"to": "Skills.Perk.BodyShots"
},
{
"from": "Skills.Attribute.Weaponry1",
"to": "Skills.Attribute.Weaponry2"
},
{
"from": "Skills.Attribute.Weaponry1",
"to": "Skills.Perk.BodyShots"
},
{
"from": "Skills.Ability.SuspensorBlast",
"to": "Skills.Perk.DeathFromAbove"
},
{
"from": "Skills.Ability.CollapseGrenade",
"to": "Skills.Ability.SuspensorBlast"
},
{
"from": "Skills.Attribute.SuspensorTech1",
"to": "Skills.Perk.DeathFromAbove"
},
{
"from": "Skills.Perk.DeathFromAbove",
"to": "Skills.Perk.SuspensorDash"
},
{
"from": "Skills.Ability.CollapseGrenade",
"to": "Skills.Attribute.SuspensorTech1"
},
{
"from": "Skills.Ability.CollapseGrenade",
"to": "Skills.Ability.SuspensorGrenade_Amplification"
},
{
"from": "Skills.Attribute.SuspensorTech1",
"to": "Skills.Perk.SuspensorDash"
},
{
"from": "Skills.Ability.SuspensorGrenade_Amplification",
"to": "Skills.Attribute.SuspensorTech1"
},
{
"from": "Skills.Ability.SuspensorGrenade_Reduction",
"to": "Skills.Perk.SuspensorDash"
},
{
"from": "Skills.Ability.SuspensorGrenade_Amplification",
"to": "Skills.Ability.SuspensorGrenade_Reduction"
}
]
}

View file

@ -0,0 +1,610 @@
{
"header": [
"Level",
"Required XP",
"Cumulative",
"Rewards"
],
"rows": [
{
"level": 1,
"xpRequired": 100,
"totalXp": 100,
"intelPoints": 23
},
{
"level": 2,
"xpRequired": 105,
"totalXp": 205,
"intelPoints": 25
},
{
"level": 3,
"xpRequired": 110,
"totalXp": 315,
"intelPoints": 0
},
{
"level": 4,
"xpRequired": 116,
"totalXp": 431,
"intelPoints": 21
},
{
"level": 5,
"xpRequired": 122,
"totalXp": 553,
"intelPoints": 0
},
{
"level": 6,
"xpRequired": 128,
"totalXp": 681,
"intelPoints": 215
},
{
"level": 7,
"xpRequired": 135,
"totalXp": 816,
"intelPoints": 0
},
{
"level": 8,
"xpRequired": 142,
"totalXp": 958,
"intelPoints": 23
},
{
"level": 9,
"xpRequired": 149,
"totalXp": 1107,
"intelPoints": 0
},
{
"level": 10,
"xpRequired": 157,
"totalXp": 1264,
"intelPoints": 0
},
{
"level": 11,
"xpRequired": 164,
"totalXp": 1428,
"intelPoints": 51
},
{
"level": 12,
"xpRequired": 171,
"totalXp": 1599,
"intelPoints": 0
},
{
"level": 13,
"xpRequired": 178,
"totalXp": 1777,
"intelPoints": 0
},
{
"level": 14,
"xpRequired": 186,
"totalXp": 1963,
"intelPoints": 51
},
{
"level": 15,
"xpRequired": 194,
"totalXp": 2157,
"intelPoints": 0
},
{
"level": 16,
"xpRequired": 202,
"totalXp": 2359,
"intelPoints": 520
},
{
"level": 17,
"xpRequired": 211,
"totalXp": 2570,
"intelPoints": 0
},
{
"level": 18,
"xpRequired": 220,
"totalXp": 2790,
"intelPoints": 53
},
{
"level": 19,
"xpRequired": 229,
"totalXp": 3019,
"intelPoints": 0
},
{
"level": 20,
"xpRequired": 239,
"totalXp": 3258,
"intelPoints": 0
},
{
"level": 21,
"xpRequired": 246,
"totalXp": 3504,
"intelPoints": 51
},
{
"level": 22,
"xpRequired": 253,
"totalXp": 3757,
"intelPoints": 0
},
{
"level": 23,
"xpRequired": 260,
"totalXp": 4017,
"intelPoints": 0
},
{
"level": 24,
"xpRequired": 268,
"totalXp": 4285,
"intelPoints": 101
},
{
"level": 25,
"xpRequired": 276,
"totalXp": 4561,
"intelPoints": 0
},
{
"level": 26,
"xpRequired": 284,
"totalXp": 4845,
"intelPoints": 105
},
{
"level": 27,
"xpRequired": 292,
"totalXp": 5137,
"intelPoints": 0
},
{
"level": 28,
"xpRequired": 301,
"totalXp": 5438,
"intelPoints": 103
},
{
"level": 29,
"xpRequired": 310,
"totalXp": 5748,
"intelPoints": 0
},
{
"level": 30,
"xpRequired": 319,
"totalXp": 6067,
"intelPoints": 0
},
{
"level": 31,
"xpRequired": 326,
"totalXp": 6393,
"intelPoints": 101
},
{
"level": 32,
"xpRequired": 334,
"totalXp": 6727,
"intelPoints": 0
},
{
"level": 33,
"xpRequired": 342,
"totalXp": 7069,
"intelPoints": 0
},
{
"level": 34,
"xpRequired": 350,
"totalXp": 7419,
"intelPoints": 101
},
{
"level": 35,
"xpRequired": 358,
"totalXp": 7777,
"intelPoints": 0
},
{
"level": 36,
"xpRequired": 366,
"totalXp": 8143,
"intelPoints": 155
},
{
"level": 37,
"xpRequired": 375,
"totalXp": 8518,
"intelPoints": 0
},
{
"level": 38,
"xpRequired": 384,
"totalXp": 8902,
"intelPoints": 153
},
{
"level": 39,
"xpRequired": 393,
"totalXp": 9295,
"intelPoints": 0
},
{
"level": 40,
"xpRequired": 402,
"totalXp": 9697,
"intelPoints": 0
},
{
"level": 41,
"xpRequired": 410,
"totalXp": 10107,
"intelPoints": 151
},
{
"level": 42,
"xpRequired": 418,
"totalXp": 10525,
"intelPoints": 0
},
{
"level": 43,
"xpRequired": 426,
"totalXp": 10951,
"intelPoints": 0
},
{
"level": 44,
"xpRequired": 434,
"totalXp": 11385,
"intelPoints": 151
},
{
"level": 45,
"xpRequired": 442,
"totalXp": 11827,
"intelPoints": 0
},
{
"level": 46,
"xpRequired": 450,
"totalXp": 12277,
"intelPoints": 0
},
{
"level": 47,
"xpRequired": 459,
"totalXp": 12736,
"intelPoints": 0
},
{
"level": 48,
"xpRequired": 468,
"totalXp": 13204,
"intelPoints": 153
},
{
"level": 49,
"xpRequired": 477,
"totalXp": 13681,
"intelPoints": 0
},
{
"level": 50,
"xpRequired": 486,
"totalXp": 14167,
"intelPoints": 0
},
{
"level": 51,
"xpRequired": 494,
"totalXp": 14661,
"intelPoints": 201
},
{
"level": 52,
"xpRequired": 502,
"totalXp": 15163,
"intelPoints": 0
},
{
"level": 53,
"xpRequired": 510,
"totalXp": 15673,
"intelPoints": 0
},
{
"level": 54,
"xpRequired": 518,
"totalXp": 16191,
"intelPoints": 201
},
{
"level": 55,
"xpRequired": 526,
"totalXp": 16717,
"intelPoints": 0
},
{
"level": 56,
"xpRequired": 534,
"totalXp": 17251,
"intelPoints": 205
},
{
"level": 57,
"xpRequired": 543,
"totalXp": 17794,
"intelPoints": 0
},
{
"level": 58,
"xpRequired": 552,
"totalXp": 18346,
"intelPoints": 203
},
{
"level": 59,
"xpRequired": 561,
"totalXp": 18907,
"intelPoints": 0
},
{
"level": 60,
"xpRequired": 570,
"totalXp": 19477,
"intelPoints": 0
},
{
"level": 61,
"xpRequired": 575,
"totalXp": 20052,
"intelPoints": 201
},
{
"level": 62,
"xpRequired": 580,
"totalXp": 20632,
"intelPoints": 0
},
{
"level": 63,
"xpRequired": 585,
"totalXp": 21217,
"intelPoints": 0
},
{
"level": 64,
"xpRequired": 590,
"totalXp": 21807,
"intelPoints": 251
},
{
"level": 65,
"xpRequired": 595,
"totalXp": 22402,
"intelPoints": 0
},
{
"level": 66,
"xpRequired": 600,
"totalXp": 23002,
"intelPoints": 255
},
{
"level": 67,
"xpRequired": 606,
"totalXp": 23608,
"intelPoints": 0
},
{
"level": 68,
"xpRequired": 612,
"totalXp": 24220,
"intelPoints": 253
},
{
"level": 69,
"xpRequired": 618,
"totalXp": 24838,
"intelPoints": 0
},
{
"level": 70,
"xpRequired": 624,
"totalXp": 25462,
"intelPoints": 0
},
{
"level": 71,
"xpRequired": 624,
"totalXp": 26086,
"intelPoints": 251
},
{
"level": 72,
"xpRequired": 624,
"totalXp": 26710,
"intelPoints": 0
},
{
"level": 73,
"xpRequired": 624,
"totalXp": 27334,
"intelPoints": 0
},
{
"level": 74,
"xpRequired": 624,
"totalXp": 27958,
"intelPoints": 251
},
{
"level": 75,
"xpRequired": 624,
"totalXp": 28582,
"intelPoints": 30
},
{
"level": 76,
"xpRequired": 624,
"totalXp": 29206,
"intelPoints": 0
},
{
"level": 77,
"xpRequired": 624,
"totalXp": 29830,
"intelPoints": 3025
},
{
"level": 78,
"xpRequired": 624,
"totalXp": 30454,
"intelPoints": 0
},
{
"level": 79,
"xpRequired": 624,
"totalXp": 31078,
"intelPoints": 303
},
{
"level": 80,
"xpRequired": 624,
"totalXp": 31702,
"intelPoints": 0
},
{
"level": 81,
"xpRequired": 624,
"totalXp": 32326,
"intelPoints": 0
},
{
"level": 82,
"xpRequired": 624,
"totalXp": 32950,
"intelPoints": 301
},
{
"level": 83,
"xpRequired": 624,
"totalXp": 33574,
"intelPoints": 0
},
{
"level": 84,
"xpRequired": 624,
"totalXp": 34198,
"intelPoints": 0
},
{
"level": 85,
"xpRequired": 624,
"totalXp": 34822,
"intelPoints": 301
},
{
"level": 86,
"xpRequired": 624,
"totalXp": 35446,
"intelPoints": 0
},
{
"level": 87,
"xpRequired": 624,
"totalXp": 36070,
"intelPoints": 0
},
{
"level": 88,
"xpRequired": 624,
"totalXp": 36694,
"intelPoints": 0
},
{
"level": 89,
"xpRequired": 624,
"totalXp": 37318,
"intelPoints": 353
},
{
"level": 90,
"xpRequired": 624,
"totalXp": 37942,
"intelPoints": 0
},
{
"level": 91,
"xpRequired": 624,
"totalXp": 38566,
"intelPoints": 355
},
{
"level": 92,
"xpRequired": 624,
"totalXp": 39190,
"intelPoints": 0
},
{
"level": 93,
"xpRequired": 624,
"totalXp": 39814,
"intelPoints": 351
},
{
"level": 94,
"xpRequired": 624,
"totalXp": 40438,
"intelPoints": 0
},
{
"level": 95,
"xpRequired": 624,
"totalXp": 41062,
"intelPoints": 3520
},
{
"level": 96,
"xpRequired": 624,
"totalXp": 41686,
"intelPoints": 0
},
{
"level": 97,
"xpRequired": 624,
"totalXp": 42310,
"intelPoints": 351
},
{
"level": 98,
"xpRequired": 624,
"totalXp": 42934,
"intelPoints": 0
},
{
"level": 99,
"xpRequired": 624,
"totalXp": 43558,
"intelPoints": 0
},
{
"level": 100,
"xpRequired": 624,
"totalXp": 44182,
"intelPoints": 405
}
]
}

View file

@ -0,0 +1,610 @@
{
"header": [
"Level",
"Required XP",
"Cumulative",
"Rewards"
],
"rows": [
{
"level": 1,
"xpRequired": 100,
"totalXp": 100,
"intelPoints": 1
},
{
"level": 2,
"xpRequired": 105,
"totalXp": 205,
"intelPoints": 0
},
{
"level": 3,
"xpRequired": 110,
"totalXp": 315,
"intelPoints": 21
},
{
"level": 4,
"xpRequired": 116,
"totalXp": 431,
"intelPoints": 0
},
{
"level": 5,
"xpRequired": 122,
"totalXp": 553,
"intelPoints": 0
},
{
"level": 6,
"xpRequired": 128,
"totalXp": 681,
"intelPoints": 0
},
{
"level": 7,
"xpRequired": 135,
"totalXp": 816,
"intelPoints": 0
},
{
"level": 8,
"xpRequired": 142,
"totalXp": 958,
"intelPoints": 2250
},
{
"level": 9,
"xpRequired": 149,
"totalXp": 1107,
"intelPoints": 0
},
{
"level": 10,
"xpRequired": 157,
"totalXp": 1264,
"intelPoints": 21
},
{
"level": 11,
"xpRequired": 164,
"totalXp": 1428,
"intelPoints": 0
},
{
"level": 12,
"xpRequired": 171,
"totalXp": 1599,
"intelPoints": 5100
},
{
"level": 13,
"xpRequired": 178,
"totalXp": 1777,
"intelPoints": 0
},
{
"level": 14,
"xpRequired": 186,
"totalXp": 1963,
"intelPoints": 5333
},
{
"level": 15,
"xpRequired": 194,
"totalXp": 2157,
"intelPoints": 0
},
{
"level": 16,
"xpRequired": 202,
"totalXp": 2359,
"intelPoints": 550
},
{
"level": 17,
"xpRequired": 211,
"totalXp": 2570,
"intelPoints": 0
},
{
"level": 18,
"xpRequired": 220,
"totalXp": 2790,
"intelPoints": 5
},
{
"level": 19,
"xpRequired": 229,
"totalXp": 3019,
"intelPoints": 0
},
{
"level": 20,
"xpRequired": 239,
"totalXp": 3258,
"intelPoints": 550
},
{
"level": 21,
"xpRequired": 246,
"totalXp": 3504,
"intelPoints": 0
},
{
"level": 22,
"xpRequired": 253,
"totalXp": 3757,
"intelPoints": 10300
},
{
"level": 23,
"xpRequired": 260,
"totalXp": 4017,
"intelPoints": 0
},
{
"level": 24,
"xpRequired": 268,
"totalXp": 4285,
"intelPoints": 0
},
{
"level": 25,
"xpRequired": 276,
"totalXp": 4561,
"intelPoints": 0
},
{
"level": 26,
"xpRequired": 284,
"totalXp": 4845,
"intelPoints": 0
},
{
"level": 27,
"xpRequired": 292,
"totalXp": 5137,
"intelPoints": 0
},
{
"level": 28,
"xpRequired": 301,
"totalXp": 5438,
"intelPoints": 0
},
{
"level": 29,
"xpRequired": 310,
"totalXp": 5748,
"intelPoints": 0
},
{
"level": 30,
"xpRequired": 319,
"totalXp": 6067,
"intelPoints": 101
},
{
"level": 31,
"xpRequired": 326,
"totalXp": 6393,
"intelPoints": 101
},
{
"level": 32,
"xpRequired": 334,
"totalXp": 6727,
"intelPoints": 0
},
{
"level": 33,
"xpRequired": 342,
"totalXp": 7069,
"intelPoints": 1525
},
{
"level": 34,
"xpRequired": 350,
"totalXp": 7419,
"intelPoints": 0
},
{
"level": 35,
"xpRequired": 358,
"totalXp": 7777,
"intelPoints": 1550
},
{
"level": 36,
"xpRequired": 366,
"totalXp": 8143,
"intelPoints": 0
},
{
"level": 37,
"xpRequired": 375,
"totalXp": 8518,
"intelPoints": 0
},
{
"level": 38,
"xpRequired": 384,
"totalXp": 8902,
"intelPoints": 15
},
{
"level": 39,
"xpRequired": 393,
"totalXp": 9295,
"intelPoints": 0
},
{
"level": 40,
"xpRequired": 402,
"totalXp": 9697,
"intelPoints": 15333
},
{
"level": 41,
"xpRequired": 410,
"totalXp": 10107,
"intelPoints": 0
},
{
"level": 42,
"xpRequired": 418,
"totalXp": 10525,
"intelPoints": 151
},
{
"level": 43,
"xpRequired": 426,
"totalXp": 10951,
"intelPoints": 0
},
{
"level": 44,
"xpRequired": 434,
"totalXp": 11385,
"intelPoints": 0
},
{
"level": 45,
"xpRequired": 442,
"totalXp": 11827,
"intelPoints": 20100
},
{
"level": 46,
"xpRequired": 450,
"totalXp": 12277,
"intelPoints": 0
},
{
"level": 47,
"xpRequired": 459,
"totalXp": 12736,
"intelPoints": 0
},
{
"level": 48,
"xpRequired": 468,
"totalXp": 13204,
"intelPoints": 0
},
{
"level": 49,
"xpRequired": 477,
"totalXp": 13681,
"intelPoints": 0
},
{
"level": 50,
"xpRequired": 486,
"totalXp": 14167,
"intelPoints": 0
},
{
"level": 51,
"xpRequired": 494,
"totalXp": 14661,
"intelPoints": 0
},
{
"level": 52,
"xpRequired": 502,
"totalXp": 15163,
"intelPoints": 20
},
{
"level": 53,
"xpRequired": 510,
"totalXp": 15673,
"intelPoints": 0
},
{
"level": 54,
"xpRequired": 518,
"totalXp": 16191,
"intelPoints": 0
},
{
"level": 55,
"xpRequired": 526,
"totalXp": 16717,
"intelPoints": 2025
},
{
"level": 56,
"xpRequired": 534,
"totalXp": 17251,
"intelPoints": 0
},
{
"level": 57,
"xpRequired": 543,
"totalXp": 17794,
"intelPoints": 0
},
{
"level": 58,
"xpRequired": 552,
"totalXp": 18346,
"intelPoints": 0
},
{
"level": 59,
"xpRequired": 561,
"totalXp": 18907,
"intelPoints": 0
},
{
"level": 60,
"xpRequired": 570,
"totalXp": 19477,
"intelPoints": 0
},
{
"level": 61,
"xpRequired": 575,
"totalXp": 20052,
"intelPoints": 25150
},
{
"level": 62,
"xpRequired": 580,
"totalXp": 20632,
"intelPoints": 0
},
{
"level": 63,
"xpRequired": 585,
"totalXp": 21217,
"intelPoints": 25300
},
{
"level": 64,
"xpRequired": 590,
"totalXp": 21807,
"intelPoints": 0
},
{
"level": 65,
"xpRequired": 595,
"totalXp": 22402,
"intelPoints": 0
},
{
"level": 66,
"xpRequired": 600,
"totalXp": 23002,
"intelPoints": 0
},
{
"level": 67,
"xpRequired": 606,
"totalXp": 23608,
"intelPoints": 0
},
{
"level": 68,
"xpRequired": 612,
"totalXp": 24220,
"intelPoints": 0
},
{
"level": 69,
"xpRequired": 618,
"totalXp": 24838,
"intelPoints": 2550
},
{
"level": 70,
"xpRequired": 624,
"totalXp": 25462,
"intelPoints": 0
},
{
"level": 71,
"xpRequired": 624,
"totalXp": 26086,
"intelPoints": 0
},
{
"level": 72,
"xpRequired": 624,
"totalXp": 26710,
"intelPoints": 25
},
{
"level": 73,
"xpRequired": 624,
"totalXp": 27334,
"intelPoints": 0
},
{
"level": 74,
"xpRequired": 624,
"totalXp": 27958,
"intelPoints": 0
},
{
"level": 75,
"xpRequired": 624,
"totalXp": 28582,
"intelPoints": 30
},
{
"level": 76,
"xpRequired": 624,
"totalXp": 29206,
"intelPoints": 0
},
{
"level": 77,
"xpRequired": 624,
"totalXp": 29830,
"intelPoints": 0
},
{
"level": 78,
"xpRequired": 624,
"totalXp": 30454,
"intelPoints": 3050
},
{
"level": 79,
"xpRequired": 624,
"totalXp": 31078,
"intelPoints": 0
},
{
"level": 80,
"xpRequired": 624,
"totalXp": 31702,
"intelPoints": 30333
},
{
"level": 81,
"xpRequired": 624,
"totalXp": 32326,
"intelPoints": 0
},
{
"level": 82,
"xpRequired": 624,
"totalXp": 32950,
"intelPoints": 0
},
{
"level": 83,
"xpRequired": 624,
"totalXp": 33574,
"intelPoints": 0
},
{
"level": 84,
"xpRequired": 624,
"totalXp": 34198,
"intelPoints": 0
},
{
"level": 85,
"xpRequired": 624,
"totalXp": 34822,
"intelPoints": 30100
},
{
"level": 86,
"xpRequired": 624,
"totalXp": 35446,
"intelPoints": 0
},
{
"level": 87,
"xpRequired": 624,
"totalXp": 36070,
"intelPoints": 351
},
{
"level": 88,
"xpRequired": 624,
"totalXp": 36694,
"intelPoints": 351
},
{
"level": 89,
"xpRequired": 624,
"totalXp": 37318,
"intelPoints": 0
},
{
"level": 90,
"xpRequired": 624,
"totalXp": 37942,
"intelPoints": 0
},
{
"level": 91,
"xpRequired": 624,
"totalXp": 38566,
"intelPoints": 0
},
{
"level": 92,
"xpRequired": 624,
"totalXp": 39190,
"intelPoints": 0
},
{
"level": 93,
"xpRequired": 624,
"totalXp": 39814,
"intelPoints": 35
},
{
"level": 94,
"xpRequired": 624,
"totalXp": 40438,
"intelPoints": 0
},
{
"level": 95,
"xpRequired": 624,
"totalXp": 41062,
"intelPoints": 0
},
{
"level": 96,
"xpRequired": 624,
"totalXp": 41686,
"intelPoints": 3550
},
{
"level": 97,
"xpRequired": 624,
"totalXp": 42310,
"intelPoints": 0
},
{
"level": 98,
"xpRequired": 624,
"totalXp": 42934,
"intelPoints": 0
},
{
"level": 99,
"xpRequired": 624,
"totalXp": 43558,
"intelPoints": 0
},
{
"level": 100,
"xpRequired": 624,
"totalXp": 44182,
"intelPoints": 40150
}
]
}

View file

@ -0,0 +1,610 @@
{
"header": [
"Level",
"Required XP",
"Cumulative",
"Rewards"
],
"rows": [
{
"level": 1,
"xpRequired": 100,
"totalXp": 100,
"intelPoints": 215
},
{
"level": 2,
"xpRequired": 105,
"totalXp": 205,
"intelPoints": 0
},
{
"level": 3,
"xpRequired": 110,
"totalXp": 315,
"intelPoints": 0
},
{
"level": 4,
"xpRequired": 116,
"totalXp": 431,
"intelPoints": 0
},
{
"level": 5,
"xpRequired": 122,
"totalXp": 553,
"intelPoints": 0
},
{
"level": 6,
"xpRequired": 128,
"totalXp": 681,
"intelPoints": 250
},
{
"level": 7,
"xpRequired": 135,
"totalXp": 816,
"intelPoints": 0
},
{
"level": 8,
"xpRequired": 142,
"totalXp": 958,
"intelPoints": 0
},
{
"level": 9,
"xpRequired": 149,
"totalXp": 1107,
"intelPoints": 0
},
{
"level": 10,
"xpRequired": 157,
"totalXp": 1264,
"intelPoints": 2
},
{
"level": 11,
"xpRequired": 164,
"totalXp": 1428,
"intelPoints": 0
},
{
"level": 12,
"xpRequired": 171,
"totalXp": 1599,
"intelPoints": 0
},
{
"level": 13,
"xpRequired": 178,
"totalXp": 1777,
"intelPoints": 0
},
{
"level": 14,
"xpRequired": 186,
"totalXp": 1963,
"intelPoints": 0
},
{
"level": 15,
"xpRequired": 194,
"totalXp": 2157,
"intelPoints": 0
},
{
"level": 16,
"xpRequired": 202,
"totalXp": 2359,
"intelPoints": 0
},
{
"level": 17,
"xpRequired": 211,
"totalXp": 2570,
"intelPoints": 5150
},
{
"level": 18,
"xpRequired": 220,
"totalXp": 2790,
"intelPoints": 0
},
{
"level": 19,
"xpRequired": 229,
"totalXp": 3019,
"intelPoints": 0
},
{
"level": 20,
"xpRequired": 239,
"totalXp": 3258,
"intelPoints": 5150
},
{
"level": 21,
"xpRequired": 246,
"totalXp": 3504,
"intelPoints": 0
},
{
"level": 22,
"xpRequired": 253,
"totalXp": 3757,
"intelPoints": 0
},
{
"level": 23,
"xpRequired": 260,
"totalXp": 4017,
"intelPoints": 1050
},
{
"level": 24,
"xpRequired": 268,
"totalXp": 4285,
"intelPoints": 0
},
{
"level": 25,
"xpRequired": 276,
"totalXp": 4561,
"intelPoints": 1015
},
{
"level": 26,
"xpRequired": 284,
"totalXp": 4845,
"intelPoints": 0
},
{
"level": 27,
"xpRequired": 292,
"totalXp": 5137,
"intelPoints": 0
},
{
"level": 28,
"xpRequired": 301,
"totalXp": 5438,
"intelPoints": 0
},
{
"level": 29,
"xpRequired": 310,
"totalXp": 5748,
"intelPoints": 0
},
{
"level": 30,
"xpRequired": 319,
"totalXp": 6067,
"intelPoints": 0
},
{
"level": 31,
"xpRequired": 326,
"totalXp": 6393,
"intelPoints": 0
},
{
"level": 32,
"xpRequired": 334,
"totalXp": 6727,
"intelPoints": 0
},
{
"level": 33,
"xpRequired": 342,
"totalXp": 7069,
"intelPoints": 10100
},
{
"level": 34,
"xpRequired": 350,
"totalXp": 7419,
"intelPoints": 0
},
{
"level": 35,
"xpRequired": 358,
"totalXp": 7777,
"intelPoints": 1550
},
{
"level": 36,
"xpRequired": 366,
"totalXp": 8143,
"intelPoints": 0
},
{
"level": 37,
"xpRequired": 375,
"totalXp": 8518,
"intelPoints": 0
},
{
"level": 38,
"xpRequired": 384,
"totalXp": 8902,
"intelPoints": 15500
},
{
"level": 39,
"xpRequired": 393,
"totalXp": 9295,
"intelPoints": 0
},
{
"level": 40,
"xpRequired": 402,
"totalXp": 9697,
"intelPoints": 15
},
{
"level": 41,
"xpRequired": 410,
"totalXp": 10107,
"intelPoints": 0
},
{
"level": 42,
"xpRequired": 418,
"totalXp": 10525,
"intelPoints": 0
},
{
"level": 43,
"xpRequired": 426,
"totalXp": 10951,
"intelPoints": 0
},
{
"level": 44,
"xpRequired": 434,
"totalXp": 11385,
"intelPoints": 0
},
{
"level": 45,
"xpRequired": 442,
"totalXp": 11827,
"intelPoints": 0
},
{
"level": 46,
"xpRequired": 450,
"totalXp": 12277,
"intelPoints": 0
},
{
"level": 47,
"xpRequired": 459,
"totalXp": 12736,
"intelPoints": 0
},
{
"level": 48,
"xpRequired": 468,
"totalXp": 13204,
"intelPoints": 2050
},
{
"level": 49,
"xpRequired": 477,
"totalXp": 13681,
"intelPoints": 0
},
{
"level": 50,
"xpRequired": 486,
"totalXp": 14167,
"intelPoints": 2015
},
{
"level": 51,
"xpRequired": 494,
"totalXp": 14661,
"intelPoints": 0
},
{
"level": 52,
"xpRequired": 502,
"totalXp": 15163,
"intelPoints": 0
},
{
"level": 53,
"xpRequired": 510,
"totalXp": 15673,
"intelPoints": 0
},
{
"level": 54,
"xpRequired": 518,
"totalXp": 16191,
"intelPoints": 0
},
{
"level": 55,
"xpRequired": 526,
"totalXp": 16717,
"intelPoints": 2050
},
{
"level": 56,
"xpRequired": 534,
"totalXp": 17251,
"intelPoints": 0
},
{
"level": 57,
"xpRequired": 543,
"totalXp": 17794,
"intelPoints": 0
},
{
"level": 58,
"xpRequired": 552,
"totalXp": 18346,
"intelPoints": 0
},
{
"level": 59,
"xpRequired": 561,
"totalXp": 18907,
"intelPoints": 0
},
{
"level": 60,
"xpRequired": 570,
"totalXp": 19477,
"intelPoints": 0
},
{
"level": 61,
"xpRequired": 575,
"totalXp": 20052,
"intelPoints": 0
},
{
"level": 62,
"xpRequired": 580,
"totalXp": 20632,
"intelPoints": 2550
},
{
"level": 63,
"xpRequired": 585,
"totalXp": 21217,
"intelPoints": 0
},
{
"level": 64,
"xpRequired": 590,
"totalXp": 21807,
"intelPoints": 0
},
{
"level": 65,
"xpRequired": 595,
"totalXp": 22402,
"intelPoints": 2550
},
{
"level": 66,
"xpRequired": 600,
"totalXp": 23002,
"intelPoints": 0
},
{
"level": 67,
"xpRequired": 606,
"totalXp": 23608,
"intelPoints": 0
},
{
"level": 68,
"xpRequired": 612,
"totalXp": 24220,
"intelPoints": 2550
},
{
"level": 69,
"xpRequired": 618,
"totalXp": 24838,
"intelPoints": 0
},
{
"level": 70,
"xpRequired": 624,
"totalXp": 25462,
"intelPoints": 0
},
{
"level": 71,
"xpRequired": 624,
"totalXp": 26086,
"intelPoints": 25350
},
{
"level": 72,
"xpRequired": 624,
"totalXp": 26710,
"intelPoints": 0
},
{
"level": 73,
"xpRequired": 624,
"totalXp": 27334,
"intelPoints": 0
},
{
"level": 74,
"xpRequired": 624,
"totalXp": 27958,
"intelPoints": 0
},
{
"level": 75,
"xpRequired": 624,
"totalXp": 28582,
"intelPoints": 30
},
{
"level": 76,
"xpRequired": 624,
"totalXp": 29206,
"intelPoints": 0
},
{
"level": 77,
"xpRequired": 624,
"totalXp": 29830,
"intelPoints": 3015
},
{
"level": 78,
"xpRequired": 624,
"totalXp": 30454,
"intelPoints": 0
},
{
"level": 79,
"xpRequired": 624,
"totalXp": 31078,
"intelPoints": 0
},
{
"level": 80,
"xpRequired": 624,
"totalXp": 31702,
"intelPoints": 0
},
{
"level": 81,
"xpRequired": 624,
"totalXp": 32326,
"intelPoints": 0
},
{
"level": 82,
"xpRequired": 624,
"totalXp": 32950,
"intelPoints": 0
},
{
"level": 83,
"xpRequired": 624,
"totalXp": 33574,
"intelPoints": 3050
},
{
"level": 84,
"xpRequired": 624,
"totalXp": 34198,
"intelPoints": 0
},
{
"level": 85,
"xpRequired": 624,
"totalXp": 34822,
"intelPoints": 3050
},
{
"level": 86,
"xpRequired": 624,
"totalXp": 35446,
"intelPoints": 0
},
{
"level": 87,
"xpRequired": 624,
"totalXp": 36070,
"intelPoints": 0
},
{
"level": 88,
"xpRequired": 624,
"totalXp": 36694,
"intelPoints": 35100
},
{
"level": 89,
"xpRequired": 624,
"totalXp": 37318,
"intelPoints": 0
},
{
"level": 90,
"xpRequired": 624,
"totalXp": 37942,
"intelPoints": 0
},
{
"level": 91,
"xpRequired": 624,
"totalXp": 38566,
"intelPoints": 0
},
{
"level": 92,
"xpRequired": 624,
"totalXp": 39190,
"intelPoints": 0
},
{
"level": 93,
"xpRequired": 624,
"totalXp": 39814,
"intelPoints": 0
},
{
"level": 94,
"xpRequired": 624,
"totalXp": 40438,
"intelPoints": 0
},
{
"level": 95,
"xpRequired": 624,
"totalXp": 41062,
"intelPoints": 3550
},
{
"level": 96,
"xpRequired": 624,
"totalXp": 41686,
"intelPoints": 0
},
{
"level": 97,
"xpRequired": 624,
"totalXp": 42310,
"intelPoints": 0
},
{
"level": 98,
"xpRequired": 624,
"totalXp": 42934,
"intelPoints": 0
},
{
"level": 99,
"xpRequired": 624,
"totalXp": 43558,
"intelPoints": 0
},
{
"level": 100,
"xpRequired": 624,
"totalXp": 44182,
"intelPoints": 4015
}
]
}

View file

@ -0,0 +1,610 @@
{
"header": [
"Level",
"Required XP",
"Cumulative",
"Rewards"
],
"rows": [
{
"level": 1,
"xpRequired": 100,
"totalXp": 100,
"intelPoints": 0
},
{
"level": 2,
"xpRequired": 105,
"totalXp": 205,
"intelPoints": 2200
},
{
"level": 3,
"xpRequired": 110,
"totalXp": 315,
"intelPoints": 21000
},
{
"level": 4,
"xpRequired": 116,
"totalXp": 431,
"intelPoints": 0
},
{
"level": 5,
"xpRequired": 122,
"totalXp": 553,
"intelPoints": 0
},
{
"level": 6,
"xpRequired": 128,
"totalXp": 681,
"intelPoints": 0
},
{
"level": 7,
"xpRequired": 135,
"totalXp": 816,
"intelPoints": 250
},
{
"level": 8,
"xpRequired": 142,
"totalXp": 958,
"intelPoints": 0
},
{
"level": 9,
"xpRequired": 149,
"totalXp": 1107,
"intelPoints": 0
},
{
"level": 10,
"xpRequired": 157,
"totalXp": 1264,
"intelPoints": 525
},
{
"level": 11,
"xpRequired": 164,
"totalXp": 1428,
"intelPoints": 0
},
{
"level": 12,
"xpRequired": 171,
"totalXp": 1599,
"intelPoints": 0
},
{
"level": 13,
"xpRequired": 178,
"totalXp": 1777,
"intelPoints": 5100
},
{
"level": 14,
"xpRequired": 186,
"totalXp": 1963,
"intelPoints": 0
},
{
"level": 15,
"xpRequired": 194,
"totalXp": 2157,
"intelPoints": 51000
},
{
"level": 16,
"xpRequired": 202,
"totalXp": 2359,
"intelPoints": 0
},
{
"level": 17,
"xpRequired": 211,
"totalXp": 2570,
"intelPoints": 550
},
{
"level": 18,
"xpRequired": 220,
"totalXp": 2790,
"intelPoints": 0
},
{
"level": 19,
"xpRequired": 229,
"totalXp": 3019,
"intelPoints": 0
},
{
"level": 20,
"xpRequired": 239,
"totalXp": 3258,
"intelPoints": 1025
},
{
"level": 21,
"xpRequired": 246,
"totalXp": 3504,
"intelPoints": 0
},
{
"level": 22,
"xpRequired": 253,
"totalXp": 3757,
"intelPoints": 0
},
{
"level": 23,
"xpRequired": 260,
"totalXp": 4017,
"intelPoints": 1050
},
{
"level": 24,
"xpRequired": 268,
"totalXp": 4285,
"intelPoints": 0
},
{
"level": 25,
"xpRequired": 276,
"totalXp": 4561,
"intelPoints": 0
},
{
"level": 26,
"xpRequired": 284,
"totalXp": 4845,
"intelPoints": 0
},
{
"level": 27,
"xpRequired": 292,
"totalXp": 5137,
"intelPoints": 0
},
{
"level": 28,
"xpRequired": 301,
"totalXp": 5438,
"intelPoints": 1050
},
{
"level": 29,
"xpRequired": 310,
"totalXp": 5748,
"intelPoints": 0
},
{
"level": 30,
"xpRequired": 319,
"totalXp": 6067,
"intelPoints": 1025
},
{
"level": 31,
"xpRequired": 326,
"totalXp": 6393,
"intelPoints": 0
},
{
"level": 32,
"xpRequired": 334,
"totalXp": 6727,
"intelPoints": 0
},
{
"level": 33,
"xpRequired": 342,
"totalXp": 7069,
"intelPoints": 15100
},
{
"level": 34,
"xpRequired": 350,
"totalXp": 7419,
"intelPoints": 0
},
{
"level": 35,
"xpRequired": 358,
"totalXp": 7777,
"intelPoints": 0
},
{
"level": 36,
"xpRequired": 366,
"totalXp": 8143,
"intelPoints": 0
},
{
"level": 37,
"xpRequired": 375,
"totalXp": 8518,
"intelPoints": 0
},
{
"level": 38,
"xpRequired": 384,
"totalXp": 8902,
"intelPoints": 1550
},
{
"level": 39,
"xpRequired": 393,
"totalXp": 9295,
"intelPoints": 0
},
{
"level": 40,
"xpRequired": 402,
"totalXp": 9697,
"intelPoints": 1525
},
{
"level": 41,
"xpRequired": 410,
"totalXp": 10107,
"intelPoints": 0
},
{
"level": 42,
"xpRequired": 418,
"totalXp": 10525,
"intelPoints": 0
},
{
"level": 43,
"xpRequired": 426,
"totalXp": 10951,
"intelPoints": 1550
},
{
"level": 44,
"xpRequired": 434,
"totalXp": 11385,
"intelPoints": 0
},
{
"level": 45,
"xpRequired": 442,
"totalXp": 11827,
"intelPoints": 0
},
{
"level": 46,
"xpRequired": 450,
"totalXp": 12277,
"intelPoints": 0
},
{
"level": 47,
"xpRequired": 459,
"totalXp": 12736,
"intelPoints": 0
},
{
"level": 48,
"xpRequired": 468,
"totalXp": 13204,
"intelPoints": 2050
},
{
"level": 49,
"xpRequired": 477,
"totalXp": 13681,
"intelPoints": 0
},
{
"level": 50,
"xpRequired": 486,
"totalXp": 14167,
"intelPoints": 2025
},
{
"level": 51,
"xpRequired": 494,
"totalXp": 14661,
"intelPoints": 0
},
{
"level": 52,
"xpRequired": 502,
"totalXp": 15163,
"intelPoints": 0
},
{
"level": 53,
"xpRequired": 510,
"totalXp": 15673,
"intelPoints": 2050
},
{
"level": 54,
"xpRequired": 518,
"totalXp": 16191,
"intelPoints": 0
},
{
"level": 55,
"xpRequired": 526,
"totalXp": 16717,
"intelPoints": 201000
},
{
"level": 56,
"xpRequired": 534,
"totalXp": 17251,
"intelPoints": 0
},
{
"level": 57,
"xpRequired": 543,
"totalXp": 17794,
"intelPoints": 0
},
{
"level": 58,
"xpRequired": 552,
"totalXp": 18346,
"intelPoints": 0
},
{
"level": 59,
"xpRequired": 561,
"totalXp": 18907,
"intelPoints": 0
},
{
"level": 60,
"xpRequired": 570,
"totalXp": 19477,
"intelPoints": 0
},
{
"level": 61,
"xpRequired": 575,
"totalXp": 20052,
"intelPoints": 2525
},
{
"level": 62,
"xpRequired": 580,
"totalXp": 20632,
"intelPoints": 0
},
{
"level": 63,
"xpRequired": 585,
"totalXp": 21217,
"intelPoints": 25100
},
{
"level": 64,
"xpRequired": 590,
"totalXp": 21807,
"intelPoints": 0
},
{
"level": 65,
"xpRequired": 595,
"totalXp": 22402,
"intelPoints": 0
},
{
"level": 66,
"xpRequired": 600,
"totalXp": 23002,
"intelPoints": 0
},
{
"level": 67,
"xpRequired": 606,
"totalXp": 23608,
"intelPoints": 0
},
{
"level": 68,
"xpRequired": 612,
"totalXp": 24220,
"intelPoints": 2550
},
{
"level": 69,
"xpRequired": 618,
"totalXp": 24838,
"intelPoints": 0
},
{
"level": 70,
"xpRequired": 624,
"totalXp": 25462,
"intelPoints": 0
},
{
"level": 71,
"xpRequired": 624,
"totalXp": 26086,
"intelPoints": 3025
},
{
"level": 72,
"xpRequired": 624,
"totalXp": 26710,
"intelPoints": 0
},
{
"level": 73,
"xpRequired": 624,
"totalXp": 27334,
"intelPoints": 0
},
{
"level": 74,
"xpRequired": 624,
"totalXp": 27958,
"intelPoints": 30400
},
{
"level": 75,
"xpRequired": 624,
"totalXp": 28582,
"intelPoints": 30
},
{
"level": 76,
"xpRequired": 624,
"totalXp": 29206,
"intelPoints": 0
},
{
"level": 77,
"xpRequired": 624,
"totalXp": 29830,
"intelPoints": 0
},
{
"level": 78,
"xpRequired": 624,
"totalXp": 30454,
"intelPoints": 30100
},
{
"level": 79,
"xpRequired": 624,
"totalXp": 31078,
"intelPoints": 0
},
{
"level": 80,
"xpRequired": 624,
"totalXp": 31702,
"intelPoints": 0
},
{
"level": 81,
"xpRequired": 624,
"totalXp": 32326,
"intelPoints": 3025
},
{
"level": 82,
"xpRequired": 624,
"totalXp": 32950,
"intelPoints": 0
},
{
"level": 83,
"xpRequired": 624,
"totalXp": 33574,
"intelPoints": 3550
},
{
"level": 84,
"xpRequired": 624,
"totalXp": 34198,
"intelPoints": 0
},
{
"level": 85,
"xpRequired": 624,
"totalXp": 34822,
"intelPoints": 0
},
{
"level": 86,
"xpRequired": 624,
"totalXp": 35446,
"intelPoints": 0
},
{
"level": 87,
"xpRequired": 624,
"totalXp": 36070,
"intelPoints": 0
},
{
"level": 88,
"xpRequired": 624,
"totalXp": 36694,
"intelPoints": 0
},
{
"level": 89,
"xpRequired": 624,
"totalXp": 37318,
"intelPoints": 0
},
{
"level": 90,
"xpRequired": 624,
"totalXp": 37942,
"intelPoints": 3525
},
{
"level": 91,
"xpRequired": 624,
"totalXp": 38566,
"intelPoints": 0
},
{
"level": 92,
"xpRequired": 624,
"totalXp": 39190,
"intelPoints": 0
},
{
"level": 93,
"xpRequired": 624,
"totalXp": 39814,
"intelPoints": 0
},
{
"level": 94,
"xpRequired": 624,
"totalXp": 40438,
"intelPoints": 0
},
{
"level": 95,
"xpRequired": 624,
"totalXp": 41062,
"intelPoints": 0
},
{
"level": 96,
"xpRequired": 624,
"totalXp": 41686,
"intelPoints": 0
},
{
"level": 97,
"xpRequired": 624,
"totalXp": 42310,
"intelPoints": 0
},
{
"level": 98,
"xpRequired": 624,
"totalXp": 42934,
"intelPoints": 0
},
{
"level": 99,
"xpRequired": 624,
"totalXp": 43558,
"intelPoints": 0
},
{
"level": 100,
"xpRequired": 624,
"totalXp": 44182,
"intelPoints": 4025
}
]
}

View file

@ -0,0 +1,610 @@
{
"header": [
"Level",
"Required XP",
"Cumulative",
"Rewards"
],
"rows": [
{
"level": 1,
"xpRequired": 100,
"totalXp": 100,
"intelPoints": 250
},
{
"level": 2,
"xpRequired": 105,
"totalXp": 205,
"intelPoints": 21000
},
{
"level": 3,
"xpRequired": 110,
"totalXp": 315,
"intelPoints": 250
},
{
"level": 4,
"xpRequired": 116,
"totalXp": 431,
"intelPoints": 0
},
{
"level": 5,
"xpRequired": 122,
"totalXp": 553,
"intelPoints": 0
},
{
"level": 6,
"xpRequired": 128,
"totalXp": 681,
"intelPoints": 0
},
{
"level": 7,
"xpRequired": 135,
"totalXp": 816,
"intelPoints": 0
},
{
"level": 8,
"xpRequired": 142,
"totalXp": 958,
"intelPoints": 21000
},
{
"level": 9,
"xpRequired": 149,
"totalXp": 1107,
"intelPoints": 0
},
{
"level": 10,
"xpRequired": 157,
"totalXp": 1264,
"intelPoints": 550
},
{
"level": 11,
"xpRequired": 164,
"totalXp": 1428,
"intelPoints": 0
},
{
"level": 12,
"xpRequired": 171,
"totalXp": 1599,
"intelPoints": 5150
},
{
"level": 13,
"xpRequired": 178,
"totalXp": 1777,
"intelPoints": 0
},
{
"level": 14,
"xpRequired": 186,
"totalXp": 1963,
"intelPoints": 0
},
{
"level": 15,
"xpRequired": 194,
"totalXp": 2157,
"intelPoints": 0
},
{
"level": 16,
"xpRequired": 202,
"totalXp": 2359,
"intelPoints": 0
},
{
"level": 17,
"xpRequired": 211,
"totalXp": 2570,
"intelPoints": 0
},
{
"level": 18,
"xpRequired": 220,
"totalXp": 2790,
"intelPoints": 0
},
{
"level": 19,
"xpRequired": 229,
"totalXp": 3019,
"intelPoints": 550
},
{
"level": 20,
"xpRequired": 239,
"totalXp": 3258,
"intelPoints": 0
},
{
"level": 21,
"xpRequired": 246,
"totalXp": 3504,
"intelPoints": 0
},
{
"level": 22,
"xpRequired": 253,
"totalXp": 3757,
"intelPoints": 1020
},
{
"level": 23,
"xpRequired": 260,
"totalXp": 4017,
"intelPoints": 0
},
{
"level": 24,
"xpRequired": 268,
"totalXp": 4285,
"intelPoints": 0
},
{
"level": 25,
"xpRequired": 276,
"totalXp": 4561,
"intelPoints": 0
},
{
"level": 26,
"xpRequired": 284,
"totalXp": 4845,
"intelPoints": 0
},
{
"level": 27,
"xpRequired": 292,
"totalXp": 5137,
"intelPoints": 1050
},
{
"level": 28,
"xpRequired": 301,
"totalXp": 5438,
"intelPoints": 0
},
{
"level": 29,
"xpRequired": 310,
"totalXp": 5748,
"intelPoints": 0
},
{
"level": 30,
"xpRequired": 319,
"totalXp": 6067,
"intelPoints": 0
},
{
"level": 31,
"xpRequired": 326,
"totalXp": 6393,
"intelPoints": 0
},
{
"level": 32,
"xpRequired": 334,
"totalXp": 6727,
"intelPoints": 10150
},
{
"level": 33,
"xpRequired": 342,
"totalXp": 7069,
"intelPoints": 0
},
{
"level": 34,
"xpRequired": 350,
"totalXp": 7419,
"intelPoints": 0
},
{
"level": 35,
"xpRequired": 358,
"totalXp": 7777,
"intelPoints": 0
},
{
"level": 36,
"xpRequired": 366,
"totalXp": 8143,
"intelPoints": 0
},
{
"level": 37,
"xpRequired": 375,
"totalXp": 8518,
"intelPoints": 1550
},
{
"level": 38,
"xpRequired": 384,
"totalXp": 8902,
"intelPoints": 0
},
{
"level": 39,
"xpRequired": 393,
"totalXp": 9295,
"intelPoints": 0
},
{
"level": 40,
"xpRequired": 402,
"totalXp": 9697,
"intelPoints": 15
},
{
"level": 41,
"xpRequired": 410,
"totalXp": 10107,
"intelPoints": 0
},
{
"level": 42,
"xpRequired": 418,
"totalXp": 10525,
"intelPoints": 0
},
{
"level": 43,
"xpRequired": 426,
"totalXp": 10951,
"intelPoints": 15100
},
{
"level": 44,
"xpRequired": 434,
"totalXp": 11385,
"intelPoints": 0
},
{
"level": 45,
"xpRequired": 442,
"totalXp": 11827,
"intelPoints": 1520
},
{
"level": 46,
"xpRequired": 450,
"totalXp": 12277,
"intelPoints": 0
},
{
"level": 47,
"xpRequired": 459,
"totalXp": 12736,
"intelPoints": 0
},
{
"level": 48,
"xpRequired": 468,
"totalXp": 13204,
"intelPoints": 0
},
{
"level": 49,
"xpRequired": 477,
"totalXp": 13681,
"intelPoints": 0
},
{
"level": 50,
"xpRequired": 486,
"totalXp": 14167,
"intelPoints": 2050
},
{
"level": 51,
"xpRequired": 494,
"totalXp": 14661,
"intelPoints": 0
},
{
"level": 52,
"xpRequired": 502,
"totalXp": 15163,
"intelPoints": 0
},
{
"level": 53,
"xpRequired": 510,
"totalXp": 15673,
"intelPoints": 2050
},
{
"level": 54,
"xpRequired": 518,
"totalXp": 16191,
"intelPoints": 0
},
{
"level": 55,
"xpRequired": 526,
"totalXp": 16717,
"intelPoints": 20150
},
{
"level": 56,
"xpRequired": 534,
"totalXp": 17251,
"intelPoints": 0
},
{
"level": 57,
"xpRequired": 543,
"totalXp": 17794,
"intelPoints": 0
},
{
"level": 58,
"xpRequired": 552,
"totalXp": 18346,
"intelPoints": 0
},
{
"level": 59,
"xpRequired": 561,
"totalXp": 18907,
"intelPoints": 0
},
{
"level": 60,
"xpRequired": 570,
"totalXp": 19477,
"intelPoints": 2550
},
{
"level": 61,
"xpRequired": 575,
"totalXp": 20052,
"intelPoints": 0
},
{
"level": 62,
"xpRequired": 580,
"totalXp": 20632,
"intelPoints": 0
},
{
"level": 63,
"xpRequired": 585,
"totalXp": 21217,
"intelPoints": 0
},
{
"level": 64,
"xpRequired": 590,
"totalXp": 21807,
"intelPoints": 0
},
{
"level": 65,
"xpRequired": 595,
"totalXp": 22402,
"intelPoints": 0
},
{
"level": 66,
"xpRequired": 600,
"totalXp": 23002,
"intelPoints": 0
},
{
"level": 67,
"xpRequired": 606,
"totalXp": 23608,
"intelPoints": 0
},
{
"level": 68,
"xpRequired": 612,
"totalXp": 24220,
"intelPoints": 0
},
{
"level": 69,
"xpRequired": 618,
"totalXp": 24838,
"intelPoints": 25150
},
{
"level": 70,
"xpRequired": 624,
"totalXp": 25462,
"intelPoints": 0
},
{
"level": 71,
"xpRequired": 624,
"totalXp": 26086,
"intelPoints": 0
},
{
"level": 72,
"xpRequired": 624,
"totalXp": 26710,
"intelPoints": 2550
},
{
"level": 73,
"xpRequired": 624,
"totalXp": 27334,
"intelPoints": 0
},
{
"level": 74,
"xpRequired": 624,
"totalXp": 27958,
"intelPoints": 3050
},
{
"level": 75,
"xpRequired": 624,
"totalXp": 28582,
"intelPoints": 30
},
{
"level": 76,
"xpRequired": 624,
"totalXp": 29206,
"intelPoints": 0
},
{
"level": 77,
"xpRequired": 624,
"totalXp": 29830,
"intelPoints": 0
},
{
"level": 78,
"xpRequired": 624,
"totalXp": 30454,
"intelPoints": 0
},
{
"level": 79,
"xpRequired": 624,
"totalXp": 31078,
"intelPoints": 0
},
{
"level": 80,
"xpRequired": 624,
"totalXp": 31702,
"intelPoints": 0
},
{
"level": 81,
"xpRequired": 624,
"totalXp": 32326,
"intelPoints": 0
},
{
"level": 82,
"xpRequired": 624,
"totalXp": 32950,
"intelPoints": 0
},
{
"level": 83,
"xpRequired": 624,
"totalXp": 33574,
"intelPoints": 0
},
{
"level": 84,
"xpRequired": 624,
"totalXp": 34198,
"intelPoints": 0
},
{
"level": 85,
"xpRequired": 624,
"totalXp": 34822,
"intelPoints": 3050
},
{
"level": 86,
"xpRequired": 624,
"totalXp": 35446,
"intelPoints": 0
},
{
"level": 87,
"xpRequired": 624,
"totalXp": 36070,
"intelPoints": 0
},
{
"level": 88,
"xpRequired": 624,
"totalXp": 36694,
"intelPoints": 3550
},
{
"level": 89,
"xpRequired": 624,
"totalXp": 37318,
"intelPoints": 0
},
{
"level": 90,
"xpRequired": 624,
"totalXp": 37942,
"intelPoints": 0
},
{
"level": 91,
"xpRequired": 624,
"totalXp": 38566,
"intelPoints": 0
},
{
"level": 92,
"xpRequired": 624,
"totalXp": 39190,
"intelPoints": 0
},
{
"level": 93,
"xpRequired": 624,
"totalXp": 39814,
"intelPoints": 0
},
{
"level": 94,
"xpRequired": 624,
"totalXp": 40438,
"intelPoints": 0
},
{
"level": 95,
"xpRequired": 624,
"totalXp": 41062,
"intelPoints": 0
},
{
"level": 96,
"xpRequired": 624,
"totalXp": 41686,
"intelPoints": 0
},
{
"level": 97,
"xpRequired": 624,
"totalXp": 42310,
"intelPoints": 0
},
{
"level": 98,
"xpRequired": 624,
"totalXp": 42934,
"intelPoints": 35150
},
{
"level": 99,
"xpRequired": 624,
"totalXp": 43558,
"intelPoints": 0
},
{
"level": 100,
"xpRequired": 624,
"totalXp": 44182,
"intelPoints": 4050
}
]
}

View file

@ -0,0 +1,38 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
image: dune-character-builder:latest
container_name: dune-builder-app
restart: unless-stopped
environment:
VALKEY_URL: redis://valkey:6379
PORT: "3000"
depends_on:
valkey:
condition: service_healthy
ports:
- "8080:3000"
valkey:
image: valkey/valkey:8-alpine
container_name: dune-builder-valkey
restart: unless-stopped
command:
- valkey-server
- --appendonly
- "yes"
- --save
- "60"
- "1"
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
valkey-data:

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dune Awakening — Character Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,20 @@
{
"name": "dune-character-builder-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

View file

@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import XpProgressCard from './components/XpProgressCard.vue';
import FactionTrack from './components/FactionTrack.vue';
import SkillTree from './components/SkillTree.vue';
import { applyBuild, build, resetBuild, setClass, setHouse } from './store';
import {
CLASSES,
HOUSES,
SPECS,
loadCharacterXp,
loadFaction,
loadSkillTree,
loadSpec,
} from './data';
import type {
ClassId,
FactionTable,
House,
SkillTree as TSkillTree,
SpecId,
XpTable,
} from './types';
const charXp = shallowRef<XpTable | null>(null);
const specTables = shallowRef<Record<SpecId, XpTable | null>>({
combat: null,
crafting: null,
exploration: null,
gathering: null,
sabotage: null,
});
const factionTables = shallowRef<Record<House, FactionTable | null>>({
atreides: null,
harkonnen: null,
});
const skillTrees = shallowRef<Record<ClassId, TSkillTree | null>>({
benegesserit: null,
mentat: null,
planetologist: null,
swordmaster: null,
trooper: null,
});
const shareLink = ref<string>('');
const shareStatus = ref<string>('');
const saving = ref(false);
onMounted(async () => {
await Promise.all([
loadCharacterXp().then((d) => (charXp.value = d)),
...SPECS.map((s) =>
loadSpec(s).then((d) => (specTables.value = { ...specTables.value, [s]: d })),
),
loadFaction('atreides').then(
(d) => (factionTables.value = { ...factionTables.value, atreides: d }),
),
loadFaction('harkonnen').then(
(d) => (factionTables.value = { ...factionTables.value, harkonnen: d }),
),
]);
// Load all skill trees in background so class switching is instant
for (const c of CLASSES) {
loadSkillTree(c.id).then(
(d) => (skillTrees.value = { ...skillTrees.value, [c.id]: d }),
);
}
// If URL has ?b=<code>, load that build.
const params = new URLSearchParams(window.location.search);
const code = params.get('b');
if (code) {
try {
const res = await fetch(`/api/builds/${code}`);
if (res.ok) {
const body = await res.json();
if (body.build) {
applyBuild(body.build);
shareStatus.value = `loaded build ${code}`;
shareLink.value = window.location.href;
}
} else {
shareStatus.value = `build ${code} not found`;
}
} catch (e) {
shareStatus.value = 'failed to load shared build';
}
}
});
const currentFaction = computed(() => factionTables.value[build.house]);
const currentTree = computed(() => skillTrees.value[build.classId]);
const totalSkillPointsAtLevel = computed(() => {
if (!charXp.value) return 0;
const idx = Math.max(0, Math.min(build.character.level - 1, charXp.value.rows.length - 1));
if (idx < 0) return 0;
return charXp.value.rows[idx]?.totalSkillPoints || 0;
});
const totalIntelAtLevel = computed(() => {
if (!charXp.value) return 0;
const idx = Math.max(0, Math.min(build.character.level - 1, charXp.value.rows.length - 1));
if (idx < 0) return 0;
return charXp.value.rows[idx]?.totalIntelPoints || 0;
});
async function share() {
saving.value = true;
shareStatus.value = '';
try {
const res = await fetch('/api/builds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(build),
});
if (!res.ok) {
const t = await res.text();
throw new Error(t);
}
const { code } = await res.json();
const url = new URL(window.location.href);
url.search = `?b=${code}`;
window.history.replaceState({}, '', url.toString());
shareLink.value = url.toString();
try {
await navigator.clipboard.writeText(shareLink.value);
shareStatus.value = 'link copied';
} catch {
shareStatus.value = 'link ready';
}
} catch (e: any) {
shareStatus.value = `error: ${e?.message || 'failed'}`;
} finally {
saving.value = false;
}
}
function newBuild() {
if (!confirm('Reset build to defaults?')) return;
resetBuild();
shareLink.value = '';
shareStatus.value = 'reset';
const url = new URL(window.location.href);
url.search = '';
window.history.replaceState({}, '', url.toString());
}
watch(
() => build.classId,
() => {
// when switching class, ensure tree exists (lazy fetch fallback)
if (!skillTrees.value[build.classId]) {
loadSkillTree(build.classId).then(
(d) =>
(skillTrees.value = { ...skillTrees.value, [build.classId]: d }),
);
}
},
);
const specMeta: Record<SpecId, { name: string; sym: string }> = {
combat: { name: 'Combat', sym: 'CMB' },
crafting: { name: 'Crafting', sym: 'CRF' },
exploration: { name: 'Exploration', sym: 'EXP' },
gathering: { name: 'Gathering', sym: 'GTH' },
sabotage: { name: 'Sabotage', sym: 'SAB' },
};
</script>
<template>
<div class="share-bar">
<div class="share-inner">
<strong
style="
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.22em;
color: var(--sand-2);
text-transform: uppercase;
"
>Dune Character Builder</strong>
<div class="grow"></div>
<span v-if="shareLink" class="share-link">{{ shareLink }}</span>
<span v-if="shareStatus" class="toast">{{ shareStatus }}</span>
<button @click="newBuild">New</button>
<button class="primary" @click="share" :disabled="saving">
{{ saving ? 'Saving…' : 'Share Build' }}
</button>
</div>
</div>
<div class="wrap">
<header class="hero">
<div class="eyebrow">Dune Awakening Build Ledger</div>
<h1>
Plan your <em>Arrakeen ascent</em> XP, specializations, factions,
skill trees.
</h1>
<p class="lede">
Pick your house and class, set your starting point, then plot the path
ahead. Your build is saved locally and can be shared by short link.
</p>
</header>
<!-- HOUSE + CLASS -->
<section class="panel">
<div class="panel-head">
<h2>House &amp; Class</h2>
<div class="sub">Identity</div>
</div>
<div class="house-grid">
<div
v-for="h in HOUSES"
:key="h.id"
:class="['house-pick', h.id, build.house === h.id ? 'active' : '']"
@click="setHouse(h.id)"
>
<h3>{{ h.name }}</h3>
<div class="meta">
{{ h.id === 'atreides' ? 'The serpent and the eagle' : 'Strength without mercy' }}
</div>
</div>
</div>
<div style="margin-top: 22px">
<div
style="
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 8px;
"
>
Class
</div>
<div class="class-grid">
<div
v-for="c in CLASSES"
:key="c.id"
:class="['class-pick', build.classId === c.id ? 'active' : '']"
@click="setClass(c.id)"
>
{{ c.name }}
</div>
</div>
</div>
</section>
<!-- CHARACTER -->
<section class="panel">
<div class="panel-head">
<h2>Character</h2>
<div class="sub">Levels 0{{ charXp?.rows.length || '?' }}</div>
</div>
<div class="totals">
<div class="total">
<div class="lbl">Level</div>
<div class="val">{{ build.character.level }}</div>
</div>
<div class="total">
<div class="lbl">Skill Points</div>
<div class="val">{{ totalSkillPointsAtLevel }}</div>
</div>
<div class="total">
<div class="lbl">Intel Points</div>
<div class="val">{{ totalIntelAtLevel }}</div>
</div>
</div>
<div class="cards">
<XpProgressCard
title="Character XP"
sym="CHR"
:table="charXp"
:level="build.character.level"
:xp-into="build.character.xpInto"
@update:level="(n) => (build.character.level = n)"
@update:xp-into="(n) => (build.character.xpInto = n)"
/>
</div>
</section>
<!-- SPECIALIZATIONS -->
<section class="panel">
<div class="panel-head">
<h2>Specializations</h2>
<div class="sub">5 tracks · L0L100</div>
</div>
<div class="cards">
<XpProgressCard
v-for="s in SPECS"
:key="s"
:title="specMeta[s].name"
:sym="specMeta[s].sym"
:table="specTables[s]"
:level="build.specs[s].level"
:xp-into="build.specs[s].xpInto"
@update:level="(n) => (build.specs[s].level = n)"
@update:xp-into="(n) => (build.specs[s].xpInto = n)"
/>
</div>
</section>
<!-- FACTION -->
<section class="panel">
<div class="panel-head">
<h2>Faction Standing</h2>
<div class="sub">
{{ build.house === 'atreides' ? 'House Atreides' : 'House Harkonnen' }}
</div>
</div>
<FactionTrack
:house="build.house"
:table="currentFaction"
:tier="build.faction.tier"
:standing-into="build.faction.standingInto"
@update:tier="(n) => (build.faction.tier = n)"
@update:standing-into="(n) => (build.faction.standingInto = n)"
/>
</section>
<!-- SKILL TREE -->
<section class="panel">
<div class="panel-head">
<h2>Skill Tree {{ CLASSES.find((c) => c.id === build.classId)?.name }}</h2>
<div class="sub">Class progression</div>
</div>
<SkillTree
:tree="currentTree"
:allocations="build.skills"
:available-points="totalSkillPointsAtLevel"
@update:allocations="(a) => (build.skills = a)"
@reset="() => (build.skills = {})"
/>
</section>
<footer class="footer-bar">
<div>Unofficial fan-made tool · not affiliated with the publisher</div>
<div>Data sourced from dune.gaming.tools snapshots</div>
</footer>
</div>
</template>

View file

@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { FactionTable, House } from '../types';
const props = defineProps<{
house: House;
table: FactionTable | null;
tier: number;
standingInto: number;
}>();
const emit = defineEmits<{
'update:tier': [n: number];
'update:standingInto': [n: number];
}>();
const tierCount = computed(() => props.table?.tiers.length ?? 0);
const maxTier = computed(() => Math.max(0, tierCount.value - 1));
const currentTier = computed(() => {
if (!props.table) return null;
return props.table.tiers[props.tier] || null;
});
const nextTier = computed(() => {
if (!props.table) return null;
return props.table.tiers[props.tier + 1] || null;
});
const pct = computed(() => {
if (!nextTier.value || !nextTier.value.standingRequired) return 0;
const p = (props.standingInto / nextTier.value.standingRequired) * 100;
return Math.min(100, Math.max(0, p));
});
function setTier(e: Event) {
const v = Number((e.target as HTMLInputElement).value) || 0;
emit('update:tier', Math.max(0, Math.min(maxTier.value, Math.floor(v))));
}
function setInto(e: Event) {
const v = Number((e.target as HTMLInputElement).value) || 0;
emit('update:standingInto', Math.max(0, Math.floor(v)));
}
function fmt(n: number): string {
return n.toLocaleString('en-US');
}
</script>
<template>
<div>
<div class="row">
<div class="field">
<label>Current Tier</label>
<input
type="number"
min="0"
:max="maxTier"
:value="tier"
@input="setTier"
/>
</div>
<div class="field">
<label>Rep into next tier</label>
<input type="number" min="0" :value="standingInto" @input="setInto" />
</div>
</div>
<div class="progress" :style="{ '--pct': pct + '%' }"></div>
<div class="progress-meta">
<span>
{{ currentTier?.name || '—' }} {{ nextTier?.name || 'Max' }}
</span>
<span>
{{ fmt(standingInto) }} / {{ fmt(nextTier?.standingRequired || 0) }} rep
</span>
</div>
<div class="tier-list" v-if="table">
<div
v-for="t in table.tiers"
:key="t.tier"
:class="[
'tier-row',
t.tier < tier ? 'reached' : '',
t.tier === tier ? 'current' : '',
]"
>
<span class="num">{{ t.tier }}</span>
<span>{{ t.name || '—' }}</span>
<span>{{ fmt(t.standingRequired) }}</span>
<span style="color: var(--ink-muted)">{{ fmt(t.totalStanding) }}</span>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { SkillTree } from '../types';
const props = defineProps<{
tree: SkillTree | null;
allocations: Record<string, number>;
availablePoints: number;
}>();
const emit = defineEmits<{
'update:allocations': [a: Record<string, number>];
reset: [];
}>();
// Cell size + gap in pixels. Match CSS .tree-cell.
const CELL = 96;
const GAP = 36; // visual spacing including label area
const gridSize = computed(() => {
if (!props.tree) return { rows: 0, cols: 0 };
let maxRow = 0;
let maxCol = 0;
for (const n of props.tree.nodes) {
if (n.row > maxRow) maxRow = n.row;
if (n.col > maxCol) maxCol = n.col;
}
return { rows: maxRow, cols: maxCol };
});
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${gridSize.value.cols}, ${CELL}px)`,
gridTemplateRows: `repeat(${gridSize.value.rows}, ${CELL}px)`,
width: `${gridSize.value.cols * CELL + (gridSize.value.cols - 1) * GAP}px`,
columnGap: `${GAP}px`,
rowGap: `${GAP}px`,
}));
const totalAllocated = computed(() =>
Object.values(props.allocations).reduce((a, b) => a + (b || 0), 0),
);
function centerForNode(row: number, col: number) {
const x = (col - 1) * (CELL + GAP) + CELL / 2;
const y = (row - 1) * (CELL + GAP) + CELL / 2;
return { x, y };
}
const edgeLines = computed(() => {
if (!props.tree) return [];
const byTag = new Map(props.tree.nodes.map((n) => [n.tag, n] as const));
const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
for (const e of props.tree.edges) {
const a = byTag.get(e.from);
const b = byTag.get(e.to);
if (!a || !b) continue;
const pa = centerForNode(a.row, a.col);
const pb = centerForNode(b.row, b.col);
lines.push({ x1: pa.x, y1: pa.y, x2: pb.x, y2: pb.y });
}
return lines;
});
const svgSize = computed(() => {
const w = gridSize.value.cols * CELL + (gridSize.value.cols - 1) * GAP;
const h = gridSize.value.rows * CELL + (gridSize.value.rows - 1) * GAP;
return { w, h };
});
function pointsFor(tag: string): number {
return props.allocations[tag] || 0;
}
function add(tag: string, delta: number) {
if (!props.tree) return;
const node = props.tree.nodes.find((n) => n.tag === tag);
if (!node) return;
const cur = pointsFor(tag);
const max = node.maxPoints;
let next = cur + delta;
if (next < 0) next = 0;
if (next > max) next = max;
if (delta > 0 && totalAllocated.value + (next - cur) > props.availablePoints) {
return; // not enough points
}
const out = { ...props.allocations };
if (next === 0) delete out[tag];
else out[tag] = next;
emit('update:allocations', out);
}
function nodeClick(tag: string, e: MouseEvent) {
if (e.shiftKey || e.button === 2) add(tag, -1);
else add(tag, 1);
}
function onContext(tag: string, e: MouseEvent) {
e.preventDefault();
add(tag, -1);
}
</script>
<template>
<div>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
"
>
<div
style="
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-dim);
"
>
<span style="color: var(--sand)">{{ totalAllocated }}</span> /
{{ availablePoints }} skill points spent
</div>
<div
style="
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--ink-muted);
letter-spacing: 0.16em;
"
>
Click +1 · Shift-click / right-click 1
</div>
<button @click="emit('reset')">Reset Skills</button>
</div>
<div class="tree-wrap" v-if="tree">
<div class="tree-grid" :style="gridStyle">
<svg
class="tree-edges"
:viewBox="`0 0 ${svgSize.w} ${svgSize.h}`"
:width="svgSize.w"
:height="svgSize.h"
preserveAspectRatio="none"
>
<line
v-for="(ln, i) in edgeLines"
:key="i"
:x1="ln.x1"
:y1="ln.y1"
:x2="ln.x2"
:y2="ln.y2"
stroke="var(--line)"
stroke-width="2"
/>
</svg>
<div
v-for="n in tree.nodes"
:key="n.tag"
class="tree-node-wrap"
:style="{
gridColumn: n.col,
gridRow: n.row,
}"
>
<div
:class="[
'tree-node',
`kind-${n.kind.toLowerCase()}`,
pointsFor(n.tag) > 0 ? 'allocated' : '',
pointsFor(n.tag) >= n.maxPoints ? 'maxed' : '',
]"
@click="(e) => nodeClick(n.tag, e)"
@contextmenu="(e) => onContext(n.tag, e)"
:title="`${n.name} (${n.kind})`"
>
<div class="name">{{ n.name }}</div>
<div class="pts">{{ pointsFor(n.tag) }}/{{ n.maxPoints }}</div>
</div>
<div class="label">{{ n.kind }}</div>
</div>
</div>
</div>
<div v-else style="padding: 32px; color: var(--ink-muted)">
Loading skill tree
</div>
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { XpTable } from '../types';
const props = defineProps<{
title: string;
sym: string;
table: XpTable | null;
level: number;
xpInto: number;
maxLevel?: number;
}>();
const emit = defineEmits<{
'update:level': [n: number];
'update:xpInto': [n: number];
}>();
const maxLevel = computed(() => props.maxLevel ?? props.table?.rows.length ?? 0);
const nextRow = computed(() => {
if (!props.table) return null;
// XP required to go from L -> L+1 lives at table.rows[L] (1-indexed in source,
// but rows[0] is level 1). We treat current level L as already reached, so XP
// required for next level is rows[L].xpRequired (or 0 if at max).
const idx = props.level; // rows[idx].level = idx+1
return props.table.rows[idx] || null;
});
const cumulativeAtCurrent = computed(() => {
if (!props.table || props.level <= 0) return 0;
const r = props.table.rows[props.level - 1];
return r ? r.totalXp : 0;
});
const pct = computed(() => {
if (!nextRow.value || !nextRow.value.xpRequired) return 0;
const p = (props.xpInto / nextRow.value.xpRequired) * 100;
return Math.min(100, Math.max(0, p));
});
function onLevel(e: Event) {
const v = Number((e.target as HTMLInputElement).value) || 0;
emit('update:level', Math.max(0, Math.min(maxLevel.value, Math.floor(v))));
}
function onXpInto(e: Event) {
const v = Number((e.target as HTMLInputElement).value) || 0;
emit('update:xpInto', Math.max(0, Math.floor(v)));
}
function fmt(n: number): string {
return n.toLocaleString('en-US');
}
</script>
<template>
<div class="card">
<div class="sym">{{ sym }}</div>
<h3>{{ title }}</h3>
<div class="row">
<div class="field">
<label>Level</label>
<input
type="number"
min="0"
:max="maxLevel"
:value="level"
@input="onLevel"
/>
</div>
<div class="field">
<label>XP into next</label>
<input type="number" min="0" :value="xpInto" @input="onXpInto" />
</div>
</div>
<div class="progress" :style="{ '--pct': pct + '%' }"></div>
<div class="progress-meta">
<span>L{{ level }} L{{ level + 1 }}</span>
<span>
{{ fmt(xpInto) }} / {{ fmt(nextRow?.xpRequired || 0) }} XP
</span>
</div>
<div class="progress-meta" style="margin-top: 4px">
<span>Cumulative</span>
<span>{{ fmt(cumulativeAtCurrent + xpInto) }} XP</span>
</div>
</div>
</template>

View file

@ -0,0 +1,53 @@
import type {
ClassId,
FactionTable,
House,
SkillTree,
SpecId,
XpTable,
} from './types';
const API = '/api/data';
async function getJSON<T>(file: string): Promise<T> {
const res = await fetch(`${API}/${file}`);
if (!res.ok) throw new Error(`fetch ${file}: ${res.status}`);
return res.json() as Promise<T>;
}
export const SPECS: SpecId[] = [
'combat',
'crafting',
'exploration',
'gathering',
'sabotage',
];
export const CLASSES: { id: ClassId; name: string }[] = [
{ id: 'benegesserit', name: 'Bene Gesserit' },
{ id: 'mentat', name: 'Mentat' },
{ id: 'planetologist', name: 'Planetologist' },
{ id: 'swordmaster', name: 'Swordmaster' },
{ id: 'trooper', name: 'Trooper' },
];
export const HOUSES: { id: House; name: string }[] = [
{ id: 'atreides', name: 'House Atreides' },
{ id: 'harkonnen', name: 'House Harkonnen' },
];
export async function loadCharacterXp(): Promise<XpTable> {
return getJSON<XpTable>('character-xp.json');
}
export async function loadSpec(id: SpecId): Promise<XpTable> {
return getJSON<XpTable>(`spec-${id}.json`);
}
export async function loadFaction(house: House): Promise<FactionTable> {
return getJSON<FactionTable>(`faction-${house}.json`);
}
export async function loadSkillTree(id: ClassId): Promise<SkillTree> {
return getJSON<SkillTree>(`skills-${id}.json`);
}

View file

@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');

View file

@ -0,0 +1,86 @@
import { reactive, watch } from 'vue';
import type { BuildState, ClassId, House, SpecId } from './types';
const STORAGE_KEY = 'dune-builder-v1';
export function defaultBuild(): BuildState {
const specs: BuildState['specs'] = {
combat: { level: 0, xpInto: 0 },
crafting: { level: 0, xpInto: 0 },
exploration: { level: 0, xpInto: 0 },
gathering: { level: 0, xpInto: 0 },
sabotage: { level: 0, xpInto: 0 },
};
return {
v: 1,
house: 'atreides',
classId: 'swordmaster',
character: { level: 0, xpInto: 0 },
specs,
faction: { tier: 0, standingInto: 0 },
skills: {},
};
}
function migrate(raw: any): BuildState {
const def = defaultBuild();
if (!raw || typeof raw !== 'object') return def;
// Shallow merge with defaults; type-safe enough for v1.
const out: BuildState = {
...def,
...raw,
character: { ...def.character, ...(raw.character || {}) },
specs: { ...def.specs },
faction: { ...def.faction, ...(raw.faction || {}) },
skills: { ...(raw.skills || {}) },
};
if (raw.specs && typeof raw.specs === 'object') {
for (const k of Object.keys(out.specs) as SpecId[]) {
if (raw.specs[k]) out.specs[k] = { ...out.specs[k], ...raw.specs[k] };
}
}
return out;
}
function loadFromStorage(): BuildState {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return migrate(raw);
} catch {
return defaultBuild();
}
}
export const build = reactive<BuildState>(loadFromStorage());
watch(
build,
(val) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val));
} catch {
// ignore (quota, private mode, etc.)
}
},
{ deep: true },
);
export function applyBuild(next: BuildState) {
const merged = migrate(next);
Object.assign(build, merged);
}
export function resetBuild() {
applyBuild(defaultBuild());
}
export function setHouse(h: House) {
build.house = h;
// resetting faction progress when house changes is the safest default
build.faction = { tier: 0, standingInto: 0 };
}
export function setClass(c: ClassId) {
build.classId = c;
build.skills = {}; // skills are per-class
}

View file

@ -0,0 +1,546 @@
:root {
--bg: #1a1410;
--bg-2: #221a14;
--panel: #2b2018;
--panel-2: #352720;
--line: #4a3628;
--line-soft: #3a2a1f;
--ink: #f4e9d8;
--ink-dim: #c9b89c;
--ink-muted: #8a7560;
--sand: #e6c98a;
--sand-2: #d4a85a;
--ember: #c8643a;
--spice: #e08a3c;
--green: #8fb87a;
--atreides: #4a86c5;
--harkonnen: #a93832;
--shadow: 0 1px 0 rgba(255, 255, 255, 0.04),
0 12px 32px -16px rgba(0, 0, 0, 0.6);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
}
body {
font-family: 'Inter', system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
background: radial-gradient(
1200px 600px at 80% -10%,
rgba(224, 138, 60, 0.1),
transparent 60%
),
radial-gradient(
900px 500px at -10% 110%,
rgba(212, 168, 90, 0.06),
transparent 60%
),
var(--bg);
min-height: 100vh;
}
a { color: var(--sand); }
.wrap {
max-width: 1240px;
margin: 0 auto;
padding: 48px 28px 96px;
}
header.hero {
margin-bottom: 32px;
}
.eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--sand-2);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.eyebrow::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--line) 0%, transparent 100%);
}
h1 {
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
font-size: clamp(36px, 5vw, 56px);
line-height: 1;
margin: 0 0 14px;
letter-spacing: -0.01em;
}
h1 em {
font-style: italic;
color: var(--sand);
font-weight: 500;
}
.lede {
color: var(--ink-dim);
max-width: 640px;
font-size: 16px;
}
.panel {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 22px 26px;
margin-bottom: 22px;
box-shadow: var(--shadow);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--line-soft);
}
.panel-head h2 {
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
font-size: 26px;
margin: 0;
letter-spacing: 0.01em;
}
.panel-head .sub {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
}
.row-grid {
display: grid;
gap: 18px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.card {
background: var(--bg-2);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 18px 20px;
position: relative;
}
.card h3 {
font-family: 'Cormorant Garamond', serif;
font-size: 22px;
font-weight: 500;
margin: 0 0 12px;
}
.card .sym {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--ink-muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
.field input[type='number'],
.field select {
background: var(--bg);
border: 1px solid var(--line-soft);
color: var(--ink);
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
padding: 8px 12px;
border-radius: 3px;
width: 100%;
}
.field input[type='number']:focus,
.field select:focus {
outline: none;
border-color: var(--sand-2);
background: #1f1812;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.row.single {
grid-template-columns: 1fr;
}
.progress {
margin-top: 10px;
height: 4px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.progress::before {
content: '';
position: absolute;
inset: 0;
width: var(--pct, 0%);
background: linear-gradient(
90deg,
var(--ember) 0%,
var(--spice) 60%,
var(--sand) 100%
);
transition: width 0.25s ease;
}
.progress-meta {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--ink-muted);
margin-top: 6px;
letter-spacing: 0.1em;
}
.house-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.house-pick {
border: 1px solid var(--line-soft);
border-radius: 4px;
background: var(--bg-2);
padding: 18px 22px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
display: flex;
flex-direction: column;
gap: 6px;
}
.house-pick:hover {
border-color: var(--line);
}
.house-pick.active {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border-color: var(--sand-2);
}
.house-pick h3 {
font-family: 'Cormorant Garamond', serif;
font-size: 24px;
margin: 0;
font-weight: 500;
}
.house-pick.active.atreides h3 { color: var(--atreides); }
.house-pick.active.harkonnen h3 { color: var(--harkonnen); }
.house-pick .meta {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
.class-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.class-pick {
border: 1px solid var(--line-soft);
border-radius: 4px;
background: var(--bg-2);
padding: 14px 12px;
cursor: pointer;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-dim);
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.class-pick:hover {
color: var(--sand);
border-color: var(--line);
}
.class-pick.active {
color: var(--sand);
border-color: var(--sand-2);
background: rgba(224, 138, 60, 0.06);
}
/* Buttons */
button {
background: transparent;
border: 1px solid var(--line);
color: var(--ink-dim);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
button:hover {
border-color: var(--sand-2);
color: var(--sand);
background: rgba(224, 138, 60, 0.06);
}
button.primary {
background: var(--spice);
color: #1f1812;
border-color: var(--spice);
font-weight: 600;
}
button.primary:hover {
background: var(--sand);
border-color: var(--sand);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.totals {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
border: 1px solid var(--line-soft);
border-radius: 4px;
overflow: hidden;
background: var(--bg-2);
margin-bottom: 22px;
}
.total {
padding: 20px 22px;
border-right: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
}
.total:last-child { border-right: none; }
.total .lbl {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 6px;
}
.total .val {
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
font-size: 36px;
line-height: 1;
color: var(--sand);
}
.total .val .unit {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.15em;
margin-left: 6px;
}
/* Share bar */
.share-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(26, 20, 16, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line-soft);
padding: 12px 0;
margin: -48px 0 32px;
}
.share-inner {
max-width: 1240px;
margin: 0 auto;
padding: 0 28px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.share-inner .grow { flex: 1; }
.share-link {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--sand);
background: var(--bg-2);
border: 1px solid var(--line-soft);
border-radius: 3px;
padding: 6px 10px;
user-select: all;
word-break: break-all;
}
.toast {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--green);
}
/* Skill tree */
.tree-wrap {
position: relative;
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 24px;
overflow: auto;
min-height: 520px;
}
.tree-grid {
position: relative;
display: grid;
gap: 12px;
margin: 0 auto;
}
.tree-cell {
width: 96px;
height: 96px;
position: relative;
}
.tree-node {
width: 96px;
height: 96px;
border: 2px solid var(--line);
border-radius: 8px;
background: var(--bg-2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
}
.tree-node:hover { border-color: var(--sand-2); }
.tree-node.allocated { border-color: var(--spice); background: rgba(224, 138, 60, 0.1); }
.tree-node.maxed { border-color: var(--sand); background: rgba(230, 201, 138, 0.14); }
.tree-node.kind-ability { border-style: solid; }
.tree-node.kind-attribute { border-style: dashed; }
.tree-node.kind-perk { border-radius: 50%; }
.tree-node.kind-spice { border-style: double; }
.tree-node .label {
position: absolute;
bottom: -22px;
left: 50%;
transform: translateX(-50%);
font-family: 'JetBrains Mono', monospace;
font-size: 9.5px;
letter-spacing: 0.1em;
color: var(--ink-dim);
text-align: center;
width: 130px;
pointer-events: none;
text-shadow: 0 0 4px var(--bg);
}
.tree-node .pts {
position: absolute;
bottom: 4px;
right: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
background: rgba(0, 0, 0, 0.6);
padding: 1px 5px;
border-radius: 2px;
color: var(--sand);
}
.tree-node .name {
font-family: 'Cormorant Garamond', serif;
font-size: 14px;
text-align: center;
line-height: 1.1;
padding: 4px;
color: var(--ink-dim);
}
.tree-edges {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.tree-node-wrap { position: relative; z-index: 1; }
/* Faction tiers list */
.tier-list {
display: grid;
gap: 6px;
margin-top: 10px;
}
.tier-row {
display: grid;
grid-template-columns: 36px 1fr auto auto;
align-items: center;
gap: 12px;
padding: 8px 10px;
border: 1px solid var(--line-soft);
border-radius: 3px;
background: var(--bg-2);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--ink-dim);
}
.tier-row.reached { border-color: var(--sand-2); color: var(--sand); }
.tier-row.current { background: rgba(224, 138, 60, 0.08); border-color: var(--spice); }
.tier-row .num {
font-family: 'Cormorant Garamond', serif;
font-size: 18px;
color: var(--sand);
}
.footer-bar {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.1em;
border-top: 1px solid var(--line-soft);
padding-top: 18px;
margin-top: 40px;
gap: 14px;
flex-wrap: wrap;
}
@media (max-width: 720px) {
.wrap { padding: 32px 18px 64px; }
.house-grid { grid-template-columns: 1fr; }
.totals { grid-template-columns: 1fr; }
.total { border-right: none; border-bottom: 1px solid var(--line-soft); }
.total:last-child { border-bottom: none; }
}

View file

@ -0,0 +1,79 @@
export type House = 'atreides' | 'harkonnen';
export type ClassId =
| 'benegesserit'
| 'mentat'
| 'planetologist'
| 'swordmaster'
| 'trooper';
export type SpecId =
| 'combat'
| 'crafting'
| 'exploration'
| 'gathering'
| 'sabotage';
export interface XpRow {
level: number;
xpRequired: number;
totalXp: number;
skillPoints?: number;
totalSkillPoints?: number;
intelPoints?: number;
totalIntelPoints?: number;
}
export interface XpTable {
header: string[];
rows: XpRow[];
}
export interface FactionTier {
tier: number;
name: string;
standingRequired: number;
totalStanding: number;
}
export interface FactionTable {
header: string[];
tiers: FactionTier[];
}
export interface SkillNode {
tag: string;
id: string;
name: string;
kind: 'Ability' | 'Attribute' | 'Perk' | 'Spice' | string;
row: number;
col: number;
maxPoints: number;
icon: string | null;
url: string | null;
}
export interface SkillEdge {
from: string;
to: string;
}
export interface SkillTree {
id: ClassId;
name: string;
nodes: SkillNode[];
edges: SkillEdge[];
}
export interface SpecProgress {
level: number;
xpInto: number;
}
export interface BuildState {
v: 1; // schema version
house: House;
classId: ClassId;
character: { level: number; xpInto: number };
specs: Record<SpecId, SpecProgress>;
faction: { tier: number; standingInto: number };
skills: Record<string, number>; // tag -> allocated points
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
build: {
target: 'es2022',
outDir: 'dist',
emptyOutDir: true,
},
});

View file

@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Extract Dune Awakening game data from saved dune.gaming.tools HTML pages.
Outputs JSON files into ../data/:
- character-xp.json
- spec-{combat,crafting,exploration,gathering,sabotage}.json
- faction-{atreides,harkonnen}.json
- skills-{benegesserit,mentat,planetologist,swordmaster,trooper}.json
- index.json (manifest)
"""
import json
import re
from html.parser import HTMLParser
from pathlib import Path
SAMPLE = Path(__file__).resolve().parents[2] / "sample-data"
OUT = Path(__file__).resolve().parents[1] / "data"
OUT.mkdir(parents=True, exist_ok=True)
# ---------- generic <table class="datatable"> extractor ----------
class TableExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.in_table = False
self.in_row = False
self.in_cell = False
self.current_row = []
self.current_cell = ""
self.rows = []
self.header = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "table" and "datatable" in (attrs.get("class") or ""):
self.in_table = True
elif self.in_table and tag == "tr":
self.in_row = True
self.current_row = []
elif self.in_table and tag in ("td", "th"):
self.in_cell = True
self.current_cell = ""
def handle_endtag(self, tag):
if tag == "table":
self.in_table = False
elif tag == "tr" and self.in_row:
if self.current_row:
self.rows.append(self.current_row)
self.in_row = False
elif tag in ("td", "th") and self.in_cell:
self.current_row.append(self.current_cell.strip())
self.in_cell = False
def handle_data(self, data):
if self.in_cell:
self.current_cell += data
def parse_table(path: Path):
p = TableExtractor()
p.feed(path.read_text())
return p.rows
def to_int(s: str) -> int:
cleaned = re.sub(r"[^\d-]", "", s or "")
if not cleaned or cleaned == "-":
return 0
try:
return int(cleaned)
except ValueError:
return 0
def extract_xp_table(path: Path, value_keys: list[str]) -> list[dict]:
"""For tables shaped: [Level | XP Required | Total XP | ...].
value_keys names the columns after Level."""
rows = parse_table(path)
if not rows:
return []
header = rows[0]
out = []
for r in rows[1:]:
if not r or not r[0].strip():
continue
try:
lvl = to_int(r[0])
except Exception:
continue
entry = {"level": lvl}
for i, key in enumerate(value_keys, start=1):
if i < len(r):
entry[key] = to_int(r[i])
out.append(entry)
return {"header": header, "rows": out}
# ---------- skill tree extractor ----------
NODE_RE = re.compile(
r'<div\s+role="button"[^>]*class="node[^"]*"[^>]*data-tag="(?P<tag>Skills\.[^"]+)"[^>]*'
r'style="(?P<style>[^"]*)"[^>]*>'
r'(?P<inner>.*?)</div>\s*</div>',
re.DOTALL,
)
ALT_RE = re.compile(r'alt="([^"]+)"')
HREF_RE = re.compile(r'href="(https://dune\.gaming\.tools/skills/[^"]+)"')
ICON_RE = re.compile(r'src="\./[^"]*/(t_ui_icon[^"]+\.webp)"')
GRID_RE = re.compile(r"grid-area:\s*(\d+)\s*/\s*(\d+)")
MAX_PTS_RE = re.compile(r">0/(\d+)<")
# connector lines: parse SVG <line> within the tree container
LINE_RE = re.compile(
r'<line[^>]*\sx1="(?P<x1>\d+)"[^>]*\sy1="(?P<y1>\d+)"[^>]*\sx2="(?P<x2>\d+)"[^>]*\sy2="(?P<y2>\d+)"'
)
def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict:
html = path.read_text()
nodes = []
# We need to also find the alt text + href + icon WITHIN each node's HTML.
# Strategy: walk through all data-tag="Skills..." occurrences, slice from
# opening of the node <div> to a balanced close. Simple slice: take 2000 chars
# after the tag and parse first alt/href/icon/max within it.
for m in re.finditer(r'data-tag="(Skills\.[^"]+)"', html):
tag = m.group(1)
start = m.start()
chunk = html[start : start + 2500]
gm = GRID_RE.search(chunk)
if not gm:
continue
row, col = int(gm.group(1)), int(gm.group(2))
alt = ALT_RE.search(chunk)
href = HREF_RE.search(chunk)
icon = ICON_RE.search(chunk)
max_pts = MAX_PTS_RE.search(chunk)
kind = tag.split(".")[1] if "." in tag else "Unknown"
nodes.append(
{
"tag": tag,
"id": tag.split(".")[-1],
"name": alt.group(1) if alt else tag.split(".")[-1],
"kind": kind, # Ability | Attribute | Perk | Spice
"row": row,
"col": col,
"maxPoints": int(max_pts.group(1)) if max_pts else 1,
"icon": icon.group(1) if icon else None,
"url": href.group(1) if href else None,
}
)
# de-duplicate nodes by tag (the regex can match twice if the same tag appears in
# a connector tooltip etc.)
seen = {}
for n in nodes:
if n["tag"] not in seen:
seen[n["tag"]] = n
nodes = list(seen.values())
# Build a position->node lookup. Grid is roughly square with ~73px cells based
# on observed example (grid 3/5 -> center 364,220 means col*~73, row*~73 with offset).
# We'll learn cell size from the data: if there are connectors, we map each (x,y) to
# the nearest node by Euclidean distance.
# First compute approximate node centers via grid math, calibrated from any node
# we can pin: actually a more reliable approach is to use the connector geometry.
edges = []
lines = list(LINE_RE.finditer(html))
if lines and nodes:
# Calibrate: find scale by looking at min/max grid coords vs min/max line coords.
all_x = [int(x) for ln in lines for x in (ln.group("x1"), ln.group("x2"))]
all_y = [int(y) for ln in lines for y in (ln.group("y1"), ln.group("y2"))]
min_x, max_x = min(all_x), max(all_x)
min_y, max_y = min(all_y), max(all_y)
cols = [n["col"] for n in nodes]
rows = [n["row"] for n in nodes]
min_c, max_c = min(cols), max(cols)
min_r, max_r = min(rows), max(rows)
# avoid div by zero
sx = (max_x - min_x) / max(1, (max_c - min_c))
sy = (max_y - min_y) / max(1, (max_r - min_r))
def center(n):
return (
min_x + (n["col"] - min_c) * sx,
min_y + (n["row"] - min_r) * sy,
)
centers = {n["tag"]: center(n) for n in nodes}
def nearest(x, y):
best_tag, best_d = None, float("inf")
for t, (cx, cy) in centers.items():
d = (cx - x) ** 2 + (cy - y) ** 2
if d < best_d:
best_d = d
best_tag = t
return best_tag
seen_edges = set()
for ln in lines:
x1, y1, x2, y2 = (
int(ln.group("x1")),
int(ln.group("y1")),
int(ln.group("x2")),
int(ln.group("y2")),
)
a = nearest(x1, y1)
b = nearest(x2, y2)
if a and b and a != b:
key = tuple(sorted((a, b)))
if key not in seen_edges:
seen_edges.add(key)
edges.append({"from": key[0], "to": key[1]})
return {
"id": class_id,
"name": class_name,
"nodes": nodes,
"edges": edges,
}
# ---------- main ----------
def main():
manifest = {"xp": {}, "factions": {}, "skills": []}
# Character XP (200 levels, 6 value columns)
char_xp = extract_xp_table(
SAMPLE / "Character XP Table - Dune Awakening.html",
[
"xpRequired",
"totalXp",
"skillPoints",
"totalSkillPoints",
"intelPoints",
"totalIntelPoints",
],
)
(OUT / "character-xp.json").write_text(json.dumps(char_xp, indent=2))
manifest["xp"]["character"] = "character-xp.json"
# Specialization XP (5 tracks)
specs = ["Combat", "Crafting", "Exploration", "Gathering", "Sabotage"]
for spec in specs:
data = extract_xp_table(
SAMPLE / f"{spec} Track XP Table - Dune Awakening.html",
["xpRequired", "totalXp", "intelPoints", "totalIntelPoints"],
)
slug = spec.lower()
(OUT / f"spec-{slug}.json").write_text(json.dumps(data, indent=2))
manifest["xp"][slug] = f"spec-{slug}.json"
# Faction standing — different shape: Tier# | TierName | Required Rep | Cumulative
factions = [("Atreides", "atreides"), ("Harkonnen", "harkonnen")]
for fac_name, fac_id in factions:
rows = parse_table(
SAMPLE / f"House {fac_name} Faction Standing Table - Dune Awakening.html"
)
header = rows[0] if rows else []
tiers = []
for r in rows[1:]:
if not r or len(r) < 4:
continue
tiers.append(
{
"tier": to_int(r[0]),
"name": r[1].strip(),
"standingRequired": to_int(r[2]),
"totalStanding": to_int(r[3]),
}
)
(OUT / f"faction-{fac_id}.json").write_text(
json.dumps({"header": header, "tiers": tiers}, indent=2)
)
manifest["factions"][fac_id] = f"faction-{fac_id}.json"
# Skill trees
classes = [
("Bene Gesserit", "benegesserit"),
("Mentat", "mentat"),
("Planetologist", "planetologist"),
("Swordmaster", "swordmaster"),
("Trooper", "trooper"),
]
for cls_name, cls_id in classes:
path = SAMPLE / f"Dune Awakening Skill Builder - {cls_name}.html"
if not path.exists():
print(f"!! missing {path.name}")
continue
tree = extract_skill_tree(path, cls_id, cls_name)
(OUT / f"skills-{cls_id}.json").write_text(json.dumps(tree, indent=2))
manifest["skills"].append(
{
"id": cls_id,
"name": cls_name,
"file": f"skills-{cls_id}.json",
"nodes": len(tree["nodes"]),
"edges": len(tree["edges"]),
}
)
(OUT / "index.json").write_text(json.dumps(manifest, indent=2))
# Print summary
print("\n=== Extraction summary ===")
cx = json.loads((OUT / "character-xp.json").read_text())
print(f"character XP rows: {len(cx['rows'])} cols: {cx['header']}")
for spec in ["combat", "crafting", "exploration", "gathering", "sabotage"]:
d = json.loads((OUT / f"spec-{spec}.json").read_text())
print(f" spec {spec:11s} rows: {len(d['rows'])}")
for fac in ["atreides", "harkonnen"]:
d = json.loads((OUT / f"faction-{fac}.json").read_text())
print(f" faction {fac:9s} tiers: {len(d['tiers'])} cols: {d['header']}")
for s in manifest["skills"]:
print(f" skills {s['id']:14s} nodes: {s['nodes']:3d} edges: {s['edges']:3d}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,662 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lansraad Specialization Calculator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #1a1410;
--bg-2: #221a14;
--panel: #2b2018;
--panel-2: #352720;
--line: #4a3628;
--line-soft: #3a2a1f;
--ink: #f4e9d8;
--ink-dim: #c9b89c;
--ink-muted: #8a7560;
--sand: #e6c98a;
--sand-2: #d4a85a;
--ember: #c8643a;
--spice: #e08a3c;
--green: #8fb87a;
--shadow: 0 1px 0 rgba(255,255,255,0.04), 0 12px 32px -16px rgba(0,0,0,0.6);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); }
body {
font-family: "Inter", system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(224,138,60,0.10), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(212,168,90,0.06), transparent 60%),
var(--bg);
min-height: 100vh;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 56px 32px 96px; }
header.hero { margin-bottom: 40px; }
.eyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--sand-2);
margin-bottom: 14px;
display: flex; align-items: center; gap: 12px;
}
.eyebrow::after {
content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line) 0%, transparent 100%);
}
h1 {
font-family: "Cormorant Garamond", serif;
font-weight: 500;
font-size: clamp(40px, 5vw, 64px);
line-height: 1.0;
margin: 0 0 14px;
letter-spacing: -0.01em;
}
h1 em { font-style: italic; color: var(--sand); font-weight: 500; }
.lede {
color: var(--ink-dim);
max-width: 640px;
font-size: 16px;
}
/* Settings strip */
.settings {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 22px 26px;
margin: 36px 0 28px;
box-shadow: var(--shadow);
display: grid;
grid-template-columns: auto 1fr auto;
gap: 28px;
align-items: center;
}
.settings-title {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
border-right: 1px solid var(--line-soft);
padding-right: 28px;
white-space: nowrap;
}
.settings-fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
.field input[type="number"] {
background: var(--bg-2);
border: 1px solid var(--line-soft);
color: var(--ink);
font-family: "JetBrains Mono", monospace;
font-size: 16px;
padding: 10px 12px;
border-radius: 3px;
width: 100%;
transition: border-color .15s, background .15s;
}
.field input[type="number"]:focus {
outline: none;
border-color: var(--sand-2);
background: #1f1812;
}
/* Custom toggle */
.toggle-wrap {
display: flex; align-items: center; gap: 12px;
background: var(--bg-2);
border: 1px solid var(--line-soft);
border-radius: 3px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
height: 42px;
}
.toggle-wrap input { display: none; }
.toggle-pill {
width: 36px; height: 20px; border-radius: 999px;
background: #1a1310;
border: 1px solid var(--line);
position: relative;
transition: background .2s, border-color .2s;
}
.toggle-pill::after {
content: "";
position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--ink-muted);
transition: transform .2s, background .2s;
}
.toggle-wrap.on .toggle-pill {
background: rgba(224,138,60,0.18);
border-color: var(--spice);
}
.toggle-wrap.on .toggle-pill::after {
transform: translateX(16px);
background: var(--spice);
}
.toggle-label {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-dim);
}
.toggle-wrap.on .toggle-label { color: var(--sand); }
/* Totals */
.totals {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
margin: 28px 0 36px;
border: 1px solid var(--line-soft);
border-radius: 4px;
overflow: hidden;
background: var(--bg-2);
}
.total {
padding: 22px 26px;
border-right: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent);
}
.total:last-child { border-right: none; }
.total .lbl {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 8px;
}
.total .val {
font-family: "Cormorant Garamond", serif;
font-weight: 500;
font-size: 42px;
line-height: 1;
color: var(--sand);
}
.total .val .unit {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: var(--ink-muted);
letter-spacing: 0.15em;
margin-left: 8px;
vertical-align: 8px;
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 18px;
}
.card {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 22px 24px 20px;
box-shadow: var(--shadow);
position: relative;
transition: border-color .15s;
}
.card:hover { border-color: var(--line); }
.card.disabled { opacity: 0.55; }
.card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--line-soft);
}
.card-name {
font-family: "Cormorant Garamond", serif;
font-size: 26px;
font-weight: 500;
letter-spacing: 0.01em;
}
.card-sym {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--ink-muted);
text-transform: uppercase;
}
.card-include {
display: flex; align-items: center; gap: 8px;
cursor: pointer; user-select: none;
}
.card-include input { accent-color: var(--spice); }
.card-include span {
font-family: "JetBrains Mono", monospace;
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ink-muted);
}
.row {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
margin-bottom: 12px;
}
.row.single { grid-template-columns: 1fr; }
.row .field input[type="number"] { font-size: 15px; padding: 8px 10px; }
.results {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.result {
display: flex; flex-direction: column; gap: 4px;
}
.result .rl {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--ink-muted);
}
.result .rv {
font-family: "JetBrains Mono", monospace;
font-size: 18px;
color: var(--ink);
font-weight: 500;
}
.result .rv.warn { color: var(--ember); }
.result .rv.good { color: var(--green); }
.result .rv .ru { color: var(--ink-muted); font-size: 11px; margin-left: 3px; }
.progress {
margin-top: 14px;
height: 4px;
background: var(--bg-2);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.progress::before {
content: ""; position: absolute; inset: 0;
width: var(--pct, 0%);
background: linear-gradient(90deg, var(--ember) 0%, var(--spice) 60%, var(--sand) 100%);
transition: width .25s ease;
}
.progress-meta {
display: flex; justify-content: space-between;
font-family: "JetBrains Mono", monospace;
font-size: 10px; color: var(--ink-muted);
margin-top: 6px;
letter-spacing: 0.1em;
}
.reset-bar {
margin-top: 28px;
display: flex; justify-content: space-between; align-items: center;
color: var(--ink-muted);
font-family: "JetBrains Mono", monospace;
font-size: 11px; letter-spacing: 0.14em;
}
.reset-bar button {
background: transparent;
border: 1px solid var(--line);
color: var(--ink-dim);
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.reset-bar button:hover {
border-color: var(--sand-2);
color: var(--sand);
background: rgba(224,138,60,0.06);
}
footer.foot {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--line-soft);
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.1em;
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
}
/* Subtle decoration */
.corner {
position: absolute; width: 14px; height: 14px;
border: 1px solid var(--line);
}
.corner.tl { top: 8px; left: 8px; border-right: none; border-bottom: none; }
.corner.tr { top: 8px; right: 8px; border-left: none; border-bottom: none; }
.corner.bl { bottom: 8px; left: 8px; border-right: none; border-top: none; }
.corner.br { bottom: 8px; right: 8px; border-left: none; border-top: none; }
/* Spinner removal on number inputs */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number] { -moz-appearance: textfield; }
@media (max-width: 720px) {
.wrap { padding: 32px 18px 64px; }
.settings { grid-template-columns: 1fr; gap: 18px; }
.settings-title { border-right: none; border-bottom: 1px solid var(--line-soft); padding: 0 0 14px; }
.settings-fields { grid-template-columns: 1fr; }
.totals { grid-template-columns: 1fr; }
.total { border-right: none; border-bottom: 1px solid var(--line-soft); }
.total:last-child { border-bottom: none; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="eyebrow">Lansraad — Specialization Ledger</div>
<h1>Calculate the XP, quests, and days <em>to your next rank</em>.</h1>
<p class="lede">Plug in your current level for each specialization and where you want to be. The ledger handles the math — total XP needed, quests to clear it, and how many days that takes at your pace.</p>
</header>
<section class="settings" aria-label="Global settings">
<div class="settings-title">Pace<br/>&amp; Rate</div>
<div class="settings-fields">
<div class="field">
<label for="xpPerQuest">XP per Quest</label>
<input id="xpPerQuest" type="number" min="1" value="125" />
</div>
<div class="field">
<label for="questsPerDay">Quests per Day</label>
<input id="questsPerDay" type="number" min="1" value="5" />
</div>
<div class="field">
<label>&nbsp;</label>
<label class="toggle-wrap" id="dblToggle" for="doubleXp">
<input id="doubleXp" type="checkbox" />
<span class="toggle-pill"></span>
<span class="toggle-label">Double XP Weekend</span>
</label>
</div>
</div>
<div style="text-align:right;">
<div style="font-family: 'JetBrains Mono', monospace; font-size: 10.5px; letter-spacing: 0.2em; color: var(--ink-muted); text-transform: uppercase;">Effective</div>
<div style="font-family: 'Cormorant Garamond', serif; font-size: 32px; color: var(--sand); line-height: 1;"><span id="effectiveXp">125</span> <span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-muted); letter-spacing: 0.15em;">XP/Q</span></div>
</div>
</section>
<section class="totals" aria-label="Totals">
<div class="total">
<div class="lbl">Total XP Needed</div>
<div class="val"><span id="totalXp">0</span></div>
</div>
<div class="total">
<div class="lbl">Total Quests</div>
<div class="val"><span id="totalQuests">0</span></div>
</div>
<div class="total">
<div class="lbl">Total Days</div>
<div class="val"><span id="totalDays">0</span><span class="unit">@ pace</span></div>
</div>
</section>
<section class="grid" id="cards"></section>
<div class="reset-bar">
<div>Saved locally · refresh-safe</div>
<button id="resetBtn">Reset All</button>
</div>
<footer class="foot">
<div>Lansraad XP table · Levels 1100</div>
<div>Unofficial fan-made calculator · not affiliated with the game's publisher</div>
</footer>
</div>
<script>
// XP required to advance INTO each level (i.e. A[N] is XP to go from N-1 -> N, per the source sheet)
// Levels 1..100
const XP_TABLE = [
100,105,110,116,122,128,135,142,149,157,
164,171,178,186,194,202,211,220,229,239,
246,253,260,268,276,284,292,301,310,319,
326,334,342,350,358,366,375,384,393,402,
410,418,426,434,442,450,459,468,477,486,
494,502,510,518,526,534,543,552,561,570,
575,580,585,590,595,600,606,612,618,624,
624,624,624,624,624,624,624,624,624,624,
624,624,624,624,624,624,624,624,624,624,
624,624,624,624,624,624,624,624,624,624
];
const MAX_LEVEL = 100;
const SPECS = [
{ id: 'crafting', name: 'Crafting', sym: 'CRF', cur: 52, into: 66, goal: 66 },
{ id: 'gathering', name: 'Gathering', sym: 'GTH', cur: 22, into: 23, goal: 23 },
{ id: 'exploration', name: 'Exploration', sym: 'EXP', cur: 40, into: 41, goal: 41 },
{ id: 'combat', name: 'Combat', sym: 'CMB', cur: 50, into: 51, goal: 51 },
{ id: 'sabotage', name: 'Sabotage', sym: 'SAB', cur: 21, into: 27, goal: 27 }
];
const STORAGE_KEY = 'lansraad-calc-v1';
// Default state
const defaultState = () => ({
xpPerQuest: 125,
questsPerDay: 5,
doubleXp: false,
specs: SPECS.reduce((acc, s) => {
acc[s.id] = {
currentLevel: s.cur,
xpInto: 0,
desiredLevel: s.goal,
included: true
};
return acc;
}, {})
});
let state = defaultState();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (saved && saved.specs) state = { ...defaultState(), ...saved, specs: { ...defaultState().specs, ...saved.specs }};
} catch(e) {}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e) {}
}
// XP from current level (with partial XP into next) to desired level
// Logic from sheet: SUM(A[current+1 .. desired]) - xpInto
// i.e. you've already earned xpInto toward the next level
function calcXp(curLvl, xpInto, desiredLvl) {
curLvl = clamp(Math.floor(curLvl||0), 0, MAX_LEVEL);
desiredLvl = clamp(Math.floor(desiredLvl||0), 0, MAX_LEVEL);
xpInto = Math.max(0, Math.floor(xpInto||0));
if (desiredLvl <= curLvl) return 0;
let sum = 0;
for (let lvl = curLvl + 1; lvl <= desiredLvl; lvl++) {
sum += XP_TABLE[lvl - 1] || 0; // A[lvl] = XP_TABLE[lvl-1]
}
return Math.max(0, sum - xpInto);
}
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function effectiveXpPerQuest() {
const base = Math.max(1, Number(state.xpPerQuest) || 1);
return state.doubleXp ? base * 2 : base;
}
// Render
const cardsEl = document.getElementById('cards');
function render() {
// Settings inputs reflect state
document.getElementById('xpPerQuest').value = state.xpPerQuest;
document.getElementById('questsPerDay').value = state.questsPerDay;
document.getElementById('doubleXp').checked = state.doubleXp;
document.getElementById('dblToggle').classList.toggle('on', state.doubleXp);
document.getElementById('effectiveXp').textContent = fmt(effectiveXpPerQuest());
// Cards
cardsEl.innerHTML = SPECS.map(s => {
const st = state.specs[s.id];
const xp = calcXp(st.currentLevel, st.xpInto, st.desiredLevel);
const eff = effectiveXpPerQuest();
const quests = xp > 0 ? Math.ceil(xp / eff) : 0;
const qpd = Math.max(1, Number(state.questsPerDay) || 1);
const days = quests > 0 ? Math.ceil(quests / qpd) : 0;
// progress: where the player is within their current level
const nextLevelXp = XP_TABLE[clamp(st.currentLevel, 0, MAX_LEVEL-1)] || 0;
const pct = nextLevelXp > 0 ? clamp((st.xpInto / nextLevelXp) * 100, 0, 100) : 0;
const valid = st.desiredLevel > st.currentLevel;
const disabled = !st.included;
return `
<article class="card ${disabled ? 'disabled' : ''}" data-id="${s.id}">
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="card-head">
<div>
<div class="card-sym">${s.sym}</div>
<div class="card-name">${s.name}</div>
</div>
<label class="card-include">
<input type="checkbox" data-field="included" ${st.included ? 'checked' : ''} />
<span>${st.included ? 'Tracking' : 'Skipped'}</span>
</label>
</div>
<div class="row">
<div class="field">
<label>Current Level</label>
<input type="number" min="0" max="${MAX_LEVEL}" data-field="currentLevel" value="${st.currentLevel}" />
</div>
<div class="field">
<label>Desired Level</label>
<input type="number" min="0" max="${MAX_LEVEL}" data-field="desiredLevel" value="${st.desiredLevel}" />
</div>
</div>
<div class="row single">
<div class="field">
<label>XP into Next Level</label>
<input type="number" min="0" data-field="xpInto" value="${st.xpInto}" />
</div>
</div>
<div class="progress" style="--pct: ${pct}%"></div>
<div class="progress-meta">
<span>L${st.currentLevel} → L${st.currentLevel+1}</span>
<span>${fmt(st.xpInto)} / ${fmt(nextLevelXp)} XP</span>
</div>
<div class="results">
<div class="result">
<div class="rl">XP Needed</div>
<div class="rv ${!valid ? 'good' : ''}">${valid ? fmt(xp) : '—'}</div>
</div>
<div class="result">
<div class="rl">Quests</div>
<div class="rv">${valid ? fmt(quests) : '—'}</div>
</div>
<div class="result">
<div class="rl">Days</div>
<div class="rv">${valid ? fmt(days) : '—'}</div>
</div>
</div>
</article>
`;
}).join('');
// Totals (across included specs)
let totalXp = 0;
SPECS.forEach(s => {
const st = state.specs[s.id];
if (!st.included) return;
totalXp += calcXp(st.currentLevel, st.xpInto, st.desiredLevel);
});
const eff = effectiveXpPerQuest();
const totalQuests = totalXp > 0 ? Math.ceil(totalXp / eff) : 0;
const qpd = Math.max(1, Number(state.questsPerDay) || 1);
const totalDays = totalQuests > 0 ? Math.ceil(totalQuests / qpd) : 0;
document.getElementById('totalXp').textContent = fmt(totalXp);
document.getElementById('totalQuests').textContent = fmt(totalQuests);
document.getElementById('totalDays').textContent = fmt(totalDays);
}
function fmt(n) {
return Number(n||0).toLocaleString('en-US');
}
// Event delegation for card inputs
cardsEl.addEventListener('input', e => {
const t = e.target;
const field = t.dataset.field;
if (!field) return;
const card = t.closest('.card');
const id = card.dataset.id;
const st = state.specs[id];
if (field === 'included') {
st.included = t.checked;
} else {
let v = Number(t.value);
if (isNaN(v)) v = 0;
if (field === 'currentLevel' || field === 'desiredLevel') v = clamp(Math.floor(v), 0, MAX_LEVEL);
if (field === 'xpInto') v = Math.max(0, Math.floor(v));
st[field] = v;
}
save();
render();
});
cardsEl.addEventListener('change', e => {
if (e.target.dataset.field === 'included') {
// re-render to update label text
render();
}
});
// Settings handlers
document.getElementById('xpPerQuest').addEventListener('input', e => {
state.xpPerQuest = Math.max(1, Number(e.target.value) || 1);
save(); render();
});
document.getElementById('questsPerDay').addEventListener('input', e => {
state.questsPerDay = Math.max(1, Number(e.target.value) || 1);
save(); render();
});
document.getElementById('doubleXp').addEventListener('change', e => {
state.doubleXp = e.target.checked;
save(); render();
});
document.getElementById('resetBtn').addEventListener('click', () => {
state = defaultState();
// Zero out the starter goals so it's a clean slate
Object.values(state.specs).forEach(s => { s.currentLevel = 0; s.desiredLevel = 0; s.xpInto = 0; });
save(); render();
});
render();
</script>
</body>
</html>