feature: Initial code setup

This commit is contained in:
Keith Solomon
2026-03-15 10:26:20 -05:00
parent e052544989
commit e303373441
18 changed files with 3329 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
vite.config.js
vite.config.d.ts

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>2D6 Dungeon Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1848
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "d2d6-dungeon-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run check && vite build",
"preview": "vite preview",
"check": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}

75
src/App.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { sampleContentPack } from "@/data/sampleContentPack";
const planningDocs = [
"Planning/PROJECT_PLAN.md",
"Planning/GAME_SPEC.md",
"Planning/content-checklist.json",
"Planning/DATA_MODEL.md",
"Planning/IMPLEMENTATION_NOTES.md",
];
const nextTargets = [
"Encode Level 1 foundational tables into structured JSON.",
"Implement dice utilities for D3, D6, 2D6, and D66.",
"Create character creation state and validation.",
"Build deterministic room generation for the Level 1 loop.",
];
function App() {
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">2D6 Dungeon Web</p>
<h1>Project scaffold is live.</h1>
<p className="lede">
The app now has a Vite + React + TypeScript foundation, shared type
models, and Zod schemas that mirror the planning documents.
</p>
</section>
<section className="panel-grid">
<article className="panel">
<h2>Planning Set</h2>
<ul>
{planningDocs.map((doc) => (
<li key={doc}>{doc}</li>
))}
</ul>
</article>
<article className="panel">
<h2>Immediate Build Targets</h2>
<ol>
{nextTargets.map((target) => (
<li key={target}>{target}</li>
))}
</ol>
</article>
<article className="panel">
<h2>Sample Content Pack</h2>
<dl className="stats">
<div>
<dt>Tables</dt>
<dd>{sampleContentPack.tables.length}</dd>
</div>
<div>
<dt>Weapons</dt>
<dd>{sampleContentPack.weapons.length}</dd>
</div>
<div>
<dt>Manoeuvres</dt>
<dd>{sampleContentPack.manoeuvres.length}</dd>
</div>
<div>
<dt>Creatures</dt>
<dd>{sampleContentPack.creatures.length}</dd>
</div>
</dl>
</article>
</section>
</main>
);
}
export default App;

View File

@@ -0,0 +1,164 @@
import type { ContentPack } from "@/types/content";
import { contentPackSchema } from "@/schemas/content";
const samplePack = {
version: "0.1.0",
sourceBooks: [
"Source/2D6 Dungeon - Core Rules.pdf",
"Source/2D6 Dungeon - Tables Codex.pdf",
"Source/2D6 Dungeon - Play Sheet.pdf",
],
tables: [
{
id: "table.weapon-manoeuvres-1",
code: "WMT1",
name: "Weapon Manoeuvres Table 1",
category: "generic",
page: 13,
diceKind: "d6",
entries: [
{
key: "1",
exact: 1,
label: "Exact Strike",
references: [{ type: "manoeuvre", id: "manoeuvre.exact-strike" }],
},
],
notes: ["Starter sample table used to validate content wiring."],
mvp: true,
},
],
weapons: [
{
id: "weapon.short-sword",
name: "Short Sword",
category: "melee",
handedness: "one-handed",
baseDamage: 1,
allowedManoeuvreIds: ["manoeuvre.exact-strike", "manoeuvre.guard-break"],
tags: ["starter"],
startingOption: true,
},
],
manoeuvres: [
{
id: "manoeuvre.exact-strike",
name: "Exact Strike",
weaponCategories: ["melee"],
precisionModifier: 1,
exactStrikeBonus: true,
effectText: "Improves accuracy and benefits from exact strike bonuses.",
mvp: true,
},
{
id: "manoeuvre.guard-break",
name: "Guard Break",
weaponCategories: ["melee"],
shiftCost: 1,
damageModifier: 1,
effectText: "Trades shift for a stronger hit.",
mvp: true,
},
],
armour: [
{
id: "armour.leather-vest",
name: "Leather Vest",
armourValue: 1,
startingOption: true,
valueGp: 6,
mvp: true,
},
],
items: [
{
id: "item.ration",
name: "Ration",
itemType: "ration",
stackable: true,
consumable: true,
mvp: true,
},
{
id: "item.lantern",
name: "Lantern",
itemType: "light-source",
stackable: false,
consumable: false,
mvp: true,
},
],
potions: [
{
id: "potion.healing",
name: "Healing Potion",
tableSource: "Magic Potions Table 1",
useTiming: "any",
effects: [{ type: "heal", amount: 3, target: "self" }],
mvp: true,
},
],
scrolls: [
{
id: "scroll.lesser-heal",
name: "Lesser Heal",
tableSource: "Starting Scroll Table 1",
castCheck: {
diceKind: "d6",
successMin: 3,
},
onSuccess: [{ type: "heal", amount: 2, target: "self" }],
onFailureTableCode: "FTCCT1",
startingOption: true,
mvp: true,
},
],
creatures: [
{
id: "creature.level1.giant-rat",
name: "Giant Rat",
level: 1,
category: "beast",
hp: 2,
attackProfile: {
discipline: 0,
precision: 0,
damage: 1,
numberAppearing: "1-2",
},
xpReward: 1,
sourcePage: 102,
traits: ["level-1", "sample"],
mvp: true,
},
],
roomTemplates: [
{
id: "room.level1.entry",
level: 1,
roomClass: "start",
tableCode: "L1R",
tableEntryKey: "entry",
title: "Entry Chamber",
text: "A quiet starting space for the first dungeon prototype.",
dimensions: {
width: 4,
height: 4,
},
exits: [{ direction: "north", exitType: "door" }],
tags: ["starter", "entry"],
mvp: true,
},
],
townServices: [
{
id: "service.market",
name: "Market",
serviceType: "market",
mvp: true,
},
],
} satisfies ContentPack;
export const sampleContentPack = contentPackSchema.parse(samplePack);

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

