Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 7 additions & 3 deletions components/KeyHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
2 changes: 1 addition & 1 deletion debug/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ <h2>Help</h2>
<p>
Move: <kbd>←↑↓→</kbd> or <kbd>WASD</kbd>, <kbd>q</kbd> <kbd>e</kbd> <kbd>z</kbd>
<kbd>c</kbd> diagonal. Into an adjacent hostile = melee. Fire: <kbd>f</kbd> + dir. Special
(Vault / Slide / Deploy): <kbd>x</kbd> + dir. Interact: <kbd>Space</kbd>. Wait / pass turn:
(Break / Slide / Deploy): <kbd>x</kbd> + dir. Interact: <kbd>Space</kbd>. Wait / pass turn:
<kbd>.</kbd>. Cancel aim: <kbd>Esc</kbd>. Archetype: <kbd>1</kbd> Merc / <kbd>2</kbd> Razor
/ <kbd>3</kbd> Tech. Reset: <kbd>r</kbd>.
</p>
Expand Down
2 changes: 1 addition & 1 deletion debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
124 changes: 90 additions & 34 deletions src/game/archetypes/Merc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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';
Expand All @@ -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' };
}
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -123,38 +135,82 @@ 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);
if (!check.ok) {
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);
}
}
6 changes: 3 additions & 3 deletions src/game/archetypes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 5 additions & 4 deletions src/game/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
22 changes: 14 additions & 8 deletions src/input/applyIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
71 changes: 67 additions & 4 deletions tests/unit/game/Merc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});
Loading
Loading