-
Notifications
You must be signed in to change notification settings - Fork 0
feat: unify territory encounter vertical slice #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UnifiedEncounterProps> = ({ block, onResolved, onClose }) => { | ||
| const containerRef = useRef<HTMLDivElement | null>(null); | ||
| const gameRef = useRef<Phaser.Game | null>(null); | ||
| const sceneRef = useRef<UnifiedEncounterScene | null>(null); | ||
| const resolvedRef = useRef(false); | ||
| const preparation = useMemo(() => prepareEncounter(block), [block]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. |
||
| const reducedMotion = useReducedMotion(); | ||
| const [snapshot, setSnapshot] = useState<CombatSnapshot>(() => getCombatSnapshot(createCombatSession(preparation))); | ||
| const [result, setResult] = useState<CombatResult | null>(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 ( | ||
| <section className="unified-encounter" aria-label="Tactical encounter"> | ||
| <header className="ue-briefing"> | ||
| <div> | ||
| <span className="ue-kicker">TACTICAL BRIEF · FICTIONALIZED LOCAL REFERENCE</span> | ||
| <h2>{preparation.sceneLabel}</h2> | ||
| <p>{preparation.locationReference}</p> | ||
| </div> | ||
| <button type="button" className="ue-close" onClick={onClose} aria-label="Return to block planning">Return to planning</button> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
While combat is active, this button immediately unmounts the encounter without dispatching the existing retreat command or applying any consequence. A player who is about to be overrun can therefore use “Return to planning” and relaunch instead of accepting the retreat morale/income penalty or a loss, making the explicit retreat action optional. Useful? React with 👍 / 👎. |
||
| </header> | ||
|
|
||
| <div className="ue-modifiers" aria-label="Encounter modifiers"> | ||
| {preparation.tacticalBrief.map((item) => <span key={item}>{item}</span>)} | ||
| </div> | ||
|
|
||
| <div className="ue-stage-shell"> | ||
| {!isReady && <div className="ue-loading" role="status">Building your tactical scene…</div>} | ||
| <div ref={containerRef} className={`ue-stage ${isReady ? 'ue-stage--ready' : ''}`} /> | ||
| </div> | ||
|
|
||
| <div className="ue-controls" aria-label="Encounter actions"> | ||
| <div className="ue-live-stats"> | ||
| <span><strong>{activeCrew}</strong> crew active</span> | ||
| <span><strong>{opposition}</strong> opposition visible</span> | ||
| <span><strong>{snapshot.objective.progress}/{snapshot.objective.target}</strong> secure exits</span> | ||
| </div> | ||
| <div className="ue-action-row"> | ||
| <button type="button" disabled={!isReady || snapshot.phase !== 'active'} onClick={() => sceneRef.current?.fireNearestSelected()}>Fire nearest</button> | ||
| <button type="button" disabled={!isReady || snapshot.phase !== 'active'} onClick={() => sceneRef.current?.reloadSelected()}>Reload</button> | ||
| <button type="button" className="ue-extract" disabled={!isReady || snapshot.phase !== 'active'} onClick={() => sceneRef.current?.interactSelected()}>Secure exit</button> | ||
| <button type="button" className="ue-retreat" disabled={!isReady || snapshot.phase !== 'active'} onClick={() => sceneRef.current?.retreatSelected()}>Retreat safely</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="ue-event-log" aria-live="polite" aria-label="Combat status"> | ||
| {snapshot.events.slice(-4).reverse().map((item) => <p key={item.id}>{item.message}</p>)} | ||
| {snapshot.events.length === 0 && <p>Choose a crew member, use cover, and reach the highlighted secure exit.</p>} | ||
| </div> | ||
|
|
||
| {result && ( | ||
| <div className="ue-result" role="dialog" aria-modal="true" aria-label="Encounter result"> | ||
| <span className={`ue-result__badge outcome-${result.outcome}`}>{result.outcome.toUpperCase()}</span> | ||
| <h3>{result.summary}</h3> | ||
| <p>{result.oppositionDown.length} opposition down · {result.crewDown.length} crew needing recovery · heat {result.heatDelta >= 0 ? '+' : ''}{result.heatDelta}</p> | ||
| <button type="button" onClick={commitResult}>Apply result and return</button> | ||
| </div> | ||
| )} | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default UnifiedEncounter; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This commit lands a substantial player-facing vertical slice and decision record but does not append the required dated entry to
docs/PROJECT_LOG.md, leaving the repository's declared running record and current direction unaware of the new canonical encounter flow. Add the corresponding log entry as part of this change.AGENTS.md reference: AGENTS.md:L3-L5
Useful? React with 👍 / 👎.