203
src/schemas/content.ts Normal file
View File

@@ -0,0 +1,203 @@
import { z } from "zod";
import {
contentReferenceSchema,
diceKindSchema,
ruleEffectSchema,
} from "./rules";
export const tableEntrySchema = z.object({
key: z.string().min(1),
min: z.number().int().optional(),
max: z.number().int().optional(),
exact: z.number().int().optional(),
d66: z.number().int().optional(),
label: z.string().min(1),
text: z.string().optional(),
effects: z.array(ruleEffectSchema).optional(),
references: z.array(contentReferenceSchema).optional(),
});
export const tableDefinitionSchema = z.object({
id: z.string().min(1),
code: z.string().min(1),
name: z.string().min(1),
category: z.enum(["generic", "random-list", "loot", "level", "optional", "town", "room"]),
level: z.number().int().positive().optional(),
page: z.number().int().positive(),
diceKind: diceKindSchema,
usesModifiedRangesRule: z.boolean().optional(),
entries: z.array(tableEntrySchema),
notes: z.array(z.string()).optional(),
mvp: z.boolean(),
});
export const weaponDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
category: z.enum(["melee", "ranged"]),
handedness: z.enum(["one-handed", "two-handed"]),
baseDamage: z.number().int(),
allowedManoeuvreIds: z.array(z.string()),
tags: z.array(z.string()),
startingOption: z.boolean(),
});
export const manoeuvreDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
weaponCategories: z.array(z.enum(["melee", "ranged"])),
shiftCost: z.number().int().optional(),
disciplineModifier: z.number().int().optional(),
precisionModifier: z.number().int().optional(),
damageModifier: z.number().int().optional(),
exactStrikeBonus: z.boolean().optional(),
interruptRule: z.string().optional(),
effectText: z.string().optional(),
mvp: z.boolean(),
});
export const armourDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
armourValue: z.number().int(),
penalties: z
.object({
shift: z.number().int().optional(),
discipline: z.number().int().optional(),
precision: z.number().int().optional(),
})
.optional(),
deflectionRule: z.string().optional(),
startingOption: z.boolean(),
valueGp: z.number().int().optional(),
mvp: z.boolean(),
});
export const itemDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
itemType: z.enum([
"gear",
"treasure",
"quest",
"herb",
"rune",
"misc",
"ration",
"light-source",
]),
stackable: z.boolean(),
consumable: z.boolean(),
valueGp: z.number().int().optional(),
weight: z.number().optional(),
rulesText: z.string().optional(),
effects: z.array(ruleEffectSchema).optional(),
mvp: z.boolean(),
});
export const potionDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
tableSource: z.string().min(1),
useTiming: z.enum(["combat", "exploration", "town", "any"]),
effects: z.array(ruleEffectSchema),
valueGp: z.number().int().optional(),
mvp: z.boolean(),
});
export const scrollDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
tableSource: z.string().min(1),
castCheck: z
.object({
diceKind: z.enum(["d6", "2d6"]),
successMin: z.number().int().optional(),
successMax: z.number().int().optional(),
})
.optional(),
onSuccess: z.array(ruleEffectSchema),
onFailureTableCode: z.string().optional(),
valueGp: z.number().int().optional(),
startingOption: z.boolean(),
mvp: z.boolean(),
});
export const creatureDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
level: z.number().int().positive(),
category: z.string().min(1),
hp: z.number().int().positive(),
attackProfile: z.object({
discipline: z.number().int(),
precision: z.number().int(),
damage: z.number().int(),
numberAppearing: z.string().optional(),
}),
defenceProfile: z
.object({
armour: z.number().int().optional(),
specialRules: z.array(z.string()).optional(),
})
.optional(),
xpReward: z.number().int().optional(),
lootTableCodes: z.array(z.string()).optional(),
interruptRules: z.array(z.string()).optional(),
traits: z.array(z.string()).optional(),
sourcePage: z.number().int().positive(),
mvp: z.boolean(),
});
export const exitTemplateSchema = z.object({
direction: z.enum(["north", "east", "south", "west"]).optional(),
exitType: z.enum(["open", "door", "locked", "secret", "shaft", "stairs"]),
destinationLevel: z.number().int().optional(),
});
export const roomTemplateSchema = z.object({
id: z.string().min(1),
level: z.number().int().positive(),
roomClass: z.enum(["normal", "small", "large", "special", "start", "stairs"]),
tableCode: z.string().min(1),
tableEntryKey: z.string().min(1),
title: z.string().min(1),
text: z.string().optional(),
dimensions: z
.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
})
.optional(),
exits: z.array(exitTemplateSchema).optional(),
encounterRefs: z.array(contentReferenceSchema).optional(),
objectRefs: z.array(contentReferenceSchema).optional(),
tags: z.array(z.string()),
mvp: z.boolean(),
});
export const townServiceDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
serviceType: z.enum(["market", "temple", "tavern", "healer", "smith", "quest"]),
tableCodes: z.array(z.string()).optional(),
costRules: z.array(z.string()).optional(),
effects: z.array(ruleEffectSchema).optional(),
mvp: z.boolean(),
});
export const contentPackSchema = z.object({
version: z.string().min(1),
sourceBooks: z.array(z.string()),
tables: z.array(tableDefinitionSchema),
weapons: z.array(weaponDefinitionSchema),
manoeuvres: z.array(manoeuvreDefinitionSchema),
armour: z.array(armourDefinitionSchema),
items: z.array(itemDefinitionSchema),
potions: z.array(potionDefinitionSchema),
scrolls: z.array(scrollDefinitionSchema),
creatures: z.array(creatureDefinitionSchema),
roomTemplates: z.array(roomTemplateSchema),
townServices: z.array(townServiceDefinitionSchema),
});

