Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
fa2656d
fix: replace SRD 5.1 references with SRD 2024 across docs, specs, and…
yanekyuk Apr 26, 2026
d4cf87f
fix(engine): require Advantage or adjacent ally for Sneak Attack
yanekyuk Apr 26, 2026
ae16a7c
fix(engine): apply death-save failures on incoming damage at 0 HP
yanekyuk Apr 26, 2026
ba0d033
Merge pull request #321 from yanekyuk/fix/sneak-attack-conditions
yanekyuk Apr 26, 2026
27358f5
Merge pull request #322 from yanekyuk/worktree-agent-a4a799102fe19f832
yanekyuk Apr 26, 2026
1ea5c9b
feat: add Npc::display_name() with disposition/role tag
yanekyuk Apr 27, 2026
141200a
feat: inspect() header line shows disposition tag via display_name()
yanekyuk Apr 27, 2026
5592029
feat: show disposition tags in room entry and look narration
yanekyuk Apr 27, 2026
a1f77db
feat(engine): surface advantage/disadvantage reason inline in combat …
yanekyuk Apr 27, 2026
082225a
chore: bump version to 0.46.0
yanekyuk Apr 27, 2026
45c9c69
chore: bump version to 0.46.0
yanekyuk Apr 27, 2026
1e63c12
Merge remote-tracking branch 'origin/next' into feat/adv-disadv-reaso…
yanekyuk Apr 27, 2026
06ea922
Merge remote-tracking branch 'origin/next' into feat/npc-hostility-in…
yanekyuk Apr 27, 2026
1dcca5e
feat(engine): update take-all output format and add coverage tests
yanekyuk Apr 27, 2026
bb367ed
feat(parser): add sleep, camp, nap as rest command aliases
yanekyuk Apr 27, 2026
5f92619
feat: add buffs/conditions/effects command to parser and wire to orch…
yanekyuk Apr 27, 2026
790071d
test: add non-vacuous examine-npc integration tests
yanekyuk Apr 27, 2026
c73047a
feat: render active effects in buffs command and combat status line
yanekyuk Apr 27, 2026
f1918b4
feat: show active effects in player turn prompt during combat
yanekyuk Apr 27, 2026
0e3470b
chore: bump version to 0.47.0
yanekyuk Apr 27, 2026
905810d
chore: resolve merge conflict — keep version 0.47.0
yanekyuk Apr 27, 2026
69a0e57
chore: bump version to 0.47.0
yanekyuk Apr 27, 2026
58dedbd
Merge pull request #326 from yanekyuk/worktree-agent-a176671bef01c6f18
yanekyuk Apr 27, 2026
0a12e4a
chore: bump version to 0.47.0
yanekyuk Apr 27, 2026
20762b3
Merge pull request #327 from yanekyuk/feat/examine-npc-command
yanekyuk Apr 27, 2026
d5ab41d
chore: bump version to 0.47.0
yanekyuk Apr 27, 2026
0e6b63b
chore: resolve merge conflict — keep active effects tests and next's …
yanekyuk Apr 27, 2026
409deb9
feat: add 'trade' as alias for Browse command
yanekyuk Apr 27, 2026
be165cd
feat: add inventory field to Npc struct for merchant stock tracking
yanekyuk Apr 27, 2026
938680d
feat: wire merchant inventory generation into world generation
yanekyuk Apr 27, 2026
db43f0c
fix(engine): spell cast targeting — no-"at" syntax and two-step promp…
yanekyuk Apr 27, 2026
0d7adbf
chore: bump version to 0.47.1
yanekyuk Apr 27, 2026
c114dfa
fix(engine): apply hostile-within-5ft disadvantage to ranged spell at…
yanekyuk Apr 27, 2026
b42b7fb
chore: bump version to 0.47.2
yanekyuk Apr 27, 2026
9fc9b99
fix(engine): exclude non-combatant NPCs from Sneak Attack ally check
yanekyuk Apr 27, 2026
2ff0697
fix: resolve rebase conflicts and update test fixtures for has_friend…
yanekyuk Apr 27, 2026
6dab76f
Merge pull request #335 from yanekyuk/worktree-agent-a470677d667dbae34
yanekyuk Apr 27, 2026
5c0643d
chore: add v0.45.2 playtest report and fix Npc test fixture
yanekyuk Apr 28, 2026
f1e05c1
fix(engine): apply Barbarian Rage damage resistance (halve BPS damage…
yanekyuk Apr 28, 2026
f1c7aa1
fix(engine): apply Barbarian Rage damage bonus (+2 to STR melee while…
yanekyuk Apr 28, 2026
1257a63
Merge pull request #350 from yanekyuk/fix/rage-damage-bonus
yanekyuk Apr 28, 2026
8e81d00
fix(engine): apply Rogue Expertise (double proficiency bonus for two …
yanekyuk Apr 28, 2026
e1d5d47
feat(engine): Rogue Cunning Action — Dash/Disengage/Hide as bonus act…
yanekyuk Apr 28, 2026
e1c32a3
Merge pull request #352 from yanekyuk/feat/rogue-cunning-action
yanekyuk Apr 28, 2026
50e3347
fix(engine): resolve skill_check arity errors blocking compilation on…
yanekyuk Apr 28, 2026
25142e4
fix(engine): show dying-state prompt and communicate death save rolls…
yanekyuk Apr 28, 2026
8cf4893
feat(engine): add skip as end-turn alias and document wait/pass/skip
yanekyuk Apr 28, 2026
cb3f5d3
fix(engine): add NPC aggro indicator before combat begins on room entry
yanekyuk Apr 28, 2026
965e004
Merge pull request #356 from yanekyuk/fix/npc-aggro-indicator
yanekyuk Apr 28, 2026
1fb05de
fix(engine): add hp/health command aliases for compact health display
yanekyuk Apr 28, 2026
808d965
feat(engine): add drop all / drop everything bulk item command
yanekyuk Apr 28, 2026
1b2285d
Merge pull request #358 from yanekyuk/feat/bulk-item-commands
yanekyuk Apr 28, 2026
dccefa8
fix(engine): show remaining/overspent budget in point-buy error messages
yanekyuk Apr 28, 2026
4545d2d
fix(engine): remove doubled definite article in Hermit dialogue template
yanekyuk Apr 28, 2026
b90ad3c
Merge pull request #360 from yanekyuk/fix/npc-dialogue-doubled-article
yanekyuk Apr 28, 2026
8b2e6a9
fix(engine): show wait narration when main action unused regardless o…
yanekyuk Apr 28, 2026
63dacd6
fix(engine): emit "You fall unconscious!" immediately when player HP …
yanekyuk Apr 28, 2026
217c1be
Merge pull request #364 from yanekyuk/fix/unconscious-immediate-narra…
yanekyuk Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "jurnalis-engine"
version = "0.45.2"
version = "0.50.0"
edition = "2021"

[lib]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
</div>

A stateless, deterministic text-based CRPG engine implementing SRD 5.1 (d20) mechanics. The engine is a standalone Rust library crate — it carries no server, no persistent state, and no runtime dependencies beyond `serde` and `rand`. Embed it in any application or run it directly via the included `jurnalis-cli` binary.
A stateless, deterministic text-based CRPG engine implementing SRD 2024 (d20) mechanics. The engine is a standalone Rust library crate — it carries no server, no persistent state, and no runtime dependencies beyond `serde` and `rand`. Embed it in any application or run it directly via the included `jurnalis-cli` binary.

All game state is serialized to JSON and owned by the caller. On every call the caller passes the current state in; the engine returns the updated state alongside text output. This makes the engine trivially embeddable in web backends, desktop apps, and test harnesses alike.

---

## Features

- **Full SRD 5.1 mechanics** — d20 ability checks, saving throws, skill checks, initiative, combat turns with attack rolls and damage, spell slots, short and long rests, conditions, and AC calculations.
- **Full SRD 2024 mechanics** — d20 ability checks, saving throws, skill checks, initiative, combat turns with attack rolls and damage, spell slots, short and long rests, conditions, and AC calculations.
- **12 character classes** — Barbarian, Bard, Cleric, Druid, Fighter, Monk, Paladin, Ranger, Rogue, Sorcerer, Warlock, Wizard.
- **3 playable races** — Human, Elf, Dwarf, each with accurate racial trait bonuses.
- **Stateless design** — the engine holds zero mutable state. State is serialized to JSON after every call and passed back in on the next. Safe to use across threads, processes, or network boundaries.
Expand Down
4 changes: 2 additions & 2 deletions src/character/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pub struct ClassFeatureState {
// ---- Concentration (spells) ----
/// Name of the spell the character is currently concentrating on, or
/// `None`. Starting a new concentration spell drops the previous one
/// (per SRD 5.1: a caster can only concentrate on one spell at a time).
/// (per SRD 2024: a caster can only concentrate on one spell at a time).
/// Also cleared when the caster fails a concentration save or drops
/// the spell deliberately.
#[serde(default)]
Expand Down Expand Up @@ -410,7 +410,7 @@ impl Class {
}

impl Class {
/// Starting tool proficiencies granted by the class at level 1 per SRD 5.1.
/// Starting tool proficiencies granted by the class at level 1 per SRD 2024.
/// Returns an empty slice for classes with no class-granted tool proficiencies
/// (background-granted tools are handled separately in `lib.rs`).
pub fn starting_tool_proficiencies(&self) -> &'static [ToolProficiency] {
Expand Down
4 changes: 2 additions & 2 deletions src/character/feat.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// jurnalis-engine/src/character/feat.rs
//
// SRD 5.1 feat catalog. Compile-time const table in the same style as
// SRD 2024 feat catalog. Compile-time const table in the same style as
// `equipment::SRD_WEAPONS` / `SRD_ARMOR` (see
// `docs/decisions/srd-const-tables.md`).
//
Expand Down Expand Up @@ -66,7 +66,7 @@ impl FeatDef {
}
}

/// SRD 5.1 feat catalog (origin + general + fighting-style).
/// SRD 2024 feat catalog (origin + general + fighting-style).
///
/// Effects marked `Flavor` are selectable but their mechanical hooks (combat
/// damage toggles, reaction attacks, concentration advantage, etc.) land in
Expand Down
100 changes: 94 additions & 6 deletions src/character/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub struct Character {
/// Per-class feature flags tracking short-rest / long-rest resources.
#[serde(default)]
pub class_features: ClassFeatureState,
/// Exhaustion level (0..=6 per SRD 5.1). Long rest reduces by 1.
/// Exhaustion level (0..=6 per SRD 2024). Long rest reduces by 1.
#[serde(default)]
pub exhaustion: u32,
/// Total accumulated experience points. Drives level advancement
Expand All @@ -71,7 +71,7 @@ pub struct Character {
#[serde(default)]
pub languages: Vec<String>,
/// IDs of magic items the character is currently attuned to. Capped at
/// `equipment::magic::MAX_ATTUNED_ITEMS` (3 per SRD 5.1). Items are
/// `equipment::magic::MAX_ATTUNED_ITEMS` (3 per SRD 2024). Items are
/// attuned via the `attune` command and released via `unattune`.
/// `#[serde(default)]` so older saves without this field deserialize
/// to an empty vec. Added 2026-04-15 (feat/magic-items).
Expand Down Expand Up @@ -126,6 +126,14 @@ pub struct Character {
/// to 0.
#[serde(default)]
pub gold_cp: u32,
/// Skills for which the character has Expertise (doubled proficiency
/// bonus). Per SRD 2024: Rogue gains Expertise in two chosen skills at
/// level 1 (and two more at level 6); Bard gains Expertise at level 3.
/// Expertise requires proficiency — if the skill is not also in
/// `skill_proficiencies`, the extra bonus is ignored. `#[serde(default)]`
/// so legacy saves deserialize cleanly to an empty vec.
#[serde(default)]
pub expertise_skills: Vec<Skill>,
}

impl Character {
Expand All @@ -138,7 +146,19 @@ impl Character {
pub fn is_proficient_in_save(&self, ability: Ability) -> bool { self.save_proficiencies.contains(&ability) }
pub fn skill_modifier(&self, skill: Skill) -> i32 {
let base = self.ability_modifier(skill.ability());
if self.is_proficient_in_skill(skill) { base + self.proficiency_bonus() } else { base }
if self.is_proficient_in_skill(skill) {
let pb = self.proficiency_bonus();
let effective_pb = if self.expertise_skills.contains(&skill) { pb * 2 } else { pb };
base + effective_pb
} else {
base
}
}

/// Returns true when the character has Expertise in the given skill
/// (i.e. the skill is in `expertise_skills` AND in `skill_proficiencies`).
pub fn has_expertise(&self, skill: Skill) -> bool {
self.expertise_skills.contains(&skill) && self.is_proficient_in_skill(skill)
}
}

Expand Down Expand Up @@ -169,7 +189,15 @@ pub fn validate_point_buy(scores: &[i32; 6]) -> Result<(), String> {
None => return Err(format!("Score {} is out of range (8-15)", score)),
}
}
if total != 27 { return Err(format!("Total cost is {} (must be 27)", total)); }
if total != 27 {
let delta = (27 - total).unsigned_abs();
let msg = if total < 27 {
format!("spent {}/27 points — {} remaining", total, delta)
} else {
format!("spent {}/27 points — {} over budget", total, delta)
};
return Err(msg);
}
Ok(())
}

Expand All @@ -189,7 +217,7 @@ pub fn create_character(
for (ability, bonus) in race.ability_bonuses() {
*final_scores.entry(ability).or_insert(10) += bonus;
}
// SRD 5.1: "None of these increases can raise a score above 20."
// SRD 2024: "None of these increases can raise a score above 20."
for score in final_scores.values_mut() {
*score = (*score).min(20);
}
Expand Down Expand Up @@ -267,6 +295,7 @@ pub fn create_character(
subrace: None,
ammo: HashMap::new(),
gold_cp: 0,
expertise_skills: Vec::new(),
}
}

Expand Down Expand Up @@ -455,6 +484,49 @@ mod tests {
assert_eq!(c.skill_modifier(Skill::Stealth), 3);
}

#[test]
fn test_skill_modifier_with_expertise_doubles_pb() {
// Elf Rogue: DEX starts at 14 → racial +2 → 16 → modifier +3.
// Level 1 proficiency bonus = +2. With Expertise on Stealth:
// expected modifier = +3 DEX + (2 * 2) doubled PB = +7.
let mut c = create_character("Test".to_string(), Race::Elf, Class::Rogue, test_scores(), vec![Skill::Stealth]);
c.expertise_skills = vec![Skill::Stealth];
assert_eq!(c.skill_modifier(Skill::Stealth), 7, "Expertise should double PB: +3 DEX + +4 PB = +7");
}

#[test]
fn test_expertise_without_proficiency_has_no_effect() {
// expertise_skills without the skill in skill_proficiencies must not double.
let mut c = create_character("Test".to_string(), Race::Elf, Class::Rogue, test_scores(), vec![]);
c.expertise_skills = vec![Skill::Stealth];
// Not proficient — base DEX only (+3 for Elf Rogue).
assert_eq!(c.skill_modifier(Skill::Stealth), 3);
assert!(!c.has_expertise(Skill::Stealth));
}

#[test]
fn test_has_expertise_true_when_proficient_and_in_expertise_list() {
let mut c = create_character("Test".to_string(), Race::Elf, Class::Rogue, test_scores(), vec![Skill::Stealth]);
c.expertise_skills = vec![Skill::Stealth];
assert!(c.has_expertise(Skill::Stealth));
}

#[test]
fn test_rogue_expertise_bug_example_stealth_plus7() {
// Regression: Rogue with DEX 16 (+3) and PB 2 with Expertise in Stealth
// should show +7, not +5 (the pre-fix bug value).
let mut scores = HashMap::new();
scores.insert(Ability::Strength, 10);
scores.insert(Ability::Dexterity, 16);
scores.insert(Ability::Constitution, 10);
scores.insert(Ability::Intelligence, 10);
scores.insert(Ability::Wisdom, 10);
scores.insert(Ability::Charisma, 10);
let mut c = create_character("Shadow".to_string(), Race::Human, Class::Rogue, scores, vec![Skill::Stealth]);
c.expertise_skills = vec![Skill::Stealth];
assert_eq!(c.skill_modifier(Skill::Stealth), 7, "Bug: was +5, should be +7 with Expertise");
}

#[test]
fn test_random_scores_in_range() {
use rand::SeedableRng; use rand::rngs::StdRng;
Expand All @@ -475,6 +547,22 @@ mod tests {
#[test]
fn test_point_buy_wrong_total() { assert!(validate_point_buy(&[15, 15, 14, 8, 8, 8]).is_err()); }

#[test]
fn test_point_buy_underspend_message() {
// [8,8,8,8,8,8] costs 0 points — 27 remaining
let err = validate_point_buy(&[8, 8, 8, 8, 8, 8]).unwrap_err();
assert!(err.contains("spent 0/27 points"), "unexpected message: {}", err);
assert!(err.contains("27 remaining"), "unexpected message: {}", err);
}

#[test]
fn test_point_buy_overspend_message() {
// [15,15,15,15,15,15] costs 54 points — 27 over budget
let err = validate_point_buy(&[15, 15, 15, 15, 15, 15]).unwrap_err();
assert!(err.contains("spent 54/27 points"), "unexpected message: {}", err);
assert!(err.contains("27 over budget"), "unexpected message: {}", err);
}

#[test]
fn test_point_buy_out_of_range() {
assert!(validate_point_buy(&[16, 14, 13, 12, 10, 8]).is_err());
Expand Down Expand Up @@ -969,7 +1057,7 @@ mod tests {
assert!(loaded.weapon_masteries.is_empty());
}

// ---- Ability score cap (SRD 5.1: scores cannot exceed 20) ----
// ---- Ability score cap (SRD 2024: scores cannot exceed 20) ----

#[test]
fn test_ability_score_cap_clamps_scores_exceeding_20() {
Expand Down
Loading