Skip to content

fix(combat): surface off-turn advisory when actorId isn't on turn#59

Merged
Mnehmos merged 3 commits intomainfrom
fix/49-off-turn-guard
Apr 20, 2026
Merged

fix(combat): surface off-turn advisory when actorId isn't on turn#59
Mnehmos merged 3 commits intomainfrom
fix/49-off-turn-guard

Conversation

@Mnehmos
Copy link
Copy Markdown
Owner

@Mnehmos Mnehmos commented Apr 18, 2026

Summary

combat_action resolved actions regardless of whose turn it was — letting a caller fire an attack as an off-turn participant, then fire again when their real turn comes up. Live session observed Marisol attacking twice in a round.

Approach

A hard reject broke 39 existing tests that rely on initiative-order guessing (they'd need individual fixes). This PR chooses the smaller-blast-radius path: emit an off_turn_action: <actorId> acting during <name>'s turn advisory in the response. Production callers see the misuse; existing flows remain intact.

Scope

  • Warning fires only when actorId is a real participant that is not the current-turn participant.
  • Unknown actors still hit the downstream error (more specific).

Future work

Promote to a hard reject once the test fixtures that depend on initiative-order guessing are fixed. Tracked implicitly — if the team wants, I can open a follow-up.

Test plan

  • New test: off-turn actor → response contains off_turn_action text.
  • New test: on-turn actor → no warning.
  • combat-action suite: 27/27
  • Full suite: 1891 passed, 6 skipped, 0 failed

Closes #49

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Combat actions now display an advisory when executed out-of-turn; lair actions are specially labeled in that advisory.
  • Tests
    • Added test coverage validating off-turn advisories, absence when on-turn, and lair-slot advisory behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

Warning

Rate limit exceeded

@Mnehmos has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 16 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 7 minutes and 16 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a9ee6db9-7925-4a8f-ad48-0fef67d9bbbc

📥 Commits

Reviewing files that changed from the base of the PR and between b99d321 and ff923cc.

📒 Files selected for processing (3)
  • src/schema/encounter.ts
  • src/server/handlers/combat-handlers.ts
  • tests/server/consolidated/combat-action.test.ts
📝 Walkthrough

Walkthrough

An off-turn action advisory and optional lair-action flag were added to combat handling. When an action is executed by a non-active participant (including special handling for an initiative-20 LAIR slot), a warning line is appended to the action response output; actions are not rejected.

Changes

Cohort / File(s) Summary
Combat handler changes
src/server/handlers/combat-handlers.ts
Added optional hasLairActions in encounter creation flow and propagated to participants; handleExecuteCombatAction now detects when parsed.actorId differs from the active participant (treating initiative 20 as LAIR action) and appends a ⚠️ off-turn advisory to the response output without altering error handling.
Off-turn advisory tests
tests/server/consolidated/combat-action.test.ts
Added tests asserting the off-turn warning appears when actorId is not the current-turn participant, is absent when it matches, and that a LAIR slot produces an advisory mentioning LAIR action when appropriate.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped in quick to scope the fray,

⚠️ "Not your turn," the rabbit seemed to say.
A LAIR at twenty, a cheeky surprise,
I nibble the edge of rules and ties.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR partially addresses issue #49 by surfacing an advisory warning instead of rejecting off-turn actions, whereas the acceptance criteria required rejecting such actions with an error that includes the current actor's name. The implementation emits a warning rather than rejecting off-turn actions as required by issue #49. Consider promoting the advisory to a hard reject after fixing test fixtures, as the author acknowledged.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: surfacing an advisory when an actor attempts to act outside their turn, which directly addresses the off-turn action problem.
Out of Scope Changes check ✅ Passed All changes are directly related to addressing off-turn action enforcement and related test coverage; no out-of-scope modifications were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/49-off-turn-guard

Comment @coderabbitai help to get the list of available commands and usage tips.

combat_action accepts any actorId and resolves the action regardless of
whose turn it is. In the live session this let Marisol attack during
Brokk's turn and then attack again on her own turn — two actions for
one turn slot.

Rather than hard-fail (which would retroactively break 39 existing
tests that pre-assume initiative order), this PR emits an
`off_turn_action: <actorId> acting during <name>'s turn` warning in the
response text. Production callers see the misuse, while existing test
flows remain intact. The guard only fires when the supplied actorId is
a real participant — unknown actors still produce the specific
downstream error.

Follow-up (not in this PR): promote to a hard reject once the test
fixtures that depend on initiative-order guessing are updated.

Closes #49

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56dc58e88e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/handlers/combat-handlers.ts Outdated
const activeId = liveState.turnOrder[liveState.currentTurnIndex];
if (
activeId &&
activeId !== 'LAIR' &&
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove LAIR bypass from off-turn warning check

The new activeId !== 'LAIR' condition suppresses the advisory during initiative-20 lair turns, so in encounters with hasLairActions a participant can still execute combat_action out of turn without any off_turn_action signal. That recreates the silent misuse path this patch is trying to expose (the actor can still act again on their normal turn later in the round), just in lair-enabled combats.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/server/consolidated/combat-action.test.ts (1)

106-118: Make the off-turn test independent of assumed initiative order.

Line 106-Line 108 hard-codes that hero-1 is on turn. Prefer deriving the current-turn actor from encounter state and selecting the opposite actor dynamically, so this regression test stays stable if initiative mechanics evolve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/server/consolidated/combat-action.test.ts` around lines 106 - 118, The
test currently assumes hero-1 is on turn; change it to read the encounter's
current actor from the encounter state and pick the other actor dynamically
before calling handleCombatAction. Specifically, fetch the encounter by
testEncounterId (using the same test helpers/context as other tests), get
encounter.currentActor or equivalent field, compute offTurnActor = currentActor
=== 'hero-1' ? 'goblin-1' : 'hero-1', and pass offTurnActor as actorId (and the
opposite as targetId) into handleCombatAction so the expect on
result.content[0].text.match(/off_turn_action/) remains valid regardless of
initiative ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/server/handlers/combat-handlers.ts`:
- Around line 1254-1264: The current check excludes LAIR turns from off-turn
detection by requiring activeId !== 'LAIR'; remove that clause so actions during
a LAIR turn are treated the same as other turns. In the block using activeId,
parsed.actorId, liveState.participants and turnWarning, delete the `activeId !==
'LAIR'` condition and keep the existing actor lookup/optional name fallback
(actor?.name ?? activeId) to safely format the advisory for LAIR as well as
normal participants.

---

Nitpick comments:
In `@tests/server/consolidated/combat-action.test.ts`:
- Around line 106-118: The test currently assumes hero-1 is on turn; change it
to read the encounter's current actor from the encounter state and pick the
other actor dynamically before calling handleCombatAction. Specifically, fetch
the encounter by testEncounterId (using the same test helpers/context as other
tests), get encounter.currentActor or equivalent field, compute offTurnActor =
currentActor === 'hero-1' ? 'goblin-1' : 'hero-1', and pass offTurnActor as
actorId (and the opposite as targetId) into handleCombatAction so the expect on
result.content[0].text.match(/off_turn_action/) remains valid regardless of
initiative ordering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f8130432-1fa7-4213-b110-8007128635b1

📥 Commits

Reviewing files that changed from the base of the PR and between 2d9ddb9 and 56dc58e.

📒 Files selected for processing (2)
  • src/server/handlers/combat-handlers.ts
  • tests/server/consolidated/combat-action.test.ts

Comment thread src/server/handlers/combat-handlers.ts
Reviewers (PR #59) flagged that the activeId !== 'LAIR' bypass let a
participant act mid-LAIR-turn without any off_turn signal — defeating
the very misuse this PR was meant to expose.

- Drop the LAIR bypass; treat LAIR as a non-participant turn slot.
- When the active slot is LAIR, the warning labels it 'LAIR action's
  turn' so callers know to use the lair_action tool instead.
- Threads hasLairActions through the inner create_encounter schema
  and the participant builder so the regression test can construct a
  LAIR-bearing encounter.
- Test forces currentTurnIndex to the LAIR slot and asserts a PC
  attack still produces an off_turn_action warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/server/handlers/combat-handlers.ts (1)

1245-1251: Keep the advisory comment aligned with supported actions.

Line 1246-Line 1248 mentions dash/dodge/help/ready, but those actions are not currently accepted by the schema enum. This is just comment drift, but it can mislead future edits.

✏️ Suggested comment-only cleanup
-    // handler (attack / cast_spell / move / dash / dodge / help / heal /
-    // disengage / ready) is an on-turn action. If actorId doesn't match the
+    // handler (attack / cast_spell / move / heal / disengage) is an on-turn
+    // action. If actorId doesn't match the
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/handlers/combat-handlers.ts` around lines 1245 - 1251, Update the
advisory comment in src/server/handlers/combat-handlers.ts so it accurately
lists only the actions currently accepted by the schema enum for the on-turn
handler (replace the outdated "dash/dodge/help/ready" entries). Locate the
comment block above the on-turn action handler (the turn-identity advisory
referencing attack / cast_spell / move / dash / dodge / help / heal / disengage
/ ready) and remove or replace the unsupported actions with the exact action
names present in the schema enum, keeping the rest of the advisory text (warning
instead of throwing) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/server/handlers/combat-handlers.ts`:
- Line 1099: The TokenSchema is missing hasLairActions and the repo.create()
token mapping omits it, so persists never store that flag; add hasLairActions:
z.boolean().optional() to the TokenSchema definition and include hasLairActions
in the token fields passed to repo.create() (the same object where other token
props are mapped) so that participants loaded later can be found by the existing
participants.find((p: any) => p.hasLairActions) lookup and lair action turn
order is preserved.

---

Nitpick comments:
In `@src/server/handlers/combat-handlers.ts`:
- Around line 1245-1251: Update the advisory comment in
src/server/handlers/combat-handlers.ts so it accurately lists only the actions
currently accepted by the schema enum for the on-turn handler (replace the
outdated "dash/dodge/help/ready" entries). Locate the comment block above the
on-turn action handler (the turn-identity advisory referencing attack /
cast_spell / move / dash / dodge / help / heal / disengage / ready) and remove
or replace the unsupported actions with the exact action names present in the
schema enum, keeping the rest of the advisory text (warning instead of throwing)
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bcb9e739-235a-4733-a868-5c94adbac5d7

📥 Commits

Reviewing files that changed from the base of the PR and between 56dc58e and b99d321.

📒 Files selected for processing (2)
  • src/server/handlers/combat-handlers.ts
  • tests/server/consolidated/combat-action.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/server/consolidated/combat-action.test.ts

initiative: 0, // Will be rolled
initiativeBonus: p.initiativeBonus ?? 0,
isEnemy: p.isEnemy ?? false,
hasLairActions: p.hasLairActions ?? false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate encounter repository implementation:"
fd -i 'encounter.repo.ts' src

echo
echo "Inspect create/load/save mappings for lair-related fields:"
rg -n -C4 'create\\(|loadState\\(|saveState\\(|hasLairActions|lairOwnerId|turnOrder|activeTokenId|tokens' src/server src/storage

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 402


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# First, check the file size and read lines around 1099 and 1131-1145
echo "=== Reading combat-handlers.ts around line 1099 ==="
wc -l src/server/handlers/combat-handlers.ts

echo
echo "=== Context around line 1099 ==="
sed -n '1090,1110p' src/server/handlers/combat-handlers.ts

echo
echo "=== Context around lines 1131-1145 (persistence mapping) ==="
sed -n '1125,1150p' src/server/handlers/combat-handlers.ts

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 2048


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the encounter repository to understand persistence
echo "=== Reading encounter.repo.ts ==="
wc -l src/storage/repos/encounter.repo.ts

echo
echo "=== Search for create method and persistence mappings ==="
rg -n "create\(" src/storage/repos/encounter.repo.ts -A 20

echo
echo "=== Search for loadState method ==="
rg -n "loadState" src/storage/repos/encounter.repo.ts -A 15

echo
echo "=== Search for hasLairActions in storage layer ==="
rg -n "hasLairActions" src/storage src/server

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 3477


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full loadState method in encounter.repo.ts ==="
sed -n '130,190p' src/storage/repos/encounter.repo.ts

echo
echo "=== Check token schema/interface definition ==="
rg -n "interface.*Token|type.*Token" src/storage src/server --type-list | head -20
rg -n "tokens:" src/storage/repos/encounter.repo.ts -B5 -A5

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 2904


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Verify what token fields are actually expected in schema ==="
rg -n "EncounterSchema\|TokenSchema" src/storage -A 30 | head -60

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for token/encounter schema definitions ==="
rg -n "type.*Token|interface.*Token|Encounter.*=" src --type ts -A 10 | head -80

echo
echo "=== Look for Zod schema definitions ==="
rg -n "z\.object|EncounterSchema\|TokenSchema" src/storage -A 20 | head -100

echo
echo "=== Check what CombatParticipant interface includes ==="
rg -n "interface CombatParticipant|type CombatParticipant" src -A 20

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 6078


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Read full TokenSchema definition ==="
sed -n '80,120p' src/schema/encounter.ts

echo
echo "=== Read full EncounterSchema ==="
sed -n '140,165p' src/schema/encounter.ts

echo
echo "=== Check if hasLairActions is in TokenSchema ==="
rg -n "hasLairActions" src/schema/encounter.ts

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 2470


Add hasLairActions to token persistence mapping.

Line 1099 sets hasLairActions on in-memory participants, but the TokenSchema (src/schema/encounter.ts) does not define this field, and the create-time persistence mapping (lines 1131–1145) omits it. When loadState() reloads the encounter, it searches for participants.find((p: any) => p.hasLairActions) (encounter.repo.ts:160), which will always be undefined since the field was never persisted. This causes lair turn order to be lost across session reloads, breaking lair action functionality.

Add hasLairActions: z.boolean().optional() to TokenSchema and include it in the token persistence mapping in the repo.create() call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/handlers/combat-handlers.ts` at line 1099, The TokenSchema is
missing hasLairActions and the repo.create() token mapping omits it, so persists
never store that flag; add hasLairActions: z.boolean().optional() to the
TokenSchema definition and include hasLairActions in the token fields passed to
repo.create() (the same object where other token props are mapped) so that
participants loaded later can be found by the existing participants.find((p:
any) => p.hasLairActions) lookup and lair action turn order is preserved.

…trip

Reviewers (PR #59) caught that hasLairActions was set on participants
but never persisted. TokenSchema didn't define it and repo.create()'s
token mapping omitted it. After a restart, encounter.repo.loadState
searched for participants.find(p => p.hasLairActions) and always got
undefined, dropping the LAIR slot from turnOrder and silently
disabling the lair-action mechanic.

- Add hasLairActions: z.boolean().optional() to TokenSchema.
- Include hasLairActions in repo.create() token mapping.
- Regression test creates a LAIR-bearing encounter, reloads via
  EncounterRepository.loadState, and asserts both that turnOrder still
  contains 'LAIR' and that the bearer's hasLairActions flag survived.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@Mnehmos
Copy link
Copy Markdown
Owner Author

Mnehmos commented Apr 19, 2026

@coderabbitai both findings are stale regenerations against the same commit that addresses them — b99d3214 removed the activeId !== 'LAIR' bypass and ff923cc6 added hasLairActions to TokenSchema + repo.create's token mapping. Round-trip regression test in tests/server/consolidated/combat-action.test.ts asserts turnOrder still contains 'LAIR' after EncounterRepository.loadState.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check current state of combat-handlers.ts for LAIR bypass removal
echo "=== combat-handlers.ts LAIR references ==="
rg -n "LAIR|activeId" --type=ts src/server/handlers/combat-handlers.ts

echo ""
echo "=== TokenSchema for hasLairActions ==="
rg -n "hasLairActions|TokenSchema" --type=ts -A2 -B2

echo ""
echo "=== repo.create token mapping for hasLairActions ==="
rg -n "hasLairActions" --type=ts -A3 -B3

echo ""
echo "=== LAIR round-trip test assertion ==="
rg -n "LAIR.*loadState\|loadState.*LAIR\|turnOrder.*LAIR\|LAIR.*turnOrder" --type=ts -A3 -B3 tests/server/consolidated/combat-action.test.ts

echo ""
echo "=== Commits on this branch ==="
git log --oneline -10

Length of output: 56245


@Mnehmos, confirmed — both findings are fully addressed in the current branch:

  1. b99d321combat-handlers.ts (Lines 1257–1270) no longer contains the activeId !== 'LAIR' bypass. LAIR turns are now treated as off-turn for participant actions, and the advisory correctly labels them as "LAIR action" to guide callers toward lair_action.

  2. ff923cchasLairActions is present in TokenSchema (src/schema/encounter.ts Line 113) and is explicitly persisted in the token mapping at combat-handlers.ts Line 1137 (hasLairActions: p.hasLairActions). The round-trip regression test at combat-action.test.ts Lines 136–155 asserts both loaded.turnOrder contains 'LAIR' and dragon?.hasLairActions === true after EncounterRepository.loadState.

My earlier comments were stale — apologies for the noise. I'll resolve them now.

[resolve]

@Mnehmos Mnehmos merged commit 84e27a8 into main Apr 20, 2026
4 checks passed
@Mnehmos Mnehmos deleted the fix/49-off-turn-guard branch April 20, 2026 14:20
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_action: action economy not enforced (off-turn actions accepted)

1 participant