71
src/schemas/rules.ts Normal file
View File

@@ -0,0 +1,71 @@
import { z } from "zod";
export const diceKindSchema = z.enum(["d3", "d6", "2d6", "d66"]);
export const contentReferenceTypeSchema = z.enum([
"table",
"weapon",
"manoeuvre",
"armour",
"item",
"potion",
"scroll",
"creature",
"room",
"service",
]);
export const contentReferenceSchema = z.object({
type: contentReferenceTypeSchema,
id: z.string().min(1),
});
export const ruleEffectSchema = z.object({
type: z.enum([
"gain-xp",
"gain-gold",
"heal",
"take-damage",
"modify-shift",
"modify-discipline",
"modify-precision",
"apply-status",
"remove-status",
"add-item",
"remove-item",
"start-combat",
"reveal-exit",
"move-level",
"log-only",
]),
amount: z.number().optional(),
statusId: z.string().optional(),
target: z.enum(["self", "enemy", "room", "campaign"]).optional(),
referenceId: z.string().optional(),
notes: z.string().optional(),
});
export const rollResultSchema = z.object({
diceKind: diceKindSchema,
rolls: z.array(z.number().int()),
primary: z.number().int().optional(),
secondary: z.number().int().optional(),
total: z.number().int().optional(),
modifier: z.number().int().optional(),
modifiedTotal: z.number().int().optional(),
clamped: z.boolean().optional(),
});
export const logEntrySchema = z.object({
id: z.string().min(1),
at: z.string().min(1),
type: z.enum(["system", "roll", "combat", "loot", "room", "town", "progression"]),
text: z.string().min(1),
relatedIds: z.array(z.string()).optional(),
});
export const actionResolutionSchema = z.object({
success: z.boolean(),
effects: z.array(ruleEffectSchema),
logEntries: z.array(logEntrySchema),
warnings: z.array(z.string()).optional(),
});

