Skip to content

Combat: target choice, special damage per enemy, host rolls - #233

Merged
sunsided merged 3 commits into
mainfrom
feature/combat-targets
Nov 9, 2026
Merged

sunsided merged 3 commits into
mainfrom
feature/combat-targets

Conversation

@sunsided

@sunsided sunsided commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Closes #190

What

All three parts, in the built-in combat (crates/loremaster-runtime/src/game/combat.rs):

  1. Special rules per enemy or per combat. The optional number properties deals (STAMINA a hit of the enemy costs the player) and takes (STAMINA a hit of the player costs the enemy) replace the fixed 2. A Luck test shifts them as it shifted the 2. A rule of one combat ("the troll takes 1 extra damage from fire") is a rule that sets troll.takes before the combat effect; writing-stories.md shows it and a test plays it. loremaster check rejects a non-number deals/takes. Enemies were already things with number props skill/stamina, so this stays data, works in the web player and loremaster test.
  2. Target choice in an all-at-once fight. With together: true and more than one living enemy, the round options are "Attack GATE WARDEN" (attack, the first living enemy, as before) and "Attack HOUND" (attack.hound). Keeping attack for the first target keeps existing walkthroughs (do: attack) and old saves working. The web player shows the options through the existing await prompt; the explorer tries each target.
  3. Rolls that the host answers. With set_host_rolls(true), a round waits for one roll await (2d6) per fighting enemy, in enemies order, then the player; "Test your Luck" waits for one. Forced rolls (walkthroughs, explorer) still go first, as for await: { roll }. The web AwaitPrompt already handles roll awaits.

CombatState has new fields by, target, rolls (breaking for code that builds it with a struct literal); all serde-default, so saves stay compatible without a version bump (ADR 0001: a defaulted field needs no step). Game::record_roll is split out of take_roll so host totals show on the dice display like session rolls.

Salt Tower unchanged: its warden has no special rule, and changing it would rebalance its walkthroughs for no gain.

Left open (new issues)

How verified

New tests in crates/loremaster-runtime/tests/combat.rs (each fails on main: unknown option / fixed damage / no roll await):

  • the_player_chooses_the_target_of_an_all_at_once_combat
  • an_enemy_can_deal_and_take_other_damage, luck_shifts_the_special_damage, a_rule_before_the_combat_sets_a_special_rule_for_it
  • the_checks_reject_a_special_rule_that_is_no_number
  • the_host_can_roll_the_dice_of_a_combat (including a save and load while the round waits for its second roll, and the Luck roll)

cargo fmt --all -- --check, cargo clippy --workspace --all-targets --no-default-features -- -D warnings, cargo test --workspace --no-default-features all pass. No web files changed.

In an all-at-once combat with more than one living enemy the player
selects the target (attack = the first living enemy, attack.<enemy> for
the others). The enemy properties deals and takes set the STAMINA a hit
costs the player or the enemy; Luck shifts them. With set_host_rolls the
host answers the combat dice as roll awaits; CombatState keeps the
totals so far, so a save mid-round restores.

Closes #190
Copilot AI balanced review requested due to automatic review settings November 9, 2026 01:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Copilot was unable to complete this review at the selected tier due to model policy or plan restrictions.

🟡 Changes recommended

A documentation nit remains in docs/writing-stories.md where the stamina rule is described as always “takes 2 stamina,” which is now only the default due to newly introduced per-enemy damage overrides and should be clarified to avoid misleading readers.

1 open issue
What changed in this PR

This pull request enhances the built-in combat system to support per-enemy/per-combat damage tuning (deals/takes), explicit target selection in “all-at-once” fights while keeping backward-compatible attack behavior, and optional host-driven dice rolling (including save/load continuity while awaiting rolls).

Changes:

  • Add optional enemy damage overrides (deals, takes) with Luck shifting behavior and project validation for non-numeric values.
  • Add target-choice commands for together: true combats with multiple living enemies (attack.<enemy>), preserving attack as the first living enemy.
  • Add host-roll sequencing for combat rounds and Luck tests, including refactoring roll recording so host totals appear in the dice display.
