8000 Sync development to main: ARM64 support + test infrastructure fixes by Mnehmos · Pull Request #10 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
51742d1
fix(combat): rebuild binary with relaxed spell damage validation
Mnehmos Dec 15, 2025
13f3664
feat: Sync recent combat tool updates
Mnehmos Dec 15, 2025
9974db8
feat: add list_rooms tool to spatial system
Mnehmos Dec 15, 2025
c5d650d
test(combat): add tests for terrain rendering in get_encounter_state
Mnehmos Dec 22, 2025
8581f5d
fix(persistence): ensure database and encounters persist to APPDATA […
Mnehmos Dec 22, 2025
c67d1dd
fix(types): correct encounter schema types for database persistence
Mnehmos Dec 22, 2025
884e784
feat(build): add ARM64 macOS (M1/M2/M3/M4) binary support
Mnehmos Dec 24, 2025
0ec6f48
fix(tests): remove explicit vitest imports to fix test suite detection
Mnehmos Dec 24, 2025
67e7a38
fix(tests): properly extract state from handleGetEncounterState respo…
Mnehmos Dec 24, 2025
762c609
fix(combat): change isEnemy default from true to false
Mnehmos Dec 24, 2025
f2947d3
test: remove uncommitted movement_mechanics test file
Mnehmos Dec 24, 2025
6d325d7
fix(tests): fix remaining test failures - 99.92% passing
Mnehmos Dec 24, 2025
40802c8
test(combat): align damage param test with CRIT-006 security
Mnehmos Dec 24, 2025
d7e3544
test(spellcasting): add retry logic to flaky spell tests
Mnehmos Dec 24, 2025
c8fd445
Merge remote-tracking branch 'origin/main' into development
Mnehmos Dec 24, 2025
6e89bfd
fix(tests): adapt CRUD tests to RichFormatter embedded JSON
Mnehmos Dec 24, 2025
7dd965b
WIP: merge main into development - partial test fixes
Mnehmos Dec 24, 2025
0fd1e4a
fix(tests): complete RichFormatter migration for all test files
Mnehmos Dec 24, 2025
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
25 changes: 25 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run test:ci:*)",
"Bash(git checkout:*)",
"Bash(npx vitest:*)",
"Bash(find:*)",
"Bash(git stash:*)",
"Bash(gh run list:*)",
"Bash(gh run view:*)",
"Bash(gh run watch:*)",
"Bash(gh pr create:*)",
"Bash(gh pr checks:*)",
"Bash(gh pr view:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(grep:*)"
]
}
}
3 changes: 3 additions & 0 deletions .roomodes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"customModes": []
}
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,22 @@ Download the pre-built binary for your platform from the [Releases](https://gith
.\rpg-mcp-win.exe
```

**macOS:**
**macOS (Intel):**

```bash
# Download rpg-mcp-macos
chmod +x rpg-mcp-macos
./rpg-mcp-macos
```

**macOS (Apple Silicon - M1/M2/M3/M4):**

```bash
# Download rpg-mcp-macos-arm64
chmod +x rpg-mcp-macos-arm64
./rpg-mcp-macos-arm64
```

**Linux:**

```bash
Expand All @@ -277,7 +285,7 @@ To build binaries yourself:

```bash
npm run build:binaries
# Output: dist-bundle/rpg-mcp-win.exe, rpg-mcp-macos, rpg-mcp-linux
# Output: bin/rpg-mcp-win.exe, rpg-mcp-macos, rpg-mcp-macos-arm64, rpg-mcp-linux
```

### MCP Client Configuration
Expand Down
31 changes: 20 additions & 11 deletions
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ async function build() {
"pkg": {
"scripts": [],
"assets": [],
"targets": ["node20-win-x64", "node20-macos-x64", "node20-linux-x64"],
"targets": ["node20-win-x64", "node20-macos-x64", "node20-macos-arm64", "node20-linux-x64"],
"outputPath": "../bin"
}
};
Expand All @@ -421,7 +421,7 @@ async function build() {

// Run pkg
console.log('📦 Creating executables with pkg...');
execSync('npx pkg dist-bundle/server.cjs --targets node20-win-x64,node20-macos-x64,node20-linux-x64 --output bin/rpg-mcp', {
execSync('npx pkg dist-bundle/server.cjs --targets node20-win-x64,node20-macos-x64,node20-macos-arm64,node20-linux-x64 --output bin/rpg-mcp', {
stdio: 'inherit',
cwd: process.cwd()
});
Expand All @@ -435,10 +435,11 @@ async function build() {
console.log(' ⚠️ NOT using local node_modules (compiled for different Node version)');
console.log('');

const platforms = ['win32-x64', 'darwin-x64', 'linux-x64'];
const platforms = ['win32-x64', 'darwin-x64', 'darwin-arm64', 'linux-x64'];
const platformSuffixes = {
'win32-x64': 'win',
'darwin-x64': 'macos',
'darwin-x64': 'macos',
'darwin-arm64': 'macos-arm64',
'linux-x64': 'linux'
};

Expand Down Expand Up @@ -482,17 +483,25 @@ async function build() {
console.log('');
console.log('📦 Deployment instructions:');
console.log('');
console.log(' IMPORTANT: Create ../src-tauri/binaries/ directory if it doesn\'t exist:');
console.log(' mkdir -p ../src-tauri/binaries (macOS/Linux)');
console.log(' md ..\\src-tauri\\binaries (Windows, if not exists)');
console.log('');
console.log(' For Tauri (Windows):');
console.log(' copy bin\\rpg-mcp-win.exe src-tauri\\binaries\\rpg-mcp-server-x86_64-pc-windows-msvc.exe');
console.log(' copy bin\\better_sqlite3.node src-tauri\\binaries\\');
console.log(' copy bin\\rpg-mcp-win.exe ..\\src-tauri\\binaries\\rpg-mcp-server-x86_64-pc-windows-msvc.exe');
console.log(' copy bin\\better_sqlite3.node ..\\src-tauri\\binaries\\');
console.log('');
console.log(' For Tauri (macOS Intel):');
console.log(' cp bin/rpg-mcp-macos ../src-tauri/binaries/rpg-mcp-server-x86_64-apple-darwin');
console.log(' cp bin/better_sqlite3-macos.node ../src-tauri/binaries/better_sqlite3.node');
console.log('');
console.log(' For Tauri (macOS):');
console.log(' cp bin/rpg-mcp-macos src-tauri/binaries/rpg-mcp-server-x86_64-apple-darwin');
console.log(' cp bin/better_sqlite3-macos.node src-tauri/binaries/better_sqlite3.node');
console.log(' For Tauri (macOS Apple Silicon):');
console.log(' cp bin/rpg-mcp-macos-arm64 ../src-tauri/binaries/rpg-mcp-server-aarch64-apple-darwin');
console.log(' cp bin/better_sqlite3-macos-arm64.node ../src-tauri/binaries/better_sqlite3.node');
console.log('');
console.log(' For Tauri (Linux):');
console.log(' cp bin/rpg-mcp-linux src-tauri/binaries/rpg-mcp-server-x86_64-unknown-linux-gnu');
console.log(' cp bin/better_sqlite3-linux.node src-tauri/binaries/better_sqlite3.node');
console.log(' cp bin/rpg-mcp-linux ../src-tauri/binaries/rpg-mcp-server-x86_64-unknown-linux-gnu');
console.log(' cp bin/better_sqlite3-linux.node ../src-tauri/binaries/better_sqlite3.node');
console.log('');

} catch (error) {
Expand Down
111 changes: 90 additions & 21 deletions src/engine/combat/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export interface CombatParticipant {
deathSaveFailures?: number; // 0-3, 3 = dead
isStabilized?: boolean; // Unconscious but won't die
isDead?: boolean; // Permanently defeated
// COMBAT STATS (Auto-resolution)
ac?: number; // Armor Class
attackDamage?: string; // Default attack damage (e.g., "1d6+2")
attackBonus?: number; // Default attack bonus used if none provided
}

/**
Expand Down Expand Up @@ -193,6 +197,9 @@ export class CombatEngine {
const rolledInitiative = this.rng.d20(p.initiativeBonus);
return {
...p,
ac: p.ac,
attackDamage: p.attackDamage,
attackBonus: p.attackBonus,
initiative: rolledInitiative,
// Auto-detect isEnemy if not explicitly set
isEnemy: p.isEnemy ?? this.detectIsEnemy(p.id, p.name),
Expand Down Expand Up @@ -258,30 +265,45 @@ export class CombatEngine {

/**
* Auto-detect if a participant is an enemy based on ID/name patterns
*
* IMPORTANT: UUIDs (like "9e48fa16-0ee4-4b99-a1e0-a162528d1e24") are typically
* player characters created via the UI. Pattern-based IDs (like "goblin-1",
* "orc-archer-2") are typically spawned enemies. Default to false for UUIDs.
*/
private detectIsEnemy(id: string, name: string): boolean {
const idLower = id.toLowerCase();
const nameLower = name.toLowerCase();

// Common enemy patterns
// Common enemy patterns - check NAME first (most reliable for determination)
const enemyPatterns = [
'goblin', 'orc', 'wolf', 'bandit', 'skeleton', 'zombie',
'dragon', 'troll', 'ogre', 'kobold', 'gnoll', 'demon',
'devil', 'undead', 'enemy', 'monster', 'creature', 'beast',
'spider', 'rat', 'bat', 'slime', 'ghost', 'wraith',
'dracolich', 'lich', 'vampire', 'golem', 'elemental'
'dracolich', 'lich', 'vampire', 'golem', 'elemental',
'cultist', 'thug', 'assassin', 'minion', 'guard', 'scout',
'warrior', 'archer', 'mage', 'shaman', 'warlord', 'boss'
];

// Check if ID or name contains enemy patterns
// Check NAME for enemy patterns (more reliable since IDs can be UUIDs)
for (const pattern of enemyPatterns) {
if (idLower.includes(pattern) || nameLower.includes(pattern)) {
if (nameLower.includes(pattern)) {
return true;
}
}

// Check ID for enemy patterns (for pattern-based IDs like "goblin-1")
for (const pattern of enemyPatterns) {
if (idLower.includes(pattern)) {
return true;
}
}

// Common player/ally patterns (not enemies)
const allyPatterns = [
'hero', 'player', 'pc', 'ally', 'companion', 'npc-friendly'
'hero', 'player', 'pc', 'ally', 'companion', 'npc-friendly',
'party', 'adventurer', 'cleric', 'paladin', 'ranger', 'rogue',
'wizard', 'sorcerer', 'warlock', 'bard', 'druid', 'monk', 'fighter'
];

for (const pattern of allyPatterns) {
Expand All @@ -290,8 +312,23 @@ export class CombatEngine {
}
}

// Default: assume it's an enemy if not clearly a player
return !idLower.startsWith('player') && !idLower.startsWith('hero');
// Check if ID looks like a UUID (player characters created via UI have UUIDs)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidPattern.test(id)) {
// UUIDs are typically player characters - default to NOT enemy
return false;
}

// Default: for non-UUID IDs that don't match patterns, check if ID starts with enemy pattern
// This catches pattern-based IDs like "enemy-1" or "mob-3"
if (idLower.startsWith('enemy') || idLower.startsWith('mob') || idLower.startsWith('hostile')) {
return true;
}

// Fallback default: unknown entities default to NOT enemy
// Reasoning: It's safer to have an enemy show as friendly (player corrects it)
// than to have a player character show as enemy (breaks immersion)
return false;
}

/**
Expand Down Expand Up @@ -511,7 +548,7 @@ export class CombatEngine {
targetId: string,
attackBonus: number,
dc: number,
damage: number,
damage: number | string,
damageType?: string // HIGH-002: Optional damage type for resistance calculation
): CombatActionResult {
if (!this.state) throw new Error('No active combat');
Expand All @@ -530,10 +567,26 @@ export class CombatEngine {
let damageDealt = 0;
let damageModifier: 'immune' | 'resistant' | 'vulnerable' | 'normal' = 'normal';

// Calculate base damage from number or string
let baseDamageVal = 0;
let damageBreakdownStr = '';

if (typeof damage === 'string') {
const dmgResult = this.rng.rollDamageDetailed(damage);
baseDamageVal = dmgResult.total;
Comment on lines +570 to +576
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 Roll damage only after a hit is confirmed

executeAttack rolls dice for string damage before checking attackRoll.isHit, so misses still consume RNG and can throw on invalid damage expressions. This changes subsequent random outcomes (deterministic seeds diverge) and makes misses capable of failing the action even though no damage should be resolved. Move the damage roll inside the hit branch to keep RNG sequencing and error handling consistent with hit/miss results.

Useful? React with 👍 / 👎.

damageBreakdownStr = ` (${dmgResult.rolls.join('+')}${dmgResult.modifier >= 0 ? '+' + dmgResult.modifier : dmgResult.modifier})`;
} else {
baseDamageVal = damage;
}

if (attackRoll.isHit) {
const baseDamage = attackRoll.isCrit ? damage * 2 : damage;
// Critical Hit: Double the dice (approx. double the value for now if passing number)
// If string was passed, we ideally double the DICE, but for now double the total is consistent with current impl.
// TODO: Implement proper crit rules (double dice) later using rollDamageDetailed
const finalBaseDamage = attackRoll.isCrit ? baseDamageVal * 2 : baseDamageVal;

// HIGH-002: Apply resistance/vulnerability/immunity
const modResult = this.calculateDamageWithModifiers(baseDamage, damageType, target);
const modResult = this.calculateDamageWithModifiers(finalBaseDamage, damageType, target);
damageDealt = modResult.finalDamage;
damageModifier = modResult.modifier;
target.hp = Math.max(0, target.hp - damageDealt);
Expand Down Expand Up @@ -566,7 +619,7 @@ export class CombatEngine {
modStr = ' [Vulnerable - Doubled!]';
}

breakdown += `\n\n💥 Damage: ${damageDealt}${typeStr}${attackRoll.isCrit ? ' (crit)' : ''}${modStr}\n`;
breakdown += `\n\n💥 Damage: ${damageDealt}${typeStr}${damageBreakdownStr}${attackRoll.isCrit ? ' (crit)' : ''}${modStr}\n`;
breakdown += ` ${target.name}: ${hpBefore} → ${target.hp}/${target.maxHp} HP`;
if (defeated) {
breakdown += ` [DEFEATED]`;
Expand Down Expand Up @@ -1033,27 +1086,43 @@ export class CombatEngine {

/**
* Enhanced nextTurn with condition processing and legendary action reset
* Now auto-skips dead participants (HP <= 0)
*/
nextTurnWithConditions(): CombatParticipant | null {
if (!this.state) return null;

// Process end-of-turn conditions for current participant (if not LAIR)
const currentParticipant = this.getCurrentParticipant();
if (currentParticipant) {
if (currentParticipant && currentParticipant.hp > 0) {
this.processEndOfTurnConditions(currentParticipant);
}

// Advance turn
this.state.currentTurnIndex++;
// Advance turn, automatically skipping dead participants
let iterations = 0;
const maxIterations = this.state.turnOrder.length + 1; // Safety limit
let newParticipant: CombatParticipant | null = null;

if (this.state.currentTurnIndex >= this.state.turnOrder.length) {
this.state.currentTurnIndex = 0;
this.state.round++;
}
do {
// Advance turn index
this.state.currentTurnIndex++;

if (this.state.currentTurnIndex >= this.state.turnOrder.length) {
this.state.currentTurnIndex = 0;
this.state.round++;
}

newParticipant = this.getCurrentParticipant();
iterations++;

// Exit if we found a living participant or exhausted all options
} while (
newParticipant &&
newParticipant.hp <= 0 &&
iterations < maxIterations
);

// Process start-of-turn conditions for new current participant (if not LAIR)
const newParticipant = this.getCurrentParticipant();
if (newParticipant) {
// Process start-of-turn conditions for new current participant (if alive)
if (newParticipant && newParticipant.hp > 0) {
this.processStartOfTurnConditions(newParticipant);
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Main entry point for the Unified MCP Simulation Server
console.error('rpg-mcp-server v1.0.5 - FIXED SPELL VALIDATION');
export * from './schema/index.js';
export * from './storage/index.js';

Expand Down
6 changes: 5 additions & 1 deletion src/schema/encounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ export const TokenSchema = z.object({
intelligence: z.number(),
wisdom: z.number(),
charisma: z.number()
}).optional()
}).optional(),
// Combat Stats for Auto-Resolution
ac: z.number().optional().describe('Armor Class for auto-resolution'),
attackDamage: z.string().optional().describe('Default attack damage (e.g., "1d6+2")'),
attackBonus: z.number().optional().describe('Default attack bonus')
});

export type Token = z.infer<typeof TokenSchema>;
Expand Down
Loading
0