From 97376043cd50d0c49024393d972d995ed616be36 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 10:41:11 -0700 Subject: [PATCH] Change Merc perk from VAULT to BREAK, allow shove when cornered --- components/KeyHelp.ts | 10 ++- debug/index.html | 2 +- debug/index.ts | 2 +- src/game/archetypes/Merc.ts | 124 +++++++++++++++++++-------- src/game/archetypes/index.ts | 6 +- src/game/constants.ts | 9 +- src/input/applyIntent.ts | 22 +++-- tests/unit/game/Merc.test.ts | 71 ++++++++++++++- tests/unit/input/applyIntent.test.ts | 28 +++++- 9 files changed, 213 insertions(+), 61 deletions(-) diff --git a/components/KeyHelp.ts b/components/KeyHelp.ts index 29d5901..91a3225 100644 --- a/components/KeyHelp.ts +++ b/components/KeyHelp.ts @@ -568,9 +568,13 @@ class KeyHelp extends HTMLElement { if (groupRows.length === 0) continue; const dl = h('dl', { className: 'rows' }); for (const r of groupRows) { - const label = r.label.includes('{perkLabel}') - ? r.label.replace('{perkLabel}', this.#archetypeInfo?.perkLabel ?? '') - : r.label; + let label = r.label; + if (label.includes('{perkName}')) { + label = label.replace('{perkName}', this.#archetypeInfo?.perkName ?? ''); + } + if (label.includes('{perkLabel}')) { + label = label.replace('{perkLabel}', this.#archetypeInfo?.perkLabel ?? ''); + } dl.appendChild(h('dt', { textContent: joinKeys(r.keys as string[]) })); dl.appendChild(h('dd', { textContent: label })); } diff --git a/debug/index.html b/debug/index.html index 89eb464..d50291e 100644 --- a/debug/index.html +++ b/debug/index.html @@ -98,7 +98,7 @@

Help

Move: ←↑↓→ or WASD, q e z c diagonal. Into an adjacent hostile = melee. Fire: f + dir. Special - (Vault / Slide / Deploy): x + dir. Interact: Space. Wait / pass turn: + (Break / Slide / Deploy): x + dir. Interact: Space. Wait / pass turn: .. Cancel aim: Esc. Archetype: 1 Merc / 2 Razor / 3 Tech. Reset: r.

diff --git a/debug/index.ts b/debug/index.ts index 5d1637a..8ef7e8b 100644 --- a/debug/index.ts +++ b/debug/index.ts @@ -294,7 +294,7 @@ function logModeChange(state: { mode: Mode; aimKind: AimKind | null }) { // even though the keystroke and mode are now shared. const verb = archetype === 'merc' - ? 'VAULT' + ? 'BREAK' : archetype === 'razor' ? 'SLIDE' : archetype === 'tech' diff --git a/src/game/archetypes/Merc.ts b/src/game/archetypes/Merc.ts index 39bbfee..88f59d6 100644 --- a/src/game/archetypes/Merc.ts +++ b/src/game/archetypes/Merc.ts @@ -2,8 +2,13 @@ import { Crew } from '../Crew.js'; import { TILE, AP_COST, FACTION, MERC_RANGED_DAMAGE } from '../constants.js'; import { canKnockbackByOffset } from '../knockback.js'; import type { CrewInit } from '../Crew.js'; +import type { Entity } from '../Entity.js'; import type { World } from '../World.js'; +export type VaultCheck = + | { ok: true; mode: 'hop' | 'shove'; occupant: Entity | null } + | { ok: false; reason: string }; + /** * Curated callsign pool for the Merc archetype. `buildCrewMember` in * `archetypes/index.js` picks one of these using a campaign-scoped `Rng`; @@ -30,30 +35,27 @@ export const CALLSIGNS = Object.freeze([ /** * Merc — ranged-combat archetype. Perk: **Vault** (breach-and-clear slam). * - * Vault hops a single COVER tile and lands two squares away in the same - * direction. The hopped tile must be COVER (the whole point of the perk) and - * the landing tile must be passable and in-bounds. Diagonal vaults are - * allowed under the same Chebyshev rule the rest of movement uses. + * Vault has two modes, evaluated in order for a chosen direction `(dx, dy)`: * - * Vault is repeatable (no one-shot gate) — the AP cost is the only limit. + * **Hop (cover vault):** hops a single COVER tile and lands two squares away. + * The hopped tile must be COVER; the landing tile must be passable and + * in-bounds. Diagonal vaults use the same Chebyshev rule as movement. * - * If a hostile entity occupies the landing tile, the Merc body-checks them: + * If a hostile occupies the landing tile, the Merc body-checks them: * - The hostile takes `VAULT_DAMAGE` (2). * - The hostile is knocked back 1 tile in the vault direction. * - The Merc lands on the tile the hostile vacated. * - * Vault is denied when: - * - A hostile is on the landing tile but the knockback destination is - * blocked (wall, OOB, occupied) — the Merc needs a clear lane. - * - A friendly entity occupies the landing tile. - * - An impassable neutral prop (objective pickup, terminal, …) blocks - * the landing tile. Walk-onto consumable pickups (`*`) are passable and - * do not block — the Merc lands and collects them like a normal step. - * - The landing tile is not walkable terrain (same passability as a normal - * step: floor, exit, smoke, hazard — not cover or wall). + * An empty landing (or walk-onto consumable only) is pure repositioning. + * + * **Shove (adjacent body-check):** when the hop path fails because there is + * no cover to vault over, Break can still fire if a hostile occupies the + * adjacent tile `(x+dx, y+dy)`. The Merc deals `VAULT_DAMAGE`, knocks the + * hostile back one tile in the same direction, and steps back one tile in + * the opposite direction when that retreat lane is clear. Blocked knockback + * destination means denial; blocked retreat still commits the shove. * - * If the landing tile is empty (or only holds a walk-onto consumable), vault - * is pure repositioning (no damage). + * Vault is repeatable (no one-shot gate) — AP cost is the only limit. */ export class Merc extends Crew { override archetype = 'Merc'; @@ -69,7 +71,7 @@ export class Merc extends Crew { super({ ...props, glyph: '@' }); } - canVault(world: World, dx: number, dy: number) { + canVault(world: World, dx: number, dy: number): VaultCheck { if (dx === 0 && dy === 0) { return { ok: false, reason: 'no-op' }; } @@ -80,6 +82,16 @@ export class Merc extends Crew { return { ok: false, reason: 'insufficient-ap' }; } + const hop = this.#canVaultHop(world, dx, dy); + if (hop.ok) return hop; + // Only fall through to adjacent shove when cover blocked the hop — a hop + // denied for knockback, allies, OOB, etc. is authoritative. + if (hop.reason !== 'no-cover') return hop; + + return this.#canVaultShove(world, dx, dy); + } + + #canVaultHop(world: World, dx: number, dy: number): VaultCheck { const hopX = this.x + dx; const hopY = this.y + dy; const landX = this.x + 2 * dx; @@ -102,7 +114,7 @@ export class Merc extends Crew { let occupant = world.entityAt(landX, landY); if (!occupant && world.consumablePickupAt(landX, landY)) { - return { ok: true, occupant: null }; + return { ok: true, mode: 'hop', occupant: null }; } if (occupant) { if (occupant.faction === this.faction) { @@ -123,17 +135,45 @@ export class Merc extends Crew { } } - return { ok: true, occupant: occupant ?? null }; + return { ok: true, mode: 'hop', occupant: occupant ?? null }; + } + + #canVaultShove(world: World, dx: number, dy: number): VaultCheck { + const adjX = this.x + dx; + const adjY = this.y + dy; + + if (!world.grid.inBounds(adjX, adjY)) { + return { ok: false, reason: 'no-target' }; + } + + const occupant = world.entityAt(adjX, adjY); + if (!occupant) { + return { ok: false, reason: 'no-target' }; + } + if (occupant.faction === this.faction) { + return { ok: false, reason: 'friendly-occupied' }; + } + if (occupant.passable || world.consumablePickupAt(adjX, adjY)) { + return { ok: false, reason: 'no-target' }; + } + if (occupant.faction === FACTION.NEUTRAL) { + return { ok: false, reason: 'blocked' }; + } + + const knockback = canKnockbackByOffset(world, occupant, dx, dy); + if (!knockback.ok) return { ok: false, reason: knockback.reason }; + + return { ok: true, mode: 'shove', occupant }; } /** - * Execute the vault. Moves the Merc, and if a hostile occupies the landing - * tile, knocks them back first. Damage is applied by `applyIntent.doVault` - * (not here) so that Merc.vault stays a pure movement+knockback operation - * and Combat event wiring remains in the intent layer. + * Execute the vault. Hop mode moves the Merc two tiles out; shove mode + * knocks an adjacent hostile back and steps the Merc away when the retreat + * lane is clear. Damage is applied by `applyIntent.doVault` (not here) so + * Combat event wiring remains in the intent layer. * - * Returns `{ occupant }` — the entity that was knocked back, or null. - * The caller uses this to apply VAULT_DAMAGE via the damage system. + * Returns `{ occupant, mode }` — the entity knocked back (if any) and which + * vault mode committed. */ vault(world: World, dx: number, dy: number) { const check = this.canVault(world, dx, dy); @@ -141,20 +181,36 @@ export class Merc extends Crew { throw new Error(`Illegal vault for ${this.id}: ${check.reason}`); } - const occupant = check.occupant; + const { occupant, mode } = check; - // Knockback hostile before Merc lands. `relocateEntity` validates - // bounds/passability/occupancy and emits ENTITY_MOVED — no silent - // bypasses. (#7 adversarial review) + // Knockback hostile before Merc lands (hop) or retreats (shove). + // `relocateEntity` validates bounds/passability/occupancy and emits + // ENTITY_MOVED — no silent bypasses. (#7 adversarial review) if (occupant) { world.relocateEntity(occupant, occupant.x + dx, occupant.y + dy, { allowCover: true }); } this.spendAp(AP_COST.VAULT); - // Merc lands where the hostile was (or on the empty tile). - // `relocateEntity` handles bounds/occupancy checks + event emission. - world.relocateEntity(this, this.x + 2 * dx, this.y + 2 * dy); - return { occupant }; + if (mode === 'hop') { + // Merc lands where the hostile was (or on the empty tile). + world.relocateEntity(this, this.x + 2 * dx, this.y + 2 * dy); + } else { + this.#tryShoveRetreat(world, dx, dy); + } + + return { occupant, mode }; + } + + /** Step back one tile opposite the shove direction when the lane is clear. */ + #tryShoveRetreat(world: World, dx: number, dy: number) { + const rx = this.x - dx; + const ry = this.y - dy; + if (!world.grid.inBounds(rx, ry) || !world.grid.isPassable(rx, ry)) return; + + const blocker = world.entityAt(rx, ry); + if (blocker && !blocker.passable && !world.consumablePickupAt(rx, ry)) return; + + world.relocateEntity(this, rx, ry); } } diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index e6d292d..11cb1b9 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -52,10 +52,10 @@ export const ARCHETYPES = Object.freeze({ merc: Object.freeze({ id: 'merc', name: 'MERC', - blurb: 'Long-range pressure. Vault repositions under fire.', + blurb: 'Long-range pressure. Break creates space under fire.', perks: Object.freeze(['vault']), - perkName: 'VAULT', - perkLabel: 'Mercs can VAULT: hop cover & knock enemies back', + perkName: 'BREAK', + perkLabel: 'Mercs can BREAK: hop cover / knock enemies back', }), razor: Object.freeze({ id: 'razor', diff --git a/src/game/constants.ts b/src/game/constants.ts index 4c89493..8cf284b 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -135,10 +135,11 @@ export const MELEE_DAMAGE = 2; export const HEAVY_MELEE_DAMAGE = 3; /** - * Vault (Merc perk). Breach-and-clear slam: hop over cover, body-check a - * hostile on the landing tile for VAULT_DAMAGE, knock them back 1 tile in - * the vault direction. Repeatable (no one-shot gate). Damage matches melee - * — the positional cost of lining up a clear knockback lane is the gate. + * Vault (Merc perk). Breach-and-clear slam in two modes: + * - **Hop:** vault over cover; body-check a hostile on the landing tile. + * - **Shove:** adjacent body-check; knock the target back and step away when clear. + * Both deal VAULT_DAMAGE and knock the target back 1 tile in the aim direction + * when the knockback lane is clear. Repeatable — AP cost is the gate. */ export const VAULT_DAMAGE = 2; diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 72f7d99..0ba9851 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -369,14 +369,14 @@ function doVault(intent: Intent, ctx: ApplyIntentContext) { const playerLabel = entityLabel(player); const check = merc.canVault(world, intent.dx!, intent.dy!); if (!check?.ok) { - log(`> ${playerLabel} VAULT DENIED: ${check?.reason}`); + log(`> ${playerLabel} BREAK DENIED: ${check?.reason}`); return; } // vault() handles the hop, knockback displacement, and AP debit. // It returns the occupant (if any) so we can apply damage here — keeping // Combat event wiring in the intent layer, not inside the archetype. - const { occupant } = merc.vault(world, intent.dx!, intent.dy!); + const { occupant, mode } = merc.vault(world, intent.dx!, intent.dy!); if (occupant) { // Body-check: guaranteed hit, VAULT_DAMAGE, no RNG roll. @@ -396,13 +396,19 @@ function doVault(intent: Intent, ctx: ApplyIntentContext) { source: player, kind: 'vault', }); - log( - `> ${playerLabel} vaulted to (${player.x}, ${player.y}) — SLAMMED ${entityLabel(occupant)} for ${VAULT_DAMAGE} damage!` + - (killed ? ` ${entityLabel(occupant).toUpperCase()} DOWN.` : '') + - ` — ${player.ap} AP left.` - ); + const slamTail = + (killed ? ` ${entityLabel(occupant).toUpperCase()} DOWN.` : '') + ` — ${player.ap} AP left.`; + if (mode === 'shove') { + log( + `> ${playerLabel} shoved ${entityLabel(occupant)} back — SLAMMED for ${VAULT_DAMAGE} damage!${slamTail}` + ); + } else { + log( + `> ${playerLabel} broke through to (${player.x}, ${player.y}) — SLAMMED ${entityLabel(occupant)} for ${VAULT_DAMAGE} damage!${slamTail}` + ); + } } else { - log(`> ${playerLabel} vaulted to (${player.x}, ${player.y}) — ${player.ap} AP left.`); + log(`> ${playerLabel} broke through to (${player.x}, ${player.y}) — ${player.ap} AP left.`); } collectTileLoot(ctx); diff --git a/tests/unit/game/Merc.test.ts b/tests/unit/game/Merc.test.ts index dd2fe01..d22de44 100644 --- a/tests/unit/game/Merc.test.ts +++ b/tests/unit/game/Merc.test.ts @@ -42,16 +42,16 @@ test('Merc.canVault rejects when AP < VAULT cost', () => { assert.equal(merc.canVault(world, 1, 0).reason, 'insufficient-ap'); }); -test('Merc.canVault rejects if the hopped tile is FLOOR (nothing to vault)', () => { +test('Merc.canVault rejects if the hopped tile is FLOOR and no adjacent hostile', () => { const { world, merc } = makeWorld(); // all floor - assert.equal(merc.canVault(world, 1, 0).reason, 'no-cover'); + assert.equal(merc.canVault(world, 1, 0).reason, 'no-target'); }); -test('Merc.canVault rejects if the hopped tile is a WALL', () => { +test('Merc.canVault rejects if the hopped tile is a WALL and no adjacent hostile', () => { const g = new Grid(8, 8); g.setTile(4, 3, TILE.WALL); const { world, merc } = makeWorld({ grid: g }); - assert.equal(merc.canVault(world, 1, 0).reason, 'no-cover'); + assert.equal(merc.canVault(world, 1, 0).reason, 'no-target'); }); test('Merc.canVault rejects if the landing tile is out of bounds', () => { @@ -133,6 +133,7 @@ test('Merc.canVault allows landing on a hostile when knockback lane is clear', ( const { world, merc } = makeWorld({ grid: g, extraEntities: [drone] }); const check = merc.canVault(world, 1, 0); assert.equal(check.ok, true); + assert.equal(check.mode, 'hop'); assert.equal(check.occupant, drone); }); @@ -277,3 +278,65 @@ test('Merc.canVault allows landing on a consumable placed on a hazard tile', () assert.equal(merc.x, 5); assert.equal(merc.y, 3); }); + +// --------------------------------------------------------------------------- +// Vault adjacent shove (no cover required) +// --------------------------------------------------------------------------- + +test('Merc.canVault accepts adjacent shove when hop tile is floor and knockback is clear', () => { + const drone = new Entity({ id: 'd', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); + const { world, merc } = makeWorld({ extraEntities: [drone] }); + const check = merc.canVault(world, 1, 0); + assert.equal(check.ok, true); + assert.equal(check.mode, 'shove'); + assert.equal(check.occupant, drone); +}); + +test('Merc.canVault rejects adjacent shove when knockback lane is blocked', () => { + const g = new Grid(8, 8); + g.setTile(5, 3, TILE.WALL); + const drone = new Entity({ id: 'd', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); + const { world, merc } = makeWorld({ grid: g, extraEntities: [drone] }); + assert.equal(merc.canVault(world, 1, 0).reason, 'knockback-blocked'); +}); + +test('Merc.canVault rejects adjacent shove on a friendly entity', () => { + const ally = new Entity({ id: 'a', x: 4, y: 3, faction: FACTION.PLAYER, glyph: 't' }); + const { world, merc } = makeWorld({ extraEntities: [ally] }); + assert.equal(merc.canVault(world, 1, 0).reason, 'friendly-occupied'); +}); + +test('Merc.vault shove knocks hostile back and Merc steps away when retreat is clear', () => { + const drone = new Entity({ id: 'd', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); + const { world, merc } = makeWorld({ extraEntities: [drone] }); + const apBefore = merc.ap; + const { occupant, mode } = merc.vault(world, 1, 0); + assert.equal(mode, 'shove'); + assert.equal(occupant, drone); + assert.equal(merc.x, 2, 'Merc steps back opposite the shove'); + assert.equal(merc.y, 3); + assert.equal(drone.x, 5, 'hostile knocked back 1 tile east'); + assert.equal(drone.y, 3); + assert.equal(merc.ap, apBefore - AP_COST.VAULT); +}); + +test('Merc.vault shove still commits when retreat lane is blocked', () => { + const g = new Grid(8, 8); + g.setTile(2, 3, TILE.WALL); + const drone = new Entity({ id: 'd', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); + const { world, merc } = makeWorld({ grid: g, extraEntities: [drone] }); + merc.vault(world, 1, 0); + assert.equal(merc.x, 3, 'Merc holds position when retreat is blocked'); + assert.equal(drone.x, 5); +}); + +test('Merc.vault prefers hop over shove when cover vault slam is legal', () => { + const g = new Grid(8, 8); + g.setTile(4, 3, TILE.COVER); + const landing = new Entity({ id: 'land', x: 5, y: 3, faction: FACTION.CORP, glyph: 'd' }); + const { world, merc } = makeWorld({ grid: g, extraEntities: [landing] }); + const { mode } = merc.vault(world, 1, 0); + assert.equal(mode, 'hop'); + assert.equal(merc.x, 5); + assert.equal(landing.x, 6); +}); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index cf6d391..be8be16 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -334,7 +334,7 @@ test('vault onto consumable succeeds when knockback lane beyond landing is a wal assert.equal(player.x, 4); assert.equal(player.y, 2); assert.equal(player.inventory?.consumables[0]?.id, ITEM_ID.STIM); - assert.ok(log.some(l => l.includes('vaulted'))); + assert.ok(log.some(l => l.includes('broke through'))); assert.ok(log.some(l => l.includes('picks up Stim'))); }); @@ -515,7 +515,7 @@ test('vault on empty tile is pure repositioning (no damage log)', () => { applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(player.x, 4, 'vault still lands'); assert.ok( - log.some(l => l.includes('vaulted to')), + log.some(l => l.includes('broke through to')), 'log mentions the vault' ); assert.ok(!log.some(l => l.includes('SLAMMED')), 'no slam logged'); @@ -529,7 +529,29 @@ test('vault denied when knockback lane is blocked', () => { ctx.world.grid.setTile(5, 2, TILE.WALL); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(player.x, 2, 'Merc stays put'); - assert.ok(log.some(l => l.includes('VAULT DENIED'))); + assert.ok(log.some(l => l.includes('BREAK DENIED'))); +}); + +test('vault adjacent shove deals VAULT_DAMAGE and steps Merc back when clear', () => { + const { ctx, log, player, world } = buildCtx({ archetype: 'merc', placeDrone: false }); + // Clear the harness cover tile so hop fails and adjacent shove can fire. + world.grid.setTile(3, 2, TILE.FLOOR); + const drone = new Skirmisher({ id: 'd1', x: 3, y: 2, maxAp: 3 }); + world.addEntity(drone); + const hpBefore = drone.hp; + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + assert.equal(player.x, 1, 'Merc steps back opposite the shove'); + assert.equal(player.y, 2); + assert.equal(drone.x, 4, 'hostile knocked back'); + assert.equal(drone.hp, hpBefore - 2); + assert.ok( + log.some(l => l.includes('shoved')), + 'log mentions the shove' + ); + assert.ok( + log.some(l => l.includes('SLAMMED')), + 'log mentions the slam' + ); }); test('AP exhaustion triggers auto-end-turn during a move', () => {