Jekyll site for dnd.rigo.nu - Custom Varlyn rules and campaign documentation
🐳 Fully containerized development environment
# Start local development server
make serveSite will be available at: http://localhost:4000/dnd/
- Docker - The only requirement! No Ruby, Jekyll, or gems needed on your machine
Run make help to see all available commands.
make serve builds Docker image, mounts current directory, and starts Jekyll server on port 4000 with auto-reload.
- docs/ - Jekyll collections for content
_Campaigns/- Campaign documentation_Classes/- Character classes_Folk/- Races and peoples_Rules*/- Game rules organized by category
- assets/ - Static assets (CSS, JS, images)
- _data/ - Site data (characters, navigation, etc.)
- _includes/ - Reusable templates
- _layouts/ - Page layouts
- Dockerfile - Container configuration
- Gemfile - Ruby dependencies
- Create markdown file in appropriate collection (e.g.,
docs/_Classes/,docs/_Folk/,docs/_Campaigns/) - Add YAML frontmatter (see existing files)
- Update
_data/if adding characters/scenery - Run
make validate-profiles(classes) ormake lint-md(formatting)
Container won't start:
make clean # Clean everything
make build # Rebuild Docker image
make serve # Try againCommon Issues:
- Port in use:
make clean - Build failures:
docker psanddocker logs - Changes not reflected:
Ctrl+Candmake serve
Site deploys automatically to https://dnd.rigo.nu when pushing to main branch via GitHub Pages.
See tools/README.md for complete tool documentation and usage.
Each answer affects multiple traits with positive/negative values. Player scores are calculated as percentages and matched against class profile requirements.
Data Structure:
# _data/question-bank.yml
- id: healing-magic
text: "Do you want to heal and protect your allies?"
answers:
"yes":
healing-magic: +4 # Strong FOR healing
damage-magic: -2 # Moderate AGAINST damage
utility-magic: +1 # Slight toward utility
"no":
healing-magic: -2 # Against healing
damage-magic: +4 # Strong for damageScoring Algorithm:
-
Folk Selection (Optional):
- System scans all archetypes for
restriction.folkfields - If folk-restricted archetypes exist, user selects their folk/race (or skips)
- Only matching archetypes (plus unrestricted ones) will appear in results
# Example: Folk-restricted archetype path-of-the-battlerager: restriction: folk: ["dwarf"] # Can be single string or array traits: ["reckless-value", "heavy-armor", "unstoppable-force"]
- System scans all archetypes for
-
Accumulate scores as player answers:
traitScores = { 'healing-magic': { current: 0, min: 0, max: 0 }, 'damage-magic': { current: 0, min: 0, max: 0 }, // ... etc } // For each question, update all affected traits for (question in questions) { for (trait in question.answer[playerChoice]) { traitScores[trait].current += trait.value traitScores[trait].min += (min possible value this Q) traitScores[trait].max += (max possible value this Q) } }
-
Calculate alignment percentage:
percentage = (current - min) / (max - min) * 100 // Example: healing-magic // current: +6, min: -4, max: +10 // percentage = (6 - (-4)) / (10 - (-4)) = 10/14 = 71%
-
Match to class profiles:
# docs/_Classes/cleric.md profile: traits: ["religious-value", "divine-magic", "healing-magic", "protective-value"] archetypes: life-domain: traits: ["divine-healer"] light-domain: traits: ["holy-power", "divine-healer", "illuminating-light"]
Match score = Player's percentage in required traits
-
Filter by folk restrictions:
- During recommendation generation, archetypes are filtered by folk selection
- Archetypes with
restriction.folkonly appear if user's folk matches - Unrestricted archetypes always appear regardless of folk selection
- If user skipped folk selection, only unrestricted archetypes are shown
-
Filter by trait mismatch (default):
- Archetypes are evaluated for "trait mismatches" - cases where user scored <20% on required traits
- By default, only "strict matches" are shown (archetypes with no trait mismatches)
- If partial matches exist, users can toggle to show all recommendations including those with low-scoring traits
- This prevents suggesting archetypes that conflict with strongly negative user responses
Character Profile Display:
After completing the questionnaire, users see their personalized character profile organized into trait categories:
-
Magic Affinity (
*-magictraits) - Shows magical preferences and inclinations- Examples:
healing-magic,damage-magic,divine-magic,arcane-magic - Displayed as: "Healing 71%", "Damage 45%", "Divine 82%"
- Examples:
-
Background (
*-backgroundtraits) - Represents character origins and experience- Examples:
military-background,academic-background,tribal-background - Displayed as: "Military 68%", "Academic 54%"
- Examples:
-
Philosophy & Values (
*-valuetraits) - Core beliefs and worldview- Examples:
lawful-value,chaotic-value,protective-value,cunning-value - Displayed as: "Lawful 75%", "Protective 62%"
- Examples:
-
Key Traits (all other traits) - Combat styles, skills, and characteristics
- Examples:
weapon-master,shield-specialist,stealth-master
- Examples:
Implementation Files:
_data/question-bank.yml- Question definitions with trait scoringassets/js/questionnaire.js- Scoring engine and recommendation algorithm_layouts/questionnaire.html- Template that loads data and questionnaire.jsdocs/_Classes/*.md- Class profiles with trait requirements
Questions adapt to explore unexplored traits for top recommended classes. Starts random, then targets traits needed by lowest-ranked recommendations, ensuring all archetypes get fair evaluation.
The questionnaire system automatically includes new archetypes when they're properly structured in class files:
- Add archetype to class file - Follow existing YAML structure with traits array
- Define trait mappings - Use consistent trait names across archetypes
- Add folk restrictions (optional) - Use
restriction.folkfield if archetype is folk-specific - Validate schema - Run
make validate-profilesto check structure - Test scoring - Run
make test-class-scoringto verify recommendation logic - Update search - Run
make extractto include in searchable content
For trait naming and archetype patterns, reference existing class files in docs/_Classes/.
The Level Duration Matrix visualizes how many real-world days each campaign spent at each character level (1-20). Accounts for characters joining at different levels and fills gaps via interpolation.
1. Data Collection
For each campaign, filter characters by path (campaign number):
campaignChars = characterData.filter(c => c.path == campaign.nr)2. Per-Character Duration Calculation
For each character with valid start, end, startlevel, and maxlvl:
totalDays = daysBetween(start, end)
startLvl = startlevel
endLvl = maxlvl - maxlvl2 // Account for multiclassing
levelsGained = endLvl - startLvl
if (levelsGained >= 0 && totalDays > 0) {
// Include both start and end level (character played at both)
levelsExperienced = levelsGained + 1
daysPerLevel = totalDays / levelsExperienced
// Distribute days-per-level to all levels played (inclusive)
for (lvl = startLvl; lvl <= endLvl; lvl++) {
levelDurations[lvl] += daysPerLevel
levelCounts[lvl] += 1
}
}Example:
- Character played 100 days, started at level 3, reached level 8
- Levels gained: 8 - 3 = 5
- Levels experienced: 5 + 1 = 6 (played at levels 3, 4, 5, 6, 7, 8)
- Days per level: 100 / 6 = 16.7 days
- Contributes 16.7 days to levels 3, 4, 5, 6, 7, 8 4. Gap Interpolation
Characters often join campaigns at current level (e.g., new player joins level 11 campaign). This creates gaps where no characters played certain levels.
allLevels = sortedKeys(avgByLevel)
minLvl = allLevels[0]
maxLvl = allLevels[last]
for (lvl = minLvl; lvl <= maxLvl; lvl++) {
if (!avgByLevel[lvl]) {
// Find nearest levels with data
leftLvl = findNearestLeft(lvl, avgByLevel)
rightLvl = findNearestRight(lvl, avgByLevel)
if (leftLvl exists && rightLvl exists) {
// Interpolate between adjacent levels
avgByLevel[lvl] = round((avgByLevel[leftLvl] + avgByLevel[rightLvl]) / 2)
} else if (leftLvl exists) {
avgByLevel[lvl] = avgByLevel[leftLvl]
} else if (rightLvl exists) {
avgByLevel[lvl] = avgByLevel[rightLvl]
}
}
}Example Gap Fill:
- Level 9: 43 days (actual data)
- Level 10: missing (no characters)
- Level 11: 21 days (actual data)
- Interpolated: Level 10 = (43 + 21) / 2 = 32 days
5. Per-Row Color Scaling
Each campaign row uses its own min/max for color intensity:
campaignDays = values(campaign.levels).filter(d => d > 0)
minDays = min(campaignDays)
maxDays = max(campaignDays)
getColorIntensity(days) {
if (!days) return lightGray
normalized = (days - minDays) / (maxDays - minDays)
lightness = 85% - (normalized * 30%) // Range: 85% to 55%
return hsl(210, 80%, lightness)
}This ensures each campaign's internal variation is visible, even if absolute day counts differ significantly between campaigns.
Implementation Files:
assets/js/campaign-stats.js-renderLevelDurationMatrix()functionassets/js/campaign-data.js- Data fetching and caching from Google Sheetstools/statistics.html- Matrix container and styling

