From c223912915a26e22dbf488f1ccdf594c4fac5063 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Wed, 1 Jul 2026 08:19:01 +1000 Subject: [PATCH] feat: wire up stuck-weapon follow-up for impale/slash specials Add POST /api/combat/stuck-weapon-removal endpoint supporting three removal types: attacker (weapon skill roll), self (STR+CON roll), and first-aid (First Aid skill roll). After a special hit with damage through, the attack result UI shows a stuck-weapon card with buttons for each removal option. --- TODO.md | 2 +- public/app.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++++-- server.js | 35 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index ac146ca..e91b9af 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ ## High priority (mechanics gaps) - [ ] Call `resolveHeadInjury()` in the attack handler when a Head hit penetrates armor -- [ ] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`) +- [x] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`) ## Medium priority (missing features) diff --git a/public/app.js b/public/app.js index d8bf583..3a59301 100644 --- a/public/app.js +++ b/public/app.js @@ -574,6 +574,45 @@ function combatantOptions(selectedId) { return combatants().map((c) => ``).join(''); } +function renderStuckWeaponFollowUp({ attackerCombatantId, defenderCombatantId, weaponName, kind }) { + const wrap = el(`
`); + wrap.appendChild(el(`

Weapon stuck (${kind}). Choose removal attempt:

`)); + const removalResult = el(`
`); + + async function doRemoval(removalType, extra = {}) { + try { + const res = await api('POST', '/api/combat/stuck-weapon-removal', { + attackerCombatantId, defenderCombatantId, weaponName, kind, removalType, ...extra, + }); + state.combat = res.combatState; + const r = res.result; + const outcome = r.weaponBreaks ? 'Weapon breaks!' : r.success ? 'Weapon removed successfully.' : 'Weapon stays stuck.'; + removalResult.innerHTML = `

${escapeHtml(outcome)}

`; + await loadLog(); + renderSidebar(); + renderLog(); + } catch (err) { + removalResult.innerHTML = `

${escapeHtml(err.message)}

`; + } + } + + const attackerBtn = el(``); + attackerBtn.addEventListener('click', () => doRemoval('attacker')); + + const selfBtn = el(``); + selfBtn.addEventListener('click', () => doRemoval('self')); + + const faSkill = el(``); + const faBtn = el(``); + faBtn.addEventListener('click', () => doRemoval('first-aid', { firstAidSkillPercent: Number(faSkill.value) || 0 })); + + const btnRow = el(`
`); + btnRow.append(attackerBtn, selfBtn, faSkill, faBtn); + wrap.appendChild(btnRow); + wrap.appendChild(removalResult); + return wrap; +} + function renderCombatMain() { const wrap = el(`
`); if (combatants().length < 1) { @@ -632,12 +671,27 @@ function renderCombatMain() { try { const res = await api('POST', '/api/combat/attack', body); state.combat = res.combatState; - resultBox.innerHTML = ` + resultBox.innerHTML = ''; + const summary = el(`

${tierTag(res.result.attackCheck.tier)} roll ${res.result.attackCheck.roll} vs ${res.result.effectiveSkillPercent}% (base ${res.result.effectiveSkillPercent - res.result.modifierTotal}${res.result.modifierTotal ? `, modifiers ${res.result.modifierTotal > 0 ? '+' : ''}${res.result.modifierTotal}` : ''})

${res.result.hitLocationRoll ? `

Hit location: ${res.result.hitLocationRoll.location}

` : ''} ${res.result.damageThrough != null ? `

Damage through: ${res.result.damageThrough}

` : ''} ${res.result.fumble ? `

Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}

` : ''} - `; +
`); + resultBox.appendChild(summary); + + const special = res.result.damageResult?.special; + const weaponStuck = (special === 'impale' || special === 'slash') && res.result.damageThrough > 0; + if (weaponStuck) { + const stuckBox = renderStuckWeaponFollowUp({ + attackerCombatantId: body.attackerCombatantId, + defenderCombatantId: body.defenderCombatantId, + weaponName: body.weaponName, + kind: special, + }); + resultBox.appendChild(stuckBox); + } + await loadLog(); renderSidebar(); renderLog(); diff --git a/server.js b/server.js index 8945ea0..6e7cf18 100644 --- a/server.js +++ b/server.js @@ -421,6 +421,41 @@ app.post('/api/combat/attack', (req, res) => { res.json({ combatState: dbApi.combat.get(), result }); }); +app.post('/api/combat/stuck-weapon-removal', (req, res) => { + const { attackerCombatantId, defenderCombatantId, weaponName, kind, removalType, firstAidSkillPercent } = req.body || {}; + const current = dbApi.combat.get(); + if (!current) return res.status(400).json({ error: 'No active combat' }); + const state = current.state; + const attacker = (state.combatants || []).find((c) => c.id === attackerCombatantId); + const defender = (state.combatants || []).find((c) => c.id === defenderCombatantId); + if (!attacker || !defender) return res.status(400).json({ error: 'Unknown attacker or defender combatant id' }); + if (kind !== 'impale' && kind !== 'slash') return res.status(400).json({ error: 'kind must be impale or slash' }); + + let result; + if (removalType === 'attacker') { + const weaponEntry = attacker.weapons.find((w) => w.weapon_name.toLowerCase() === (weaponName || '').toLowerCase()); + if (!weaponEntry) return res.status(400).json({ error: `Attacker has no weapon named "${weaponName}"` }); + result = rq3.attemptWeaponRemoval(weaponEntry.skill_percent, kind); + result.removalType = 'attacker'; + } else if (removalType === 'self') { + result = { success: rq3.removeStuckWeaponFromSelf(defender.str + defender.con), weaponBreaks: false, removalType: 'self' }; + } else if (removalType === 'first-aid') { + const pct = Number(firstAidSkillPercent) || 0; + result = { success: rq3.removeStuckWeaponWithFirstAid(pct), weaponBreaks: false, removalType: 'first-aid' }; + } else { + return res.status(400).json({ error: 'removalType must be attacker, self, or first-aid' }); + } + + const outcome = result.weaponBreaks ? 'weapon breaks' : result.success ? 'weapon removed' : 'weapon stays stuck'; + dbApi.log.append({ + type: 'combat', + summary: `Stuck-weapon removal (${removalType}, ${kind}): ${outcome}`, + details: result, + }); + + res.json({ combatState: dbApi.combat.get(), result }); +}); + app.post('/api/combat/cast-spell', (req, res) => { const { casterCombatantId, targetCombatantId, mechanicId, mpSpent } = req.body || {}; const current = dbApi.combat.get();