diff --git a/docs/DECISION_RECORD_UNIFIED_VERTICAL_SLICE.md b/docs/DECISION_RECORD_UNIFIED_VERTICAL_SLICE.md new file mode 100644 index 0000000..d2e3073 --- /dev/null +++ b/docs/DECISION_RECORD_UNIFIED_VERTICAL_SLICE.md @@ -0,0 +1,57 @@ +# Decision Record: Unified Territory-to-Encounter Vertical Slice + +**Status:** Accepted for implementation +**Date:** 2026-08-20 +**Owner:** SLIDE production director + +## Player Outcome + +A player scouts a selected, privacy-safe location reference, sees the place’s visual character and tactical modifiers, deploys a crew, launches one playable top-down encounter, and receives a clear persistent block/crew consequence. The place may resemble an entered area through the existing satellite/scene seed and location archetype, but the encounter remains fictionalized and never creates a real-world target. + +## Current Fragmentation + +The repository currently contains separate grid combat, arcade drive-by, and block-defense renderer paths. `BlockModeView` is the active map-to-combat integration point, `blockStore` owns claimed-block state, and `PhaserTopDownBlock` is the existing React–Phaser bridge. The first implementation will add a canonical offline `CombatSession` and a Phaser encounter scene behind the existing block flow; legacy modes remain intact as a rollback path during migration. + +## Chosen Scope + +The first vertical slice delivers the following without adding a new engine or external runtime: + +| Area | Chosen implementation | +|---|---| +| Location resemblance | Keep existing Mapbox/satellite flow and `resolveBlockDNA`; show a fictional district brief, archetype, terrain tags, and explicit tactical effect rather than expose precise location as a target. | +| Strategy preparation | Use current block placements, zone cover/exposure, heat, morale, and resolved DNA to prepare a deterministic encounter. | +| Combat | Add a pure TypeScript fixed-step session with commands, seeded RNG, cover/line-of-sight, movement, projectile events, reload, extraction objective, and retreat. | +| Rendering | Add one Phaser scene wrapped by a React component. Reuse current world actor assets and location/satellite backdrop; use bounded graphics effects and pooled projectile display objects. | +| Persistence | Apply one idempotent local result to block placements, heat, morale, and pending income; keep server synchronization separate from combat authority. | +| Accessibility | Support keyboard, pointer/touch, gamepad basics, reduced-motion feedback, readable HUD, and explicit text alternatives for state. | + +## Deferred + +Authoritative multiplayer, payments/entitlements, broad live operations, full address persistence policy, voice/chat, and any new third-party engine/library are deferred. Existing legacy drive-by and raid screens remain unchanged except for replacing the main player-facing encounter launch route. + +## Contracts + +The domain layer owns `CombatSession`, `CombatCommand`, seed/clock, participants, terrain, cover, objective, and outcome. Phaser owns input capture, rendering, camera, animation, and feedback. React owns HUD, menus, accessibility settings, and result presentation. `blockStore` owns durable local territory projections but does not resolve combat. + +## Acceptance Evidence + +1. A block’s selected address/DNA, placements, cover, exposure, morale, and heat affect a deterministic encounter preparation summary. +2. The encounter supports move, aim/fire, reload, objective progress, cover, retreat, win/loss, and result summary. +3. The same seed and command sequence produce the same snapshot/result in unit tests. +4. The player-facing route uses the unified encounter from `BlockModeView`, while legacy modes remain available for rollback. +5. Typecheck, tests, build, and asset audit pass. A visual verification covers desktop and touch-sized viewports. + +## Changed-File Plan + +| Path | Responsibility | +|---|---| +| `frontend/src/game/combat/*` | Pure contracts, seeded simulation, preparation, replay tests | +| `frontend/src/components/encounter/*` | React–Phaser bridge, scene, HUD/result surface, styles | +| `frontend/src/components/map/BlockModeView.tsx` | Launch unified preparation/encounter from claimed block flow | +| `frontend/src/stores/blockStore.ts` | Apply idempotent vertical-slice result projection | +| `frontend/src/types/block.types.ts` | Optional result/brief types where block ownership needs them | +| `frontend/src/**/__tests__/*` | Domain, bridge, and flow tests | + +## Risks and Rollback + +Cross-origin satellite imagery may fail to load in a Phaser canvas; the encounter must render a deterministic scene fallback. Existing legacy `DriveByEngine` remains an accessible fallback until the new flow has passing tests and visual verification. Limit the first scene to a small roster and bounded projectile/effect pool to protect mobile frame time. diff --git a/docs/VERIFICATION_NOTES.md b/docs/VERIFICATION_NOTES.md new file mode 100644 index 0000000..7f85fdf --- /dev/null +++ b/docs/VERIFICATION_NOTES.md @@ -0,0 +1,11 @@ +# Verification Notes + +## 2026-08-20 — Unified Encounter Preview + +The Vite preview started successfully at port 3000. A browser check reached the app shell, but the rendered application remained a blank dark root container after a second load check. The DOM extraction contains only the root element. This is a pre-existing app bootstrap/runtime issue or a browser-preview incompatibility that blocks full visual interaction testing; typecheck and focused domain tests remain the current verified evidence. + +Next diagnostic action: inspect browser console/runtime error output and application entry assumptions before treating the encounter scene as visually accepted. + +## 2026-08-20 — Hosted Validation + +The local production build twice exceeded the sandbox memory ceiling during Vite chunk rendering, but the hosted `CI/Frontend (lint + build)` pull-request check completed successfully. The pull request’s backend test, frontend lint/build, and deployment checks are green. The local visual shell issue remains a separate manual-acceptance follow-up. diff --git a/frontend/src/components/encounter/UnifiedEncounter.css b/frontend/src/components/encounter/UnifiedEncounter.css new file mode 100644 index 0000000..f28824a --- /dev/null +++ b/frontend/src/components/encounter/UnifiedEncounter.css @@ -0,0 +1,138 @@ +.unified-encounter { + position: relative; + width: min(100%, 1080px); + margin: 0 auto; + color: #edf7ff; + border: 1px solid rgba(126, 219, 210, 0.28); + border-radius: 18px; + overflow: hidden; + background: linear-gradient(145deg, #07111d 0%, #0d1d2d 60%, #07111d 100%); + box-shadow: 0 22px 80px rgba(0, 0, 0, 0.38); +} + +.ue-briefing { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 20px 22px 14px; + background: linear-gradient(90deg, rgba(57, 189, 177, 0.16), rgba(8, 20, 33, 0.1)); +} + +.ue-kicker { + display: block; + margin-bottom: 5px; + font-family: monospace; + font-size: 10px; + letter-spacing: 0.12em; + color: #8de8df; +} + +.ue-briefing h2, +.ue-briefing p { margin: 0; } +.ue-briefing h2 { font-size: clamp(20px, 3vw, 29px); letter-spacing: -0.03em; } +.ue-briefing p { margin-top: 4px; color: #a5b7c7; font-size: 13px; } + +.ue-close, +.ue-action-row button, +.ue-result button { + border: 1px solid rgba(152, 215, 226, 0.34); + border-radius: 10px; + padding: 10px 13px; + color: #eaf9ff; + background: rgba(14, 35, 50, 0.88); + font: 700 12px/1 monospace; + cursor: pointer; + transition: transform 140ms ease, background 140ms ease, border-color 140ms ease; +} + +.ue-close:hover, +.ue-action-row button:hover, +.ue-result button:hover { transform: translateY(-1px); border-color: #8de8df; background: rgba(38, 99, 115, 0.8); } +.ue-action-row button:focus-visible, +.ue-close:focus-visible, +.ue-result button:focus-visible { outline: 3px solid #f6cc75; outline-offset: 3px; } +.ue-action-row button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; } + +.ue-modifiers { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 10px 16px; + border-block: 1px solid rgba(135, 192, 210, 0.16); + background: rgba(4, 10, 17, 0.42); +} + +.ue-modifiers span { + flex: 0 0 auto; + max-width: 280px; + padding: 7px 9px; + border-radius: 7px; + background: rgba(83, 145, 163, 0.15); + color: #c8dce7; + font-size: 11px; + line-height: 1.3; +} + +.ue-stage-shell { position: relative; min-height: 270px; background: #07111d; } +.ue-stage { width: 100%; min-height: 270px; opacity: 0; transition: opacity 220ms ease; } +.ue-stage--ready { opacity: 1; } +.ue-stage canvas { display: block; width: 100% !important; height: auto !important; max-height: min(66vh, 650px); object-fit: contain; } + +.ue-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + z-index: 2; + color: #b9f6f1; + font: 700 13px monospace; + letter-spacing: 0.05em; + background: radial-gradient(circle at center, rgba(55, 128, 146, 0.24), rgba(7, 17, 29, 0.95)); +} + +.ue-controls { display: grid; gap: 12px; padding: 16px; border-top: 1px solid rgba(135, 192, 210, 0.18); } +.ue-live-stats { display: flex; flex-wrap: wrap; gap: 10px; color: #afc4d2; font-size: 12px; } +.ue-live-stats span { padding: 5px 8px; border-radius: 6px; background: rgba(8, 22, 35, 0.64); } +.ue-live-stats strong { color: #b9f6f1; } +.ue-action-row { display: flex; flex-wrap: wrap; gap: 8px; } +.ue-action-row .ue-extract { border-color: rgba(74, 222, 128, 0.55); color: #c9ffd8; } +.ue-action-row .ue-retreat { border-color: rgba(251, 191, 36, 0.55); color: #ffe7a7; } + +.ue-event-log { min-height: 82px; padding: 2px 16px 15px; color: #c8dce7; font: 12px/1.45 monospace; } +.ue-event-log p { margin: 5px 0; } +.ue-event-log p::before { content: '› '; color: #8de8df; } + +.ue-result { + position: absolute; + inset: 0; + z-index: 5; + display: grid; + place-content: center; + gap: 12px; + padding: 28px; + text-align: center; + background: rgba(4, 10, 17, 0.9); + backdrop-filter: blur(10px); +} +.ue-result h3, .ue-result p { max-width: 420px; margin: 0 auto; } +.ue-result h3 { font-size: clamp(23px, 4vw, 34px); } +.ue-result p { color: #b8c9d3; line-height: 1.5; } +.ue-result button { justify-self: center; margin-top: 4px; padding: 13px 16px; background: #1b746d; border-color: #9cf5ed; } +.ue-result__badge { justify-self: center; padding: 6px 9px; border-radius: 999px; font: 800 10px monospace; letter-spacing: 0.12em; } +.outcome-secured { color: #d1fae5; background: rgba(34, 197, 94, 0.2); } +.outcome-retreated { color: #fef3c7; background: rgba(245, 158, 11, 0.2); } +.outcome-overrun { color: #ffe4e6; background: rgba(244, 63, 94, 0.2); } + +@media (max-width: 620px) { + .ue-briefing { align-items: flex-start; flex-direction: column; padding: 16px; } + .ue-close { width: 100%; min-height: 42px; } + .ue-modifiers { padding-inline: 12px; } + .ue-action-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ue-action-row button { min-height: 46px; } + .ue-event-log { font-size: 11px; } +} + +@media (prefers-reduced-motion: reduce) { + .ue-stage, .ue-close, .ue-action-row button, .ue-result button { transition: none; } +} diff --git a/frontend/src/components/encounter/UnifiedEncounter.tsx b/frontend/src/components/encounter/UnifiedEncounter.tsx new file mode 100644 index 0000000..3f9d8a1 --- /dev/null +++ b/frontend/src/components/encounter/UnifiedEncounter.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import Phaser from 'phaser'; +import type { BlockData } from '../../types/block.types'; +import { createCombatSession, getCombatSnapshot } from '../../game/combat/combatSession'; +import { prepareEncounter } from '../../game/combat/prepareEncounter'; +import type { CombatResult, CombatSnapshot } from '../../game/combat/types'; +import { UnifiedEncounterScene } from './UnifiedEncounterScene'; +import './UnifiedEncounter.css'; + +interface UnifiedEncounterProps { + block: BlockData; + onResolved: (result: CombatResult) => void; + onClose: () => void; +} + +function useReducedMotion(): boolean { + const [reducedMotion, setReducedMotion] = useState(() => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ); + + useEffect(() => { + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const sync = () => setReducedMotion(media.matches); + media.addEventListener('change', sync); + return () => media.removeEventListener('change', sync); + }, []); + + return reducedMotion; +} + +export const UnifiedEncounter: React.FC = ({ block, onResolved, onClose }) => { + const containerRef = useRef(null); + const gameRef = useRef(null); + const sceneRef = useRef(null); + const resolvedRef = useRef(false); + const preparation = useMemo(() => prepareEncounter(block), [block]); + const reducedMotion = useReducedMotion(); + const [snapshot, setSnapshot] = useState(() => getCombatSnapshot(createCombatSession(preparation))); + const [result, setResult] = useState(null); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + resolvedRef.current = false; + const scene = new UnifiedEncounterScene(); + scene.configure(preparation, reducedMotion); + sceneRef.current = scene; + scene.events.on('ready', () => setIsReady(true)); + scene.events.on('snapshot', (next: CombatSnapshot) => setSnapshot(next)); + scene.events.on('combatResult', (next: CombatResult | null) => { + if (next) setResult(next); + }); + + const game = new Phaser.Game({ + type: Phaser.AUTO, + width: 960, + height: 600, + parent: containerRef.current, + backgroundColor: '#07111d', + scene, + banner: false, + scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, + render: { antialias: true, pixelArt: false, roundPixels: true }, + audio: { noAudio: true }, + }); + gameRef.current = game; + + return () => { + game.destroy(true); + gameRef.current = null; + sceneRef.current = null; + setIsReady(false); + }; + }, [preparation, reducedMotion]); + + const commitResult = () => { + if (!result || resolvedRef.current) return; + resolvedRef.current = true; + onResolved(result); + }; + + const activeCrew = snapshot.combatants.filter((actor) => actor.team === 'crew' && !actor.isDown).length; + const opposition = snapshot.combatants.filter((actor) => actor.team === 'opposition' && !actor.isDown).length; + + return ( +
+
+
+ TACTICAL BRIEF · FICTIONALIZED LOCAL REFERENCE +