File Summary
docs/​writing-stories.md Documents target choice, special damage (deals/takes), and host-roll behavior for combat.
crates/​loremaster-runtime/​tests/​combat.rs Adds tests covering target choice, special damage + Luck shifting, checker rejection, and host rolls (including save/load mid-await).
crates/​loremaster-runtime/​src/​state.rs Extends CombatState with serde-defaulted fields (by, target, rolls) to support new combat behavior while maintaining save compatibility.
crates/​loremaster-runtime/​src/​game/​combat.rs Implements target selection, variable damage rules, host-roll awaits, and Luck interactions in core combat flow.
crates/​loremaster-runtime/​src/​game/​awaits.rs Splits out roll recording (record_roll) so host-supplied totals are logged/displayed like session rolls.
crates/​loremaster-runtime/​src/​explore.rs Updates explorer behavior to handle the new “Attack ” options in multi-enemy combats.
crates/​loremaster-project/​src/​check.rs Validates that optional deals/takes enemy properties are numeric.
CHANGELOG.md Records the new combat capabilities and the (library) breaking change to CombatState struct shape.

Review effort: Balanced

Comment thread docs/writing-stories.md Outdated
`win`, `loss` and `escape` are rooms. `escape` is optional. With `together: true`, all enemies fight in the same round; without it, they fight one after the other. Effects after `combat` run in the room where the combat ends.

In each round, the player selects "Attack" or, if the combat has `escape`, "Escape". The enemy and the player each roll 2d6 and add their `skill`. The enemy rolls first. The higher total hits and takes 2 `stamina`. In an all-at-once combat, each enemy rolls and the player rolls once. The player attacks the first living enemy, and the other enemies only attack.
In each round, the player selects "Attack" or, if the combat has `escape`, "Escape". The enemy and the player each roll 2d6 and add their `skill`. The enemy rolls first. The higher total hits and takes 2 `stamina`. In an all-at-once combat, each enemy rolls and the player rolls once. While more than one enemy lives, the player selects the target: "Attack GATE WARDEN" (`attack`, the first living enemy) or "Attack HOUND" (`attack.hound`). The other enemies only attack.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84eaa70. The paragraph now says a hit takes 2 stamina unless the enemy has deals or takes, which the next paragraph explains.

}
}
// Special rules of the enemy: optional, but numbers.
for prop in ["deals", "takes"] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: deals/takes are only type-checked as Int, not bounded below. The runtime clamps them with .max(0) (combat.rs:123), so takes: -1 (or a rule setting it negative) silently makes the enemy undamageable, and deals: -1 makes the player invincible — a combat can become unwinnable with no diagnostic. Reject negative literals here and consider a diagnostic when a rule can set them negative.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84eaa70. loremaster check rejects a negative deals/takes literal on the enemy (test the_checks_reject_a_negative_special_rule). A value a rule sets at run time is not tracked by the checker (it has no value ranges for set); the combat counts it as 0, and the docs say so.

