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.
This commit is contained in:
2026-07-01 08:19:01 +10:00
parent 4742a7d876
commit c223912915
3 changed files with 92 additions and 3 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
## High priority (mechanics gaps) ## High priority (mechanics gaps)
- [ ] Call `resolveHeadInjury()` in the attack handler when a Head hit penetrates armor - [ ] 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) ## Medium priority (missing features)
+56 -2
View File
@@ -574,6 +574,45 @@ function combatantOptions(selectedId) {
return combatants().map((c) => `<option value="${c.id}" ${c.id === selectedId ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join(''); return combatants().map((c) => `<option value="${c.id}" ${c.id === selectedId ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join('');
} }
function renderStuckWeaponFollowUp({ attackerCombatantId, defenderCombatantId, weaponName, kind }) {
const wrap = el(`<div class="card" style="margin-top:0.75rem"></div>`);
wrap.appendChild(el(`<p><strong>Weapon stuck (${kind}).</strong> Choose removal attempt:</p>`));
const removalResult = el(`<div></div>`);
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 = `<p>${escapeHtml(outcome)}</p>`;
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
removalResult.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
}
const attackerBtn = el(`<button class="button">Attacker removes weapon</button>`);
attackerBtn.addEventListener('click', () => doRemoval('attacker'));
const selfBtn = el(`<button class="button">Target removes from self</button>`);
selfBtn.addEventListener('click', () => doRemoval('self'));
const faSkill = el(`<input type="number" placeholder="First Aid %" style="width:7rem">`);
const faBtn = el(`<button class="button">Remove with First Aid</button>`);
faBtn.addEventListener('click', () => doRemoval('first-aid', { firstAidSkillPercent: Number(faSkill.value) || 0 }));
const btnRow = el(`<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>`);
btnRow.append(attackerBtn, selfBtn, faSkill, faBtn);
wrap.appendChild(btnRow);
wrap.appendChild(removalResult);
return wrap;
}
function renderCombatMain() { function renderCombatMain() {
const wrap = el(`<div></div>`); const wrap = el(`<div></div>`);
if (combatants().length < 1) { if (combatants().length < 1) {
@@ -632,12 +671,27 @@ function renderCombatMain() {
try { try {
const res = await api('POST', '/api/combat/attack', body); const res = await api('POST', '/api/combat/attack', body);
state.combat = res.combatState; state.combat = res.combatState;
resultBox.innerHTML = ` resultBox.innerHTML = '';
const summary = el(`<div>
<p>${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}` : ''})</p> <p>${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}` : ''})</p>
${res.result.hitLocationRoll ? `<p>Hit location: ${res.result.hitLocationRoll.location}</p>` : ''} ${res.result.hitLocationRoll ? `<p>Hit location: ${res.result.hitLocationRoll.location}</p>` : ''}
${res.result.damageThrough != null ? `<p>Damage through: ${res.result.damageThrough}</p>` : ''} ${res.result.damageThrough != null ? `<p>Damage through: ${res.result.damageThrough}</p>` : ''}
${res.result.fumble ? `<p>Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}</p>` : ''} ${res.result.fumble ? `<p>Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}</p>` : ''}
`; </div>`);
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(); await loadLog();
renderSidebar(); renderSidebar();
renderLog(); renderLog();
+35
View File
@@ -421,6 +421,41 @@ app.post('/api/combat/attack', (req, res) => {
res.json({ combatState: dbApi.combat.get(), result }); 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) => { app.post('/api/combat/cast-spell', (req, res) => {
const { casterCombatantId, targetCombatantId, mechanicId, mpSpent } = req.body || {}; const { casterCombatantId, targetCombatantId, mechanicId, mpSpent } = req.body || {};
const current = dbApi.combat.get(); const current = dbApi.combat.get();