{preparation.sceneLabel}

+

{preparation.locationReference}

+
+ +
+ +
+ {preparation.tacticalBrief.map((item) => {item})} +
+ +
+ {!isReady &&
Building your tactical scene…
} +
+
+ +
+
+ {activeCrew} crew active + {opposition} opposition visible + {snapshot.objective.progress}/{snapshot.objective.target} secure exits +
+
+ + + + +
+
+ +
+ {snapshot.events.slice(-4).reverse().map((item) =>

{item.message}

)} + {snapshot.events.length === 0 &&

Choose a crew member, use cover, and reach the highlighted secure exit.

} +
+ + {result && ( +
+ {result.outcome.toUpperCase()} +

{result.summary}

+

{result.oppositionDown.length} opposition down · {result.crewDown.length} crew needing recovery · heat {result.heatDelta >= 0 ? '+' : ''}{result.heatDelta}

+ +
+ )} +
+ ); +}; + +export default UnifiedEncounter; diff --git a/frontend/src/components/encounter/UnifiedEncounterScene.ts b/frontend/src/components/encounter/UnifiedEncounterScene.ts new file mode 100644 index 0000000..419b87d --- /dev/null +++ b/frontend/src/components/encounter/UnifiedEncounterScene.ts @@ -0,0 +1,365 @@ +import Phaser from 'phaser'; +import { advanceCombat, createCombatSession, dispatchCombatCommand, getCombatSnapshot } from '../../game/combat/combatSession'; +import type { CombatCommand, CombatSnapshot, Combatant, EncounterPreparation, GridPoint } from '../../game/combat/types'; +import { getWorldActor } from '../../render/worldActorResolver'; + +const CANVAS_W = 960; +const CANVAS_H = 600; +const GRID_COLS = 8; +const GRID_ROWS = 8; +const PLAYFIELD_TOP = 86; +const PLAYFIELD_BOTTOM = 72; +const CELL_W = CANVAS_W / GRID_COLS; +const CELL_H = (CANVAS_H - PLAYFIELD_TOP - PLAYFIELD_BOTTOM) / GRID_ROWS; + +interface ActorView { + sprite: Phaser.GameObjects.Image | Phaser.GameObjects.Arc; + healthBack: Phaser.GameObjects.Rectangle; + healthFill: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + selectedRing: Phaser.GameObjects.Arc; +} + +interface TransientEffect { + line: Phaser.GameObjects.Line | Phaser.GameObjects.Arc; + expiresAt: number; +} + +type CommandWithoutSequence = + T extends CombatCommand ? Omit : never; + +export class UnifiedEncounterScene extends Phaser.Scene { + private preparation!: EncounterPreparation; + private session = createCombatSession({ + sessionId: 'placeholder', seed: 1, sceneLabel: 'Loading', locationReference: 'Loading', fictionNotice: '', + terrain: [], crew: [], opposition: [], heatAtStart: 0, moraleAtStart: 0, + objective: { kind: 'extract', label: 'Loading', extraction: { x: 0, y: 0 }, requiredCrew: 1, progress: 0, target: 1 }, + tacticalBrief: [], + }); + private actorViews = new Map(); + private effects: TransientEffect[] = []; + private lastEventId: string | null = null; + private selectedCrewId: string | null = null; + private sequence = 0; + private accumulator = 0; + private reducedMotion = false; + private objectiveText!: Phaser.GameObjects.Text; + private statusText!: Phaser.GameObjects.Text; + private ammoText!: Phaser.GameObjects.Text; + private timerText!: Phaser.GameObjects.Text; + private terrainLayer!: Phaser.GameObjects.Container; + private actorLayer!: Phaser.GameObjects.Container; + private uiLayer!: Phaser.GameObjects.Container; + private backgroundKey = 'encounter-background'; + + constructor() { + super({ key: 'UnifiedEncounterScene' }); + } + + configure(preparation: EncounterPreparation, reducedMotion: boolean): void { + this.preparation = preparation; + this.reducedMotion = reducedMotion; + this.session = createCombatSession(preparation); + this.selectedCrewId = preparation.crew[0]?.id ?? null; + } + + preload(): void { + if (this.preparation.backgroundUrl) this.load.image(this.backgroundKey, this.preparation.backgroundUrl); + const loaded = new Set(); + for (const actor of [...this.preparation.crew, ...this.preparation.opposition]) { + if (actor.team !== 'crew') continue; + const resolved = getWorldActor(actor.role, 'idle', 'topdown'); + if (!resolved || loaded.has(resolved.url)) continue; + const key = this.textureKey(actor); + this.load.image(key, resolved.url); + loaded.add(resolved.url); + } + } + + create(): void { + this.terrainLayer = this.add.container(0, 0); + this.actorLayer = this.add.container(0, 0); + this.uiLayer = this.add.container(0, 0); + this.drawBackground(); + this.drawTerrain(); + this.createHud(); + this.syncViews(); + this.bindInput(); + this.events.emit('ready'); + this.emitSnapshot(); + } + + update(_time: number, delta: number): void { + if (this.session.phase === 'active') { + this.accumulator += Math.min(delta, 180); + while (this.accumulator >= 100) { + this.accumulator -= 100; + this.session = advanceCombat(this.session, 2); + this.handleNewEvents(); + } + } + this.clearExpiredEffects(); + this.syncViews(); + this.updateHud(); + } + + dispatch(command: CommandWithoutSequence): void { + this.sequence += 1; + this.session = dispatchCombatCommand(this.session, { ...command, sequence: this.sequence } as CombatCommand); + this.handleNewEvents(); + this.syncViews(); + this.emitSnapshot(); + } + + reloadSelected(): void { + this.withSelected((actor) => this.dispatch({ type: 'reload', actorId: actor.id })); + } + + interactSelected(): void { + this.withSelected((actor) => this.dispatch({ type: 'interact', actorId: actor.id })); + } + + retreatSelected(): void { + this.withSelected((actor) => this.dispatch({ type: 'retreat', actorId: actor.id })); + } + + fireNearestSelected(): void { + this.fireNearest(); + } + + private drawBackground(): void { + this.add.rectangle(CANVAS_W / 2, CANVAS_H / 2, CANVAS_W, CANVAS_H, 0x07111d, 1); + if (this.textures.exists(this.backgroundKey)) { + this.add.image(CANVAS_W / 2, CANVAS_H / 2, this.backgroundKey) + .setDisplaySize(CANVAS_W, CANVAS_H) + .setAlpha(0.52); + } else { + const background = this.add.graphics(); + background.fillGradientStyle(0x162436, 0x162436, 0x080d16, 0x080d16, 1); + background.fillRect(0, 0, CANVAS_W, CANVAS_H); + for (let index = 0; index < 20; index += 1) { + const x = (index * 89) % CANVAS_W; + const y = 120 + ((index * 53) % 390); + background.fillStyle(index % 2 ? 0x173146 : 0x222b3a, 0.42); + background.fillRect(x, y, 55 + (index % 4) * 24, 26 + (index % 3) * 18); + } + } + this.add.rectangle(CANVAS_W / 2, 42, CANVAS_W, 84, 0x07111d, 0.94); + this.add.rectangle(CANVAS_W / 2, CANVAS_H - 35, CANVAS_W, 70, 0x07111d, 0.94); + } + + private drawTerrain(): void { + const tint: Record = { + street: 0x273245, curb: 0x6b6f67, sidewalk: 0x2c4658, storefront: 0x31414c, + alley: 0x192c2d, parking: 0x45413d, rooftop: 0x3e3154, building: 0x101722, + }; + this.preparation.terrain.flat().forEach((cell) => { + const { x, y } = this.pointToPixel(cell); + const fill = tint[cell.zoneType] ?? 0x233142; + const alpha = cell.passable ? 0.74 : 0.92; + const rectangle = this.add.rectangle(x, y, CELL_W - 4, CELL_H - 4, fill, alpha) + .setStrokeStyle(1, cell.cover >= 0.6 ? 0x83d5d0 : 0x314c5d, 0.7); + this.terrainLayer.add(rectangle); + if (cell.cover >= 0.6) { + const coverMark = this.add.rectangle(x, y - CELL_H * 0.25, CELL_W * 0.52, 5, 0x83d5d0, 0.85); + this.terrainLayer.add(coverMark); + } + }); + const exit = this.pointToPixel(this.session.objective.extraction); + const extraction = this.add.rectangle(exit.x, exit.y, CELL_W - 13, CELL_H - 13, 0x4ade80, 0.22) + .setStrokeStyle(2, 0x4ade80, 0.95); + const exitLabel = this.add.text(exit.x, exit.y, 'EXIT', { fontSize: '12px', color: '#d1fae5', fontStyle: 'bold' }).setOrigin(0.5); + this.terrainLayer.add([extraction, exitLabel]); + } + + private createHud(): void { + const title = this.add.text(22, 13, this.preparation.sceneLabel.toUpperCase(), { + fontFamily: 'monospace', fontSize: '18px', fontStyle: 'bold', color: '#f5f7ff', + }); + const location = this.add.text(22, 39, 'FICTIONALIZED LOCAL REFERENCE · TACTICAL SIMULATION', { + fontFamily: 'monospace', fontSize: '10px', color: '#8ea8bd', + }); + this.objectiveText = this.add.text(CANVAS_W / 2, 13, '', { fontFamily: 'monospace', fontSize: '13px', color: '#b9f6f1', align: 'center' }).setOrigin(0.5, 0); + this.statusText = this.add.text(CANVAS_W / 2, 39, '', { fontFamily: 'monospace', fontSize: '10px', color: '#f1c77d', align: 'center' }).setOrigin(0.5, 0); + this.ammoText = this.add.text(CANVAS_W - 20, 13, '', { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff', align: 'right' }).setOrigin(1, 0); + this.timerText = this.add.text(CANVAS_W - 20, 38, '', { fontFamily: 'monospace', fontSize: '10px', color: '#9db0bf', align: 'right' }).setOrigin(1, 0); + const controls = this.add.text(20, CANVAS_H - 54, 'TAP/CICK: move or target · WASD/ARROWS: move · SPACE: fire · R: reload · E: exit · Q: retreat', { + fontFamily: 'monospace', fontSize: '10px', color: '#9db0bf', wordWrap: { width: CANVAS_W - 40 }, + }); + this.uiLayer.add([title, location, this.objectiveText, this.statusText, this.ammoText, this.timerText, controls]); + } + + private bindInput(): void { + this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.y < PLAYFIELD_TOP || pointer.y > CANVAS_H - PLAYFIELD_BOTTOM || this.session.phase !== 'active') return; + const point = this.pixelToPoint(pointer.x, pointer.y); + const hit = this.session.combatants.find((actor) => !actor.isDown && actor.position.x === point.x && actor.position.y === point.y); + if (hit?.team === 'crew') { + this.selectedCrewId = hit.id; + this.syncViews(); + return; + } + const actor = this.selectedCrew(); + if (!actor) return; + if (hit?.team === 'opposition') { + this.dispatch({ type: 'aim-fire', actorId: actor.id, targetId: hit.id }); + } else { + this.dispatch({ type: 'move', actorId: actor.id, destination: point }); + } + }); + const keyboard = this.input.keyboard; + if (!keyboard) return; + keyboard.on('keydown-W', () => this.moveSelected(0, -1)); + keyboard.on('keydown-A', () => this.moveSelected(-1, 0)); + keyboard.on('keydown-S', () => this.moveSelected(0, 1)); + keyboard.on('keydown-D', () => this.moveSelected(1, 0)); + keyboard.on('keydown-UP', () => this.moveSelected(0, -1)); + keyboard.on('keydown-LEFT', () => this.moveSelected(-1, 0)); + keyboard.on('keydown-DOWN', () => this.moveSelected(0, 1)); + keyboard.on('keydown-RIGHT', () => this.moveSelected(1, 0)); + keyboard.on('keydown-R', () => this.withSelected((actor) => this.dispatch({ type: 'reload', actorId: actor.id }))); + keyboard.on('keydown-E', () => this.withSelected((actor) => this.dispatch({ type: 'interact', actorId: actor.id }))); + keyboard.on('keydown-Q', () => this.withSelected((actor) => this.dispatch({ type: 'retreat', actorId: actor.id }))); + keyboard.on('keydown-SPACE', () => this.fireNearest()); + this.input.gamepad?.on('down', (_pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button) => { + if (button.index === 0) this.fireNearest(); + if (button.index === 1) this.withSelected((actor) => this.dispatch({ type: 'reload', actorId: actor.id })); + if (button.index === 9) this.withSelected((actor) => this.dispatch({ type: 'retreat', actorId: actor.id })); + }); + } + + private withSelected(fn: (actor: Combatant) => void): void { + const actor = this.selectedCrew(); + if (actor) fn(actor); + } + + private moveSelected(dx: number, dy: number): void { + this.withSelected((actor) => this.dispatch({ type: 'move', actorId: actor.id, destination: { x: actor.position.x + dx, y: actor.position.y + dy } })); + } + + private fireNearest(): void { + const actor = this.selectedCrew(); + if (!actor) return; + const target = this.session.combatants + .filter((candidate) => candidate.team === 'opposition' && !candidate.isDown) + .sort((left, right) => this.manhattan(actor.position, left.position) - this.manhattan(actor.position, right.position))[0]; + if (target) this.dispatch({ type: 'aim-fire', actorId: actor.id, targetId: target.id }); + } + + private selectedCrew(): Combatant | undefined { + const selected = this.session.combatants.find((actor) => actor.id === this.selectedCrewId && actor.team === 'crew' && !actor.isDown); + return selected ?? this.session.combatants.find((actor) => actor.team === 'crew' && !actor.isDown); + } + + private syncViews(): void { + const activeIds = new Set(this.session.combatants.map((actor) => actor.id)); + for (const [id, view] of this.actorViews) { + if (!activeIds.has(id)) { + view.sprite.destroy(); view.healthBack.destroy(); view.healthFill.destroy(); view.label.destroy(); view.selectedRing.destroy(); + this.actorViews.delete(id); + } + } + this.session.combatants.forEach((actor) => { + const point = this.pointToPixel(actor.position); + const hpRatio = Math.max(0, actor.health / actor.maxHealth); + let view = this.actorViews.get(actor.id); + if (!view) { + view = this.createActorView(actor, point); + this.actorViews.set(actor.id, view); + } + view.sprite.setPosition(point.x, point.y - 8).setAlpha(actor.isDown ? 0.36 : 1); + view.healthBack.setPosition(point.x, point.y - CELL_H * 0.34); + view.healthFill.setPosition(point.x - 24 + 24 * hpRatio, point.y - CELL_H * 0.34).setSize(48 * hpRatio, 5); + view.label.setPosition(point.x, point.y + CELL_H * 0.31).setText(actor.isDown ? `${actor.name} · DOWN` : actor.name); + view.selectedRing.setPosition(point.x, point.y).setVisible(actor.id === this.selectedCrewId && !actor.isDown); + }); + } + + private createActorView(actor: Combatant, point: { x: number; y: number }): ActorView { + const actorTexture = this.textureKey(actor); + const hasTexture = actor.team === 'crew' && this.textures.exists(actorTexture); + const sprite = hasTexture + ? this.add.image(point.x, point.y, actorTexture).setScale(0.32) + : this.add.circle(point.x, point.y, 15, actor.team === 'crew' ? 0x54d6d0 : 0xef7777, 1); + const healthBack = this.add.rectangle(point.x, point.y - 24, 50, 6, 0x111827, 0.9); + const healthFill = this.add.rectangle(point.x, point.y - 24, 48, 5, actor.team === 'crew' ? 0x4ade80 : 0xfb7185, 1); + const label = this.add.text(point.x, point.y + 25, actor.name, { fontFamily: 'monospace', fontSize: '9px', color: actor.team === 'crew' ? '#d9fffb' : '#ffd8d8', align: 'center' }).setOrigin(0.5); + const selectedRing = this.add.circle(point.x, point.y, 22, 0x9cf5ed, 0).setStrokeStyle(2, 0x9cf5ed, 0.95).setVisible(false); + this.actorLayer.add([sprite, healthBack, healthFill, label, selectedRing]); + return { sprite, healthBack, healthFill, label, selectedRing }; + } + + private handleNewEvents(): void { + const latestEvents = this.lastEventId + ? this.session.events.slice(this.session.events.findIndex((item) => item.id === this.lastEventId) + 1) + : this.session.events; + if (latestEvents.length === 0) return; + latestEvents.forEach((item) => this.renderEvent(item)); + this.lastEventId = latestEvents.at(-1)?.id ?? this.lastEventId; + this.emitSnapshot(); + } + + private renderEvent(item: CombatSnapshot['events'][number]): void { + if (item.type === 'weapon-fired' && item.actorId && item.targetId) { + const source = this.session.combatants.find((actor) => actor.id === item.actorId); + const target = this.session.combatants.find((actor) => actor.id === item.targetId); + if (source && target) { + const from = this.pointToPixel(source.position); + const to = this.pointToPixel(target.position); + const line = this.add.line(0, 0, from.x, from.y, to.x, to.y, 0xfacc15, 0.95).setLineWidth(2, 2); + this.effects.push({ line, expiresAt: this.time.now + (this.reducedMotion ? 65 : 125) }); + } + } + if ((item.type === 'impact-actor' || item.type === 'actor-downed') && item.targetId) { + const target = this.session.combatants.find((actor) => actor.id === item.targetId); + if (target) { + const point = this.pointToPixel(target.position); + const impact = this.add.circle(point.x, point.y, item.type === 'actor-downed' ? 27 : 17, 0xf97316, 0.24).setStrokeStyle(2, 0xfef3c7, 0.8); + this.effects.push({ line: impact, expiresAt: this.time.now + 190 }); + if (!this.reducedMotion) this.cameras.main.shake(70, 0.0025); + } + } + if (item.type === 'resolved') this.events.emit('combatResult', this.session.result); + } + + private clearExpiredEffects(): void { + const now = this.time.now; + this.effects = this.effects.filter((effect) => { + if (effect.expiresAt > now) return true; + effect.line.destroy(); + return false; + }); + } + + private updateHud(): void { + const selected = this.selectedCrew(); + const latest = this.session.events.at(-1); + this.objectiveText.setText(`OBJECTIVE: ${this.session.objective.label.toUpperCase()}`); + this.statusText.setText(latest?.message ?? 'Choose a crew member, then move or target.'); + this.ammoText.setText(selected ? `${selected.name.toUpperCase()} · ${selected.ammo}/${selected.maxAmmo} AMMO` : 'NO ACTIVE CREW'); + this.timerText.setText(`TICK ${this.session.tick} · HEAT ${this.session.heatAtStart}/5`); + } + + private emitSnapshot(): void { + this.events.emit('snapshot', getCombatSnapshot(this.session)); + } + + private pointToPixel(point: GridPoint): { x: number; y: number } { + return { x: (point.x + 0.5) * CELL_W, y: PLAYFIELD_TOP + (point.y + 0.5) * CELL_H }; + } + + private pixelToPoint(x: number, y: number): GridPoint { + return { + x: Math.max(0, Math.min(GRID_COLS - 1, Math.floor(x / CELL_W))), + y: Math.max(0, Math.min(GRID_ROWS - 1, Math.floor((y - PLAYFIELD_TOP) / CELL_H))), + }; + } + + private manhattan(a: GridPoint, b: GridPoint): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + } + + private textureKey(actor: Combatant): string { + return `encounter-actor-${actor.role}`; + } +} diff --git a/frontend/src/components/map/BlockModeView.tsx b/frontend/src/components/map/BlockModeView.tsx index 5c164ac..65192b9 100644 --- a/frontend/src/components/map/BlockModeView.tsx +++ b/frontend/src/components/map/BlockModeView.tsx @@ -10,6 +10,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useBlockStore } from '../../stores/blockStore'; import { usePlayerStore, useGangStore } from '../../stores/gameStore'; import type { BlockData, BlockViewMode, MemberRole } from '../../types/block.types'; +import type { CombatResult } from '../../game/combat/types'; import { getDefaultTopdownBgUrl, getDefaultStreetBackdropUrl, @@ -25,6 +26,7 @@ import DriveByEngine from '../slide/BlockDriveByEngine'; import BailModal from '../gang/BailModal'; import DrugAssignmentPanel from './DrugAssignmentPanel'; import { PoliceRaidGame } from '../topdown/PoliceRaidGame'; +import UnifiedEncounter from '../encounter/UnifiedEncounter'; import { vaultDeposit } from '../../utils/moneyRouter'; import './BlockModeView.css'; @@ -119,12 +121,14 @@ const BlockModeView: React.FC = ({ upsertBlock, setBlockViewMode, collectIncome, + applyEncounterResult, setPlacementMode, } = useBlockStore(); const { updateMoney, updatePlayer, player } = usePlayerStore(); const [showDeployPanel, setShowDeployPanel] = useState(false); const [showDriveBy, setShowDriveBy] = useState(false); + const [showEncounter, setShowEncounter] = useState(false); const [showRaid, setShowRaid] = useState(false); const [toast, setToast] = useState(null); const [bailIncidents, setBailIncidents] = useState([]); @@ -224,6 +228,28 @@ const BlockModeView: React.FC = ({ [selectedBlockId, setPlacementMode, showToast, checkMoraleOnDeploy, completeStep] ); + const handleEncounterResolved = useCallback((result: CombatResult) => { + if (!selectedBlockId) return; + const activeBlock = blocks[selectedBlockId]; + applyEncounterResult(selectedBlockId, result); + updatePlayer({ heat: Math.max(0, Math.min(5, (player.heat ?? 0) + result.heatDelta)) }); + setShowEncounter(false); + if (activeBlock && result.crewDown.length > 0) { + setBailIncidents(result.crewDown.map((memberId, index) => { + const placement = activeBlock.placements.find((item) => item.memberId === memberId); + return { + memberId, + memberName: placement?.memberName ?? 'Crew member', + incidentType: 'injured' as const, + cost: 2200 + index * 800, + blockId: activeBlock.id, + }; + })); + setShowBailModal(true); + } + showToast(result.summary, 3500); + }, [applyEncounterResult, blocks, player.heat, selectedBlockId, showToast, updatePlayer]); + const handleDriveByResolved = useCallback( (outcome: 'repelled' | 'successful' | 'fled' | undefined, casualties: string[]) => { setShowDriveBy(false); @@ -291,14 +317,14 @@ const BlockModeView: React.FC = ({ 🏙️ Street @@ -312,7 +338,13 @@ const BlockModeView: React.FC = ({ {/* Main view */}
- {showRaid ? ( + {showEncounter ? ( + setShowEncounter(false)} + /> + ) : showRaid ? ( { @@ -360,10 +392,10 @@ const BlockModeView: React.FC = ({ setShowDriveBy(true)} + onClick={() => { setShowDriveBy(false); setShowRaid(false); setShowEncounter(true); }} whileTap={{ scale: 0.95 }} > - 🚗 Slide + 🎯 Encounter
diff --git a/frontend/src/game/combat/__tests__/combatSession.test.ts b/frontend/src/game/combat/__tests__/combatSession.test.ts new file mode 100644 index 0000000..2b36477 --- /dev/null +++ b/frontend/src/game/combat/__tests__/combatSession.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { advanceCombat, createCombatSession, dispatchCombatCommand, getCombatSnapshot } from '../combatSession'; +import { prepareEncounter } from '../prepareEncounter'; +import type { BlockData } from '../../../types/block.types'; +import type { Combatant, EncounterPreparation } from '../types'; + +const terrain = Array.from({ length: 3 }, (_, y) => Array.from({ length: 3 }, (_, x) => ({ + x, + y, + zoneType: 'sidewalk' as const, + passable: true, + cover: x === 1 ? 0.65 : 0.2, + exposure: x === 1 ? 0.2 : 0.7, +}))); + +function actor(overrides: Partial): Combatant { + return { + id: 'crew-1', + name: 'Scout', + team: 'crew', + role: 'shooter', + position: { x: 0, y: 1 }, + health: 100, + maxHealth: 100, + armor: 0, + ammo: 8, + maxAmmo: 8, + reloadUntilTick: null, + nextFireTick: 0, + level: 1, + lastSequence: -1, + isDown: false, + ...overrides, + }; +} + +function preparation(): EncounterPreparation { + return { + sessionId: 'test-session', + seed: 42, + sceneLabel: 'Test Strip', + locationReference: 'Fictional reference', + fictionNotice: 'Fictional scene.', + terrain, + crew: [actor({})], + opposition: [actor({ + id: 'opposition-1', + name: 'Lookout', + team: 'opposition', + role: 'opposition', + position: { x: 2, y: 0 }, + health: 55, + maxHealth: 55, + })], + objective: { + kind: 'extract', + label: 'Reach exit', + extraction: { x: 0, y: 2 }, + requiredCrew: 1, + progress: 0, + target: 1, + }, + heatAtStart: 1, + moraleAtStart: 80, + tacticalBrief: [], + }; +} + +describe('CombatSession', () => { + it('resolves the same command sequence deterministically from the same seed', () => { + const first = dispatchCombatCommand(createCombatSession(preparation()), { + type: 'aim-fire', actorId: 'crew-1', targetId: 'opposition-1', sequence: 1, + }); + const second = dispatchCombatCommand(createCombatSession(preparation()), { + type: 'aim-fire', actorId: 'crew-1', targetId: 'opposition-1', sequence: 1, + }); + + expect(getCombatSnapshot(first)).toEqual(getCombatSnapshot(second)); + }); + + it('requires a valid adjacent route before moving', () => { + const session = createCombatSession(preparation()); + const invalid = dispatchCombatCommand(session, { + type: 'move', actorId: 'crew-1', destination: { x: 2, y: 2 }, sequence: 1, + }); + + expect(invalid.combatants[0].position).toEqual({ x: 0, y: 1 }); + expect(invalid.events.at(-1)?.type).toBe('blocked'); + }); + + it('allows the crew to reach the exit and resolves a secured result', () => { + const moved = dispatchCombatCommand(createCombatSession(preparation()), { + type: 'move', actorId: 'crew-1', destination: { x: 0, y: 2 }, sequence: 1, + }); + const resolved = dispatchCombatCommand(moved, { + type: 'interact', actorId: 'crew-1', sequence: 2, + }); + + expect(resolved.phase).toBe('resolved'); + expect(resolved.result?.outcome).toBe('secured'); + expect(resolved.result?.idempotencyKey).toBe('test-session:secured'); + }); + + it('turns a claimed location archetype into deterministic terrain and a fallback scene brief', () => { + const grid = Array.from({ length: 8 }, (_, y) => Array.from({ length: 8 }, (_, x) => ({ + x, y, zoneType: 'sidewalk' as const, incomeModifier: 60, exposureRisk: 50, coverScore: 0.3, passable: true, occupantId: null, + }))); + const block: BlockData = { + id: 'alley-reference', address: '6200 NW 17th Ave', lat: 25.8487, lng: -80.2298, owner: 'player', + grid, placements: [], incomePerTick: 0, heat: 1, morale: 70, members: 0, viewMode: 'topdown', pendingIncome: 0, + topdownBgUrl: '/assets/reference.webp', + }; + + const first = prepareEncounter(block); + const second = prepareEncounter(block); + + expect(first.seed).toBe(second.seed); + expect(first.terrain[2][0].zoneType).toBe('alley'); + expect(first.backgroundUrl).toBe('/assets/reference.webp'); + expect(first.tacticalBrief[0]).toContain('Liberty City Alley'); + }); + + it('uses bounded reload timing and ignores stale commands', () => { + const empty = createCombatSession({ + ...preparation(), + crew: [actor({ ammo: 0, maxAmmo: 8 })], + }); + const reloading = dispatchCombatCommand(empty, { + type: 'reload', actorId: 'crew-1', sequence: 1, + }); + const advanced = advanceCombat(reloading, 20); + const stale = dispatchCombatCommand(advanced, { + type: 'move', actorId: 'crew-1', destination: { x: 0, y: 2 }, sequence: 1, + }); + + expect(advanced.combatants[0].ammo).toBe(8); + expect(stale.events.at(-1)?.type).toBe('blocked'); + }); +}); diff --git a/frontend/src/game/combat/combatSession.ts b/frontend/src/game/combat/combatSession.ts new file mode 100644 index 0000000..6cbad03 --- /dev/null +++ b/frontend/src/game/combat/combatSession.ts @@ -0,0 +1,247 @@ +import type { + CombatCommand, + CombatEvent, + CombatResult, + CombatSession, + CombatSnapshot, + Combatant, + EncounterPreparation, + GridPoint, +} from './types'; + +const RELOAD_TICKS = 20; +const FIRE_INTERVAL_TICKS = 5; +const MAX_EVENT_LOG = 30; + +function nextRandom(session: CombatSession): [CombatSession, number] { + const nextState = (Math.imul(session.rngState, 1664525) + 1013904223) >>> 0; + return [{ ...session, rngState: nextState }, nextState / 0x1_0000_0000]; +} + +function event(session: CombatSession, type: CombatEvent['type'], message: string, actorId?: string, targetId?: string, amount?: number): CombatEvent { + return { + id: `${session.id}:${session.tick}:${session.events.length}:${type}`, + tick: session.tick, + type, + actorId, + targetId, + amount, + message, + }; +} + +function appendEvents(session: CombatSession, events: CombatEvent[]): CombatSession { + return { ...session, events: [...session.events, ...events].slice(-MAX_EVENT_LOG) }; +} + +function cellAt(session: CombatSession, point: GridPoint) { + return session.terrain[point.y]?.[point.x]; +} + +function distance(a: GridPoint, b: GridPoint): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +function isOccupied(session: CombatSession, point: GridPoint, ignoredId?: string): boolean { + return session.combatants.some((actor) => !actor.isDown && actor.id !== ignoredId && actor.position.x === point.x && actor.position.y === point.y); +} + +function updateCombatant(session: CombatSession, combatant: Combatant): CombatSession { + return { + ...session, + combatants: session.combatants.map((current) => current.id === combatant.id ? combatant : current), + }; +} + +function getActor(session: CombatSession, id: string): Combatant | undefined { + return session.combatants.find((actor) => actor.id === id); +} + +function lineOfSight(session: CombatSession, start: GridPoint, end: GridPoint): boolean { + const steps = Math.max(Math.abs(end.x - start.x), Math.abs(end.y - start.y)); + for (let index = 1; index < steps; index += 1) { + const x = Math.round(start.x + ((end.x - start.x) * index) / steps); + const y = Math.round(start.y + ((end.y - start.y) * index) / steps); + if (!cellAt(session, { x, y })?.passable) return false; + } + return true; +} + +function resolveResult(session: CombatSession, outcome: CombatResult['outcome']): CombatSession { + if (session.result) return session; + const crewDown = session.combatants.filter((actor) => actor.team === 'crew' && actor.isDown).map((actor) => actor.id); + const oppositionDown = session.combatants.filter((actor) => actor.team === 'opposition' && actor.isDown).map((actor) => actor.id); + const secured = outcome === 'secured'; + const result: CombatResult = { + idempotencyKey: `${session.id}:${outcome}`, + outcome, + crewDown, + oppositionDown, + objectiveProgress: session.objective.progress, + heatDelta: secured ? 1 : outcome === 'overrun' ? 2 : 0, + moraleDelta: secured ? 4 : outcome === 'overrun' ? -12 : -4, + pendingIncomeDelta: secured ? 75 : outcome === 'overrun' ? -50 : -15, + summary: secured + ? 'Secure exit reached. The crew held together under pressure.' + : outcome === 'retreated' + ? 'The crew disengaged and kept the situation from escalating.' + : 'The crew was overrun and needs time to recover.', + }; + const resolved = { ...session, phase: 'resolved' as const, result }; + return appendEvents(resolved, [event(resolved, 'resolved', result.summary)]); +} + +function applyDamage(session: CombatSession, source: Combatant, target: Combatant, random: number): CombatSession { + const targetCell = cellAt(session, target.position); + const rangePenalty = Math.max(0, distance(source.position, target.position) - 1) * 0.045; + const hitChance = Math.max(0.18, Math.min(0.9, 0.76 + source.level * 0.025 - (targetCell?.cover ?? 0) * 0.35 - rangePenalty)); + const fired = appendEvents(session, [event(session, 'weapon-fired', `${source.name} fires.`, source.id, target.id)]); + if (random > hitChance) { + return appendEvents(fired, [event(fired, 'impact-cover', 'Shot strikes cover.', source.id, target.id)]); + } + const damage = Math.max(6, 18 + source.level * 2 - target.armor * 3); + const updatedTarget = { ...target, health: Math.max(0, target.health - damage) }; + let updated = updateCombatant(fired, updatedTarget); + updated = appendEvents(updated, [event(updated, 'impact-actor', `${target.name} takes ${damage} damage.`, source.id, target.id, damage)]); + if (updatedTarget.health <= 0) { + updated = updateCombatant(updated, { ...updatedTarget, isDown: true }); + updated = appendEvents(updated, [event(updated, 'actor-downed', `${target.name} is down.`, source.id, target.id)]); + } + return updated; +} + +function takeStepToward(session: CombatSession, actor: Combatant, target: Combatant): CombatSession { + const choices: GridPoint[] = [ + { x: actor.position.x + Math.sign(target.position.x - actor.position.x), y: actor.position.y }, + { x: actor.position.x, y: actor.position.y + Math.sign(target.position.y - actor.position.y) }, + ].filter((point, index, array) => point.x !== actor.position.x || point.y !== actor.position.y) + .filter((point, index, array) => array.findIndex((candidate) => candidate.x === point.x && candidate.y === point.y) === index); + const candidate = choices.find((point) => cellAt(session, point)?.passable && !isOccupied(session, point, actor.id)); + if (!candidate) return session; + const moved = updateCombatant(session, { ...actor, position: candidate }); + return appendEvents(moved, [event(moved, 'moved', `${actor.name} repositions.`, actor.id)]); +} + +function runOppositionTurn(session: CombatSession): CombatSession { + let next = session; + const crew = next.combatants.filter((actor) => actor.team === 'crew' && !actor.isDown); + for (const oppositionId of next.combatants.filter((actor) => actor.team === 'opposition' && !actor.isDown).map((actor) => actor.id)) { + const actor = getActor(next, oppositionId); + if (!actor || crew.length === 0) continue; + const target = [...crew].sort((a, b) => distance(actor.position, a.position) - distance(actor.position, b.position))[0]; + if (!target) continue; + if (actor.reloadUntilTick !== null || actor.nextFireTick > next.tick || actor.ammo <= 0) { + if (actor.ammo <= 0 && actor.reloadUntilTick === null) { + next = updateCombatant(next, { ...actor, reloadUntilTick: next.tick + RELOAD_TICKS }); + next = appendEvents(next, [event(next, 'reload-start', `${actor.name} reloads.`, actor.id)]); + } + continue; + } + if (distance(actor.position, target.position) > 4 || !lineOfSight(next, actor.position, target.position)) { + next = takeStepToward(next, actor, target); + continue; + } + const [randomized, random] = nextRandom(next); + next = applyDamage(randomized, actor, target, random); + const current = getActor(next, actor.id); + if (current) next = updateCombatant(next, { ...current, ammo: current.ammo - 1, nextFireTick: next.tick + FIRE_INTERVAL_TICKS }); + } + return next; +} + +export function createCombatSession(preparation: EncounterPreparation): CombatSession { + return { + id: preparation.sessionId, + seed: preparation.seed, + rngState: preparation.seed || 1, + tick: 0, + phase: 'active', + terrain: preparation.terrain, + combatants: [...preparation.crew, ...preparation.opposition], + objective: preparation.objective, + heatAtStart: preparation.heatAtStart, + moraleAtStart: preparation.moraleAtStart, + events: [], + result: null, + }; +} + +export function advanceCombat(session: CombatSession, steps = 1): CombatSession { + let next = session; + for (let index = 0; index < steps && next.phase === 'active'; index += 1) { + next = { ...next, tick: next.tick + 1 }; + const reloaded: Combatant[] = next.combatants.map((actor) => { + if (actor.reloadUntilTick !== null && actor.reloadUntilTick <= next.tick) { + return { ...actor, ammo: actor.maxAmmo, reloadUntilTick: null }; + } + return actor; + }); + const completedReloads = next.combatants.filter((actor) => actor.reloadUntilTick !== null && actor.reloadUntilTick <= next.tick); + next = { ...next, combatants: reloaded }; + if (completedReloads.length > 0) { + next = appendEvents(next, completedReloads.map((actor) => event(next, 'reload-complete', `${actor.name} is ready.`, actor.id))); + } + if (next.tick % 6 === 0) next = runOppositionTurn(next); + const livingCrew = next.combatants.filter((actor) => actor.team === 'crew' && !actor.isDown); + if (livingCrew.length === 0) next = resolveResult(next, 'overrun'); + } + return next; +} + +export function dispatchCombatCommand(session: CombatSession, command: CombatCommand): CombatSession { + if (session.phase !== 'active') return session; + const actor = getActor(session, command.actorId); + if (!actor || actor.isDown || actor.team !== 'crew' || command.sequence <= actor.lastSequence) { + return appendEvents(session, [event(session, 'blocked', 'Command was not accepted.', command.actorId)]); + } + let next = updateCombatant(session, { ...actor, lastSequence: command.sequence }); + if (command.type === 'move') { + const targetCell = cellAt(next, command.destination); + if (!targetCell?.passable || distance(actor.position, command.destination) !== 1 || isOccupied(next, command.destination, actor.id)) { + return appendEvents(next, [event(next, 'blocked', 'Route blocked. Choose an adjacent open tile.', actor.id)]); + } + next = updateCombatant(next, { ...getActor(next, actor.id)!, position: command.destination }); + return appendEvents(next, [event(next, 'moved', `${actor.name} moves into ${targetCell.zoneType} cover.`, actor.id)]); + } + if (command.type === 'reload') { + const current = getActor(next, actor.id)!; + if (current.reloadUntilTick !== null || current.ammo === current.maxAmmo) return next; + next = updateCombatant(next, { ...current, reloadUntilTick: next.tick + RELOAD_TICKS }); + return appendEvents(next, [event(next, 'reload-start', `${actor.name} starts reloading.`, actor.id)]); + } + if (command.type === 'aim-fire') { + const source = getActor(next, actor.id)!; + const target = getActor(next, command.targetId); + if (!target || target.isDown || target.team !== 'opposition' || source.ammo <= 0 || source.reloadUntilTick !== null || source.nextFireTick > next.tick || distance(source.position, target.position) > 6 || !lineOfSight(next, source.position, target.position)) { + return appendEvents(next, [event(next, 'blocked', 'No clean line of sight. Reposition or reload.', actor.id)]); + } + const [randomized, random] = nextRandom(next); + next = applyDamage(randomized, source, target, random); + const current = getActor(next, source.id); + if (current) next = updateCombatant(next, { ...current, ammo: current.ammo - 1, nextFireTick: next.tick + FIRE_INTERVAL_TICKS }); + return next; + } + if (command.type === 'interact') { + const current = getActor(next, actor.id)!; + if (current.position.x !== next.objective.extraction.x || current.position.y !== next.objective.extraction.y) { + return appendEvents(next, [event(next, 'blocked', 'Move to the secure exit to extract.', actor.id)]); + } + const objective = { ...next.objective, progress: Math.min(next.objective.target, next.objective.progress + 1) }; + next = { ...next, objective }; + next = appendEvents(next, [event(next, 'objective-progress', 'Secure exit reached.', actor.id, undefined, objective.progress)]); + if (objective.progress >= objective.target) next = resolveResult(next, 'secured'); + return next; + } + return resolveResult(next, 'retreated'); +} + +export function getCombatSnapshot(session: CombatSession): CombatSnapshot { + return { + tick: session.tick, + phase: session.phase, + combatants: session.combatants, + objective: session.objective, + events: session.events, + result: session.result, + }; +} diff --git a/frontend/src/game/combat/prepareEncounter.ts b/frontend/src/game/combat/prepareEncounter.ts new file mode 100644 index 0000000..268f8f5 --- /dev/null +++ b/frontend/src/game/combat/prepareEncounter.ts @@ -0,0 +1,166 @@ +import { resolveBlockDNA } from '../../utils/blockDNAResolver'; +import type { BlockData, BlockPlacement, BlockZoneType } from '../../types/block.types'; +import type { + CombatTerrainCell, + Combatant, + EncounterPreparation, + GridPoint, +} from './types'; + +function hashString(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function seededIndex(seed: number, index: number, modulo: number): number { + const value = Math.imul(seed ^ (index + 1) * 0x9e3779b9, 0x85ebca6b) >>> 0; + return value % modulo; +} + +const DNA_ZONE_PROFILE: Record = { + street: { cover: 0.05, exposure: 0.95, passable: false }, + curb: { cover: 0.15, exposure: 0.8, passable: true }, + sidewalk: { cover: 0.3, exposure: 0.5, passable: true }, + storefront: { cover: 0.6, exposure: 0.25, passable: true }, + alley: { cover: 0.8, exposure: 0.1, passable: true }, + parking: { cover: 0.35, exposure: 0.4, passable: true }, + rooftop: { cover: 0.9, exposure: 0.05, passable: true }, + building: { cover: 1, exposure: 0, passable: false }, +}; + +function toTerrain(block: BlockData, coverBonus: number, zoneLayout: BlockZoneType[]): CombatTerrainCell[][] { + return block.grid.map((row) => row.map((zone) => { + const zoneType = zoneLayout[zone.y] ?? zone.zoneType; + const profile = DNA_ZONE_PROFILE[zoneType]; + return { + x: zone.x, + y: zone.y, + zoneType, + passable: zone.passable && profile.passable, + cover: Math.max(0, Math.min(1, Math.max(zone.coverScore, profile.cover) + coverBonus)), + exposure: Math.max(0, Math.min(1, Math.min(zone.exposureRisk / 100, profile.exposure))), + }; + })); +} + +function toCrew(placements: BlockPlacement[]): Combatant[] { + return placements + .filter((placement) => placement.health > 0) + .slice(0, 4) + .map((placement) => ({ + id: placement.memberId, + name: placement.memberName, + team: 'crew' as const, + role: placement.role, + position: { x: placement.x, y: placement.y }, + health: Math.max(1, placement.health), + maxHealth: 100, + armor: placement.role === 'enforcer' ? 2 : placement.role === 'shooter' ? 1 : 0, + ammo: placement.role === 'shooter' ? 8 + placement.level : 5 + placement.level, + maxAmmo: placement.role === 'shooter' ? 8 + placement.level : 5 + placement.level, + reloadUntilTick: null, + nextFireTick: 0, + level: Math.max(1, placement.level), + lastSequence: -1, + isDown: false, + })); +} + +function findSafePoint(terrain: CombatTerrainCell[][]): CombatTerrainCell { + return terrain + .flat() + .filter((cell) => cell.passable) + .sort((left, right) => (right.cover - left.cover) || (left.exposure - right.exposure))[0] ?? { + x: 0, + y: 0, + zoneType: 'sidewalk', + passable: true, + cover: 0, + exposure: 1, + }; +} + +function findOppositionStart(terrain: CombatTerrainCell[][], seed: number, index: number): GridPoint { + const cells = terrain + .flat() + .filter((cell) => cell.passable && cell.y <= 3) + .sort((left, right) => right.exposure - left.exposure); + return cells[seededIndex(seed, index, Math.max(cells.length, 1))] ?? { x: 0, y: 0 }; +} + +export function prepareEncounter(block: BlockData): EncounterPreparation { + const resolved = resolveBlockDNA(block.lat, block.lng, block.address); + const seed = hashString(`${block.id}:${resolved.seed}:${block.heat}:${block.morale}`); + const terrain = toTerrain(block, resolved.dna.globalCoverBonus, resolved.zoneLayout); + const crew = toCrew(block.placements); + const fallbackCrew: Combatant[] = crew.length > 0 ? crew : [{ + id: 'crew-scout', + name: 'Scout', + team: 'crew', + role: 'recruit', + position: findSafePoint(terrain), + health: 80, + maxHealth: 80, + armor: 0, + ammo: 6, + maxAmmo: 6, + reloadUntilTick: null, + nextFireTick: 0, + level: 1, + lastSequence: -1, + isDown: false, + }]; + const oppositionCount = Math.min(4, Math.max(2, 1 + Math.ceil(block.heat / 2))); + const opposition = Array.from({ length: oppositionCount }, (_, index) => ({ + id: `opposition-${index + 1}`, + name: index === 0 ? 'Lookout' : `Rival ${index + 1}`, + team: 'opposition' as const, + role: 'opposition' as const, + position: findOppositionStart(terrain, seed, index), + health: index === 0 ? 70 : 55, + maxHealth: index === 0 ? 70 : 55, + armor: 0, + ammo: 7, + maxAmmo: 7, + reloadUntilTick: null, + nextFireTick: 0, + level: 1 + Math.floor(block.heat / 3), + lastSequence: -1, + isDown: false, + })); + const extraction = findSafePoint(terrain); + const coverPercent = Math.round(extraction.cover * 100); + const exposurePercent = Math.round(extraction.exposure * 100); + + return { + sessionId: `encounter-${block.id}-${seed.toString(36)}`, + seed, + sceneLabel: resolved.dna.name, + locationReference: block.address, + fictionNotice: 'This scene is a fictionalized, gameplay-only interpretation of local street character.', + backgroundUrl: block.topdownBgUrl ?? block.streetBackdropUrl, + terrain, + crew: fallbackCrew, + opposition, + objective: { + kind: 'extract', + label: 'Reach the secure exit with your active crew', + extraction, + requiredCrew: 1, + progress: 0, + target: 1, + }, + heatAtStart: block.heat, + moraleAtStart: block.morale, + tacticalBrief: [ + `${resolved.dna.name} is shaped by ${resolved.dna.tags.join(' · ')} terrain cues.`, + `Secure exit has ${coverPercent}% cover and ${exposurePercent}% exposure.`, + `Current pressure: heat ${block.heat}/5 · morale ${block.morale}%.`, + 'Placement, cover, and crew condition carry directly into this encounter.', + ], + }; +} diff --git a/frontend/src/game/combat/types.ts b/frontend/src/game/combat/types.ts new file mode 100644 index 0000000..dc531bd --- /dev/null +++ b/frontend/src/game/combat/types.ts @@ -0,0 +1,127 @@ +import type { BlockZoneType, MemberRole } from '../../types/block.types'; + +export type CombatPhase = 'briefing' | 'active' | 'resolved'; +export type CombatTeam = 'crew' | 'opposition'; +export type CombatObjectiveKind = 'extract'; + +export interface GridPoint { + x: number; + y: number; +} + +export interface CombatTerrainCell extends GridPoint { + zoneType: BlockZoneType; + passable: boolean; + cover: number; + exposure: number; +} + +export interface Combatant { + id: string; + name: string; + team: CombatTeam; + role: MemberRole | 'opposition'; + position: GridPoint; + health: number; + maxHealth: number; + armor: number; + ammo: number; + maxAmmo: number; + reloadUntilTick: number | null; + nextFireTick: number; + level: number; + lastSequence: number; + isDown: boolean; +} + +export interface CombatObjective { + kind: CombatObjectiveKind; + label: string; + extraction: GridPoint; + requiredCrew: number; + progress: number; + target: number; +} + +export type CombatCommand = + | { type: 'move'; actorId: string; destination: GridPoint; sequence: number } + | { type: 'aim-fire'; actorId: string; targetId: string; sequence: number } + | { type: 'reload'; actorId: string; sequence: number } + | { type: 'interact'; actorId: string; sequence: number } + | { type: 'retreat'; actorId: string; sequence: number }; + +export type CombatEventType = + | 'moved' + | 'blocked' + | 'weapon-fired' + | 'impact-cover' + | 'impact-actor' + | 'actor-downed' + | 'reload-start' + | 'reload-complete' + | 'objective-progress' + | 'risk-changed' + | 'retreated' + | 'resolved'; + +export interface CombatEvent { + id: string; + tick: number; + type: CombatEventType; + actorId?: string; + targetId?: string; + amount?: number; + message: string; +} + +export interface CombatSession { + id: string; + seed: number; + rngState: number; + tick: number; + phase: CombatPhase; + terrain: CombatTerrainCell[][]; + combatants: Combatant[]; + objective: CombatObjective; + heatAtStart: number; + moraleAtStart: number; + events: CombatEvent[]; + result: CombatResult | null; +} + +export interface CombatResult { + idempotencyKey: string; + outcome: 'secured' | 'retreated' | 'overrun'; + crewDown: string[]; + oppositionDown: string[]; + objectiveProgress: number; + heatDelta: number; + moraleDelta: number; + pendingIncomeDelta: number; + summary: string; +} + +export interface EncounterPreparation { + sessionId: string; + seed: number; + sceneLabel: string; + locationReference: string; + fictionNotice: string; + backgroundUrl?: string; + terrain: CombatTerrainCell[][]; + crew: Combatant[]; + opposition: Combatant[]; + objective: CombatObjective; + heatAtStart: number; + moraleAtStart: number; + tacticalBrief: string[]; +} + +export interface CombatSnapshot { + tick: number; + phase: CombatPhase; + combatants: Combatant[]; + objective: CombatObjective; + events: CombatEvent[]; + result: CombatResult | null; +} diff --git a/frontend/src/stores/blockStore.encounter.test.ts b/frontend/src/stores/blockStore.encounter.test.ts new file mode 100644 index 0000000..fd62aee --- /dev/null +++ b/frontend/src/stores/blockStore.encounter.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useBlockStore } from './blockStore'; +import type { BlockData } from '../types/block.types'; +import type { CombatResult } from '../game/combat/types'; + +function block(): BlockData { + return { + id: 'block-1', + address: 'Fictional Reference', + lat: 0, + lng: 0, + owner: 'player', + grid: [[{ + x: 0, y: 0, zoneType: 'sidewalk', incomeModifier: 60, exposureRisk: 50, + coverScore: 0.3, passable: true, occupantId: 'crew-1', + }]], + placements: [{ + memberId: 'crew-1', memberName: 'Scout', role: 'shooter', x: 0, y: 0, + zoneType: 'sidewalk', incomePerTick: 0, exposureRisk: 50, level: 1, health: 100, + }], + incomePerTick: 0, + heat: 2, + morale: 70, + members: 1, + viewMode: 'topdown', + pendingIncome: 100, + }; +} + +const result: CombatResult = { + idempotencyKey: 'encounter-1:overrun', + outcome: 'overrun', + crewDown: ['crew-1'], + oppositionDown: [], + objectiveProgress: 0, + heatDelta: 2, + moraleDelta: -12, + pendingIncomeDelta: -50, + summary: 'The crew was overrun.', +}; + +describe('blockStore unified encounter outcomes', () => { + beforeEach(() => { + useBlockStore.setState({ + blocks: { 'block-1': block() }, + selectedBlockId: 'block-1', + activeDriveBys: {}, + isPlacementMode: false, + pendingPlacementMemberId: null, + pendingPlacementMember: null, + }); + }); + + it('projects a result exactly once and bounds the durable consequences', () => { + useBlockStore.getState().applyEncounterResult('block-1', result); + useBlockStore.getState().applyEncounterResult('block-1', result); + const resolved = useBlockStore.getState().blocks['block-1']; + + expect(resolved.placements[0].health).toBe(0); + expect(resolved.heat).toBe(4); + expect(resolved.morale).toBe(58); + expect(resolved.pendingIncome).toBe(50); + expect(resolved.appliedEncounterResultKeys).toEqual(['encounter-1:overrun']); + }); +}); diff --git a/frontend/src/stores/blockStore.ts b/frontend/src/stores/blockStore.ts index b39481a..60c9f72 100644 --- a/frontend/src/stores/blockStore.ts +++ b/frontend/src/stores/blockStore.ts @@ -276,6 +276,31 @@ export const useBlockStore = create()( }; }), + applyEncounterResult: (blockId, result) => + set((state) => { + const block = state.blocks[blockId]; + if (!block) return state; + const applied = block.appliedEncounterResultKeys ?? []; + if (applied.includes(result.idempotencyKey)) return state; + const downed = new Set(result.crewDown); + const placements = block.placements.map((placement) => + downed.has(placement.memberId) ? { ...placement, health: 0 } : placement + ); + return { + blocks: { + ...state.blocks, + [blockId]: { + ...block, + placements, + heat: Math.max(0, Math.min(5, block.heat + result.heatDelta)), + morale: Math.max(0, Math.min(100, block.morale + result.moraleDelta)), + pendingIncome: Math.max(0, block.pendingIncome + result.pendingIncomeDelta), + appliedEncounterResultKeys: [...applied, result.idempotencyKey].slice(-24), + }, + }, + }; + }), + collectIncome: (blockId) => { const block = get().blocks[blockId]; if (!block || block.pendingIncome <= 0) return 0; diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 8a5957b..16aa75e 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -3,6 +3,8 @@ // Sprint: block-mode-combat-assets // ============================================================ +import type { CombatResult } from '../game/combat/types'; + // ─── Zone / Tile ──────────────────────────────────────────── export type BlockZoneType = @@ -105,6 +107,8 @@ export interface BlockData { lastCollectedAt?: string; /** Pending income not yet collected */ pendingIncome: number; + /** Idempotency keys for unified encounter outcomes already applied to this block */ + appliedEncounterResultKeys?: string[]; /** * Gang name of whoever last landed a successful tag here. Set by the * graffiti mission so the territory layer can show a block as contested @@ -195,6 +199,8 @@ export interface BlockStoreActions { recordShot: (blockId: string, shot: DriveByShot, isDefender: boolean) => void; /** Resolve a drive-by */ resolveDriveBy: (blockId: string, outcome: DriveByEvent['outcome']) => void; + /** Apply a unified encounter result once to local territory projections */ + applyEncounterResult: (blockId: string, result: CombatResult) => void; /** Collect pending income from a block */ collectIncome: (blockId: string) => number; /** Tick income for all blocks (call from game loop) */ diff --git a/frontend/src/utils/blockDNAResolver.ts b/frontend/src/utils/blockDNAResolver.ts index 50001d6..89f0623 100644 --- a/frontend/src/utils/blockDNAResolver.ts +++ b/frontend/src/utils/blockDNAResolver.ts @@ -118,6 +118,25 @@ function findDNAByTag(tag: BlockTag, seed: string): BlockDNA | null { return matches[seedNum % matches.length]; } +/** + * Preserve the authored personality of a curated location when the claimed + * coordinate is genuinely nearby. This prevents generic road words such as + * "Avenue" from overriding a more meaningful local archetype. + */ +function findNearbyCuratedDNA(lat: number, lng: number): BlockDNA | null { + const maxDistanceSquared = 0.000025; // roughly a few city blocks + let closest: BlockDNA | null = null; + let closestDistance = Infinity; + for (const candidate of BLOCK_DNA_LIBRARY) { + const distance = (candidate.lat - lat) ** 2 + (candidate.lng - lng) ** 2; + if (distance <= maxDistanceSquared && distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + return closest; +} + /** * Convert a hash string to a stable integer for indexing. */ @@ -167,20 +186,21 @@ export function resolveBlockDNA( ): ResolvedBlock { const seed = generateBlockHash(lat, lng); - // 1. Try keyword match from address string - const detectedTag = detectTagFromAddress(address); - let dna: BlockDNA | null = null; + // 1. Preserve the authored visual identity for a nearby curated place. + let dna: BlockDNA | null = findNearbyCuratedDNA(lat, lng); - if (detectedTag) { + // 2. Use broad address cues when the player is not near a curated record. + const detectedTag = detectTagFromAddress(address); + if (!dna && detectedTag) { dna = findDNAByTag(detectedTag, seed); } - // 2. Fall back to nearest DNA by geographic distance + // 3. Fall back to nearest DNA by geographic distance. if (!dna) { dna = getNearestDNA(lat, lng); } - // 3. Final fallback: seed-based pick from full library + // 4. Final fallback: seed-based pick from full library if (!dna) { const idx = seedToNumber(seed) % BLOCK_DNA_LIBRARY.length; dna = BLOCK_DNA_LIBRARY[idx];