From 9227dc67d0ce174b7960ceaa06f42f865cf84abc Mon Sep 17 00:00:00 2001 From: BrandDead Date: Fri, 9 Oct 2026 15:02:03 -0400 Subject: [PATCH] fix: reuse Nightfall role art and preserve Phaser tap geometry Render registered idle/downed role art, deduplicate presentation effects, and expose existing crew-condition snapshots in the DOM HUD. Keep final HUD browser/capture verification explicitly pending after workspace outage. --- ASSETS.md | 19 ++ docs/PROJECT_LOG.md | 11 + .../README.md | 34 +++ .../verification-summary.json | 208 ++++++++++++++++++ .../components/encounter/UnifiedEncounter.css | 8 +- .../components/encounter/UnifiedEncounter.tsx | 5 + .../encounter/UnifiedEncounterScene.ts | 60 ++--- .../__tests__/UnifiedEncounterLog.test.tsx | 23 ++ ...UnifiedEncounterScene.presentation.test.ts | 95 ++++++++ 9 files changed, 435 insertions(+), 28 deletions(-) create mode 100644 docs/evidence/2026-10-09-nightfall-presentation/README.md create mode 100644 docs/evidence/2026-10-09-nightfall-presentation/verification-summary.json create mode 100644 frontend/src/components/encounter/__tests__/UnifiedEncounterScene.presentation.test.ts diff --git a/ASSETS.md b/ASSETS.md index e416a00..9e3e24c 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -89,3 +89,22 @@ The Block DNA batch-one reference establishes harbor, transit-market, canal, and | Matching vertical vehicle loadout plate | Original OpenAI GPT Image 2.5 generation; no found artwork incorporated | `art-src/generated/environments/street/block_slide_car_loadout_mobile_v001.png` (1440×2560) | `generated.environments.street.block_slide_car_loadout_mobile_v001` → `/assets/runtime/generated/environments/street/block_slide_car_loadout_mobile_v001.webp` | 1080×1920, 201,184 bytes | Both sources were imported using the processor's exact-source `--only` option after #198, not by hand-editing the manifest. The global runtime package is 7.70 MB against the 20 MB limit. These plates are **decorative only**: four seat buttons, selection/status text, stats, cargo, and the launch action remain accessible DOM. Fictional demo members have only a clearly labeled role-art fallback; a verified personal likeness depends on a real member photo and the separate #195 generation-provider gate. The loadout excludes the driver from passenger-shooter seats and post-mission shooter XP, but the legacy mini-game does not yet attribute shots to named passengers (#201). + +## Nightfall tactical presentation reuse — 2026-10-09 + +`UnifiedEncounterScene` now consumes the existing `worldActorResolver` topdown idle/downed role art for crew and fictional opposition. Opposition borrows the registered shooter kit for presentation only; named identity, engine role, team, position and combat authority are preserved. URL-derived texture keys keep resolver aliases on the same loaded image. No generated art, asset import, manifest change, dependency or acquisition was added. + +| Registered asset ID | Runtime file under `/assets/runtime/generated/characters/topdown/` | Existing source under `art-src/generated/characters/topdown/` | Bytes | +|---|---|---|---:| +| `generated.characters.topdown.character_dealer_male_blacktee_topdown_idle_v001` | `character_dealer_male_blacktee_topdown_idle_v001.webp` | `character_dealer_male_blacktee_topdown_idle_v001.png` | 12,014 | +| `generated.characters.topdown.character_dealer_male_topdown_downed_v001` | `character_dealer_male_topdown_downed_v001.webp` | `character_dealer_male_topdown_downed_v001.png` | 19,238 | +| `generated.characters.topdown.character_shooter_male_topdown_idle_v001` | `character_shooter_male_topdown_idle_v001.webp` | `character_shooter_male_topdown_idle_v001.png` | 11,120 | +| `generated.characters.topdown.character_shooter_male_topdown_downed_v001` | `character_shooter_male_topdown_downed_v001.webp` | `character_shooter_male_topdown_downed_v001.png` | 21,134 | + +These are previously registered repository assets with their existing provenance, not newly acquired art or member likenesses. All four are 384×384 with alpha and resolver pivot `(0.5,0.5)`. The scene uses resolver pivots and the existing `0.32` sprite scale (122.88 world pixels; approximately 49.92 CSS pixels at 390px width). + +**State/fallback contract:** preload idle/downed URLs once; use registered downed art when available. Missing actor images retain team-colored geometric markers and named labels. Aim/fire/hit/reload pose art is not available in this kit: idle is intentional while existing tracers/impacts and snapshot HUD communicate combat. No pose animation is claimed. The existing Las Olas `preparation.backgroundUrl` remains the plate; increased plate visibility and lighter passable-cell tint preserve cover/extraction readability. Missing plate retains the existing terrain/background fallback. + +**Projection/budget:** 960×600 world, 8×8 terrain, 120×55.25 cells, top 86/bottom 72 and engine hit cells are unchanged. Phaser FIT owns canvas sizing/centering; CSS does not stretch it. Preloading the two already-shipped downed images adds at most 40,372 bytes of requests for the demonstrated dealer/shooter pair; shipped asset budget delta is zero. Audit remained 112 assets / 7.70 MB of 20 MB / no errors or warnings, with 5 valid packages and 4 schemas. + +Phone/desktop and actor-image-failure checks completed before the final DOM health/ammo/reload HUD addition. Final HUD recapture and screenshot upload are pending after a workspace outage; see `docs/evidence/2026-10-09-nightfall-presentation/README.md`. This narrow reuse fix does not complete #190's full hero-package/style approval or its missing pose inventory. diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index e4373f7..a9529b2 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,17 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-10-09 — Nightfall reuse and touch geometry, draft handoff (#190) + +- **Base/branch:** `9eef5aac28a82275d7a2e1596e8658b167953ed7` / `fix/190-tactical-role-art`. Reservations: #190 comments 6086504547 and 6087048558. Reuse existing resources before creating code/assets. +- **Player change:** registered idle/downed role art now renders named fictional rivals; resolver aliases share URL texture keys; distinct team rings and backed labels improve readability. Existing engine events render once even when same-tick IDs collide. Reveal the registered plate without changing cover/extraction geometry. +- **Input repair:** remove forced canvas CSS dimensions so existing Phaser FIT keeps taps aligned. Put full-screen combat above the rival banner; close control minimum becomes 44px. No camera, cell, hitbox, engine command, economy or result-writer change. +- **HUD:** expose existing crew snapshot HP/ammo/reload/down state in the existing DOM stats row. No parallel selection/state or new service. +- **Regression/gates before HUD:** scene suite 4 failed / 1 passed on base, then 5 passed. Full frontend 1,038 passed / 4 skipped / 92 files; typecheck/build pass; lint 200 baseline warnings / 0 errors / no new warnings; asset audit 112 / 7.70 MB of 20 MB / no errors or warnings; packages 5 / schemas 4. HUD regression failed on the base UI; final local preflight completion is unknown after the workspace disconnected. +- **Built-game proof before HUD:** real 390×844 touch and 1440×900 pointer path Strip → named Nightfall → actual cell movement → fire/reload → registered downed rival → retreat → apply once → strategy → reload. Both happy paths had no page/console errors and one unchanged local receipt. Reinjecting old canvas CSS reproduced wrong destination cells at both sizes. Phone actor-image failure retained playable labels/controls/result/reload (0 page errors; 16 expected resource errors). +- **Draft hold:** GitHub CI checks the published final source; final HUD phone/desktop/failure recapture and screenshot upload remain pending because the workspace went offline. Evidence summary and exact limits: `docs/evidence/2026-10-09-nightfall-presentation/`. Do not claim final HUD manual proof or authenticated receipt durability. +- **Integration/limits:** independent of #204 ATTACK response; both append this log and require append-only reconciliation on integration. No #169 resolver/member-identity, #201 DRIVE, #175 staging, migrations, secrets, Supabase or deployment edits. Role-art provenance/fallback/budget recorded in ASSETS.md. Full #190 hero approval/missing poses, phone Diorama hit-area overlap and #175 authenticated recovery remain open. + ### 2026-10-01 — Backend timestamps are timezone-aware UTC (#170) - Replaced all 17 `datetime.utcnow()` call sites in the live backend runtime with one shared helper, `backend/python/utils/utc.py` (`utc_now`, `to_iso_utc`, `utc_now_iso`). Python 3.12 deprecates `utcnow()` and the required CI runs the full backend suite, so the debt was visible on every run: the audited baseline emitted 153 warnings, all of them this single warning. The suite now runs with 0 warnings. diff --git a/docs/evidence/2026-10-09-nightfall-presentation/README.md b/docs/evidence/2026-10-09-nightfall-presentation/README.md new file mode 100644 index 0000000..deaa641 --- /dev/null +++ b/docs/evidence/2026-10-09-nightfall-presentation/README.md @@ -0,0 +1,34 @@ +# Nightfall presentation verification — 2026-10-09 + +This is a partial evidence handoff for issue #190. The scene/CSS fix passed local gates and real built-game checks before the final crew-condition HUD addition. The workspace disconnected during the final HUD preflight. **Keep the PR draft until the final HUD browser check and capture handoff are complete.** + +## Regression and automated gates + +- Against unchanged base `9eef5aa`, the new scene suite failed four cases and passed one. The corrected scene passed all five. +- Before the HUD addition: full frontend suite **1,038 passed / 4 skipped / 92 files**; typecheck and production build passed; lint **200 baseline warnings / 0 errors / 0 new warnings**. +- Asset audit: **112 assets / 7.70 MB of 20 MB / 0 errors / 0 warnings**. Package validation: **5 packages / 4 schemas**. +- The crew-condition HUD regression failed on the unchanged UI as expected. Its final local preflight started but its completion is unknown. GitHub CI verifies the published final source separately; consult the PR checks. +- Existing build warnings include large chunks and legacy logo/menu/drive-by background references. No new asset or dependency was introduced. + +## Real player path completed before the HUD addition + +At **390×844** with real touchscreen taps and **1440×900** with pointer input: age gate → Run the Block → lock Dre/Rome → place/assign through the existing Legal board → Close Deal → Defend Block → named Nightfall encounter → move → fire/reload → down a named rival → retreat → apply result once → return → reload. + +Actual session cells matched the rendered destinations: Dre `(0,2)`, Rome `(4,4)`. A registered shooter downed texture appeared on the named Nightfall lookout. Both happy paths produced **0 page errors and 0 console errors**. One local receipt survived reload unchanged. + +As a controlled reproduction, reinjecting only the old canvas sizing rule into the same build made Dre land at `(0,3)` on phone and `(1,2)` on desktop when the intended rendered cell was `(0,2)`. Removing forced CSS dimensions lets existing Phaser FIT own the canvas geometry; world projection and hit cells remain unchanged. + +On phone, aborting registered topdown actor images left named fallback markers, fire/reload/retreat, result application, return and reload playable: **0 page errors**, with **16 expected failed-resource console errors**. + +The demo used the real built game and commands, with a read-only observer for session/texture inspection. Built files were delivered through a hermetic static handler; unavailable web fonts used the system fallback. Game state, engine commands and result application were not stubbed. These are local demo checks, not authenticated Supabase receipt proof. + +## Evidence gap and release hold + +`verification-summary.json` records the checks inspected during the session. It is a summary, not a replacement for raw browser reports or screenshots. Local screenshots and browser reports were produced and inspected, but the disconnected workspace prevented their upload. No unavailable capture is presented as attached evidence. + +Before making the PR ready for review: +1. Repeat phone/desktop movement, crew HP/ammo/reload HUD, fire/downed/retreat/apply/return/reload on the final commit. +2. Repeat actor-image failure and confirm labels, controls, HUD and one receipt remain intact. +3. Attach before/after and failure captures, confirm no clipping or cell offset after the extra HUD row, and record the final CI results. + +This uses existing topdown role art and preserves the historical tactical plane. It does not complete the full #190 hero-package/style approval or missing aim/fire/hit/reload poses. The Legal board was used because Diorama hit areas overlap on phone; that separate overlap remains. Authenticated durability/recovery stays with #175. diff --git a/docs/evidence/2026-10-09-nightfall-presentation/verification-summary.json b/docs/evidence/2026-10-09-nightfall-presentation/verification-summary.json new file mode 100644 index 0000000..7e90364 --- /dev/null +++ b/docs/evidence/2026-10-09-nightfall-presentation/verification-summary.json @@ -0,0 +1,208 @@ +{ + "schemaVersion": 1, + "date": "2026-10-09", + "repository": "BrandDead/slide", + "baseCommit": "9eef5aac28a82275d7a2e1596e8658b167953ed7", + "scope": "Nightfall presentation and Phaser FIT correction", + "recordType": "Summary of locally completed and inspected checks; not raw Playwright output", + "verifiedSourceBlobs": { + "scene": "60feef55e8ff462e77aa6abea3a5c04d8ff8ada0", + "css": "67b2e6c7468d3235c5f50e1558a304db0e69d483" + }, + "timing": { + "localAutomatedChecks": "Before the final DOM crew-condition HUD addition", + "browserChecks": "Before the final DOM crew-condition HUD addition", + "finalHudLocalPreflight": "Started, completion unknown: workspace environment disconnected", + "finalHudBrowserCheck": "Pending", + "screenshotUpload": "Blocked by workspace outage; no screenshot files are included in this summary" + }, + "regressionFirst": { + "sceneBase": { + "passed": 1, + "failed": 4 + }, + "sceneAfter": { + "passed": 5, + "failed": 0 + }, + "hudBase": { + "passed": 1, + "failed": 1, + "expectedFailure": "Scout condition label absent" + } + }, + "preHudLocalGates": { + "frontend": { + "passed": 1038, + "skipped": 4, + "files": 92 + }, + "lint": { + "warnings": 200, + "errors": 0, + "newWarnings": 0 + }, + "typecheck": "passed", + "build": "passed", + "assets": { + "count": 112, + "runtimeMB": 7.7, + "budgetMB": 20, + "errors": 0, + "warnings": 0 + }, + "packages": { + "count": 5, + "schemas": 4 + } + }, + "browserMethod": { + "build": "Existing demo mode built after normal validation, with demo Supabase configuration", + "runtime": "Real built React/Phaser game; real controls, commands and result application", + "delivery": "Hermetic static delivery of built files and system font fallback", + "inspection": "Read-only Phaser boot observer inspected actual session cells and sprite texture URLs", + "route": [ + "Age gate", + "Run the Block", + "Lock Dre and Rome", + "Place Dre", + "Legal board", + "Storefront at (4,3)", + "Assign Rome", + "Close Deal", + "Defend Block", + "Named Nightfall encounter" + ], + "persistenceBoundary": "Local demo receipt and reload; authenticated server receipt not exercised" + }, + "happyPaths": [ + { + "viewport": { + "width": 390, + "height": 844 + }, + "input": "Real touchscreen taps at rendered cell centers", + "intendedDestinations": { + "Dre": { + "x": 0, + "y": 2 + }, + "Rome": { + "x": 4, + "y": 4 + } + }, + "observedDestinations": { + "Dre": { + "x": 0, + "y": 2 + }, + "Rome": { + "x": 4, + "y": 4 + } + }, + "fireReload": "completed", + "downedRival": "Nightfall lookout used registered shooter downed texture", + "outcome": "retreated", + "appliedReceiptCount": 1, + "reloadLedgerUnchanged": true, + "pageErrors": 0, + "consoleErrors": 0 + }, + { + "viewport": { + "width": 1440, + "height": 900 + }, + "input": "Real pointer clicks at rendered cell centers", + "intendedDestinations": { + "Dre": { + "x": 0, + "y": 2 + }, + "Rome": { + "x": 4, + "y": 4 + } + }, + "observedDestinations": { + "Dre": { + "x": 0, + "y": 2 + }, + "Rome": { + "x": 4, + "y": 4 + } + }, + "fireReload": "completed", + "downedRival": "Nightfall lookout used registered shooter downed texture", + "outcome": "retreated", + "appliedReceiptCount": 1, + "reloadLedgerUnchanged": true, + "pageErrors": 0, + "consoleErrors": 0 + } + ], + "sizingRegression": { + "method": "Reinjected base canvas CSS into the otherwise unchanged presentation build", + "phone": { + "intendedDre": { + "x": 0, + "y": 2 + }, + "actualDre": { + "x": 0, + "y": 3 + }, + "correctCells": false + }, + "desktop": { + "intendedDre": { + "x": 0, + "y": 2 + }, + "actualDre": { + "x": 1, + "y": 2 + }, + "correctCells": false + }, + "geometry": { + "worldWidth": 960, + "worldHeight": 600, + "gridCols": 8, + "gridRows": 8, + "cellWidth": 120, + "cellHeight": 55.25, + "playfieldTop": 86, + "playfieldBottom": 72 + } + }, + "imageFailure": { + "viewport": { + "width": 390, + "height": 844 + }, + "method": "Abort registered character/topdown image requests", + "labelsAndMarkers": "visible", + "fireReloadRetreat": "playable", + "appliedReceiptCount": 1, + "reloadLedgerUnchanged": true, + "pageErrors": 0, + "expectedFailedResourceConsoleErrors": 16 + }, + "observedLocalReceipt": { + "key": "encounter-demo-block-las-olas-m36lx4:retreated", + "money": 13256, + "playerHeat": 9, + "blockMorale": 66 + }, + "remaining": [ + "Repeat final HUD phone/desktop happy and image-failure paths and attach captures", + "Complete #190 hero package/style approval and missing aim/fire/hit/reload pose inventory", + "Authenticated result durability and recovery remain under #175", + "Legal-board fallback was used; mobile Diorama hit-area overlap is not fixed here" + ] +} diff --git a/frontend/src/components/encounter/UnifiedEncounter.css b/frontend/src/components/encounter/UnifiedEncounter.css index d163bc1..67b2e6c 100644 --- a/frontend/src/components/encounter/UnifiedEncounter.css +++ b/frontend/src/components/encounter/UnifiedEncounter.css @@ -1,7 +1,7 @@ .unified-encounter { position: fixed; inset: 0; - z-index: 2000; + z-index: 10000; width: 100vw; height: 100dvh; margin: 0; @@ -82,7 +82,9 @@ .ue-stage-shell { position: relative; min-height: 0; background: #07111d; overflow: hidden; } .ue-stage { width: 100%; height: 100%; min-height: 0; opacity: 0; transition: opacity 220ms ease; } .ue-stage--ready { opacity: 1; } -.ue-stage canvas { display: block; width: 100% !important; height: 100% !important; object-fit: contain; } +/* Phaser FIT owns canvas dimensions and centering. Stretching this element + offsets pointer coordinates from the visible letterboxed cells. */ +.ue-stage canvas { display: block; } .ue-loading { position: absolute; @@ -134,7 +136,7 @@ .ue-briefing { align-items: center; flex-direction: row; padding: 8px 10px; gap: 8px; } .ue-briefing p { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ue-kicker { display: none; } - .ue-close { min-height: 40px; padding-inline: 10px; } + .ue-close { min-height: 44px; padding-inline: 10px; } .ue-modifiers { padding: 6px 10px; } .ue-modifiers span { max-width: 220px; padding: 5px 7px; font-size: 10px; } .ue-controls { grid-template-columns: 1fr; padding: 8px 10px; } diff --git a/frontend/src/components/encounter/UnifiedEncounter.tsx b/frontend/src/components/encounter/UnifiedEncounter.tsx index f496f69..e8a50d8 100644 --- a/frontend/src/components/encounter/UnifiedEncounter.tsx +++ b/frontend/src/components/encounter/UnifiedEncounter.tsx @@ -154,6 +154,11 @@ export const UnifiedEncounter: React.FC = ({ {activeCrew} crew active {opposition} opposition visible {snapshot.objective.progress}/{snapshot.objective.target} secure exits + {snapshot.combatants.filter(actor => actor.team === 'crew').map(actor => ( + + {actor.name} · {actor.isDown ? 'DOWN' : `${actor.health} HP`} · {actor.ammo}/{actor.maxAmmo} ammo{actor.reloadUntilTick !== null && !actor.isDown ? ' · reloading' : ''} + + ))}
diff --git a/frontend/src/components/encounter/UnifiedEncounterScene.ts b/frontend/src/components/encounter/UnifiedEncounterScene.ts index 918f5a6..60feef5 100644 --- a/frontend/src/components/encounter/UnifiedEncounterScene.ts +++ b/frontend/src/components/encounter/UnifiedEncounterScene.ts @@ -2,7 +2,7 @@ import Phaser from 'phaser'; import { advanceCombat, createCombatSession, dispatchCombatCommand, getCombatSnapshot } from '../../game/combat/combatSession'; import { PhaserReadyGate } from '../../utils/phaserSceneReady'; import type { CombatCommand, CombatSnapshot, Combatant, EncounterPreparation, GridPoint } from '../../game/combat/types'; -import { getWorldActor } from '../../render/worldActorResolver'; +import { getWorldActor, type ActorState } from '../../render/worldActorResolver'; const CANVAS_W = 960; const CANVAS_H = 600; @@ -39,7 +39,8 @@ export class UnifiedEncounterScene extends Phaser.Scene { }); private actorViews = new Map(); private effects: TransientEffect[] = []; - private lastEventId: string | null = null; + // Engine events retain object identity; their same-tick IDs can collide. + private readonly renderedEvents = new WeakSet(); private selectedCrewId: string | null = null; private sequence = 0; private accumulator = 0; @@ -73,12 +74,14 @@ export class UnifiedEncounterScene extends Phaser.Scene { 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); + // Opposition is a fictional armed role, not a crew identity. Borrow the + // registered shooter kit without changing the engine role or hit cell. + for (const state of ['idle', 'downed'] as const) { + const resolved = getWorldActor(actor.role === 'opposition' ? 'shooter' : actor.role, state, 'topdown'); + if (!resolved || loaded.has(resolved.url)) continue; + this.load.image(this.textureKey(actor, state), resolved.url); + loaded.add(resolved.url); + } } } @@ -138,7 +141,7 @@ export class UnifiedEncounterScene extends Phaser.Scene { 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); + .setAlpha(0.68); } else { const background = this.add.graphics(); background.fillGradientStyle(0x162436, 0x162436, 0x080d16, 0x080d16, 1); @@ -162,9 +165,11 @@ export class UnifiedEncounterScene extends Phaser.Scene { 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 alpha = this.textures.exists(this.backgroundKey) + ? (cell.passable ? 0.28 : 0.74) + : (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); + .setStrokeStyle(1, cell.cover >= 0.6 ? 0x83d5d0 : 0x314c5d, cell.cover >= 0.6 ? 0.7 : 0.3); 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); @@ -189,7 +194,7 @@ export class UnifiedEncounterScene extends Phaser.Scene { 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', { + const controls = this.add.text(20, CANVAS_H - 54, 'TAP/CLICK: 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]); @@ -273,35 +278,38 @@ export class UnifiedEncounterScene extends Phaser.Scene { 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); + const texture = this.textureKey(actor); + if ('setTexture' in view.sprite && this.textures.exists(texture)) view.sprite.setTexture(texture); + view.sprite.setPosition(point.x, point.y - 8).setAlpha(actor.isDown ? 0.68 : 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); + view.selectedRing.setPosition(point.x, point.y).setVisible(!actor.isDown && (actor.team === 'opposition' || actor.id === this.selectedCrewId)); }); } 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 hasTexture = this.textures.exists(actorTexture); + const art = getWorldActor(actor.role === 'opposition' ? 'shooter' : actor.role, actor.isDown ? 'downed' : 'idle', 'topdown'); const sprite = hasTexture - ? this.add.image(point.x, point.y, actorTexture).setScale(0.32) + ? this.add.image(point.x, point.y, actorTexture).setOrigin(art?.pivot.x ?? 0.5, art?.pivot.y ?? 0.5).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); + const label = this.add.text(point.x, point.y + 25, actor.name, { fontFamily: 'monospace', fontSize: '12px', backgroundColor: '#07111dcc', padding: { x: 3, y: 2 }, 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, actor.team === 'crew' ? 0x9cf5ed : 0xfb7185, 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; + const latestEvents = this.session.events.filter(item => !this.renderedEvents.has(item)); if (latestEvents.length === 0) return; - latestEvents.forEach((item) => this.renderEvent(item)); - this.lastEventId = latestEvents.at(-1)?.id ?? this.lastEventId; + latestEvents.forEach((item) => { + this.renderedEvents.add(item); + this.renderEvent(item); + }); this.emitSnapshot(); } @@ -365,7 +373,9 @@ export class UnifiedEncounterScene extends Phaser.Scene { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } - private textureKey(actor: Combatant): string { - return `encounter-actor-${actor.role}`; + private textureKey(actor: Combatant, state: ActorState = actor.isDown ? 'downed' : 'idle'): string { + const art = getWorldActor(actor.role === 'opposition' ? 'shooter' : actor.role, state, 'topdown'); + // Aliased roles/states share one loaded texture rather than an absent key. + return `encounter-actor-${art?.url ?? actor.role}`; } } diff --git a/frontend/src/components/encounter/__tests__/UnifiedEncounterLog.test.tsx b/frontend/src/components/encounter/__tests__/UnifiedEncounterLog.test.tsx index 31a076f..0d6daeb 100644 --- a/frontend/src/components/encounter/__tests__/UnifiedEncounterLog.test.tsx +++ b/frontend/src/components/encounter/__tests__/UnifiedEncounterLog.test.tsx @@ -42,4 +42,27 @@ describe('combat live log identity', () => { act(() => bridge.snapshots?.({ ...snapshot, events: [...events, next] })); expect(screen.getByText('Dre fires from cover.')).toBe(dreNode); }); + it('shows current crew condition in the readable HUD as snapshots change', () => { + const block = createAuthoritativeLoopBlock(); + const onResolved = vi.fn(); + render(); + const snapshot = getCombatSnapshot(createCombatSession(prepareEncounter(block))); + const condition = screen.getByLabelText('Scout condition'); + expect(condition).toHaveTextContent('80 HP'); + expect(condition).toHaveTextContent('6/6 ammo'); + act(() => bridge.snapshots?.({ ...snapshot, combatants: snapshot.combatants.map(actor => + actor.team === 'crew' ? { ...actor, health: 42, ammo: 2, reloadUntilTick: 20 } : actor, + ) })); + expect(screen.getByLabelText('Scout condition')).toBe(condition); + expect(condition).toHaveTextContent('42 HP'); + expect(condition).toHaveTextContent('2/6 ammo'); + expect(condition).toHaveTextContent('reloading'); + act(() => bridge.snapshots?.({ ...snapshot, combatants: snapshot.combatants.map(actor => + actor.team === 'crew' ? { ...actor, health: 0, isDown: true, ammo: 0 } : actor, + ) })); + expect(condition).toHaveTextContent('DOWN'); + expect(condition).not.toHaveTextContent('reloading'); + expect(onResolved).not.toHaveBeenCalled(); + }); + }); diff --git a/frontend/src/components/encounter/__tests__/UnifiedEncounterScene.presentation.test.ts b/frontend/src/components/encounter/__tests__/UnifiedEncounterScene.presentation.test.ts new file mode 100644 index 0000000..317dec8 --- /dev/null +++ b/frontend/src/components/encounter/__tests__/UnifiedEncounterScene.presentation.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorldActor } from '../../../render/worldActorResolver'; +import { createCombatSession } from '../../../game/combat/combatSession'; +import type { Combatant, EncounterPreparation, CombatEvent } from '../../../game/combat/types'; + +const renderer = vi.hoisted(() => { + class Node { + x = 0; y = 0; alpha = 1; visible = true; key = ''; text = ''; color = 0; + constructor(public kind: string) {} + setPosition(x: number, y: number) { this.x = x; this.y = y; return this; } + setAlpha(alpha: number) { this.alpha = alpha; return this; } + setTexture(key: string) { this.key = key; return this; } + setText(text: string) { this.text = text; return this; } + setVisible(visible: boolean) { this.visible = visible; return this; } + setScale() { return this; } setOrigin() { return this; } setTint() { return this; } + setSize() { return this; } setDisplaySize() { return this; } setStrokeStyle() { return this; } + setLineWidth() { return this; } add() { return this; } destroy() { return this; } + fillGradientStyle() { return this; } fillRect() { return this; } fillStyle() { return this; } + } + const nodes: Node[] = [], assets = new Map(), failedUrls = new Set(); + const emit = vi.fn(); + const node = (kind: string, x = 0, y = 0) => { const n = new Node(kind).setPosition(x, y); nodes.push(n); return n; }; + class Scene { + load = { image: vi.fn((key: string, url: string) => assets.set(key, url)) }; + textures = { exists: (key: string) => assets.has(key) && !failedUrls.has(assets.get(key)!) }; + events = { emit }; time = { now: 0 }; cameras = { main: { shake: vi.fn() } }; + input = { on: vi.fn(), keyboard: { on: vi.fn() }, gamepad: { on: vi.fn() } }; + add = { + container: () => node('container'), graphics: () => node('graphics'), + image: (x: number, y: number, key: string) => { const n = node('image', x, y); n.key = key; return n; }, + text: (x: number, y: number, text: string) => { const n = node('text', x, y); n.text = text; return n; }, + rectangle: (x: number, y: number) => node('rectangle', x, y), + circle: (x: number, y: number, _radius: number, color: number) => { const n = node('circle', x, y); n.color = color; return n; }, + line: () => node('line'), + }; + } + return { Scene, nodes, assets, failedUrls, emit }; +}); +vi.mock('phaser', () => ({ default: { Scene: renderer.Scene } })); +import { UnifiedEncounterScene } from '../UnifiedEncounterScene'; + +function actor(overrides: Partial = {}): Combatant { + return { id: 'crew-1', name: 'Rome', team: 'crew', role: 'shooter', position: { x: 2, y: 2 }, + health: 100, maxHealth: 100, armor: 0, ammo: 8, maxAmmo: 8, reloadUntilTick: null, + nextFireTick: 0, level: 3, lastSequence: -1, isDown: false, ...overrides }; +} +function preparation(): EncounterPreparation { + return { sessionId: 'nightfall-proof', seed: 42, sceneLabel: 'Fictional Strip', locationReference: 'Fictional district', fictionNotice: 'Fictional.', + terrain: Array.from({ length: 8 }, (_, y) => Array.from({ length: 8 }, (_, x) => ({ x, y, zoneType: 'sidewalk' as const, passable: true, cover: 0, exposure: 0.5 }))), + crew: [actor()], opposition: [actor({ id: 'rival-1', name: 'Nightfall 1', team: 'opposition', role: 'opposition', position: { x: 3, y: 2 }, health: 1, maxHealth: 55 })], + objective: { kind: 'extract', label: 'Reach exit', extraction: { x: 7, y: 7 }, requiredCrew: 1, progress: 0, target: 1 }, + heatAtStart: 1, moraleAtStart: 80, tacticalBrief: [] }; +} +function start() { + const scene = new UnifiedEncounterScene(); scene.configure(preparation(), true); scene.preload(); scene.create(); return scene; +} +function rivalImage() { + return renderer.nodes.find(n => n.kind === 'image' && n.x === 420 && n.y === 216.125); +} + +describe('Nightfall encounter presentation', () => { + beforeEach(() => { renderer.nodes.length = 0; renderer.assets.clear(); renderer.failedUrls.clear(); renderer.emit.mockClear(); }); + it('preloads registered idle and downed art for crew and fictional opposition', () => { + start(); + expect([...renderer.assets.values()]).toContain(getWorldActor('shooter', 'idle', 'topdown')!.url); + expect([...renderer.assets.values()]).toContain(getWorldActor('shooter', 'downed', 'topdown')!.url); + }); + it('renders opposition as resolver-backed actors in unchanged tactical cells', () => { + start(); const image = rivalImage(); expect(image).toBeDefined(); + expect(renderer.assets.get(image!.key)).toBe(getWorldActor('shooter', 'idle', 'topdown')!.url); + }); + it('switches a downed rival to registered downed art and preserves its identity', () => { + const scene = start(); scene.fireNearestSelected(); + expect(renderer.assets.get(rivalImage()!.key)).toBe(getWorldActor('shooter', 'downed', 'topdown')!.url); + expect(renderer.nodes.some(n => n.text === 'Nightfall 1 · DOWN')).toBe(true); + }); + it('presents same-tick events once even when their IDs collide', () => { + const scene = start(); + const events: CombatEvent[] = [ + { id: 'same-tick', tick: 0, type: 'weapon-fired', actorId: 'crew-1', targetId: 'rival-1', message: 'Rome fires.' }, + { id: 'same-tick', tick: 0, type: 'weapon-fired', actorId: 'rival-1', targetId: 'crew-1', message: 'Nightfall fires.' }, + ]; + Object.defineProperty(scene, 'session', { value: { ...createCombatSession(preparation()), events }, writable: true }); + scene.reloadSelected(); scene.reloadSelected(); + expect(renderer.nodes.filter(n => n.kind === 'line')).toHaveLength(2); + }); + it('retains labels, controls and result authority when an actor image fails', () => { + renderer.failedUrls.add(getWorldActor('shooter', 'idle', 'topdown')!.url); + const scene = start(); + expect(renderer.nodes.some(n => n.text === 'Nightfall 1')).toBe(true); + expect(() => scene.retreatSelected()).not.toThrow(); + const results = renderer.emit.mock.calls.filter(([name]) => name === 'combatResult'); + expect(results).toHaveLength(1); expect(results[0][1].outcome).toBe('retreated'); + }); +});