217
src/schemas/state.ts Normal file
View File

@@ -0,0 +1,217 @@
import { z } from "zod";
import { logEntrySchema, rollResultSchema, ruleEffectSchema } from "./rules";
export const statusInstanceSchema = z.object({
id: z.string().min(1),
source: z.string().optional(),
duration: z.enum(["round", "combat", "room", "run", "permanent"]).optional(),
value: z.number().optional(),
notes: z.string().optional(),
});
export const inventoryEntrySchema = z.object({
definitionId: z.string().min(1),
quantity: z.number().int().nonnegative(),
identified: z.boolean().optional(),
charges: z.number().int().optional(),
notes: z.string().optional(),
});
export const inventoryStateSchema = z.object({
carried: z.array(inventoryEntrySchema),
equipped: z.array(inventoryEntrySchema),
stored: z.array(inventoryEntrySchema),
currency: z.object({
gold: z.number().int().nonnegative(),
}),
rationCount: z.number().int().nonnegative(),
lightSources: z.array(inventoryEntrySchema),
});
export const adventurerStateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
level: z.number().int().positive(),
xp: z.number().int().nonnegative(),
hp: z.object({
current: z.number().int(),
max: z.number().int().positive(),
base: z.number().int().positive(),
}),
stats: z.object({
shift: z.number().int(),
discipline: z.number().int(),
precision: z.number().int(),
}),
weaponId: z.string().min(1),
manoeuvreIds: z.array(z.string()),
armourId: z.string().optional(),
favour: z.record(z.string(), z.number().int()),
statuses: z.array(statusInstanceSchema),
inventory: inventoryStateSchema,
progressionFlags: z.array(z.string()),
});
export const townStateSchema = z.object({
visits: z.number().int().nonnegative(),
knownServices: z.array(z.string()),
stash: z.array(inventoryEntrySchema),
pendingSales: z.array(inventoryEntrySchema),
serviceFlags: z.array(z.string()),
});
export const questStateSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
status: z.enum(["available", "active", "completed", "failed"]),
progressFlags: z.array(z.string()),
rewardText: z.string().optional(),
});
export const runSummarySchema = z.object({
runId: z.string().min(1),
startedAt: z.string().min(1),
endedAt: z.string().optional(),
deepestLevel: z.number().int().positive(),
roomsVisited: z.number().int().nonnegative(),
creaturesDefeated: z.array(z.string()),
xpGained: z.number().int().nonnegative(),
treasureValue: z.number().int().nonnegative(),
outcome: z.enum(["escaped", "defeated", "saved-in-progress"]),
});
export const campaignStateSchema = z.object({
id: z.string().min(1),
createdAt: z.string().min(1),
updatedAt: z.string().min(1),
rulesVersion: z.string().min(1),
contentVersion: z.string().min(1),
adventurer: adventurerStateSchema,
unlockedLevels: z.array(z.number().int().positive()),
completedLevels: z.array(z.number().int().positive()),
townState: townStateSchema,
questState: z.array(questStateSchema),
campaignFlags: z.array(z.string()),
runHistory: z.array(runSummarySchema),
});
export const roomExitStateSchema = z.object({
id: z.string().min(1),
direction: z.enum(["north", "east", "south", "west"]),
exitType: z.enum(["open", "door", "locked", "secret", "shaft", "stairs"]),
discovered: z.boolean(),
traversable: z.boolean(),
leadsToRoomId: z.string().optional(),
destinationLevel: z.number().int().optional(),
});
export const encounterStateSchema = z.object({
id: z.string().min(1),
sourceTableCode: z.string().optional(),
creatureIds: z.array(z.string()),
resolved: z.boolean(),
surprise: z.boolean().optional(),
rewardPending: z.boolean().optional(),
});
export const roomObjectStateSchema = z.object({
id: z.string().min(1),
objectType: z.enum(["container", "altar", "corpse", "hazard", "feature", "quest"]),
sourceTableCode: z.string().optional(),
interacted: z.boolean(),
hidden: z.boolean().optional(),
effects: z.array(ruleEffectSchema).optional(),
notes: z.string().optional(),
});
export const roomStateSchema = z.object({
id: z.string().min(1),
level: z.number().int().positive(),
templateId: z.string().optional(),
position: z.object({
x: z.number().int(),
y: z.number().int(),
}),
dimensions: z.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
}),
roomClass: z.enum(["normal", "small", "large", "special", "start", "stairs"]),
exits: z.array(roomExitStateSchema),
discovery: z.object({
generated: z.boolean(),
entered: z.boolean(),
cleared: z.boolean(),
searched: z.boolean(),
}),
encounter: encounterStateSchema.optional(),
objects: z.array(roomObjectStateSchema),
notes: z.array(z.string()),
flags: z.array(z.string()),
});
export const dungeonLevelStateSchema = z.object({
level: z.number().int().positive(),
themeName: z.string().optional(),
rooms: z.record(z.string(), roomStateSchema),
discoveredRoomOrder: z.array(z.string()),
stairsUpRoomId: z.string().optional(),
stairsDownRoomId: z.string().optional(),
secretDoorUsed: z.boolean().optional(),
exhaustedExitSearch: z.boolean().optional(),
});
export const dungeonStateSchema = z.object({
levels: z.record(z.string(), dungeonLevelStateSchema),
revealedPercentByLevel: z.record(z.string(), z.number()),
globalFlags: z.array(z.string()),
});
export const combatantStateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
sourceDefinitionId: z.string().optional(),
hpCurrent: z.number().int(),
hpMax: z.number().int().positive(),
shift: z.number().int(),
discipline: z.number().int(),
precision: z.number().int(),
armourValue: z.number().int().optional(),
statuses: z.array(statusInstanceSchema),
traits: z.array(z.string()),
});
export const interruptStateSchema = z.object({
source: z.enum(["player", "enemy"]),
trigger: z.string().min(1),
effectText: z.string().min(1),
resolved: z.boolean(),
});
export const combatStateSchema = z.object({
id: z.string().min(1),
round: z.number().int().positive(),
actingSide: z.enum(["player", "enemy"]),
fatigueDie: z.number().int().optional(),
player: combatantStateSchema,
enemies: z.array(combatantStateSchema),
selectedManoeuvreId: z.string().optional(),
lastRoll: rollResultSchema.optional(),
pendingInterrupt: interruptStateSchema.optional(),
combatLog: z.array(logEntrySchema),
});
export const runStateSchema = z.object({
id: z.string().min(1),
campaignId: z.string().min(1),
status: z.enum(["active", "paused", "completed", "failed"]),
startedAt: z.string().min(1),
currentLevel: z.number().int().positive(),
currentRoomId: z.string().optional(),
dungeon: dungeonStateSchema,
adventurerSnapshot: adventurerStateSchema,
activeCombat: combatStateSchema.optional(),
log: z.array(logEntrySchema),
pendingEffects: z.array(ruleEffectSchema),
});