let damage = self.hit_damage(&hit);
let helps = if hit.as_str() == PLAYER {
self.int("stamina") > HIT - 1
self.int("stamina") > damage - 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: With deals: 0 a wound deals 0, but damage - 1 is -1, so stamina > -1 holds and 'Test your Luck' is offered although it cannot help: a lucky result is clamped to 0 (same as no test) and an unlucky one raises it to 1. The 'no test where it can not help' guard should also require damage > 0.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84eaa70. A wound on the player now needs damage > 0 for the Luck offer (test no_luck_test_for_a_wound_that_costs_nothing). A hit on an enemy with takes: 0 keeps the offer: a lucky result gives 2 there, so the test helps.

Comment thread crates/loremaster-runtime/src/state.rs Outdated
/// The hit that waits for "Test your Luck": an enemy, or `player`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending: Option<Id>,
/// The enemy that made the pending hit on the player, for its `deals`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: by is set for every pending hit (combat.rs:338 and combat_strike at combat.rs:362), including a player hit on an enemy, where it holds the target rather than the enemy that wounded the player. Reword to 'the enemy of the pending hit (for deals when the hit is on the player)' so the field is not mistaken for wound-only state.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84eaa70. The doc now says: the enemy of the pending hit, the one hit or the one that hit the player (then its deals sets the damage).

@kilo-code-bot

kilo-code-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 0
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
crates/loremaster-project/src/check.rs 1863 n @ ..0 binds a range pattern, a hard error in edition 2021+; the workspace is edition 2024, so loremaster-project does not compile.
Files Reviewed (8 files)
  • CHANGELOG.md - 0 issues (unchanged since last review)
  • crates/loremaster-project/src/check.rs - 1 issue
  • crates/loremaster-runtime/src/explore.rs - 0 issues (unchanged since last review)
  • crates/loremaster-runtime/src/game/awaits.rs - 0 issues (unchanged since last review)
  • crates/loremaster-runtime/src/game/combat.rs - 0 new issues (previous Luck-offer finding fixed)
  • crates/loremaster-runtime/src/state.rs - 0 new issues (previous by doc finding fixed)
  • crates/loremaster-runtime/tests/combat.rs - 0 issues
  • docs/writing-stories.md - 0 issues

Fix these issues in Kilo Cloud

Previous Review Summary (commit db6827e)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit db6827e)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
crates/loremaster-project/src/check.rs 1851 deals/takes are only checked as Int, not bounded below; the runtime clamps negatives to 0, silently making an enemy undamageable or the player invincible.

SUGGESTION

File Line Issue
crates/loremaster-runtime/src/game/combat.rs 345 With deals: 0 a Luck test is offered for a wound that cannot be improved (a lucky result is clamped to 0; unlucky adds 1).
crates/loremaster-runtime/src/state.rs 134 The by doc says it is the enemy of a pending hit on the player, but it is set for every pending hit.
Files Reviewed (8 files)
  • CHANGELOG.md - 0 issues
  • crates/loremaster-project/src/check.rs - 1 issue
  • crates/loremaster-runtime/src/explore.rs - 0 issues
  • crates/loremaster-runtime/src/game/awaits.rs - 0 issues
  • crates/loremaster-runtime/src/game/combat.rs - 1 issue
  • crates/loremaster-runtime/src/state.rs - 1 issue
  • crates/loremaster-runtime/tests/combat.rs - 0 issues
  • docs/writing-stories.md - 0 new issues (one pre-existing Copilot note on line 460)

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash-1017 · Input: 53.4K · Output: 33.1K · Cached: 835.1K

# Conflicts:
#	CHANGELOG.md
#	docs/writing-stories.md
- check rejects a negative deals/takes literal.
- No Luck test for a wound that costs nothing.
- Docs: 2 is the default damage; deals/takes are 0 or more.
- The doc of CombatState::by names both uses.
);
}
let value = (self.story().thing(e.as_str())).and_then(|t| t.props.get(prop));
if let Some(loremaster_model::Value::Int(n @ ..0)) = value {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: n @ ..0 is a range pattern in binding position (bindings_with_range, rust-lang/rust#67258), which is a hard error in edition 2021 and later. This workspace is on edition 2024 (Cargo.toml:21), so loremaster-project does not compile here: error: @ bindings cannot be used with range patterns. The whole workspace build and the new the_checks_reject_a_negative_special_rule test fail.

Bind the integer and test it instead, for example if let Some(loremaster_model::Value::Int(n)) = value.copied() && n < 0 { (or keep the borrow and compare *n < 0).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compiles: CI on the merged head built and tested it (edition 2024, current stable). Half-open range patterns in binding position (n @ ..0) are stable since Rust 1.80; #67258 was about ..=-less inclusive forms.

@sunsided
sunsided merged commit 68451fe into main Nov 9, 2026
7 checks passed
@sunsided
sunsided deleted the feature/combat-targets branch November 9, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combat: special rules per enemy and a target choice

2 participants