130
src/styles.css Normal file
View File

@@ -0,0 +1,130 @@
:root {
font-family: "Segoe UI", "Aptos", sans-serif;
color: #f3f1e8;
background:
radial-gradient(circle at top, rgba(179, 121, 59, 0.35), transparent 30%),
linear-gradient(180deg, #17130f 0%, #0d0b09 100%);
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
.app-shell {
width: min(1100px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 4rem;
}
.hero {
padding: 2rem;
border: 1px solid rgba(243, 241, 232, 0.16);
background: rgba(21, 18, 14, 0.72);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #d8b27a;
font-size: 0.8rem;
}
.hero h1 {
margin: 0;
font-size: clamp(2.5rem, 7vw, 4.5rem);
line-height: 0.95;
}
.lede {
width: min(55ch, 100%);
margin: 1.25rem 0 0;
color: rgba(243, 241, 232, 0.82);
font-size: 1.05rem;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.panel {
padding: 1.25rem;
border: 1px solid rgba(243, 241, 232, 0.14);
background: rgba(29, 24, 19, 0.82);
}
.panel h2 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1rem;
color: #f6d49e;
}
.panel ul,
.panel ol {
margin: 0;
padding-left: 1.1rem;
}
.panel li + li {
margin-top: 0.45rem;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.stats div {
padding: 0.9rem;
background: rgba(243, 241, 232, 0.05);
}
.stats dt {
color: rgba(243, 241, 232, 0.62);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stats dd {
margin: 0.3rem 0 0;
font-size: 1.7rem;
font-weight: 700;
}
@media (max-width: 640px) {
.app-shell {
width: min(100% - 1rem, 1100px);
padding-top: 1rem;
}
.hero,
.panel {
padding: 1rem;
}
}

215
src/types/content.ts Normal file
View File

@@ -0,0 +1,215 @@
import type { ContentReference, DiceKind, RuleEffect } from "./rules";
export type TableCategory =
| "generic"
| "random-list"
| "loot"
| "level"
| "optional"
| "town"
| "room";
export type TableEntry = {
key: string;
min?: number;
max?: number;
exact?: number;
d66?: number;
label: string;
text?: string;
effects?: RuleEffect[];
references?: ContentReference[];
};
export type TableDefinition = {
id: string;
code: string;
name: string;
category: TableCategory;
level?: number;
page: number;
diceKind: DiceKind;
usesModifiedRangesRule?: boolean;
entries: TableEntry[];
notes?: string[];
mvp: boolean;
};
export type WeaponCategory = "melee" | "ranged";
export type WeaponDefinition = {
id: string;
name: string;
category: WeaponCategory;
handedness: "one-handed" | "two-handed";
baseDamage: number;
allowedManoeuvreIds: string[];
tags: string[];
startingOption: boolean;
};
export type ManoeuvreDefinition = {
id: string;
name: string;
weaponCategories: WeaponCategory[];
shiftCost?: number;
disciplineModifier?: number;
precisionModifier?: number;
damageModifier?: number;
exactStrikeBonus?: boolean;
interruptRule?: string;
effectText?: string;
mvp: boolean;
};
export type ArmourDefinition = {
id: string;
name: string;
armourValue: number;
penalties?: {
shift?: number;
discipline?: number;
precision?: number;
};
deflectionRule?: string;
startingOption: boolean;
valueGp?: number;
mvp: boolean;
};
export type ItemType =
| "gear"
| "treasure"
| "quest"
| "herb"
| "rune"
| "misc"
| "ration"
| "light-source";
export type ItemDefinition = {
id: string;
name: string;
itemType: ItemType;
stackable: boolean;
consumable: boolean;
valueGp?: number;
weight?: number;
rulesText?: string;
effects?: RuleEffect[];
mvp: boolean;
};
export type PotionUseTiming = "combat" | "exploration" | "town" | "any";
export type PotionDefinition = {
id: string;
name: string;
tableSource: string;
useTiming: PotionUseTiming;
effects: RuleEffect[];
valueGp?: number;
mvp: boolean;
};
export type ScrollDefinition = {
id: string;
name: string;
tableSource: string;
castCheck?: {
diceKind: Extract<DiceKind, "d6" | "2d6">;
successMin?: number;
successMax?: number;
};
onSuccess: RuleEffect[];
onFailureTableCode?: string;
valueGp?: number;
startingOption: boolean;
mvp: boolean;
};
export type CreatureDefinition = {
id: string;
name: string;
level: number;
category: string;
hp: number;
attackProfile: {
discipline: number;
precision: number;
damage: number;
numberAppearing?: string;
};
defenceProfile?: {
armour?: number;
specialRules?: string[];
};
xpReward?: number;
lootTableCodes?: string[];
interruptRules?: string[];
traits?: string[];
sourcePage: number;
mvp: boolean;
};
export type ExitType = "open" | "door" | "locked" | "secret" | "shaft" | "stairs";
export type ExitTemplate = {
direction?: "north" | "east" | "south" | "west";
exitType: ExitType;
destinationLevel?: number;
};
export type RoomClass = "normal" | "small" | "large" | "special" | "start" | "stairs";
export type RoomTemplate = {
id: string;
level: number;
roomClass: RoomClass;
tableCode: string;
tableEntryKey: string;
title: string;
text?: string;
dimensions?: {
width: number;
height: number;
};
exits?: ExitTemplate[];
encounterRefs?: ContentReference[];
objectRefs?: ContentReference[];
tags: string[];
mvp: boolean;
};
export type TownServiceType =
| "market"
| "temple"
| "tavern"
| "healer"
| "smith"
| "quest";
export type TownServiceDefinition = {
id: string;
name: string;
serviceType: TownServiceType;
tableCodes?: string[];
costRules?: string[];
effects?: RuleEffect[];
mvp: boolean;
};
export type ContentPack = {
version: string;
sourceBooks: string[];
tables: TableDefinition[];
weapons: WeaponDefinition[];
manoeuvres: ManoeuvreDefinition[];
armour: ArmourDefinition[];
items: ItemDefinition[];
potions: PotionDefinition[];
scrolls: ScrollDefinition[];
creatures: CreatureDefinition[];
roomTemplates: RoomTemplate[];
townServices: TownServiceDefinition[];
};

81
src/types/rules.ts Normal file
View File

@@ -0,0 +1,81 @@
export type DiceKind = "d3" | "d6" | "2d6" | "d66";
export type ContentReferenceType =
| "table"
| "weapon"
| "manoeuvre"
| "armour"
| "item"
| "potion"
| "scroll"
| "creature"
| "room"
| "service";
export type ContentReference = {
type: ContentReferenceType;
id: string;
};
export type RuleEffectType =
| "gain-xp"
| "gain-gold"
| "heal"
| "take-damage"
| "modify-shift"
| "modify-discipline"
| "modify-precision"
| "apply-status"
| "remove-status"
| "add-item"
| "remove-item"
| "start-combat"
| "reveal-exit"
| "move-level"
| "log-only";
export type RuleEffectTarget = "self" | "enemy" | "room" | "campaign";
export type RuleEffect = {
type: RuleEffectType;
amount?: number;
statusId?: string;
target?: RuleEffectTarget;
referenceId?: string;
notes?: string;
};
export type RollResult = {
diceKind: DiceKind;
rolls: number[];
primary?: number;
secondary?: number;
total?: number;
modifier?: number;
modifiedTotal?: number;
clamped?: boolean;
};
export type LogEntryType =
| "system"
| "roll"
| "combat"
| "loot"
| "room"
| "town"
| "progression";
export type LogEntry = {
id: string;
at: string;
type: LogEntryType;
text: string;
relatedIds?: string[];
};
export type ActionResolution = {
success: boolean;
effects: RuleEffect[];
logEntries: LogEntry[];
warnings?: string[];
};

218
src/types/state.ts Normal file
View File

@@ -0,0 +1,218 @@
import type { ExitType, RoomClass } from "./content";
import type { LogEntry, RollResult, RuleEffect } from "./rules";
export type StatusDuration = "round" | "combat" | "room" | "run" | "permanent";
export type StatusInstance = {
id: string;
source?: string;
duration?: StatusDuration;
value?: number;
notes?: string;
};
export type InventoryEntry = {
definitionId: string;
quantity: number;
identified?: boolean;
charges?: number;
notes?: string;
};
export type InventoryState = {
carried: InventoryEntry[];
equipped: InventoryEntry[];
stored: InventoryEntry[];
currency: {
gold: number;
};
rationCount: number;
lightSources: InventoryEntry[];
};
export type AdventurerState = {
id: string;
name: string;
level: number;
xp: number;
hp: {
current: number;
max: number;
base: number;
};
stats: {
shift: number;
discipline: number;
precision: number;
};
weaponId: string;
manoeuvreIds: string[];
armourId?: string;
favour: Record<string, number>;
statuses: StatusInstance[];
inventory: InventoryState;
progressionFlags: string[];
};
export type TownState = {
visits: number;
knownServices: string[];
stash: InventoryEntry[];
pendingSales: InventoryEntry[];
serviceFlags: string[];
};
export type QuestState = {
id: string;
title: string;
status: "available" | "active" | "completed" | "failed";
progressFlags: string[];
rewardText?: string;
};
export type RunSummary = {
runId: string;
startedAt: string;
endedAt?: string;
deepestLevel: number;
roomsVisited: number;
creaturesDefeated: string[];
xpGained: number;
treasureValue: number;
outcome: "escaped" | "defeated" | "saved-in-progress";
};
export type CampaignState = {
id: string;
createdAt: string;
updatedAt: string;
rulesVersion: string;
contentVersion: string;
adventurer: AdventurerState;
unlockedLevels: number[];
completedLevels: number[];
townState: TownState;
questState: QuestState[];
campaignFlags: string[];
runHistory: RunSummary[];
};
export type RoomExitState = {
id: string;
direction: "north" | "east" | "south" | "west";
exitType: ExitType;
discovered: boolean;
traversable: boolean;
leadsToRoomId?: string;
destinationLevel?: number;
};
export type EncounterState = {
id: string;
sourceTableCode?: string;
creatureIds: string[];
resolved: boolean;
surprise?: boolean;
rewardPending?: boolean;
};
export type RoomObjectState = {
id: string;
objectType: "container" | "altar" | "corpse" | "hazard" | "feature" | "quest";
sourceTableCode?: string;
interacted: boolean;
hidden?: boolean;
effects?: RuleEffect[];
notes?: string;
};
export type RoomState = {
id: string;
level: number;
templateId?: string;
position: {
x: number;
y: number;
};
dimensions: {
width: number;
height: number;
};
roomClass: RoomClass;
exits: RoomExitState[];
discovery: {
generated: boolean;
entered: boolean;
cleared: boolean;
searched: boolean;
};
encounter?: EncounterState;
objects: RoomObjectState[];
notes: string[];
flags: string[];
};
export type DungeonLevelState = {
level: number;
themeName?: string;
rooms: Record<string, RoomState>;
discoveredRoomOrder: string[];
stairsUpRoomId?: string;
stairsDownRoomId?: string;
secretDoorUsed?: boolean;
exhaustedExitSearch?: boolean;
};
export type DungeonState = {
levels: Record<number, DungeonLevelState>;
revealedPercentByLevel: Record<number, number>;
globalFlags: string[];
};
export type CombatantState = {
id: string;
name: string;
sourceDefinitionId?: string;
hpCurrent: number;
hpMax: number;
shift: number;
discipline: number;
precision: number;
armourValue?: number;
statuses: StatusInstance[];
traits: string[];
};
export type InterruptState = {
source: "player" | "enemy";
trigger: string;
effectText: string;
resolved: boolean;
};
export type CombatState = {
id: string;
round: number;
actingSide: "player" | "enemy";
fatigueDie?: number;
player: CombatantState;
enemies: CombatantState[];
selectedManoeuvreId?: string;
lastRoll?: RollResult;
pendingInterrupt?: InterruptState;
combatLog: LogEntry[];
};
export type RunState = {
id: string;
campaignId: string;
status: "active" | "paused" | "completed" | "failed";
startedAt: string;
currentLevel: number;
currentRoomId?: string;
dungeon: DungeonState;
adventurerSnapshot: AdventurerState;
activeCombat?: CombatState;
log: LogEntry[];
pendingEffects: RuleEffect[];
};

24
tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

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

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});