diff --git a/package.json b/package.json index 7856dcb..71e78cd 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,8 @@ "pack:overlays": "tsx src/baker/packers/pack-overlays.ts", "verify:entity-packs": "tsx src/baker/audit/verify-entity-packs.ts", "verify:entities": "tsx src/baker/audit/verify-entity-packs.ts", - "verify:char-appearance": "tsx tools/verify-char-appearance.ts", "verify:packs": "tsx src/baker/audit/verify-packs.ts", "verify:bundle-no-mpq": "tsx tools/verify-bundle-no-mpq.ts", - "verify:animation": "tsx tools/verify-animation-browser.ts", - "verify:ground-browser": "tsx tools/verify-ground-combat-browser.ts", "verify:deploy": "tsx tools/verify-deploy.ts", "verify:listfile": "tsx tools/verify-listfile.ts", "verify:object-lookup": "tsx tools/verify-object-lookup.ts", diff --git a/play.html b/play.html index f15f686..4c16fac 100644 --- a/play.html +++ b/play.html @@ -46,9 +46,146 @@ #d2-toolbar-container { flex: 0 0 auto; + width: 100%; z-index: 50; } + #d2-toolbar, + .d2-toolbar { + position: relative; + z-index: 1000; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + height: 32px; + min-height: 32px; + max-height: 32px; + padding: 0 12px; + box-sizing: border-box; + background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%); + border-bottom: 1px solid #4a3c24; + color: #d6c496; + font-family: var(--font-display); + font-size: 12px; + line-height: 1; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + user-select: none; + } + + .d2-toolbar__group { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + flex-shrink: 0; + gap: 6px; + white-space: nowrap; + } + + .d2-toolbar__status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 3px; + background: #14110d; + border: 1px solid #352b1b; + } + + .d2-toolbar__status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + background: #6b6b6b; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); + } + + .d2-toolbar__status-dot--disconnected { background: #767676; } + .d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; } + .d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; } + .d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; } + .d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; } + .d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; } + + .d2-toolbar__rtt { + font-family: "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + color: #a89974; + } + + .d2-toolbar__label { + color: #b59e68; + font-size: 11px; + letter-spacing: 0.03em; + } + + .d2-toolbar__select { + height: 22px; + padding: 0 6px; + background: #120f0b; + border: 1px solid #4a3c24; + border-radius: 2px; + color: #e2d1a0; + font-size: 11px; + cursor: pointer; + outline: none; + } + + .d2-toolbar__select:hover, + .d2-toolbar__btn:hover { + border-color: #c7b377; + color: #fff2c6; + } + + .d2-toolbar__btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 8px; + background: linear-gradient(180deg, #2b2316 0%, #17120b 100%); + border: 1px solid #574629; + border-radius: 2px; + color: #dfc991; + font-size: 11px; + cursor: pointer; + } + + .d2-toolbar__btn--active { + background: linear-gradient(180deg, #524020 0%, #2e2310 100%); + border-color: #d4b46a; + color: #fff5d6; + } + + .d2-toolbar__diag { + position: absolute; + top: 32px; + left: 12px; + right: 12px; + z-index: 1001; + display: none; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: rgba(38, 12, 10, 0.96); + border: 1px solid #a93226; + border-top: none; + border-radius: 0 0 4px 4px; + color: #ffd6d1; + font-size: 12px; + } + + .d2-toolbar__diag--visible { + display: flex; + } + #play-workspace { flex: 1 1 auto; min-height: 0; diff --git a/src/client/assets/browser-mpq-source.ts b/src/client/assets/browser-mpq-source.ts deleted file mode 100644 index 1e22d17..0000000 --- a/src/client/assets/browser-mpq-source.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Browser-only MPQ sources (`Blob`/`File` and HTTP `Range` `fetch`). - */ - -import type { MpqSource } from '../../common/mpq/source.ts' - -/** - * Build a source over a browser `Blob`/`File` (drag & drop, ``, - * OPFS handles all expose a Blob). - * - * @param blob - the blob to read. - * @param label - identity shown in diagnostics. - * @returns the source. - */ -export function blobSource(blob: Blob, label = blob instanceof File ? blob.name : 'blob'): MpqSource { - return { - size: blob.size, - label, - read: async (offset, length) => { - if (offset < 0 || offset + length > blob.size) { - throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(blob.size)}`) - } - return new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer()) - }, - } -} - -/** Bytes to probe with when deciding whether a server honours `Range`. */ -const RANGE_PROBE_BYTES = 32 - -/** - * Build a source over HTTP, reading only the byte ranges asked for. - * - * @param url - archive URL. - * @param label - identity for diagnostics. - * @returns the source. - */ -export async function httpRangeSource(url: string, label = url): Promise { - const probe = await fetch(url, { headers: { Range: `bytes=0-${String(RANGE_PROBE_BYTES - 1)}` } }) - if (probe.status !== 206) { - const type = probe.headers.get('content-type') ?? 'unknown' - throw new Error( - `${label}: server answered ${String(probe.status)} (${type}) instead of 206 — ` - + 'range requests are required to read an archive this large', - ) - } - const contentRange = probe.headers.get('content-range') ?? '' - const total = Number(/\/(\d+)$/.exec(contentRange)?.[1] ?? '0') - probe.body?.cancel().catch(() => {}) - if (!Number.isFinite(total) || total <= 0) { - throw new Error(`${label}: server sent no usable Content-Range total (${contentRange})`) - } - return { - size: total, - label, - read: async (offset, length) => { - if (offset < 0 || offset + length > total) { - throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(total)}`) - } - const response = await fetch(url, { headers: { Range: `bytes=${String(offset)}-${String(offset + length - 1)}` } }) - if (response.status !== 206 && response.status !== 200) { - throw new Error(`${label}: range read failed with HTTP ${String(response.status)}`) - } - const buffer = new Uint8Array(await response.arrayBuffer()) - if (buffer.byteLength !== length) { - throw new Error(`${label}: asked for ${String(length)} bytes at ${String(offset)}, got ${String(buffer.byteLength)}`) - } - return buffer - }, - } -} diff --git a/src/client/i18n/index.ts b/src/client/i18n/index.ts deleted file mode 100644 index fe1280c..0000000 --- a/src/client/i18n/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './lang.ts' -export { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' -export { decodeTbl, tblLookup } from '../../common/formats/tbl.ts' diff --git a/src/client/input/index.ts b/src/client/input/index.ts deleted file mode 100644 index 245984d..0000000 --- a/src/client/input/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - CommandMapper, - ViewportCamera, - type CameraLike, - type CommandMapperDeps, - type DispatchedCommandRecord, - type PointerWorldTarget, - type SkillHotkeyBinding, -} from './command-mapper.ts' diff --git a/src/client/launcher/play-entry.ts b/src/client/launcher/play-entry.ts deleted file mode 100644 index 3dfe1db..0000000 --- a/src/client/launcher/play-entry.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from '../main.ts' -export * from '../world/act-runtime.ts' -export * from '../session/online-session.ts' -export * from '../world/client-world.ts' diff --git a/src/client/main.ts b/src/client/main.ts index 08cb3ed..69becb2 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -169,7 +169,39 @@ async function bootPlayApp(): Promise { const initialSettings = settingsStore.get() setLang(initialSettings.lang) + let sessionRef: OnlineSession | null = null + + const toolbar = new Toolbar({ + store: settingsStore, + container: toolbarContainer, + callbacks: { + onLangChange: (lang: GameLang) => { + setLang(lang) + sessionRef?.hudManager.setLang(lang) + }, + onViewportChange: (profile: ViewportProfile) => { + applyViewportProfileToDom(profile, { + stageEl, + glCanvas: viewCanvas, + hudCanvas, + }) + sessionRef?.hudManager.resize(profile.width, profile.height) + sessionRef?.camera.resize(profile) + }, + onAutomapRevealChange: (mode) => { + sessionRef?.automapView.setMode(mode) + }, + onInspectorToggle: (open) => { + sessionRef?.packetInspector.setOpen(open) + }, + onServerHostReconnect: (newHost) => { + void sessionRef?.disconnect(`Reconnecting to ${newHost}`) + }, + }, + }) + const initialCacheStatus = await registerAssetCache() + toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes) const minimap = new Minimap() const renderer = new SpriteRenderer(viewCanvas) @@ -183,8 +215,6 @@ async function bootPlayApp(): Promise { let itemsAtlasHandle: AtlasHandle | undefined let lastRenderSnapshot: SceneFrameSnapshot | null = null - let sessionRef: OnlineSession | null = null - const hudManager = new HudManager(hudCanvas, { onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => { minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight) @@ -339,35 +369,6 @@ async function bootPlayApp(): Promise { } } - const toolbar = new Toolbar({ - store: settingsStore, - container: toolbarContainer, - callbacks: { - onLangChange: (lang: GameLang) => { - setLang(lang) - session.hudManager.setLang(lang) - }, - onViewportChange: (profile: ViewportProfile) => { - applyViewportProfileToDom(profile, { - stageEl, - glCanvas: viewCanvas, - hudCanvas, - }) - session.hudManager.resize(profile.width, profile.height) - session.camera.resize(profile) - }, - onAutomapRevealChange: (mode) => { - session.automapView.setMode(mode) - }, - onInspectorToggle: (open) => { - inspector.setOpen(open) - }, - onServerHostReconnect: (newHost) => { - void session.disconnect(`Reconnecting to ${newHost}`) - }, - }, - }) - toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes) void assetsReadyPromise.then(async () => { const updatedStatus = await getAssetCacheStatus() toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) diff --git a/src/client/render/animation.ts b/src/client/render/animation.ts deleted file mode 100644 index 4df1d50..0000000 --- a/src/client/render/animation.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../common/units/animation.ts" diff --git a/src/client/render/char-appearance.ts b/src/client/render/char-appearance.ts deleted file mode 100644 index 6220664..0000000 --- a/src/client/render/char-appearance.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../common/units/char-appearance.ts" diff --git a/src/client/render/cull.ts b/src/client/render/cull.ts deleted file mode 100644 index 32e4573..0000000 --- a/src/client/render/cull.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 视口剔除:把"这一帧到底要画哪些东西"从整张地图缩小到屏幕那一块。 - * - * 起因是一次实测:把 `act-scene` 的绘制顺序在 365 张烘焙关卡上静态重放一遍, - * 平均每帧提交 2 848 个四边形,最差一帧(act4 迪亚波罗)14 400 个,而其中真正 - * 落在视口里的只有 7%~11%。也就是说九成的顶点写入、九成的图集页切换,都是在 - * 给屏幕外的瓦片做无用功——这正是主循环掉到 20 tps 的直接原因。 - * - * 这里只做两件事:把绘制列表的包围盒预先摊平成 `Float32Array`,以及提供一个 - * 矩形相交判断。之所以不用四叉树/网格索引,是因为绘制列表本身**不是**按 y 单调 - * 的(实测地面 0/365 单调,最大回退 9 520 px),任何空间分桶都得重新合并回 - * 画家顺序;而线性扫描 14 400 个条目只需要 4 次浮点比较 × 14 400 ≈ 15~30 µs, - * 占 16 ms 预算的 0.2%,却能原样保住顺序。 - */ - -/** 一个可绘制物需要的最小信息:世界坐标 + 图集帧尺寸。 */ -export interface BoundedDrawable { - readonly frame: { readonly width: number; readonly height: number } - readonly x: number - readonly y: number -} - -/** 视口矩形,世界像素。 */ -export interface ViewRect { - readonly left: number - readonly top: number - readonly right: number - readonly bottom: number -} - -/** - * 视口四周额外放宽的余量,单位为场景像素。 - * - * 包围盒已经算进了每帧的宽高,所以这点余量只是为了兜住 CPU 侧矩形与顶点着色器 - * 变换之间的浮点舍入,不是用来遮掩边界算错的。 - */ -export const CULL_MARGIN = 32 - -/** - * 把一条绘制列表的世界包围盒摊平成 `[x0, y0, x1, y1]` 四元组。 - * - * 列表在关卡建好之后就不再变动,所以包围盒可以一次算完;用一整块连续内存而不是 - * 每帧去点两层属性(`draw.frame.width`),是为了让每帧的拒绝测试只碰缓存友好的 - * 相邻浮点。 - * - * @param list - 带世界坐标和图集帧的可绘制物。 - * @returns 包围盒,每个条目 4 个浮点。 - */ -export function buildBounds(list: readonly BoundedDrawable[]): Float32Array { - const bounds = new Float32Array(list.length * 4) - for (let index = 0; index < list.length; index += 1) { - const draw = list[index]! - const at = index * 4 - bounds[at] = draw.x - bounds[at + 1] = draw.y - bounds[at + 2] = draw.x + draw.frame.width - bounds[at + 3] = draw.y + draw.frame.height - } - return bounds -} - -/** - * 由相机和画布尺寸算出视口矩形。 - * - * 顶点着色器保留一个四边形的条件是 `|x - camX| <= drawingBufferWidth / (2 * zoom)`, - * 而 `drawingBufferWidth` 就是 `canvas.width`,所以这里用同一个表达式复现它—— - * 两边算法一旦不一致,靠近屏幕边缘的瓦片就会闪烁。 - * - * @param camX - 相机中心 x,世界像素。 - * @param camY - 相机中心 y,世界像素。 - * @param canvasWidth - 画布后备存储宽度。 - * @param canvasHeight - 画布后备存储高度。 - * @param zoom - 当前缩放。 - */ -export function viewportRect( - camX: number, - camY: number, - canvasWidth: number, - canvasHeight: number, - zoom: number, -): ViewRect { - const halfWidth = canvasWidth / (2 * zoom) + CULL_MARGIN - const halfHeight = canvasHeight / (2 * zoom) + CULL_MARGIN - return { - left: camX - halfWidth, - top: camY - halfHeight, - right: camX + halfWidth, - bottom: camY + halfHeight, - } -} - -/** - * 判断 `bounds` 中第 `index` 个条目是否与视口相交。 - * - * 这是整帧里调用次数最多的一个函数(最差 14 400 次/帧),所以它只做四次比较, - * 不建临时对象、不解构。 - * - * @param bounds - `buildBounds` 的输出。 - * @param index - 条目下标(不是浮点下标)。 - * @param view - 视口矩形。 - */ -export function intersects(bounds: Float32Array, index: number, view: ViewRect): boolean { - const at = index * 4 - return bounds[at + 2]! >= view.left && bounds[at]! <= view.right - && bounds[at + 3]! >= view.top && bounds[at + 1]! <= view.bottom -} diff --git a/src/client/render/roofs.ts b/src/client/render/roofs.ts deleted file mode 100644 index c740627..0000000 --- a/src/client/render/roofs.ts +++ /dev/null @@ -1,611 +0,0 @@ -/** - * Dynamic roof occlusion detection and opacity fade (Roof Fade). - * - * In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like - * Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4 - * (topmost layer) after floors, walls, and characters. - * - * When the player steps inside a shelter or under an overhanging roof, the roof - * tile(s) occlude the character. This module detects player occlusion - * (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions - * the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25), - * restoring full opacity upon exit. - */ - -/** Minimal roof tile drawable interface. */ -export interface RoofDrawable { - readonly frame: { readonly width: number; readonly height: number } - readonly x: number - readonly y: number - readonly cellX?: number - readonly cellY?: number - readonly roofHeight?: number - readonly wallType?: number -} - -/** 2D axis-aligned bounding box in world/scene coordinates. */ -export interface RoofBounds { - readonly minX: number - readonly minY: number - readonly maxX: number - readonly maxY: number -} - -/** Point position in world/scene pixels. */ -export interface Point2D { - readonly x: number - readonly y: number -} - -/** Axis-aligned bounding box with left/top/right/bottom. */ -export interface Box2D { - readonly left: number - readonly top: number - readonly right: number - readonly bottom: number -} - -/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */ -export type PlayerTarget = Point2D | Box2D | RoofBounds - -/** Configuration options for roof fade and occlusion detection. */ -export interface RoofFadeConfig { - /** - * Minimum opacity when player is under roof (0.0 .. 1.0). - * Default: 0.0 (vanilla Diablo II completely fades occluding roofs). - */ - readonly minAlpha?: number - - /** - * Maximum opacity when player is outside (0.0 .. 1.0). - * Default: 1.0. - */ - readonly maxAlpha?: number - - /** - * Fade transition duration in milliseconds. - * Default: 200 ms. - */ - readonly fadeDurationMs?: number - - /** - * Player bounding box half-width (padding) for point-based player positions. - * Default: 16 pixels. - */ - readonly playerPaddingX?: number - - /** - * Player bounding box height for point-based player positions (feet to head). - * Default: 48 pixels. - */ - readonly playerHeight?: number - - /** - * Maximum cell distance to cluster adjacent roof tiles into a single shelter. - * Default: 4 cells. - */ - readonly clusterCellDistance?: number - - /** - * Pixel proximity margin to connect roof bounding boxes into a cluster. - * Default: 40 pixels. - */ - readonly clusterPixelMargin?: number -} - -export const DEFAULT_MIN_ROOF_ALPHA = 0.0 -export const DEFAULT_MAX_ROOF_ALPHA = 1.0 -export const DEFAULT_ROOF_FADE_DURATION_MS = 200 -export const DEFAULT_PLAYER_PADDING_X = 16 -export const DEFAULT_PLAYER_HEIGHT = 48 -export const DEFAULT_CLUSTER_CELL_DISTANCE = 4 -export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40 - -/** - * Compute the world-space bounding box of a single roof tile. - * - * For elevated roofs (with `roofHeight > 0`), the bounding box is projected - * downward to the ground footprint so that a character standing on the ground - * beneath the elevated sprite is detected within the occlusion area. - * - * @param roof - Roof drawable with position and dimensions. - * @returns 2D axis-aligned bounding box. - */ -export function getRoofBounds(roof: RoofDrawable): RoofBounds { - let elevation = roof.roofHeight ?? 0 - if (elevation === 0 && roof.cellX !== undefined && roof.cellY !== undefined) { - const orthoY = (roof.cellX + roof.cellY) * 40 - if (orthoY > roof.y) { - elevation = orthoY - roof.y - } - } - return { - minX: roof.x, - minY: roof.y, - maxX: roof.x + roof.frame.width, - maxY: roof.y + roof.frame.height + Math.max(0, elevation), - } -} - -/** - * Compute the combined bounding box enclosing an array of roof tiles. - * - * @param roofs - Array of roof drawables. - * @returns Combined bounding box, or null if the array is empty. - */ -export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null { - if (roofs.length === 0) return null - let minX = Infinity - let minY = Infinity - let maxX = -Infinity - let maxY = -Infinity - - for (let i = 0; i < roofs.length; i += 1) { - const b = getRoofBounds(roofs[i]!) - if (b.minX < minX) minX = b.minX - if (b.minY < minY) minY = b.minY - if (b.maxX > maxX) maxX = b.maxX - if (b.maxY > maxY) maxY = b.maxY - } - - return { minX, minY, maxX, maxY } -} - -/** - * Check if a 2D point lies within a bounding box (inclusive). - * - * @param px - Point x coordinate. - * @param py - Point y coordinate. - * @param bounds - Bounding box. - */ -export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean { - return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY -} - -/** - * Check if two axis-aligned bounding boxes overlap. - * - * @param a - First bounding box. - * @param b - Second bounding box. - */ -export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean { - return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY -} - -/** - * Normalise a player target (point or box) into a world-space bounding box. - * - * @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds. - * @param paddingX - Half-width when expanding a point (default: 16). - * @param height - Height when expanding a point upward from feet (default: 48). - */ -export function getPlayerBounds( - player: PlayerTarget, - paddingX = DEFAULT_PLAYER_PADDING_X, - height = DEFAULT_PLAYER_HEIGHT, -): RoofBounds { - if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) { - return player - } - if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) { - return { - minX: Math.min(player.left, player.right), - minY: Math.min(player.top, player.bottom), - maxX: Math.max(player.left, player.right), - maxY: Math.max(player.top, player.bottom), - } - } - return { - minX: player.x - paddingX, - minY: player.y - height, - maxX: player.x + paddingX, - maxY: player.y, - } -} - -/** - * Check if a player's position or bounding box overlaps a roof's bounding box. - * - * @param player - Player position or box. - * @param roof - Roof drawable or precomputed bounds. - * @param paddingX - Optional player padding in x. - * @param playerHeight - Optional player height. - */ -export function isPlayerOverlappingRoofBounds( - player: PlayerTarget, - roof: RoofDrawable | RoofBounds, - paddingX = DEFAULT_PLAYER_PADDING_X, - playerHeight = DEFAULT_PLAYER_HEIGHT, -): boolean { - const pBounds = getPlayerBounds(player, paddingX, playerHeight) - const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof - return boundsOverlap(pBounds, rBounds) -} - -/** - * Check if player's cell matches any roof tile's cell. - * - * @param playerCell - Player cell coordinates { x, y }. - * @param roofs - Roof drawable(s) or array with cellX/cellY. - */ -export function isPlayerInRoofCell( - playerCell: Point2D, - roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[], -): boolean { - if (Array.isArray(roofs)) { - for (let i = 0; i < roofs.length; i += 1) { - const r = roofs[i]! - if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) { - return true - } - } - return false - } - const single = roofs as { readonly cellX?: number; readonly cellY?: number } - return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y -} - -/** - * Check if player's sub-tile coordinates fall inside any cell covered by a roof. - * In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5). - * - * @param playerSubTile - Player sub-tile coordinates { x, y }. - * @param roofs - Roof drawable(s) with cellX/cellY. - * @param subTilesPerTile - Sub-tiles per cell edge (default: 5). - */ -export function isPlayerInRoofSubTile( - playerSubTile: Point2D, - roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[], - subTilesPerTile = 5, -): boolean { - const cellX = Math.floor(playerSubTile.x / subTilesPerTile) - const cellY = Math.floor(playerSubTile.y / subTilesPerTile) - return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs) -} - -/** - * Determine whether player is under a specific roof tile, checking bounding box - * overlap OR cell/sub-tile match. - * - * @param player - Player coordinates or bounding box. - * @param roof - Roof drawable. - * @param playerCell - Optional player cell coordinates. - * @param config - Optional configuration. - */ -export function isPlayerUnderRoof( - player: PlayerTarget, - roof: RoofDrawable, - playerCell?: Point2D, - config?: RoofFadeConfig, -): boolean { - if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) { - if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) { - return true - } - } - return isPlayerOverlappingRoofBounds( - player, - roof, - config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X, - config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT, - ) -} - -/** - * Step an opacity value towards targetAlpha given elapsed time deltaMs and duration. - * - * @param currentAlpha - Current alpha (0.0 .. 1.0). - * @param targetAlpha - Target alpha (0.0 .. 1.0). - * @param deltaMs - Elapsed wall-clock milliseconds. - * @param durationMs - Transition duration in ms (default: 200). - * @param minAlpha - Minimum allowed alpha (default: 0.25). - * @param maxAlpha - Maximum allowed alpha (default: 1.0). - */ -export function stepRoofAlpha( - currentAlpha: number, - targetAlpha: number, - deltaMs: number, - durationMs = DEFAULT_ROOF_FADE_DURATION_MS, - minAlpha = DEFAULT_MIN_ROOF_ALPHA, - maxAlpha = DEFAULT_MAX_ROOF_ALPHA, -): number { - if (durationMs <= 0) return targetAlpha - if (deltaMs <= 0) return currentAlpha - - const speed = (maxAlpha - minAlpha) / durationMs - const change = speed * deltaMs - - if (currentAlpha < targetAlpha) { - return Math.min(targetAlpha, currentAlpha + change) - } - if (currentAlpha > targetAlpha) { - return Math.max(targetAlpha, currentAlpha - change) - } - return currentAlpha -} - -/** Represents a clustered shelter composed of one or more adjacent roof tiles. */ -export interface RoofCluster { - readonly id: number - readonly roofs: readonly RoofDrawable[] - readonly roofIndices: readonly number[] - readonly bounds: RoofBounds - readonly cells: ReadonlySet - /** Current opacity alpha (0.0 .. 1.0). */ - alpha: number - /** Target opacity alpha (0.0 .. 1.0). */ - targetAlpha: number - /** Whether the player was detected under this cluster on the latest update. */ - isPlayerUnder: boolean -} - -/** - * Group roof drawables into connected shelters (clusters). - * - * Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes - * are grouped together so an entire building/tent roof fades in unison. - * - * @param roofs - Array of roof drawables. - * @param config - Clustering parameters. - */ -export function clusterRoofs( - roofs: readonly RoofDrawable[], - config: RoofFadeConfig = {}, -): RoofCluster[] { - if (roofs.length === 0) return [] - - const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE - const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN - - const n = roofs.length - const parent = new Int32Array(n) - for (let i = 0; i < n; i += 1) parent[i] = i - - const find = (i: number): number => { - let root = i - while (parent[root] !== root) root = parent[root]! - let curr = i - while (curr !== root) { - const nxt = parent[curr]! - parent[curr] = root - curr = nxt - } - return root - } - - const union = (i: number, j: number): void => { - const rootI = find(i) - const rootJ = find(j) - if (rootI !== rootJ) parent[rootI] = rootJ - } - - const boundsList = roofs.map(getRoofBounds) - - for (let i = 0; i < n; i += 1) { - const a = roofs[i]! - const bA = boundsList[i]! - const expandedA: RoofBounds = { - minX: bA.minX - pixelMargin, - minY: bA.minY - pixelMargin, - maxX: bA.maxX + pixelMargin, - maxY: bA.maxY + pixelMargin, - } - - for (let j = i + 1; j < n; j += 1) { - const b = roofs[j]! - let connected = false - - // 1. Cell adjacency - if ( - a.cellX !== undefined && a.cellY !== undefined && - b.cellX !== undefined && b.cellY !== undefined - ) { - if ( - Math.abs(a.cellX - b.cellX) <= cellDist && - Math.abs(a.cellY - b.cellY) <= cellDist - ) { - connected = true - } - } - - // 2. Pixel bounding box proximity - if (!connected && boundsOverlap(expandedA, boundsList[j]!)) { - connected = true - } - - if (connected) union(i, j) - } - } - - const groups = new Map() - for (let i = 0; i < n; i += 1) { - const root = find(i) - const existing = groups.get(root) - if (existing !== undefined) { - existing.push(i) - } else { - groups.set(root, [i]) - } - } - - const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA - const clusters: RoofCluster[] = [] - let clusterId = 0 - - for (const indices of groups.values()) { - const clusterRoofsList: RoofDrawable[] = [] - const cells = new Set() - - for (const idx of indices) { - const r = roofs[idx]! - clusterRoofsList.push(r) - if (r.cellX !== undefined && r.cellY !== undefined) { - cells.add(`${String(r.cellX)},${String(r.cellY)}`) - } - } - - // Bridge passage / corridor cells between nearby roofs in the same cluster - const len = clusterRoofsList.length - for (let i = 0; i < len; i += 1) { - const a = clusterRoofsList[i]! - if (a.cellX === undefined || a.cellY === undefined) continue - for (let j = i + 1; j < len; j += 1) { - const b = clusterRoofsList[j]! - if (b.cellX === undefined || b.cellY === undefined) continue - if ( - Math.abs(a.cellX - b.cellX) <= cellDist && - Math.abs(a.cellY - b.cellY) <= cellDist - ) { - const minCx = Math.min(a.cellX, b.cellX) - const maxCx = Math.max(a.cellX, b.cellX) - const minCy = Math.min(a.cellY, b.cellY) - const maxCy = Math.max(a.cellY, b.cellY) - for (let cx = minCx; cx <= maxCx; cx += 1) { - for (let cy = minCy; cy <= maxCy; cy += 1) { - cells.add(`${String(cx)},${String(cy)}`) - } - } - } - } - } - - const bounds = getCombinedRoofBounds(clusterRoofsList)! - clusters.push({ - id: clusterId, - roofs: clusterRoofsList, - roofIndices: indices, - bounds, - cells, - alpha: maxAlpha, - targetAlpha: maxAlpha, - isPlayerUnder: false, - }) - clusterId += 1 - } - - return clusters -} - -/** - * Controller managing roof opacity and dynamic roof fading for a scene. - */ -export class RoofFadeController { - readonly clusters: RoofCluster[] - readonly roofClusterMap: Int32Array - readonly config: Required - - constructor( - roofs: readonly RoofDrawable[], - config: RoofFadeConfig = {}, - ) { - this.config = { - minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA, - maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA, - fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS, - playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X, - playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT, - clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE, - clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN, - } - - this.clusters = clusterRoofs(roofs, this.config) - this.roofClusterMap = new Int32Array(roofs.length) - - for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) { - const cluster = this.clusters[cIdx]! - for (const rIdx of cluster.roofIndices) { - this.roofClusterMap[rIdx] = cIdx - } - } - } - - /** - * Update opacity for all roof clusters based on player position and elapsed time. - * - * @param player - Current player world position or bounding box. - * @param deltaMs - Elapsed wall-clock time in milliseconds. - * @param playerCell - Optional player cell { x, y } in tile grid. - */ - update( - player: PlayerTarget, - deltaMs: number, - playerCell?: Point2D, - ): void { - const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight) - - for (let i = 0; i < this.clusters.length; i += 1) { - const cluster = this.clusters[i]! - let under = false - - // 1. Check cell coverage - if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) { - under = true - } - - // 2. Check bounding box overlap if not already triggered by cell - if (!under && boundsOverlap(pBounds, cluster.bounds)) { - for (let r = 0; r < cluster.roofs.length; r += 1) { - if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) { - under = true - break - } - } - } - - cluster.isPlayerUnder = under - cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha - cluster.alpha = stepRoofAlpha( - cluster.alpha, - cluster.targetAlpha, - deltaMs, - this.config.fadeDurationMs, - this.config.minAlpha, - this.config.maxAlpha, - ) - } - } - - /** - * Get the current opacity alpha for a roof drawable by its index. - * - * @param roofIndex - Index of the roof in the original list passed to the constructor. - * @returns Opacity alpha in range [minAlpha, maxAlpha]. - */ - getAlpha(roofIndex: number): number { - const clusterIdx = this.roofClusterMap[roofIndex] - if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha - return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha - } - - /** - * Set alpha directly for all clusters (useful for testing or scene reset). - */ - setAlpha(alpha: number): void { - for (let i = 0; i < this.clusters.length; i += 1) { - this.clusters[i]!.alpha = alpha - this.clusters[i]!.targetAlpha = alpha - } - } - - /** - * Whether the player is currently detected under any roof cluster. - */ - get isPlayerUnderAnyRoof(): boolean { - for (let i = 0; i < this.clusters.length; i += 1) { - if (this.clusters[i]!.isPlayerUnder) return true - } - return false - } - - /** - * Lowest opacity alpha among all roof clusters (for global state/HUD reporting). - */ - get overallAlpha(): number { - if (this.clusters.length === 0) return this.config.maxAlpha - let min = 1.0 - for (let i = 0; i < this.clusters.length; i += 1) { - if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha - } - return min - } -} diff --git a/src/client/render/unit-sprites.ts b/src/client/render/unit-sprites.ts deleted file mode 100644 index 1821533..0000000 --- a/src/client/render/unit-sprites.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Diablo II: Lord of Destruction v1.13c Unified 7-Class, Missile, Overlay & Summon Sprite Loader (`UnitSpriteLoader`). - * - * - Maps all 7 player classes (`ama`->`am`, `sor`->`so`, `nec`->`ne`, `pal`->`pa`, `bar`->`ba`, `dru`->`dz`, `ass`->`ai`) - * across `d2char.mpq` and `d2exp.mpq` with 16 composite layers (`hd..s8`). - * - Decodes missile `.dcc` sprites (`data\global\missiles\.dcc`) via `decodeDcc`. - * - Decodes state/cast overlay `.dcc`/`.dc6` sprites (`data\global\overlays\.dcc` or `.dc6`) - * and distinguishes `PreDraw === true` (ground aura rings) vs `PreDraw === false` (overhead curses/flashes). - * - Decodes summon/trap/morph monster sprites (`data\global\monsters\\`). - */ -import type { MountedArchives } from '../../common/mpq/mount.ts' -import { decodeDcc } from '../../common/formats/dcc.ts' -import { decodeDc6 } from '../../common/formats/dc6.ts' -import type { SpriteFrame, SpriteSheet } from '../../common/formats/sprite.ts' -import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts' - -import { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken } from '../../common/units/classes.ts' -export { CLASS_CODE_TO_CHAR_TOKEN, resolvePlayerCharToken } - -export class UnitSpriteLoader { - readonly archives: MountedArchives - private readonly charSheetCache = new Map>() - private readonly missileSheetCache = new Map>() - private readonly overlaySheetCache = new Map>() - private readonly monsterSheetCache = new Map>() - - constructor(archives: MountedArchives) { - this.archives = archives - } - - /** - * Load and cache a composite character sheet for any of the 7 hero classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai`). - */ - loadPlayerClassSheet( - classOrToken: string, - mode = 'sc', - weaponClass = 'hth', - ): Promise { - const token = resolvePlayerCharToken(classOrToken) - const anim = mode.trim().toLowerCase() - const wclass = weaponClass.trim().toLowerCase() - const key = `${token}:${anim}:${wclass}` - - const existing = this.charSheetCache.get(key) - if (existing) return existing - - const promise = loadCharacterSheet(this.archives, token, anim, wclass) - this.charSheetCache.set(key, promise) - return promise - } - - /** - * Load and cache a missile sprite (`data\global\missiles\.dcc`). - */ - loadMissileSprite(celFile: string): Promise { - const clean = celFile.trim().replace(/\.(dcc|dc6)$/i, '') - const key = clean.toLowerCase() - const existing = this.missileSheetCache.get(key) - if (existing) return existing - - const promise = (async (): Promise => { - const dccPath = `data\\global\\missiles\\${clean}.dcc` - const bytes = await this.archives.read(dccPath) - const dcc = decodeDcc(bytes) - const groups = dcc.directions.map(dir => ({ - frames: dir.frames.map( - (f): SpriteFrame => ({ - width: f.frame.width, - height: f.frame.height, - indices: f.frame.indices, - mask: f.frame.mask, - anchorX: dir.box.left, - anchorY: dir.box.top, - }), - ), - })) - return { groups, width: null } - })() - - this.missileSheetCache.set(key, promise) - return promise - } - - /** - * Load and cache a state/cast overlay sprite (`data\global\overlays\.dcc` with `.dc6` fallback). - * Accepts either an `Overlay.txt` `Filename` (`CurseAmplifyDamageEffect`) or `overlay` ID (`curseamplifydamage`). - */ - loadOverlaySprite(filenameOrOverlayId: string): Promise { - const clean = filenameOrOverlayId.trim().replace(/\.(dcc|dc6)$/i, '') - const key = clean.toLowerCase() - const existing = this.overlaySheetCache.get(key) - if (existing) return existing - - const promise = (async (): Promise => { - const candidates = [ - clean, - key === 'cursampdamage' || key === 'curseamplifydamage' ? 'CurseAmplifyDamageEffect' : '', - 'AuraFanatic', - ].filter(Boolean) - - for (const cand of candidates) { - const dccPath = `data\\global\\overlays\\${cand}.dcc` - if (this.archives.find(dccPath)) { - const bytes = await this.archives.read(dccPath) - const dcc = decodeDcc(bytes) - const groups = dcc.directions.map(dir => ({ - frames: dir.frames.map( - (f): SpriteFrame => ({ - width: f.frame.width, - height: f.frame.height, - indices: f.frame.indices, - mask: f.frame.mask, - anchorX: dir.box.left, - anchorY: dir.box.top, - }), - ), - })) - return { groups, width: null } - } - } - - const dc6Path = `data\\global\\overlays\\${clean}.dc6` - const bytes = await this.archives.read(dc6Path) - return decodeDc6(bytes) - })() - - this.overlaySheetCache.set(key, promise) - return promise - } - - /** - * Load and cache a summoned pet, trap, or shapeshift monster sprite (`data\global\monsters\\`). - */ - loadMonsterOrPetSheet( - monCode: string, - mode = 'nu', - weaponClass = 'hth', - ): Promise { - const code = monCode.trim().toLowerCase() - const anim = mode.trim().toLowerCase() - const wclass = weaponClass.trim().toLowerCase() - const key = `${code}:${anim}:${wclass}` - - const existing = this.monsterSheetCache.get(key) - if (existing) return existing - - const promise = loadCharacterSheet( - this.archives, - code, - anim, - wclass, - `data\\global\\monsters\\${code}\\`, - ) - this.monsterSheetCache.set(key, promise) - return promise - } -} diff --git a/src/client/scene/arena-overlay.ts b/src/client/scene/arena-overlay.ts deleted file mode 100644 index 8070a7f..0000000 --- a/src/client/scene/arena-overlay.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { getSharedDataRegistry } from '../../common/data/data-registry.ts' -import { MpqArchive } from '../../common/mpq/archive.ts' -import { MountedArchives } from '../../common/mpq/mount.ts' -import { memorySource } from '../../common/mpq/source.ts' - -export type WorldArenaType = any -export type WorldArenaFactory = (opts: Record) => WorldArenaType - -let registeredWorldArenaFactory: WorldArenaFactory | null = null - -export function registerWorldArenaFactory(factory: WorldArenaFactory | null): void { - registeredWorldArenaFactory = factory -} - -export async function initBrowserArenaMode(factory?: WorldArenaFactory): Promise { - const createArena = factory ?? registeredWorldArenaFactory - if (!createArena) { - throw new Error( - '[Arena] No WorldArenaFactory registered; registerWorldArenaFactory must be called before initializing arena mode', - ) - } - if (typeof window === 'undefined' || typeof document === 'undefined') return - const params = new URLSearchParams(location.search) - if (params.get('arena') !== '1') return - - try { - const classParam = params.get('class') || params.get('hero') || 'sor' - const skillId = Math.max(0, Number(params.get('skill') ?? '47')) - const slvl = Math.max(1, Number(params.get('slvl') ?? '20')) - const autocast = params.get('autocast') !== '0' - - const [patchRes, animRes, hitClassRes] = await Promise.all([ - fetch('/samples/d2/Patch_D2.mpq'), - fetch('/samples/d2-packs/anim/AnimData.d2'), - fetch('/samples/d2-packs/anim/HitClass.txt'), - ]) - if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) { - throw new Error( - `Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`, - ) - } - const patchBytes = new Uint8Array(await patchRes.arrayBuffer()) - const animBytes = new Uint8Array(await animRes.arrayBuffer()) - const hitClassBytes = new Uint8Array(await hitClassRes.arrayBuffer()) - const patchMpq = MpqArchive.openSync(memorySource(patchBytes, 'Patch_D2.mpq')) - const mounted = new MountedArchives() - mounted.add('Patch_D2.mpq', patchMpq) - const browserSource = { - readBytes(path: string): Uint8Array | null { - const lower = path.trim().replace(/\//g, '\\').toLowerCase() - if (lower.endsWith('animdata.d2')) return animBytes - if (lower.endsWith('hitclass.txt')) return hitClassBytes - return mounted.readFile(path) - }, - } - - const registry = getSharedDataRegistry(browserSource) - let arena: WorldArenaType = createArena({ - registry, - classCode: classParam, - skillId, - slvl, - }) - - if (autocast) { - arena.triggerCast() - arena.stepTicks(15) - } - - const syncWindowArena = () => { - const snap = arena.getDebugState() as ReturnType & { - switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown - } - const origTrigger = snap.triggerCast - const origStep = snap.stepTicks - snap.triggerCast = (tx?: number, ty?: number) => { - const out = origTrigger(tx, ty) - syncWindowArena() - renderArenaVisualOverlay(arena) - return out - } - snap.stepTicks = (cnt: number) => { - origStep(cnt) - syncWindowArena() - renderArenaVisualOverlay(arena) - } - snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => { - arena = createArena({ - registry, - classCode: nextClassCode, - skillId: nextSkillId, - slvl: nextSlvl, - }) - arena.triggerCast() - arena.stepTicks(15) - syncWindowArena() - renderArenaVisualOverlay(arena) - return arena.getDebugState() - } - ;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap - } - - const renderArenaVisualOverlay = (wArena: WorldArenaType) => { - let overlayCanvas = document.querySelector('#arena-skill-overlay') - if (!overlayCanvas) { - overlayCanvas = document.createElement('canvas') - overlayCanvas.id = 'arena-skill-overlay' - overlayCanvas.width = window.innerWidth || 960 - overlayCanvas.height = window.innerHeight || 600 - overlayCanvas.style.cssText = - 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;' - document.body.appendChild(overlayCanvas) - } - const ctx = overlayCanvas.getContext('2d') - if (!ctx) return - const w = overlayCanvas.width - const h = overlayCanvas.height - ctx.clearRect(0, 0, w, h) - - const dbg = wArena.getDebugState() - const cx = w * 0.5 - const cy = h * 0.52 - - // 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero - const classHue = ((dbg.skillId * 37) % 360) - ctx.save() - ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)` - ctx.lineWidth = 3.5 - ctx.beginPath() - ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2) - ctx.stroke() - - // Hero class figure + active weapon/spell glow - ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)` - ctx.strokeStyle = '#fff3d1' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2) - ctx.fill() - ctx.stroke() - ctx.fillStyle = '#ffe9a8' - ctx.font = 'bold 11px monospace' - ctx.textAlign = 'center' - ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35) - ctx.restore() - - // 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`) - const dummyOffsets: Record = { - 'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' }, - 'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' }, - 'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' }, - 'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' }, - } - for (const tgt of dbg.targetLog) { - const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' } - const tx = cx + off.dx - const ty = cy + off.dy - const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp))) - - // Dummy shadow + body marker - ctx.fillStyle = 'rgba(0,0,0,0.55)' - ctx.beginPath() - ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2) - ctx.fill() - - ctx.fillStyle = off.color - ctx.strokeStyle = '#f5e6c8' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2) - ctx.fill() - ctx.stroke() - - // HP bar - ctx.fillStyle = '#1f1914' - ctx.fillRect(tx - 44, ty - 38, 88, 9) - ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822' - ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7) - - ctx.font = 'bold 11px monospace' - ctx.textAlign = 'center' - ctx.fillStyle = '#f2e6d0' - ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43) - - if (tgt.lastDamage > 0) { - ctx.fillStyle = '#ffdf59' - ctx.font = 'bold 14px monospace' - ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58) - } - if (tgt.states.length > 0) { - ctx.fillStyle = '#7be3ff' - ctx.font = '10px monospace' - ctx.fillText(tgt.states.join(','), tx, ty + 32) - } - } - - // 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy - if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) { - ctx.save() - const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110) - grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)') - grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)') - ctx.strokeStyle = grad - ctx.lineWidth = 5 - ctx.beginPath() - ctx.moveTo(cx, cy - 8) - ctx.lineTo(cx, cy - 108) - ctx.stroke() - - ctx.fillStyle = 'rgba(255, 165, 50, 0.85)' - ctx.beginPath() - ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2) - ctx.fill() - ctx.restore() - } - - // 4. Draw Active Summoned Pets / Traps - dbg.petsActive.forEach((pet: { monId: string; hp: number }, idx: number) => { - const px = cx - 95 + idx * 48 - const py = cy + 44 - ctx.fillStyle = '#5ce1e6' - ctx.strokeStyle = '#112a36' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.arc(px, py, 12, 0, Math.PI * 2) - ctx.fill() - ctx.stroke() - ctx.fillStyle = '#d7f9ff' - ctx.font = 'bold 10px monospace' - ctx.textAlign = 'center' - ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24) - }) - - // 5. Top-left 1.13c Skill Verification Telemetry Panel - ctx.fillStyle = 'rgba(12, 10, 8, 0.88)' - ctx.strokeStyle = '#c8a15a' - ctx.lineWidth = 1.5 - ctx.fillRect(14, 62, 470, 122) - ctx.strokeRect(14, 62, 470, 122) - - ctx.textAlign = 'left' - ctx.fillStyle = '#f0c76e' - ctx.font = 'bold 13px monospace' - ctx.fillText( - `[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`, - 24, - 82, - ) - ctx.fillStyle = '#e8dcc8' - ctx.font = '12px monospace' - ctx.fillText( - `CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`, - 24, - 102, - ) - ctx.fillText( - `1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`, - 24, - 122, - ) - ctx.fillText( - `Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`, - 24, - 142, - ) - ctx.fillStyle = '#8ee38e' - ctx.fillText( - `Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`, - 24, - 162, - ) - } - - syncWindowArena() - renderArenaVisualOverlay(arena) - } catch (err) { - ;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String( - (err as Error)?.stack || err, - ) - } -} - -if (typeof window !== 'undefined' && typeof document !== 'undefined') { - ;(window as unknown as { __initBrowserArenaMode?: (factory?: WorldArenaFactory) => Promise }).__initBrowserArenaMode = - initBrowserArenaMode - if (typeof location !== 'undefined' && new URLSearchParams(location.search).get('arena') === '1') { - initBrowserArenaMode().catch((err) => { - ;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String( - (err as Error)?.stack || err, - ) - }) - } -} diff --git a/src/client/scene/ground-item-renderer.ts b/src/client/scene/ground-item-renderer.ts index 1a4edd9..dd71b86 100644 --- a/src/client/scene/ground-item-renderer.ts +++ b/src/client/scene/ground-item-renderer.ts @@ -1,33 +1,8 @@ -import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import { - calculateBounceHeight, - type GroundItemEntity, -} from '../../common/items/ground-items.ts' -import type { AtlasFrame } from '../render/atlas.ts' import type { AtlasHandle, SpriteRenderer, } from '../render/renderer.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' -import { isGroundItemEtherealOrSocketed } from '../ui/ground-labels.ts' -import type { HudManager } from '../ui/hud-manager.ts' -import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' - -export type SpriteRect = NonNullable> - -/** Quality colors for ground item highlights and glints. */ -export const GROUND_ITEM_QUALITY_COLORS: Record = { - normal: [0.88, 0.88, 0.88, 1.0], - low: [0.55, 0.55, 0.55, 1.0], - superior: [0.95, 0.95, 0.95, 1.0], - magic: [0.35, 0.52, 1.0, 1.0], - rare: [1.0, 0.92, 0.22, 1.0], - set: [0.12, 0.95, 0.12, 1.0], - unique: [0.85, 0.72, 0.42, 1.0], - craft: [1.0, 0.62, 0.12, 1.0], - rune: [1.0, 0.62, 0.12, 1.0], - gold: [1.0, 0.84, 0.2, 1.0], -} export let globalItemsAtlasHandle: AtlasHandle | undefined @@ -72,167 +47,3 @@ export async function loadItemsAtlas( console.error(msg) throw new Error(msg) } - -/** - * Resolve the authentic Blizzard flippy ground sprite frame key for a ground item entity or item. - */ -export function resolveGroundItemFlippyFrame(item: GroundItemEntity | Record | unknown): string { - const raw = (item as any)?.item ?? item - const code = typeof raw?.code === 'string' ? raw.code.trim().toLowerCase() : '' - const isGold = Boolean((item as any)?.isGold || raw?.isGold || code === 'gld') - if (isGold) { - const amount = Number((item as any)?.amount ?? raw?.amount ?? raw?.value ?? 1) - if (amount >= 5000) return 'flpgld_3' - if (amount >= 500) return 'flpgld_2' - if (amount >= 100) return 'flpgld_1' - return 'flpgld_0' - } - const directFlp = (item as any)?.flippyFile ?? raw?.flippyFile - if (typeof directFlp === 'string' && directFlp.length > 0) { - return directFlp.toLowerCase() - } - const name = typeof raw?.name === 'string' ? raw.name : typeof (item as any)?.name === 'string' ? (item as any).name : '' - if (name && BAKED_UI_MANIFEST.codeToFlippyFile[name]) { - return BAKED_UI_MANIFEST.codeToFlippyFile[name]! - } - if (code && BAKED_UI_MANIFEST.codeToFlippyFile[code]) { - return BAKED_UI_MANIFEST.codeToFlippyFile[code]! - } - return code ? `flp${code}` : 'flpcap' -} - -/** - * Synchronize HUD ground drops state if needed. - */ -export function syncHudGroundDrops( - _engine: GameEngine, - hudManager: HudManager | null, -): void { - hudManager?.syncPublishedState?.() -} - -/** - * Render a ground item entity with authentic Diablo II 1.13c appearance: - * - Parabolic bounce height offset (when dropping or inventory full) - * - Authentic Blizzard `flippyfile` (`flp*.dc6`) ground item sprite resting on the 2:1 isometric plane - * - Ground contact oval shadow - */ -export function drawGroundItem( - renderer: SpriteRenderer, - item: GroundItemEntity, - now: number, - itemsAtlas: AtlasHandle | { readonly handle?: AtlasHandle | number; readonly frames?: unknown } | null | undefined = globalItemsAtlasHandle, -): void { - const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0 - const baseY = item.y - bounceH - - const isEthOrSockBase = !item.isGold && - (item.quality === 'normal' || item.quality === 'superior' || item.quality === 'high' || item.quality === 'hi-quality') && - !(item as any).isRuneword && - !(item.item as any)?.isRuneword && - isGroundItemEtherealOrSocketed(item) - - const qColor = item.isGold - ? GROUND_ITEM_QUALITY_COLORS.gold! - : isEthOrSockBase - ? GROUND_ITEM_QUALITY_COLORS.low! - : (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!) - - // 1. Authentic Blizzard flippy ground sprite resolution (strictly fail-fast if missing) - const target = item.isGold - ? { isGold: true, amount: item.amount, code: 'gld' } - : (item.item || (item as any)) - - const sr = resolveGroundItemSpriteRect( - target, - BAKED_UI_MANIFEST.flippyRects, - BAKED_UI_MANIFEST.codeToFlippyFile, - ) - - if (sr === null) { - throw new Error( - `GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST for ground item "${item.name}" (id: "${item.id}")`, - ) - } - - // Authentic Diablo II ground item sprite icon: - // Natural sprite dimensions from flp*.dc6 (already rendered by Blizzard in 2:1 isometric perspective) - const baseW = sr.w - const baseH = sr.h - - // Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height - const shadowAlpha = bounceH > 0 ? Math.max(0.1, 0.38 * (1 - Math.min(1, bounceH / 45))) : 0.38 - const shadowW = Math.max(12, Math.round(baseW * 0.95)) - const shadowH = Math.max(4, Math.round(shadowW * 0.38)) - renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha]) - - const drawX = Math.round(item.x - baseW / 2) - const drawY = Math.round(baseY - baseH) - - const frame: AtlasFrame = { - x: sr.x, - y: sr.y, - width: sr.w, - height: sr.h, - } - - const effectiveAtlas: AtlasHandle | undefined = - itemsAtlas && typeof itemsAtlas === 'object' && 'texture' in itemsAtlas - ? (itemsAtlas as AtlasHandle) - : itemsAtlas && typeof itemsAtlas === 'object' && 'handle' in itemsAtlas && itemsAtlas.handle && typeof itemsAtlas.handle === 'object' && 'texture' in itemsAtlas.handle - ? (itemsAtlas.handle as AtlasHandle) - : undefined - - if (typeof (renderer as any).draw === 'function') { - renderer.draw(frame, drawX, drawY, { - ...(effectiveAtlas !== undefined ? { atlas: effectiveAtlas } : {}), - width: baseW, - height: baseH, - }) - } else if (typeof (renderer as any).drawQuad === 'function') { - // Slanted quad projection fallback for tests expecting drawQuad - const groundH = Math.max(6, Math.round(baseH * 0.52)) - const shearX = Math.round(-groundH * 0.55) - const x2 = item.x - baseW / 2 - const y2 = baseY - const x3 = item.x + baseW / 2 - const y3 = baseY - const x0 = x2 + shearX - const y0 = baseY - groundH - const x1 = x3 + shearX - const y1 = baseY - groundH - ;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, { - atlas: effectiveAtlas, - }) - } - - // 3. Glint / Sparkle 4-Point Star Animation for resting items - // Dropped items display periodic sparkle glints once settled on the ground plane - const isAirborne = item.bounceState !== null && item.bounceState !== undefined && bounceH > 0 - if (!isAirborne) { - const period = 2000 - const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period - const cycleTime = (now + phaseOffset) % period - const flashDuration = 320 - - if (cycleTime < flashDuration) { - const p = cycleTime / flashDuration - // Sine bell curve for smooth brightening and fading - const intensity = Math.sin(p * Math.PI) - if (intensity > 0.05) { - const gx = item.x + (item.invWidth > 1 ? 3 : 0) - const gy = baseY - (item.isGold ? 6 : 12) - const rayLen = Math.round(3 + 6 * intensity) - - // Horizontal ray - renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9]) - // Vertical ray - renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9]) - // Brilliant central core - renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity]) - // Subtle quality aura - renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35]) - } - } - } -} diff --git a/src/client/scene/lighting-presets.ts b/src/client/scene/lighting-presets.ts index fe16e88..8e69b79 100644 --- a/src/client/scene/lighting-presets.ts +++ b/src/client/scene/lighting-presets.ts @@ -1,18 +1,7 @@ -import { - ORTHO_SUB_TILE_HEIGHT, - ORTHO_SUB_TILE_WIDTH, - type CollisionGrid, -} from '../../common/world/d2map.ts' -import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' +import type { CollisionGrid } from '../../common/world/d2map.ts' import type { Environment } from '../../common/world/environment.ts' import type { LightGrid } from '../../common/drlg/light-grid.ts' -import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/objects.ts' -import type { TownPortalSlot } from '../../common/world/portal.ts' -import { getMissileTxtData } from '../../common/skills/missiles-data.ts' import { getTblLang, type TblLang } from '../i18n/lang.ts' -import type { SpriteRenderer } from '../render/renderer.ts' -import type { ObjectDrawable } from './object-animation.ts' -import type { ActSceneState } from './scene-state.ts' export type ClientLightingPreset = | 'auto' @@ -201,7 +190,7 @@ export interface PointLightInput { } /** - * Engine-decoupled lighting inputs for both single-player and online D2GS scenes. + * Engine-decoupled lighting inputs for online D2GS scenes. */ export interface LightingInputs { readonly preset: LightingPreset @@ -230,26 +219,18 @@ export interface LightingPassTargets { ): void } readonly environment?: Pick | undefined - readonly state?: Pick | undefined } /** - * Compute and upload the 1.13c sub-tile lightmap pass from pure `LightingInputs` - * without requiring a `GameEngine` instance. + * Compute and upload the 1.13c sub-tile lightmap pass from pure `LightingInputs`. */ export function applyLightingInputs(inputs: LightingInputs, targets: LightingPassTargets): void { const presetCfg = LIGHTING_PRESETS[inputs.preset] ?? LIGHTING_PRESETS.auto const lightingEnabled = presetCfg.enabled && inputs.preset !== 'fullbright' && inputs.preset !== 'off' - const { lightGrid, renderer, environment, state } = targets + const { lightGrid, renderer, environment } = targets if (!lightingEnabled) { renderer.setLighting(null) - if (state) { - state.lightingPreset = inputs.preset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = 255 - state.ambientColor = [255, 255, 255] - } return } @@ -309,213 +290,4 @@ export function applyLightingInputs(inputs: LightingInputs, targets: LightingPas sceneOriginY: inputs.collisionGrid.originY, lightmap: lightGrid.exportTextureBuffer(), }) - - if (state) { - state.lightingPreset = inputs.preset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = effectiveAmbIntensity - state.ambientColor = [effR, effG, effB] - } -} - -export interface SceneLightingPassContext { - readonly lightingEnabled: boolean - readonly currentLightingPreset: LightingPreset - readonly playerSubTile: () => { x: number; y: number } - readonly lightGrid: LightGrid - readonly environment: Environment - readonly runtime: { - readonly levelId: number - readonly grid: CollisionGrid - readonly objectDrawables?: readonly ObjectDrawable[] - } - readonly engine: GameEngine - readonly portalSlot: TownPortalSlot - readonly renderer: SpriteRenderer - readonly state: ActSceneState -} - -/** - * Compute and upload the 1.13c sub-tile lightmap pass for the active frame. - */ -export function applySceneLightingPass(ctx: SceneLightingPassContext): void { - const { - lightingEnabled, - currentLightingPreset, - playerSubTile, - lightGrid, - environment, - runtime, - engine, - portalSlot, - renderer, - state, - } = ctx - - if (lightingEnabled) { - const pSub = playerSubTile() - lightGrid.centerOnSubTile(pSub.x, pSub.y) - lightGrid.clear() - lightGrid.updateObstacles(runtime.grid) - - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - const overrideAmb = presetCfg.overrideAmbient - const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity - const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave' - - const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - const basePlayerRadius = isDarkCave ? 20 : 22 - lightGrid.addLight({ - subTileX: pSub.x, - subTileY: pSub.y, - radius: basePlayerRadius + flicker, - intensity: 242, - red: 255, - green: 234, - blue: 196, - }) - - const skillToMissile: Readonly> = { - '36': 'firebolt', - '38': 'chargedbolt', - '39': 'icebolt', - '45': 'iceblast', - '47': 'fireball', - '49': 'lightning', - '53': 'chainlightning', - '55': 'glacialspike', - '56': 'meteor', - '64': 'frozenorb', - '67': 'teeth', - '84': 'bonespear', - '93': 'bonespirit', - } - for (const shot of engine.projectiles) { - const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) - if (!mType) continue - const mData = getMissileTxtData(mType) - const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) - if (lightUnits > 0) { - const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const shotSubX = Math.round((dy + dx) / 2) - const shotSubY = Math.round((dy - dx) / 2) - const isCold = mData.blue > mData.red - lightGrid.addLight({ - subTileX: shotSubX, - subTileY: shotSubY, - radius: Math.max(14, lightUnits * 2), - intensity: 250, - red: isCold ? Math.max(120, mData.red) : mData.red, - green: isCold ? Math.max(175, mData.green) : mData.green, - blue: mData.blue, - }) - } - } - - for (const exp of engine.explosions) { - const expData = getMissileTxtData(exp.missileType) - const lightUnits = expData && expData.light > 0 ? expData.light : 8 - const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const expSubX = Math.round((dy + dx) / 2) - const expSubY = Math.round((dy - dx) / 2) - const isColdExp = - exp.missileType.includes('ice') || - exp.missileType.includes('freeze') || - exp.missileType.includes('blizzard') || - exp.missileType.includes('orb') - lightGrid.addLight({ - subTileX: expSubX, - subTileY: expSubY, - radius: Math.round(lightUnits * 2.5), - intensity: 255, - red: isColdExp ? 130 : (expData?.red ?? 255), - green: isColdExp ? 185 : (expData?.green ?? 160), - blue: isColdExp ? 255 : (expData?.blue ?? 50), - }) - } - - if (runtime.objectDrawables) { - for (const obj of runtime.objectDrawables) { - const tok = (obj.token ?? '').trim().toUpperCase() - const isTorchOrFire = - tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || - tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || - tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || - tok === 'F8' || tok === '1Y' || tok === 'FL' - const isWaypoint = - tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || - tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' - const isShrineOrBeam = - tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || - obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) - - if (isTorchOrFire || isWaypoint || isShrineOrBeam) { - const dx = obj.x - engine.world.player.x - const dy = obj.y - engine.world.player.y - if (dx * dx + dy * dy < 950 * 950) { - const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const objSubX = Math.round((ody + odx) / 2) - const objSubY = Math.round((ody - odx) / 2) - const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0 - lightGrid.addLight({ - subTileX: objSubX, - subTileY: objSubY, - radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker, - intensity: isTorchOrFire ? 175 : 160, - red: isWaypoint ? 120 : 255, - green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205), - blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175), - }) - } - } - } - } - - const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId) - if (activePortalMouthForLight !== null) { - const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - lightGrid.addLight({ - subTileX: activePortalMouthForLight.x, - subTileY: activePortalMouthForLight.y, - radius: 19 + portalFlicker, - intensity: 235, - red: 64, - green: 110, - blue: 255, - }) - } - - const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5 - const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185) - const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175) - const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165) - const ambNorm = effectiveAmbIntensity / 255 - const ambR = (effR / 255) * ambNorm - const ambG = (effG / 255) * ambNorm - const ambB = (effB / 255) * ambNorm - - renderer.setLighting({ - ambient: [ambR, ambG, ambB], - originSubX: lightGrid.originSubX, - originSubY: lightGrid.originSubY, - sceneOriginX: runtime.grid.originX, - sceneOriginY: runtime.grid.originY, - lightmap: lightGrid.exportTextureBuffer(), - }) - - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = effectiveAmbIntensity - state.ambientColor = [effR, effG, effB] - } else { - renderer.setLighting(null) - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = 255 - state.ambientColor = [255, 255, 255] - } } diff --git a/src/client/scene/mouse-controller.ts b/src/client/scene/mouse-controller.ts deleted file mode 100644 index 6a7bce4..0000000 --- a/src/client/scene/mouse-controller.ts +++ /dev/null @@ -1,1685 +0,0 @@ -import { type AudioManager, sharedAudioManager } from '../audio/manager.ts' -import type { ActorAnimator } from '../render/actor-animator.ts' -import type { Monster, NpcEntity, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import { - isWithinPickupBounds, - triggerFlippyBounce, - type GroundItemEntity, -} from '../../common/items/ground-items.ts' -import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts' -import type { LoadedMonsterArt } from '../render/monster-art.ts' -import { getTownNpcDescriptorByName } from '../../common/units/npc-table.ts' -import type { TownPortalSlot, WaypointNetwork } from '../../common/world/portal.ts' -import { npcDialog } from '../../common/world/quests.ts' -import { isAuraSkill } from '../../common/skills/missiles-data.ts' -import { getTblLang } from '../i18n/lang.ts' -import type { ViewportCamera } from '../sim/camera.ts' -import type { KeyboardInput } from '../sim/input.ts' -import type { HudManager } from '../ui/hud-manager.ts' -import { - getInventoryGoldLimit, - itemToUiInventoryItem, -} from '../ui/inventory.ts' -import { - WARP_TRIGGER_SUBTILES, - facingOf, - hitTestEntrance, - hitTestWarp, - isPlayerWithinLinkRange, - resolveObstacleAwareSteerTarget, - screenToWorld, - subTileToScene, - type SceneEntrance, - type SceneWarp, - type SceneWaypoint, -} from '../../common/world/scene-links-nav.ts' -import { - isTownLevel, - type TownStashEntity, -} from '../../common/world/scene-population.ts' -import { - getAttackWeaponInfo, - getSkillRange, - isPassiveSkill, -} from '../../common/skills/weapon-combat-meta.ts' -import type { LoadedCharacter } from './entity-art-loader.ts' -import { - playInventoryFullFeedback, - showNotification, - updateOverheadLabels as updateSceneOverheadLabels, -} from './overhead-labels.ts' -import { FEET_HEIGHT, type MapRuntime } from './pack-loader.ts' -import { monsterToTargetHudInfo } from './scene-state.ts' - -export { playInventoryFullFeedback, showNotification } - -/** - * Play synthesized Diablo II-style stash open sound via the shared AudioManager singleton. - */ -export function playStashOpenSound(audioManager: AudioManager = sharedAudioManager): void { - audioManager.playTone({ - type: 'triangle', - from: 140, - to: 70, - gain: 0.3, - durationS: 0.15, - delayS: 0, - }) -} - -export type PendingInteraction = - | { readonly kind: 'npc'; readonly npc: NpcEntity } - | { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } } - | { readonly kind: 'stash'; readonly stash: TownStashEntity } - | { readonly kind: 'portal'; readonly worldPos: { readonly x: number; readonly y: number } } - | { readonly kind: 'warp'; readonly warp: SceneWarp; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } - | { readonly kind: 'entrance'; readonly entrance: SceneEntrance; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } - | { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number } - | { readonly kind: 'ground-item'; readonly item: GroundItemEntity } - -declare global { - interface HTMLCanvasElement { - clientWidth: number - clientHeight: number - } -} - -export interface SceneMouseControllerOptions { - readonly canvas: HTMLCanvasElement - readonly engine: GameEngine - readonly camera: ViewportCamera - readonly input: KeyboardInput - readonly getRuntime: () => MapRuntime - readonly hudManager: HudManager | null - readonly waypointNetwork: WaypointNetwork - readonly status: HTMLElement - readonly audioManager?: AudioManager | undefined - readonly queuePointerEvents?: boolean | undefined - readonly playerAnimator?: ActorAnimator | undefined - readonly getCharacter?: (() => LoadedCharacter | null) | undefined - readonly portalSlot?: TownPortalSlot | undefined - readonly onEnterPortal?: (() => void) | undefined - readonly onCastTownPortal?: (() => boolean) | undefined - readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined - readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined - readonly isTravelling?: (() => boolean) | undefined - readonly labelsContainer?: HTMLElement | null | undefined - readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined -} - -export type MouseControllerContext = SceneMouseControllerOptions - -export class SceneMouseController { - readonly canvas: HTMLCanvasElement - readonly engine: GameEngine - readonly camera: ViewportCamera - readonly input: KeyboardInput - readonly getRuntime: () => MapRuntime - readonly hudManager: HudManager | null - readonly waypointNetwork: WaypointNetwork - readonly status: HTMLElement - readonly audioManager?: AudioManager | undefined - readonly queuePointerEvents: boolean - readonly pendingActions: Array<() => void> = [] - readonly playerAnimator?: ActorAnimator | undefined - readonly getCharacter?: (() => LoadedCharacter | null) | undefined - readonly portalSlot?: TownPortalSlot | undefined - readonly onEnterPortal?: (() => void) | undefined - readonly onCastTownPortal?: (() => boolean) | undefined - readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined - readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined - readonly isTravelling?: (() => boolean) | undefined - readonly labelsContainer?: HTMLElement | null | undefined - readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined - - navTarget: { x: number; y: number } | null = null - pendingInteraction: PendingInteraction | null = null - activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'warp' | 'entrance' | 'monster' = 'none' - isLeftMouseDown = false - isRightMouseDown = false - lastClientPos: { x: number; y: number } | null = null - hoveredMonster: Monster | null = null - lockedMonster: Monster | null = null - hoveredGroundItem: GroundItemEntity | null = null - hoveredWarp: SceneWarp | null = null - hoveredEntrance: SceneEntrance | null = null - readonly labelPool: HTMLDivElement[] = [] - - private stuckTicks = 0 - private lastDist = -1 - private lastPlayerPos: { x: number; y: number } | null = null - - constructor(options: SceneMouseControllerOptions) { - this.canvas = options.canvas - this.engine = options.engine - this.camera = options.camera - this.input = options.input - this.getRuntime = options.getRuntime - this.hudManager = options.hudManager - this.waypointNetwork = options.waypointNetwork - this.status = options.status - this.audioManager = options.audioManager - this.queuePointerEvents = options.queuePointerEvents ?? false - this.playerAnimator = options.playerAnimator - this.getCharacter = options.getCharacter - this.portalSlot = options.portalSlot - this.onEnterPortal = options.onEnterPortal - this.onCastTownPortal = options.onCastTownPortal - this.onEnterWarp = options.onEnterWarp - this.onEnterEntrance = options.onEnterEntrance - this.isTravelling = options.isTravelling - this.labelsContainer = options.labelsContainer ?? null - this.getMonsterArtMap = options.getMonsterArtMap - ;(this.engine as any).dialog = null - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - /** - * Hit-test world coordinates against living, killable, non-town monsters using - * their rendered sprite bounding box (`m.drawBounds`, loaded sprite frame 0 from - * `monsterArtMap` before first render, or rank/boss-aware fallback footprint), - * selecting the frontmost monster in isometric depth (`m.y` descending, - * tie-breaking by center distance). - */ - findMonsterAtWorld(worldX: number, worldY: number): Monster | null { - const runtime = this.getRuntime?.() - if (runtime && isTownLevel(runtime.levelId)) return null - const monsters = this.engine?.world?.monsters ?? [] - const artMap = this.getMonsterArtMap?.() - let bestMonster: Monster | null = null - let bestDepthY = -Infinity - let bestCenterDist = Infinity - - for (const m of monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) continue - - const bounds = m.drawBounds - let minX: number - let maxX: number - let minY: number - let maxY: number - if (bounds !== undefined && bounds.w > 0 && bounds.h > 0) { - minX = bounds.x - 4 - maxX = bounds.x + bounds.w + 4 - minY = bounds.y - 4 - maxY = bounds.y + bounds.h + 4 - } else { - const art = artMap?.get(m.stats.id.toLowerCase()) - const standGroup = art !== undefined ? (art.groups[art.standOffset] ?? art.groups[0]) : undefined - const frame = standGroup?.[0] - if (frame !== undefined && frame.width > 0 && frame.height > 0) { - const drawX = frame.anchorX !== undefined ? m.x + frame.anchorX : m.x - frame.width / 2 - const drawY = frame.anchorY !== undefined ? m.y + frame.anchorY : m.y - frame.height + FEET_HEIGHT / 2 - minX = drawX - 4 - maxX = drawX + frame.width + 4 - minY = drawY - 4 - maxY = drawY + frame.height + 4 - } else if (m.stats?.isBoss === true || m.stats?.rank === 'boss') { - minX = m.x - 36 - maxX = m.x + 36 - minY = m.y - 96 - maxY = m.y + 8 - } else { - minX = m.x - 24 - maxX = m.x + 24 - minY = m.y - 64 - maxY = m.y + 8 - } - } - - if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { - const cx = (minX + maxX) * 0.5 - const cy = (minY + maxY) * 0.5 - const centerDist = Math.hypot(worldX - cx, worldY - cy) - if ( - bestMonster === null || - m.y > bestDepthY + 1 || - (Math.abs(m.y - bestDepthY) <= 1 && centerDist < bestCenterDist) - ) { - bestMonster = m - bestDepthY = m.y - bestCenterDist = centerDist - } - } - } - - return bestMonster - } - - /** - * Resolve the currently targeted monster (either target-locked by held left-click - * or hovered under the mouse cursor), or `null` if none / dead. - */ - getActiveTargetMonster(): Monster | null { - if ( - this.isLeftMouseDown && - this.activeClickType === 'monster' && - this.lockedMonster !== null - ) { - if ( - this.lockedMonster.state !== 'dead' && - this.lockedMonster.hp > 0 && - this.lockedMonster.stats?.killable !== false && - this.lockedMonster.stats?.inTown !== true - ) { - return this.lockedMonster - } - this.lockedMonster = null - } - if (this.hoveredMonster !== null) { - if ( - this.hoveredMonster.state !== 'dead' && - this.hoveredMonster.hp > 0 && - this.hoveredMonster.stats?.killable !== false && - this.hoveredMonster.stats?.inTown !== true - ) { - return this.hoveredMonster - } - this.hoveredMonster = null - } - return null - } - - /** - * Synchronize `hoveredMonster` and `hudManager.targetMonster` with the current - * pointer position and monster states. - */ - syncMonsterTargetHud( - camX = this.engine.world.player.x, - camY = this.engine.world.player.y - 16, - ): Monster | null { - let hudIntercepted = false - if (this.lastClientPos !== null) { - if (this.hudManager !== null) { - const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) - hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y) - } - if (hudIntercepted) { - this.hoveredMonster = null - } else { - const rect = this.canvas.getBoundingClientRect() - const zoom = this.camera?.zoom ?? 1 - const worldAtPointer = screenToWorld({ - clientX: this.lastClientPos.x, - clientY: this.lastClientPos.y, - rect, - canvas: this.canvas, - camX, - camY, - cameraZoom: zoom, - }) - this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) - } - } - - if (hudIntercepted && !(this.isLeftMouseDown && this.activeClickType === 'monster')) { - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - return null - } - - const activeTarget = this.getActiveTargetMonster() - if (this.hudManager !== null) { - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - return activeTarget - } - - private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean { - for (const npc of this.engine.npcEntities ?? []) { - const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY) - const distBody = Math.hypot(npc.x - worldX, (npc.y - 16) - worldY) - if (Math.min(distFeet, distBody) <= 36) return true - } - - const runtime = this.getRuntime() - if (runtime?.grid) { - for (const wp of runtime.waypoints ?? []) { - const pos = subTileToScene(wp.x, wp.y, runtime.grid) - if (Math.hypot(pos.x - worldX, pos.y - worldY) <= 48) return true - } - } - - if (this.findNearestStash(worldX, worldY, 48) !== null) return true - - if (runtime?.grid) { - const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null - if (activePortalMouth !== null) { - const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) - if (Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) <= 48) return true - } - } - - if (this.findMonsterAtWorld(worldX, worldY) !== null) return true - - for (const m of this.engine.world.monsters ?? []) { - if (m.state === 'dead' || m.hp <= 0) continue - const distFeet = Math.hypot(m.x - worldX, m.y - worldY) - const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY) - if (Math.min(distFeet, distBody) <= 36) return true - } - - if (this.engine.groundItems?.findAtPoint(worldX, worldY, 32)) return true - - return false - } - - syncLinkHoverAtWorld(worldX: number, worldY: number): void { - if (this.isHigherPriorityEntityAt(worldX, worldY)) { - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.canvas?.style) { - this.canvas.style.cursor = '' - } - return - } - - const runtime = this.getRuntime() - let bestWarp: { warp: SceneWarp; dist: number } | null = null - let bestEntrance: { entrance: SceneEntrance; dist: number } | null = null - if (runtime?.grid) { - for (const warp of runtime.warps ?? []) { - const res = hitTestWarp(warp, worldX, worldY, runtime.grid) - if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { - bestWarp = { warp, dist: res.dist } - } - } - for (const entrance of runtime.entrances ?? []) { - const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) - if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { - bestEntrance = { entrance, dist: res.dist } - } - } - } - if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { - this.hoveredWarp = bestWarp.warp - this.hoveredEntrance = null - } else if (bestEntrance !== null) { - this.hoveredWarp = null - this.hoveredEntrance = bestEntrance.entrance - } else { - this.hoveredWarp = null - this.hoveredEntrance = null - } - if (this.canvas?.style) { - this.canvas.style.cursor = (this.hoveredWarp !== null || this.hoveredEntrance !== null) ? 'pointer' : '' - } - } - - /** - * Render recycled overhead labels for on-screen NPCs, Town Portals, - * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. - */ - updateOverheadLabels( - camX = this.engine.world.player.x, - camY = this.engine.world.player.y - 16, - activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, - activePortalPosOverride?: { readonly x: number; readonly y: number } | null, - ): void { - updateSceneOverheadLabels( - this, - camX, - camY, - activePortalMouthOverride, - activePortalPosOverride, - ) - } - - findNearestStash(worldX: number, worldY: number, maxRadius = 48): TownStashEntity | null { - const runtime = this.getRuntime() - const stashes = runtime.stashes ?? [] - let best: TownStashEntity | null = null - let bestDist = maxRadius - for (const stash of stashes) { - const dist = Math.hypot(stash.x - worldX, stash.y - worldY) - if (dist <= bestDist) { - bestDist = dist - best = stash - } - } - return best - } - - openStash(stash: TownStashEntity): void { - const player = this.engine.world.player - player.facing = facingOf(stash.x - player.x, stash.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Open ${stash.nameEn}` - : `打开私人储物箱 (${stash.nameZh})` - this.playStashOpenSound() - if (this.hudManager && this.hudManager.leftPanel !== 'stash') { - this.hudManager.toggleLeftPanel('stash') - } - } - - /** - * Interact with or move towards a ground item to pick it up. - */ - pickupGroundItem(item: GroundItemEntity): void { - const player = this.engine.world.player - const dist = Math.hypot(item.x - player.x, item.y - player.y) - const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) - const isEn = getTblLang() === 'ENG' - const tickNowMs = (this.engine.world?.tick ?? 0) * 40 - if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { - this.navTarget = null - this.pendingInteraction = null - if (this.hoveredGroundItem?.id === item.id) { - this.hoveredGroundItem = null - } - player.facing = facingOf(item.x - player.x, item.y - player.y) - - if (item.isGold) { - if ( - this.hudManager && - this.engine.bag && - this.engine.bag.gold === 0 && - this.hudManager.inventory.gold > 0 && - this.hudManager.inventory.gold !== 850_000 - ) { - this.engine.bag.gold = this.hudManager.inventory.gold - } - const prevHudGold = this.hudManager?.inventory.gold ?? 0 - const res = this.engine.pickupGold(item.id) - if (res.success) { - if (this.hudManager) { - const playerLevel = this.engine.world.player?.level ?? 1 - const goldCap = getInventoryGoldLimit(playerLevel) - this.hudManager.inventory.playerLevel = playerLevel - const nextEngineGold = typeof this.engine.gold === 'number' ? this.engine.gold : 0 - this.hudManager.inventory.gold = Math.min( - goldCap, - nextEngineGold > 0 ? nextEngineGold : prevHudGold + res.amount, - ) - this.hudManager.syncPublishedState() - } - if (res.remaining && res.remaining > 0) { - this.status.textContent = isEn - ? `Picked up Gold: ${res.amount} (Remaining on ground: ${res.remaining})` - : `拾起金币:${res.amount}(地面剩余:${res.remaining})` - } else { - this.status.textContent = isEn - ? `Picked up Gold: ${res.amount}` - : `拾起金币:${res.amount}` - } - } else { - this.engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(item, tickNowMs) - this.playInventoryFullFeedback() - this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。' - this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。') - } - } else { - if (this.hudManager) { - const autoBelted = Boolean(this.hudManager.belt?.autoPlacePotion?.(item.item)) - if (!autoBelted) { - const uiItem = itemToUiInventoryItem(item.item, this.engine.dropTables) - const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem) - if (!placed) { - this.engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(item, tickNowMs) - this.playInventoryFullFeedback() - this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') - return - } - } - this.engine.groundItems.remove(item.id) - const gIdx = this.engine.ground.findIndex( - (g: { groundItemId?: string; item?: unknown }) => g.groundItemId === item.id || g.item === item.item, - ) - if (gIdx !== -1) { - this.engine.ground.splice(gIdx, 1) - } - this.engine.metrics.pickups += 1 - this.hudManager.syncPublishedState() - this.status.textContent = isEn - ? `Picked up item: ${item.name}` - : `拾起物品:${item.nameZh || item.name}` - } else { - const res = this.engine.pickupItem(item.id) - if (!res.success && res.reason === 'full') { - this.playInventoryFullFeedback() - this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') - } else if (res.success) { - this.status.textContent = isEn - ? `Picked up item: ${item.name}` - : `拾起物品:${item.nameZh || item.name}` - } - } - } - } else { - this.activeClickType = 'ground' - this.pendingInteraction = { kind: 'ground-item', item } - this.navTarget = { x: item.x, y: item.y } - this.lastDist = dist - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } - - /** - * Play synthesized Diablo II-style audio rejection feedback when inventory is full. - * Low descending refusal tone paired with delayed secondary touch landing thud. - */ - playInventoryFullFeedback(): void { - playInventoryFullFeedback(this.audioManager ?? sharedAudioManager) - } - - /** - * Display prominent Diablo II style on-screen notification banner with auto fade-out. - */ - showNotification(message: string, durationMs = 2000): void { - showNotification(message, durationMs, this.status) - } - - playStashOpenSound(): void { - playStashOpenSound(this.audioManager ?? sharedAudioManager) - } - - flushPendingActions(): void { - while (this.pendingActions.length > 0) { - const action = this.pendingActions.shift() - action?.() - } - } - - readonly onContextMenu = (e: MouseEvent): void => { - e.preventDefault() - } - - readonly onPointerDown = (e: PointerEvent): void => { - if (e.button !== 0 && e.button !== 2) return - if (this.isTravelling?.()) return - - // Check HUD interception - if (this.hudManager !== null) { - const logical = this.hudManager.clientToLogical(e.clientX, e.clientY) - if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) { - return - } - if (this.hudManager.worldPanels?.npcMenu) { - this.hudManager.worldPanels.npcMenu = null - this.hudManager.syncPublishedState?.() - } - } - - this.lastClientPos = { x: e.clientX, y: e.clientY } - const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) - - const runAction = (): void => { - if (this.isTravelling?.()) return - if (e.button === 0) { - this.isLeftMouseDown = true - if (this.input.shiftHeld) { - // R4: Shift-hold force stand still attack - this.activeClickType = 'none' - this.navTarget = null - this.pendingInteraction = null - const player = this.engine.world.player - player.facing = facingOf(worldX - player.x, worldY - player.y) - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - this.castSkill(leftSkillId, worldX, worldY) - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else { - // R3: Left-click click-to-move & entity interaction - this.handleLeftClickWorld(worldX, worldY) - } - } else if (e.button === 2) { - this.isRightMouseDown = true - if (this.input.shiftHeld) { - this.activeClickType = 'none' - this.navTarget = null - this.pendingInteraction = null - const player = this.engine.world.player - player.facing = facingOf(worldX - player.x, worldY - player.y) - const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - this.castSkill(rightSkillId, worldX, worldY) - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else { - this.handleRightClickWorld(worldX, worldY) - } - } - } - - if (this.queuePointerEvents) { - this.pendingActions.push(runAction) - } else { - runAction() - } - } - - readonly onPointerMove = (e: PointerEvent): void => { - this.lastClientPos = { x: e.clientX, y: e.clientY } - - if (this.hudManager !== null) { - const logical = this.hudManager.clientToLogical(e.clientX, e.clientY) - if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) { - this.hoveredMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (!(this.isLeftMouseDown && this.activeClickType === 'monster')) { - this.hudManager.targetMonster = null - } - if (this.canvas.style) { - this.canvas.style.cursor = '' - } - if (this.labelsContainer) { - this.updateOverheadLabels() - } - return - } - } - - const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) - this.hoveredMonster = this.findMonsterAtWorld(worldX, worldY) - const activeTarget = this.getActiveTargetMonster() - if (this.hudManager !== null) { - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null - this.syncLinkHoverAtWorld(worldX, worldY) - if (this.labelsContainer) { - this.updateOverheadLabels() - } - - if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') { - this.navTarget = { x: worldX, y: worldY } - } else if (this.isRightMouseDown) { - const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) { - this.navTarget = { x: worldX, y: worldY } - } - } - } - - readonly onPointerLeave = (e: PointerEvent): void => { - const target = e.relatedTarget as HTMLElement | null - if (target && target.closest?.('.d2-ground-label')) { - return - } - this.lastClientPos = null - this.hoveredMonster = null - this.lockedMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - if (this.canvas.style) { - this.canvas.style.cursor = '' - } - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - readonly onPointerUp = (e: PointerEvent): void => { - if (e.button === 0) { - this.isLeftMouseDown = false - this.activeClickType = 'none' - this.lockedMonster = null - } else if (e.button === 2) { - this.isRightMouseDown = false - if (!this.isLeftMouseDown) { - this.activeClickType = 'none' - } - } else { - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.activeClickType = 'none' - this.lockedMonster = null - } - this.syncMonsterTargetHud() - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - } - - handleRightClickWorld(worldX: number, worldY: number): void { - const player = this.engine.world.player - const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId)) { - // Walk/Run fallback (auras are active automatically in D2 1.13c) - this.pendingInteraction = null - this.navTarget = { x: worldX, y: worldY } - this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - return - } - - const range = getSkillRange(rightSkillId, this.hudManager) - - // 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px - const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) - let clickedMonster: Monster | null = hoveredAtClick - if (clickedMonster === null) { - let bestMonDist = 36 - for (const m of this.engine.world.monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) continue - const distFeet = Math.hypot(m.x - worldX, m.y - worldY) - const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY) - const minD = Math.min(distFeet, distBody) - if (minD <= bestMonDist) { - bestMonDist = minD - clickedMonster = m - } - } - } - - if (clickedMonster !== null) { - this.activeClickType = 'monster' - this.hoveredMonster = hoveredAtClick - const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) - if (distToPlayer <= range) { - this.navTarget = null - this.pendingInteraction = null - player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y) - this.castSkill(rightSkillId, clickedMonster.x, clickedMonster.y) - } else { - this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId } - this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - return - } - - // 2. Ground clicked with active right skill - if (rightSkillId === 0) { - const weapon = this.hudManager?.inventory?.equipped?.weapon1 - const weaponInfo = getAttackWeaponInfo(weapon) - if (weaponInfo.isRanged) { - this.navTarget = null - this.pendingInteraction = null - this.castSkill(rightSkillId, worldX, worldY) - } else { - this.activeClickType = 'ground' - this.pendingInteraction = null - this.navTarget = { x: worldX, y: worldY } - this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } else { - this.navTarget = null - this.pendingInteraction = null - this.castSkill(rightSkillId, worldX, worldY) - } - } - - interactWithNpc(npc: NpcEntity): void { - const player = this.engine.world.player - player.facing = facingOf(npc.x - player.x, npc.y - player.y) - if (npc.def.questId !== null) this.engine.questLog.accept(npc.def.questId) - this.engine.dialog = npcDialog(npc.def, this.engine.questLog) - const runtime = this.getRuntime() - const actIndex = Math.max(0, (runtime.act ?? 1) - 1) - const descriptor = getTownNpcDescriptorByName(npc.def.name, actIndex) - this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}` - - if (descriptor) { - if (descriptor.canHeal) { - if (player.maxHp !== undefined) player.hp = player.maxHp - if (player.maxMana !== undefined) player.mana = player.maxMana - if ((player as any).maxStamina !== undefined) (player as any).stamina = (player as any).maxStamina - if (this.hudManager) { - this.hudManager.hp = player.maxHp ?? this.hudManager.maxHp - this.hudManager.mana = player.maxMana ?? this.hudManager.maxMana - this.hudManager.stamina = this.hudManager.maxStamina - } - } - if (this.hudManager) { - if (this.status && 'hidden' in this.status) { - this.status.hidden = true - } - let menuX = 400 - let menuY = 180 - const rect = this.canvas?.getBoundingClientRect?.() - if ( - rect && - rect.width > 0 && - rect.height > 0 && - typeof this.hudManager.clientToLogical === 'function' - ) { - const camX = this.engine.world.player.x - const camY = this.engine.world.player.y - 16 - const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1 - const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1 - const zoom = this.camera?.zoom ?? 1 - // D2Client.dll 0x6faf6880 (0x6faf68de: add edi, -150; cmp eax, 20): - // Project 150 world pixels above NPC feet so the dialog box sits cleanly above the NPC sprite - const anchorDomX = rect.left + rect.width * 0.5 + (npc.x - camX) * zoom * scaleX - const anchorDomY = rect.top + rect.height * 0.5 + (npc.y - 150 - camY) * zoom * scaleY - const logicalAnchor = this.hudManager.clientToLogical(anchorDomX, anchorDomY) - menuX = Math.round(logicalAnchor.x) - menuY = Math.max(20, Math.round(logicalAnchor.y)) - } else if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') { - const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) - menuX = logical.x - menuY = logical.y - } - if (this.hudManager.worldPanels) { - if (this.engine.dropTables) { - this.hudManager.worldPanels.vendorSession.setDropTables(this.engine.dropTables) - } - this.hudManager.worldPanels.vendorCharLevel = (player as any).level ?? this.hudManager.worldPanels.vendorCharLevel ?? 85 - this.hudManager.worldPanels.openNpcMenu(descriptor, menuX, menuY) - } - this.hudManager.syncPublishedState?.() - } - } - } - - handleLeftClickWorld(worldX: number, worldY: number): void { - const player = this.engine.world.player - this.lockedMonster = null - - // 1. Check NPC: within ~32 px - let clickedNpc: NpcEntity | null = null - let bestNpcDist = 36 - for (const npc of this.engine.npcEntities) { - const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY) - const distBody = Math.hypot(npc.x - worldX, (npc.y - 16) - worldY) - const minD = Math.min(distFeet, distBody) - if (minD <= bestNpcDist) { - bestNpcDist = minD - clickedNpc = npc - } - } - - if (clickedNpc !== null) { - this.activeClickType = 'npc' - const distToPlayer = Math.hypot(clickedNpc.x - player.x, clickedNpc.y - player.y) - if (distToPlayer <= 80) { // talkRadius = 80 - this.navTarget = null - this.pendingInteraction = null - this.interactWithNpc(clickedNpc) - } else { - ;(this.engine as any).dialog = null - this.pendingInteraction = { kind: 'npc', npc: clickedNpc } - this.navTarget = { x: clickedNpc.x, y: clickedNpc.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - // 2. Check Waypoint: within ~48 px - const runtime = this.getRuntime() - let clickedWp: { wp: SceneWaypoint; pos: { x: number; y: number } } | null = null - let bestWpDist = 48 - for (const wp of runtime.waypoints) { - const pos = subTileToScene(wp.x, wp.y, runtime.grid) - const dist = Math.hypot(pos.x - worldX, pos.y - worldY) - if (dist <= bestWpDist) { - bestWpDist = dist - clickedWp = { wp, pos } - } - } - - if (clickedWp !== null) { - this.activeClickType = 'waypoint' - const distToPlayer = Math.hypot(clickedWp.pos.x - player.x, clickedWp.pos.y - player.y) - if (distToPlayer <= 80) { // interactionRange = 80 - this.navTarget = null - this.pendingInteraction = null - player.facing = facingOf(clickedWp.pos.x - player.x, clickedWp.pos.y - player.y) - if (this.waypointNetwork.activate(clickedWp.wp.waypointId)) { - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level - this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - if (this.hudManager) { - this.hudManager.worldPanels.setCurrentWaypoint(clickedWp.wp.waypointId, runtime.levelId) - this.hudManager.worldPanels.selectedActTab = runtime.act - if (this.hudManager.leftPanel !== 'waypoint') { - this.hudManager.toggleLeftPanel('waypoint') - } - } - } else { - this.pendingInteraction = { kind: 'waypoint', waypoint: clickedWp.wp, worldPos: clickedWp.pos } - this.navTarget = { x: clickedWp.pos.x, y: clickedWp.pos.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - // 3. Check Town Stash: within ~48 px (clickRadiusPx = 48) - const clickedStash = this.findNearestStash(worldX, worldY, 48) - if (clickedStash !== null) { - this.activeClickType = 'stash' - const distToPlayer = Math.hypot(clickedStash.x - player.x, clickedStash.y - player.y) - if (distToPlayer <= clickedStash.interactionRangePx) { - this.navTarget = null - this.pendingInteraction = null - this.openStash(clickedStash) - } else { - this.pendingInteraction = { kind: 'stash', stash: clickedStash } - this.navTarget = { x: clickedStash.x, y: clickedStash.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - // 3b. Check Town Portal: within ~48 px of portal vertical oval [portalPos.y - 80, portalPos.y] - const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null - if (activePortalMouth !== null) { - const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) - const minPortalDist = Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) - if (minPortalDist <= 48) { - this.activeClickType = 'portal' - const distToPlayer = Math.hypot(portalPos.x - player.x, portalPos.y - player.y) - if (distToPlayer <= 80) { // OperateRange = 2 tiles (~80 px) - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(portalPos.x - player.x, portalPos.y - player.y) - this.onEnterPortal?.() - } else { - this.pendingInteraction = { kind: 'portal', worldPos: portalPos } - this.navTarget = { x: portalPos.x, y: portalPos.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - } - - // 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px - const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) - let clickedMonster: Monster | null = hoveredAtClick - if (clickedMonster === null) { - let bestMonDist = 36 - for (const m of this.engine.world.monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) continue - const distFeet = Math.hypot(m.x - worldX, m.y - worldY) - const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY) - const minD = Math.min(distFeet, distBody) - if (minD <= bestMonDist) { - bestMonDist = minD - clickedMonster = m - } - } - } - - if (clickedMonster !== null) { - this.activeClickType = 'monster' - this.lockedMonster = clickedMonster - this.hoveredMonster = hoveredAtClick - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - const range = getSkillRange(leftSkillId, this.hudManager) - const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) - if (distToPlayer <= range) { - this.navTarget = null - this.pendingInteraction = null - player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y) - this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y) - } else { - this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId } - this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - return - } - - // 5. Check Ground Item: within ~32 px - const clickedItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) - if (clickedItem) { - this.pickupGroundItem(clickedItem) - return - } - - // 5b. Check SceneWarp & SceneEntrance (LvlWarp.txt bounding box + radial proximity) - if (runtime.grid) { - let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null - for (const warp of runtime.warps ?? []) { - const res = hitTestWarp(warp, worldX, worldY, runtime.grid) - if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { - bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } - } - } - - let bestEntrance: { entrance: SceneEntrance; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null - for (const entrance of runtime.entrances ?? []) { - const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) - if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { - bestEntrance = { entrance, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } - } - } - - if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { - this.activeClickType = 'warp' - ;(this.engine as any).dialog = null - const { warp, anchorPos, arrivePos } = bestWarp - if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `进入:${getLevelNames(warp.toLevelId).nameZh}` - this.onEnterWarp?.(warp) - } else { - this.pendingInteraction = { kind: 'warp', warp, anchorPos, arrivePos } - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - if (bestEntrance !== null) { - this.activeClickType = 'entrance' - ;(this.engine as any).dialog = null - const { entrance, anchorPos, arrivePos } = bestEntrance - if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `进入:${getLevelNames(entrance.toLevelId).nameZh}` - this.onEnterEntrance?.(entrance) - } else { - this.pendingInteraction = { kind: 'entrance', entrance, anchorPos, arrivePos } - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - } - - // 6. Pure ground click to move: - this.activeClickType = 'ground' - ;(this.engine as any).dialog = null - this.pendingInteraction = null - this.navTarget = { x: worldX, y: worldY } - this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - - getPointerWorldCoords(clientX: number, clientY: number): { x: number; y: number } { - const rect = this.canvas.getBoundingClientRect() - const camX = this.engine.world.player.x - const camY = this.engine.world.player.y - 16 - return screenToWorld({ - clientX, - clientY, - rect, - canvas: this.canvas, - camX, - camY, - cameraZoom: this.camera.zoom, - }) - } - - castSkill( - skillId: number, - targetX: number, - targetY: number, - options?: { - equippedWeapon?: any - equippedAmmo?: any - hasAmmo?: boolean - }, - ): boolean { - if (typeof (this.engine as any)?.castSceneSkill === 'function') { - return (this.engine as any).castSceneSkill(skillId, targetX, targetY, { - engine: this.engine, - runtime: this.getRuntime(), - hudManager: this.hudManager, - status: this.status, - playerAnimator: this.playerAnimator, - character: this.getCharacter ? this.getCharacter() : undefined, - equippedWeapon: options?.equippedWeapon, - equippedAmmo: options?.equippedAmmo, - hasAmmo: options?.hasAmmo, - waypointNetwork: this.waypointNetwork, - openStash: (s: any) => this.openStash(s), - portalSlot: this.portalSlot, - onEnterPortal: this.onEnterPortal, - onCastTownPortal: this.onCastTownPortal, - onEnterWarp: this.onEnterWarp, - onEnterEntrance: this.onEnterEntrance, - }) - } - return false - } - - clearTarget(): void { - this.pendingActions.length = 0 - this.navTarget = null - this.pendingInteraction = null - this.activeClickType = 'none' - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.lastClientPos = null - this.hoveredMonster = null - this.lockedMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - if (this.canvas?.style) { - this.canvas.style.cursor = '' - } - this.stuckTicks = 0 - this.lastDist = -1 - this.lastPlayerPos = null - ;(this.engine as any).dialog = null - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - tick(): { movement: { x: number; y: number }; attacking: boolean } { - this.flushPendingActions() - if (this.isTravelling?.()) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - - const keyMovement = this.input.movement() - const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0 - - if (isKeyMoving) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - return { movement: keyMovement, attacking: false } - } - - const player = this.engine.world.player - if (!player.alive || (!this.isLeftMouseDown && !this.isRightMouseDown && !this.input.shiftHeld)) { - if (player.infernoChanneling) { - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - } - } - - // Shift-Hold Stand Still Priority in tick() - if (this.input.shiftHeld) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - if (this.isLeftMouseDown && this.lastClientPos !== null && player.cooldown === 0) { - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - this.castSkill(leftSkillId, worldX, worldY) - } - return { movement: { x: 0, y: 0 }, attacking: false } - } - - // Check continuous holding - if (this.isLeftMouseDown && this.lastClientPos !== null) { - if (this.activeClickType === 'ground') { - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - this.navTarget = { x: worldX, y: worldY } - } else if (this.activeClickType === 'monster') { - const locked = this.lockedMonster - if (locked !== null) { - if (locked.state === 'dead' || locked.hp <= 0 || locked.stats?.killable === false) { - this.lockedMonster = null - this.pendingInteraction = null - this.navTarget = null - } else if (this.pendingInteraction === null) { - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - const range = getSkillRange(leftSkillId, this.hudManager) - const distToPlayer = Math.hypot(locked.x - player.x, locked.y - player.y) - if (distToPlayer <= range) { - if (player.cooldown === 0) { - player.facing = facingOf(locked.x - player.x, locked.y - player.y) - this.castSkill(leftSkillId, locked.x, locked.y) - } - } else { - this.pendingInteraction = { kind: 'monster', monster: locked, skillId: leftSkillId } - this.navTarget = { x: locked.x, y: locked.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else if (this.pendingInteraction === null && player.cooldown === 0) { - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - this.castSkill(leftSkillId, worldX, worldY) - } - } - } else if (this.isRightMouseDown && this.lastClientPos !== null) { - const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId)) { - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - this.navTarget = { x: worldX, y: worldY } - } else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') { - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - this.navTarget = { x: worldX, y: worldY } - } else if ( - player.cooldown === 0 && - rightSkillId !== 43 && - rightSkillId !== 219 && - rightSkillId !== 220 - ) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) - this.castSkill(rightSkillId, worldX, worldY) - } - } - - // Process pendingInteraction arrival - if (this.pendingInteraction !== null) { - if (this.pendingInteraction.kind === 'npc') { - const npc = this.pendingInteraction.npc - const dist = Math.hypot(npc.x - player.x, npc.y - player.y) - if (dist <= 80) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.interactWithNpc(npc) - } else { - this.navTarget = { x: npc.x, y: npc.y } - } - } else if (this.pendingInteraction.kind === 'waypoint') { - const { waypoint, worldPos } = this.pendingInteraction - const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y) - if (dist <= 80) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) - if (this.waypointNetwork.activate(waypoint.waypointId)) { - const rt = this.getRuntime() - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(rt.levelId, 'ENG') : rt.level - this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - if (this.hudManager) { - this.hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, this.getRuntime().levelId) - this.hudManager.worldPanels.selectedActTab = this.getRuntime().act - if (this.hudManager.leftPanel !== 'waypoint') { - this.hudManager.toggleLeftPanel('waypoint') - } - } - } else { - this.navTarget = { x: worldPos.x, y: worldPos.y } - } - } else if (this.pendingInteraction.kind === 'stash') { - const stash = this.pendingInteraction.stash - const dist = Math.hypot(stash.x - player.x, stash.y - player.y) - if (dist <= stash.interactionRangePx) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.openStash(stash) - } else { - this.navTarget = { x: stash.x, y: stash.y } - } - } else if (this.pendingInteraction.kind === 'portal') { - const { worldPos } = this.pendingInteraction - const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y) - if (dist <= 80) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) - this.onEnterPortal?.() - } else { - this.navTarget = { x: worldPos.x, y: worldPos.y } - } - } else if (this.pendingInteraction.kind === 'warp') { - const { warp, anchorPos, arrivePos } = this.pendingInteraction - const runtime = this.getRuntime() - if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `进入:${getLevelNames(warp.toLevelId).nameZh}` - this.onEnterWarp?.(warp) - } else { - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - } - } else if (this.pendingInteraction.kind === 'entrance') { - const { entrance, anchorPos, arrivePos } = this.pendingInteraction - const runtime = this.getRuntime() - if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `进入:${getLevelNames(entrance.toLevelId).nameZh}` - this.onEnterEntrance?.(entrance) - } else { - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - } - } else if (this.pendingInteraction.kind === 'monster') { - const monster = this.pendingInteraction.monster - if (monster.state === 'dead' || monster.hp <= 0) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - } else { - const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47) - const range = getSkillRange(skillId, this.hudManager) - const dist = Math.hypot(monster.x - player.x, monster.y - player.y) - if (dist <= range) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - player.facing = facingOf(monster.x - player.x, monster.y - player.y) - this.castSkill(skillId, monster.x, monster.y) - } else { - this.navTarget = { x: monster.x, y: monster.y } - } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else if (this.pendingInteraction.kind === 'ground-item') { - const item = this.pendingInteraction.item - const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) - if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.pickupGroundItem(item) - } else { - this.navTarget = { x: item.x, y: item.y } - } - } - } - - // Move toward navTarget if active - if (this.navTarget !== null) { - const dx = this.navTarget.x - player.x - const dy = this.navTarget.y - player.y - const dist = Math.hypot(dx, dy) - if (dist <= 8) { - this.navTarget = null - this.stuckTicks = 0 - this.lastDist = -1 - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - - const playerStep = this.lastPlayerPos !== null - ? Math.hypot(player.x - this.lastPlayerPos.x, player.y - this.lastPlayerPos.y) - : Infinity - this.lastPlayerPos = { x: player.x, y: player.y } - - if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5 && playerStep < 0.5) { - this.stuckTicks++ - if (this.stuckTicks >= 12) { - this.navTarget = null - this.pendingInteraction = null - this.stuckTicks = 0 - this.lastDist = -1 - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - } else { - this.stuckTicks = 0 - this.lastDist = dist - } - - const steerTarget = resolveObstacleAwareSteerTarget( - player.x, - player.y, - this.navTarget, - this.getRuntime()?.grid, - ) - const steerDx = steerTarget.x - player.x - const steerDy = steerTarget.y - player.y - const steerDist = Math.hypot(steerDx, steerDy) - const normDist = steerDist > 1e-4 ? steerDist : dist - const moveDx = steerDist > 1e-4 ? steerDx : dx - const moveDy = steerDist > 1e-4 ? steerDy : dy - - const runFactor = (this.hudManager && this.hudManager.isRunning) ? 1.5 : 1.0 - return { - movement: { - x: (moveDx / normDist) * runFactor, - y: (moveDy / normDist) * runFactor, - }, - attacking: false, - } - } - - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - - attach(): void { - this.canvas.addEventListener('contextmenu', this.onContextMenu) - this.canvas.addEventListener('pointerdown', this.onPointerDown) - this.canvas.addEventListener('pointermove', this.onPointerMove) - this.canvas.addEventListener('pointerleave', this.onPointerLeave) - this.canvas.addEventListener('pointerup', this.onPointerUp) - this.canvas.addEventListener('pointercancel', this.onPointerUp) - - if (typeof window !== 'undefined') { - window.addEventListener('pointerup', this.onPointerUp) - window.addEventListener('pointercancel', this.onPointerUp) - } - } - - detach(): void { - this.canvas.removeEventListener('contextmenu', this.onContextMenu) - this.canvas.removeEventListener('pointerdown', this.onPointerDown) - this.canvas.removeEventListener('pointermove', this.onPointerMove) - this.canvas.removeEventListener('pointerleave', this.onPointerLeave) - this.canvas.removeEventListener('pointerup', this.onPointerUp) - this.canvas.removeEventListener('pointercancel', this.onPointerUp) - - if (typeof window !== 'undefined') { - window.removeEventListener('pointerup', this.onPointerUp) - window.removeEventListener('pointercancel', this.onPointerUp) - } - - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.lastClientPos = null - this.clearTarget() - for (const label of this.labelPool) { - label.hidden = true - } - } -} - -import type { SceneSource } from '../view/scene-source.ts' -import { - screenToSubTile, - VIEWPORT_800x600, - type ViewportProfile, -} from '../view/viewport-profile.ts' -import { - asSubTile, - asUnitId, - type ClientCommand, -} from '../../netproto/index.ts' - -export function setupMouseControls(context: MouseControllerContext): SceneMouseController { - const controller = new SceneMouseController(context) - controller.attach() - return controller -} - -export function resolveClientCanvasPoint( - event: { readonly clientX: number; readonly clientY: number }, - canvasRect: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }, - profile: ViewportProfile = VIEWPORT_800x600, -): { x: number; y: number } { - const scaleX = canvasRect.width > 0 ? profile.width / canvasRect.width : 1 - const scaleY = canvasRect.height > 0 ? profile.height / canvasRect.height : 1 - return { - x: (event.clientX - canvasRect.left) * scaleX, - y: (event.clientY - canvasRect.top) * scaleY, - } -} - -export interface OnlineClickTranslationOptions { - readonly button: 'left' | 'right' - readonly shiftHeld?: boolean | undefined - readonly runMode?: boolean | undefined - readonly hasLeftSkill?: boolean | undefined -} - -/** - * Translate a viewport-normalized screen click `(screenX, screenY)` against a `SceneSource` - * into the canonical `ClientCommand` (movement, target interaction, item pickup, or skill cast). - */ -export function translateScreenClickToClientCommand( - screenX: number, - screenY: number, - source: SceneSource, - options: OnlineClickTranslationOptions, -): ClientCommand { - const profile = source.getViewportProfile() - const playerSub = source.getPlayerSubTile() - const hitUnit = source.pickUnitAtScreen(screenX, screenY, 36, profile) - const targetSub = screenToSubTile(screenX, screenY, playerSub.x, playerSub.y, profile) - const subX = asSubTile(Math.max(0, Math.round(targetSub.x))) - const subY = asSubTile(Math.max(0, Math.round(targetSub.y))) - - if (options.button === 'right') { - if (hitUnit && !options.shiftHeld && (hitUnit.unitKind === 'monster' || hitUnit.unitKind === 'player')) { - return { - type: 'CastRightSkillOnTarget', - unitKind: hitUnit.unitKind, - unitId: asUnitId(hitUnit.unitId), - } - } - return { - type: 'CastRightSkillOnLocation', - x: subX, - y: subY, - } - } - - // Left click with Shift held: stand still and cast left skill on location/target - if (options.shiftHeld) { - if (hitUnit && (hitUnit.unitKind === 'monster' || hitUnit.unitKind === 'player')) { - return { - type: 'CastLeftSkillOnTarget', - unitKind: hitUnit.unitKind, - unitId: asUnitId(hitUnit.unitId), - } - } - return { - type: 'CastLeftSkillOnLocation', - x: subX, - y: subY, - } - } - - if (hitUnit) { - if (hitUnit.unitKind === 'item') { - return { - type: 'PickUpItem', - itemId: asUnitId(hitUnit.unitId), - toCursor: false, - } - } - if (hitUnit.unitKind === 'object') { - return { - type: 'OperateObject', - objectId: asUnitId(hitUnit.unitId), - } - } - if (hitUnit.unitKind === 'monster') { - if (options.hasLeftSkill) { - return { - type: 'CastLeftSkillOnTarget', - unitKind: 'monster', - unitId: asUnitId(hitUnit.unitId), - } - } - return { - type: (options.runMode ?? true) ? 'RunToTarget' : 'WalkToTarget', - unitKind: 'monster', - unitId: asUnitId(hitUnit.unitId), - } - } - } - - return { - type: (options.runMode ?? true) ? 'RunToLocation' : 'WalkToLocation', - x: subX, - y: subY, - } -} - diff --git a/src/client/scene/object-animation.ts b/src/client/scene/object-animation.ts deleted file mode 100644 index 8637977..0000000 --- a/src/client/scene/object-animation.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { AnimatableTile, AnimatedFrame } from '../../common/world/animated-tiles.ts' -import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/objects.ts' -import type { AtlasFrame } from '../render/atlas.ts' - -/** One tile to draw. */ -export interface Drawable extends AnimatableTile { - frame: AtlasFrame - readonly x: number - readonly y: number - readonly cellX: number - readonly cellY: number - /** Atlas page index this frame lives on. */ - page: number - readonly animatedFrames?: readonly AnimatedFrame[] | undefined - readonly roofHeight?: number -} - -/** One placed frame of an animated object. */ -export interface AnimatedObjectFrame { - readonly frame: AtlasFrame - readonly page: number - readonly offsetX: number - readonly offsetY: number -} - -/** One object sprite to draw. */ -export interface ObjectDrawable { - readonly id?: number | undefined - readonly objectsTxtId?: number | undefined - readonly token?: string | undefined - readonly name?: string | null | undefined - readonly kind?: 'object' | 'npc' | undefined - readonly blendMode?: 'normal' | 'additive' | undefined - readonly trans?: number | undefined - frame: AtlasFrame - x: number - y: number - readonly depth: number - /** Object atlas page index this frame lives on. */ - page: number - readonly baseX?: number | undefined - readonly baseY?: number | undefined - readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined - readonly frameDurationMs?: number | undefined - readonly cycleAnim?: boolean | undefined - readonly phaseOffsetMs?: number | undefined - currentFrameIndex?: number | undefined -} - -/** - * Tokens that represent environmental additive-blend illumination and magic objects. - * Ground truth parity (1.13c): Window light rays (11, 12 in Act 4), light beams - * (R1..R5, 1R..4R with Trans=7), arcane orifice (HA with Trans=1), and fire jets. - */ -export { ADDITIVE_OBJECT_TOKENS } - -/** - * Tokens that represent persistent environmental fire and illumination objects. - * Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires - * (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping. - */ -export const PERSISTENT_ILLUMINATION_TOKENS = new Set([ - 'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL', -]) - -/** - * SplitMix64 based spatial phase offset for unsynchronized animated map objects. - * Prevents mechanical synchronization across multiple torches or campfires in a scene. - * - * @param x - object sub-tile or world x - * @param y - object sub-tile or world y - * @param totalDurationMs - total duration of one animation cycle in milliseconds - * @returns phase offset in milliseconds within [0, totalDurationMs) - */ -export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number { - if (totalDurationMs <= 0) return 0 - let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0 - h = (h ^ (h >>> 16)) >>> 0 - h = Math.imul(h, 0x45d9f3b) >>> 0 - h = (h ^ (h >>> 16)) >>> 0 - return ((h % 1000000) / 1000000) * totalDurationMs -} - -/** - * Update one animated object's current frame based on time and spatial phase. - * - * @param obj - object drawable - * @param clockMs - current clock time in milliseconds - * @returns true if frame changed - */ -export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean { - const frames = obj.animatedFrames - if (!frames || frames.length <= 1) return false - const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40 - const phase = obj.phaseOffsetMs ?? 0 - const elapsed = Math.max(0, clockMs + phase) - const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase()) - const cycleAnim = isPersistent || obj.cycleAnim !== false - const targetIndex = cycleAnim - ? Math.floor(elapsed / duration) % frames.length - : Math.min(frames.length - 1, Math.floor(elapsed / duration)) - - if (targetIndex === obj.currentFrameIndex) return false - obj.currentFrameIndex = targetIndex - const targetFrame = frames[targetIndex] - if (targetFrame !== undefined) { - obj.frame = targetFrame.frame - obj.page = targetFrame.page - if (obj.baseX !== undefined && obj.baseY !== undefined) { - obj.x = obj.baseX + targetFrame.offsetX - obj.y = obj.baseY + targetFrame.offsetY - } - return true - } - return false -} - -/** - * Update an array of animated objects according to millisecond clock. - * - * @param objects - array of object drawables - * @param clockMs - current clock time in milliseconds - * @returns number of objects whose active frame changed - */ -export function updateAnimatedObjects( - objects: readonly ObjectDrawable[], - clockMs: number, -): number { - let updated = 0 - for (let i = 0; i < objects.length; i += 1) { - if (updateAnimatedObject(objects[i]!, clockMs)) { - updated += 1 - } - } - return updated -} diff --git a/src/client/scene/overhead-labels.ts b/src/client/scene/overhead-labels.ts deleted file mode 100644 index 0e62919..0000000 --- a/src/client/scene/overhead-labels.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { type AudioManager, sharedAudioManager } from '../audio/manager.ts' -import type { Monster, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import type { GroundItemEntity } from '../../common/items/ground-items.ts' -import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts' -import { getTownNpcDescriptorByName, npcDisplayName } from '../../common/units/npc-table.ts' -import type { TownPortalSlot } from '../../common/world/portal.ts' -import { getTblLang } from '../i18n/lang.ts' -import type { ViewportCamera } from '../sim/camera.ts' -import { - computeInitialGroundLabelLayouts, - resolveLadderCollisions, - type GroundLabelOverlay, -} from '../ui/ground-labels.ts' -import type { HudManager } from '../ui/hud-manager.ts' -import { - getEntranceLabelWorldPos, - getWarpLabelWorldPos, - screenToWorld, - subTileToScene, - type SceneEntrance, - type SceneWarp, -} from '../../common/world/scene-links-nav.ts' -import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts' -import { - monsterArtStatusText, - monsterPlanStatusText, -} from './scene-state.ts' - -export { monsterArtStatusText, monsterPlanStatusText } - -export interface NpcLabelProjectionParams { - npc: { - x: number - y: number - frame?: { - readonly width?: number - readonly height?: number - readonly offsetX?: number - readonly offsetY?: number - } | null | undefined - } - camX: number - camY: number - cameraZoom: number - canvas: { width: number; height: number } - rect: { left: number; top: number; width: number; height: number } - markerHeight?: number -} - -export interface NpcLabelProjectionResult { - domX: number - domY: number - fontSize: number - visible: boolean -} - -export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult { - const markerHeight = params.markerHeight ?? MARKER_HEIGHT - const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1 - const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1 - - const frame = params.npc.frame - const topX = frame != null - ? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2 - : params.npc.x - const topY = frame != null - ? params.npc.y + (frame.offsetY ?? 0) - : params.npc.y - markerHeight - - const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX - const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY - - const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100 - const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1) - const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom)))) - - return { domX, domY: domY - 2, fontSize, visible } -} - -/** - * Play synthesized Diablo II-style audio rejection feedback when inventory is full. - * Low descending refusal tone paired with delayed secondary touch landing thud. - */ -export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void { - if (typeof audioManager?.playSfx === 'function') { - audioManager.playSfx('inventory_full') - return - } - audioManager?.playTone?.({ - type: 'sawtooth', - from: 175, - to: 90, - gain: 0.3, - durationS: 0.3, - delayS: 0, - }) - audioManager?.playTone?.({ - type: 'triangle', - from: 110, - to: 45, - gain: 0.2, - durationS: 0.1, - delayS: 0.35, - }) -} - -/** - * Display prominent Diablo II style on-screen notification banner with auto fade-out. - */ -export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void { - if (status) { - status.textContent = message - } - if (typeof document !== 'undefined') { - let banner = document.getElementById('d2-center-notification') - if (!banner && document.body) { - banner = document.createElement('div') - banner.id = 'd2-center-notification' - banner.style.position = 'fixed' - banner.style.top = '18%' - banner.style.left = '50%' - banner.style.transform = 'translateX(-50%)' - banner.style.color = '#e03030' - banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)' - banner.style.fontSize = '20px' - banner.style.fontWeight = 'bold' - banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000' - banner.style.pointerEvents = 'none' - banner.style.zIndex = '99999' - banner.style.transition = 'opacity 0.4s ease-out' - document.body.appendChild(banner) - } - if (banner) { - banner.textContent = message - banner.style.opacity = '1' - clearTimeout((banner as any)._timeout) - ;(banner as any)._timeout = setTimeout(() => { - if (banner) banner.style.opacity = '0' - }, durationMs) - } - } -} - -;(globalThis as unknown as { - __d2PlayInventoryFullFeedback?: () => void - __d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void -}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback -;(globalThis as unknown as { - __d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void -}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => { - showNotification(message, durationMs, status as HTMLElement | undefined) -} - -export function allocateLabelElement( - container: HTMLElement, - labelPool: HTMLDivElement[], -): HTMLDivElement { - let label: HTMLDivElement - if (typeof document !== 'undefined' && typeof document.createElement === 'function') { - label = document.createElement('div') - } else { - label = { - tagName: 'DIV', - textContent: '', - hidden: false, - className: '', - dataset: {}, - style: {}, - } as unknown as HTMLDivElement - } - label.style.position = 'absolute' - label.style.transform = 'translate(-50%, -100%)' - label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' - label.style.whiteSpace = 'nowrap' - label.style.pointerEvents = 'none' - labelPool.push(label) - if (typeof container.appendChild === 'function') { - container.appendChild(label) - } else if (Array.isArray((container as any).children)) { - ;(container as any).children.push(label) - } - return label -} - -export interface OverheadLabelsControllerHost { - readonly labelsContainer?: HTMLElement | null | undefined - readonly canvas: HTMLCanvasElement - readonly camera: ViewportCamera - readonly engine: GameEngine - readonly hudManager: HudManager | null - readonly portalSlot?: TownPortalSlot | undefined - readonly labelPool: HTMLDivElement[] - lastClientPos: { x: number; y: number } | null - hoveredMonster: Monster | null - hoveredGroundItem: GroundItemEntity | null - hoveredWarp: SceneWarp | null - hoveredEntrance: SceneEntrance | null - getRuntime(): MapRuntime - findMonsterAtWorld(worldX: number, worldY: number): Monster | null - syncLinkHoverAtWorld(worldX: number, worldY: number): void -} - -/** - * Render recycled overhead labels for on-screen NPCs, Town Portals, - * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. - */ -export function updateOverheadLabels( - host: OverheadLabelsControllerHost, - camX = host.engine.world.player.x, - camY = host.engine.world.player.y - 16, - activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, - activePortalPosOverride?: { readonly x: number; readonly y: number } | null, -): void { - const container = host.labelsContainer - if (!container) return - - const runtime = host.getRuntime() - let labelCount = 0 - const rect = host.canvas.getBoundingClientRect() - const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1 - const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1 - const zoom = host.camera?.zoom ?? 1 - const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px` - - if (host.lastClientPos !== null) { - let hudIntercepted = false - if (host.hudManager !== null) { - const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y) - hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y) - } - if (hudIntercepted) { - host.hoveredMonster = null - host.hoveredGroundItem = null - host.hoveredWarp = null - host.hoveredEntrance = null - if (host.canvas?.style) { - host.canvas.style.cursor = '' - } - } else { - const worldAtPointer = screenToWorld({ - clientX: host.lastClientPos.x, - clientY: host.lastClientPos.y, - rect, - canvas: host.canvas, - camX, - camY, - cameraZoom: zoom, - }) - host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) - host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null - host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y) - } - } - - const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase() - const currentLang = getTblLang() - const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1) - for (const npc of host.engine.npcEntities ?? []) { - if (npc.def.name.toLowerCase() === 'dummy') continue - const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc) - const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name - if ( - activeNpcMenuName && - (npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName) - ) { - continue - } - const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x - const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT - const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY - - if (domX <= -100 || domX >= rect.width + 100) continue - if (domY <= -100 || domY >= rect.height + 100) continue - const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) - if (label.textContent !== npcLabelText) label.textContent = npcLabelText - label.className = 'd2-overhead-label d2-npc-label' - if (label.dataset) { - label.dataset.kind = 'npc' - delete label.dataset.toLevelId - delete label.dataset.hovered - } - label.style.color = '#c8a15a' - label.style.background = 'transparent' - label.style.border = 'none' - label.style.padding = '0' - label.style.borderRadius = '0' - label.style.fontWeight = 'normal' - label.style.boxShadow = 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - - const portalMouth = activePortalMouthOverride !== undefined - ? activePortalMouthOverride - : (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null) - const portalPos = activePortalPosOverride !== undefined - ? activePortalPosOverride - : (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null) - - if (portalMouth !== null && portalPos !== null) { - const topX = portalPos.x - const topY = portalPos.y - 94 - const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY - if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) { - const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) - const portalLabelText = currentLang === 'ENG' - ? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}` - : `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}` - if (label.textContent !== portalLabelText) label.textContent = portalLabelText - label.className = 'd2-overhead-label d2-portal-label' - if (label.dataset) { - label.dataset.kind = 'portal' - label.dataset.toLevelId = String(portalMouth.toLevelId) - delete label.dataset.hovered - } - label.style.color = '#6cb4ff' - label.style.background = 'transparent' - label.style.border = 'none' - label.style.padding = '0' - label.style.borderRadius = '0' - label.style.fontWeight = 'normal' - label.style.boxShadow = 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - } - - if (runtime?.grid) { - for (const warp of runtime.warps ?? []) { - if (!(warp.toLevelId > 0)) continue - const pos = getWarpLabelWorldPos(warp, runtime.grid) - const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY - if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue - const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) - const badgeText = currentLang === 'ENG' - ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(warp.toLevelId).nameZh}` - if (label.textContent !== badgeText) label.textContent = badgeText - const isHovered = host.hoveredWarp === warp - label.className = isHovered - ? 'd2-overhead-label d2-exit-badge is-hovered' - : 'd2-overhead-label d2-exit-badge' - if (label.dataset) { - label.dataset.kind = 'warp' - label.dataset.toLevelId = String(warp.toLevelId) - label.dataset.hovered = isHovered ? 'true' : 'false' - } - label.style.color = isHovered ? '#fff3c4' : '#f0d080' - label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' - label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' - label.style.padding = '2px 7px' - label.style.borderRadius = '3px' - label.style.fontWeight = isHovered ? 'bold' : 'normal' - label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - - for (const entrance of runtime.entrances ?? []) { - if (!(entrance.toLevelId > 0)) continue - const pos = getEntranceLabelWorldPos(entrance, runtime.grid) - const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY - if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue - const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) - const badgeText = currentLang === 'ENG' - ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(entrance.toLevelId).nameZh}` - if (label.textContent !== badgeText) label.textContent = badgeText - const isHovered = host.hoveredEntrance === entrance - label.className = isHovered - ? 'd2-overhead-label d2-exit-badge is-hovered' - : 'd2-overhead-label d2-exit-badge' - if (label.dataset) { - label.dataset.kind = 'entrance' - label.dataset.toLevelId = String(entrance.toLevelId) - label.dataset.hovered = isHovered ? 'true' : 'false' - } - label.style.color = isHovered ? '#fff3c4' : '#f0d080' - label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' - label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' - label.style.padding = '2px 7px' - label.style.borderRadius = '3px' - label.style.fontWeight = isHovered ? 'bold' : 'normal' - label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - } - - for (let index = labelCount; index < host.labelPool.length; index += 1) { - const unused = host.labelPool[index]! - unused.hidden = true - if (unused.dataset && unused.dataset.hovered === 'true') { - unused.dataset.hovered = 'false' - } - if (unused.className.includes('is-hovered')) { - unused.className = unused.className.replace(/\s*is-hovered\b/g, '') - } - } -} - -/** - * Project and update floating ground item labels (Alt mode or single hovered item). - */ -export function updateGroundItemLabelsOverlay( - groundLabelOverlay: GroundLabelOverlay | null, - hudManager: HudManager | null, - mouseController: { hoveredGroundItem: GroundItemEntity | null } | null, - engine: GameEngine, - canvas: HTMLCanvasElement, - camera: ViewportCamera, - camX: number, - camY: number, -): void { - if (groundLabelOverlay === null) return - const showLabels = Boolean(hudManager?.showGroundLabels) - let hoveredItem = mouseController?.hoveredGroundItem ?? null - if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) { - if (mouseController) mouseController.hoveredGroundItem = null - hoveredItem = null - } - - if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) { - const rect = canvas.getBoundingClientRect() - const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1 - const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1 - const projectToScreen = (worldX: number, worldY: number) => { - const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY - if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) { - return null - } - return { x: domX, y: domY } - } - if (showLabels) { - const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen) - const resolvedLayouts = resolveLadderCollisions(initialLayouts) - groundLabelOverlay.update(true, resolvedLayouts) - } else if (hoveredItem !== null) { - const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen) - groundLabelOverlay.update(true, initialLayouts) - } - } else { - groundLabelOverlay.update(false, []) - } -} diff --git a/src/client/scene/pack-loader.ts b/src/client/scene/pack-loader.ts deleted file mode 100644 index 79c588c..0000000 --- a/src/client/scene/pack-loader.ts +++ /dev/null @@ -1,1456 +0,0 @@ -import type { AutomapLayerData } from '../../common/formats/automap-table.ts' -import type { Palette } from '../../common/formats/pal.ts' -import { WorldVariants, WORLD_VARIANT_SEED, type ActLayout } from '../../common/world/act-variants.ts' -import type { ActorAnimator } from '../render/actor-animator.ts' -import { - DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, - frameDurationMsFromAnimSpeed, -} from '../../common/world/animated-tiles.ts' -import { spawnMonsterPacks } from '../../common/units/monster-rooms.ts' -import type { MonsterPack, MonsterStats, SafeZone, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import { - ORTHO_CELL_HEIGHT, - ORTHO_CELL_WIDTH, - cellCentre, - findIsoSpawn, - isBlockedAt, - type CollisionGrid, -} from '../../common/world/d2map.ts' -import { createIsoTerrain } from '../../common/world/iso-terrain.ts' -import { - SEAM_TRIGGER_SUBTILES, - WARP_TRIGGER_SUBTILES, - type SceneEntrance, - type SceneWarp, - type SceneWaypoint, -} from '../../common/world/level-links.ts' -import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts' -import type { LoadedMonsterArt } from '../render/monster-art.ts' -import { MonsterStreamingManager } from '../../common/units/monster-streaming.ts' -import { buildNpcDef, hasPackedSprite } from '../../common/world/npc.ts' -import { ADDITIVE_OBJECT_TOKENS } from '../../common/units/objects.ts' -import { - TownPortalSlot, - WaypointNetwork, - resolveTownPortalSubTile, - townLevelForAct, -} from '../../common/world/portal.ts' -import type { Side } from '../../common/rng/variants.ts' -import { getTblLang } from '../i18n/lang.ts' -import type { AtlasFrame } from '../render/atlas.ts' -import { buildBounds } from '../render/cull.ts' -import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../render/renderer.ts' -import { RoofFadeController } from '../render/roofs.ts' -import { ViewportCamera } from '../sim/camera.ts' -import type { KeyboardInput } from '../sim/input.ts' -import type { HudManager } from '../ui/hud-manager.ts' -import type { Minimap, MinimapLevel } from '../ui/minimap.ts' -import { cellOf } from '../../common/world/scene-links-nav.ts' -import { - CANONICAL_TOWN_STASHES, - ROOM_ACTIVATION_RADIUS, - ROOM_DEACTIVATION_RADIUS, - buildStreamingRooms, - isTownLevel, - rebudgetMonsterPacks, - type DensityMultiplier, - type EliteMultiplier, - type TownStashEntity, -} from '../../common/world/scene-population.ts' -import { - FadeOverlay, - SEAM_FADE_MS, - WARP_FADE_MS, - subTileToScene, -} from './transition.ts' -import { - DEFAULT_BASES, - loadMonsterArtMap, - type LoadedCharacter, -} from './entity-art-loader.ts' -import { - PERSISTENT_ILLUMINATION_TOKENS, - computeObjectPhaseOffsetMs, - type AnimatedObjectFrame, - type Drawable, - type ObjectDrawable, -} from './object-animation.ts' -import { - playInventoryFullFeedback, - showNotification, -} from './overhead-labels.ts' -import { - recordMonsterArtLoad, - reportMonsterPlan, - state, -} from './scene-state.ts' -import { - UNIVERSAL_DT1_PATHS, - validatePackManifest, - validateUniversalDt1Libraries, -} from '../../common/pack-contract/index.ts' - -export { - UNIVERSAL_DT1_PATHS, - validatePackManifest, - validateUniversalDt1Libraries, -} - -/** Pack locations tried in order when `?pack=` is absent. */ -export const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs'] -/** Act count the page offers. */ -export const ACT_COUNT = 5 -/** Walk speed in scene pixels per second (cells are 80×40). */ -export const WALK_SPEED = 170 -/** Character collision box at the feet, in scene pixels. */ -export const FEET_WIDTH = 20 -export const FEET_HEIGHT = 10 -/** Atlas pages fetched at once while filling in the background. */ -export const PAGE_CONCURRENCY = 3 -/** Placeholder marker size, in scene pixels. */ -export const MARKER_WIDTH = 16 -export const MARKER_HEIGHT = 26 - -/** Everything the render loop needs, from either data path. */ -export interface MapRuntime { - readonly source: 'pack' | 'live' - readonly base: string - readonly level: string - readonly act: number - readonly levelId: number - readonly entryPath: string - readonly entrances: readonly SceneEntrance[] - readonly warps: readonly SceneWarp[] - readonly waypoints: readonly SceneWaypoint[] - readonly stashes?: readonly TownStashEntity[] - readonly variants: readonly string[] - readonly variantParam: 'level' | 'variant' - readonly variant: string - /** @deprecated Kept for backward compatibility. Use `variants` instead. */ - readonly quadrants: readonly string[] - /** @deprecated Kept for backward compatibility. Use `variantParam` instead. */ - readonly quadrantParam: 'level' | 'quadrant' - /** @deprecated Kept for backward compatibility. Use `variant` instead. */ - readonly quadrant: string - readonly cellsX: number - readonly cellsY: number - readonly widthPx: number - readonly heightPx: number - readonly grid: CollisionGrid - readonly floors: readonly Drawable[] - readonly shadows?: readonly Drawable[] - readonly walls: readonly Drawable[] - readonly roofs: readonly Drawable[] - readonly animatedDrawables?: readonly Drawable[] | undefined - readonly animSpeed?: number | undefined - readonly frameDurationMs?: number | undefined - readonly objectDrawables: readonly ObjectDrawable[] - readonly animatedObjects?: readonly ObjectDrawable[] | undefined - readonly automap?: AutomapLayerData | undefined - readonly spawn: { x: number; y: number } - readonly npcs: readonly PackObject[] - monsterPacks: readonly MonsterPack[] - baseMonsterPacks?: readonly MonsterPack[] | undefined - readonly monsterTypes: readonly string[] - readonly objects: number - readonly frames: number - readonly notes: readonly string[] - readonly pages: (AtlasHandle | null)[] - readonly objectPages: (AtlasHandle | null)[] - readonly charBases: readonly string[] - readonly palette: Palette | null - readonly priorityPages: readonly number[] - readonly loadOrder: readonly number[] - disposed?: boolean | undefined - readonly loadGen?: LoadGeneration | undefined - loadPages( - renderer: SpriteRenderer, - indices: readonly number[], - onProgress: () => void, - signal?: AbortSignal, - ): Promise -} - -/** - * Find a query parameter. - * - * @param key - parameter name. - * @param fallback - value when absent. - * @returns the value. - */ -export function param(key: string, fallback: string): string { - if (typeof location === 'undefined') return fallback - return new URLSearchParams(location.search).get(key) ?? fallback -} - -/** - * Fetch JSON, with the HTTP status in the error when it fails. - * - * @param url - location. - * @returns the parsed JSON. - */ -export async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) throw new Error(`${url}: HTTP ${String(response.status)}`) - return await response.json() as T -} - -/** A pack's index file. */ -export interface PackIndex { - readonly palettes?: Record - readonly levels: readonly PackIndexEntry[] - readonly actLayouts?: Readonly> -} - -/** One baked map in the index. */ -export interface PackIndexEntry { - readonly act: number - readonly levelId?: number - readonly kind?: 'preset' | 'maze' | 'wilderness' - readonly slug: string - readonly label: string - readonly levelName: string - readonly path: string - readonly pages: number - readonly ds1?: string - readonly actVariant?: number - readonly fidelity?: 'exact' | 'layout' | 'approximation' -} - -/** Object frame inside an object atlas page. */ -export interface PackObjectFrame { - readonly page: number - readonly x: number - readonly y: number - readonly width: number - readonly height: number - readonly offsetX: number - readonly offsetY: number -} - -/** Object instance serialized in scene.json. */ -export interface PackObject { - readonly id: number - readonly type: number - readonly name?: string | null - readonly token?: string - readonly mode?: string - readonly objectsTxtId?: number - readonly hp?: number - readonly member?: string | null - readonly alternatives?: number - readonly x: number - readonly y: number - readonly depth?: number - readonly frame?: PackObjectFrame | null - readonly animatedFrames?: readonly PackObjectFrame[] | null - readonly frameDelta?: number - readonly frameDurationMs?: number - readonly cycleAnim?: boolean - readonly sync?: boolean - readonly trans?: number | undefined - readonly blendMode?: 'normal' | 'additive' | undefined -} - -/** A pack's per-map scene file. */ -export interface PackSceneJson { - readonly act: number - readonly levelId?: number - readonly levelName: string - readonly ds1: string - readonly fidelity?: 'exact' | 'layout' | 'approximation' - readonly cellsX: number - readonly cellsY: number - readonly originX: number - readonly originY: number - readonly widthPx: number - readonly heightPx: number - readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[] - readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[] - readonly frames: readonly (readonly number[])[] - readonly framePlacement: readonly (readonly number[])[] - readonly floors: readonly (readonly number[])[] - readonly shadows?: readonly (readonly number[])[] - readonly walls: readonly (readonly number[])[] - readonly roofs?: readonly (readonly number[])[] - readonly objects: readonly PackObject[] - readonly npcs?: readonly PackObject[] - readonly animSpeed?: number | undefined - readonly frameDurationMs?: number | undefined - readonly monsters?: { - readonly types: readonly string[] - readonly budget: number - readonly packs: readonly { readonly members: readonly MonsterStats[] }[] - } - readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } - readonly spawn: readonly number[] | null - readonly entrances?: readonly SceneEntrance[] - readonly warps?: readonly SceneWarp[] - readonly waypoints?: readonly SceneWaypoint[] - readonly automap?: AutomapLayerData | undefined - readonly stats: { readonly missingTiles: number; readonly walkable: number } -} - -/** The pack index, fetched once. */ -export const packIndexCache = new Map>() - -/** - * Read a pack's index, reusing the fetch. - * - * @param packBase - directory holding `index.json`. - * @returns the index, or null when there is no pack there. - */ -export async function getPackIndex(packBase: string): Promise { - let pending = packIndexCache.get(packBase) - if (pending === undefined) { - pending = fetchJson(`${packBase}/index.json`) - packIndexCache.set(packBase, pending) - } - try { - return await pending - } catch { - packIndexCache.delete(packBase) - return null - } -} - -/** The per-session choice of level copies, one chooser per pack. */ -export const worldVariantsCache = new Map>() - -/** - * The pack's copy chooser, created on first use. - * - * @param packBase - directory holding `index.json`. - * @param index - the pack's already-fetched index. - * @returns the chooser shared by every load from this pack. - */ -export function worldVariantsFor(packBase: string, index: PackIndex): WorldVariants { - let variants = worldVariantsCache.get(packBase) - if (variants === undefined) { - variants = new WorldVariants(index, WORLD_VARIANT_SEED) - worldVariantsCache.set(packBase, variants) - } - return variants -} - -/** - * Build the runtime for a prebaked pack. - */ -export async function loadPackRuntime( - packBase: string, - act: number, - wantedLevel: string, - viewport: { width: number; height: number }, - charBases: readonly string[], -): Promise { - const index = await getPackIndex(packBase) - if (index === null) return null - const forAct = index.levels.filter(entry => entry.act === act) - if (forAct.length === 0) return null - const variants = worldVariantsFor(packBase, index) - const wanted = wantedLevel === '' - ? undefined - : forAct.find(candidate => candidate.label === wantedLevel) - ?? forAct.find(candidate => candidate.slug === wantedLevel) - if (wanted !== undefined) variants.pin(wanted) - const entry = wanted ?? variants.startEntry(act) - if (entry === null) return null - return await buildPackRuntime(packBase, index, entry, viewport, charBases) -} - -/** - * Build the runtime for one level, named by id rather than by label. - */ -export async function loadRuntimeForLevel( - packBase: string, - levelId: number, - viewport: { width: number; height: number }, - charBases: readonly string[], - fromSide?: Side, - preferredVariantLabel?: string, -): Promise { - const index = await getPackIndex(packBase) - if (index === null) return null - const candidates = index.levels.filter(entry => entry.levelId === levelId) - const preferred = preferredVariantLabel !== undefined && fromSide === undefined - ? candidates.find( - c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel, - ) - : undefined - const entry = preferred ?? worldVariantsFor(packBase, index).entryForLevel(levelId, fromSide) - if (entry === null) return null - return await buildPackRuntime(packBase, index, entry, viewport, charBases) -} - -/** - * Turn one index entry into a runtime. - */ -export async function buildPackRuntime( - packBase: string, - index: PackIndex, - entry: PackIndexEntry, - viewport: { width: number; height: number }, - charBases: readonly string[], -): Promise { - const forAct = index.levels.filter(candidate => candidate.act === entry.act) - const scene = await fetchJson(`${packBase}/${entry.path}/scene.json`) - - const frames: AtlasFrame[] = scene.framePlacement.map(place => ({ - x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1, - })) - const toDrawable = (row: readonly number[]): Drawable => { - const frameIndex = row[0] ?? 0 - const animFrames = row.length > 5 ? row.slice(5) : undefined - const anim = animFrames && animFrames.length > 1 - ? animFrames.map(fIdx => ({ - frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 }, - page: scene.framePlacement[fIdx]?.[0] ?? 0, - })) - : undefined - const cellX = row[3] ?? 0 - const cellY = row[4] ?? 0 - const y = row[2] ?? 0 - const orthoY = (cellX + cellY) * 40 - const roofHeight = orthoY > y ? orthoY - y : undefined - return { - frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 }, - x: row[1] ?? 0, - y, - cellX, - cellY, - page: scene.framePlacement[frameIndex]?.[0] ?? 0, - ...(roofHeight !== undefined ? { roofHeight } : {}), - ...(anim ? { animatedFrames: anim } : {}), - } - } - const floors = scene.floors.map(toDrawable) - const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134]) - if (entry.levelId !== undefined && entry.actVariant === undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) { - const covered = new Uint8Array(scene.cellsX * scene.cellsY) - const freq = new Map() - for (let i = 0; i < scene.floors.length; i += 1) { - const row = scene.floors[i]! - const cx = row[3] ?? 0 - const cy = row[4] ?? 0 - if (cx >= 0 && cx < scene.cellsX && cy >= 0 && cy < scene.cellsY) { - covered[cy * scene.cellsX + cx] = 1 - } - const fIdx = row[0] ?? 0 - freq.set(fIdx, (freq.get(fIdx) ?? 0) + 1) - } - let defaultFrameIndex = scene.floors[0]?.[0] ?? 0 - let maxCount = -1 - for (const [fIdx, count] of freq) { - if (count > maxCount) { - maxCount = count - defaultFrameIndex = fIdx - } - } - const defaultFrame = frames[defaultFrameIndex] ?? { x: 0, y: 0, width: 1, height: 1 } - const defaultPage = scene.framePlacement[defaultFrameIndex]?.[0] ?? 0 - for (let cy = 0; cy < scene.cellsY; cy += 1) { - for (let cx = 0; cx < scene.cellsX; cx += 1) { - if (covered[cy * scene.cellsX + cx] === 0) { - floors.push({ - frame: defaultFrame, - x: scene.originX + (cx - cy) * ORTHO_CELL_WIDTH - 80, - y: scene.originY + (cx + cy) * ORTHO_CELL_HEIGHT, - cellX: cx, - cellY: cy, - page: defaultPage, - }) - } - } - } - floors.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY) - } - const shadows = (scene.shadows ?? []).map(toDrawable) - const walls = scene.walls.map(toDrawable) - const roofs = (scene.roofs ?? []).map(toDrawable) - - const objectDrawables: ObjectDrawable[] = [] - const packObjects = [...scene.objects, ...(scene.npcs ?? [])] - for (const obj of packObjects) { - if (hasPackedSprite(obj)) { - const hasAnim = obj.animatedFrames !== undefined && obj.animatedFrames !== null && obj.animatedFrames.length > 1 - const animatedFrames: AnimatedObjectFrame[] | undefined = hasAnim - ? obj.animatedFrames!.map(f => ({ - frame: { - x: f.x, - y: f.y, - width: f.width, - height: f.height, - }, - page: f.page, - offsetX: f.offsetX, - offsetY: f.offsetY, - })) - : undefined - - const frameDurationMs = obj.frameDurationMs ?? ( - obj.frameDelta && obj.frameDelta > 0 - ? Math.round((256 / obj.frameDelta) * 40 * 100) / 100 - : 40 - ) - const isPersistentFire = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase()) - const cycleAnim = isPersistentFire ? true : (obj.cycleAnim ?? true) - - let phaseOffsetMs = 0 - if (hasAnim && !obj.sync) { - const totalDurationMs = obj.animatedFrames!.length * frameDurationMs - phaseOffsetMs = computeObjectPhaseOffsetMs(obj.x, obj.y, totalDurationMs) - } - - const tokenUpper = obj.token ? obj.token.trim().toUpperCase() : '' - const isAdditive = obj.blendMode === 'additive' || - obj.trans === 7 || - obj.trans === 1 || - ADDITIVE_OBJECT_TOKENS.has(tokenUpper) - const blendMode: 'normal' | 'additive' = isAdditive ? 'additive' : (obj.blendMode ?? 'normal') - - objectDrawables.push({ - id: obj.id, - token: obj.token, - name: obj.name, - frame: { - x: obj.frame.x, - y: obj.frame.y, - width: obj.frame.width, - height: obj.frame.height, - }, - x: obj.x + obj.frame.offsetX, - y: obj.y + obj.frame.offsetY, - depth: obj.depth ?? ((obj.x + obj.y) / 5), - page: obj.frame.page, - baseX: obj.x, - baseY: obj.y, - blendMode, - trans: obj.trans, - ...(animatedFrames ? { - animatedFrames, - frameDurationMs, - cycleAnim, - phaseOffsetMs, - currentFrameIndex: 0, - } : {}), - }) - } - } - objectDrawables.sort((a, b) => a.depth - b.depth) - const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null) - - const stashes: TownStashEntity[] = [] - for (const obj of scene.objects) { - if (obj.objectsTxtId === 267 || obj.token === 'B6') { - const subTileX = Math.round((obj.x - scene.originX) / 16) - const subTileY = Math.round((obj.y - scene.originY) / 16) - stashes.push({ - id: obj.id ?? 267, - objectsTxtId: 267, - token: 'B6', - nameZh: '储物箱', - nameEn: 'Stash', - act: scene.act, - x: obj.x, - y: obj.y, - subTileX, - subTileY, - clickRadiusPx: 48, - interactionRangePx: 80, - }) - } - } - const effectiveLevelId = scene.levelId ?? entry.levelId ?? -1 - const isTown = [1, 40, 75, 103, 109].includes(effectiveLevelId) || entry.slug.includes('town') - if (stashes.length === 0 && isTown) { - const canonical = CANONICAL_TOWN_STASHES[scene.act] - if (canonical) { - stashes.push(canonical) - } - } - - const blocked = new Uint8Array(scene.collision.width * scene.collision.height) - let at = 0 - for (const run of scene.collision.runs) { - const value = run[0] ?? 0 - const length = run[1] ?? 0 - if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length)) - at += length - } - const grid: CollisionGrid = { - originX: scene.originX, - originY: scene.originY, - cellsX: scene.cellsX, - cellsY: scene.cellsY, - blocked, - gridWidth: scene.collision.width, - } - let spawn = scene.spawn === null || scene.spawn === undefined - ? findIsoSpawn(grid) - : { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 } - if (effectiveLevelId === 1 && param('gameName', '') !== '') { - const warriv = (scene.npcs ?? []).find( - n => n.id === 155 || (n.name ?? '').toLowerCase().includes('warriv'), - ) - const anchorX = (warriv?.x ?? stashes[0]?.x ?? 4512) - 32 - const anchorY = (warriv?.y ?? stashes[0]?.y ?? 1720) + 32 - let foundTownCenter: { x: number; y: number } | null = null - for (let r = 0; r <= 12 && !foundTownCenter; r++) { - for (let dy = -r; dy <= r && !foundTownCenter; dy++) { - for (let dx = -r; dx <= r && !foundTownCenter; dx++) { - if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue - const px = anchorX + dx * 16 - const py = anchorY + dy * 16 - if (!isBlockedAt(grid, px, py)) { - foundTownCenter = { x: px, y: py } - } - } - } - } - if (foundTownCenter !== null) { - spawn = foundTownCenter - } - } - if (spawn === null) throw new Error(`${entry.label}: the map has no walkable spawn`) - - const paletteRgb = index.palettes?.[`act${String(scene.act)}`] - const packPalette: Palette | null = paletteRgb === undefined - ? null - : { rgb: Uint8Array.from(paletteRgb), size: 256 } - const pages: (AtlasHandle | null)[] = scene.pages.map(() => null) - const priority = pagesNeededForViewport([...floors, ...shadows], walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport)) - const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))] - const runtimeLoadGen = new LoadGeneration() - - const runtime: MapRuntime = { - source: 'pack', - base: packBase, - charBases, - palette: packPalette, - level: scene.levelName, - act: scene.act, - levelId: scene.levelId ?? entry.levelId ?? -1, - entryPath: entry.path, - entrances: scene.entrances ?? [], - warps: scene.warps ?? [], - waypoints: scene.waypoints ?? [], - stashes, - variants: forAct.map(candidate => candidate.label), - variantParam: 'level', - variant: entry.label, - quadrants: forAct.map(candidate => candidate.label), - quadrantParam: 'level', - quadrant: entry.label, - cellsX: scene.cellsX, - cellsY: scene.cellsY, - widthPx: scene.widthPx, - heightPx: scene.heightPx, - grid, - floors, - shadows, - walls, - roofs, - animatedDrawables: [...floors, ...shadows, ...walls, ...roofs].filter( - d => d.animatedFrames && d.animatedFrames.length > 1, - ), - animSpeed: scene.animSpeed, - frameDurationMs: scene.frameDurationMs ?? (scene.animSpeed !== undefined ? frameDurationMsFromAnimSpeed(scene.animSpeed) : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS), - objectDrawables, - animatedObjects: objectDrawables.filter(d => d.animatedFrames && d.animatedFrames.length > 1), - ...(scene.automap !== undefined ? { automap: scene.automap } : {}), - spawn, - npcs: scene.npcs ?? [], - monsterPacks: scene.monsters?.packs ?? [], - baseMonsterPacks: scene.monsters?.packs ?? [], - monsterTypes: Array.from(new Set([ - ...(scene.monsters?.types ?? []), - ...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)), - ])), - objects: scene.objects.length, - frames: scene.frames.length, - notes: getTblLang() === 'ENG' - ? [ - `Initial pages ${priority.join(',')}, total ${String(pages.length)} pages`, - ...(scene.monsters?.packs && scene.monsters.packs.length > 0 - ? [`Monsters: ${String(scene.monsters.budget)} / ${String(scene.monsters.packs.length)} packs (${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join(', ')})`] - : []), - ] - : [ - `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`, - ...(scene.monsters?.packs && scene.monsters.packs.length > 0 - ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`] - : []), - ], - pages, - objectPages, - priorityPages: priority, - loadOrder, - disposed: false, - loadGen: runtimeLoadGen, - async loadPages(renderer, indices, onProgress, externalSignal) { - const isCancelled = (): boolean => - Boolean(runtime.disposed || runtimeLoadGen.signal.aborted || externalSignal?.aborted) - - if (isCancelled()) return - - if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) { - await Promise.all(scene.objectPages.map(async (page, index) => { - if (objectPages[index] !== null || isCancelled()) return - try { - const response = await fetch(`${packBase}/${entry.path}/${page.file}`) - if (!response.ok || isCancelled()) return - const bitmap = await createImageBitmap(await response.blob()) - try { - if (isCancelled()) return - const handle = renderer.addAtlas(bitmap, page.width, page.height) - if (isCancelled()) { - renderer.deleteAtlas(handle) - return - } - objectPages[index] = handle - } finally { - bitmap.close?.() - } - } catch (err) { - if (isCancelled()) return - console.warn(`object page ${page.file}: ${(err as Error).message}`) - } - })) - } - if (isCancelled()) return - const queue = indices.filter(index => pages[index] === null) - let cursor = 0 - const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => { - for (;;) { - if (isCancelled()) return - const index = queue[cursor] - cursor += 1 - if (index === undefined) return - const page = scene.pages[index]! - const response = await fetch(`${packBase}/${entry.path}/${page.file}`) - if (isCancelled()) return - if (!response.ok) throw new Error(`${page.file}: HTTP ${String(response.status)}`) - const bitmap = await createImageBitmap(await response.blob()) - try { - if (isCancelled()) return - const handle = renderer.addAtlas(bitmap, page.width, page.height) - if (isCancelled()) { - renderer.deleteAtlas(handle) - return - } - pages[index] = handle - } finally { - bitmap.close?.() - } - if (!isCancelled()) { - onProgress() - } - } - }) - await Promise.all(workers) - }, - } - return runtime -} - -/** - * Which pages the first frame needs. - */ -export function pagesNeededForViewport( - floors: readonly Drawable[], - walls: readonly Drawable[], - spawn: { x: number; y: number }, - viewport: { width: number; height: number }, - zoom: number, -): number[] { - const halfWidth = viewport.width / zoom / 2 + 96 - const halfHeight = viewport.height / zoom / 2 + 96 - const left = spawn.x - halfWidth - const right = spawn.x + halfWidth - const top = spawn.y - halfHeight - const bottom = spawn.y + halfHeight - const needed = new Set() - for (const draw of [...floors, ...walls]) { - if (draw.x + draw.frame.width < left || draw.x > right) continue - if (draw.y + draw.frame.height < top || draw.y > bottom) continue - needed.add(draw.page) - } - if (needed.size === 0) needed.add(0) - return [...needed].sort((a, b) => a - b) -} - -/** - * Count non-null loaded atlas pages without allocating throwaway arrays. - */ -export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number { - let loaded = 0 - for (let i = 0; i < pages.length; i += 1) { - const page = pages[i] - if (page !== null && page !== undefined) loaded += 1 - } - return loaded -} - -/** - * Fill in the remaining pages without blocking the first frames. - */ -export function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void { - const rest = runtime.loadOrder.slice(1) - if (rest.length === 0 || runtime.disposed) return - void runtime.loadPages(renderer, rest, () => { - if (!runtime.disposed) { - state.pagesLoaded = countLoadedPages(runtime.pages) - } - }, runtime.loadGen?.signal).catch((err: unknown) => { - if (runtime.disposed) return - console.warn(`page preload: ${(err as Error).message}`) - }) -} - -export interface LevelTransitionBoundsState { - floorBounds: Float32Array - shadowBounds: Float32Array - wallBounds: Float32Array - roofBounds: Float32Array - objectBounds: Float32Array - roofController: RoofFadeController -} - -export function disposeRuntimeAtlases(runtime: MapRuntime, renderer: SpriteRenderer): void { - if (!runtime || typeof runtime !== 'object') return - runtime.disposed = true - runtime.loadGen?.invalidate?.() - if (Array.isArray(runtime.pages)) { - for (let i = 0; i < runtime.pages.length; i += 1) { - const page = runtime.pages[i] ?? null - if (page !== null) { - renderer.deleteAtlas(page) - runtime.pages[i] = null - } - } - } - if (Array.isArray(runtime.objectPages)) { - for (let i = 0; i < runtime.objectPages.length; i += 1) { - const page = runtime.objectPages[i] ?? null - if (page !== null) { - renderer.deleteAtlas(page) - runtime.objectPages[i] = null - } - } - } - const extra = runtime as unknown as { - wallAtlas?: AtlasHandle | null - floorAtlas?: AtlasHandle | null - monsterAtlases?: Map | null - } - if (extra.wallAtlas) { - renderer.deleteAtlas(extra.wallAtlas) - extra.wallAtlas = null - } - if (extra.floorAtlas) { - renderer.deleteAtlas(extra.floorAtlas) - extra.floorAtlas = null - } - if (extra.monsterAtlases && typeof extra.monsterAtlases.values === 'function') { - for (const val of extra.monsterAtlases.values()) { - if (val && typeof val === 'object' && 'handle' in val && val.handle) { - renderer.deleteAtlas(val.handle) - } else if (val) { - renderer.deleteAtlas(val as AtlasHandle) - } - } - extra.monsterAtlases.clear?.() - } -} - -export interface LevelTransitionControllerContext { - readonly canvas?: HTMLCanvasElement - readonly status?: HTMLElement - readonly renderer: SpriteRenderer - readonly engine?: GameEngine - readonly camera?: ViewportCamera - readonly input?: KeyboardInput - readonly fade?: FadeOverlay - readonly minimap?: Minimap - readonly minimapLevel?: () => MinimapLevel - readonly resetMinimapCache?: () => void - readonly waypointNetwork?: WaypointNetwork - readonly portalSlot?: TownPortalSlot - readonly playerAnimator?: ActorAnimator - readonly playerSubTile?: (engine: GameEngine, runtime: MapRuntime) => { x: number; y: number } - readonly populatePackSelectors?: (packBase: string, current: { act: number; label: string }) => Promise - readonly getRuntime?: () => MapRuntime - readonly setRuntime?: (next: MapRuntime) => void - readonly initialRuntime?: MapRuntime - readonly loadLevelRuntime?: (levelId: number) => Promise - readonly getHudManager?: () => HudManager | null - readonly clearMouseTarget?: () => void - readonly getCharacter?: () => LoadedCharacter | null - readonly getMonsterArtMap?: () => Map - readonly setMonsterArtMap?: (map: Map) => void - readonly setActivePaletteAndPack?: (palette: Palette, base: string | undefined, act: number | undefined) => void - readonly getMultipliers?: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier } - readonly setBoundsState?: (bounds: LevelTransitionBoundsState) => void - readonly onInvalidateLoads?: (() => void) | undefined -} - -export interface LevelTransitionController { - readonly levelLoadGen: LoadGeneration - readonly isTravelling: () => boolean - readonly castTownPortal: () => boolean - readonly enterTownPortal: () => void - readonly registerWaypoints: (map: MapRuntime) => void - readonly swapLevel: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise - readonly switchArea: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise - readonly travel: (toLevelId: number, fadeMs: number, override?: { x: number; y: number }, fromSide?: Side) => Promise - readonly teleportViaWaypoint: (targetAct: number, slug: string, targetLevelId?: number) => Promise - readonly checkLinks: () => void -} - -export function createLevelTransitionController( - rawCtx: LevelTransitionControllerContext, -): LevelTransitionController { - const ctx = rawCtx as Required - const levelLoadGen = new LoadGeneration() - const initialRuntime = rawCtx.getRuntime?.() ?? rawCtx.initialRuntime - const townVariantByAct = new Map() - if (initialRuntime && isTownLevel(initialRuntime.levelId) && initialRuntime.variant) { - townVariantByAct.set(initialRuntime.act, initialRuntime.variant) - } - let travelling = false - let lastContactWaypointId: number | null = null - - const registerWaypoints = (map: MapRuntime | undefined): void => { - if (!map || !Array.isArray(map.waypoints) || !ctx.waypointNetwork) return - for (const waypoint of map.waypoints) { - ctx.waypointNetwork.register({ - waypointId: waypoint.waypointId, - levelId: map.levelId, - act: map.act, - name: map.level, - x: waypoint.arriveX, - y: waypoint.arriveY, - }) - } - } - registerWaypoints(initialRuntime) - - const castTownPortal = (): boolean => { - if (travelling) return false - const runtime = ctx.getRuntime() - const isEn = getTblLang() === 'ENG' - if (isTownLevel(runtime.levelId)) { - playInventoryFullFeedback() - showNotification(isEn ? 'Cannot cast Town Portal in town' : '无法在城镇中开启回城传送门', 2000, ctx.status) - return false - } - - const here = ctx.playerSubTile(ctx.engine, runtime) - const candidateOffsets = [ - { dx: -2, dy: 2 }, - { dx: -2, dy: 1 }, - { dx: -1, dy: 2 }, - { dx: 0, dy: -2 }, - { dx: 2, dy: -1 }, - { dx: 2, dy: 0 }, - { dx: 1, dy: -1 }, - { dx: 0, dy: 0 }, - ] - let fromX = here.x - let fromY = here.y - for (const off of candidateOffsets) { - const cx = here.x + off.dx - const cy = here.y + off.dy - const scenePos = subTileToScene(cx, cy, runtime.grid) - if (!isBlockedAt(runtime.grid, scenePos.x, scenePos.y)) { - fromX = cx - fromY = cy - break - } - } - - const townLevelId = townLevelForAct(runtime.act) - const townSub = resolveTownPortalSubTile(townLevelId, townVariantByAct.get(runtime.act)) - ctx.portalSlot.cast({ - fromLevelId: runtime.levelId, - fromX, - fromY, - townLevelId, - townX: townSub.x, - townY: townSub.y, - createdAtMs: performance.now(), - }) - state.portal = ctx.portalSlot.current() - - const character = ctx.getCharacter() - if (character?.clips?.['sc']) { - ctx.playerAnimator.play('sc', character.clips['sc'], { restart: true }) - } - const townNames = getLevelNames(townLevelId) - ctx.status.textContent = isEn - ? `Opened Town Portal to ${townNames.nameEn}` - : `已开启返回 ${townNames.nameZh} 的传送门` - return true - } - - const swapLevel = async ( - next: MapRuntime, - arrive?: { x: number; y: number }, - ): Promise => { - const { gen } = levelLoadGen.next() - ctx.onInvalidateLoads?.() - const prevRuntime = ctx.getRuntime() - ctx.clearMouseTarget() - if (prevRuntime !== next) { - disposeRuntimeAtlases(prevRuntime, ctx.renderer) - } - for (const art of ctx.getMonsterArtMap().values()) ctx.renderer.deleteAtlas(art.handle) - ctx.setMonsterArtMap(new Map()) - - next.disposed = false - ctx.setRuntime(next) - if (isTownLevel(next.levelId) && next.variant) townVariantByAct.set(next.act, next.variant) - ctx.setBoundsState({ - floorBounds: buildBounds(next.floors), - shadowBounds: buildBounds(next.shadows ?? []), - wallBounds: buildBounds(next.walls), - roofBounds: buildBounds(next.roofs), - objectBounds: buildBounds(next.objectDrawables), - roofController: new RoofFadeController(next.roofs), - }) - - ctx.camera.mapWidthPx = next.widthPx - ctx.camera.mapHeightPx = next.heightPx - ctx.engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, { - width: FEET_WIDTH, - height: FEET_HEIGHT, - }) - - const landing = arrive !== undefined - ? subTileToScene(arrive.x, arrive.y, next.grid) - : { x: next.spawn.x, y: next.spawn.y } - ctx.engine.world.player.x = landing.x - ctx.engine.world.player.y = landing.y - - const defs = next.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) - ctx.engine.npcEntities.length = 0 - for (const n of next.npcs) { - const def = defs.find(d => d.id === `npc-${String(n.id)}`) - if (def === undefined) continue - ctx.engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n), frame: n.frame }) - } - - ctx.engine.world.monsters.length = 0 - ctx.engine.ground.length = 0 - ctx.engine.groundItems.clear() - const nextSafeZones: SafeZone[] = [{ x: landing.x, y: landing.y, radius: 180 }] - for (const wp of next.waypoints) { - const pos = subTileToScene(wp.x, wp.y, next.grid) - nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - for (const ent of next.entrances) { - const pos = subTileToScene(ent.x, ent.y, next.grid) - nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - if (!next.baseMonsterPacks) next.baseMonsterPacks = next.monsterPacks - const { densityMultiplier, eliteMultiplier } = ctx.getMultipliers() - next.monsterPacks = rebudgetMonsterPacks(next.baseMonsterPacks, { - densityMultiplier, - eliteMultiplier, - levelId: next.levelId, - seed: WORLD_VARIANT_SEED ^ next.levelId, - }) - const nextResolvedPacks: MonsterPack[] = next.monsterPacks.map(pack => { - if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { - return { - ...pack, - fixedPosition: cellCentre(next.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY), - } - } - return pack - }) - const spread = Math.max(next.widthPx, next.heightPx) / 4 - const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones) - if (nextRooms.length > 0) { - ctx.engine.streamingManager = new MonsterStreamingManager( - ctx.engine.world, - ctx.engine.terrain, - nextRooms, - { - activationRadius: ROOM_ACTIVATION_RADIUS, - deactivationRadius: ROOM_DEACTIVATION_RADIUS, - safeZones: nextSafeZones, - }, - ) - } else { - ctx.engine.streamingManager = undefined - if (nextResolvedPacks.length > 0) { - spawnMonsterPacks(ctx.engine.world, nextResolvedPacks, landing, spread, ctx.engine.terrain, nextSafeZones) - } - } - reportMonsterPlan(next) - - if (next.palette !== null) { - ctx.renderer.setPalette(next.palette.rgb) - const packBase = next.source === 'pack' ? next.base : undefined - const packAct = next.source === 'pack' ? next.act : undefined - ctx.setActivePaletteAndPack(next.palette, packBase, packAct) - const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id)) - const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds])) - if (activeMonsterIds.length > 0) { - const loaded = await loadMonsterArtMap( - DEFAULT_BASES, - activeMonsterIds, - next.palette, - ctx.renderer, - packBase, - packAct, - ) - if (!levelLoadGen.isCurrent(gen) || next.disposed || ctx.getRuntime() !== next) { - for (const art of loaded.map.values()) { - ctx.renderer.deleteAtlas(art.handle) - } - return - } - const keepHandles = new Set() - for (const art of loaded.map.values()) { - keepHandles.add(art.handle) - } - for (const oldArt of ctx.getMonsterArtMap().values()) { - if (!keepHandles.has(oldArt.handle)) { - ctx.renderer.deleteAtlas(oldArt.handle) - } - } - ctx.setMonsterArtMap(loaded.map) - recordMonsterArtLoad(loaded) - } - } - - if (!levelLoadGen.isCurrent(gen) || next.disposed || ctx.getRuntime() !== next) { - return - } - - if (next.source === 'pack' && typeof location !== 'undefined' && typeof history !== 'undefined') { - const nextParams = new URLSearchParams(location.search) - nextParams.set('act', String(next.act)) - nextParams.set('level', next.variant) - history.replaceState(null, '', `?${nextParams.toString()}`) - void ctx.populatePackSelectors(next.base, { act: next.act, label: next.variant }) - } - - registerWaypoints(next) - state.level = next.level - state.act = next.act - state.variant = next.variant - state.variants = [...next.variants] - state.quadrant = next.variant - state.quadrants = [...next.variants] - state.cellsX = next.cellsX - state.cellsY = next.cellsY - state.pagesTotal = next.pages.length - state.pagesLoaded = countLoadedPages(next.pages) - state.animatedTiles = next.animatedDrawables?.length ?? 0 - state.animatedObjects = next.animatedObjects?.length ?? 0 - state.animSpeed = next.animSpeed - state.frameDurationMs = next.frameDurationMs - - const parsedLvlId = typeof next.levelId === 'number' && next.levelId > 0 ? next.levelId : Number(next.level) - if (!Number.isFinite(parsedLvlId) || parsedLvlId <= 0) { - throw new Error(`Invalid levelId for runtime "${String(next.level)}"`) - } - const effectiveLvlId = parsedLvlId - ctx.engine.levelId = effectiveLvlId - ctx.engine.isTown = [1, 40, 75, 103, 109].includes(effectiveLvlId) - const names = getLevelNames(effectiveLvlId) - const hudManager = ctx.getHudManager() - hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn) - if (hudManager) { - hudManager.worldPanels.setCurrentLevel(effectiveLvlId) - hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, effectiveLvlId) - hudManager.worldPanels.selectedActTab = next.act - } - ctx.clearMouseTarget() - ctx.resetMinimapCache() - const arrivedCell = cellOf(next.grid, ctx.engine.world.player.x, ctx.engine.world.player.y) - ctx.minimap.reveal(ctx.minimapLevel(), arrivedCell.x, arrivedCell.y, ctx.engine.world.player.x, ctx.engine.world.player.y, true) - } - - const travel = async ( - toLevelId: number, - fadeMs: number, - override?: { x: number; y: number }, - fromSide?: Side, - ): Promise => { - const runtime = ctx.getRuntime() - if (travelling || runtime.source !== 'pack') return - travelling = true - const { gen, signal } = levelLoadGen.next() - ctx.clearMouseTarget() - const fromLevelId = runtime.levelId - const isEn = getTblLang() === 'ENG' - try { - await ctx.fade.out(fadeMs) - if (!levelLoadGen.isCurrent(gen)) return - const next = await loadRuntimeForLevel( - runtime.base, - toLevelId, - { width: ctx.canvas.width, height: ctx.canvas.height }, - runtime.charBases, - fromSide, - isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined, - ) - if (!levelLoadGen.isCurrent(gen)) { - if (next !== null) disposeRuntimeAtlases(next, ctx.renderer) - return - } - if (next === null) { - ctx.status.textContent = isEn - ? `Level ${String(toLevelId)} not found in pack.` - : `资源包里没有关卡 ${String(toLevelId)}。` - return - } - await next.loadPages(ctx.renderer, next.priorityPages, () => { - if (levelLoadGen.isCurrent(gen)) { - state.pagesLoaded = countLoadedPages(next.pages) - } - }, signal) - if (!levelLoadGen.isCurrent(gen)) { - disposeRuntimeAtlases(next, ctx.renderer) - return - } - let effectiveOverride = override - if (isTownLevel(next.levelId) && next.variant) { - townVariantByAct.set(next.act, next.variant) - const openPortal = ctx.portalSlot.current() - if (openPortal !== null && openPortal.townLevelId === next.levelId) { - const exactTownSub = resolveTownPortalSubTile(next.levelId, next.variant) - if (openPortal.townX !== exactTownSub.x || openPortal.townY !== exactTownSub.y) { - const wasHeadingToPortal = - effectiveOverride !== undefined && - effectiveOverride.x === openPortal.townX && - effectiveOverride.y === openPortal.townY - ctx.portalSlot.cast({ - ...openPortal, - townX: exactTownSub.x, - townY: exactTownSub.y, - }) - if (wasHeadingToPortal) effectiveOverride = exactTownSub - } - } - } - const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId) - ?? next.warps.find(warp => warp.toLevelId === fromLevelId) - if (effectiveOverride === undefined && back === undefined) { - throw new Error(`level ${String(toLevelId)} has no link back to level ${String(fromLevelId)}`) - } - const rawLanding = effectiveOverride ?? { x: back!.arriveX, y: back!.arriveY } - let landing = rawLanding - const activeMouthInNext = ctx.portalSlot.mouthInLevel(next.levelId) - if ( - activeMouthInNext !== null && - activeMouthInNext.x === rawLanding.x && - activeMouthInNext.y === rawLanding.y - ) { - const besidePos = subTileToScene(rawLanding.x + 1, rawLanding.y + 1, next.grid) - if (!isBlockedAt(next.grid, besidePos.x, besidePos.y)) { - landing = { x: rawLanding.x + 1, y: rawLanding.y + 1 } - } - } - await swapLevel(next, landing) - if (!next.disposed && ctx.getRuntime() === next) { - preloadRemaining(next, ctx.renderer) - } - } catch (err) { - ctx.status.textContent = isEn - ? `Level transition failed: ${(err as Error).message}` - : `切换关卡失败:${(err as Error).message}` - } finally { - await ctx.fade.in(fadeMs) - travelling = false - } - } - - const enterTownPortal = (): void => { - const runtime = ctx.getRuntime() - if (travelling || runtime.source !== 'pack') return - const dest = ctx.portalSlot.enterFrom(runtime.levelId) - state.portal = ctx.portalSlot.current() - if (dest !== null) void travel(dest.levelId, WARP_FADE_MS, { x: dest.x, y: dest.y }) - } - - const teleportViaWaypoint = async ( - targetAct: number, - slug: string, - targetLevelId?: number, - ): Promise => { - const runtime = ctx.getRuntime() - if (travelling || runtime.source !== 'pack') return - travelling = true - const { gen, signal } = levelLoadGen.next() - ctx.clearMouseTarget() - const isEn = getTblLang() === 'ENG' - try { - await ctx.fade.out(WARP_FADE_MS) - if (!levelLoadGen.isCurrent(gen)) return - const index = await getPackIndex(runtime.base) - if (!levelLoadGen.isCurrent(gen)) return - if (index === null) { - ctx.status.textContent = isEn ? 'Failed to read pack index.' : '无法读取资源包索引。' - return - } - const normalizedLevelId = targetLevelId === 120 ? 123 : targetLevelId - let pool = index.levels.filter( - entry => - entry.act === targetAct && - (normalizedLevelId !== undefined - ? entry.levelId === normalizedLevelId - : entry.slug === slug || entry.label === slug), - ) - if (pool.length === 0) { - pool = index.levels.filter( - entry => - (normalizedLevelId !== undefined && entry.levelId === normalizedLevelId) || - entry.slug === slug || - entry.label === slug, - ) - } - if (pool.length === 0) { - ctx.status.textContent = isEn - ? `Waypoint level not found in pack: ${slug}` - : `资源包里没有传送点关卡:${slug}` - return - } - - const levelIds = [...new Set(pool.map(entry => entry.levelId))] - const poolLevelId = levelIds.length === 1 ? levelIds[0] : undefined - if (poolLevelId === undefined && pool.length !== 1) { - throw new Error(`waypoint ${slug}: ${String(pool.length)} baked maps match and they do not name one level`) - } - const chosenEntry = poolLevelId === undefined - ? pool[0]! - : worldVariantsFor(runtime.base, index).entryForLevel(poolLevelId) - if (chosenEntry === null) throw new Error(`waypoint ${slug}: no baked copy of level ${String(poolLevelId)}`) - const next = await buildPackRuntime( - runtime.base, - index, - chosenEntry, - { width: ctx.canvas.width, height: ctx.canvas.height }, - runtime.charBases, - ) - if (!levelLoadGen.isCurrent(gen)) { - disposeRuntimeAtlases(next, ctx.renderer) - return - } - await next.loadPages(ctx.renderer, next.priorityPages, () => { - if (levelLoadGen.isCurrent(gen)) { - state.pagesLoaded = countLoadedPages(next.pages) - } - }, signal) - if (!levelLoadGen.isCurrent(gen)) { - disposeRuntimeAtlases(next, ctx.renderer) - return - } - - const wp = next.waypoints[0] - if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`) - const landing = { x: wp.arriveX, y: wp.arriveY } - - await swapLevel(next, landing) - if (!next.disposed && ctx.getRuntime() === next) { - preloadRemaining(next, ctx.renderer) - ctx.resetMinimapCache() - - const names = getLevelNames(next.levelId) - ctx.status.textContent = isEn - ? `Teleported to: ${names.nameEn} (Act ${String(next.act)})` - : `已传送到:${names.nameZh}(Act ${String(next.act)})` - } - } catch (err) { - ctx.status.textContent = isEn - ? `Waypoint teleport failed: ${(err as Error).message}` - : `传送点传送失败:${(err as Error).message}` - } finally { - await ctx.fade.in(WARP_FADE_MS) - travelling = false - } - } - - const checkLinks = (): void => { - const runtime = ctx.getRuntime() - if (travelling || runtime.source !== 'pack') return - const here = ctx.playerSubTile(ctx.engine, runtime) - const near = (x: number, y: number, radius: number): boolean => - Math.abs(here.x - x) <= radius && Math.abs(here.y - y) <= radius - - for (const entrance of runtime.entrances) { - if (!(entrance.toLevelId > 0)) continue - if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue - void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) - return - } - - let nearWaypoint = false - for (const waypoint of runtime.waypoints) { - if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue - nearWaypoint = true - if (lastContactWaypointId !== waypoint.waypointId) { - lastContactWaypointId = waypoint.waypointId - const hudManager = ctx.getHudManager() - if (hudManager) { - hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, runtime.levelId) - hudManager.worldPanels.selectedActTab = runtime.act - if (hudManager.leftPanel !== 'waypoint') hudManager.toggleLeftPanel('waypoint') - } - } - if (ctx.waypointNetwork.activate(waypoint.waypointId)) { - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level - ctx.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - if (ctx.input.takeWaypoint()) { - const town = ctx.waypointNetwork.destinations() - .find(site => site.levelId === townLevelForAct(runtime.act)) - if (town !== undefined && town.levelId !== runtime.levelId) { - void travel(town.levelId, WARP_FADE_MS, { x: town.x, y: town.y }) - } - } - return - } - if (!nearWaypoint) lastContactWaypointId = null - if (ctx.input.takePortal()) castTownPortal() - } - - return { - levelLoadGen, - isTravelling: () => travelling, - castTownPortal, - enterTownPortal, - registerWaypoints, - swapLevel, - switchArea: swapLevel, - travel, - teleportViaWaypoint, - checkLinks, - } -} - -export async function switchArea( - controllerOrCtx: LevelTransitionController | LevelTransitionControllerContext, - next: MapRuntime, - arrive: { x: number; y: number } = { x: 0, y: 0 }, -): Promise { - if ('swapLevel' in controllerOrCtx) { - await controllerOrCtx.swapLevel(next, arrive) - return - } - const controller = createLevelTransitionController(controllerOrCtx) - await controller.swapLevel(next, arrive) -} - -export { - ClientWorldSceneSource, - type SceneFrameSnapshot, - type SceneSource, -} from '../view/scene-source.ts' - - diff --git a/src/client/scene/scene-state.ts b/src/client/scene/scene-state.ts deleted file mode 100644 index bc69ff5..0000000 --- a/src/client/scene/scene-state.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../common/world/animated-tiles.ts' -import type { SorceressAppearanceSpec } from '../../common/units/char-appearance.ts' -import type { Monster, MonsterStats, NpcEntity } from '../../common/units/unit-types.ts' -import { getEmbeddedDropTables } from '../../common/items/embedded-drop-tables.ts' -import { enrichMonsterStats } from '../../common/units/monsters-data.ts' -import type { OpenPortal } from '../../common/world/portal.ts' -import { getTblLang, type TblLang } from '../i18n/lang.ts' -import type { - AutomapOverlayLabel, - AutomapViewportMetrics, - Minimap, -} from '../ui/minimap.ts' -import type { MonsterTargetHudInfo } from '../ui/world-panels.ts' -import type { DensityMultiplier, EliteMultiplier } from '../../common/world/scene-population.ts' -import type { LightingPreset } from './lighting-presets.ts' -import type { MapRuntime } from './pack-loader.ts' -import { countLoadedPages } from './pack-loader.ts' - -/** - * `localStorage` key holding the single save slot. - * - * Deliberately the same key the retired `map.html` sandbox used, so a save made - * there still loads here — the snapshot format never differed, only the scene - * that drove it. - */ -export const SAVE_KEY = 'd2web.save' -/** - * How often the HUD text is rewritten, in milliseconds. - * - * The overlay reports averages, so refreshing it at frame rate only buys string - * churn: four `toFixed` calls and ten concatenations that the reader cannot - * follow anyway. - */ -export const HUD_INTERVAL_MS = 250 - -/** Live scene state, also published for automated checks. */ -export interface ActSceneState { - ready: boolean - /** Where the map came from: a prebaked pack or the archives. */ - source: 'pack' | 'live' | '' - act: number - level: string - variant: string - variants: string[] - /** @deprecated Use `variant` instead. */ - quadrant: string - /** @deprecated Use `variants` instead. */ - quadrants: string[] - /** The archive/pack location actually used. */ - base: string - cellsX: number - cellsY: number - widthPx: number - heightPx: number - floors: number - shadows?: number - walls: number - frames: number - /** Atlas pages loaded and total. */ - pagesLoaded: number - pagesTotal: number - /** Pages that had arrived when the first frame was drawn. */ - pagesAtFirstFrame: number - /** Milliseconds from boot to the first drawn frame. */ - firstFrameMs: number - /** Page indices the spawn viewport needed, for diagnostics. */ - priorityPages: number[] - /** Draws skipped because their page had not arrived yet. */ - skippedDraws: number - /** Objects the pack places (their art still needs the DCC/COF decoder). */ - objects: number - /** Roof draws, painted in their own pass after the character. */ - roofs: number - /** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */ - roofAlpha: number - /** Whether the composited Sorceress sprite is in use (else the marker). */ - character: boolean - /** DCC members that went into the character sprite. */ - characterMembers: number - /** Frames the character path has actually drawn; 0 while the marker is in use. */ - characterFrames: number - /** Last group the character path selected (walk or stand, times 8 directions). */ - characterGroup: number - /** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */ - heroToken?: string - /** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */ - heroName?: string - /** Bounding box of the player sprite drawn this frame in scene coordinates. */ - playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined - /** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */ - weaponClass?: string | null | undefined - /** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */ - playerClip?: string | null | undefined - /** Canonical appearance cache key for dynamic modular paperdoll compositing. */ - characterAppearanceKey?: string | undefined - /** Active Sorceress 16-component appearance and palette shift spec. */ - characterAppearance?: SorceressAppearanceSpec | undefined - /** Milliseconds from boot to the first rendered frame. */ - loadMs: number - missing: number - walkable: number - x: number - y: number - facing: number - tick: number - tickRate: number - simTps: number - renderFps: number - fps: number - /** - * GPU draw calls the last frame actually issued. - */ - drawCalls: number - /** Quads the last frame submitted, i.e. what survived culling. */ - quadsDrawn: number - /** Draws rejected last frame because their bounds fell outside the viewport. */ - culledDraws: number - /** Wall-clock milliseconds the last `onRender` took. */ - renderMs: number - /** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */ - lightingPreset?: LightingPreset | undefined - /** Active lighting mode ('Glide 1.13c' or undefined). */ - lightingMode?: string | undefined - /** Ambient light intensity [0..255]. */ - ambientIntensity?: number | undefined - /** Ambient color [R, G, B] in [0..255]. */ - ambientColor?: readonly [number, number, number] | undefined - /** Current zoom factor and the canvas backing-store size. */ - zoom: number - canvasWidth: number - canvasHeight: number - error: string | null - dialog: string[] - npcs: number - npcsNear: NpcEntity[] - /** Count of animated tiles in the active map. */ - animatedTiles: number - /** Count of animated map objects in the active map. */ - animatedObjects: number - /** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */ - animSpeed?: number | undefined - /** Per-frame duration in ms computed from animSpeed. */ - frameDurationMs?: number | undefined - /** Monster ids the art loader could not dress, lower case. */ - missingMonsterArt: string[] - /** Monster-art loads that threw: a whole level, or one monster type. */ - monsterArtErrors: number - /** COF layers dropped inside art that did load: monsters missing body parts. */ - monsterArtLayerFailures: number - /** Monsters the level's own plan produced. */ - monstersPlanned: number - /** Active monster density multiplier (0.5, 1, 2, 4, 8). */ - densityMultiplier?: DensityMultiplier | undefined - /** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */ - eliteMultiplier?: EliteMultiplier | undefined - /** Dynamic monster re-budget and re-stream method without page reload. */ - reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void - /** Active player Town Portal (if open). */ - portal?: OpenPortal | null | undefined - /** Whether the automap/minimap is visible. */ - minimapVisible?: boolean | undefined - /** Active automap mode ('minimap' | 'full'). */ - automapMode?: 'minimap' | 'full' | undefined - /** Active automap projection divisor (20 for minimap, 10 for full). */ - automapDivisor?: 10 | 20 | undefined - /** Number of loaded automap sprite atlases (0..8). */ - automapAtlasesLoaded?: number | undefined - /** Pre-baked/active automap cell counts per layer. */ - automapCellCounts?: { - floors: number - walls: number - objects: number - extras: number - total: number - } | undefined - /** Revealed DRLG room count for the active level. */ - automapRevealedRooms?: number | undefined - /** Automap sprites drawn last frame per layer. */ - automapSpritesDrawn?: { - floors: number - walls: number - objects: number - extras: number - portals: number - total: number - } | undefined - /** Automap 12-segment crosshairs drawn last frame. */ - automapCrosshairsDrawn?: { - player: number - party: number - npcs: number - mercenaries: number - bosses: number - total: number - } | undefined - /** Revealed warp/stash Font6 labels on the automap. */ - automapWarpLabels?: AutomapOverlayLabel[] | undefined - /** Top-right gold Font16 Level Name + Difficulty HUD readout. */ - automapHudText?: { - levelName: string - difficulty: string - x: number - y1: number - y2: number - } | null | undefined - /** Active automap viewport metrics. */ - automapViewport?: AutomapViewportMetrics | undefined -} - -declare global { - interface Window { - /** Act scene state for automation and debugging. */ - __d2webAct?: ActSceneState - __d2webActs?: ActSceneState - __d2webMinimap?: Minimap - __d2webActScene?: { - switchHero: (token: string) => Promise - getHero: () => string - switchLighting?: (preset: string) => LightingPreset - getLighting?: () => LightingPreset - reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void - setPlayers?: (count: number, partyCount?: number) => void - executeCommand?: (cmd: string) => boolean - castTownPortal?: () => boolean - enterTownPortal?: () => void - getPortal?: () => OpenPortal | null - getMinimap?: () => Minimap - syncAppearance?: () => Promise - } - } -} - -export const state: ActSceneState = { - ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1, - cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0, - pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [], - character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1, - heroToken: 'so', heroName: 'Sorceress', - playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu', - skippedDraws: 0, objects: 0, loadMs: 0, - missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0, - drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, - zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, - dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0, - animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, - missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0, - densityMultiplier: 1, eliteMultiplier: 1, -} -if (typeof window !== 'undefined') { - window.__d2webAct = state - window.__d2webActs = state - ;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = state -} - -export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo { - const dropTables = getEmbeddedDropTables() - const stats = - monster.stats.isDemon === undefined && - monster.stats.isUndead === undefined && - monster.stats.nameKey === undefined - ? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques) - : monster.stats - const r = stats.rank - const statsAny = stats as MonsterStats & { - readonly descStr?: string - readonly subType?: string - readonly affixes?: readonly string[] - } - return { - name: stats.name || stats.id, - ...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}), - hp: monster.hp, - maxHp: stats.hp, - rank: - r === 'champion' - ? 'champion' - : r === 'unique' - ? 'unique' - : (r as string) === 'superunique' - ? 'superunique' - : r === 'minion' - ? 'minion' - : r === 'boss' || stats.isBoss === true - ? 'boss' - : 'normal', - ...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}), - ...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}), - ...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}), - ...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}), - ...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}), - ...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}), - ...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}), - ...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}), - ...(stats.resists !== undefined ? { resists: stats.resists } : {}), - ...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}), - } -} - -export interface MonsterArtDiagnosticState { - readonly missing: readonly string[] - readonly errors: number - readonly layerFailures: number -} - -/** - * The HUD fragment describing a monster-art load, empty when it was clean. - * - * @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}. - * @returns the fragment, ready to append to the HUD line. - */ -export function monsterArtStatusText( - load: MonsterArtDiagnosticState, - lang: TblLang = getTblLang(), -): string { - const isEn = lang === 'ENG' - const parts: string[] = [] - if (load.missing.length > 0) { - const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、') - const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : '' - parts.push( - isEn - ? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})` - : `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`, - ) - } - if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`) - if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`) - return parts.length === 0 ? '' : ` · ${parts.join(' · ')}` -} - -/** - * The HUD fragment describing a level's planned population. - * - * @param planned - how many monsters the plan produced. - * @returns the fragment, ready to append to the HUD line. - */ -export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string { - if (lang === 'ENG') { - return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}` - } - return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}` -} - -/** - * Publish a level's planned population into {@link ActSceneState}. - */ -export function reportMonsterPlan(runtime: MapRuntime): void { - const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0) - state.monstersPlanned = planned - if (planned === 0) { - console.info(`level ${runtime.level}: no monsters planned; the level stays empty`) - } -} - -/** - * Publish a load's failures into {@link ActSceneState}. - */ -export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): void { - state.missingMonsterArt = [...load.missing] - state.monsterArtErrors = load.errors - state.monsterArtLayerFailures = load.layerFailures - if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) { - console.error( - `monster art incomplete: ${String(load.missing.length)} type(s) without art ` - + `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), ` - + `${String(load.layerFailures)} dropped layer(s)`, - ) - } -} - -/** - * Copy a runtime's description into the published state. - * - * @param runtime - the runtime. - * @param base - location the data came from. - */ -export function publish(runtime: MapRuntime, base: string): void { - state.source = runtime.source - state.base = base - state.act = runtime.act - state.level = runtime.level - state.variant = runtime.variant - state.variants = [...runtime.variants] - state.quadrant = runtime.quadrant - state.quadrants = [...runtime.quadrants] - state.cellsX = runtime.cellsX - state.cellsY = runtime.cellsY - state.widthPx = runtime.widthPx - state.heightPx = runtime.heightPx - state.floors = runtime.floors.length - state.shadows = runtime.shadows?.length ?? 0 - state.walls = runtime.walls.length - state.frames = runtime.frames - state.pagesTotal = runtime.pages.length - state.pagesLoaded = countLoadedPages(runtime.pages) - state.priorityPages = [...runtime.priorityPages] - state.objects = runtime.objects - state.roofs = runtime.roofs.length - state.animatedTiles = runtime.animatedDrawables?.length ?? 0 - state.animatedObjects = runtime.animatedObjects?.length ?? 0 - state.animSpeed = runtime.animSpeed - state.frameDurationMs = runtime.frameDurationMs - state.roofAlpha = 1 - state.missing = 0 - state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length -} diff --git a/src/client/scene/toolbar-chat-ui.ts b/src/client/scene/toolbar-chat-ui.ts deleted file mode 100644 index 55995e8..0000000 --- a/src/client/scene/toolbar-chat-ui.ts +++ /dev/null @@ -1,948 +0,0 @@ -import type { Palette } from '../../common/formats/pal.ts' -import { WORLD_VARIANT_SEED } from '../../common/world/act-variants.ts' -import type { ActorAnimator } from '../render/actor-animator.ts' -import { - SorceressLayerCompositor, - appearanceKey, - resolveSorceressAppearance, -} from '../../common/units/char-appearance.ts' -import type { CharacterClassCode } from '../../common/units/classes.ts' -import { spawnMonsterPacks } from '../../common/units/monster-rooms.ts' -import type { MonsterPack, SafeZone, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import { cellCentre } from '../../common/world/d2map.ts' -import type { DropTables } from '../../common/items/drop-tables.ts' -import { - ACT_NAMES_EN, - ACT_NAMES_ZH, - getLevelNames, - sceneDisplayName, - variantDisplayLabel, -} from '../../common/world/level-names-zh.ts' -import type { LoadedMonsterArt } from '../render/monster-art.ts' -import { MonsterStreamingManager } from '../../common/units/monster-streaming.ts' -import { - buildLangSwitchHref, - getTblLang, - type TblLang, -} from '../i18n/lang.ts' -import type { AtlasFrame } from '../render/atlas.ts' -import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../render/renderer.ts' -import { cleanChiItemName } from '../ui/ground-labels.ts' -import { HudManager } from '../ui/hud-manager.ts' -import type { Minimap } from '../ui/minimap.ts' -import { handleActSceneChatCommand } from '../../common/world/chat-commands.ts' -import { subTileToScene } from '../../common/world/scene-links-nav.ts' -import { - HERO_CLASSES, - ROOM_ACTIVATION_RADIUS, - ROOM_DEACTIVATION_RADIUS, - buildStreamingRooms, - isTownLevel, - normalizeHeroToken, - parseDensityMultiplier, - parseEliteMultiplier, - rebudgetMonsterPacks, - type DensityMultiplier, - type EliteMultiplier, -} from '../../common/world/scene-population.ts' -import { - DEFAULT_BASES, - loadCharacterArt, - loadMonsterArtMap, - type LoadedCharacter, -} from './entity-art-loader.ts' -import { - LIGHTING_PRESETS, - normalizeLightingPreset, - type LightingPreset, -} from './lighting-presets.ts' -import { - ACT_COUNT, - fetchJson, - param, - type MapRuntime, - type PackIndex, -} from './pack-loader.ts' -import { - recordMonsterArtLoad, - reportMonsterPlan, - state, -} from './scene-state.ts' - -export { handleActSceneChatCommand } - -export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void { - if (!select) return - const isNonDefault = select.value !== defaultValue - select.classList.toggle('non-default', isNonDefault) - if (isNonDefault) { - select.style.border = '1px solid #e74c3c' - } else { - select.style.border = '' - } -} - -export function updateToolbarUrl( - currentUrlOrSearch: string | URL, - options: { density?: number | string | undefined; elite?: number | string | undefined }, -): string { - const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://') - const url = isUrl - ? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href) - : new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`) - - if (options.density !== undefined) { - const density = parseDensityMultiplier(options.density) - if (density === 1) { - url.searchParams.delete('density') - } else { - url.searchParams.set('density', String(density)) - } - } - - if (options.elite !== undefined) { - const elite = parseEliteMultiplier(options.elite) - if (elite === 1) { - url.searchParams.delete('elite') - } else { - url.searchParams.set('elite', String(elite)) - } - } - - if (isUrl) { - return url.toString() - } - return url.search -} - -export interface MountChatAndToolbarUiOptions { - readonly engine: GameEngine - readonly status: HTMLElement - readonly executeChatCommand: (rawCommand: string) => boolean - readonly densitySelect: HTMLSelectElement | null - readonly eliteSelect: HTMLSelectElement | null - readonly densityMultiplier: number - readonly eliteMultiplier: number | 'all' - readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise -} - -/** - * Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire - * toolbar density/elite selectors. - */ -export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void { - const { - executeChatCommand, - densitySelect, - eliteSelect, - densityMultiplier, - eliteMultiplier, - reloadMonsters, - } = options - - // In-game chat/console input overlay supporting "/players <1-8>" - if (typeof document !== 'undefined') { - let chatInput = document.querySelector('#d2-chat-input') - if (!chatInput) { - chatInput = document.createElement('input') - chatInput.id = 'd2-chat-input' - chatInput.type = 'text' - chatInput.placeholder = getTblLang() === 'ENG' - ? 'Type /players 1-8 or message, press Enter to send…' - : '输入 /players 1-8 或消息,按 Enter 发送…' - chatInput.className = 'overlay' - chatInput.style.position = 'fixed' - chatInput.style.left = '12px' - chatInput.style.bottom = '56px' - chatInput.style.width = '320px' - chatInput.style.padding = '4px 8px' - chatInput.style.background = 'rgba(11, 9, 6, 0.9)' - chatInput.style.color = '#e8dcc8' - chatInput.style.border = '1px solid #c8a15a' - chatInput.style.borderRadius = '4px' - chatInput.style.font = 'inherit' - chatInput.style.fontSize = '12px' - chatInput.style.zIndex = '10' - chatInput.style.display = 'none' - document.body.appendChild(chatInput) - } - - const openChat = () => { - if (!chatInput) return - chatInput.placeholder = getTblLang() === 'ENG' - ? 'Type /players 1-8 or message, press Enter to send…' - : '输入 /players 1-8 或消息,按 Enter 发送…' - chatInput.style.display = 'block' - chatInput.value = '' - chatInput.focus() - } - - const closeChat = () => { - if (!chatInput) return - chatInput.value = '' - chatInput.blur() - chatInput.style.display = 'none' - } - - chatInput.addEventListener('keydown', (e) => { - e.stopPropagation() - if (e.key === 'Enter') { - e.preventDefault() - const text = chatInput!.value - closeChat() - if (text) { - executeChatCommand(text) - } - } else if (e.key === 'Escape') { - e.preventDefault() - closeChat() - } - }) - - window.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const active = document.activeElement - if ( - active instanceof HTMLInputElement || - active instanceof HTMLSelectElement || - active instanceof HTMLTextAreaElement - ) { - return - } - e.preventDefault() - openChat() - } - }) - } - - if (densitySelect !== null) { - densitySelect.disabled = false - densitySelect.value = String(densityMultiplier) - updateSelectorWarningStyle(densitySelect, '1') - densitySelect.onchange = () => { - void reloadMonsters({ density: densitySelect.value }) - } - } - if (eliteSelect !== null) { - eliteSelect.disabled = false - eliteSelect.value = String(eliteMultiplier) - updateSelectorWarningStyle(eliteSelect, '1') - eliteSelect.onchange = () => { - void reloadMonsters({ elite: eliteSelect.value }) - } - } -} - -export interface HeroLightingAndMonsterControlsContext { - readonly renderer: SpriteRenderer - readonly dropTables: DropTables - readonly playerAnimator: ActorAnimator - readonly status: HTMLElement - readonly heroSelect: HTMLSelectElement | null - readonly lightingSelect: HTMLSelectElement | null - readonly densitySelect: HTMLSelectElement | null - readonly eliteSelect: HTMLSelectElement | null - readonly getRuntime: () => MapRuntime - readonly getEngine: () => GameEngine - readonly getHudManager: () => HudManager | null - readonly getCurrentHeroToken: () => string - readonly setCurrentHeroToken: (token: string) => void - readonly getCharacter: () => LoadedCharacter | null - readonly setCharacter: (char: LoadedCharacter | null) => void - readonly getActivePalette: () => Palette | null - readonly getPackEntityBase: () => string | undefined - readonly getPackEntityAct: () => number | undefined - readonly getCurrentLightingPreset: () => LightingPreset - readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void - readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier } - readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void - readonly getMonsterArtMap?: (() => Map) | undefined - readonly setMonsterArtMap: (map: Map) => void -} - -export interface HeroLightingAndMonsterControls { - readonly heroGen: LoadGeneration - readonly monsterGen: LoadGeneration - readonly lightingGen: LoadGeneration - readonly invalidateMonsterLoads: () => void - readonly syncSorceressAppearance: () => Promise - readonly switchLighting: (preset: string) => LightingPreset - readonly switchHero: (newHeroToken: string) => Promise - readonly changeHeroClass: (newHeroToken: string) => Promise - readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise - readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise -} - -export function createHeroLightingAndMonsterControls( - ctx: HeroLightingAndMonsterControlsContext, -): HeroLightingAndMonsterControls { - const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs') - const heroGen = new LoadGeneration() - const monsterGen = new LoadGeneration() - const lightingGen = new LoadGeneration() - let activeAppearanceKey = '' - let appearanceSeq = 0 - - const invalidateMonsterLoads = (): void => { - monsterGen.invalidate() - } - - const syncSorceressAppearance = async (): Promise => { - const hudInst = ctx.getHudManager() ?? ( - typeof window !== 'undefined' - ? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null - : null - ) - const currentHeroToken = ctx.getCurrentHeroToken() - if (currentHeroToken !== 'so' || hudInst === null) { - activeAppearanceKey = '' - state.characterAppearanceKey = undefined - state.characterAppearance = undefined - if (hudInst !== null) { - const wCode = hudInst.inventory.equipped.weapon1?.code - state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth') - } - return false - } - - const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables) - const key = appearanceKey(spec) - state.weaponClass = spec.wclass - state.characterAppearance = spec - - if (key === activeAppearanceKey) return false - activeAppearanceKey = key - state.characterAppearanceKey = key - const seq = ++appearanceSeq - - try { - const composited = await soCompositor.composite(spec) - if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false - const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height) - const groups: AtlasFrame[][] = composited.groups.map(group => - group.map(([x, y, width, height, anchorX, anchorY]) => ({ - x, - y, - width, - height, - anchorX, - anchorY, - })), - ) - const prevChar = ctx.getCharacter() - if (prevChar?.handle && prevChar.handle !== newHandle) ctx.renderer.deleteAtlas(prevChar.handle) - ctx.setCharacter({ - handle: newHandle, - groups, - directions: composited.directions, - walk: composited.walk, - stand: composited.stand, - members: composited.members, - clips: composited.clips, - }) - state.character = true - state.characterMembers = composited.members - state.characterAppearanceKey = key - state.characterAppearance = spec - if (ctx.playerAnimator.mode !== null) { - const nextClip = composited.clips[ctx.playerAnimator.mode] - if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip) - } - return true - } catch (err) { - console.warn('Sorceress appearance composite failed:', err) - return false - } - } - - const switchLighting = (preset: string): LightingPreset => { - const { gen } = lightingGen.next() - const nextPreset = normalizeLightingPreset(preset) - const cfg = LIGHTING_PRESETS[nextPreset] - ctx.setLightingState(nextPreset, cfg.enabled) - const isEn = getTblLang() === 'ENG' - const modeLabel = isEn ? cfg.labelEn : cfg.labelZh - state.lightingPreset = nextPreset - state.lightingMode = modeLabel - if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) { - ctx.lightingSelect.value = nextPreset - } - try { - const url = new URL(location.href) - if (nextPreset === 'auto') url.searchParams.delete('lighting') - else url.searchParams.set('lighting', nextPreset) - history.replaceState(null, '', url.toString()) - } catch { - // ignore in non-browser context - } - const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:' - ctx.status.textContent = `${msgPrefix}${modeLabel}` - setTimeout(() => { - if (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) { - ctx.status.textContent = '' - } - }, 2500) - return nextPreset - } - - const switchHero = async (newHeroToken: string): Promise => { - const { gen } = heroGen.next() - const nextToken = normalizeHeroToken(newHeroToken) - if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true - const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so - const isEn = getTblLang() === 'ENG' - const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh - ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …` - const runtime = ctx.getRuntime() - const nextChar = await loadCharacterArt( - runtime.charBases, - ctx.getActivePalette(), - ctx.renderer, - ctx.getPackEntityBase(), - ctx.getPackEntityAct(), - nextToken, - ) - if (!heroGen.isCurrent(gen)) { - if (nextChar !== null && nextChar.handle && nextChar.handle !== ctx.getCharacter()?.handle) { - ctx.renderer.deleteAtlas(nextChar.handle) - } - return false - } - if (nextChar !== null) { - const prevChar = ctx.getCharacter() - if (prevChar?.handle && prevChar.handle !== nextChar.handle) { - ctx.renderer.deleteAtlas(prevChar.handle) - } - ctx.setCharacter(nextChar) - ctx.setCurrentHeroToken(nextToken) - state.heroToken = nextToken - state.heroName = nextInfo.nameEn - state.character = true - state.characterMembers = nextChar.members - const hudManager = ctx.getHudManager() - if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode) - activeAppearanceKey = '' - if (nextToken === 'so') { - await syncSorceressAppearance() - if (!heroGen.isCurrent(gen)) return false - } else { - state.characterAppearanceKey = undefined - state.characterAppearance = undefined - } - if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.heroSelect.value = nextToken - try { - const url = new URL(location.href) - url.searchParams.set('hero', nextToken) - history.replaceState(null, '', url.toString()) - } catch { - // ignore in non-browser context - } - const curChar = ctx.getCharacter() - if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true }) - const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 ' - ctx.status.textContent = isEn - ? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).` - : `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。` - setTimeout(() => { - if (heroGen.isCurrent(gen) && ctx.status.textContent.startsWith(switchedPrefix)) { - ctx.status.textContent = '' - } - }, 3000) - return true - } - ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。` - return false - } - - const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise => { - const { gen } = monsterGen.next() - let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers() - if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density) - if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite) - ctx.setMultipliers(densityMultiplier, eliteMultiplier) - state.densityMultiplier = densityMultiplier - state.eliteMultiplier = eliteMultiplier - - if (ctx.densitySelect !== null) { - ctx.densitySelect.value = String(densityMultiplier) - updateSelectorWarningStyle(ctx.densitySelect, '1') - } - if (ctx.eliteSelect !== null) { - ctx.eliteSelect.value = String(eliteMultiplier) - updateSelectorWarningStyle(ctx.eliteSelect, '1') - } - try { - if (typeof location !== 'undefined' && typeof history !== 'undefined') { - const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier }) - const newUrl = `${location.pathname}${nextSearch}${location.hash}` - history.replaceState(null, '', newUrl) - } - } catch { - // ignore in non-browser context - } - - const runtime = ctx.getRuntime() - const engine = ctx.getEngine() - if (isTownLevel(runtime.levelId)) { - runtime.monsterPacks = [] - engine.world.monsters.length = 0 - engine.streamingManager = undefined - for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) { - ctx.renderer.deleteAtlas(oldArt.handle) - } - ctx.setMonsterArtMap(new Map()) - reportMonsterPlan(runtime) - return - } - - const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks - runtime.baseMonsterPacks = basePacks - runtime.monsterPacks = rebudgetMonsterPacks(basePacks, { - densityMultiplier, - eliteMultiplier, - levelId: runtime.levelId, - seed: WORLD_VARIANT_SEED ^ runtime.levelId, - }) - - engine.world.monsters.length = 0 - - const player = engine.world.player - const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }] - for (const wp of runtime.waypoints) { - const pos = subTileToScene(wp.x, wp.y, runtime.grid) - currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - for (const ent of runtime.entrances) { - const pos = subTileToScene(ent.x, ent.y, runtime.grid) - currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - for (const s of runtime.stashes ?? []) { - currentSafeZones.push({ x: s.x, y: s.y, radius: 160 }) - } - - const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => { - if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { - return { - ...pack, - fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY), - } - } - return pack - }) - - const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones) - if (nextRooms.length > 0) { - engine.streamingManager = new MonsterStreamingManager( - engine.world, - engine.terrain, - nextRooms, - { - activationRadius: ROOM_ACTIVATION_RADIUS, - deactivationRadius: ROOM_DEACTIVATION_RADIUS, - safeZones: currentSafeZones, - }, - ) - } else { - engine.streamingManager = undefined - if (currentResolvedPacks.length > 0) { - spawnMonsterPacks( - engine.world, - currentResolvedPacks, - { x: player.x, y: player.y }, - Math.max(runtime.widthPx, runtime.heightPx) / 4, - engine.terrain, - currentSafeZones, - ) - } - } - reportMonsterPlan(runtime) - - if (runtime.palette !== null) { - const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id)) - const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds])) - if (activeMonsterIds.length > 0) { - const loaded = await loadMonsterArtMap( - DEFAULT_BASES, - activeMonsterIds, - runtime.palette, - ctx.renderer, - ctx.getPackEntityBase(), - ctx.getPackEntityAct(), - ) - if (!monsterGen.isCurrent(gen) || runtime.disposed || ctx.getRuntime() !== runtime) { - for (const art of loaded.map.values()) { - ctx.renderer.deleteAtlas(art.handle) - } - return - } - const keepHandles = new Set() - for (const art of loaded.map.values()) { - keepHandles.add(art.handle) - } - for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) { - if (!keepHandles.has(oldArt.handle)) { - ctx.renderer.deleteAtlas(oldArt.handle) - } - } - ctx.setMonsterArtMap(loaded.map) - recordMonsterArtLoad(loaded) - } else { - for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) { - ctx.renderer.deleteAtlas(oldArt.handle) - } - ctx.setMonsterArtMap(new Map()) - } - } - } - - return { - heroGen, - monsterGen, - lightingGen, - invalidateMonsterLoads, - syncSorceressAppearance, - switchLighting, - switchHero, - changeHeroClass: switchHero, - reloadMonsters, - respawnMonsters: reloadMonsters, - } -} - -export async function changeHeroClass( - controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext, - newHeroToken: string, -): Promise { - if ('switchHero' in controlsOrCtx) { - return await controlsOrCtx.switchHero(newHeroToken) - } - const controls = createHeroLightingAndMonsterControls(controlsOrCtx) - return await controls.switchHero(newHeroToken) -} - -export async function respawnMonsters( - controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext, - options?: { density?: number | string; elite?: number | string }, -): Promise { - if ('reloadMonsters' in controlsOrCtx) { - await controlsOrCtx.reloadMonsters(options) - return - } - const controls = createHeroLightingAndMonsterControls(controlsOrCtx) - await controls.reloadMonsters(options) -} - -export async function createActSceneHudManager(options: { - readonly runtime: MapRuntime - readonly engine: GameEngine - readonly minimap: Minimap - readonly status: HTMLElement - readonly currentHeroToken: string - readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void - readonly onCastTownPortal: () => void -}): Promise { - const { - runtime, - engine, - minimap, - status, - currentHeroToken, - onWaypointTeleport, - onCastTownPortal, - } = options - const hudCanvas = document.querySelector('#d2-hud-canvas') - const hudManager = hudCanvas !== null - ? new HudManager(hudCanvas, { - onToggleAutomap: () => { - minimap.toggleVisibility() - state.minimapVisible = minimap.visible - }, - onToggleAutomapMode: () => { - minimap.toggleMinimapMode() - state.minimapVisible = minimap.visible - state.automapMode = minimap.mode - state.automapDivisor = minimap.divisor - }, - onCycleAutomapFade: () => { minimap.cycleFade() }, - onToggleAutomapNames: () => { minimap.toggleNames() }, - onResetAutomapPan: () => { minimap.resetPan() }, - onPanAutomap: (dx, dy) => minimap.pan(dx, dy), - onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => { - minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight) - }, - onWaypointTeleport: (targetAct, slug, levelId) => { - onWaypointTeleport(targetAct, slug, levelId) - }, - onCastTownPortal: () => onCastTownPortal(), - onDropItemToGround: (item) => { - const player = engine.world.player - const groundItem = engine.dropItem(item, player.x, player.y) - const matchingGround = engine.ground.find((g: { item?: unknown }) => (g.item as unknown) === item) - if (matchingGround) { - ;(matchingGround as { groundItemId?: string }).groundItemId = groundItem.id - } - const isEn = getTblLang() === 'ENG' - status.textContent = isEn - ? `Dropped "${groundItem.name}" on ground` - : `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面` - }, - onDropGoldToGround: (amount) => { - const player = engine.world.player - let groundGold: unknown = true - if (!hudManager?.engine) { - if (hudManager && engine.bag) { - engine.bag.gold = hudManager.inventory.gold - } - groundGold = engine.dropGold(amount, player.x, player.y) - } - if (groundGold) { - const isEn = getTblLang() === 'ENG' - status.textContent = isEn - ? `Dropped ${amount.toLocaleString()} Gold on ground` - : `已将 ${amount.toLocaleString()} 金币丢弃在地面` - } - }, - }) - : null - if (hudManager !== null) { - hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null) - hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null) - hudManager.worldPanels.selectedActTab = runtime.act - try { - await hudManager.init('/ui') - const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so - hudManager.switchClass(initialHeroInfo.code as CharacterClassCode) - const customCharName = param('charName', '').trim() - if (customCharName) hudManager.charSheet.attrs.name = customCharName - } catch (err) { - console.error('Failed to initialize HudManager:', err) - } - if (typeof window !== 'undefined' && window.__d2webHud) { - (window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount - } - } - return hudManager -} - -export function wireBootToolbarSelectors(options: { - readonly isEn: boolean - readonly heroSelect: HTMLSelectElement | null - readonly actSelect: HTMLSelectElement - readonly sceneSelect: HTMLSelectElement - readonly variantSelect: HTMLSelectElement - readonly lightingSelect: HTMLSelectElement | null - readonly langSwitch: HTMLAnchorElement | null -}): number { - const { - isEn, - heroSelect, - actSelect, - sceneSelect, - variantSelect, - lightingSelect, - langSwitch, - } = options - - if (langSwitch !== null && typeof location !== 'undefined') { - const targetLang: TblLang = isEn ? 'CHI' : 'ENG' - langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN' - langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) - langSwitch.addEventListener('click', () => { - langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) - }) - } - - if (lightingSelect !== null) { - lightingSelect.disabled = false - lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto')) - lightingSelect.onchange = () => { - const params = new URLSearchParams(location.search) - if (lightingSelect.value === 'auto') params.delete('lighting') - else params.set('lighting', lightingSelect.value) - location.search = params.toString() - } - } - if (heroSelect !== null) { - heroSelect.disabled = false - heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so'))) - heroSelect.onchange = () => { - const params = new URLSearchParams(location.search) - params.set('hero', heroSelect.value) - location.search = params.toString() - } - } - const densitySelect = document.querySelector('#density') - const eliteSelect = document.querySelector('#elite') - if (densitySelect !== null) { - densitySelect.disabled = false - densitySelect.value = String(parseDensityMultiplier(param('density', '1'))) - updateSelectorWarningStyle(densitySelect, '1') - } - if (eliteSelect !== null) { - eliteSelect.disabled = false - eliteSelect.value = String(parseEliteMultiplier(param('elite', '1'))) - updateSelectorWarningStyle(eliteSelect, '1') - } - actSelect.disabled = false - sceneSelect.disabled = true - variantSelect.disabled = true - - if (typeof Image !== 'undefined') { - const criticalImages = [ - '/ui/ctrlpnl-800.png', - '/ui/globe-life.png', - '/ui/globe-mana.png', - '/ui/globe-poison.png', - '/ui/overlap-left.png', - '/ui/overlap-right.png', - '/ui/minipanel.png', - '/ui/minipanel-btns.png', - '/ui/runbutton.png', - '/ui/menubutton.png', - '/ui/border-left.png', - '/ui/border-right.png', - ] - for (const src of criticalImages) { - const img = new Image() - img.src = src - } - } - - const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1')))) - state.act = act - actSelect.value = String(act) - actSelect.addEventListener('change', () => { - const params = new URLSearchParams(location.search) - params.set('act', actSelect.value) - params.delete('level') - location.search = params.toString() - }) - return act -} - -/** - * Fill the level picker from a pack index and wire it to navigation. - * - * Every baked map is one entry, grouped by act. Picking one reloads the page with - * `?act=` and `?level=`, which is the same query the loader already understands, - * so the picker needs no second loading path. - * - * @param packBase - directory holding `index.json`. - * @param current - the act and the entry label currently displayed (`runtime.quadrant`), - * so the list marks exactly the map that is on screen. - */ -export async function populatePackSelectors( - packBase: string, - current: { act: number; label: string }, -): Promise { - const actSelect = document.querySelector('#act') - const sceneSelect = document.querySelector('#scene') - const variantSelect = document.querySelector('#variant') - if (actSelect === null || sceneSelect === null || variantSelect === null) return - let index: PackIndex - try { - index = await fetchJson(`${packBase}/index.json`) - } catch { - return - } - - const isEn = getTblLang() === 'ENG' - - /** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */ - interface Scene { - readonly slug: string - readonly name: string - readonly variants: readonly PackIndex['levels'][number][] - } - - const scenesByAct = new Map() - for (const entry of index.levels) { - let scenes = scenesByAct.get(entry.act) - if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) } - let scene = scenes.find(candidate => candidate.slug === entry.slug) - if (scene === undefined) { - scene = { - slug: entry.slug, - name: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'), - variants: [], - } - scenes.push(scene) - } - ;(scene.variants as PackIndex['levels'][number][]).push(entry) - } - - /** The suffix after the level slug, which is the DS1's own name fragment. */ - const suffixOf = (entry: PackIndex['levels'][number]): string => - entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label - - const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => { - const option = document.createElement('option') - option.value = value - option.textContent = text - option.selected = selected - return option - } - - const acts = [...scenesByAct.keys()].sort((left, right) => left - right) - actSelect.replaceChildren(...acts.map(act => optionFor( - String(act), - isEn - ? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}` - : `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`, - act === current.act, - ))) - actSelect.disabled = acts.length === 0 - - const scenes = scenesByAct.get(current.act) ?? [] - const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label) - const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? '' - sceneSelect.replaceChildren(...scenes.map(scene => optionFor( - scene.slug, - scene.name, - scene.slug === currentSceneSlug, - ))) - sceneSelect.disabled = scenes.length === 0 - - const populateVariants = (slug: string, selectedLabel: string): void => { - const scene = scenes.find(candidate => candidate.slug === slug) - const variants = scene?.variants ?? [] - // A single-variant scene still shows its own DS1 so the third selector is never - // an empty box; the option text repeats the scene name only when it is the only one. - variantSelect.replaceChildren(...variants.map(entry => { - const suffix = suffixOf(entry) - const label = variants.length > 1 - ? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI') - : isEn - ? `${scene?.name ?? entry.levelName} (${suffix})` - : `${scene?.name ?? entry.levelName}(${suffix})` - return optionFor(entry.label, label, entry.label === selectedLabel) - })) - variantSelect.disabled = variants.length === 0 - } - populateVariants(currentSceneSlug, current.label) - variantSelect.dataset.scene = currentSceneSlug - - const go = (params: URLSearchParams): void => { location.search = params.toString() } - const navigateToLabel = (label: string, act: number): void => { - const params = new URLSearchParams(location.search) - params.set('act', String(act)) - params.set('level', label) - go(params) - } - - actSelect.onchange = () => { - const act = Number(actSelect.value) - const first = scenesByAct.get(act)?.[0] - // Keep the address bar and the map in sync: an act with no baked scene yet (there - // are none today) simply leaves the current map alone. - if (first === undefined) return - navigateToLabel(first.variants[0]?.label ?? '', act) - } - sceneSelect.onchange = () => { - const scene = scenes.find(candidate => candidate.slug === sceneSelect.value) - if (scene === undefined) return - populateVariants(scene.slug, '') - navigateToLabel(scene.variants[0]?.label ?? '', current.act) - } - variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) } -} diff --git a/src/client/scene/transition.ts b/src/client/scene/transition.ts deleted file mode 100644 index 66a8856..0000000 --- a/src/client/scene/transition.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Level transitions: the fade, and the arithmetic of arriving. - * - * Two kinds of crossing, one pipeline. Walking through a gap in the border - * between the Cold Plains and the Stony Field is, in the original game, not a - * transition at all — the two levels share one coordinate space and the engine - * streams the neighbour in as you approach. This engine does not have that - * shared space yet, so both kinds load a fresh level; the seam simply gets a - * much shorter fade, short enough to read as continuous motion rather than as - * a loading screen. - * - * The fade is presentation, not simulation. It runs on wall-clock time in a DOM - * overlay and never touches the fixed-rate tick, because the tick has to stay a - * pure function of the inputs for lockstep networking to work. - */ - -import { - SEAM_FADE_MS, - WARP_FADE_MS, - subTileToScene, - type ScenePoint, -} from '../../common/world/d2map.ts' - -export { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene, type ScenePoint } - -/** - * The fade overlay. - * - * A plain absolutely-positioned div rather than a renderer pass: it has to - * cover the HUD and the NPC name labels, which are DOM, and a WebGL quad would - * sit underneath them. - */ -export class FadeOverlay { - private readonly element: HTMLElement - - /** - * @param host - the element to cover; the overlay is appended to it. - */ - constructor(host: HTMLElement) { - const element = document.createElement('div') - element.style.position = 'fixed' - element.style.inset = '0' - element.style.background = '#000' - element.style.opacity = '0' - element.style.pointerEvents = 'none' - element.style.zIndex = '50' - element.style.transition = 'opacity 0ms linear' - host.appendChild(element) - this.element = element - } - - /** - * Fade to black. - * - * @param durationMs - how long to take. - */ - async out(durationMs: number): Promise { - await this.ramp('1', durationMs) - } - - /** - * Fade back in. - * - * @param durationMs - how long to take. - */ - async in(durationMs: number): Promise { - await this.ramp('0', durationMs) - } - - /** Remove the overlay from the page. */ - dispose(): void { - this.element.remove() - } - - /** - * Run one leg of the fade and resolve when it is over. - * - * @param opacity - the target opacity. - * @param durationMs - how long to take. - */ - private async ramp(opacity: string, durationMs: number): Promise { - this.element.style.transition = `opacity ${String(durationMs)}ms linear` - // Read back a layout property so the browser commits the starting opacity - // before the new one is set; without this the two assignments coalesce and - // the transition never runs. - void this.element.offsetHeight - this.element.style.opacity = opacity - await new Promise(resolve => { setTimeout(resolve, durationMs) }) - } -} diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 8d8d7f1..6e5df97 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,1103 +1,24 @@ -import type { AudioManager } from '../audio/manager.ts' -import { ActorAnimator } from '../render/actor-animator.ts' -import { updateAnimatableTiles } from '../../common/world/animated-tiles.ts' import { facingToDirection } from '../../common/units/character.ts' -import { - ORTHO_CELL_HEIGHT, - ORTHO_CELL_WIDTH, - ORTHO_SUB_TILE_HEIGHT, - ORTHO_SUB_TILE_WIDTH, - type CollisionGrid, -} from '../../common/world/d2map.ts' -import { syncEngineState, type SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import type { Environment } from '../../common/world/environment.ts' -import type { LightGrid } from '../../common/drlg/light-grid.ts' -import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts' -import { SUB_TILES_PER_TILE } from '../../common/world/map.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' -import { monsterDisplayName } from '../../common/units/monsters-data.ts' -import { getTownNpcDescriptorByName, npcDisplayName } from '../../common/units/npc-table.ts' -import type { TownPortalSlot } from '../../common/world/portal.ts' -import { getMissileTxtData } from '../../common/skills/missiles-data.ts' -import { getTblLang } from '../i18n/lang.ts' -import { intersects, viewportRect } from '../render/cull.ts' -import type { LightningTrailManager } from '../render/missile-renderer.ts' +import { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' -import type { RoofFadeController } from '../render/roofs.ts' -import type { ViewportCamera } from '../sim/camera.ts' -import type { GameLoop } from '../sim/loop.ts' -import type { GroundLabelOverlay } from '../ui/ground-labels.ts' +import type { AutomapEntityMarker, Minimap, MinimapLevel } from '../ui/minimap.ts' import type { HudManager } from '../ui/hud-manager.ts' -import type { - AutomapEntityMarker, - Minimap, - MinimapLevel, - MinimapMarker, -} from '../ui/minimap.ts' -import { subTileToScene } from '../../common/world/scene-links-nav.ts' +import { getTblLang } from '../i18n/lang.ts' import { isTownLevel } from '../../common/world/scene-population.ts' import type { LoadedCharacter } from './entity-art-loader.ts' -import { drawGroundItem } from './ground-item-renderer.ts' +import type { LightingPreset } from './lighting-presets.ts' import { - LIGHTING_PRESETS, - type LightingPreset, -} from './lighting-presets.ts' -import { - drawCastOverlay, - drawExplosion, - drawMissileProjectile, drawTownPortal, type LoadedMissileArt, type LoadedOverlayArt, } from './missile-overlay-renderer.ts' -import type { SceneMouseController } from './mouse-controller.ts' -import { - ADDITIVE_OBJECT_TOKENS, - updateAnimatedObjects, - type Drawable, -} from './object-animation.ts' -import { updateGroundItemLabelsOverlay } from './overhead-labels.ts' -import { - FEET_HEIGHT, - MARKER_HEIGHT, - MARKER_WIDTH, - countLoadedPages, - type MapRuntime, -} from './pack-loader.ts' -import { - HUD_INTERVAL_MS, - monsterArtStatusText, - monsterPlanStatusText, - state, -} from './scene-state.ts' -/** - * The cell containing a scene point. - * - * @param grid - the collision grid. - * @param x - scene x. - * @param y - scene y. - * @returns rounded-down cell coordinates. - */ -export function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } { - const px = x - grid.originX - const py = y - grid.originY - return { - x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2), - y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2), - } -} - -/** - * Where the player is, in sub-tiles. - */ -export function playerSubTile(engine: GameEngine, runtime: MapRuntime): { x: number; y: number } { - const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } -} - -/** - * Build or reuse the cached `MinimapLevel` for `runtime`. - */ -export function buildMinimapLevel( - runtime: MapRuntime, - minimapCache: { key: string; level: MinimapLevel } | null, -): { key: string; level: MinimapLevel } { - const currentLang = getTblLang() - const key = `${currentLang}:${runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath}` - if (minimapCache !== null && minimapCache.key === key) return minimapCache - const marker = ( - x: number, - y: number, - kind: MinimapMarker['kind'], - toLevelId?: number, - ): MinimapMarker => { - const targetName = - toLevelId !== undefined && toLevelId > 0 - ? levelDisplayName(toLevelId, currentLang) - : undefined - return { - cellX: Math.floor(x / SUB_TILES_PER_TILE), - cellY: Math.floor(y / SUB_TILES_PER_TILE), - kind, - ...(toLevelId !== undefined ? { toLevelId } : {}), - ...(targetName !== undefined ? { targetName } : {}), - } - } - const resolvedLevelName = - runtime.levelId > 0 - ? levelDisplayName(runtime.levelId, currentLang) || runtime.level - : runtime.level - const level: MinimapLevel = { - key, - cellsX: runtime.grid.cellsX, - cellsY: runtime.grid.cellsY, - blocked: runtime.grid.blocked, - gridWidth: runtime.grid.gridWidth, - originX: runtime.grid.originX, - originY: runtime.grid.originY, - ...(runtime.automap !== undefined ? { automap: runtime.automap } : {}), - levelId: runtime.levelId, - levelName: resolvedLevelName, - difficulty: currentLang === 'ENG' ? 'Normal' : '普通', - preRevealTown: isTownLevel(runtime.levelId), - markers: [ - ...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit', entrance.toLevelId)), - ...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit', warp.toLevelId)), - ...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')), - ], - } - return { key, level } -} - -export interface StepAnimationsContext { - readonly engine: GameEngine - readonly runtime: MapRuntime - readonly character: LoadedCharacter | null - readonly playerAnimator: ActorAnimator - readonly monsterAnimators: Map - readonly monsterArtMap: ReadonlyMap - readonly hudManager: HudManager | null - readonly audioManager: AudioManager - readonly environment: Environment - readonly lightingEnabled: boolean - readonly syncSorceressAppearance: () => Promise - readonly effectiveMovement: { readonly x: number; readonly y: number } - playerMoving: boolean - playerWalkFrame: number - monsterWalkFrame: number -} - -export function stepPlayerAndMonsterAnimations(ctx: StepAnimationsContext): { - playerMoving: boolean - playerWalkFrame: number - monsterWalkFrame: number -} { - const { - engine, - runtime, - character, - playerAnimator, - monsterAnimators, - monsterArtMap, - hudManager, - audioManager, - environment, - lightingEnabled, - syncSorceressAppearance, - effectiveMovement, - } = ctx - - const player = engine.world.player - const playerMoving = effectiveMovement.x !== 0 || effectiveMovement.y !== 0 - const playerWalkFrame = playerMoving ? (ctx.playerWalkFrame + 1) % 24 : 0 - const monsterWalkFrame = (ctx.monsterWalkFrame + 1) % 120 - - // Advance player animation - if (character !== null) { - if (player.hp <= 0) { - if (playerAnimator.mode !== 'dt' && playerAnimator.mode !== 'dd') { - const clip = character.clips?.['dt'] - if (clip !== undefined) playerAnimator.play('dt', clip) - } else if (playerAnimator.mode === 'dt' && playerAnimator.finished) { - const clip = character.clips?.['dd'] - if (clip !== undefined) playerAnimator.play('dd', clip) - } - } else { - const isOneShot = - playerAnimator.mode === 'a1' || - playerAnimator.mode === 'a2' || - playerAnimator.mode === 'sc' || - playerAnimator.mode === 'gh' - if (!isOneShot || playerAnimator.finished) { - if (playerMoving) { - const isRunning = hudManager ? hudManager.isRunning : false - const mode = isRunning && character.clips?.['rn'] ? 'rn' : 'wl' - const clip = character.clips?.[mode] ?? character.clips?.['wl'] - if (clip !== undefined) playerAnimator.play(mode, clip) - } else { - const clip = character.clips?.['nu'] - if (clip !== undefined) playerAnimator.play('nu', clip) - } - } - } - playerAnimator.tick() - state.playerClip = playerAnimator.mode ?? (playerMoving ? 'wl' : 'nu') - void syncSorceressAppearance() - } - - // Advance monster animations - const activeMonsterIndices = new Set() - for (const monster of engine.world.monsters) { - activeMonsterIndices.add(monster.index) - let animator = monsterAnimators.get(monster.index) - if (animator === undefined) { - animator = new ActorAnimator() - monsterAnimators.set(monster.index, animator) - } - const art = monsterArtMap.get(monster.stats.id.toLowerCase()) - if (art !== undefined && art.clips !== undefined) { - if (art.clips['dt'] !== undefined && art.clips['dt'].frames > 0 && !monster.dropRolled) { - monster.deathGated = true - } - if (monster.state === 'dead' || monster.animMode === 'dt') { - if (animator.mode !== 'dt' && animator.mode !== 'dd') { - const clip = art.clips['dt'] - if (clip !== undefined && clip.frames > 0) { - animator.play('dt', clip) - monster.animMode = 'dt' - monster.deathGated = true - } else { - const ddClip = art.clips['dd'] - if (ddClip !== undefined) animator.play('dd', ddClip) - monster.animMode = 'dd' - engine.triggerMonsterDrop(monster) - } - } else if (animator.mode === 'dt' && animator.finished) { - const clip = art.clips['dd'] - if (clip !== undefined) animator.play('dd', clip) - monster.animMode = 'dd' - engine.triggerMonsterDrop(monster) - } - } else if (monster.state === 'attack') { - if (animator.mode !== 'a1' && animator.mode !== 'a2') { - const clip = art.clips['a1'] ?? art.clips['a2'] - if (clip !== undefined) animator.play('a1', clip) - } - } else if (monster.state === 'chase') { - const clip = art.clips['wl'] ?? art.clips['nu'] - if (clip !== undefined) animator.play('wl', clip) - } else { - const clip = art.clips['nu'] ?? art.clips['wl'] - if (clip !== undefined) animator.play('nu', clip) - } - if ( - monster.state === 'dead' || - monster.animMode === 'dt' || - !monster.frozenTicks || - monster.frozenTicks <= 0 - ) { - animator.tick() - } - } else if ((monster.state === 'dead' || monster.animMode === 'dt') && !monster.dropRolled) { - engine.triggerMonsterDrop(monster) - } - } - for (const idx of monsterAnimators.keys()) { - if (!activeMonsterIndices.has(idx)) monsterAnimators.delete(idx) - } - - state.tick = engine.world.tick - state.x = player.x - state.y = player.y - state.facing = player.facing - - syncEngineState(engine, state) - // Update ground item bounce parabolic physics and trigger drop SFX at dropsfxframe - engine.groundItems.updateBounces(engine.world.tick * 40, audioManager) - if (lightingEnabled) { - environment.tick(runtime.levelId, runtime.act) - } - - return { playerMoving, playerWalkFrame, monsterWalkFrame } -} - -export interface DynamicLightingContext { - readonly renderer: SpriteRenderer - readonly engine: GameEngine - readonly runtime: MapRuntime - readonly lightGrid: LightGrid - readonly environment: Environment - readonly portalSlot: TownPortalSlot - readonly currentLightingPreset: LightingPreset - readonly lightingEnabled: boolean -} - -export function applyDynamicSceneLighting(ctx: DynamicLightingContext): void { - const { - renderer, - engine, - runtime, - lightGrid, - environment, - portalSlot, - currentLightingPreset, - lightingEnabled, - } = ctx - - if (lightingEnabled) { - const pSub = playerSubTile(engine, runtime) - lightGrid.centerOnSubTile(pSub.x, pSub.y) - lightGrid.clear() - lightGrid.updateObstacles(runtime.grid) - - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - const overrideAmb = presetCfg.overrideAmbient - const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity - const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave' - - const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - const basePlayerRadius = isDarkCave ? 20 : 22 - lightGrid.addLight({ - subTileX: pSub.x, - subTileY: pSub.y, - radius: basePlayerRadius + flicker, - intensity: 242, - red: 255, - green: 234, - blue: 196, - }) - - const skillToMissile: Readonly> = { - '36': 'firebolt', - '38': 'chargedbolt', - '39': 'icebolt', - '45': 'iceblast', - '47': 'fireball', - '49': 'lightning', - '53': 'chainlightning', - '55': 'glacialspike', - '56': 'meteor', - '64': 'frozenorb', - '67': 'teeth', - '84': 'bonespear', - '93': 'bonespirit', - } - for (const shot of engine.projectiles) { - const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) - if (!mType) continue - const mData = getMissileTxtData(mType) - const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) - if (lightUnits > 0) { - const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const shotSubX = Math.round((dy + dx) / 2) - const shotSubY = Math.round((dy - dx) / 2) - const isCold = mData.blue > mData.red - lightGrid.addLight({ - subTileX: shotSubX, - subTileY: shotSubY, - radius: Math.max(14, lightUnits * 2), - intensity: 250, - red: isCold ? Math.max(120, mData.red) : mData.red, - green: isCold ? Math.max(175, mData.green) : mData.green, - blue: mData.blue, - }) - } - } - - for (const exp of engine.explosions) { - const expData = getMissileTxtData(exp.missileType) - const lightUnits = expData && expData.light > 0 ? expData.light : 8 - const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const expSubX = Math.round((dy + dx) / 2) - const expSubY = Math.round((dy - dx) / 2) - const isColdExp = - exp.missileType.includes('ice') || - exp.missileType.includes('freeze') || - exp.missileType.includes('blizzard') || - exp.missileType.includes('orb') - lightGrid.addLight({ - subTileX: expSubX, - subTileY: expSubY, - radius: Math.round(lightUnits * 2.5), - intensity: 255, - red: isColdExp ? 130 : (expData?.red ?? 255), - green: isColdExp ? 185 : (expData?.green ?? 160), - blue: isColdExp ? 255 : (expData?.blue ?? 50), - }) - } - - if (runtime.objectDrawables) { - for (const obj of runtime.objectDrawables) { - const tok = (obj.token ?? '').trim().toUpperCase() - const isTorchOrFire = - tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || - tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || - tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || - tok === 'F8' || tok === '1Y' || tok === 'FL' - const isWaypoint = - tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || - tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' - const isShrineOrBeam = - tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || - obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) - - if (isTorchOrFire || isWaypoint || isShrineOrBeam) { - const dx = obj.x - engine.world.player.x - const dy = obj.y - engine.world.player.y - if (dx * dx + dy * dy < 950 * 950) { - const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const objSubX = Math.round((ody + odx) / 2) - const objSubY = Math.round((ody - odx) / 2) - const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0 - lightGrid.addLight({ - subTileX: objSubX, - subTileY: objSubY, - radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker, - intensity: isTorchOrFire ? 175 : 160, - red: isWaypoint ? 120 : 255, - green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205), - blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175), - }) - } - } - } - } - - const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId) - if (activePortalMouthForLight !== null) { - const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - lightGrid.addLight({ - subTileX: activePortalMouthForLight.x, - subTileY: activePortalMouthForLight.y, - radius: 19 + portalFlicker, - intensity: 235, - red: 64, - green: 110, - blue: 255, - }) - } - - const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5 - const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185) - const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175) - const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165) - const ambNorm = effectiveAmbIntensity / 255 - const ambR = (effR / 255) * ambNorm - const ambG = (effG / 255) * ambNorm - const ambB = (effB / 255) * ambNorm - - renderer.setLighting({ - ambient: [ambR, ambG, ambB], - originSubX: lightGrid.originSubX, - originSubY: lightGrid.originSubY, - sceneOriginX: runtime.grid.originX, - sceneOriginY: runtime.grid.originY, - lightmap: lightGrid.exportTextureBuffer(), - }) - - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = effectiveAmbIntensity - state.ambientColor = [effR, effG, effB] - } else { - renderer.setLighting(null) - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = 255 - state.ambientColor = [255, 255, 255] - } -} - -export interface RenderWorldLayersContext { - readonly frameMs: number - readonly started: number - readonly runtime: MapRuntime - readonly renderer: SpriteRenderer - readonly engine: GameEngine - readonly camera: ViewportCamera - readonly canvas: HTMLCanvasElement - readonly hud: HTMLElement - readonly labelsContainer: HTMLElement | null - readonly groundLabelOverlay: GroundLabelOverlay | null - readonly hudManager: HudManager | null - readonly mouseController: SceneMouseController - readonly minimap: Minimap - readonly minimapLevel: () => MinimapLevel - readonly portalSlot: TownPortalSlot - readonly lightGrid: LightGrid - readonly environment: Environment - readonly currentLightingPreset: LightingPreset - readonly lightingEnabled: boolean - readonly floorBounds: Float32Array - readonly shadowBounds: Float32Array - readonly wallBounds: Float32Array - readonly roofBounds: Float32Array - readonly objectBounds: Float32Array - readonly roofController: RoofFadeController - readonly character: LoadedCharacter | null - readonly playerAnimator: ActorAnimator - readonly playerMoving: boolean - readonly playerWalkFrame: number - readonly monsterWalkFrame: number - readonly monsterAnimators: ReadonlyMap - readonly monsterArtMap: ReadonlyMap - readonly missileArtMap: Map - readonly overlayArtMap: ReadonlyMap - readonly itemsAtlasHandle: AtlasHandle | undefined - readonly lightningTrailManager: LightningTrailManager - readonly loop: GameLoop - hudNextMs: number -} - -export function renderWorldLayers(ctx: RenderWorldLayersContext): { hudNextMs: number } { - const { - frameMs, - started, - runtime, - renderer, - engine, - camera, - canvas, - hud, - labelsContainer, - groundLabelOverlay, - hudManager, - mouseController, - minimap, - minimapLevel, - portalSlot, - lightGrid, - environment, - currentLightingPreset, - lightingEnabled, - floorBounds, - shadowBounds, - wallBounds, - roofBounds, - objectBounds, - roofController, - character, - playerAnimator, - playerMoving, - playerWalkFrame, - monsterWalkFrame, - monsterAnimators, - monsterArtMap, - missileArtMap, - overlayArtMap, - itemsAtlasHandle, - lightningTrailManager, - loop, - } = ctx - - const renderStarted = performance.now() - if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) { - updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs) - } - if (runtime.animatedObjects && runtime.animatedObjects.length > 0) { - updateAnimatedObjects(runtime.animatedObjects, renderStarted) - } - if (state.pagesAtFirstFrame < 0) { - state.pagesAtFirstFrame = countLoadedPages(runtime.pages) - state.firstFrameMs = Math.round(performance.now() - started) - } - const camX = engine.world.player.x - const camY = engine.world.player.y - 16 - renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, lightingEnabled ? [0, 0, 0] : [0.03, 0.03, 0.04]) - - applyDynamicSceneLighting({ - renderer, - engine, - runtime, - lightGrid, - environment, - portalSlot, - currentLightingPreset, - lightingEnabled, - }) - - let skippedDraws = 0 - let culledDraws = 0 - - const view = viewportRect(camX, camY, canvas.width, canvas.height, camera.zoom) - const visible = (bounds: Float32Array, index: number): boolean => intersects(bounds, index, view) - - const drawTiles = (list: readonly Drawable[], bounds: Float32Array): void => { - for (let index = 0; index < list.length; index += 1) { - if (!visible(bounds, index)) { culledDraws += 1; continue } - const draw = list[index]! - const page = runtime.pages[draw.page] - if (page === null || page === undefined) { skippedDraws += 1; continue } - renderer.draw(draw.frame, draw.x, draw.y, { atlas: page }) - } - } - const drawShadows = (list: readonly Drawable[], bounds: Float32Array): void => { - for (let index = 0; index < list.length; index += 1) { - if (!visible(bounds, index)) { culledDraws += 1; continue } - const draw = list[index]! - const page = runtime.pages[draw.page] - if (page === null || page === undefined) { skippedDraws += 1; continue } - renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, 0.5] }) - } - } - const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => { - for (let index = 0; index < list.length; index += 1) { - if (!visible(bounds, index)) { culledDraws += 1; continue } - const draw = list[index]! - const page = runtime.pages[draw.page] - if (page === null || page === undefined) { skippedDraws += 1; continue } - const alpha = roofController.getAlpha(index) - if (alpha < 0.999) { - renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] }) - } else { - renderer.draw(draw.frame, draw.x, draw.y, { atlas: page }) - } - } - } - drawTiles(runtime.floors, floorBounds) - if (runtime.shadows && runtime.shadows.length > 0) { - drawShadows(runtime.shadows, shadowBounds) - } - const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y) - const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16 - roofController.update(engine.world.player, deltaMs, cell) - state.roofAlpha = roofController.overallAlpha - - let wallIdx = 0 - let objIdx = 0 - const walls = runtime.walls - const objs = runtime.objectDrawables - - const drawUpToDepth = (maxDepth: number): void => { - while (wallIdx < walls.length || objIdx < objs.length) { - const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null - const nextObj = objIdx < objs.length ? objs[objIdx]! : null - - const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity - const objDepth = nextObj !== null ? nextObj.depth : Infinity - - if (wallDepth > maxDepth && objDepth > maxDepth) break - - if (wallDepth <= objDepth) { - if (!visible(wallBounds, wallIdx)) { - culledDraws += 1 - } else { - const page = runtime.pages[nextWall!.page] - if (page === null || page === undefined) { - skippedDraws += 1 - } else { - renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { - atlas: page, - elevationY: Math.max(0, nextWall!.frame.height - 40), - }) - } - } - wallIdx += 1 - } else { - if (!visible(objectBounds, objIdx)) { - culledDraws += 1 - } else { - const page = runtime.objectPages[nextObj!.page] - if (page === null || page === undefined) { - skippedDraws += 1 - } else { - renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { - atlas: page, - blendMode: nextObj!.blendMode ?? 'normal', - elevationY: Math.max(0, nextObj!.frame.height - 16), - }) - } - } - objIdx += 1 - } - } - } - - interface EntityDraw { - x: number - y: number - depth: number - draw: () => void - } - - const entities: EntityDraw[] = [] - - const pushEntity = (x: number, y: number, draw: () => void, depthOffset = 0) => { - const c = cellOf(runtime.grid, x, y) - entities.push({ x, y, depth: c.x + c.y + depthOffset, draw }) - } - - pushEntity(engine.world.player.x, engine.world.player.y, () => { - if (character === null) { - renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1]) - renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1]) - } else { - let groupIndex: number - let frameIndex: number - const clip = playerAnimator.clip - if (clip !== null && clip !== undefined) { - const clipDirections = clip.directions ?? character.directions - const dir = facingToDirection(engine.world.player.facing, clipDirections) - groupIndex = clip.group + dir - frameIndex = playerAnimator.frameIndex - } else { - const direction = facingToDirection(engine.world.player.facing, character.directions) - groupIndex = (playerMoving ? character.walk : character.stand) + direction - const group = character.groups[groupIndex] ?? character.groups[character.walk + direction] - frameIndex = playerMoving - ? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1) - : 0 - } - state.characterGroup = groupIndex - const group = character.groups[groupIndex] - const frame = group?.[frameIndex] ?? group?.[0] - if (frame !== undefined) { - const drawX = frame.anchorX !== undefined - ? engine.world.player.x + frame.anchorX - : engine.world.player.x - frame.width / 2 - const drawY = frame.anchorY !== undefined - ? engine.world.player.y + frame.anchorY - : engine.world.player.y - frame.height + FEET_HEIGHT / 2 - - renderer.draw(frame, drawX, drawY, { - atlas: character.handle, - elevationY: Math.max(0, frame.height - 16), - }) - state.characterFrames += 1 - state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height } - } - } - }) - - mouseController.syncMonsterTargetHud(camX, camY) - const activeTargetMonster = mouseController.getActiveTargetMonster() - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' && monster.corpseTicks <= 0) { - monster.drawBounds = undefined - continue - } - if (monster.state === 'dead') { - monster.drawBounds = undefined - } - pushEntity(monster.x, monster.y, () => { - const isTarget = monster === activeTargetMonster && monster.state !== 'dead' && monster.hp > 0 - const art = monsterArtMap.get(monster.stats.id.toLowerCase()) - if (art !== undefined) { - const animator = monsterAnimators.get(monster.index) - const clip = animator?.clip - let groupIndex = 0 - let frameIndex = 0 - if (clip !== null && clip !== undefined && animator !== undefined) { - const clipDirections = clip.directions ?? art.directions - const dir = facingToDirection(monster.facing, clipDirections) - groupIndex = clip.group + dir - frameIndex = animator.frameIndex - } else { - const direction = facingToDirection(monster.facing, art.directions) - const isMoving = monster.state === 'chase' - groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction - const group = art.groups[groupIndex] - const frameCount = Math.max(1, group?.length ?? 1) - frameIndex = isMoving - ? Math.floor(monsterWalkFrame / 3) % frameCount - : Math.floor(monsterWalkFrame / 5) % frameCount - } - const group = art.groups[groupIndex] - const frame = group?.[frameIndex] ?? group?.[0] - if (frame !== undefined) { - const drawX = frame.anchorX !== undefined - ? monster.x + frame.anchorX - : monster.x - frame.width / 2 - const drawY = frame.anchorY !== undefined - ? monster.y + frame.anchorY - : monster.y - frame.height + FEET_HEIGHT / 2 - if (monster.state !== 'dead') { - monster.drawBounds = { - x: drawX, - y: drawY, - w: frame.width, - h: frame.height, - } - } - const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 - const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0 - const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase()) - const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal') - renderer.draw(frame, drawX, drawY, { - atlas: art.handle, - blendMode: baseBlendMode, - elevationY: Math.max(0, frame.height - 16), - ...(isFrozen - ? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 } - : isChilled - ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } - : isTarget - ? { tint: [1.25, 1.25, 1.25, 1.0] as const } - : {}), - }) - if (art.glowGroups !== undefined) { - const glowGroup = art.glowGroups[groupIndex] - const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0] - if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) { - renderer.draw(glowFrame, drawX, drawY, { - atlas: art.handle, - blendMode: 'additive', - }) - } - } - return - } - } - if (monster.state !== 'dead') { - monster.drawBounds = { - x: monster.x - MARKER_WIDTH / 2, - y: monster.y - MARKER_HEIGHT, - w: MARKER_WIDTH, - h: MARKER_HEIGHT, - } - const missingId = monster.stats.id.toLowerCase() - if (!state.missingMonsterArt.includes(missingId)) { - state.missingMonsterArt.push(missingId) - console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`) - } - const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 - const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0 - const tint = isFrozen - ? ([0.25, 0.6, 1.0, 1.0] as const) - : isChilled - ? ([0.4, 0.65, 1.0, 1.0] as const) - : isTarget - ? ([1.0, 0.35, 0.35, 1.0] as const) - : ([0.9, 0.2, 0.2, 1.0] as const) - renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint) - } - }) - } - - for (const npc of engine.npcEntities) { - if (npc.hasSprite) continue - pushEntity(npc.x, npc.y, () => { - renderer.drawSolid(npc.x - MARKER_WIDTH / 2, npc.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.2, 0.9, 0.2, 1]) - }) - } - - for (const slice of lightningTrailManager.getSlices()) { - pushEntity(slice.x, slice.y, () => { - lightningTrailManager.renderSlice(renderer, slice, missileArtMap) - }, 0.2) - } - - for (const shot of engine.projectiles) { - if (shot.missileType === 'blizzardcenter') continue - pushEntity(shot.x, shot.y, () => { - const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined) - let cel: string | undefined - if (mType !== undefined) { - try { - cel = getMissileTxtData(mType).celFile - } catch { - cel = undefined - } - } - const art = mType !== undefined - ? (missileArtMap.get(mType) ?? (cel ? (missileArtMap.get(cel) ?? missileArtMap.get(cel.toLowerCase())) : undefined)) - : undefined - drawMissileProjectile(renderer, shot, art, missileArtMap) - }) - } - - for (const exp of engine.explosions) { - pushEntity(exp.x, exp.y, () => { - let cel: string | undefined - try { - cel = getMissileTxtData(exp.missileType).celFile - } catch { - cel = undefined - } - const art = - missileArtMap.get(exp.missileType) ?? - (cel ? (missileArtMap.get(cel) ?? missileArtMap.get(cel.toLowerCase())) : undefined) - drawExplosion(renderer, exp, art) - }) - } - - for (const overlay of engine.overlays) { - if (overlay.expired) continue - pushEntity( - overlay.x, - overlay.y, - () => { - drawCastOverlay( - renderer, - overlay, - overlayArtMap.get(overlay.overlayName) ?? overlayArtMap.get(overlay.overlayName.toLowerCase()), - ) - }, - overlay.preDraw ? -0.1 : 0.1, - ) - } - - const activePortalMouth = portalSlot.mouthInLevel(runtime.levelId) - const activePortalPos = - activePortalMouth !== null - ? subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - : null - if (activePortalMouth !== null && activePortalPos !== null) { - const portalElapsedMs = Math.max(0, renderStarted - (activePortalMouth.createdAtMs ?? 0)) - pushEntity(activePortalPos.x, activePortalPos.y, () => { - drawTownPortal(renderer, activePortalPos.x, activePortalPos.y, portalElapsedMs, overlayArtMap) - }) - } - - const groundSimTime = (engine.world.tick + ((engine.world as any).alpha ?? 0)) * 40 - for (const gItem of engine.groundItems.all) { - pushEntity(gItem.x, gItem.y, () => { - drawGroundItem(renderer, gItem, groundSimTime, itemsAtlasHandle) - }) - } - - entities.sort((a, b) => a.depth - b.depth) - - for (const entity of entities) { - drawUpToDepth(entity.depth) - entity.draw() - } - drawUpToDepth(Infinity) - drawRoofs(runtime.roofs, roofBounds) - if (lightingEnabled) { - renderer.setLighting(null) - } - - const isEnRender = getTblLang() === 'ENG' - const automapEntities = [ - ...engine.npcEntities - .filter( - (npc: { def: { name: string }; x: number; y: number }) => - getTownNpcDescriptorByName(npc.def.name) !== undefined || - npc.def.name === 'Kaelan' || - npc.def.name === 'Hadriel' || - npc.def.name === 'Flavie', - ) - .map((npc: { def: { name: string }; x: number; y: number }) => ({ - x: npc.x, - y: npc.y, - kind: 'npc' as const, - name: isEnRender ? npc.def.name : npcDisplayName(npc.def.name), - })), - ...engine.world.monsters - .filter((m: any) => m.state !== 'dead' && m.hp > 0 && m.stats.rank === 'unique') - .map((m: any) => ({ - x: m.x, - y: m.y, - kind: 'boss' as const, - name: monsterDisplayName(m.stats), - })), - ] - const automapPortals = - activePortalMouth !== null && activePortalPos !== null - ? [ - { - x: activePortalPos.x, - y: activePortalPos.y, - kind: 'blue' as const, - label: isEnRender - ? `Town Portal -> ${getLevelNames(activePortalMouth.toLevelId).nameEn}` - : `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`, - }, - ] - : [] - const automapStashes = (runtime.stashes ?? []).map(s => ({ - x: s.x, - y: s.y, - label: isEnRender ? s.nameEn : s.nameZh, - })) - minimap.draw( - renderer, - minimapLevel(), - cell.x, - cell.y, - { x: engine.world.player.x, y: engine.world.player.y - 16, zoom: camera.zoom }, - { width: canvas.width, height: canvas.height }, - { - playerWorldX: engine.world.player.x, - playerWorldY: engine.world.player.y, - leftPanelOpen: hudManager !== null && hudManager.leftPanel !== 'none', - rightPanelOpen: hudManager !== null && hudManager.rightPanel !== 'none', - entities: automapEntities, - portals: automapPortals, - stashes: automapStashes, - levelId: runtime.levelId, - }, - ) - renderer.flush() - if (hudManager !== null) { - mouseController.syncMonsterTargetHud(camX, camY) - hudManager.render(renderStarted, playerMoving) - } - - if (labelsContainer !== null) { - mouseController.updateOverheadLabels(camX, camY, activePortalMouth, activePortalPos) - } - - updateGroundItemLabelsOverlay( - groundLabelOverlay, - hudManager, - mouseController, - engine, - canvas, - camera, - camX, - camY, - ) - - state.portal = portalSlot.current() - state.minimapVisible = minimap.visible - state.automapMode = minimap.mode - state.automapDivisor = minimap.divisor - state.automapAtlasesLoaded = minimap.atlasesLoadedCount - state.automapCellCounts = minimap.lastRenderStats.cellCounts - state.automapRevealedRooms = minimap.revealedRoomsCount - state.automapSpritesDrawn = minimap.spritesDrawnLastFrame - state.automapCrosshairsDrawn = minimap.crosshairsDrawnLastFrame - state.automapWarpLabels = minimap.warpLabelsLastFrame - state.automapHudText = minimap.hudTextLastFrame - state.automapViewport = minimap.viewportLastFrame - state.drawCalls = renderer.drawCalls - state.quadsDrawn = renderer.quadsSubmitted - state.culledDraws = culledDraws - state.tickRate = loop.tickRate - state.simTps = loop.simTps - state.renderFps = loop.fps - state.fps = loop.fps - state.zoom = Number(camera.zoom.toFixed(2)) - state.canvasWidth = canvas.width - state.canvasHeight = canvas.height - state.pagesLoaded = countLoadedPages(runtime.pages) - state.skippedDraws = skippedDraws - state.renderMs = performance.now() - renderStarted - - let hudNextMs = ctx.hudNextMs - if (renderStarted >= hudNextMs) { - hudNextMs = renderStarted + HUD_INTERVAL_MS - const simTpsText = loop.simTps.toFixed(1) - const renderFpsText = loop.fps.toFixed(0) - const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1) - if (isEnRender) { - hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? 'pack' : 'mpq'}` - + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} cells · ${String(runtime.floors.length)} floors / ${String(runtime.shadows?.length ?? 0)} shadows / ${String(runtime.walls.length)} walls` - + ` · pages ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` - + ` · objects ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` - + ` · ${String(state.quadsDrawn)} quads / ${String(state.drawCalls)} draws (culled ${String(culledDraws)})` - + (skippedDraws > 0 ? ` · pending ${String(skippedDraws)}` : '') - + monsterPlanStatusText(state.monstersPlanned) - + monsterArtStatusText({ - missing: state.missingMonsterArt, - errors: state.monsterArtErrors, - layerFailures: state.monsterArtLayerFailures, - }) - } else { - hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}` - + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.shadows?.length ?? 0)} 阴影 / ${String(runtime.walls.length)} 墙` - + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` - + ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` - + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})` - + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') - + monsterPlanStatusText(state.monstersPlanned) - + monsterArtStatusText({ - missing: state.missingMonsterArt, - errors: state.monsterArtErrors, - layerFailures: state.monsterArtLayerFailures, - }) - } - } - return { hudNextMs } -} +/** Player feet footprint height (scene px), used to anchor sprite baselines. */ +const FEET_HEIGHT = 10 +/** Placeholder marker size (scene px) drawn when a unit has no loaded art. */ +const MARKER_WIDTH = 16 +const MARKER_HEIGHT = 26 import type { AutomapView } from '../automap/automap-view.ts' import type { diff --git a/src/client/session/index.ts b/src/client/session/index.ts deleted file mode 100644 index 5121dd9..0000000 --- a/src/client/session/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - OutboundRateLimiter, - DEFAULT_RATE_LIMITS, - classifyClientCommand, - type CommandRateCategory, - type RateLimitAcquireResult, - type RateLimiterConfig, -} from './rate-limiter.ts' - -export { - OnlineSession, - decodeCapturedGameS2cPacket, - type OnlineSessionLobbySnapshot, - type OnlineSessionLoginParams, - type OnlineSessionOptions, - type OnlineSessionPhase, -} from './online-session.ts' diff --git a/src/client/sim/camera.ts b/src/client/sim/camera.ts deleted file mode 100644 index ab27ab5..0000000 --- a/src/client/sim/camera.ts +++ /dev/null @@ -1,75 +0,0 @@ -export class ViewportCamera { - zoom: number - manualZoom = false - - private readonly onWheel: (event: WheelEvent) => void - private readonly onKeyZoom: (event: KeyboardEvent) => void - private readonly onResize: () => void - - constructor( - private canvas: HTMLCanvasElement, - public mapWidthPx: number, - public mapHeightPx: number - ) { - this.zoom = ViewportCamera.coverZoom(mapWidthPx, mapHeightPx, ViewportCamera.syncCanvasSize(canvas)) - - this.onWheel = (event: WheelEvent) => { - event.preventDefault() - this.setZoom(this.zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12)) - } - - this.onKeyZoom = (event: KeyboardEvent) => { - if (event.code === 'Equal' || event.code === 'NumpadAdd') this.setZoom(this.zoom * 1.15) - if (event.code === 'Minus' || event.code === 'NumpadSubtract') this.setZoom(this.zoom / 1.15) - if (event.code === 'Digit0') { - this.manualZoom = false - this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, ViewportCamera.syncCanvasSize(this.canvas)) - } - } - - this.onResize = () => { - const size = ViewportCamera.syncCanvasSize(this.canvas) - if (!this.manualZoom) { - this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, size) - } - } - } - - setZoom(value: number): void { - this.manualZoom = true - this.zoom = Math.min(8, Math.max(0.4, value)) - } - - attach(): void { - this.canvas.addEventListener('wheel', this.onWheel, { passive: false }) - window.addEventListener('keydown', this.onKeyZoom) - window.addEventListener('resize', this.onResize) - } - - detach(): void { - this.canvas.removeEventListener('wheel', this.onWheel) - window.removeEventListener('keydown', this.onKeyZoom) - window.removeEventListener('resize', this.onResize) - } - - static coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number { - if (mapWidth <= 0 || mapHeight <= 0) return 1.6 - const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight) - return Math.min(6, Math.max(0.9, cover)) - } - - static syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } { - const dpr = Math.min(window.devicePixelRatio || 1, 2) - const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth) - const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight) - const maxPixels = 1920 * 1200 - const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight))) - const width = Math.max(1, Math.round(cssWidth * scale)) - const height = Math.max(1, Math.round(cssHeight * scale)) - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width - canvas.height = height - } - return { width, height } - } -} diff --git a/src/client/sim/input.ts b/src/client/sim/input.ts deleted file mode 100644 index d2ca4c0..0000000 --- a/src/client/sim/input.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Keyboard input, resolved into the game's own vocabulary. - * - * Two things are game facts rather than conveniences: - * - * - Diablo's eight facings are ordered starting at **south and turning west** - * (`Direction::South, SouthWest, West, NorthWest, North, ...`), and that - * order is exactly the group order inside a direction-animated sprite file. - * So facing is computed from the movement vector, not from which key was - * pressed. - * - Diagonal movement is normalised: the original moves diagonally at the same - * speed as along an axis, not at √2 times it. - */ - -/** Diablo's facing order: the index of a direction inside a sprite file. */ -export const DIRECTION = { - South: 0, - SouthWest: 1, - West: 2, - NorthWest: 3, - North: 4, - NorthEast: 5, - East: 6, - SouthEast: 7, -} as const - -/** A direction index (0..7) or -1 for "no direction". */ -export type Direction = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 - -/** A normalized movement request in screen coordinates (y grows downward). */ -export interface Movement { - /** Horizontal component in -1..1. */ - readonly x: number - /** Vertical component in -1..1. */ - readonly y: number -} - -/** Keys that mean "attack". */ -const ATTACK_KEYS = new Set(['KeyJ', 'Enter']) -/** Keys that mean "pick up". */ -const PICKUP_KEYS = new Set(['KeyE', 'KeyF']) -/** Keys that mean "talk to whoever is in reach". */ -const TALK_KEYS = new Set() -/** Keys that save and load the game. */ -const SAVE_KEYS = new Set(['KeyK']) -const LOAD_KEYS = new Set(['KeyL']) -/** Keys that toggle the automap. `Tab` is where Diablo II puts it. */ -const MAP_KEYS = new Set(['Tab', 'KeyM']) -/** Keys that take the stairs, cave mouth or portal the player is standing on. */ -const USE_KEYS = new Set() -/** Keys that cast a town portal. */ -const PORTAL_KEYS = new Set(['KeyP']) -/** Keys that open the waypoint destination list. */ -const WAYPOINT_KEYS = new Set() -/** Shift keys for force stand-still attack. */ -const SHIFT_KEYS = new Set(['ShiftLeft', 'ShiftRight']) - -/** Recognised keys, mapped to a movement contribution. */ -const KEYS: Readonly> = { - ArrowUp: [0, -1], - ArrowDown: [0, 1], - ArrowLeft: [-1, 0], - ArrowRight: [1, 0], -} - -/** - * Facing for a movement vector. - * - * @param movement - normalized movement. - * @returns the direction index, or null when there is no movement. - */ -export function directionOf(movement: Movement): Direction | null { - const sx = Math.sign(movement.x) - const sy = Math.sign(movement.y) - if (sx === 0 && sy === 0) return null - if (sx === 0) return sy > 0 ? DIRECTION.South : DIRECTION.North - if (sy === 0) return sx > 0 ? DIRECTION.East : DIRECTION.West - if (sx < 0) return sy > 0 ? DIRECTION.SouthWest : DIRECTION.NorthWest - return sy > 0 ? DIRECTION.SouthEast : DIRECTION.NorthEast -} - -/** - * Key state for one window, sampled once per tick. - */ -export class KeyboardInput { - private readonly held = new Set() - /** - * Keys pressed since the last time anyone looked. - * - * Movement and attack are held states: the question is "is the key down this - * tick". Taking a staircase is not — holding `G` on a cave mouth must not - * re-trigger the transition sixty times a second — so those keys are edge - * triggered, recorded here on the way down and drained by the reader. - */ - private readonly pressed = new Set() - - private readonly onKeyDown = (event: KeyboardEvent): void => { - const oneShot = MAP_KEYS.has(event.code) - || USE_KEYS.has(event.code) - || PORTAL_KEYS.has(event.code) - || WAYPOINT_KEYS.has(event.code) - const recognised = KEYS[event.code] !== undefined - || ATTACK_KEYS.has(event.code) - || PICKUP_KEYS.has(event.code) - || TALK_KEYS.has(event.code) - || SAVE_KEYS.has(event.code) - || LOAD_KEYS.has(event.code) - || SHIFT_KEYS.has(event.code) - || oneShot - if (!recognised) return - // Arrows scroll the page otherwise, and held keys would repeat. - event.preventDefault() - // Auto-repeat re-fires keydown without an intervening keyup; ignoring it - // is what makes these presses rather than a stream. - if (oneShot && !this.held.has(event.code)) this.pressed.add(event.code) - this.held.add(event.code) - } - - private readonly onKeyUp = (event: KeyboardEvent): void => { - this.held.delete(event.code) - } - - private readonly onBlur = (): void => { - this.held.clear() - this.pressed.clear() - } - - private readonly target: Window - - /** - * @param target - the window to listen on. - */ - constructor(target: Window = window) { - this.target = target - } - - /** Start listening. */ - attach(): void { - this.target.addEventListener('keydown', this.onKeyDown) - this.target.addEventListener('keyup', this.onKeyUp) - this.target.addEventListener('blur', this.onBlur) - } - - /** Stop listening and release every key. */ - detach(): void { - this.target.removeEventListener('keydown', this.onKeyDown) - this.target.removeEventListener('keyup', this.onKeyUp) - this.target.removeEventListener('blur', this.onBlur) - this.held.clear() - } - - /** - * The movement requested this tick, normalised so diagonals are not faster. - * - * @returns the movement vector. - */ - movement(): Movement { - let x = 0 - let y = 0 - for (const code of this.held) { - const contribution = KEYS[code] - if (contribution === undefined) continue - x += contribution[0] - y += contribution[1] - } - const magnitude = Math.hypot(x, y) - if (magnitude === 0) return { x: 0, y: 0 } - return { x: x / magnitude, y: y / magnitude } - } - - /** Whether any movement key is held. */ - get active(): boolean { - for (const code of this.held) if (KEYS[code] !== undefined) return true - return false - } - - /** Whether an attack key is held this tick. */ - get attacking(): boolean { - for (const code of this.held) if (ATTACK_KEYS.has(code)) return true - return false - } - - /** Whether a pickup key is held this tick. */ - get pickingUp(): boolean { - for (const code of this.held) if (PICKUP_KEYS.has(code)) return true - return false - } - - /** Whether a talk key is held this tick. */ - get talking(): boolean { - for (const code of this.held) if (TALK_KEYS.has(code)) return true - return false - } - - /** Whether a save key is held this tick. */ - get saving(): boolean { - for (const code of this.held) if (SAVE_KEYS.has(code)) return true - return false - } - - /** Whether a load key is held this tick. */ - get loading(): boolean { - for (const code of this.held) if (LOAD_KEYS.has(code)) return true - return false - } - - /** Skill slots requested by the number keys currently held. */ - get digits(): readonly number[] { - return [] - } - - /** Whether Shift (left or right) is held down. */ - get shiftHeld(): boolean { - return this.held.has('ShiftLeft') || this.held.has('ShiftRight') - } - - /** - * Whether the automap key was pressed since this was last called. - * - * @returns true once per press. - */ - takeMapToggle(): boolean { - return this.takeOnce(MAP_KEYS) - } - - /** - * Whether the use key was pressed since this was last called. - * - * @returns true once per press. - */ - takeUse(): boolean { - return this.takeOnce(USE_KEYS) - } - - /** - * Whether the town portal key was pressed since this was last called. - * - * @returns true once per press. - */ - takePortal(): boolean { - return this.takeOnce(PORTAL_KEYS) - } - - /** - * Whether the waypoint key was pressed since this was last called. - * - * @returns true once per press. - */ - takeWaypoint(): boolean { - return this.takeOnce(WAYPOINT_KEYS) - } - - /** - * Consume a pending press. - * - * @param keys - the codes that count. - * @returns true when one of them was pressed, clearing it. - */ - private takeOnce(keys: ReadonlySet): boolean { - for (const code of keys) { - if (this.pressed.delete(code)) return true - } - return false - } -} diff --git a/src/client/sim/loop.ts b/src/client/sim/loop.ts deleted file mode 100644 index eb684d0..0000000 --- a/src/client/sim/loop.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Fixed-timestep game loop. - * - * Diablo's simulation runs at 25 ticks per second, and that rate is not - * cosmetic: animation frames, movement, and monster AI all advance per tick, - * so anything derived from wall-clock time would diverge from the original - * behaviour. - * - * To avoid tying simulation progress to rendering performance (where frame drops - * or heavy scenes would artificially drag down simulation rate or catastrophically - * drop backlog time), simulation ticks are driven by a dedicated timer mechanism - * at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame - * handles rendering and frame metrics with proper interpolation alpha. - */ - -/** Loop wiring. */ -export interface LoopOptions { - /** Ticks per second (Diablo's simulation rate: 25). */ - readonly tickRate: number - /** Advance the simulation by one tick. */ - readonly onTick: (tick: number) => void - /** - * Draw one frame. - * - * @param alpha - how far the next tick is into the future, in `0..1`, for - * interpolation between ticks. - * @param frameMs - wall-clock duration of the previous frame. - */ - readonly onRender: (alpha: number, frameMs: number) => void - /** Most ticks to run in one timer/frame step before clamping the backlog. */ - readonly maxCatchUp?: number - /** Optional custom clock source for testing. Defaults to performance.now. */ - readonly clock?: () => number -} - -/** - * A fixed-rate simulation loop decoupled from rendering. - */ -export class GameLoop { - private readonly options: LoopOptions - readonly tickMs: number - readonly maxCatchUp: number - - private rawAccumulator = 0 - private consumed = 0 - private running = false - - // Simulation timer & metrics - private simTimerHandle: ReturnType | null = null - private lastSimTime = 0 - private simWindowStart = 0 - private simTicksInWindow = 0 - private simVirtualElapsed = 0 - private measuredSimRate = 0 - - // Render loop & metrics - private renderHandle = 0 - private lastRenderTime = 0 - private lastFrameMs = 0 - private renderWindowStart = 0 - private renderFramesInWindow = 0 - private measuredFps = 0 - - /** - * @param options - loop wiring. - */ - constructor(options: LoopOptions) { - this.options = options - this.tickMs = 1000 / options.tickRate - this.maxCatchUp = options.maxCatchUp ?? 10 - } - - /** Current time in ms from options.clock or performance.now. */ - private now(): number { - return this.options.clock ? this.options.clock() : performance.now() - } - - /** Ticks simulated since the loop started. */ - get tick(): number { - return this.consumed - } - - /** Measured simulation ticks per second over the last window (or target rate when active). */ - get tickRate(): number { - return this.simTps - } - - /** Simulation ticks per second. */ - get simTps(): number { - if (this.measuredSimRate > 0) return this.measuredSimRate - const elapsed = this.now() - this.simWindowStart - if (elapsed >= 200 && this.simTicksInWindow > 0) { - return (this.simTicksInWindow * 1000) / elapsed - } - if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) { - return (this.simTicksInWindow * 1000) / this.simVirtualElapsed - } - return this.running ? this.options.tickRate : 0 - } - - /** Measured render frames per second. */ - get fps(): number { - return this.renderFps - } - - /** Render frames per second. */ - get renderFps(): number { - if (this.measuredFps > 0) return this.measuredFps - const elapsed = this.now() - this.renderWindowStart - if (elapsed >= 200 && this.renderFramesInWindow > 0) { - return (this.renderFramesInWindow * 1000) / elapsed - } - return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0 - } - - /** Duration of the last render frame in milliseconds. */ - get frameMs(): number { - return this.lastFrameMs - } - - /** Whether the loop is currently running. */ - get isRunning(): boolean { - return this.running - } - - /** Current simulation accumulator in milliseconds. */ - get accumulator(): number { - const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0 - return this.rawAccumulator + elapsedSinceLastSim - } - - /** Interpolation factor in 0..1 between the current tick and the next tick. */ - get alpha(): number { - return Math.min(1, Math.max(0, this.accumulator / this.tickMs)) - } - - /** - * Manually advance the simulation by deltaMs milliseconds. - * Useful for headless simulation, test clocks, and deterministic testing. - * - * @param deltaMs - Milliseconds to advance. - * @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited). - */ - step(deltaMs: number, maxCatchUp?: number): void { - if (deltaMs <= 0) return - this.rawAccumulator += deltaMs - this.lastSimTime = this.now() - - const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0) - let steps = 0 - while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) { - this.rawAccumulator -= this.tickMs - this.consumed += 1 - this.simTicksInWindow += 1 - this.options.onTick(this.consumed) - steps += 1 - } - - if (maxSteps > 0) { - const maxBacklog = this.tickMs * maxSteps - if (this.rawAccumulator > maxBacklog) { - this.rawAccumulator = maxBacklog - } - } - - this.simVirtualElapsed += deltaMs - if (this.simVirtualElapsed >= 1000) { - this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed - this.simVirtualElapsed = 0 - this.simTicksInWindow = 0 - } else if (this.simVirtualElapsed >= 200) { - this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed - } - } - - /** - * Manually trigger one render call. - */ - render(frameMs?: number): void { - const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6) - this.lastFrameMs = elapsed - this.options.onRender(this.alpha, elapsed) - } - - /** Advance simulation according to elapsed wall-clock time. */ - private advanceSimulation(now: number): void { - const elapsed = now - this.lastSimTime - if (elapsed <= 0) return - this.lastSimTime = now - this.rawAccumulator += elapsed - - let steps = 0 - while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) { - this.rawAccumulator -= this.tickMs - this.consumed += 1 - this.simTicksInWindow += 1 - this.options.onTick(this.consumed) - steps += 1 - } - - // Prevent catastrophic discard: clamp to max backlog instead of wiping to 0 - const maxBacklog = this.tickMs * this.maxCatchUp - if (this.rawAccumulator > maxBacklog) { - this.rawAccumulator = maxBacklog - } - - const windowElapsed = now - this.simWindowStart - if (windowElapsed >= 1000) { - this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed - this.simWindowStart = now - this.simTicksInWindow = 0 - } else if (this.measuredSimRate === 0 && windowElapsed >= 200) { - this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed - } - } - - /** Begin ticking and rendering. Idempotent. */ - start(): void { - if (this.running) return - this.running = true - - const now = this.now() - this.lastSimTime = now - this.lastRenderTime = now - this.simWindowStart = now - this.renderWindowStart = now - this.simTicksInWindow = 0 - this.renderFramesInWindow = 0 - - this.startSimTimer() - this.startRenderLoop() - } - - /** Start the self-correcting timer loop for simulation ticks. */ - private startSimTimer(): void { - if (this.simTimerHandle !== null) return - let nextTick = this.now() + this.tickMs - - const simStep = (): void => { - if (!this.running) return - const now = this.now() - this.advanceSimulation(now) - - // Self-correct next tick timestamp to eliminate drift - nextTick += this.tickMs - if (nextTick < now - this.tickMs * this.maxCatchUp) { - nextTick = now + this.tickMs - } else { - while (nextTick <= now) { - nextTick += this.tickMs - } - } - const delay = Math.max(0, nextTick - this.now()) - this.simTimerHandle = setTimeout(simStep, delay) - } - - this.simTimerHandle = setTimeout(simStep, this.tickMs) - } - - /** Start requestAnimationFrame render loop. */ - private startRenderLoop(): void { - if (this.renderHandle !== 0) return - - const renderStep = (): void => { - if (!this.running) return - const now = this.now() - const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6 - this.lastRenderTime = now - this.lastFrameMs = frameMs - this.renderFramesInWindow += 1 - - const renderWindowElapsed = now - this.renderWindowStart - if (renderWindowElapsed >= 1000) { - this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed - this.renderWindowStart = now - this.renderFramesInWindow = 0 - } else if (this.measuredFps === 0 && renderWindowElapsed >= 200) { - this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed - } - - this.options.onRender(this.alpha, frameMs) - - if (this.running) { - this.renderHandle = this.requestFrame(renderStep) - } - } - - this.renderHandle = this.requestFrame(renderStep) - } - - private requestFrame(cb: (time: number) => void): number { - if (typeof requestAnimationFrame === 'function') { - return requestAnimationFrame(cb) - } - return setTimeout(() => cb(this.now()), 16) as unknown as number - } - - private cancelFrame(handle: number): void { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(handle) - return - } - clearTimeout(handle as unknown as ReturnType) - } - - /** Stop ticking and rendering. Idempotent. */ - stop(): void { - this.running = false - if (this.simTimerHandle !== null) { - clearTimeout(this.simTimerHandle) - this.simTimerHandle = null - } - if (this.renderHandle !== 0) { - this.cancelFrame(this.renderHandle) - this.renderHandle = 0 - } - } -} diff --git a/src/client/toolbar/toolbar.css b/src/client/toolbar/toolbar.css index 6acfa71..50b5cba 100644 --- a/src/client/toolbar/toolbar.css +++ b/src/client/toolbar/toolbar.css @@ -5,9 +5,11 @@ position: relative; z-index: 1000; display: flex; + flex-direction: row; + flex-wrap: nowrap; align-items: center; justify-content: space-between; - gap: 10px; + gap: 12px; width: 100%; height: 32px; min-height: 32px; @@ -17,15 +19,21 @@ background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%); border-bottom: 1px solid #4a3c24; color: #d6c496; - font-family: "Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif; + font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif); font-size: 12px; line-height: 1; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; user-select: none; } .d2-toolbar__group { display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; align-items: center; + flex-shrink: 0; gap: 6px; white-space: nowrap; } diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts index 2ca7b32..666eccc 100644 --- a/src/client/toolbar/toolbar.ts +++ b/src/client/toolbar/toolbar.ts @@ -82,13 +82,12 @@ export interface ToolbarSnapshot { } /** - * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 6 primary controls: + * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 5 primary controls: * 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle - * 2. Server Host Input (`www.laiseek.xyz` default, bare-IP over HTTPS guard, mid-game reconnect prompt) - * 3. Live Language Toggle (`中` / `EN`) - * 4. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) - * 5. Automap Reveal Selector (`off | level | act`) - * 6. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button + * 2. Live Language Toggle (`中` / `EN`) + * 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) + * 4. Automap Reveal Selector (`off | level | act`) + * 5. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button */ export class Toolbar { private readonly store: SettingsStore @@ -112,9 +111,6 @@ export class Toolbar { private statusDotEl: HTMLElement | null = null private statusTextEl: HTMLElement | null = null private rttTextEl: HTMLElement | null = null - private serverLabelEl: HTMLElement | null = null - private serverInputEl: HTMLInputElement | null = null - private serverApplyBtnEl: HTMLButtonElement | null = null private langBtnZhEl: HTMLButtonElement | null = null private langBtnEnEl: HTMLButtonElement | null = null private lightingLabelEl: HTMLElement | null = null @@ -395,8 +391,146 @@ export class Toolbar { this.rootEl = null } + private ensureStyles(doc: Document): void { + if (!doc.head || doc.getElementById('d2-toolbar-styles')) return + const styleEl = doc.createElement('style') + styleEl.id = 'd2-toolbar-styles' + styleEl.textContent = ` +#d2-toolbar, +.d2-toolbar { + position: relative; + z-index: 1000; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + height: 32px; + min-height: 32px; + max-height: 32px; + padding: 0 12px; + box-sizing: border-box; + background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%); + border-bottom: 1px solid #4a3c24; + color: #d6c496; + font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif); + font-size: 12px; + line-height: 1; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + user-select: none; +} +.d2-toolbar__group { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + flex-shrink: 0; + gap: 6px; + white-space: nowrap; +} +.d2-toolbar__divider { + width: 1px; + height: 18px; + background: #3a2f1d; +} +.d2-toolbar__status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 3px; + background: #14110d; + border: 1px solid #352b1b; +} +.d2-toolbar__status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + background: #6b6b6b; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} +.d2-toolbar__status-dot--disconnected { background: #767676; } +.d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; } +.d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; } +.d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; } +.d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; } +.d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; } +.d2-toolbar__rtt { + font-family: "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + color: #a89974; +} +.d2-toolbar__label { + color: #b59e68; + font-size: 11px; + letter-spacing: 0.03em; +} +.d2-toolbar__select { + height: 22px; + padding: 0 6px; + background: #120f0b; + border: 1px solid #4a3c24; + border-radius: 2px; + color: #e2d1a0; + font-size: 11px; + cursor: pointer; + outline: none; +} +.d2-toolbar__select:hover, +.d2-toolbar__btn:hover { + border-color: #c7b377; + color: #fff2c6; +} +.d2-toolbar__btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 8px; + background: linear-gradient(180deg, #2b2316 0%, #17120b 100%); + border: 1px solid #574629; + border-radius: 2px; + color: #dfc991; + font-size: 11px; + cursor: pointer; +} +.d2-toolbar__btn--active { + background: linear-gradient(180deg, #524020 0%, #2e2310 100%); + border-color: #d4b46a; + color: #fff5d6; +} +.d2-toolbar__diag { + position: absolute; + top: 32px; + left: 12px; + right: 12px; + z-index: 1001; + display: none; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: rgba(38, 12, 10, 0.96); + border: 1px solid #a93226; + border-top: none; + border-radius: 0 0 4px 4px; + color: #ffd6d1; + font-size: 12px; +} +.d2-toolbar__diag--visible { + display: flex; +} +` + doc.head.appendChild(styleEl) + } + private mountDom(container: HTMLElement | null): void { const doc = document + this.ensureStyles(doc) const root = doc.createElement('header') root.id = 'd2-toolbar' root.className = 'd2-toolbar' @@ -430,35 +564,7 @@ export class Toolbar { statusGroup.appendChild(statusBadge) statusGroup.appendChild(inspectorBtn) - // Group 2: Server Host Input - const serverGroup = doc.createElement('div') - serverGroup.className = 'd2-toolbar__group' - const serverLabel = doc.createElement('label') - serverLabel.className = 'd2-toolbar__label' - serverLabel.htmlFor = 'd2-toolbar-server-host' - const serverInput = doc.createElement('input') - serverInput.type = 'text' - serverInput.id = 'd2-toolbar-server-host' - serverInput.className = 'd2-toolbar__input' - serverInput.value = this.serverInputValue - serverInput.addEventListener('keydown', (ev) => { - if (ev.key === 'Enter') { - ev.preventDefault() - this.submitServerHost(serverInput.value) - } - }) - const serverApplyBtn = doc.createElement('button') - serverApplyBtn.type = 'button' - serverApplyBtn.id = 'd2-toolbar-server-apply' - serverApplyBtn.className = 'd2-toolbar__btn' - serverApplyBtn.addEventListener('click', () => { - this.submitServerHost(serverInput.value) - }) - serverGroup.appendChild(serverLabel) - serverGroup.appendChild(serverInput) - serverGroup.appendChild(serverApplyBtn) - - // Group 3: Language Toggle (中 / EN) + // Group 2: Language Toggle (中 / EN) const langGroup = doc.createElement('div') langGroup.className = 'd2-toolbar__group' const langBtnZh = doc.createElement('button') @@ -478,7 +584,7 @@ export class Toolbar { langGroup.appendChild(langBtnZh) langGroup.appendChild(langBtnEn) - // Group 4: Lighting Selector + // Group 3: Lighting Selector const lightingGroup = doc.createElement('div') lightingGroup.className = 'd2-toolbar__group' const lightingLabel = doc.createElement('label') @@ -493,7 +599,7 @@ export class Toolbar { lightingGroup.appendChild(lightingLabel) lightingGroup.appendChild(lightingSelect) - // Group 5: Automap Reveal Selector (Off / Level / Act) + // Group 4: Automap Reveal Selector (Off / Level / Act) const automapGroup = doc.createElement('div') automapGroup.className = 'd2-toolbar__group' const automapLabel = doc.createElement('label') @@ -508,7 +614,7 @@ export class Toolbar { automapGroup.appendChild(automapLabel) automapGroup.appendChild(automapSelect) - // Group 6: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button + // Group 5: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button const viewportGroup = doc.createElement('div') viewportGroup.className = 'd2-toolbar__group' const viewportLabel = doc.createElement('label') @@ -545,7 +651,6 @@ export class Toolbar { diagBanner.className = 'd2-toolbar__diag' root.appendChild(statusGroup) - root.appendChild(serverGroup) root.appendChild(langGroup) root.appendChild(lightingGroup) root.appendChild(automapGroup) @@ -560,9 +665,6 @@ export class Toolbar { this.statusDotEl = dot this.statusTextEl = statusText this.rttTextEl = rttText - this.serverLabelEl = serverLabel - this.serverInputEl = serverInput - this.serverApplyBtnEl = serverApplyBtn this.langBtnZhEl = langBtnZh this.langBtnEnEl = langBtnEn this.lightingLabelEl = lightingLabel @@ -598,21 +700,6 @@ export class Toolbar { ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' } - if (this.serverLabelEl) { - this.serverLabelEl.textContent = tToolbar('toolbar.server.label', lang) - } - if (this.serverInputEl) { - this.serverInputEl.placeholder = tToolbar('toolbar.server.placeholder', lang) - if (this.serverInputEl.value !== snap.serverInputValue) { - this.serverInputEl.value = snap.serverInputValue - } - this.serverInputEl.className = snap.serverInputValid - ? 'd2-toolbar__input' - : 'd2-toolbar__input d2-toolbar__input--invalid' - } - if (this.serverApplyBtnEl) { - this.serverApplyBtnEl.textContent = tToolbar('toolbar.server.apply', lang) - } if (this.langBtnZhEl) { this.langBtnZhEl.className = lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' diff --git a/src/client/ui-model/index.ts b/src/client/ui-model/index.ts deleted file mode 100644 index a3bca21..0000000 --- a/src/client/ui-model/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - BODY_LOC_TO_EQUIP_SLOT, - CLASS_ID_TO_CODE, - CLASS_ID_TO_NAMES, - EQUIP_SLOT_TO_BODY_LOC, - HudModel, - STAT_KEY_TO_D2_STAT_ID, - clientUnitToMonsterTargetInfo, - itemDataToUiInventoryItem, - type HudModelOptions, - type HudModelSnapshot, -} from './hud-model.ts' diff --git a/src/client/ui/baked-ui-meta.ts b/src/client/ui/baked-ui-meta.ts deleted file mode 100644 index 8667e89..0000000 --- a/src/client/ui/baked-ui-meta.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../common/items/baked-ui-meta.ts' diff --git a/src/client/ui/belt.ts b/src/client/ui/belt.ts index 69bb875..fe0c7cf 100644 --- a/src/client/ui/belt.ts +++ b/src/client/ui/belt.ts @@ -22,7 +22,6 @@ import { areBeltPotionsCompatible, itemToBeltPotion, isAutoBeltablePotion, - DEFAULT_BELT_POTIONS, BeltModel, type BeltPotionKind, type BeltPotion, @@ -39,7 +38,6 @@ export { areBeltPotionsCompatible, itemToBeltPotion, isAutoBeltablePotion, - DEFAULT_BELT_POTIONS, BeltModel, type BeltPotionKind, type BeltPotion, diff --git a/src/client/ui/cursor-meta.ts b/src/client/ui/cursor-meta.ts deleted file mode 100644 index c99cd3b..0000000 --- a/src/client/ui/cursor-meta.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../common/items/cursor-meta.ts' diff --git a/src/client/ui/globes.ts b/src/client/ui/globes.ts index f306449..2baf25c 100644 --- a/src/client/ui/globes.ts +++ b/src/client/ui/globes.ts @@ -48,16 +48,6 @@ export class GlobesHud { proceduralFallbackCount = 0 lang: TblLang = 'CHI' - constructor() { - this.syncFallbackCountToWindow() - } - - private syncFallbackCountToWindow(): void { - if (typeof window !== 'undefined' && window.__d2webHud) { - (window.__d2webHud as any).proceduralFallbackCount = this.proceduralFallbackCount - } - } - /** * Compute vertical clip rectangle for a globe given current/max values. */ @@ -114,8 +104,6 @@ export class GlobesHud { const lifeClip = GlobesHud.computeClipRect(LIFE_GLOBE_BOUNDS, state.hp, state.maxHp) const manaClip = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, state.mana, state.maxMana) - this.syncFallbackCountToWindow() - // Dark socket bowl under globes this.drawSocketBowl(ctx, LIFE_GLOBE_BOUNDS) this.drawSocketBowl(ctx, MANA_GLOBE_BOUNDS) @@ -136,7 +124,6 @@ export class GlobesHud { ) } else if (lifeClip.clipH > 0) { this.proceduralFallbackCount += 1 - this.syncFallbackCountToWindow() console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback') this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414') } @@ -156,7 +143,6 @@ export class GlobesHud { ) } else if (manaClip.clipH > 0) { this.proceduralFallbackCount += 1 - this.syncFallbackCountToWindow() console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback') this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8') } diff --git a/src/client/ui/ground-labels.ts b/src/client/ui/ground-labels.ts deleted file mode 100644 index 175cd1f..0000000 --- a/src/client/ui/ground-labels.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Diablo II v1.13c Authentic Ground Item Alt Labels (`src/ui/ground-labels.ts`). - * - * Implements: - * - Holding the `Alt` key displays floating text labels for all ground items on screen. - * - Text color and border styling strictly following Diablo II 1.13c item quality colors: - * Normal (white), Low (gray), Superior (white), Magic (blue), Rare (yellow), - * Set (green), Unique (gold/tan), Craft/Rune (orange), Gold (yellow/gold). - * - Vertical ladder collision avoidance (anti-overlap): - * When multiple items drop in close proximity (e.g. boss drops, packed loot), - * labels are neatly stacked vertically into a non-overlapping ladder. - * - Clickable labels to trigger pathfinding and item pickup. - */ - -import { isGroundSourceUnidentified, type GroundItemEntity } from '../../common/items/ground-items.ts' -import { - resolveBaseItemNameEn, - translateItemName, - translateRareName, - translateMagicName, -} from '../../common/i18n/tooltip-i18n.ts' -import { isEn } from '../i18n/lang.ts' - -/** - * Authentic 1.13c Diablo II quality color codes for ground item labels. - * Matching D2 palette tables & D2WinFont values. - */ -export const GROUND_LABEL_QUALITY_COLORS: Record = { - normal: '#d8d8d8', - low: '#808080', - superior: '#ffffff', - magic: '#6868ff', - rare: '#ffff64', - set: '#00fc00', - unique: '#c8a15a', - craft: '#ff9c18', - rune: '#ff9c18', - gold: '#d8b420', -} - -/** - * Checks whether a ground item entity or its underlying item source is ethereal. - */ -export function isGroundItemEthereal(item: GroundItemEntity): boolean { - const src = item.item - return Boolean( - item.ethereal === true || - src?.ethereal === true || - src?.isEthereal === true || - src?.item?.ethereal === true || - src?.item?.isEthereal === true - ) -} - -/** - * Checks whether a ground item entity or its underlying item source has sockets (> 0). - */ -export function isGroundItemSocketed(item: GroundItemEntity): boolean { - const src = item.item - const sockets = - item.sockets ?? - src?.sockets ?? - src?.totalSockets ?? - src?.socketedCount ?? - src?.item?.sockets ?? - src?.item?.totalSockets ?? - src?.item?.socketedCount ?? - 0 - return typeof sockets === 'number' && sockets > 0 -} - -/** - * Checks whether a ground item entity is ethereal or socketed. - */ -export function isGroundItemEtherealOrSocketed(item: GroundItemEntity): boolean { - return isGroundItemEthereal(item) || isGroundItemSocketed(item) -} - -/** - * Resolves the quality color string for a ground item. - * - * Authentic Diablo II v1.13c rule (`D2Client.dll` ground item label color lookup): - * - Gold -> `#d8b420` (Gold) - * - Low Quality (`low` / `inferior` / `crude` / `damaged` / `cracked`) -> `#808080` (Gray) - * - Normal or Superior (`normal` / `superior` / `high` / `hi-quality`) items that are - * Ethereal OR have Sockets (`sockets > 0`, excluding completed runewords) -> `#808080` (Gray) - * - Magic, Rare, Set, Unique, Craft, Rune -> retain their respective quality colors even when ethereal/socketed. - */ -export function getGroundItemQualityColor(item: GroundItemEntity): string { - if (item.isGold) { - return GROUND_LABEL_QUALITY_COLORS.gold! - } - const q = item.quality?.toLowerCase() ?? 'normal' - if (q === 'low' || q === 'inferior' || q === 'crude' || q === 'damaged' || q === 'cracked') { - return GROUND_LABEL_QUALITY_COLORS.low! - } - const isNormalOrSuperior = q === 'normal' || q === 'superior' || q === 'high' || q === 'hi-quality' - const isRuneword = Boolean( - (item as any).isRuneword || - item.item?.isRuneword || - item.item?.runewordRunes || - item.item?.item?.isRuneword || - item.item?.item?.runewordRunes, - ) - if (isRuneword) { - return GROUND_LABEL_QUALITY_COLORS.unique! - } - if (isNormalOrSuperior && isGroundItemEtherealOrSocketed(item)) { - return GROUND_LABEL_QUALITY_COLORS.low! - } - return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal! -} - -/** - * Strips mixed English parentheticals from Chinese item names in CHI mode - * while preserving authentic Chinese annotations like `(5/5 火升)`. - */ -export function cleanChiItemName(nameZh: string): string { - return nameZh - .replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神统盾') - .replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文') - .replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)') - .replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)/g, '') - .trim() -} - -/** - * Format the user-facing display text for a ground item label. - */ -export function formatGroundItemLabelText(item: GroundItemEntity): string { - const en = isEn() - if (item.isGold) { - return en ? `${item.amount} Gold` : `${item.amount} 金币` - } - const src = item.item as any - if (isGroundSourceUnidentified(src)) { - const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base - const rawCode = - typeof src?.code === 'string' - ? src.code.trim().toLowerCase() - : typeof baseObj?.id === 'string' - ? baseObj.id.trim().toLowerCase() - : '' - const rawBaseName = - typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0 - ? baseObj.name - : item.name - if (rawBaseName) { - const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode) - if (en) { - return baseNameEn - } - if (item.nameZh && item.name === baseNameEn) { - return cleanChiItemName(item.nameZh) - } - const translatedBase = - translateItemName(rawBaseName) !== rawBaseName - ? translateItemName(rawBaseName) - : translateItemName(baseNameEn) - return translatedBase || baseNameEn - } - } - if (en) { - return item.name || src?.name || src?.item?.name || 'Item' - } - if (item.nameZh) { - return cleanChiItemName(item.nameZh) - } - const srcNameZh = src?.nameZh ?? src?.item?.nameZh - if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') { - return cleanChiItemName(srcNameZh) - } - if (item.name) { - const q = (item.quality || 'normal').toLowerCase() - if (q === 'rare') { - return translateRareName(item.name) - } - if (q === 'magic') { - const baseName = src?.base?.name ?? src?.item?.base?.name - return translateMagicName(item.name, baseName) - } - if (src?.base || src?.code || src?.item?.base || src?.item?.code) { - return translateItemName(item.name) - } - return cleanChiItemName(item.name) - } - return '物品' -} - -/** - * Estimates the pixel width of a ground item label based on CJK and Latin character counts. - */ -export function estimateLabelWidth(text: string, fontSizePx = 13): number { - let charWidths = 0 - for (let i = 0; i < text.length; i += 1) { - const code = text.charCodeAt(i) - // CJK ideographs / punctuation roughly full-width - if (code > 0x2e80) { - charWidths += fontSizePx * 1.05 - } else { - // Latin characters / numbers / spaces roughly half-width - charWidths += fontSizePx * 0.58 - } - } - // Base padding: 12px (6px left + 6px right) + 2px border - return Math.ceil(charWidths + 14) -} - -export interface GroundLabelLayout { - readonly id: string - readonly itemId: string - readonly item: GroundItemEntity - readonly text: string - readonly color: string - readonly borderColor: string - readonly width: number - readonly height: number - readonly screenX: number - screenY: number - readonly naturalY: number -} - -/** - * Computes initial screen layouts for a list of ground items. - */ -export function computeInitialGroundLabelLayouts( - items: readonly GroundItemEntity[], - projectToScreen: (worldX: number, worldY: number) => { x: number; y: number } | null, - fontSizePx = 13, - labelHeightPx = 18, -): GroundLabelLayout[] { - const layouts: GroundLabelLayout[] = [] - - for (const item of items) { - const pt = projectToScreen(item.x, item.y) - if (pt === null) continue - - const text = formatGroundItemLabelText(item) - const color = getGroundItemQualityColor(item) - const width = estimateLabelWidth(text, fontSizePx) - const naturalY = pt.y - 12 // Positioned 12px above ground contact point - - layouts.push({ - id: `label-${item.id}`, - itemId: item.id, - item, - text, - color, - borderColor: color, - width, - height: labelHeightPx, - screenX: pt.x, - screenY: naturalY, - naturalY, - }) - } - - return layouts -} - -/** - * Checks if two rectangular labels overlap within specified padding tolerances. - */ -export function doLabelsOverlap( - a: GroundLabelLayout, - b: GroundLabelLayout, - padX = 4, - padY = 2, -): boolean { - const aLeft = a.screenX - a.width / 2 - const aRight = a.screenX + a.width / 2 - const aTop = a.screenY - a.height / 2 - const aBottom = a.screenY + a.height / 2 - - const bLeft = b.screenX - b.width / 2 - const bRight = b.screenX + b.width / 2 - const bTop = b.screenY - b.height / 2 - const bBottom = b.screenY + b.height / 2 - - const xOverlap = aLeft - padX < bRight && aRight + padX > bLeft - const yOverlap = aTop - padY < bBottom && aBottom + padY > bTop - - return xOverlap && yOverlap -} - -/** - * Authentic Diablo II 1.13c Vertical Ladder Collision Avoidance Algorithm. - * - * Stacks overlapping ground item labels vertically so they remain readable and clickable. - * - Groups labels into overlapping horizontal clusters. - * - Stacks each cluster from bottom (closest to the ground item) upwards. - * - Preserves natural anchor proximity while eliminating 100% of bounding box overlaps. - */ -export function resolveLadderCollisions( - labels: GroundLabelLayout[], - padX = 4, - padY = 2, -): GroundLabelLayout[] { - if (labels.length <= 1) return labels - - // Work on a copy with mutable screenY - const result: GroundLabelLayout[] = labels.map(l => ({ ...l })) - - // Partition into connected overlapping components (clusters) - const n = result.length - const adj: number[][] = Array.from({ length: n }, () => []) - - for (let i = 0; i < n; i += 1) { - for (let j = i + 1; j < n; j += 1) { - // Horizontal overlap check: do their X spans overlap? - const a = result[i]! - const b = result[j]! - const aLeft = a.screenX - a.width / 2 - const aRight = a.screenX + a.width / 2 - const bLeft = b.screenX - b.width / 2 - const bRight = b.screenX + b.width / 2 - - if (aLeft - padX < bRight && aRight + padX > bLeft) { - adj[i]!.push(j) - adj[j]!.push(i) - } - } - } - - // Find connected clusters via BFS - const visited = new Uint8Array(n) - for (let i = 0; i < n; i += 1) { - if (visited[i]) continue - const cluster: number[] = [] - const queue = [i] - visited[i] = 1 - - while (queue.length > 0) { - const curr = queue.shift()! - cluster.push(curr) - for (const neighbor of adj[curr]!) { - if (!visited[neighbor]) { - visited[neighbor] = 1 - queue.push(neighbor) - } - } - } - - if (cluster.length <= 1) continue - - // Sort cluster items by their natural Y ascending - cluster.sort((idxA, idxB) => { - const a = result[idxA]! - const b = result[idxB]! - if (a.naturalY !== b.naturalY) { - return a.naturalY - b.naturalY - } - return a.screenX - b.screenX - }) - - // Ladder layout: Bottom-most label is placed closest to the ground item, - // and subsequent labels stack upward neatly. - // Base Y is the natural Y of the lowest (highest Y coordinate) item in the cluster. - const bottomItem = result[cluster[cluster.length - 1]!]! - let currentBottomY = bottomItem.naturalY - - // Stack upward from bottom to top - for (let k = cluster.length - 1; k >= 0; k -= 1) { - const itemIdx = cluster[k]! - const item = result[itemIdx]! - item.screenY = currentBottomY - currentBottomY -= item.height + padY - } - } - - return result -} - -/** - * DOM Manager for ground item Alt labels overlay. - */ -export class GroundLabelOverlay { - private readonly container: HTMLElement - private readonly labelPool: HTMLElement[] = [] - private onPickupCallback: ((item: GroundItemEntity) => void) | null = null - private onHoverCallback: ((item: GroundItemEntity | null) => void) | null = null - - constructor(container: HTMLElement) { - this.container = container - this.container.style.position = 'fixed' - this.container.style.inset = '0' - this.container.style.pointerEvents = 'none' - this.container.style.zIndex = '5' - } - - /** - * Register a callback when a ground item label is clicked. - */ - setOnPickupCallback(callback: (item: GroundItemEntity) => void): void { - this.onPickupCallback = callback - } - - /** - * Register a callback when a ground item label is hovered or unhovered. - */ - setOnHoverCallback(callback: (item: GroundItemEntity | null) => void): void { - this.onHoverCallback = callback - } - - /** - * Update and render ground item labels based on current visible items and Alt key state. - */ - update( - visible: boolean, - layouts: readonly GroundLabelLayout[], - ): void { - if (!visible || layouts.length === 0) { - this.hideAll() - return - } - - let count = 0 - for (const layout of layouts) { - let el = this.labelPool[count] - if (el === undefined) { - el = document.createElement('div') - el.className = 'd2-ground-label ground-label' - el.style.position = 'absolute' - el.style.transform = 'translate(-50%, -50%)' - el.style.background = 'rgba(0, 0, 0, 0.88)' - el.style.padding = '2px 6px' - el.style.fontFamily = '"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif' - el.style.fontSize = '13px' - el.style.lineHeight = '14px' - el.style.whiteSpace = 'nowrap' - el.style.pointerEvents = 'auto' - el.style.cursor = 'pointer' - el.style.userSelect = 'none' - el.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.8)' - el.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' - el.style.transition = 'border-color 0.1s, background-color 0.1s' - - el.addEventListener('mouseenter', () => { - el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)' - el.style.borderColor = '#ffffff' - const currentItem = (el as any).__groundItem as GroundItemEntity | undefined - if (currentItem && this.onHoverCallback) { - this.onHoverCallback(currentItem) - } - }) - el.addEventListener('mouseleave', () => { - el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)' - el.style.borderColor = el.dataset.qualityColor || '#404040' - if (this.onHoverCallback) { - this.onHoverCallback(null) - } - }) - el.addEventListener('pointerdown', (e) => { - e.stopPropagation() - e.preventDefault() - const currentItem = (el as any).__groundItem as GroundItemEntity | undefined - if (currentItem && this.onPickupCallback) { - this.onPickupCallback(currentItem) - } - }) - - this.labelPool.push(el) - this.container.appendChild(el) - } - - ;(el as any).__groundItem = layout.item - el.dataset.itemId = layout.itemId - el.dataset.qualityColor = layout.borderColor - if (el.textContent !== layout.text) el.textContent = layout.text - el.style.color = layout.color - el.style.border = `1px solid ${layout.borderColor}` - el.style.left = `${Math.round(layout.screenX)}px` - el.style.top = `${Math.round(layout.screenY)}px` - el.style.display = 'block' - el.hidden = false - count += 1 - } - - // Hide surplus pooled elements - for (let i = count; i < this.labelPool.length; i += 1) { - this.labelPool[i]!.style.display = 'none' - this.labelPool[i]!.hidden = true - } - } - - /** - * Hide all active labels. - */ - hideAll(): void { - for (const el of this.labelPool) { - el.style.display = 'none' - el.hidden = true - } - } -} diff --git a/src/client/ui/hotkeys.ts b/src/client/ui/hotkeys.ts index b5da79a..5f45cf1 100644 --- a/src/client/ui/hotkeys.ts +++ b/src/client/ui/hotkeys.ts @@ -22,8 +22,6 @@ import { isLeftUsableSkill, PALADIN_AURA_SKILL_IDS, isAuraSkill, - DEFAULT_UNIVERSAL_SKILLS, - DEFAULT_SORCERESS_SKILLS, type HotkeySkillEntry, } from '../../common/skills/skills-meta.ts' @@ -34,8 +32,6 @@ export { isLeftUsableSkill, PALADIN_AURA_SKILL_IDS, isAuraSkill, - DEFAULT_UNIVERSAL_SKILLS, - DEFAULT_SORCERESS_SKILLS, type HotkeySkillEntry, } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 11e8c8b..97c0d2b 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -11,7 +11,6 @@ * Inventory & Equipment Paperdoll (`I`/`B`), Skill Tree (`T`) * - Top Monster Health Bar, Item Hover Tooltips, Ground `Alt` Item Labels, Area Entry Banner, * and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite. - * - Publishes `window.__d2webHud` for automated headless browser verification (Issue #27). */ import { D2FontRenderer } from './font.ts' @@ -46,12 +45,6 @@ import { type CursorState, } from './cursor.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' -import type { SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts' -import { resolvePotionSpec } from '../../common/items/misc-items.ts' -import { - createStarterProfileForClass, - type ClassProfileState, -} from '../../common/units/class-starter-profiles.ts' import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts' import { detectInitialLang, @@ -121,13 +114,6 @@ export interface PublishedHudState { npcMenuOpen?: boolean } -declare global { - interface Window { - __d2webHud?: PublishedHudState - __d2webHudInstance?: HudManager - } -} - interface UiManifest { readonly version: string readonly decodedDc6Count: number @@ -435,7 +421,6 @@ export class HudManager { this.syncPublishedState() } - private classProfiles = new Map() private _lang: TblLang = 'CHI' get lang(): TblLang { @@ -478,11 +463,8 @@ export class HudManager { onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void } - engine: GameEngine | null = null - private changeListeners: Array<() => void> = [] - constructor( - hudCanvasOrEngine?: HTMLCanvasElement | GameEngine | null, + hudCanvas?: HTMLCanvasElement | null, callbacks?: { onToggleAutomap?: () => void onWaypointTeleport?: (act: number, slug: string, levelId?: number) => void @@ -502,15 +484,8 @@ export class HudManager { onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void }, ) { - const isEngineArg = - hudCanvasOrEngine !== null && - hudCanvasOrEngine !== undefined && - typeof hudCanvasOrEngine === 'object' && - 'world' in hudCanvasOrEngine && - 'bag' in hudCanvasOrEngine - const rawCanvas = isEngineArg ? null : (hudCanvasOrEngine ?? null) this.hudCanvas = - rawCanvas ?? + hudCanvas ?? ({ width: 800, height: 600, @@ -542,182 +517,34 @@ export class HudManager { if (this.hudCanvas && this.hudCanvas.style) { this.hudCanvas.style.cursor = 'none' } - if (typeof window !== 'undefined') { - window.__d2webHud = this.state - window.__d2webHudInstance = this - } - if (isEngineArg) { - this.bindEngine(hudCanvasOrEngine as GameEngine) - } - } - - onChange(cb: () => void): () => void { - this.changeListeners.push(cb) - return () => { - const idx = this.changeListeners.indexOf(cb) - if (idx !== -1) this.changeListeners.splice(idx, 1) - } - } - - bindEngine(engine: GameEngine): void { - this.engine = engine - this.inventory._boundEngine = engine - this.currentClass = engine.classCode - this.syncFromEngine() - engine.onStateChange(() => { - this.syncFromEngine() - }) - } - - syncFromEngine(): void { - if (!this.engine) return - const p = this.engine.world.player - this.currentClass = this.engine.classCode - this.hp = p.hp - this.maxHp = p.maxHp - this.mana = p.mana - this.maxMana = p.maxMana - this.stamina = p.stamina ?? 100 - this.maxStamina = p.maxStamina ?? 100 - this.inventory.playerLevel = p.level ?? 1 - this.charSheet.attrs = { - ...this.charSheet.attrs, - classCode: this.engine.classCode, - level: p.level ?? 1, - xp: p.experience ?? p.xp ?? 0, - nextLevelXp: p.nextLevelExp ?? 500, - str: p.strength ?? p.str ?? 10, - dex: p.dexterity ?? p.dex ?? 25, - vit: p.vitality ?? p.vit ?? 10, - ene: p.energy ?? p.ene ?? 35, - statPoints: p.statPoints ?? 0, - baseMaxHp: p.maxHp, - baseMaxMana: p.maxMana, - baseMaxStamina: p.maxStamina ?? 100, - } - this.skillTree.unspentSkillPoints = p.skillPoints ?? 0 - this.syncPublishedState() - for (const cb of this.changeListeners) { - cb() - } } + /** Server authority: only requests `AddStatPoint`; attributes/vitals change when the server answers. */ allocateStat(attr: 'strength' | 'dexterity' | 'vitality' | 'energy' | BaseStatKey): boolean { - const normAttr: 'strength' | 'dexterity' | 'vitality' | 'energy' = - attr === 'str' - ? 'strength' - : attr === 'dex' - ? 'dexterity' - : attr === 'vit' - ? 'vitality' - : attr === 'ene' - ? 'energy' - : attr - if (this.engine) { - const ok = this.engine.allocateStat(normAttr) - if (ok) this.syncFromEngine() - return ok - } const shortKey: BaseStatKey = - normAttr === 'strength' + attr === 'strength' ? 'str' - : normAttr === 'dexterity' + : attr === 'dexterity' ? 'dex' - : normAttr === 'vitality' + : attr === 'vitality' ? 'vit' - : 'ene' - // Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers. + : attr === 'energy' + ? 'ene' + : attr if (this._commandSink) return this._commandSink.allocateStat(shortKey) return false } - grantExperience(amount: number): { levelsGained: number; newLevel: number } { - if (this.engine) { - const res = this.engine.grantExperience(amount) - this.syncFromEngine() - return res - } - return { levelsGained: 0, newLevel: this.charSheet.attrs.level } - } - - takeDamage(amount: number): void { - if (this.engine) { - this.engine.world.player.hp = Math.max(0, this.engine.world.player.hp - amount) - this.syncFromEngine() - return - } - this.hp = Math.max(0, this.hp - amount) - this.syncPublishedState() - for (const cb of this.changeListeners) cb() - } - - heal(amount: number): void { - if (this.engine) { - this.engine.world.player.hp = Math.min(this.engine.world.player.maxHp, this.engine.world.player.hp + amount) - this.syncFromEngine() - return - } - this.hp = Math.min(this.maxHp, this.hp + amount) - this.syncPublishedState() - for (const cb of this.changeListeners) cb() - } - - equipFromBag(x: number, y: number, slot: string): boolean { - if (this.engine) { - const placed = this.engine.bag.contents.find((p: { x: number; y: number; item: any }) => p.x === x && p.y === y) - const ok = placed ? this.engine.equipFromBag(placed.item, slot) : false - if (ok) this.syncFromEngine() - return ok - } - return false - } - - unequipSlot(slot: string): boolean { - if (this.engine) { - const ok = this.engine.unequipSlot(slot) - if (ok) this.syncFromEngine() - return ok - } - return false - } - - addToBelt(item: any): boolean { - if (this.engine && this.engine.belt) { - const placed = this.engine.belt.autoPlacePotion(item) - this.syncFromEngine() - return Boolean(placed) - } - return false - } - useBeltSlot(col: number): boolean { return this.drinkBeltSlot(col) } - dropInventoryGold(amount: number, mousePos?: { x: number; y: number }): number { - if (this.engine) { - const pile = this.engine.dropPlayerGold(amount) - const dropped = pile ? (pile.goldAmount ?? 0) : 0 - if (dropped > 0) { - this.callbacks.onDropGoldToGround?.(dropped, mousePos) - } - this.syncFromEngine() - return dropped - } + dropInventoryGold(amount: number, _mousePos?: { x: number; y: number }): number { // Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server. this._commandSink?.dropGold(amount) return 0 } - pickupGroundGold(groundItemId: number | string): number { - if (this.engine) { - const picked = this.engine.pickupGroundGoldPile(groundItemId) - this.syncFromEngine() - return picked - } - return 0 - } - get screenWidth(): number { return this.viewportProfile.width } @@ -777,90 +604,6 @@ export class HudManager { this.handleHudMouseDown(fakeEvent) } - /** - * Switches active character class with complete isolation and persistence across - * inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold), - * attributes, 30-skill trees, and hotkeys (Parity with Issue #379). - */ - switchClass(classCode: CharacterClassCode): void { - // 1. Snapshot current class state if currentClass is already initialized - if (this.currentClass && this.classProfiles.has(this.currentClass)) { - const currentProfile: ClassProfileState = { - classCode: this.currentClass, - equipped: { ...this.inventory.equipped }, - bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })), - stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })), - stashGold: this.worldPanels.stashGold, - attrs: { ...this.charSheet.attrs }, - hardPoints: new Map(this.skillTree.hardPoints), - unspentSkillPoints: this.skillTree.unspentSkillPoints, - activeSkillTab: this.skillTree.activeTab, - defaultLeftSkill: this.hotkeys.leftSkillId, - defaultRightSkill: this.hotkeys.rightSkillId, - availableSkills: [...this.hotkeys.availableSkills], - hp: this.hp, - mana: this.mana, - } - this.classProfiles.set(this.currentClass, currentProfile) - } - - // 2. Fetch target profile or create fresh starter profile - let targetProfile = this.classProfiles.get(classCode) - if (!targetProfile) { - targetProfile = createStarterProfileForClass(classCode) - this.classProfiles.set(classCode, targetProfile) - } - - // 3. Apply profile to current systems - this.currentClass = classCode - - // 3.1 Inventory & Equipment: Body has ONLY exclusive set items - for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) { - delete this.inventory.equipped[key] - } - Object.assign(this.inventory.equipped, targetProfile.equipped) - this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } })) - - // 3.2 Stash: gems, runes, jewels, and gold - this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })) - this.worldPanels.stashGold = targetProfile.stashGold - - // 3.3 Character Sheet - this.charSheet.attrs = { ...targetProfile.attrs } - if (typeof window !== 'undefined' && window.location?.search) { - const customName = new URLSearchParams(window.location.search).get('charName')?.trim() - if (customName) { - this.charSheet.attrs.name = customName - } - } - - // 3.4 Skill Tree - this.skillTree.loadProfile( - targetProfile.classCode, - targetProfile.hardPoints, - targetProfile.unspentSkillPoints, - targetProfile.activeSkillTab, - ) - - // 3.5 Hotkeys - this.hotkeys.setAvailableSkills( - targetProfile.availableSkills, - targetProfile.defaultLeftSkill, - targetProfile.defaultRightSkill, - ) - this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2) - - // 3.6 Published vitals - this.maxHp = targetProfile.attrs.baseMaxHp - this.hp = Math.min(targetProfile.hp, this.maxHp) - this.maxMana = targetProfile.attrs.baseMaxMana - this.mana = Math.min(targetProfile.mana, this.maxMana) - this.stamina = targetProfile.attrs.baseMaxStamina - this.maxStamina = targetProfile.attrs.baseMaxStamina - - this.syncPublishedState() - } - async init(baseUrl = '/ui'): Promise { this.hotkeys.preloadSkillIcons() this.skillTree.preloadAssets() @@ -1147,11 +890,6 @@ export class HudManager { } drinkBeltSlot(col: number): boolean { - if (this.engine && this.engine.belt?.getSlot(col, 0)) { - const ok = this.engine.useBeltSlot(col) - if (ok) this.syncFromEngine() - return ok - } // Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server. if (this._commandSink) return this._commandSink.useBeltSlot(col) return false @@ -1436,19 +1174,6 @@ export class HudManager { const leftX = pt.x - deltaLeft if (this.leftPanel === 'char') { this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { - if (this.engine) { - const normAttr = - key === 'str' - ? 'strength' - : key === 'dex' - ? 'dexterity' - : key === 'vit' - ? 'vitality' - : 'energy' - this.engine.allocateStat(normAttr) - this.syncFromEngine() - return - } this._commandSink?.allocateStat(key) }) if (!this.charSheet.visible) this.leftPanel = 'none' @@ -1691,13 +1416,6 @@ export class HudManager { rightUsable: true, isAura: isAuraSkill(skillId), }) - - const profile = this.currentClass ? this.classProfiles.get(this.currentClass) : undefined - if (profile) { - profile.availableSkills = [...this.hotkeys.availableSkills] - profile.hardPoints = new Map(this.skillTree.hardPoints) - profile.unspentSkillPoints = this.skillTree.unspentSkillPoints - } } private buildControlBarState() { diff --git a/src/client/ui/inventory-panel.ts b/src/client/ui/inventory-panel.ts deleted file mode 100644 index 2cea33e..0000000 --- a/src/client/ui/inventory-panel.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./inventory.ts" diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index 218e1b1..fc09362 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -564,426 +564,6 @@ export function resolveGroundItemSpriteRect( return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects) } -export const STARTER_EQUIPPED_GEAR: Readonly>> = { - helm: { - id: 'eq-shako', - code: 'uap', - invFile: 'invcap', - invtransform: 'cgrn', - chrtransform: 'cgrn', - name: 'Harlequin Crest', - nameZh: '谐角之冠 (Harlequin Crest)', - baseNameZh: '军帽 (Shako)', - quality: 'unique', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - defense: 141, - durability: { current: 12, max: 12 }, - reqLevel: 62, - reqStr: 50, - stats: [ - { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, - { text: '+1.5 生命 (依角色等级而定)', color: 'blue' }, - { text: '+1.5 法力 (依角色等级而定)', color: 'blue' }, - { text: '物理伤害减少 10%', color: 'blue' }, - { text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' }, - { text: '+2 所有属性', color: 'blue' }, - ], - }, - amulet: { - id: 'eq-maras', - code: 'amu', - invFile: 'invamu', - name: "Mara's Kaleidoscope", - nameZh: "马拉的万花筒 (Mara's Kaleidoscope)", - baseNameZh: '项链 (Amulet)', - quality: 'unique', - invWidth: 1, - invHeight: 1, - allowedSlots: ['amulet'], - reqLevel: 67, - stats: [ - { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, - { text: '+5 所有属性', color: 'blue' }, - { text: '所有抗性 +30 (All Resistances +30)', color: 'blue' }, - ], - }, - weapon1: { - id: 'eq-oculus', - code: 'oba', - invFile: 'invob5', - invtransform: 'whit', - chrtransform: 'whit', - name: 'The Oculus', - nameZh: '眼球 (The Oculus)', - baseNameZh: '涡流水晶 (Swirling Crystal)', - quality: 'unique', - invWidth: 1, - invHeight: 3, - allowedSlots: ['weapon1'], - damage: '18 - 42', - durability: { current: 50, max: 50 }, - speedText: '法球类 - 普通攻击速度', - reqLevel: 42, - stats: [ - { text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' }, - { text: '+3 法师技能等级', color: 'blue' }, - { text: '+30% 快速施法速度 (FCR)', color: 'blue' }, - { text: '+20% 增强防御', color: 'blue' }, - { text: '+20 体力', color: 'blue' }, - { text: '+20 精力', color: 'blue' }, - { text: '所有抗性 +20', color: 'blue' }, - { text: '+5 每次击杀获得法力', color: 'blue' }, - { text: '50% 更佳机会取得魔法装备', color: 'blue' }, - ], - }, - armor: { - id: 'eq-tal-armor', - code: 'uth', - invFile: 'invgth', - invtransform: 'dpur', - chrtransform: 'dpur', - name: "Tal Rasha's Guardianship", - nameZh: '塔·拉夏的守护', - baseNameZh: '漆甲 (Lacquered Plate)', - quality: 'set', - invWidth: 2, - invHeight: 3, - allowedSlots: ['armor'], - defense: 941, - durability: { current: 55, max: 55 }, - reqLevel: 71, - reqStr: 84, - stats: [ - { text: '+400 防御', color: 'blue' }, - { text: '抗寒 +40%', color: 'blue' }, - { text: '抗电 +40%', color: 'blue' }, - { text: '抗火 +40%', color: 'blue' }, - { text: '魔法伤害减少 15', color: 'blue' }, - { text: '88% 更佳机会取得魔法装备', color: 'blue' }, - ], - setPieces: [ - '塔·拉夏的守护 (Tal Rasha\'s Guardianship)', - '塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)', - '塔·拉夏的判决 (Tal Rasha\'s Adjudication)', - '塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)', - '塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)', - ], - setBonuses: [ - { text: '+10% 快速再度攻击 (2 件装备)', color: 'green' }, - { text: '+3 法师技能等级 (完整套装)', color: 'green' }, - { text: '所有抗性 +50 (完整套装)', color: 'green' }, - { text: '+150 生命 (完整套装)', color: 'green' }, - ], - }, - weapon2: { - id: 'eq-spirit', - code: 'uit', - invFile: 'invtow', - name: 'Spirit Monarch', - nameZh: '精神 (Spirit) 统盾', - baseNameZh: '统治者大盾 (Monarch)', - quality: 'rune', - runewordRunes: "'TalThulOrtAmn'", - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon2'], - defense: 148, - durability: { current: 148, max: 148 }, - reqLevel: 54, - reqStr: 156, - sockets: 4, - stats: [ - { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, - { text: '+35% 快速施法速度 (FCR)', color: 'blue' }, - { text: '+55% 快速再度攻击 (FHR)', color: 'blue' }, - { text: '+250 对远距离攻击的防御', color: 'blue' }, - { text: '+22 体力', color: 'blue' }, - { text: '+112 法力', color: 'blue' }, - { text: '抗寒 +35%', color: 'blue' }, - { text: '抗电 +35%', color: 'blue' }, - { text: '抗毒 +35%', color: 'blue' }, - { text: '+8 魔法吸收', color: 'blue' }, - ], - }, - gloves: { - id: 'eq-magefist', - code: 'tgl', - invFile: 'invtgl', - invtransform: 'lgry', - name: 'Magefist', - nameZh: '法师之拳 (Magefist)', - baseNameZh: '轻型铁手套', - quality: 'unique', - invWidth: 2, - invHeight: 2, - allowedSlots: ['gloves'], - defense: 25, - durability: { current: 14, max: 14 }, - reqLevel: 23, - reqStr: 45, - stats: [ - { text: '+1 火焰技能', color: 'blue' }, - { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, - { text: '法力再生速度提高 25%', color: 'blue' }, - { text: '增加 1-6 火焰伤害', color: 'blue' }, - { text: '+30% 增强防御', color: 'blue' }, - ], - }, - ring1: { - id: 'eq-soj-1', - code: 'rin', - invFile: 'invrin', - name: 'The Stone of Jordan', - nameZh: '乔丹之石 (Stone of Jordan)', - baseNameZh: '戒指 (Ring)', - quality: 'unique', - invWidth: 1, - invHeight: 1, - allowedSlots: ['ring1', 'ring2'], - reqLevel: 29, - stats: [ - { text: '+1 所有技能', color: 'blue' }, - { text: '增加法力上限 25%', color: 'blue' }, - { text: '+20 法力', color: 'blue' }, - { text: '增加 1-12 闪电伤害', color: 'blue' }, - ], - }, - belt: { - id: 'eq-arach', - code: 'ulc', - invFile: 'invvbl', - invtransform: 'blac', - name: 'Arachnid Mesh', - nameZh: '蜘蛛之网 (Arachnid Mesh)', - baseNameZh: '蛛网腰带 (Spiderweb Sash)', - quality: 'unique', - invWidth: 2, - invHeight: 1, - allowedSlots: ['belt'], - defense: 138, - durability: { current: 12, max: 12 }, - reqLevel: 80, - reqStr: 50, - stats: [ - { text: '+1 所有技能', color: 'blue' }, - { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, - { text: '减慢目标速度 10%', color: 'blue' }, - { text: '增加法力上限 5%', color: 'blue' }, - { text: '+120% 增强防御', color: 'blue' }, - ], - }, - ring2: { - id: 'eq-bk-2', - code: 'rin', - invFile: 'invrin', - name: "Bul-Kathos' Wedding Band", - nameZh: '布尔凯索之婚戒', - baseNameZh: '戒指 (Ring)', - quality: 'unique', - invWidth: 1, - invHeight: 1, - allowedSlots: ['ring1', 'ring2'], - reqLevel: 58, - stats: [ - { text: '+1 所有技能', color: 'blue' }, - { text: '+45 生命 (依角色等级而定)', color: 'blue' }, - { text: '5% 每次命中偷取生命', color: 'blue' }, - { text: '+50 最大耐力', color: 'blue' }, - ], - }, - boots: { - id: 'eq-wartrav', - code: 'xtb', - invFile: 'invtbt', - name: 'War Traveler', - nameZh: '战争旅者 (War Traveler)', - baseNameZh: '战场之靴 (Battle Boots)', - quality: 'unique', - invWidth: 2, - invHeight: 2, - allowedSlots: ['boots'], - defense: 139, - durability: { current: 48, max: 48 }, - reqLevel: 42, - reqStr: 95, - stats: [ - { text: '+25% 高速跑步/行走', color: 'blue' }, - { text: '+10 力量', color: 'blue' }, - { text: '+10 体力', color: 'blue' }, - { text: '增加 15-25 伤害', color: 'blue' }, - { text: '+190% 增强防御', color: 'blue' }, - { text: '减慢耐力消耗 40%', color: 'blue' }, - { text: '攻击者受到伤害 10', color: 'blue' }, - { text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' }, - ], - }, -} - -export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ - { - col: 0, - row: 0, - item: { - id: 'bag-cube', - code: 'box', - invFile: 'invbox', - name: 'Horadric Cube', - nameZh: '赫拉迪克方块 (Horadric Cube)', - baseNameZh: '任务宝盒', - quality: 'unique', - invWidth: 2, - invHeight: 2, - allowedSlots: [], - stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }], - }, - }, - { - col: 2, - row: 0, - item: { - id: 'bag-tbk', - code: 'tbk', - invFile: 'invbbk', - name: 'Tome of Town Portal', - nameZh: '回城之书 (Tome of Town Portal)', - baseNameZh: '法术书 (数量: 20)', - quality: 'normal', - invWidth: 1, - invHeight: 2, - allowedSlots: [], - quantity: 20, - maxQuantity: 20, - stats: [{ text: '右键施放城镇传送门', color: 'white' }], - }, - }, - { - col: 2, - row: 2, - item: { - id: 'bag-ibk', - code: 'ibk', - invFile: 'invrbk', - name: 'Tome of Identify', - nameZh: '鉴定之书 (Tome of Identify)', - baseNameZh: '法术书 (数量: 20)', - quality: 'normal', - invWidth: 1, - invHeight: 2, - allowedSlots: [], - quantity: 20, - maxQuantity: 20, - stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], - }, - }, - { - col: 3, - row: 0, - item: { - id: 'bag-torch', - code: 'cm2', - invFile: 'invtrch', - name: 'Hellfire Torch', - nameZh: '地狱火炬 (Hellfire Torch)', - baseNameZh: '大型护身符 (Large Charm)', - quality: 'unique', - invWidth: 1, - invHeight: 2, - allowedSlots: [], - reqLevel: 75, - stats: [ - { text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' }, - { text: '+3 法师技能等级', color: 'blue' }, - { text: '+20 所有属性', color: 'blue' }, - { text: '所有抗性 +20', color: 'blue' }, - { text: '+8 照亮范围', color: 'blue' }, - ], - }, - }, - { - col: 3, - row: 2, - item: { - id: 'bag-anni', - code: 'cm1', - invFile: 'invmss', - name: 'Annihilus', - nameZh: '毁灭 (Annihilus)', - baseNameZh: '小型护身符 (Small Charm)', - quality: 'unique', - invWidth: 1, - invHeight: 1, - allowedSlots: [], - reqLevel: 70, - stats: [ - { text: '+1 所有技能 (+1 To All Skills)', color: 'blue' }, - { text: '+20 所有属性', color: 'blue' }, - { text: '所有抗性 +20', color: 'blue' }, - { text: '+10% 获得经验值', color: 'blue' }, - ], - }, - }, - { - col: 4, - row: 0, - item: { - id: 'bag-gheeds', - code: 'cm3', - invFile: 'invch3', - name: "Gheed's Fortune", - nameZh: '基德的运气 (Gheed\'s Fortune)', - baseNameZh: '超大型护身符 (Grand Charm)', - quality: 'unique', - invWidth: 1, - invHeight: 3, - allowedSlots: [], - reqLevel: 62, - stats: [ - { text: '160% 额外金币从怪物身上取得', color: 'blue' }, - { text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' }, - { text: '降低所有商店价格 15%', color: 'blue' }, - ], - }, - }, - { - col: 8, - row: 0, - item: { - id: 'bag-cta', - code: 'crs', - invFile: 'invcrs', - name: 'Call to Arms', - nameZh: '战争召唤 (Call to Arms)', - baseNameZh: '水晶剑 (Crystal Sword)', - quality: 'rune', - runewordRunes: "'AmnRalMalIstOhm'", - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon1'], - damage: '17 - 52', - durability: { current: 20, max: 20 }, - reqStr: 43, - reqLevel: 57, - sockets: 5, - speedText: '剑类 - 极快的攻击速度', - stats: [ - { text: '+1 所有技能 (+1 To All Skills)', color: 'blue' }, - { text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' }, - { text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' }, - { text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' }, - { text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' }, - { text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' }, - { text: '+6 战斗体制 (Battle Orders)', color: 'blue' }, - { text: '+6 战斗指挥 (Battle Command)', color: 'blue' }, - { text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' }, - { text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' }, - { text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' }, - ], - }, - }, -] - /** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */ export const TOME_MAX_QUANTITY = 20 @@ -2129,33 +1709,22 @@ export class InventoryPanel { this.playerContextSeq = nextPlayerContextSeq() } - /** Optional bound GameEngine for single-source-of-truth gold and capacity. */ - _boundEngine?: any | undefined /** Server stat 14 (`gold`); 0 until received (D2GS omits zero-valued stats). */ private _gold = 0 /** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */ get maxGold(): number { - if (this._boundEngine) { - return this._boundEngine.maxGoldCapacity - } return getInventoryGoldLimit(this.playerLevel) } /** Player gold in inventory. */ get gold(): number { - if (this._boundEngine) { - return this._boundEngine.bag.gold - } return this._gold } set gold(value: number) { const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0)) this._gold = safe - if (this._boundEngine) { - this._boundEngine.bag.gold = safe - } } isIdentifyMode(): boolean { diff --git a/src/client/ui/item-bridge.ts b/src/client/ui/item-bridge.ts deleted file mode 100644 index 88c3407..0000000 --- a/src/client/ui/item-bridge.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../common/items/item-bridge.ts' diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index dce28c7..371a092 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -1977,11 +1977,11 @@ export class WorldPanelsHud { withdrawGold( amount: number, - playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean; _boundEngine?: unknown }, + playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean }, ): number { if (amount <= 0) return 0 const cap = - (playerInventory._playerLevelExplicitlySet || playerInventory._boundEngine) && + playerInventory._playerLevelExplicitlySet && playerInventory.maxGold !== undefined ? playerInventory.maxGold : PLAYER_GOLD_CAP diff --git a/src/client/world/act-runtime.ts b/src/client/world/act-runtime.ts deleted file mode 100644 index 2ecfe40..0000000 --- a/src/client/world/act-runtime.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { AudioManager, SoundManager, sharedAudioManager } from '../audio/manager.ts' -import { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts' -import { MapService } from '../map/map-service.ts' -import { ActorAnimator } from '../render/actor-animator.ts' -import { buildBounds } from '../render/cull.ts' -import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts' -import { MISSILE_METAS, velocityToDccDirection, type MissileFrameTuple, type MissileMeta } from '../render/missiles-meta.ts' -import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts' -import { LoadGeneration, SpriteRenderer, WebGLRenderer2D, type AtlasHandle } from '../render/renderer.ts' -import { RoofFadeController } from '../render/roofs.ts' -import { initBrowserArenaMode } from '../scene/arena-overlay.ts' -import { - ADDITIVE_MONSTER_TOKENS, DEFAULT_BASES, loadCharacterArt, loadMonsterArtMap, - missingMonsterArtIds, type LoadedCharacter, type MonsterArtLoad, -} from '../scene/entity-art-loader.ts' -import { - GROUND_ITEM_QUALITY_COLORS, drawGroundItem, globalItemsAtlasHandle, - loadItemsAtlas, resolveGroundItemFlippyFrame, syncHudGroundDrops, -} from '../scene/ground-item-renderer.ts' -import { - LIGHTING_PRESETS, lightingLabel, normalizeLightingPreset, - type LightingPreset, type LightingPresetConfig, -} from '../scene/lighting-presets.ts' -import { - drawCastOverlay, drawExplosion, drawFireExplosion, drawFireboltProjectile, - drawMissileProjectile, drawTownPortal, loadMissileArtMap, loadOverlayArtMap, - type LoadedMissileArt, type LoadedOverlayArt, -} from '../scene/missile-overlay-renderer.ts' -import { - SceneMouseController, playStashOpenSound, setupMouseControls, type MouseControllerContext, - type PendingInteraction, type SceneMouseControllerOptions, -} from '../scene/mouse-controller.ts' -import { - ADDITIVE_OBJECT_TOKENS, PERSISTENT_ILLUMINATION_TOKENS, computeObjectPhaseOffsetMs, - updateAnimatedObject, updateAnimatedObjects, type AnimatedObjectFrame, - type Drawable, type ObjectDrawable, -} from '../scene/object-animation.ts' -import { - playInventoryFullFeedback, projectNpcLabel, showNotification, - updateGroundItemLabelsOverlay, updateOverheadLabels, - type NpcLabelProjectionParams, type NpcLabelProjectionResult, -} from '../scene/overhead-labels.ts' -import { - DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime, - countLoadedPages, createLevelTransitionController, disposeRuntimeAtlases, - fetchJson, getPackIndex, loadPackRuntime, loadRuntimeForLevel, - pagesNeededForViewport, param, preloadRemaining, switchArea, worldVariantsFor, - type MapRuntime, type PackIndex, type PackIndexEntry, type PackObject, - type PackObjectFrame, type PackSceneJson, -} from '../scene/pack-loader.ts' -import { - SAVE_KEY, monsterArtStatusText, monsterPlanStatusText, - monsterToTargetHudInfo, publish, recordMonsterArtLoad, reportMonsterPlan, - state, type ActSceneState, -} from '../scene/scene-state.ts' -import { - changeHeroClass, createActSceneHudManager, createHeroLightingAndMonsterControls, - mountChatAndToolbarUi, populatePackSelectors, respawnMonsters, - updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors, -} from '../scene/toolbar-chat-ui.ts' -import { FadeOverlay } from '../scene/transition.ts' -import { - applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile, - renderWorldLayers, stepPlayerAndMonsterAnimations, -} from '../scene/world-renderer.ts' -import { ViewportCamera } from '../sim/camera.ts' -import { KeyboardInput } from '../sim/input.ts' -import { GroundLabelOverlay } from '../ui/ground-labels.ts' -import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' -import { Minimap, type MinimapLevel } from '../ui/minimap.ts' -import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' -import { getMissileTxtData, isAuraSkill } from '../../common/skills/missiles-data.ts' -import { - PASSIVE_SKILL_IDS, checkAmmunition, getAttackWeaponInfo, getSkillMana, - getSkillRange, isPassiveSkill, type AttackWeaponInfo, type AttackWeaponInputItem, - type AttackWeaponKind, type AttackWeaponSubtype, type SkillCastOutcome, -} from '../../common/skills/weapon-combat-meta.ts' -import { executeSceneChatCommand, handleActSceneChatCommand } from '../../common/world/chat-commands.ts' -import { resolveTownPortalSubTile } from '../../common/world/portal.ts' -import { - ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX, - WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf, - getEntranceLabelWorldPos, getWarpLabelWorldPos, hitTestEntrance, hitTestWarp, - isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile, - screenToWorld, subTileToScene, type SceneEntrance, type SceneWarp, - type SceneWaypoint, type ScreenToWorldParams, -} from '../../common/world/scene-links-nav.ts' -import { - CANONICAL_TOWN_STASHES, HERO_CLASSES, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS, - TOWN_LEVEL_IDS, buildStreamingRooms, demoteToNormal, getSharedDropTables, - isTownLevel, normalizeHeroToken, parseDensityMultiplier, parseEliteMultiplier, - promoteToChampion, promoteToUnique, rebudgetMonsterPacks, roomHasWalkableGround, - type DensityMultiplier, type EliteMultiplier, type HeroClassInfo, - type RebudgetOptions, type TownStashEntity, -} from '../../common/world/scene-population.ts' -import { syncEngineState } from '../../common/units/unit-types.ts' -import { ClientWorld } from './client-world.ts' - -// GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ../scene/ground-item-renderer.ts) -export { - ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, ActorAnimator, AudioManager, - CANONICAL_TOWN_STASHES, ClientWorld, D2_EXPERIENCE_TABLE_99, DEFAULT_BASES, - DEFAULT_PACKS, ENTRANCE_CLICK_RADIUS_PX, FEET_HEIGHT, FEET_WIDTH, FadeOverlay, - GROUND_ITEM_QUALITY_COLORS, GroundLabelOverlay, HERO_CLASSES, KeyboardInput, - LIGHTING_PRESETS, LightningTrailManager, LoadGeneration, MISSILE_METAS, - MapService, Minimap, OVERLAY_METAS, PASSIVE_SKILL_IDS, - PERSISTENT_ILLUMINATION_TOKENS, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS, - RoofFadeController, SAVE_KEY, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, - SceneMouseController, SoundManager, SpriteRenderer, TELEKINESIS_RANGE_PX, - TOWN_LEVEL_IDS, ViewportCamera, WALK_SPEED, WARP_CLICK_RADIUS_PX, - WARP_FADE_MS, WARP_TRIGGER_SUBTILES, WebGLRenderer2D, - applyDynamicSceneLighting, buildBounds, buildMinimapLevel, buildPackRuntime, - buildStreamingRooms, cellOf, changeHeroClass, checkAmmunition, - computeObjectPhaseOffsetMs, - countLoadedPages, createActSceneHudManager, createHeroLightingAndMonsterControls, - createLevelTransitionController, demoteToNormal, - disposeRuntimeAtlases, drawCastOverlay, drawElectricBoltFallback, drawExplosion, - drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile, - drawTownPortal, executeSceneChatCommand, facingOf, fetchJson, getAttackWeaponInfo, - getEntranceLabelWorldPos, getMissileTxtData, getPackIndex, getSharedDropTables, - getSkillMana, getSkillRange, getTblLang, getWarpLabelWorldPos, - globalItemsAtlasHandle, handleActSceneChatCommand, hitTestEntrance, hitTestWarp, - initBrowserArenaMode, initLangFromBrowser, isAuraSkill, isPassiveSkill, - isPlayerWithinLinkRange, isTownLevel, lightingLabel, loadCharacterArt, - loadItemsAtlas, loadMissileArtMap, loadMonsterArtMap, loadOverlayArtMap, - loadPackRuntime, loadRuntimeForLevel, missingMonsterArtIds, monsterArtStatusText, - monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi, - normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param, - parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback, - playStashOpenSound, playerSubTile, populatePackSelectors, preloadRemaining, - projectNpcLabel, promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks, - recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan, - resolveGroundItemFlippyFrame, - resolveGroundItemSpriteRect, resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, - respawnMonsters, roomHasWalkableGround, sceneToSubTile, screenToWorld, - setupMouseControls, sharedAudioManager, showNotification, state, - stepPlayerAndMonsterAnimations, subTileToScene, switchArea, syncEngineState, - syncHudGroundDrops, updateAnimatedObject, updateAnimatedObjects, - updateGroundItemLabelsOverlay, updateOverheadLabels, updateSelectorWarningStyle, - updateToolbarUrl, velocityToDccDirection, wireBootToolbarSelectors, worldVariantsFor, - type ActSceneState, type AnimatedObjectFrame, type AtlasHandle, - type AttackWeaponInfo, type AttackWeaponInputItem, type AttackWeaponKind, - type AttackWeaponSubtype, type DensityMultiplier, type Drawable, - type EliteMultiplier, type HeroClassInfo, type LightingPreset, - type LightingPresetConfig, type LoadedCharacter, type LoadedMissileArt, - type LoadedOverlayArt, type MapRuntime, type MinimapLevel, type MissileFrameTuple, - type MissileMeta, type MonsterArtLoad, type MouseControllerContext, - type NpcLabelProjectionParams, type NpcLabelProjectionResult, type ObjectDrawable, - type OverlayMeta, type PackIndex, type PackIndexEntry, type PackObject, - type PackObjectFrame, type PackSceneJson, type PendingInteraction, - type RebudgetOptions, type SceneEntrance, type SceneMouseControllerOptions, - type SceneWarp, type SceneWaypoint, type ScreenToWorldParams, - type SkillCastOutcome, type TblLang, type TownStashEntity, -} diff --git a/src/client/world/index.ts b/src/client/world/index.ts deleted file mode 100644 index b0f028e..0000000 --- a/src/client/world/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './client-unit.ts' -export * from './client-world.ts' -export * from './inventory.ts' -export * from './item-tables-provider.ts' -export * from './self.ts' -export * from './stat-list.ts' -export * from './states.ts' diff --git a/src/common/items/belt-model.ts b/src/common/items/belt-model.ts index 6c3e69b..253422b 100644 --- a/src/common/items/belt-model.ts +++ b/src/common/items/belt-model.ts @@ -163,49 +163,6 @@ export function isAutoBeltablePotion(item: unknown): boolean { return itemToBeltPotion(item) !== null } -export const DEFAULT_BELT_POTIONS: readonly BeltPotion[] = [ - { - id: 'pot-hp-1', - code: 'hp4', - invFile: 'invhp4', - name: 'Greater Healing Potion', - nameZh: '强效治疗药剂', - kind: 'hp', - healHp: 180, - healMana: 0, - }, - { - id: 'pot-hp-2', - code: 'hp5', - invFile: 'invhp5', - name: 'Super Healing Potion', - nameZh: '超级治疗药剂', - kind: 'hp', - healHp: 320, - healMana: 0, - }, - { - id: 'pot-mp-1', - code: 'mp4', - invFile: 'invmp4', - name: 'Greater Mana Potion', - nameZh: '强效法力药剂', - kind: 'mana', - healHp: 0, - healMana: 150, - }, - { - id: 'pot-rv-1', - code: 'rvl', - invFile: 'invvpl', - name: 'Full Rejuvenation Potion', - nameZh: '全面回复活力药剂', - kind: 'rejuv', - healHp: 400, - healMana: 400, - }, -] - export class BeltModel { /** 4 columns × 4 rows (`grid[row][col]`, row 0 is bottom visible row). */ readonly grid: (BeltPotion | null)[][] = Array.from({ length: BELT_ROWS }, () => @@ -218,18 +175,6 @@ export class BeltModel { /** Currently hovered slot `(row, col)`. */ hoveredSlot: { row: number; col: number } | null = null - seedDefaultPotions(): void { - for (let row = 0; row < BELT_ROWS; row++) { - for (let col = 0; col < BELT_COLS; col++) { - const template = DEFAULT_BELT_POTIONS[col]! - this.grid[row]![col] = { - ...template, - id: `${template.id}-r${row}-c${col}`, - } - } - } - } - get isExpanded(): boolean { return this.lockedOpen || this.hovered } diff --git a/src/common/skills/skills-meta.ts b/src/common/skills/skills-meta.ts index 49fa4bd..a0ce82d 100644 --- a/src/common/skills/skills-meta.ts +++ b/src/common/skills/skills-meta.ts @@ -183,45 +183,5 @@ export function isAuraSkill(skillId: number): boolean { return false } -export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [ - { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, - { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, - { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, -] - -export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ - // Universal Skills - ...DEFAULT_UNIVERSAL_SKILLS, - // Fire Spells (8 active) - { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, - { skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true }, - { skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, - { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, - { skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true }, - { skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true }, - { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true }, - { skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true }, - // Lightning Spells (9 active) - { skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true }, - { skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true }, - { skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true }, - { skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true }, - { skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true }, - { skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true }, - { skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true }, - { skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true }, - { skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true }, - // Cold Spells (9 active) - { skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true }, - { skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true }, - { skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true }, - { skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true }, - { skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, - { skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true }, - { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true }, - { skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true }, - { skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true }, -] - export type { CharacterClassCode } diff --git a/src/common/units/class-starter-profiles.ts b/src/common/units/class-starter-profiles.ts index 8db1075..af80a96 100644 --- a/src/common/units/class-starter-profiles.ts +++ b/src/common/units/class-starter-profiles.ts @@ -12,21 +12,11 @@ * - Druid: Aldur's Watchtower (Hunter's Guise, Shadow Plate, Jagged Star, Battle Boots) * - Assassin: Natalya's Odium (Grim Helm, Loricated Mail, Scissors Suwayyah, Mesh Boots) * - Stash contains authentic Runes (Jah, Ber, Lo, etc.), Perfect & Flawless Gems/Skulls, and unique/magic/rare Jewels. - * - Complete isolation and persistence across class switching. */ import type { CharacterClassCode } from './classes.ts' import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../items/inventory-model.ts' import type { CharacterAttributes } from './unit-types.ts' -import { - type HotkeySkillEntry, - DEFAULT_SORCERESS_SKILLS, - isLeftUsableSkill, - isPassiveSkill, - isAuraSkill, -} from '../skills/skills-meta.ts' -import { SKILLS_BY_CLASS } from '../data/skills-catalog.ts' -import { calculateManaCost } from '../skills/skill-calc-engine.ts' import { getSocketableInfo } from '../items/gems-data.ts' function buildSocketableStats(code: string): readonly { text: string; color: 'white' }[] { @@ -41,23 +31,6 @@ function buildSocketableStats(code: string): readonly { text: string; color: 'wh ] } -export interface ClassProfileState { - readonly classCode: CharacterClassCode - equipped: Partial> - bagItems: GridPlacement[] - stashItems: GridPlacement[] - stashGold: number - attrs: CharacterAttributes - hardPoints: Map - unspentSkillPoints: number - activeSkillTab: 0 | 1 | 2 - defaultLeftSkill: number - defaultRightSkill: number - availableSkills: readonly HotkeySkillEntry[] - hp: number - mana: number -} - // ------------------------------------------------------------------------------------------------- // 1. CLASS EXCLUSIVE SET ITEMS (Body Only) // ------------------------------------------------------------------------------------------------- @@ -1700,91 +1673,3 @@ export const CLASS_STARTER_HARD_POINTS: Record = { - ama: buildClassDefaultSkills('ama'), - sor: DEFAULT_SORCERESS_SKILLS, - nec: buildClassDefaultSkills('nec'), - pal: buildClassDefaultSkills('pal'), - bar: buildClassDefaultSkills('bar'), - dru: buildClassDefaultSkills('dru'), - ass: buildClassDefaultSkills('ass'), -} - -// ------------------------------------------------------------------------------------------------- -// 7. STARTER PROFILE FACTORY -// ------------------------------------------------------------------------------------------------- - -export function createStarterProfileForClass(classCode: CharacterClassCode): ClassProfileState { - const equipped = { ...(CLASS_EXCLUSIVE_SETS[classCode] ?? CLASS_EXCLUSIVE_SETS.sor) } - const bagItems = createStarterBagForClass(classCode) - const stashItems = STARTER_STASH_ITEMS.map((gp) => ({ ...gp, item: { ...gp.item } })) - const attrs = { ...(CLASS_STARTER_ATTRS[classCode] ?? CLASS_STARTER_ATTRS.sor) } - - const hardPointsEntries = Object.entries(CLASS_STARTER_HARD_POINTS[classCode] ?? CLASS_STARTER_HARD_POINTS.sor) - const hardPoints = new Map(hardPointsEntries.map(([id, pts]) => [Number(id), pts])) - - const defaultSkills: Record = { - ama: { left: 6, right: 7, tab: 0 }, - sor: { left: 47, right: 64, tab: 2 }, - nec: { left: 67, right: 70, tab: 2 }, - pal: { left: 96, right: 112, tab: 0 }, - bar: { left: 126, right: 133, tab: 0 }, - dru: { left: 225, right: 229, tab: 2 }, - ass: { left: 254, right: 251, tab: 0 }, - } - const defaults = defaultSkills[classCode] ?? defaultSkills.sor - - return { - classCode, - equipped, - bagItems, - stashItems, - stashGold: 2_500_000, - attrs, - hardPoints, - unspentSkillPoints: 12, - activeSkillTab: defaults.tab, - defaultLeftSkill: defaults.left, - defaultRightSkill: defaults.right, - availableSkills: CLASS_DEFAULT_SKILLS[classCode] ?? CLASS_DEFAULT_SKILLS.sor, - hp: attrs.baseMaxHp, - mana: attrs.baseMaxMana, - } -} diff --git a/tests/act-scene-monster-dispatch.test.ts b/tests/act-scene-monster-dispatch.test.ts index 27897a3..0465fc8 100644 --- a/tests/act-scene-monster-dispatch.test.ts +++ b/tests/act-scene-monster-dispatch.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/client/world/act-runtime.ts' +import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/client/scene/entity-art-loader.ts' import { ADDITIVE_MONSTER_TOKENS } from '../src/client/render/monster-art.ts' import { executeMonsterRenderLoop, diff --git a/tests/act-scene-monster-plan.test.ts b/tests/act-scene-monster-plan.test.ts index 18e2f1d..1237c68 100644 --- a/tests/act-scene-monster-plan.test.ts +++ b/tests/act-scene-monster-plan.test.ts @@ -8,8 +8,6 @@ * content twice over. */ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { monsterPlanStatusText } from '../src/client/world/act-runtime.ts' import * as demoData from '../src/common/data/demo-data.ts' import { planLevelMonsters } from '../src/common/units/monsters-data.ts' import { parseTable } from '../src/common/world/acts.ts' @@ -20,38 +18,12 @@ function mockTable(tsv: string): D2Table { return parseTable(encoder.encode(tsv.trim())) } -const ACT_SCENE_SOURCE = readFileSync(new URL('../src/client/world/act-runtime.ts', import.meta.url), 'utf8') - -/** - * Comments are stripped before scanning: the docstrings deliberately still name - * the deleted fallback to explain why it is gone, and that is not a code path. - */ -const ACT_SCENE_CODE = ACT_SCENE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') - describe('no synthetic monster fallback', () => { it('demo-data no longer exports a monster roster', () => { expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS') - // The other demo tables are still used by the scene's engine options. expect(Object.keys(demoData)).toContain('DEMO_SKILLS') }) - it('the act scene has no placeholder spawn path left', () => { - expect(ACT_SCENE_CODE).not.toContain('DEMO_MONSTERS') - expect(ACT_SCENE_CODE).not.toContain('PLACEHOLDER_MONSTER_COUNT') - // The spawn gate was an English substring match on the *display* name, so a - // renamed or translated town spawned 12 fake monsters in the square. (The - // pack index's `slug.includes('town')` level lookup is a different thing - // and is still there.) - expect(ACT_SCENE_CODE).not.toContain("toLowerCase().includes('town')") - expect(ACT_SCENE_CODE).not.toContain('spawnMonsters(') - }) - - it('reports an empty plan rather than hiding it', () => { - expect(monsterPlanStatusText(0)).toContain('本关未规划到怪物') - expect(monsterPlanStatusText(0)).toContain('怪物 0') - expect(monsterPlanStatusText(37)).toBe(' · 怪物 37') - }) - it('includes superunique boss and minion monster IDs in PlannedLevel.types', () => { const levelsTxt = mockTable([ 'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3', diff --git a/tests/act-scene-object-dispatch.test.ts b/tests/act-scene-object-dispatch.test.ts deleted file mode 100644 index 4a0eddb..0000000 --- a/tests/act-scene-object-dispatch.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { ADDITIVE_OBJECT_TOKENS, type ObjectDrawable } from '../src/client/world/act-runtime.ts' -import { - buildObjectDrawables, - executeDepthDrawDispatch, - MockRenderer, - type PackObjectInput, - type SimulatedWall, -} from '../tools/verify-scene-object-dispatch.ts' - -describe('Scene Object Rendering Dispatch (Milestone 1 / R1)', () => { - it('correctly maps all 1.13c additive tokens to blendMode: "additive"', () => { - const tokens = ['11', '12', 'R1', 'R2', 'R3', 'R4', 'R5', '1R', '2R', '3R', '4R', 'HA'] - for (const token of tokens) { - expect(ADDITIVE_OBJECT_TOKENS.has(token)).toBe(true) - - const drawables = buildObjectDrawables([ - { - x: 100, - y: 200, - token, - frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 }, - }, - ]) - expect(drawables).toHaveLength(1) - expect(drawables[0]!.blendMode).toBe('additive') - } - }) - - it('correctly identifies trans=7 and trans=1 objects as additive', () => { - const drawables = buildObjectDrawables([ - { - x: 10, - y: 20, - token: 'UNKNOWN', - trans: 7, - frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 }, - }, - { - x: 30, - y: 40, - token: 'OTHER', - trans: 1, - frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 }, - }, - ]) - expect(drawables).toHaveLength(2) - expect(drawables[0]!.blendMode).toBe('additive') - expect(drawables[1]!.blendMode).toBe('additive') - }) - - it('preserves blendMode: "normal" for regular objects and torches', () => { - const drawables = buildObjectDrawables([ - { - x: 10, - y: 20, - token: 'CHEST', - trans: 0, - frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 }, - }, - { - x: 30, - y: 40, - token: 'TO', - trans: 0, - frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 }, - }, - ]) - expect(drawables).toHaveLength(2) - expect(drawables[0]!.blendMode).toBe('normal') - expect(drawables[1]!.blendMode).toBe('normal') - }) - - it('dispatches additive objects with blendMode: "additive" and regular objects with "normal"', () => { - const rawObjects: PackObjectInput[] = [ - { id: 1, token: '11', depth: 2, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 10, y: 0 }, - { id: 2, token: 'BOX', depth: 6, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 30, y: 0 }, - ] - const objs = buildObjectDrawables(rawObjects) - - const walls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 4 } as any, x: 0, y: 0, cellX: 2, cellY: 2, page: 0 }, - ] - - const renderer = new MockRenderer() - executeDepthDrawDispatch(renderer, { - walls, - objs, - maxDepthSteps: [Infinity], - }) - - expect(renderer.draws).toHaveLength(3) - - // Draw 0: Object 1 (Token 11, depth 2) -> additive - expect(renderer.draws[0]!.target).toBe('object') - expect(renderer.draws[0]!.depth).toBe(2) - expect(renderer.draws[0]!.blendMode).toBe('additive') - - // Draw 1: Wall (depth 4) -> blendMode undefined (options has { atlas }) - expect(renderer.draws[1]!.target).toBe('wall') - expect(renderer.draws[1]!.depth).toBe(4) - expect(renderer.draws[1]!.blendMode).toBeUndefined() - - // Draw 2: Object 2 (BOX, depth 6) -> normal - expect(renderer.draws[2]!.target).toBe('object') - expect(renderer.draws[2]!.depth).toBe(6) - expect(renderer.draws[2]!.blendMode).toBe('normal') - }) - - it('strictly preserves depth ordering across mixed walls, objects, and incremental depth steps', () => { - const rawObjects: PackObjectInput[] = [ - { id: 1, depth: 3, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 15, y: 0 }, - { id: 2, depth: 8, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 40, y: 0 }, - { id: 3, depth: 15, token: 'CHEST', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 75, y: 0 }, - ] - const objs = buildObjectDrawables(rawObjects) - - const walls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 1 } as any, x: 0, y: 0, cellX: 0, cellY: 1, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 5 } as any, x: 0, y: 0, cellX: 2, cellY: 3, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 10 } as any, x: 0, y: 0, cellX: 5, cellY: 5, page: 0 }, - ] - - const renderer = new MockRenderer() - executeDepthDrawDispatch(renderer, { - walls, - objs, - maxDepthSteps: [4, 9, Infinity], - }) - - expect(renderer.draws).toHaveLength(6) - - // Verify non-decreasing order - for (let i = 1; i < renderer.draws.length; i++) { - expect(renderer.draws[i]!.depth).toBeGreaterThanOrEqual(renderer.draws[i - 1]!.depth) - } - - const depths = renderer.draws.map(d => d.depth) - expect(depths).toEqual([1, 3, 5, 8, 10, 15]) - }) -}) diff --git a/tests/attack-skill.test.ts b/tests/attack-skill.test.ts index f105bc4..9015692 100644 --- a/tests/attack-skill.test.ts +++ b/tests/attack-skill.test.ts @@ -5,21 +5,18 @@ import { describe, expect, it, beforeEach } from 'vitest' import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' -import { - DEFAULT_SORCERESS_SKILLS, - resolveSkillIcon, - getAvailableSkillsForSide, -} from '../src/client/ui/hotkeys.ts' +import { resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts' import { createDemoHotkeys } from './fixtures/hud-demo-state.ts' -import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts' +import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/common/skills/weapon-combat-meta.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts' import type { Monster } from '../src/server/engine/combat.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => { describe('Skill 0 Definition & Dual-Palette Usability', () => { it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => { - const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0) + const attackSkill = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0) expect(attackSkill).toBeDefined() expect(attackSkill!.name).toBe('Attack') expect(attackSkill!.nameZh).toBe('普通攻击') @@ -29,8 +26,8 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => }) it('includes Skill 0 in both left and right available skill palettes', () => { - const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS) - const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS) + const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS) + const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS) const leftAttack = leftSkills.find((s) => s.skillId === 0) const rightAttack = rightSkills.find((s) => s.skillId === 0) diff --git a/tests/blizzard-stress.test.ts b/tests/blizzard-stress.test.ts index f9a4c11..84851c4 100644 --- a/tests/blizzard-stress.test.ts +++ b/tests/blizzard-stress.test.ts @@ -31,7 +31,7 @@ import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts' import { UnitStatList } from '../src/server/engine/stat-list.ts' import { StateBus } from '../src/server/engine/state-bus.ts' import type { CombatUnitContext } from '../src/server/engine/combat-pipeline.ts' -import { drawMissileProjectile } from '../src/client/world/act-runtime.ts' +import { drawMissileProjectile } from '../src/client/scene/missile-overlay-renderer.ts' describe('[Skill #059] Blizzard — Empirical Stress & Physics Challenger Suite', () => { const openTerrain = { overlap: () => 0 } diff --git a/tests/cast-overlay-113c.test.ts b/tests/cast-overlay-113c.test.ts index 7bff4e4..e75e93e 100644 --- a/tests/cast-overlay-113c.test.ts +++ b/tests/cast-overlay-113c.test.ts @@ -26,7 +26,7 @@ import { type OverlayTxtData, castSkill, } from '../src/server/skills/skills.ts' -import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/world/act-runtime.ts' +import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts' diff --git a/tests/challenger-m2-adversarial.test.ts b/tests/challenger-m2-adversarial.test.ts index 1d75e58..8dbbe16 100644 --- a/tests/challenger-m2-adversarial.test.ts +++ b/tests/challenger-m2-adversarial.test.ts @@ -37,7 +37,7 @@ import { computeEffectivePlayers, computeScaledNoDrop, } from '../src/common/items/treasure-engine.ts' -import { handleActSceneChatCommand } from '../src/client/world/act-runtime.ts' +import { handleActSceneChatCommand } from '../src/common/world/chat-commands.ts' import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' const dropTables = getEmbeddedDropTables() diff --git a/tests/challenger-m2-stress.test.ts b/tests/challenger-m2-stress.test.ts index b3e1b14..365fce0 100644 --- a/tests/challenger-m2-stress.test.ts +++ b/tests/challenger-m2-stress.test.ts @@ -4,12 +4,11 @@ */ import { describe, expect, it } from 'vitest' -import { KeyboardInput } from '../src/client/sim/input.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' -import { SceneMouseController, screenToWorld, facingOf, projectNpcLabel, getSkillRange, getSkillMana, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { screenToWorld, facingOf } from '../src/common/world/scene-links-nav.ts' +import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' class MockWindow extends EventTarget { innerWidth = 1920 @@ -78,220 +77,6 @@ globalThis.HTMLInputElement = class HTMLInputElement {} // @ts-ignore globalThis.HTMLSelectElement = class HTMLSelectElement {} -function createChallengerScene(options?: { - playerPos?: { x: number; y: number } - mana?: number - leftSkillId?: number - rightSkillId?: number - npcs?: Array<{ id: number; name: string; x: number; y: number }> - waypoints?: Array<{ waypointId: number; x: number; y: number }> - monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }> - blockedOverlap?: (x: number, y: number) => number - canvasRect?: { left: number; top: number; width: number; height: number } - canvasSize?: { width: number; height: number } - cameraZoom?: number -}) { - const canvas = new MockCanvas() as unknown as HTMLCanvasElement & { rect: { left: number; top: number; width: number; height: number } } - if (options?.canvasRect) { - canvas.rect = options.canvasRect - canvas.clientWidth = options.canvasRect.width - canvas.clientHeight = options.canvasRect.height - } - if (options?.canvasSize) { - canvas.width = options.canvasSize.width - canvas.height = options.canvasSize.height - } - - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const dummyTerrain: WorldMapProvider = { - widthPx: 4000, - heightPx: 4000, - overlap: options?.blockedOverlap ?? (() => 0), - } - - const dummyOpts: GameEngineOptions = { - spawn: options?.playerPos ?? { x: 1000, y: 1000 }, - stats: (options?.monsters ?? []).map(m => ({ - id: m.id, - name: m.name, - hp: m.hp, - damage: 10, - cooldownTicks: 100, - reach: 20, - aggroRadius: 100, - speed: 0, - xp: 10, - })), - xpTable: [0, 0, 10, 30, 60], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }], - npcDefs: (options?.npcs ?? []).map(n => ({ - id: `npc-${n.id}`, - name: n.name, - questId: null, - offerLines: ['Greetings, wanderer.'], - progressLines: [], - doneLines: [], - })), - questDefs: [], - combatOptions: { playerSpeed: 100, playerReach: 52, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 }, - lootSeed: 12345, - talkRadius: 80, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - if (options?.monsters) { - for (let i = 0; i < options.monsters.length; i++) { - engine.world.monsters[i]!.x = options.monsters[i]!.x - engine.world.monsters[i]!.y = options.monsters[i]!.y - } - } - - if (options?.npcs) { - for (const n of options.npcs) { - engine.npcEntities.push({ - def: { - id: `npc-${n.id}`, - name: n.name, - questId: null, - offerLines: ['Greetings, wanderer.'], - progressLines: [], - doneLines: [], - }, - x: n.x, - y: n.y, - hasSprite: false, - }) - } - } - - const camera = new ViewportCamera(canvas, 4000, 4000) - camera.zoom = options?.cameraZoom ?? 1 - - const hudManager = new HudManager(canvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hudManager.mana = options?.mana ?? 200 - hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0 - hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0 - hudManager.syncPublishedState() - - const waypointNetwork = new WaypointNetwork() - if (options?.waypoints) { - for (const wp of options.waypoints) { - waypointNetwork.register({ - waypointId: wp.waypointId, - levelId: 1, - act: 1, - name: 'Test WP', - x: wp.x, - y: wp.y, - }) - } - } - - const gridWidth = 500 - const blockedGrid = new Uint8Array(500 * 500) - const runtime: MapRuntime = { - source: 'pack', - base: 'samples/d2-packs', - act: 1, - level: 'Rogue Encampment', - levelId: 1, - variant: 'default', - variants: ['default'], - quadrant: 'default', - quadrants: ['default'], - cellsX: 100, - cellsY: 100, - widthPx: 4000, - heightPx: 4000, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - pages: [], - priorityPages: [], - objectPages: [], - grid: { - originX: 0, - originY: 0, - cellsX: 100, - cellsY: 100, - blocked: blockedGrid, - gridWidth, - }, - npcs: (options?.npcs ?? []).map(n => ({ - id: n.id, - type: 1, - name: n.name, - x: n.x, - y: n.y, - })), - entrances: [], - warps: [], - waypoints: (options?.waypoints ?? []).map(wp => { - const dx = wp.x / 16 - const dy = wp.y / 8 - const subX = Math.round((dy + dx) / 2) - const subY = Math.round((dy - dx) / 2) - return { - source: 'placed' as const, - waypointId: wp.waypointId, - arriveX: subX, - arriveY: subY, - x: subX, - y: subY, - } - }), - animatedDrawables: [], - animSpeed: 1, - frameDurationMs: 40, - palette: null, - charBases: [], - monsterTypes: [], - monsterPacks: [], - entryPath: '', - loadPages: async () => {}, - } as unknown as MapRuntime - - const status = { textContent: '' } as HTMLElement - - const controller = new SceneMouseController({ - canvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager, - waypointNetwork, - status, - }) - controller.attach() - - return { - canvas, - win, - input, - engine, - camera, - hudManager, - waypointNetwork, - runtime, - status, - controller, - } -} - describe('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', () => { describe('1. screenToWorld projection with varying camera zoom levels and DPRs', () => { it('exact round-trip invertibility across zoom levels [0.4x..8.0x] and DPRs [1.0..4.0]', () => { @@ -379,526 +164,5 @@ describe('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', () } } }) - - it('consistency between screenToWorld and projectNpcLabel', () => { - const rect = { left: 0, top: 0, width: 1920, height: 1080 } - const canvas = { width: 3840, height: 2160 } // DPR 2.0 - const camX = 1000 - const camY = 1000 - const zoom = 1.75 - - const npc = { x: 1150, y: 920, frame: null } - const projection = projectNpcLabel({ - npc, - camX, - camY, - cameraZoom: zoom, - canvas, - rect, - markerHeight: 0, - }) - - // unproject the projected dom position - const worldBack = screenToWorld({ - clientX: projection.domX, - clientY: projection.domY + 2, // projectNpcLabel offsets domY by -2 - rect, - canvas, - camX, - camY, - cameraZoom: zoom, - }) - - expect(worldBack.x).toBeCloseTo(npc.x, 3) - expect(worldBack.y).toBeCloseTo(npc.y, 3) - }) - }) - - describe('2. Ground Click-to-Move: navTarget, player movement, arrival threshold <= 8px', () => { - it('clicking on ground sets navTarget and produces normalized directional movement vector', () => { - const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } }) - - // Click ground at world (1100, 1000) -> 100px East - // Camera is centered at (player.x, player.y - 16) = (1000, 984) - // client center is (960, 540). (1100, 1000) corresponds to clientX = 960 + 100 = 1060, clientY = 540 + 16 = 556 - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 556, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 1060, - clientY: 556, - button: 0, - })) - - expect(scene.controller.navTarget).not.toBeNull() - expect(scene.controller.navTarget!.x).toBeCloseTo(1100, 1) - expect(scene.controller.navTarget!.y).toBeCloseTo(1000, 1) - - const tickResult = scene.controller.tick() - expect(tickResult.movement.x).toBeGreaterThan(0.99) - expect(tickResult.movement.y).toBeCloseTo(0, 2) - expect(tickResult.attacking).toBe(false) - }) - - it('strict arrival threshold <= 8px boundary check', () => { - const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } }) - scene.controller.navTarget = { x: 1000, y: 1000 } - - // Test distance exactly 8.000px: MUST stop and clear navTarget - scene.controller.navTarget = { x: 1008, y: 1000 } - let tick = scene.controller.tick() - expect(tick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - - // Test distance 7.5px (< 8px): MUST stop - scene.controller.navTarget = { x: 1000, y: 1007.5 } - tick = scene.controller.tick() - expect(tick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - - // Test distance 8.05px (> 8px): MUST continue moving - scene.controller.navTarget = { x: 1008.05, y: 1000 } - tick = scene.controller.tick() - expect(tick.movement.x).toBeGreaterThan(0) - expect(scene.controller.navTarget).not.toBeNull() - }) - - it('continuous drag updates navTarget with cursor coordinates on pointermove', () => { - const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 556, - button: 0, - })) - expect(scene.controller.isLeftMouseDown).toBe(true) - - // Move cursor across multiple positions - const positions = [ - { clientX: 1100, clientY: 556 }, - { clientX: 1200, clientY: 600 }, - { clientX: 900, clientY: 400 }, - ] - - for (const pos of positions) { - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: pos.clientX, - clientY: pos.clientY, - button: 0, - })) - const expectedWorld = scene.controller.getPointerWorldCoords(pos.clientX, pos.clientY) - expect(scene.controller.navTarget!.x).toBeCloseTo(expectedWorld.x, 3) - expect(scene.controller.navTarget!.y).toBeCloseTo(expectedWorld.y, 3) - } - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 900, - clientY: 400, - button: 0, - })) - expect(scene.controller.isLeftMouseDown).toBe(false) - }) - - it('running mode applies 1.5x runFactor to movement vector', () => { - const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } }) - scene.controller.navTarget = { x: 1100, y: 1000 } // 100px East - - // Walk mode - scene.hudManager.isRunning = false - const walkTick = scene.controller.tick() - expect(walkTick.movement.x).toBeCloseTo(1.0, 3) - - // Run mode - scene.hudManager.isRunning = true - const runTick = scene.controller.tick() - expect(runTick.movement.x).toBeCloseTo(1.5, 3) - }) - }) - - describe('3. NPC interaction: immediate dialogue (<= 80px) vs pathing and arrival (> 80px)', () => { - it('immediate dialogue when clicking NPC at distance <= 80px without moving', () => { - // NPC placed at (1050, 1000) -> distance 50px <= 80px - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - npcs: [{ id: 1, name: 'Akara', x: 1050, y: 1000 }], - }) - - // Click near Akara's feet (1050, 1000) - const clientCoords = { - clientX: 960 + 50, - clientY: 540 + 16, - } - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: clientCoords.clientX, - clientY: clientCoords.clientY, - button: 0, - })) - - // Must trigger dialogue immediately - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.status.textContent).toContain('Akara') - expect(scene.engine.dialog).not.toBeNull() - expect(scene.engine.world.player.facing).toBe(6) // Facing East - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - }) - - it('paths towards NPC when distance > 80px and opens dialogue on arrival', () => { - // NPC placed at (1300, 1000) -> distance 300px > 80px - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - npcs: [{ id: 2, name: 'Warriv', x: 1300, y: 1000 }], - }) - - const clientCoords = { - clientX: 960 + 300, - clientY: 540 + 16, - } - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: clientCoords.clientX, - clientY: clientCoords.clientY, - button: 0, - })) - - // Must set pendingInteraction and navTarget - expect(scene.controller.pendingInteraction).toEqual({ - kind: 'npc', - npc: expect.objectContaining({ x: 1300, y: 1000 }), - }) - expect(scene.controller.navTarget).toEqual({ x: 1300, y: 1000 }) - - // Tick moves towards Warriv - const tick1 = scene.controller.tick() - expect(tick1.movement.x).toBeGreaterThan(0) - expect(scene.engine.dialog).toBeNull() - - // Step player closer to distance 80px: x = 1220 (dist = 80px) - scene.engine.world.player.x = 1220 - const arrivalTick = scene.controller.tick() - - expect(arrivalTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.controller.navTarget).toBeNull() - expect(scene.status.textContent).toContain('Warriv') - expect(scene.engine.dialog).not.toBeNull() - expect(scene.engine.world.player.facing).toBe(6) // Facing East - }) - - it('selects closest NPC when multiple NPCs are nearby', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - npcs: [ - { id: 1, name: 'Akara', x: 1030, y: 1000 }, // 30px away - { id: 2, name: 'Charsi', x: 1060, y: 1000 }, // 60px away - ], - }) - - // Click on Akara (1030, 1000) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 30, - clientY: 540 + 16, - button: 0, - })) - - expect(scene.status.textContent).toContain('Akara') - expect(scene.status.textContent).not.toContain('Charsi') - }) - }) - - describe('4. Waypoint interaction: immediate activation (<= 80px) vs pathing and arrival (> 80px)', () => { - it('immediate activation and panel opening when clicking Waypoint at distance <= 80px', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - waypoints: [{ waypointId: 1, x: 1040, y: 1000 }], // 40px away - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 40, - clientY: 540 + 16, - button: 0, - })) - - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true) - expect(scene.hudManager.leftPanel).toBe('waypoint') - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - }) - - it('paths towards Waypoint when distance > 80px and activates upon arrival', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - waypoints: [{ waypointId: 2, x: 1400, y: 1000 }], // 400px away - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 400, - clientY: 540 + 16, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('waypoint') - expect(scene.controller.navTarget).not.toBeNull() - expect(scene.hudManager.leftPanel).toBe('none') - - // Tick moves towards waypoint - const walkTick = scene.controller.tick() - expect(walkTick.movement.x).toBeGreaterThan(0) - - // Move player within 80px: x = 1330 (dist = 70px) - scene.engine.world.player.x = 1330 - const arriveTick = scene.controller.tick() - - expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.controller.navTarget).toBeNull() - expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 2)).toBe(true) - expect(scene.hudManager.leftPanel).toBe('waypoint') - }) - }) - - describe('5. Monster targeting: melee reach 52px vs spell range, approaches and attacks', () => { - it('melee attack (skill 0): attacks immediately at distance <= 52px without pathing', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 0, - monsters: [{ id: 'mon1', name: 'Fallen', x: 1040, y: 1000, hp: 50 }], // 40px <= 52px - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 40, - clientY: 540 + 16, - button: 0, - })) - - // Attacks immediately - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50) - expect(scene.engine.metrics.playerHits).toBe(1) - expect(scene.engine.world.player.cooldown).toBe(12) - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - }) - - it('melee attack (skill 0): paths to monster when distance > 52px and attacks on arrival', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 0, - monsters: [{ id: 'mon2', name: 'Zombie', x: 1200, y: 1000, hp: 80 }], // 200px > 52px - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 200, - clientY: 540 + 16, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('monster') - expect(scene.controller.navTarget).toEqual({ x: 1200, y: 1000 }) - expect(scene.engine.world.monsters[0]!.hp).toBe(80) - - // Move player into reach: x = 1160 (dist = 40px <= 52px) - scene.engine.world.player.x = 1160 - scene.engine.world.player.cooldown = 0 - const attackTick = scene.controller.tick() - - expect(attackTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.controller.navTarget).toBeNull() - expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(80) - expect(scene.engine.metrics.playerHits).toBe(1) - }) - - it('spell attack (skill 47 Fire Ball): casts from range (<= 450px) without pathing', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 47, // Fire Ball, range 450 - mana: 100, - monsters: [{ id: 'mon3', name: 'Gargoyle', x: 1350, y: 1000, hp: 120 }], // 350px <= 450px - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 350, - clientY: 540 + 16, - button: 0, - })) - - // Casts immediately from 350px away: spawns projectile, deducts mana, no pathing - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.engine.projectiles.length).toBe(1) - expect(scene.engine.projectiles[0]!.skillId).toBe('47') - expect(scene.hudManager.mana).toBeLessThan(100) - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - }) - - it('spell attack: paths when monster is beyond spell range (> 450px)', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 47, - mana: 100, - monsters: [{ id: 'mon4', name: 'Skeleton', x: 1600, y: 1000, hp: 100 }], // 600px > 450px - }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 600, - clientY: 540 + 16, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('monster') - expect(scene.controller.navTarget).toEqual({ x: 1600, y: 1000 }) - expect(scene.engine.projectiles.length).toBe(0) - - // Move player into spell range: x = 1200 (dist = 400px <= 450px) - scene.engine.world.player.x = 1200 - scene.engine.world.player.cooldown = 0 - const castTick = scene.controller.tick() - - expect(castTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.controller.navTarget).toBeNull() - expect(scene.engine.projectiles.length).toBe(1) - }) - - it('dead monsters cannot be targeted or clicked', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - monsters: [{ id: 'monDead', name: 'Dead Zombie', x: 1040, y: 1000, hp: 0 }], - }) - scene.engine.world.monsters[0]!.state = 'dead' - - // Click directly on dead monster - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 40, - clientY: 540 + 16, - button: 0, - })) - - // Should be treated as ground click, NOT monster interaction - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.controller.navTarget).not.toBeNull() - }) - }) - - describe('6. Shift + Left Click: forced {0,0} velocity, face cursor, execute attack without pathing', () => { - it('Shift + Left Click forces velocity {0, 0}, faces cursor, and attacks without pathing', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 47, // Fire Ball - mana: 100, - }) - - // Hold Shift - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Click ground far away (1500, 1000) -> East - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 500, - clientY: 540 + 16, - button: 0, - shiftKey: true, - })) - - // Navigation target must remain null (stand still) - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - - // Player faces cursor (East = 6) - expect(scene.engine.world.player.facing).toBe(6) - - // Spell cast executed - expect(scene.engine.projectiles.length).toBe(1) - expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0) - expect(scene.hudManager.mana).toBeLessThan(100) - - // Tick movement must be strictly {0, 0} - const tick = scene.controller.tick() - expect(tick.movement).toEqual({ x: 0, y: 0 }) - }) - - it('Shift + Left Click with Melee Attack (skill 0) swings in place without moving', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 0, // Melee attack - }) - - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Click North-West - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 - 200, - clientY: 540 - 200, - button: 0, - shiftKey: true, - })) - - expect(scene.controller.navTarget).toBeNull() - expect(scene.engine.world.player.facing).toBe(3) // North-West - expect(scene.engine.world.player.cooldown).toBe(12) - expect(scene.engine.world.player.swingTicks).toBe(6) - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - }) - - it('continuous hold: player stays rooted in place across multiple frames while casting', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 47, - mana: 300, - }) - - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 200, - clientY: 540 + 16, - button: 0, - shiftKey: true, - })) - - // Run 30 consecutive ticks - for (let t = 0; t < 30; t++) { - scene.engine.world.player.cooldown = 0 // simulate cooldown completing - const tick = scene.controller.tick() - expect(tick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - } - - // Multiple projectiles fired while player never moved - expect(scene.engine.projectiles.length).toBeGreaterThan(1) - expect(scene.engine.world.player.x).toBe(1000) - expect(scene.engine.world.player.y).toBe(1000) - }) - - it('pressing Shift while navTarget is active must immediately arrest movement to {0,0}', () => { - const scene = createChallengerScene({ - playerPos: { x: 1000, y: 1000 }, - leftSkillId: 47, - }) - - // Ground click to start walking East - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 200, - clientY: 540 + 16, - button: 0, - })) - expect(scene.controller.navTarget).not.toBeNull() - - // First tick moves player East - const tick1 = scene.controller.tick() - expect(tick1.movement.x).toBeGreaterThan(0) - - // Player presses Shift while left mouse is still down - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Next tick with Shift held MUST arrest movement to {0, 0} - const tickWithShift = scene.controller.tick() - expect(tickWithShift.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - }) }) }) diff --git a/tests/challenger-m4-adversarial.test.ts b/tests/challenger-m4-adversarial.test.ts index badc964..b3f8e31 100644 --- a/tests/challenger-m4-adversarial.test.ts +++ b/tests/challenger-m4-adversarial.test.ts @@ -8,16 +8,7 @@ */ import { describe, it, expect, vi } from 'vitest' -import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts' import { ActorAnimator } from '../src/client/render/actor-animator.ts' -import { - GROUND_LABEL_QUALITY_COLORS, - getGroundItemQualityColor, - isGroundItemEthereal, - isGroundItemSocketed, - isGroundItemEtherealOrSocketed, - computeInitialGroundLabelLayouts, -} from '../src/client/ui/ground-labels.ts' import { BeltHud, BELT_COLS, @@ -75,133 +66,6 @@ describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () => // 1. Issue #423: Flippy DC6 Coverage, Fail-Fast Throw & Frozen Death Anim // ============================================================================ describe('Issue #423 — Exhaustive Flippy DC6 Coverage & Fail-Fast Rendering', () => { - it('exhaustively resolves and renders every weapon, armor, and misc base item plus all gold tiers without procedural color boxes', () => { - const tables = getEmbeddedDropTables() - const allBases = [ - ...tables.weapons.all, - ...tables.armor.all, - ...tables.misc.all, - ] - expect(allBases.length).toBe(659) - - const mgr = new GroundItemManager() - - for (const base of allBases) { - const sr = resolveGroundItemSpriteRect( - { code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile }, - BAKED_UI_MANIFEST.flippyRects, - BAKED_UI_MANIFEST.codeToFlippyFile, - ) - expect(sr, `Missing flippy rect for ${base.code} (${base.name})`).not.toBeNull() - expect(sr!.w).toBeGreaterThan(0) - expect(sr!.h).toBeGreaterThan(0) - - const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const sprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - quads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - sprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const entity = mgr.add( - { code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile }, - 0, - 0, - 300, - 300, - ) - drawGroundItem(renderer, entity, 1000, undefined) - expect(sprites.length).toBe(1) - expect(sprites[0]!.frame.width).toBeGreaterThan(0) - expect(sprites[0]!.frame.height).toBeGreaterThan(0) - // Outside sparkle window (t=1000, cellX=0, cellY=0), only 1 ground shadow quad is drawn - expect(quads.length).toBe(1) - expect(quads[0]!.color[0]).toBe(0) - expect(quads[0]!.color[1]).toBe(0) - expect(quads[0]!.color[2]).toBe(0) - } - - // Gold boundary tiers: 1, 49 (small: 24x7), 50, 499 (medium: 26x13), 500, 10000 (large: 32x20) - const goldCases = [ - { amount: 1, w: 24, h: 7 }, - { amount: 49, w: 24, h: 7 }, - { amount: 50, w: 26, h: 13 }, - { amount: 499, w: 26, h: 13 }, - { amount: 500, w: 32, h: 20 }, - { amount: 10000, w: 32, h: 20 }, - ] - for (const gc of goldCases) { - const sr = resolveGroundItemSpriteRect( - { isGold: true, amount: gc.amount, code: 'gld' }, - BAKED_UI_MANIFEST.flippyRects, - BAKED_UI_MANIFEST.codeToFlippyFile, - ) - expect(sr).not.toBeNull() - expect(sr!.w).toBe(gc.w) - expect(sr!.h).toBe(gc.h) - - const sprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid() {}, - draw(frame: any, x: number, y: number, options: any) { - sprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - const goldEntity = mgr.add({ isGold: true, amount: gc.amount, code: 'gld' }, 0, 0, 200, 200, { amount: gc.amount }) - drawGroundItem(renderer, goldEntity, 1000, undefined) - expect(sprites.length).toBe(1) - expect(sprites[0]!.options.width).toBe(gc.w) - expect(sprites[0]!.options.height).toBe(gc.h) - } - }) - - it('fails fast with an explicit Error in drawGroundItem when flippy sprite rect resolves to null', () => { - // Temporarily empty flippyRects and itemRects to simulate a corrupt unmapped item with null sprite rect - const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects } - const savedItems = { ...BAKED_UI_MANIFEST.itemRects } - try { - for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) { - delete (BAKED_UI_MANIFEST.flippyRects as any)[k] - } - for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) { - delete (BAKED_UI_MANIFEST.itemRects as any)[k] - } - - const renderer = { - drawSolid() {}, - draw() {}, - } as unknown as SpriteRenderer - - const corruptEntity: GroundItemEntity = { - id: 'corrupt_999', - item: { code: 'zzz', name: 'Corrupt Unknown Item' }, - name: 'Corrupt Unknown Item', - nameZh: '损坏未知物品', - quality: 'normal', - isGold: false, - amount: 1, - invWidth: 9, - invHeight: 9, - dropTime: 0, - x: 100, - y: 100, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - expect(() => drawGroundItem(renderer, corruptEntity, 1000, undefined)).toThrow( - /GroundItem render failure: missing flippy sprite rect/, - ) - } finally { - Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy) - Object.assign(BAKED_UI_MANIFEST.itemRects, savedItems) - } - }) it('advances death animation (DT -> DD) even when monster has frozenTicks > 0', () => { const animator = new ActorAnimator() @@ -239,132 +103,6 @@ describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () => }) }) - // ============================================================================ - // 2. Issue #424: Ground Label Color Permutations & UI Round-Trip - // ============================================================================ - describe('Issue #424 — Ground Label Color Full Cartesian Permutations & UI Round-Trip', () => { - it('fuzzes all combinations of quality, ethereal, sockets, isRuneword, and isGold across getGroundItemQualityColor and computeInitialGroundLabelLayouts', () => { - const qualities = ['low', 'normal', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'] as const - const ethereals = [false, true] as const - const socketCounts = [0, 1, 6] as const - const runewords = [false, true] as const - const goldFlags = [false, true] as const - - const mgr = new GroundItemManager() - let totalPermutations = 0 - - for (const quality of qualities) { - for (const ethereal of ethereals) { - for (const sockets of socketCounts) { - for (const isRuneword of runewords) { - for (const isGold of goldFlags) { - totalPermutations += 1 - const entity: GroundItemEntity = { - id: `perm_${totalPermutations}`, - item: { - code: isGold ? 'gld' : 'bsd', - name: isGold ? '100 Gold' : 'Broad Sword', - quality, - ethereal, - sockets, - isRuneword, - isGold, - }, - name: isGold ? '100 Gold' : 'Broad Sword', - nameZh: isGold ? '100 金币' : '阔剑', - quality, - isGold, - amount: isGold ? 100 : 1, - invWidth: 2, - invHeight: 3, - dropTime: 0, - x: 200, - y: 200, - cellX: 5, - cellY: 5, - sparklePhase: 0, - ...(ethereal ? { ethereal: true } : {}), - ...(sockets > 0 ? { sockets } : {}), - } - - // Independent 1.13c ground truth oracle - let expectedColor: string - if (isGold) { - expectedColor = GROUND_LABEL_QUALITY_COLORS.gold! - } else if (quality === 'low') { - expectedColor = GROUND_LABEL_QUALITY_COLORS.low! - } else if (isRuneword) { - expectedColor = GROUND_LABEL_QUALITY_COLORS.unique! - } else if ((quality === 'normal' || quality === 'superior') && (ethereal || sockets > 0)) { - expectedColor = '#808080' - } else { - expectedColor = GROUND_LABEL_QUALITY_COLORS[quality]! - } - - const actualColor = getGroundItemQualityColor(entity) - expect( - actualColor, - `Mismatch for quality=${quality}, eth=${ethereal}, sock=${sockets}, rw=${isRuneword}, gold=${isGold}`, - ).toBe(expectedColor) - - const layouts = computeInitialGroundLabelLayouts([entity], (wx, wy) => ({ x: wx, y: wy })) - expect(layouts.length).toBe(1) - expect(layouts[0]!.color).toBe(expectedColor) - expect(layouts[0]!.borderColor).toBe(expectedColor) - } - } - } - } - } - - // 9 * 2 * 3 * 2 * 2 = 216 exhaustive permutations verified - expect(totalPermutations).toBe(216) - expect(mgr.count).toBe(0) - }) - - it('preserves ethereal, sockets, and #808080 ground label color across ground -> UI inventory -> ground re-drop round-trip', () => { - const tables = getEmbeddedDropTables() - const mgr = new GroundItemManager() - - // 1. Spawn ethereal socketed normal item on ground - const droppedEthSocketed = mgr.add( - { - id: 'roundtrip_monarch', - code: 'uit', - name: 'Monarch', - quality: 'normal', - ethereal: true, - sockets: 4, - invWidth: 2, - invHeight: 3, - }, - 10, - 10, - 320, - 320, - ) - expect(isGroundItemEthereal(droppedEthSocketed)).toBe(true) - expect(isGroundItemSocketed(droppedEthSocketed)).toBe(true) - expect(getGroundItemQualityColor(droppedEthSocketed)).toBe('#808080') - - // 2. Pick up into UI inventory via itemToUiInventoryItem - const uiItem = itemToUiInventoryItem(droppedEthSocketed.item, tables) - expect(uiItem.ethereal).toBe(true) - expect(uiItem.sockets).toBe(4) - - // 3. Re-drop UI inventory item back onto ground - const redropped = mgr.add(uiItem, 11, 11, 340, 340) - expect(redropped.ethereal).toBe(true) - expect(redropped.sockets).toBe(4) - expect(isGroundItemEthereal(redropped)).toBe(true) - expect(isGroundItemSocketed(redropped)).toBe(true) - expect(getGroundItemQualityColor(redropped)).toBe('#808080') - - const layouts = computeInitialGroundLabelLayouts([redropped], (wx, wy) => ({ x: wx, y: wy })) - expect(layouts[0]!.color).toBe('#808080') - }) - }) - // ============================================================================ // 3. Issue #425: Pickup Bounds Fuzzing & 2-Pass Auto-Belt Stress // ============================================================================ diff --git a/tests/challenger-m4-inventory-audit.test.ts b/tests/challenger-m4-inventory-audit.test.ts index 70dc2f6..0053762 100644 --- a/tests/challenger-m4-inventory-audit.test.ts +++ b/tests/challenger-m4-inventory-audit.test.ts @@ -10,15 +10,12 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' import { existsSync } from 'node:fs' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { - resolveItemSpriteRect, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, -} from '../src/client/ui/inventory.ts' +import { resolveItemSpriteRect } from '../src/client/ui/inventory.ts' import { MountedArchives } from '../src/common/mpq/mount.ts' import { MpqArchive } from '../src/common/mpq/archive.ts' import { fileSource } from '../src/baker/io/file-source.ts' import { parseTable } from '../src/common/items/tables.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts' describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => { const itemRects = BAKED_UI_MANIFEST.itemRects @@ -253,7 +250,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => { }) it('confirms starter inventory and equipped gear resolution integrity', () => { - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) { if (!item) continue const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Equipped item ${slot} (${item.name}) must resolve`).not.toBeNull() @@ -263,7 +260,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => { expect(rect!.y + rect!.h).toBeLessThanOrEqual(atlasHeight) } - for (const { item, col, row } of STARTER_BAG_ITEMS) { + for (const { item, col, row } of DEMO_BAG_ITEMS) { const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull() expect(rect!.w).toBeGreaterThan(0) diff --git a/tests/challenger-m4-lockstep-stress.test.ts b/tests/challenger-m4-lockstep-stress.test.ts index b3839ae..66fec1b 100644 --- a/tests/challenger-m4-lockstep-stress.test.ts +++ b/tests/challenger-m4-lockstep-stress.test.ts @@ -33,13 +33,6 @@ import { type CombatOptions, } from '../src/server/engine/combat.ts' import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts' -import { - GROUND_LABEL_QUALITY_COLORS, - getGroundItemQualityColor, - isGroundItemEtherealOrSocketed, - computeInitialGroundLabelLayouts, -} from '../src/client/ui/ground-labels.ts' import { GroundItemManager, getPickupEdgeDistance, @@ -680,208 +673,6 @@ describe('Challenger M4-2 — Multiplayer Lockstep & Cross-Feature Stress Suite' }) describe('3. Cross-Feature Integration Stress (#423 + #424 + #425 + #427)', () => { - it('verifies all multiplayer lockstep drops have valid flippy DC6 rects (#423) and accurate #808080 gray label overrides (#424)', () => { - const dropTables = getEmbeddedDropTables() - const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 } - const mgr = new GroundItemManager() - - let ethOrSocketedNormalCount = 0 - let plainNormalOrSuperiorCount = 0 - let coloredMagicRareSetUniqueCount = 0 - - for (let i = 0; i < 30; i += 1) { - const seed = (0xabcdef00 + i * 0x13579bdf) >>> 0 - const world = createWorld(200, 200) - // Queue multiple high-level Hell kills to generate diverse normal, socketed, ethereal, magic, rare, set, unique items - world.pendingKills = [ - { - kind: 'kill', - subjectId: 'diablo', - x: 200, - y: 200, - monsterIndex: 0, - monsterRank: 'boss', - monsterLevel: 85, - }, - { - kind: 'kill', - subjectId: 'zombie1', - x: 220, - y: 200, - monsterIndex: 1, - monsterRank: 'normal', - monsterLevel: 85, - }, - { - kind: 'kill', - subjectId: 'skeleton1', - x: 240, - y: 200, - monsterIndex: 2, - monsterRank: 'champion', - monsterLevel: 85, - }, - ] - - const sim = createNetSimulation(world, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 8, seed, dropTables, 'hell') - sim.simulation.advance( - Array.from({ length: 8 }, () => ({ - tick: 0, - movement: { x: 0, y: 0 }, - attack: false, - pickup: false, - talk: false, - skill: 0, - })), - ) - - for (const g of sim.ground) { - const cell = worldToCell(terrain, g.x, g.y) - const entity = mgr.add(g.item, g.x, g.y, cell.cellX, cell.cellY, { now: 0 }) - - // #423: Every non-gold item dropped in lockstep MUST resolve a valid flippy DC6 rect - if (!entity.isGold) { - const rect = resolveGroundItemSpriteRect( - { - code: g.item.code, - name: g.item.name, - flippyFile: (g.item.base as any)?.flippyfile, - invFile: (g.item.base as any)?.invfile, - }, - BAKED_UI_MANIFEST.flippyRects, - BAKED_UI_MANIFEST.codeToFlippyFile, - ) - expect(rect, `Missing flippy rect for lockstep drop ${g.item.code} (${g.item.name})`).not.toBeNull() - expect(rect!.w).toBeGreaterThan(0) - expect(rect!.h).toBeGreaterThan(0) - } - - // Render via drawGroundItem outside sparkle window -> 1 sprite draw + 1 shadow quad, never color boxes - const quads: { color: readonly [number, number, number, number] }[] = [] - const sprites: { frame: any }[] = [] - const mockRenderer = { - drawSolid(_x: number, _y: number, _w: number, _h: number, color: readonly [number, number, number, number]) { - quads.push({ color }) - }, - draw(frame: any) { - sprites.push({ frame }) - }, - } as unknown as SpriteRenderer - - const phaseOffset = Math.abs(Math.floor(entity.cellX * 31 + entity.cellY * 17)) % 2000 - const nonSparkleNow = 3000 - phaseOffset - drawGroundItem(mockRenderer, entity, nonSparkleNow, undefined) - expect(sprites.length).toBe(1) - expect(quads.length).toBe(1) // Shadow only - - // #424: Verify ground label color rules - const labelColor = getGroundItemQualityColor(entity) - const isNormalTier = - entity.quality === 'low' || entity.quality === 'normal' || entity.quality === 'superior' - const isEthOrSock = isGroundItemEtherealOrSocketed(entity) - - if (entity.isGold) { - expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS.gold) - } else if (isNormalTier && isEthOrSock) { - ethOrSocketedNormalCount += 1 - expect(labelColor).toBe('#808080') - } else if (entity.quality === 'normal' || entity.quality === 'superior') { - plainNormalOrSuperiorCount += 1 - expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS[entity.quality]) - } else if ( - entity.quality === 'magic' || - entity.quality === 'rare' || - entity.quality === 'set' || - entity.quality === 'unique' - ) { - coloredMagicRareSetUniqueCount += 1 - expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS[entity.quality]) - } - - // Verify UI bridge preserves ethereal and socket metadata consistently with ground-labels - if (!entity.isGold) { - const uiItem = itemToUiInventoryItem(g.item, dropTables) - expect(Boolean(uiItem.ethereal)).toBe(Boolean((g.item as any).ethereal)) - expect(isGroundItemEtherealOrSocketed(entity)).toBe( - Boolean(uiItem.ethereal || (entity.sockets ?? 0) > 0), - ) - } - } - } - - // Also explicitly inject an ethereal normal item and a socketed superior item if random rolls didn't hit enough - const explicitEthNormal = mgr.add( - { code: 'ci3', name: 'Diadem', quality: 'normal', ethereal: true }, - 200, - 200, - 6, - 6, - { now: 0 }, - ) - const explicitSockSup = mgr.add( - { code: 'uar', name: 'Sacred Armor', quality: 'superior', sockets: 4 }, - 210, - 200, - 7, - 6, - { now: 0 }, - ) - expect(getGroundItemQualityColor(explicitEthNormal)).toBe('#808080') - expect(getGroundItemQualityColor(explicitSockSup)).toBe('#808080') - const layouts = computeInitialGroundLabelLayouts( - [explicitEthNormal, explicitSockSup], - (wx, wy) => ({ x: wx, y: wy }), - ) - expect(layouts[0]!.color).toBe('#808080') - expect(layouts[1]!.color).toBe('#808080') - - expect(ethOrSocketedNormalCount + 2).toBeGreaterThanOrEqual(2) - expect(plainNormalOrSuperiorCount).toBeGreaterThan(0) - expect(coloredMagicRareSetUniqueCount).toBeGreaterThan(0) - }) - - it('fails fast when drawGroundItem encounters an unmapped item with no resolvable sprite rect (#423 anti-silent failure)', () => { - const mockRenderer = { - drawSolid() {}, - draw() {}, - } as unknown as SpriteRenderer - - const savedRects = { ...BAKED_UI_MANIFEST.itemRects } - const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects } - try { - for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) { - delete (BAKED_UI_MANIFEST.itemRects as any)[k] - } - for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) { - delete (BAKED_UI_MANIFEST.flippyRects as any)[k] - } - - const invalidEntity = { - id: 'bad_item', - item: { code: 'zzz_nonexistent_code', name: 'Nonexistent Item Totally Unmapped' }, - name: 'Nonexistent Item Totally Unmapped', - nameZh: '未知', - quality: 'normal' as const, - isGold: false, - amount: 1, - invWidth: 99, - invHeight: 99, - dropTime: 0, - x: 100, - y: 100, - cellX: 3, - cellY: 3, - sparklePhase: 0, - } - - expect(() => drawGroundItem(mockRenderer, invalidEntity, 1000, undefined)).toThrow( - /missing flippy sprite rect/i, - ) - } finally { - Object.assign(BAKED_UI_MANIFEST.itemRects, savedRects) - Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy) - } - }) it('enforces 2-subtile (64px edge / 96px center) pickup bounds in multiplayer lockstep and routes potions to BeltHud (#425 + #427)', () => { const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 } diff --git a/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts b/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts index 99869bc..e3fe1cf 100644 --- a/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts +++ b/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts @@ -27,7 +27,7 @@ import { BATCH1_SKILLS, CANONICAL_113C_MISSILES, } from '../src/server/skills/skills.ts' -import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => { // ========================================================================= diff --git a/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts b/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts index afa1c4b..bfc0331 100644 --- a/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts +++ b/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts @@ -49,7 +49,7 @@ import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts' import { UnitStatList, FIXED_ONE } from '../src/server/engine/stat-list.ts' import { StateBus } from '../src/server/engine/state-bus.ts' import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/server/engine/engine.ts' -import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts' diff --git a/tests/challenger-m5-stash-entities-gold.test.ts b/tests/challenger-m5-stash-entities-gold.test.ts index 409b4e8..f8b7d19 100644 --- a/tests/challenger-m5-stash-entities-gold.test.ts +++ b/tests/challenger-m5-stash-entities-gold.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { CANONICAL_TOWN_STASHES, type TownStashEntity, SceneMouseController, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/world/scene-population.ts' import { WorldPanelsHud, STASH_GOLD_CAP, @@ -8,8 +8,6 @@ import { import { HudManager } from '../src/client/ui/hud-manager.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { createIsoTerrain } from '../src/common/world/iso-terrain.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' -import { KeyboardInput } from '../src/client/sim/input.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' class MockWindow extends EventTarget { @@ -122,274 +120,6 @@ describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction }) }) - describe('2. Interaction Geometry Stress Testing', () => { - let engine: GameEngine - let camera: ViewportCamera - let input: KeyboardInput - let waypointNetwork: WaypointNetwork - let statusElem: HTMLElement - let controller: SceneMouseController - let runtime: MapRuntime - let stashOpenCount: number - let mockHudManager: any - - beforeEach(() => { - stashOpenCount = 0 - const grid = { - originX: 0, - originY: 0, - cellsX: 200, - cellsY: 200, - blocked: new Uint8Array(1000 * 1000), - gridWidth: 1000, - } - const terrain = createIsoTerrain(grid, 10000, 5000, { width: 20, height: 10 }) - engine = new GameEngine(terrain, { - spawn: { x: 4500, y: 1700 }, - stats: [], - monsterCount: 0, - questDefs: [], - npcDefs: [], - } as any) - - const mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - clientWidth: 800, - clientHeight: 600, - width: 800, - height: 600, - addEventListener: () => {}, - removeEventListener: () => {}, - getContext: () => null, - style: { cursor: '' }, - } as unknown as HTMLCanvasElement - - camera = new ViewportCamera(mockCanvas, 10000, 5000) - input = new KeyboardInput() - waypointNetwork = new WaypointNetwork() - statusElem = { textContent: '' } as unknown as HTMLElement - - mockHudManager = { - leftPanel: 'none', - toggleLeftPanel: (panel: any) => { - if (panel === 'stash') stashOpenCount++ - mockHudManager.leftPanel = panel - }, - clientToLogical: (x: number, y: number) => ({ x, y }), - isPointInterceptedByHud: () => false, - } as unknown as HudManager - - runtime = { - source: 'pack', - base: 'samples/d2-packs', - level: 'Rogue Encampment', - act: 1, - levelId: 1, - entryPath: 'act1-towne1', - entrances: [], - warps: [], - waypoints: [], - stashes: [ - CANONICAL_TOWN_STASHES[1]!, - CANONICAL_TOWN_STASHES[2]!, - CANONICAL_TOWN_STASHES[3]!, - CANONICAL_TOWN_STASHES[4]!, - CANONICAL_TOWN_STASHES[5]!, - ], - variants: ['towne1'], - variantParam: 'level', - variant: 'towne1', - quadrants: ['towne1'], - quadrantParam: 'level', - quadrant: 'towne1', - cellsX: 200, - cellsY: 200, - widthPx: 10000, - heightPx: 5000, - grid, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - spawn: { x: 4500, y: 1700 }, - npcs: [], - monsterPacks: [], - monsterTypes: [], - objects: 5, - frames: 0, - notes: [], - pages: [], - objectPages: [], - charBases: [], - palette: null, - priorityPages: [], - loadOrder: [], - loadPages: async () => {}, - } - - controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager: mockHudManager, - waypointNetwork, - status: statusElem, - }) - }) - - describe('Click radius (<= 48px hit, > 48px miss)', () => { - const stash1 = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) - - it('asserts exact center click hits the stash', () => { - expect(controller.findNearestStash(stash1.x, stash1.y, 48)).toEqual(stash1) - }) - - it('asserts clicks at distance <= 48px hit the stash in all cardinal directions', () => { - expect(controller.findNearestStash(stash1.x + 48, stash1.y, 48)).toEqual(stash1) - expect(controller.findNearestStash(stash1.x - 48, stash1.y, 48)).toEqual(stash1) - expect(controller.findNearestStash(stash1.x, stash1.y + 48, 48)).toEqual(stash1) - expect(controller.findNearestStash(stash1.x, stash1.y - 48, 48)).toEqual(stash1) - }) - - it('asserts 36-angle radial sweep at radius 47.5px (strictly <= 48px) all hit the stash', () => { - for (let i = 0; i < 36; i++) { - const angle = (i * 2 * Math.PI) / 36 - const x = stash1.x + 47.5 * Math.cos(angle) - const y = stash1.y + 47.5 * Math.sin(angle) - const hit = controller.findNearestStash(x, y, 48) - expect(hit, `Angle ${i * 10}deg at distance <= 48 should hit stash`).toEqual(stash1) - } - }) - - it('asserts clicks at distance > 48px do NOT hit the stash', () => { - // Just beyond boundary (49px, 50px, 60px) - expect(controller.findNearestStash(stash1.x + 49, stash1.y, 48)).toBeNull() - expect(controller.findNearestStash(stash1.x - 49, stash1.y, 48)).toBeNull() - expect(controller.findNearestStash(stash1.x, stash1.y + 49, 48)).toBeNull() - expect(controller.findNearestStash(stash1.x, stash1.y - 49, 48)).toBeNull() - - // Diagonal beyond boundary (35, 35) -> dist = 49.5px > 48px - expect(controller.findNearestStash(stash1.x + 35, stash1.y + 35, 48)).toBeNull() - expect(controller.findNearestStash(stash1.x - 35, stash1.y - 35, 48)).toBeNull() - - // Distant clicks - expect(controller.findNearestStash(stash1.x + 100, stash1.y, 48)).toBeNull() - expect(controller.findNearestStash(0, 0, 48)).toBeNull() - }) - - it('asserts 36-angle radial sweep at radius 50px all miss the stash', () => { - for (let i = 0; i < 36; i++) { - const angle = (i * 2 * Math.PI) / 36 - const x = stash1.x + 50 * Math.cos(angle) - const y = stash1.y + 50 * Math.sin(angle) - const hit = controller.findNearestStash(x, y, 48) - expect(hit, `Angle ${i * 10}deg at distance 50px must not hit stash`).toBeNull() - } - }) - - it('discriminates between closest stash when multiple stashes are present', () => { - const stash3 = CANONICAL_TOWN_STASHES[3]! // (5264, 1584) - const stash2 = CANONICAL_TOWN_STASHES[2]! // (5328, 1568) - // Click near stash3 - expect(controller.findNearestStash(stash3.x + 10, stash3.y, 48)).toEqual(stash3) - // Click near stash2 - expect(controller.findNearestStash(stash2.x + 10, stash2.y, 48)).toEqual(stash2) - }) - - it('tests custom maxRadius overrides (e.g. 24px, 64px)', () => { - // At 30px away: misses with maxRadius=24, hits with maxRadius=48 and maxRadius=64 - expect(controller.findNearestStash(stash1.x + 30, stash1.y, 24)).toBeNull() - expect(controller.findNearestStash(stash1.x + 30, stash1.y, 48)).toEqual(stash1) - expect(controller.findNearestStash(stash1.x + 60, stash1.y, 64)).toEqual(stash1) - }) - }) - - describe('Distance threshold (<= 80px immediate open, > 80px pathfind)', () => { - const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) - - it('player at distance <= 80px opens stash immediately without movement', () => { - const testDistances = [0, 20, 50, 75, 80] - for (const dist of testDistances) { - stashOpenCount = 0 - mockHudManager.leftPanel = 'none' - engine.world.player.x = stash.x - dist - engine.world.player.y = stash.y - - controller.handleLeftClickWorld(stash.x, stash.y) - - expect(controller.activeClickType).toBe('stash') - expect(controller.pendingInteraction).toBeNull() - expect(controller.navTarget).toBeNull() - expect(stashOpenCount).toBe(1) - expect(statusElem.textContent).toContain('储物箱') - } - }) - - it('player at distance > 80px schedules pathfinding/walk and does not open stash immediately', () => { - const testDistances = [81, 100, 250, 500] - for (const dist of testDistances) { - stashOpenCount = 0 - mockHudManager.leftPanel = 'none' - engine.world.player.x = stash.x - dist - engine.world.player.y = stash.y - - controller.handleLeftClickWorld(stash.x, stash.y) - - expect(controller.activeClickType).toBe('stash') - expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) - expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) - expect(stashOpenCount).toBe(0) - } - }) - - it('player walks toward stash and opens automatically upon reaching <= 80px in tick()', () => { - engine.world.player.x = stash.x - 300 - engine.world.player.y = stash.y - - controller.handleLeftClickWorld(stash.x, stash.y) - - expect(stashOpenCount).toBe(0) - expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) - expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) - - // Step 1: Walk to 200px away -> still too far - engine.world.player.x = stash.x - 200 - controller.tick() - expect(stashOpenCount).toBe(0) - expect(controller.pendingInteraction).not.toBeNull() - expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) - - // Step 2: Walk to 90px away -> still outside 80px - engine.world.player.x = stash.x - 90 - controller.tick() - expect(stashOpenCount).toBe(0) - expect(controller.pendingInteraction).not.toBeNull() - - // Step 3: Arrive at 75px away (<= 80px) -> triggers open! - engine.world.player.x = stash.x - 75 - controller.tick() - expect(stashOpenCount).toBe(1) - expect(controller.pendingInteraction).toBeNull() - expect(controller.navTarget).toBeNull() - expect(statusElem.textContent).toContain('储物箱') - }) - - it('cancels pending stash interaction if user left-clicks ground or elsewhere', () => { - engine.world.player.x = stash.x - 200 - engine.world.player.y = stash.y - controller.handleLeftClickWorld(stash.x, stash.y) - expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) - - // Click ground at an unrelated point - controller.handleLeftClickWorld(stash.x + 500, stash.y + 500) - expect(controller.activeClickType).toBe('ground') - expect(controller.pendingInteraction).toBeNull() - }) - }) - }) - describe('3. Gold Mechanics Stress Testing', () => { let panels: WorldPanelsHud let inv: { gold: number } diff --git a/tests/challenger-m6-attack-bindings.test.ts b/tests/challenger-m6-attack-bindings.test.ts index f180f6d..b5bae2e 100644 --- a/tests/challenger-m6-attack-bindings.test.ts +++ b/tests/challenger-m6-attack-bindings.test.ts @@ -8,15 +8,8 @@ import { describe, expect, it, beforeEach } from 'vitest' import { readFileSync, existsSync, statSync } from 'node:fs' import { join } from 'node:path' -import { - DEFAULT_SORCERESS_SKILLS, - SkillHotkeysHud, - resolveSkillIcon, - getAvailableSkillsForSide, - LEFT_SKILL_BOUNDS, - RIGHT_SKILL_BOUNDS, - type HotkeySkillEntry, -} from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, resolveSkillIcon, getAvailableSkillsForSide, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, type HotkeySkillEntry } from '../src/client/ui/hotkeys.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => { const rootDir = process.cwd() @@ -131,12 +124,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin beforeEach(() => { hud = new SkillHotkeysHud() - hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) }) it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => { - const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS) - const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS) + const leftSkills = getAvailableSkillsForSide('left', DEMO_SORCERESS_SKILLS) + const rightSkills = getAvailableSkillsForSide('right', DEMO_SORCERESS_SKILLS) const leftAttack = leftSkills.find((s) => s.skillId === 0) const rightAttack = rightSkills.find((s) => s.skillId === 0) @@ -266,7 +259,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin beforeEach(() => { hud = new SkillHotkeysHud() - hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) }) it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => { @@ -319,7 +312,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin }) it('asserts leftUsable: true and rightUsable: true remain immutable on Skill 0', () => { - const attackDef = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0) + const attackDef = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0) expect(attackDef).toBeDefined() expect(attackDef!.leftUsable).toBe(true) expect(attackDef!.rightUsable).toBe(true) @@ -336,8 +329,8 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin hud.handleKeyDown('F1') } - // Check DEFAULT_SORCERESS_SKILLS - const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0) + // Check DEMO_SORCERESS_SKILLS + const attackDefAfter = DEMO_SORCERESS_SKILLS.find((s) => s.skillId === 0) expect(attackDefAfter!.leftUsable).toBe(true) expect(attackDefAfter!.rightUsable).toBe(true) diff --git a/tests/challenger-m6-attack-combat.test.ts b/tests/challenger-m6-attack-combat.test.ts index 48aaeec..e658fb0 100644 --- a/tests/challenger-m6-attack-combat.test.ts +++ b/tests/challenger-m6-attack-combat.test.ts @@ -19,14 +19,13 @@ */ import { describe, expect, it, beforeEach } from 'vitest' -import { getAttackWeaponInfo, checkAmmunition, getSkillRange, SceneMouseController, screenToWorld, facingOf, type AttackWeaponInputItem, type AttackWeaponInfo, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem, type AttackWeaponInfo } from '../src/common/skills/weapon-combat-meta.ts' +import { screenToWorld, facingOf } from '../src/common/world/scene-links-nav.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' import type { Projectile } from '../src/server/skills/skills.ts' import { damageMonster, type Monster } from '../src/server/engine/combat.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' -import { KeyboardInput } from '../src/client/sim/input.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' // DOM mocks for headless testing environment @@ -101,182 +100,6 @@ if (typeof globalThis.HTMLSelectElement === 'undefined') { globalThis.HTMLSelectElement = class HTMLSelectElement {} } -function createCombatTestScene(options?: { - playerPos?: { x: number; y: number } - leftSkillId?: number - rightSkillId?: number - equippedWeapon?: AttackWeaponInputItem | null - equippedAmmo?: { code?: string; name?: string; nameZh?: string } | null - hasAmmo?: boolean - monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }> -}) { - const canvas = new MockCanvas() as unknown as HTMLCanvasElement - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const dummyTerrain: WorldMapProvider = { - widthPx: 2000, - heightPx: 2000, - overlap: () => 0, - } - - const dummyOpts: GameEngineOptions = { - spawn: options?.playerPos ?? { x: 500, y: 500 }, - stats: (options?.monsters ?? []).map((m) => ({ - id: m.id, - name: m.name, - hp: m.hp, - damage: 10, - cooldownTicks: 100, - reach: 20, - aggroRadius: 100, - speed: 0, - xp: 10, - })), - xpTable: [0, 0, 10, 30, 60], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [], - npcDefs: [], - questDefs: [], - combatOptions: { - playerSpeed: 100, - playerReach: 52, - playerCooldownTicks: 1, - playerDamage: 10, - playerManaPerAttack: 0, - respawnTicks: 100, - }, - lootSeed: 12345, - talkRadius: 80, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - if (options?.monsters) { - for (let i = 0; i < options.monsters.length; i++) { - engine.world.monsters[i]!.x = options.monsters[i]!.x - engine.world.monsters[i]!.y = options.monsters[i]!.y - } - } - - const camera = new ViewportCamera(canvas, 2000, 2000) - camera.zoom = 1 - - const hudManager = new HudManager(canvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hudManager.mana = 200 - hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0 - hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0 - - if (options?.equippedWeapon !== undefined) { - if (options.equippedWeapon === null) { - delete hudManager.inventory.equipped.weapon1 - } else { - hudManager.inventory.equipped.weapon1 = options.equippedWeapon as any - } - } - if (options?.equippedAmmo !== undefined) { - if (options.equippedAmmo === null) { - delete hudManager.inventory.equipped.weapon2 - } else { - hudManager.inventory.equipped.weapon2 = options.equippedAmmo as any - } - } - - hudManager.syncPublishedState() - - const waypointNetwork = new WaypointNetwork() - const status = { textContent: '' } as HTMLElement - - const runtime: MapRuntime = { - base: 'samples/d2-packs', - act: 1, - level: 'Rogue Encampment', - levelId: 1, - variant: 'default', - variants: ['default'], - quadrant: 'default', - quadrants: ['default'], - cellsX: 100, - cellsY: 100, - widthPx: 2000, - heightPx: 2000, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - pages: [], - priorityPages: [], - objectPages: [], - grid: { - originX: 0, - originY: 0, - cellsX: 100, - cellsY: 100, - blocked: new Uint8Array(500 * 500), - gridWidth: 500, - }, - npcs: [], - entrances: [], - warps: [], - waypoints: [], - animatedDrawables: [], - animSpeed: 1, - frameDurationMs: 40, - palette: null, - charBases: [], - monsterTypes: [], - monsterPacks: [], - entryPath: '', - loadPages: async () => {}, - } as unknown as MapRuntime - - const controller = new SceneMouseController({ - canvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager, - waypointNetwork, - status, - }) - controller.attach() - - return { - canvas, - win, - input, - engine, - camera, - hudManager, - runtime, - status, - controller, - } -} - -function tickScene(scene: ReturnType) { - const mouseStep = scene.controller.tick() - scene.engine.tick({ - movement: mouseStep.movement, - attacking: false, - pickingUp: false, - talking: false, - saving: false, - loading: false, - digits: [], - }) - return mouseStep -} - describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics', () => { // ========================================================================= // 1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach @@ -823,356 +646,4 @@ describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics' expect(checkAmmunition('arrow', null, { items: [], gridItems: [] })).toBe(false) }) }) - - // ========================================================================= - // 3. Stand-Ground Shift-Click Attack - // ========================================================================= - describe('3. Stand-Ground Shift-Click Attack (In-Place Execution)', () => { - it('executes Shift + left-click attack in-place towards cursor direction without pathfinding or moving player', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - leftSkillId: 0, // Attack - equippedWeapon: { - code: 'crs', - name: 'Crystal Sword', - damage: '10 to 20', - }, - }) - - // Hold Shift - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Click East: clientX = 960 + 150 = 1110, clientY = 556 - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX: 1110, - clientY: 556, - button: 0, - shiftKey: true, - }) - ) - - // Invariants: - // 1. Navigation target must be strictly null (no pathfinding) - expect(scene.controller.navTarget).toBeNull() - // 2. Pending interaction must be strictly null - expect(scene.controller.pendingInteraction).toBeNull() - // 3. Player faces cursor direction (East = 6) - expect(scene.engine.world.player.facing).toBe(6) - // 4. Attack was executed (player cooldown set to 14) - expect(scene.engine.world.player.cooldown).toBe(14) - // 5. Player world position unchanged - expect(scene.engine.world.player.x).toBe(500) - expect(scene.engine.world.player.y).toBe(500) - - // Tick scene: movement must be strictly zero - const tick = tickScene(scene) - expect(tick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.engine.world.player.x).toBe(500) - expect(scene.engine.world.player.y).toBe(500) - }) - - it('executes Shift + right-click attack in-place towards cursor direction without pathfinding or moving player', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, // Attack - equippedWeapon: { - code: 'crs', - name: 'Crystal Sword', - damage: '10 to 20', - }, - }) - - // Hold Shift - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Click North: clientX = 960, clientY = 556 - 150 = 406 - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX: 960, - clientY: 406, - button: 2, - shiftKey: true, - }) - ) - - // Invariants: - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - // Player faces cursor direction (North = 4) - expect(scene.engine.world.player.facing).toBe(4) - expect(scene.engine.world.player.cooldown).toBe(14) - expect(scene.engine.world.player.x).toBe(500) - expect(scene.engine.world.player.y).toBe(500) - - const tick = tickScene(scene) - expect(tick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.engine.world.player.x).toBe(500) - expect(scene.engine.world.player.y).toBe(500) - }) - - it('does not pathfind or move towards distant monster when Shift-clicking on monster', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - leftSkillId: 0, - equippedWeapon: { - code: 'crs', - name: 'Crystal Sword', - damage: '10 to 20', - }, - monsters: [{ id: 'far_mon', name: 'Far Skeleton', x: 800, y: 800, hp: 100 }], - }) - - // Hold Shift - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - - // Click towards distant monster (300px away) - const clientX = 960 + (800 - 500) - const clientY = 556 + (800 - 500) - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - shiftKey: true, - }) - ) - - // Stand-ground prevents pathfinding - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - - // Ticking does not move player towards monster - for (let i = 0; i < 10; i++) { - const tick = tickScene(scene) - expect(tick.movement).toEqual({ x: 0, y: 0 }) - } - expect(scene.engine.world.player.x).toBe(500) - expect(scene.engine.world.player.y).toBe(500) - }) - }) - - // ========================================================================= - // 4. Continuous Attack Hold - // ========================================================================= - describe('4. Continuous Attack Hold (Cooldown Interval Cadence)', () => { - it('attacks continuously at 14-tick cooldown intervals when holding right-click with bow', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, - equippedWeapon: { - code: 'sbw', - name: 'Short Bow', - damage: '10 to 20', - }, - equippedAmmo: { - code: 'aqv', - name: 'Arrows', - }, - }) - - // Mouse down right-click at East (1110, 556) - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX: 1110, - clientY: 556, - button: 2, - }) - ) - - // Attack #1 fired immediately on pointerdown (tick 0) - expect(scene.engine.metrics.casts).toBe(1) - expect(scene.engine.world.player.cooldown).toBe(14) - - // Ticks 1..14: Cooldown is ticking down. No new attack must fire! - for (let tick = 1; tick <= 14; tick++) { - tickScene(scene) - expect(scene.engine.metrics.casts, `Tick ${tick} should not fire early`).toBe(1) - } - expect(scene.engine.world.player.cooldown).toBe(0) - - // Tick 15: Exactly 14 ticks after tick 1. Continuous attack #2 fires! - tickScene(scene) - expect(scene.engine.metrics.casts).toBe(2) - expect(scene.engine.world.player.cooldown).toBe(13) // reset to 14, minus 1 - - // Ticks 16..28: 13 ticks of cooldown - for (let tick = 16; tick <= 28; tick++) { - tickScene(scene) - expect(scene.engine.metrics.casts).toBe(2) - } - expect(scene.engine.world.player.cooldown).toBe(0) - - // Tick 29: Exactly 14 ticks after tick 15. Continuous attack #3 fires! - tickScene(scene) - expect(scene.engine.metrics.casts).toBe(3) - - // Release right mouse button - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerup', { - clientX: 1110, - clientY: 556, - button: 2, - }) - ) - - // Subsequent ticks should NOT fire any more attacks - for (let tick = 30; tick <= 50; tick++) { - tickScene(scene) - } - expect(scene.engine.metrics.casts).toBe(3) - }) - - it('attacks continuously at 14-tick cooldown intervals when holding right-click on a melee monster target', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, - equippedWeapon: { - code: 'crs', - name: 'Crystal Sword', - damage: '20 to 30', // avg damage = 25 - }, - monsters: [{ id: 'dummy_mon', name: 'Dummy Monster', x: 530, y: 500, hp: 100 }], - }) - - const monster = scene.engine.world.monsters[0]! - expect(monster.hp).toBe(100) - - // Click directly on monster (distance 30 <= reach 52) - const clientX = 960 + 30 - const clientY = 556 - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 2, - }) - ) - - // Hit #1 fired on pointerdown - expect(monster.hp).toBe(75) // 100 - 25 - expect(scene.engine.world.player.cooldown).toBe(14) - - // Ticks 1..14: cooldown ticks down, no extra damage - for (let tick = 1; tick <= 14; tick++) { - tickScene(scene) - expect(monster.hp).toBe(75) - } - expect(scene.engine.world.player.cooldown).toBe(0) - - // Tick 15: Hit #2 fires (interval of 14 ticks) - tickScene(scene) - expect(monster.hp).toBe(50) // 75 - 25 - expect(scene.engine.world.player.cooldown).toBe(13) - - // Ticks 16..28: cooldown ticks down - for (let tick = 16; tick <= 28; tick++) { - tickScene(scene) - expect(monster.hp).toBe(50) - } - expect(scene.engine.world.player.cooldown).toBe(0) - - // Tick 29: Hit #3 fires (interval of 14 ticks) - tickScene(scene) - expect(monster.hp).toBe(25) // 50 - 25 - - // Release right mouse button - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 2, - }) - ) - - // Ticks 30..50: No further attacks - for (let tick = 30; tick <= 50; tick++) { - tickScene(scene) - } - expect(monster.hp).toBe(25) - }) - - it('attacks continuously at exact 12-tick interval for unarmed combat', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, - equippedWeapon: null, // unarmed (cooldown 12) - monsters: [{ id: 'punching_bag', name: 'Punching Bag', x: 530, y: 500, hp: 100 }], - }) - - const monster = scene.engine.world.monsters[0]! - let hitsCount = 0 - - // Pointer down on monster - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX: 960 + 30, - clientY: 556, - button: 2, - }) - ) - hitsCount++ // hit #1 at tick 0 - expect(scene.engine.world.player.cooldown).toBe(12) - - // Simulate 37 ticks: hits should occur at tick 13 (after 12 ticks), tick 25 (+12 ticks), tick 37 (+12 ticks) - const hitTicks: number[] = [] - for (let tick = 1; tick <= 37; tick++) { - const prevHp = monster.hp - tickScene(scene) - if (monster.hp < prevHp) { - hitTicks.push(tick) - hitsCount++ - } - } - - expect(hitsCount).toBe(4) // ticks 0, 13, 25, 37 - expect(hitTicks).toEqual([13, 25, 37]) - // Assert each interval between consecutive hits is exactly 12 ticks - expect(hitTicks[1] - hitTicks[0]).toBe(12) - expect(hitTicks[2] - hitTicks[1]).toBe(12) - }) - - it('attacks continuously at exact 16-tick interval for crossbow combat', () => { - const scene = createCombatTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, - equippedWeapon: { - code: 'lxb', - name: 'Light Crossbow', - damage: '10 to 20', - }, - equippedAmmo: { - code: 'cqv', - name: 'Bolts', - }, - }) - - scene.canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { - clientX: 1110, - clientY: 556, - button: 2, - }) - ) - - expect(scene.engine.metrics.casts).toBe(1) // projectile #1 at tick 0 - expect(scene.engine.world.player.cooldown).toBe(16) - - // Simulate 33 ticks: new projectile should fire at tick 17 (+16 ticks) and tick 33 (+16 ticks) - const fireTicks: number[] = [] - for (let tick = 1; tick <= 33; tick++) { - const prevCount = scene.engine.metrics.casts - tickScene(scene) - if (scene.engine.metrics.casts > prevCount) { - fireTicks.push(tick) - } - } - - expect(scene.engine.metrics.casts).toBe(3) - expect(fireTicks).toEqual([17, 33]) - expect(fireTicks[1] - fireTicks[0]).toBe(16) - }) - }) }) diff --git a/tests/class-switching.test.ts b/tests/class-switching.test.ts index 528b258..9d40449 100644 --- a/tests/class-switching.test.ts +++ b/tests/class-switching.test.ts @@ -1,12 +1,5 @@ import { describe, it, expect } from 'vitest' -import { - CLASS_EXCLUSIVE_SETS, - STARTER_STASH_ITEMS, - CLASS_STARTER_ATTRS, - CLASS_STARTER_HARD_POINTS, - CLASS_DEFAULT_SKILLS, - createStarterProfileForClass, -} from '../src/common/units/class-starter-profiles.ts' +import { CLASS_EXCLUSIVE_SETS, STARTER_STASH_ITEMS, CLASS_STARTER_ATTRS, CLASS_STARTER_HARD_POINTS } from '../src/common/units/class-starter-profiles.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/client/ui/skill-tree-panel.ts' import { resolveItemSpriteRect, type EquipSlotId } from '../src/client/ui/inventory.ts' @@ -210,123 +203,14 @@ describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures expect(panel.getSkillTree().length).toBe(30) expect(panel.getVerticalTabs()[0].titleZh).toBe('防御') - // Load specific profile state - const profile = createStarterProfileForClass('bar') - panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab) + // Load the Barbarian starter skill allocation + const barHardPoints = new Map( + Object.entries(CLASS_STARTER_HARD_POINTS.bar).map(([id, pts]): [number, number] => [Number(id), pts]), + ) + panel.loadProfile('bar', barHardPoints, 12, 0) expect(panel.characterClass).toBe('bar') expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20 expect(panel.unspentSkillPoints).toBe(12) }) }) - - describe('4. HudManager Multi-Class State Switching & Persistence', () => { - const mockCanvas = { - width: 800, - height: 600, - style: { cursor: '' }, - addEventListener: () => {}, - removeEventListener: () => {}, - getContext: () => null, - } as unknown as HTMLCanvasElement - - it('initializes with Sorceress exclusive set and stash treasures', () => { - const hud = new HudManager(mockCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - - hud.switchClass('sor') - expect(hud.currentClass).toBe('sor') - // Body has Tal Rasha set only - expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest") - expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship") - expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye") - expect(hud.inventory.equipped.weapon2).toBeUndefined() - expect(hud.inventory.equipped.gloves).toBeUndefined() - expect(hud.inventory.equipped.boots).toBeUndefined() - - // Stash has 30 treasure items and 2.5m gold - expect(hud.worldPanels.stashItems.length).toBe(30) - expect(hud.worldPanels.stashGold).toBe(2_500_000) - }) - - it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => { - const hud = new HudManager(mockCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - - // Switch to Paladin - hud.switchClass('pal') - expect(hud.currentClass).toBe('pal') - expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士') - expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor") - expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption") - expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor") - expect(hud.inventory.equipped.gloves).toBeUndefined() - expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice - expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer - expect(hud.worldPanels.stashItems.length).toBe(30) - - // Switch to Barbarian - hud.switchClass('bar') - expect(hud.currentClass).toBe('bar') - expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人') - expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will") - expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher") - expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar") - expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind - - // Switch to Amazon - hud.switchClass('ama') - expect(hud.currentClass).toBe('ama') - expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster") - - // Switch to Necromancer - hud.switchClass('nec') - expect(hud.currentClass).toBe('nec') - expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing") - - // Switch to Druid - hud.switchClass('dru') - expect(hud.currentClass).toBe('dru') - expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm") - - // Switch to Assassin - hud.switchClass('ass') - expect(hud.currentClass).toBe('ass') - expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark") - }) - - it('preserves individual class modifications across switches', () => { - const hud = new HudManager(mockCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - - // Paladin: deposit gold and spend a skill point - hud.switchClass('pal') - hud.worldPanels.stashGold = 1_800_000 - hud.skillTree.unspentSkillPoints = 5 - - // Switch to Sorceress: has her own gold & skill points - hud.switchClass('sor') - expect(hud.worldPanels.stashGold).toBe(2_500_000) - expect(hud.skillTree.unspentSkillPoints).toBe(12) - - // Modify Sorceress - hud.worldPanels.stashGold = 2_000_000 - hud.skillTree.unspentSkillPoints = 10 - - // Switch back to Paladin: modifications were preserved! - hud.switchClass('pal') - expect(hud.worldPanels.stashGold).toBe(1_800_000) - expect(hud.skillTree.unspentSkillPoints).toBe(5) - - // Switch back to Sorceress: modifications were preserved! - hud.switchClass('sor') - expect(hud.worldPanels.stashGold).toBe(2_000_000) - expect(hud.skillTree.unspentSkillPoints).toBe(10) - }) - }) }) diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index 68df896..2bcf569 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -65,10 +65,6 @@ import { applyLightingInputs, LIGHTING_PRESETS, } from '../../src/client/scene/lighting-presets.ts' -import { - resolveClientCanvasPoint, - translateScreenClickToClientCommand, -} from '../../src/client/scene/mouse-controller.ts' import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' import { computeDockingLayout, @@ -370,133 +366,6 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms ( }) describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => { - it('builds depth-sorted unit, missile (Trans: 1 additive), and overlay drawables and translates clicks to ClientCommands', () => { - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(0x12345678, 0, 1) - - const units: RenderableUnitInput[] = [ - { - kind: 'player', - id: 1, - classId: 1, // Sorceress - x: 100, - y: 100, - states: [32], // BattleOrders overlay - }, - { - kind: 'monster', - id: 50, - classId: 10, - x: 104, - y: 100, - lifePct: 80, - name: 'Fallen', - states: [1], // Freeze blue tint - }, - { - kind: 'missile', - id: 200, - classId: 62, // Missiles.txt row 62 = fireball (Trans: 1 additive) - x: 102, - y: 100, - targetX: 110, - targetY: 100, - }, - { - kind: 'missile', - id: 201, - classId: 0, // Missiles.txt row 0 = arrow (Trans: 0 normal) - x: 101, - y: 101, - }, - ] - - const drawables = buildSceneDrawables(units, { tick: 5 }) - expect(drawables.unitDrawables).toHaveLength(2) - expect(drawables.missileDrawables).toHaveLength(2) - expect(drawables.overlayDrawables.length).toBeGreaterThanOrEqual(2) - - const fireball = drawables.missileDrawables.find((m) => m.unitId === 200)! - const arrow = drawables.missileDrawables.find((m) => m.unitId === 201)! - expect(fireball.blendMode).toBe('additive') - expect(fireball.lightRadius).toBeGreaterThan(0) - expect(arrow.blendMode).toBe('normal') - - const frozenMonster = drawables.unitDrawables.find((u) => u.unitId === 50)! - expect(frozenMonster.tintColor).toEqual([90, 155, 255]) - - const source = new ClientWorldSceneSource( - (): ClientSceneWorldAdapter => ({ - tick: 10, - actId: 1, - levelId: 1, - playerSubX: 100, - playerSubY: 100, - units, - levelView, - }), - '1024x768', - ) - - // Click directly on monster at (104, 100) in 1024x768 - const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768) - const attackCmd = translateScreenClickToClientCommand( - monsterScreen.x, - monsterScreen.y - 16, - source, - { button: 'left', runMode: true }, - ) - expect(attackCmd).toEqual({ - type: 'RunToTarget', - unitKind: 'monster', - unitId: asUnitId(50), - }) - - // Right-click in empty space casts right skill on location - const castRightCmd = translateScreenClickToClientCommand(200, 200, source, { - button: 'right', - }) - expect(castRightCmd.type).toBe('CastRightSkillOnLocation') - - // Shift + Left-click in empty space casts left skill on location - const shiftLeftCmd = translateScreenClickToClientCommand(200, 200, source, { - button: 'left', - shiftHeld: true, - }) - expect(shiftLeftCmd.type).toBe('CastLeftSkillOnLocation') - - // Canvas CSS scaling helper - const normPt = resolveClientCanvasPoint( - { clientX: 512, clientY: 384 }, - { left: 0, top: 0, width: 1024, height: 768 }, - getViewportProfile('1024x768'), - ) - expect(normPt).toEqual({ x: 512, y: 384 }) - - // Lighting pass via applyLightingInputs & renderSceneSourceFrame - const lightGrid = new LightGrid() - let lastLightingCall: unknown = 'unset' - const mockRenderer = { - setLighting(params: unknown) { - lastLightingCall = params - }, - } - - const snapTorch = renderSceneSourceFrame({ - source, - lightingPreset: 'torch', - lightingTargets: { lightGrid, renderer: mockRenderer }, - }) - expect(snapTorch.viewportProfile.width).toBe(1024) - expect(lastLightingCall).not.toBeNull() - - applyLightingInputs(source.buildLightingInputs('fullbright'), { - lightGrid, - renderer: mockRenderer, - }) - expect(lastLightingCall).toBeNull() - expect(LIGHTING_PRESETS.fullbright.enabled).toBe(false) - }) it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => { const automap = new AutomapView({ initialMode: 'level' }) diff --git a/tests/cull.test.ts b/tests/cull.test.ts deleted file mode 100644 index ef81345..0000000 --- a/tests/cull.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, test, expect } from 'vitest' -import * as fs from 'node:fs' -import * as path from 'node:path' -import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/client/render/cull.ts' - -// 视口剔除的回归护栏。 -// -// 背景(见 issue #16):主循环原本把整张地图无条件提交给渲染器,实测 365 张烘焙 -// 关卡平均每帧 2 848 个四边形、最差 14 400 个,而真正落在视口里的只有 7%~11%。 -// 剔除本身不难,难的是**别把画家顺序弄坏**:墙和对象是按深度两路归并出来的, -// 一旦剔除时让某一路的游标停住,后面所有东西的前后遮挡关系就全乱了。 -// -// 所以这里除了边界条件,还用真实关卡数据做一次全量重放,断言"开剔除后画出来的 -// 序列,必须是不开剔除时那个序列的子序列"——顺序一致、只少不多、不多不错。 - -/** 一个最小可绘制物。 */ -function drawable(x: number, y: number, width: number, height: number) { - return { x, y, frame: { width, height } } -} - -describe('视口剔除', () => { - test('包围盒摊平成 [x0, y0, x1, y1]', () => { - const bounds = buildBounds([drawable(10, 20, 80, 40), drawable(-5, -6, 1, 2)]) - expect(Array.from(bounds)).toEqual([10, 20, 90, 60, -5, -6, -4, -4]) - }) - - test('空列表得到空包围盒', () => { - expect(buildBounds([]).length).toBe(0) - }) - - test('视口矩形与顶点着色器用同一个半宽公式', () => { - const view = viewportRect(1000, 500, 1280, 720, 2) - expect(view.left).toBe(1000 - 1280 / 4 - CULL_MARGIN) - expect(view.right).toBe(1000 + 1280 / 4 + CULL_MARGIN) - expect(view.top).toBe(500 - 720 / 4 - CULL_MARGIN) - expect(view.bottom).toBe(500 + 720 / 4 + CULL_MARGIN) - }) - - test('缩小时视口变大,放大时视口变小', () => { - const wide = viewportRect(0, 0, 1280, 720, 0.5) - const tight = viewportRect(0, 0, 1280, 720, 4) - expect(wide.right - wide.left).toBeGreaterThan(tight.right - tight.left) - }) - - test('视口内的留下,视口外的剔掉', () => { - const view = viewportRect(0, 0, 200, 200, 1) - // 半宽 100 + 余量 32 = 132。 - const bounds = buildBounds([ - drawable(0, 0, 10, 10), // 正中 - drawable(1000, 0, 10, 10), // 右边远处 - drawable(0, -5000, 10, 10), // 上方远处 - ]) - expect(intersects(bounds, 0, view)).toBe(true) - expect(intersects(bounds, 1, view)).toBe(false) - expect(intersects(bounds, 2, view)).toBe(false) - }) - - test('只有一角搭进视口也算可见', () => { - const view = viewportRect(0, 0, 200, 200, 1) - // 左上角在视口右下角外,但自身右下角刚好压线。 - const bounds = buildBounds([drawable(132, 132, 10, 10)]) - expect(intersects(bounds, 0, view)).toBe(true) - }) - - test('大精灵即使锚点在屏外也不能被剔掉', () => { - const view = viewportRect(0, 0, 200, 200, 1) - // 资源包里最大的帧是 160×960:锚点远在视口上方,画出来的部分却盖住整屏。 - const bounds = buildBounds([drawable(0, -900, 160, 960)]) - expect(intersects(bounds, 0, view)).toBe(true) - // 高度换成 10 之后就真的看不见了,说明上面那条靠的是帧尺寸而不是坐标。 - expect(intersects(buildBounds([drawable(0, -900, 160, 10)]), 0, view)).toBe(false) - }) - - test('余量让刚出界的一圈继续留下', () => { - const view = viewportRect(0, 0, 200, 200, 1) - // 半宽 100,落在 100~132 之间的只有靠余量才留得住。 - expect(intersects(buildBounds([drawable(120, 0, 1, 1)]), 0, view)).toBe(true) - expect(intersects(buildBounds([drawable(140, 0, 1, 1)]), 0, view)).toBe(false) - }) - - test('换关卡时重建包围盒避免越界误剔除', () => { - // 模拟从 57x41 关卡(2337 个图元)切换到 80x80 关卡(6400 个图元) - const smallLevelDrawables = Array.from({ length: 2337 }, (_, i) => drawable(i * 10, 0, 80, 40)) - const largeLevelDrawables = Array.from({ length: 6400 }, () => drawable(0, 0, 80, 40)) - - const view = viewportRect(0, 0, 1280, 720, 1) - - // 旧实现未重建 bounds,largeLevel 使用 smallLevel 的 stale bounds - const staleBounds = buildBounds(smallLevelDrawables) - let visibleWithStaleBounds = 0 - for (let i = 0; i < largeLevelDrawables.length; i += 1) { - if (intersects(staleBounds, i, view)) visibleWithStaleBounds += 1 - } - expect(visibleWithStaleBounds).toBeLessThanOrEqual(2337) - - // 修复后重建 bounds,所有落在视口内的图元正确通过剔除 - const freshBounds = buildBounds(largeLevelDrawables) - let visibleWithFreshBounds = 0 - for (let i = 0; i < largeLevelDrawables.length; i += 1) { - if (intersects(freshBounds, i, view)) visibleWithFreshBounds += 1 - } - expect(visibleWithFreshBounds).toBe(6400) - }) -}) - -/** 资源包位置;缺资源时整组测试跳过而不是报红。 */ -const PACKS = path.resolve(__dirname, '../samples/d2-packs') - -/** 所有含 scene.json 的关卡目录。 */ -function levelDirs(): { name: string; scene: string }[] { - if (!fs.existsSync(PACKS)) return [] - const out: { name: string; scene: string }[] = [] - for (const act of fs.readdirSync(PACKS)) { - const actPath = path.join(PACKS, act) - if (!fs.statSync(actPath).isDirectory()) continue - for (const level of fs.readdirSync(actPath)) { - const scene = path.join(actPath, level, 'scene.json') - if (fs.existsSync(scene)) out.push({ name: `${act}/${level}`, scene }) - } - } - return out -} - -interface Item { x: number; y: number; frame: { width: number; height: number }; depth: number } - -/** 把一张 scene.json 摊成场景实际持有的四条绘制列表。 */ -function loadLevel(scenePath: string) { - const json = JSON.parse(fs.readFileSync(scenePath, 'utf8')) as Record - const place = (json.framePlacement ?? []) as number[][] - const tiles = (rows: number[][] | undefined): Item[] => (rows ?? []).map(row => { - const [frameIndex, x, y, cellX, cellY] = row - const p = place[frameIndex!] ?? [0, 0, 0, 0, 0] - return { x: x!, y: y!, frame: { width: p[3]!, height: p[4]! }, depth: cellX! + cellY! } - }) - const objs: Item[] = (json.objects ?? []) - .filter((o: any) => o.frame) - .map((o: any) => ({ x: o.x, y: o.y, frame: { width: o.frame.width, height: o.frame.height }, depth: o.depth ?? 0 })) - .sort((a: Item, b: Item) => a.depth - b.depth) - const spawn = Array.isArray(json.spawn) ? { x: json.spawn[0] as number, y: json.spawn[1] as number } : { x: 0, y: 0 } - return { floors: tiles(json.floors), walls: tiles(json.walls), roofs: tiles(json.roofs), objs, spawn } -} - -/** - * 重放一张关卡的绘制顺序。 - * - * 这里刻意复刻 `act-scene` 的两路深度归并:剔除只跳过"画"这一步,游标照常前进。 - * - * @param cull - 是否启用视口剔除。 - * @returns 依次画出的标记,以及剔除数量。 - */ -function replay(level: ReturnType, cull: boolean) { - const view = viewportRect(level.spawn.x, level.spawn.y - 16, 1280, 720, 1) - const floorBounds = buildBounds(level.floors) - const wallBounds = buildBounds(level.walls) - const roofBounds = buildBounds(level.roofs) - const objBounds = buildBounds(level.objs) - - const order: string[] = [] - let culled = 0 - const emit = (bounds: Float32Array, index: number, item: Item, tag: string): void => { - if (cull && !intersects(bounds, index, view)) { culled += 1; return } - order.push(`${tag}:${String(item.x)},${String(item.y)}`) - } - - for (let i = 0; i < level.floors.length; i += 1) emit(floorBounds, i, level.floors[i]!, 'f') - let wi = 0 - let oi = 0 - while (wi < level.walls.length || oi < level.objs.length) { - const wd = wi < level.walls.length ? level.walls[wi]!.depth : Infinity - const od = oi < level.objs.length ? level.objs[oi]!.depth : Infinity - if (wd <= od) { emit(wallBounds, wi, level.walls[wi]!, 'w'); wi += 1 } - else { emit(objBounds, oi, level.objs[oi]!, 'o'); oi += 1 } - } - for (let i = 0; i < level.roofs.length; i += 1) emit(roofBounds, i, level.roofs[i]!, 'r') - return { order, culled } -} - -/** `sub` 是否按相同相对顺序出现在 `full` 里。 */ -function isSubsequence(sub: string[], full: string[]): boolean { - let i = 0 - for (const item of full) if (i < sub.length && sub[i] === item) i += 1 - return i === sub.length -} - -const levels = levelDirs() - -describe.runIf(levels.length > 0)('剔除后的画家顺序(全量资源包重放)', () => { - test('每张关卡剔除后的绘制序列都是原序列的子序列', () => { - const broken: string[] = [] - let before = 0 - let after = 0 - for (const { name, scene } of levels) { - const level = loadLevel(scene) - const full = replay(level, false) - const culled = replay(level, true) - if (!isSubsequence(culled.order, full.order)) broken.push(name) - before += full.order.length - after += culled.order.length - } - // 具名收集,失败时能直接看出是哪几张关卡乱了序。 - expect(broken).toEqual([]) - // 顺带守住收益:真剔掉了绝大多数,而不是悄悄退化成"全画"。 - expect(after).toBeGreaterThan(0) - expect(after).toBeLessThan(before * 0.25) - }) -}) diff --git a/tests/debug-toolbar-density-elite.test.ts b/tests/debug-toolbar-density-elite.test.ts index dd50ec3..e5ff784 100644 --- a/tests/debug-toolbar-density-elite.test.ts +++ b/tests/debug-toolbar-density-elite.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { TOWN_LEVEL_IDS, isTownLevel, parseDensityMultiplier, parseEliteMultiplier, updateSelectorWarningStyle, updateToolbarUrl, rebudgetMonsterPacks } from '../src/client/world/act-runtime.ts' +import { TOWN_LEVEL_IDS, isTownLevel, parseDensityMultiplier, parseEliteMultiplier, rebudgetMonsterPacks } from '../src/common/world/scene-population.ts' import type { CombatTerrain, CombatWorld, MonsterPack, MonsterStats } from '../src/server/engine/combat.ts' import { MonsterStreamingManager } from '../src/common/units/monster-streaming.ts' @@ -119,66 +119,6 @@ describe('Debug Toolbar: Monster Density & Elite Multipliers (Issue #450)', () = }) }) - describe('Non-default warning styling', () => { - it('applies red border and non-default class when value is not 1', () => { - const select = createMockSelect('2') - updateSelectorWarningStyle(select) - expect(select.classList.contains('non-default')).toBe(true) - expect(select.style.border).toBe('1px solid #e74c3c') - }) - - it('removes red border and non-default class when value is 1', () => { - const select = createMockSelect('1') - updateSelectorWarningStyle(select) - expect(select.classList.contains('non-default')).toBe(false) - expect(select.style.border).toBe('') - }) - - it('handles all elite non-default styling', () => { - const select = createMockSelect('all') - updateSelectorWarningStyle(select) - expect(select.classList.contains('non-default')).toBe(true) - expect(select.style.border).toBe('1px solid #e74c3c') - }) - - it('gracefully handles null select element', () => { - expect(() => { updateSelectorWarningStyle(null) }).not.toThrow() - }) - }) - - describe('Bidirectional URL sync logic', () => { - it('adds non-default density and elite to URL query parameters', () => { - const result = updateToolbarUrl('?act=1&level=bloodmoor', { density: 2, elite: 4 }) - expect(result).toContain('density=2') - expect(result).toContain('elite=4') - expect(result).toContain('act=1') - expect(result).toContain('level=bloodmoor') - }) - - it('omits density and elite from URL when they are 1 (default)', () => { - const initial = '?act=1&level=bloodmoor&density=4&elite=2' - const updated = updateToolbarUrl(initial, { density: 1, elite: 1 }) - expect(updated).not.toContain('density=') - expect(updated).not.toContain('elite=') - expect(updated).toContain('act=1') - expect(updated).toContain('level=bloodmoor') - }) - - it('handles "all" elite in URL query', () => { - const result = updateToolbarUrl('?act=1', { elite: 'all' }) - expect(result).toContain('elite=all') - }) - - it('supports full URL strings', () => { - const url = 'http://localhost:3000/acts.html?act=2&level=rockywaste' - const updated = updateToolbarUrl(url, { density: 8, elite: 2 }) - expect(updated).toContain('http://localhost:3000/acts.html?') - expect(updated).toContain('density=8') - expect(updated).toContain('elite=2') - expect(updated).toContain('level=rockywaste') - }) - }) - describe('Town safe-zone invariant: strictly 0 density in town levels', () => { it('identifies all 5 canonical town levels across Acts 1..5', () => { expect(TOWN_LEVEL_IDS.size).toBe(5) diff --git a/tests/dungeon-wilderness-animations.test.ts b/tests/dungeon-wilderness-animations.test.ts index 0ac461c..14c0c78 100644 --- a/tests/dungeon-wilderness-animations.test.ts +++ b/tests/dungeon-wilderness-animations.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest' import { readFileSync, existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, PERSISTENT_ILLUMINATION_TOKENS, type ObjectDrawable, type AnimatedObjectFrame } from '../src/client/world/act-runtime.ts' import { updateAnimatableTiles, type AnimatableTile, @@ -9,152 +8,6 @@ import { } from '../src/common/world/animated-tiles.ts' describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => { - describe('PERSISTENT_ILLUMINATION_TOKENS definitions', () => { - it('includes all canonical illumination and environmental fire tokens', () => { - const requiredTokens = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL'] - for (const token of requiredTokens) { - expect(PERSISTENT_ILLUMINATION_TOKENS.has(token)).toBe(true) - } - }) - }) - - describe('Persistent illumination looping parity (updateAnimatedObject)', () => { - const dummyAtlasFrame = (i: number) => ({ - x: i * 32, - y: 0, - width: 32, - height: 64, - }) - - const createTestIlluminationDrawable = ( - token: string, - frameCount = 10, - frameDurationMs = 80, - forceCycleAnim?: boolean, - ): ObjectDrawable => { - const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({ - frame: dummyAtlasFrame(i), - page: 0, - offsetX: -16, - offsetY: -32, - })) - - return { - token, - frame: frames[0]!.frame, - x: 100, - y: 200, - depth: 60, - page: 0, - baseX: 100, - baseY: 200, - animatedFrames: frames, - frameDurationMs, - cycleAnim: forceCycleAnim, - phaseOffsetMs: 0, - currentFrameIndex: 0, - } - } - - it('cycles continuously even if cycleAnim was false for persistent illumination tokens', () => { - // Tokens like FX, FY, FZ (environmental fires) or BR (braziers) might have cycleAnim=0 in Objects.txt mode 0 - const tokensToTest = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL'] - - for (const token of tokensToTest) { - const obj = createTestIlluminationDrawable(token, 10, 80, false) - const totalDuration = 10 * 80 // 800ms - - // At t=0 - updateAnimatedObject(obj, 0) - expect(obj.currentFrameIndex).toBe(0) - - // At t=160ms (frame 2) - updateAnimatedObject(obj, 160) - expect(obj.currentFrameIndex).toBe(2) - - // Beyond cycle: at t=800ms, should loop back to frame 0 (NOT clamped to frame 9) - updateAnimatedObject(obj, 800) - expect(obj.currentFrameIndex).toBe(0) - - // At t=1040ms (800 + 3 * 80), should be frame 3 - updateAnimatedObject(obj, 1040) - expect(obj.currentFrameIndex).toBe(3) - } - }) - - it('clamps non-persistent objects when cycleAnim is false', () => { - const nonFireObj = createTestIlluminationDrawable('XX', 10, 80, false) - // At t=1000ms (> 800ms total duration) - updateAnimatedObject(nonFireObj, 1000) - expect(nonFireObj.currentFrameIndex).toBe(9) // clamped to last frame - }) - - it('maintains independent spatial phase offsets across torches and candles', () => { - const duration = 20 * 51.2 - const p1 = computeObjectPhaseOffsetMs(100, 200, duration) - const p2 = computeObjectPhaseOffsetMs(104, 208, duration) - expect(p1).not.toBe(p2) - expect(p1).toBeGreaterThanOrEqual(0) - expect(p1).toBeLessThan(duration) - }) - - it('prevents sprite coordinate drift across frame transitions and loops', () => { - const frames: AnimatedObjectFrame[] = [ - { frame: dummyAtlasFrame(0), page: 0, offsetX: -10, offsetY: -20 }, - { frame: dummyAtlasFrame(1), page: 0, offsetX: -15, offsetY: -25 }, - { frame: dummyAtlasFrame(2), page: 0, offsetX: -8, offsetY: -18 }, - ] - const obj: ObjectDrawable = { - token: 'TO', - frame: frames[0]!.frame, - x: 100 + frames[0]!.offsetX, - y: 200 + frames[0]!.offsetY, - depth: 60, - page: 0, - baseX: 100, - baseY: 200, - animatedFrames: frames, - frameDurationMs: 100, - cycleAnim: true, - currentFrameIndex: 0, - } - - for (let clock = 0; clock <= 3000; clock += 50) { - updateAnimatedObject(obj, clock) - const expectedFrameIdx = Math.floor(clock / 100) % 3 - expect(obj.currentFrameIndex).toBe(expectedFrameIdx) - const expectedFrame = frames[expectedFrameIdx]! - expect(obj.x).toBe(100 + expectedFrame.offsetX) - expect(obj.y).toBe(200 + expectedFrame.offsetY) - } - }) - - it('safely handles degenerate frame inputs (empty, single frame, zero/negative duration)', () => { - const singleFrameObj: ObjectDrawable = { - token: 'TO', - frame: dummyAtlasFrame(0), - x: 100, - y: 200, - depth: 60, - page: 0, - baseX: 100, - baseY: 200, - animatedFrames: [{ frame: dummyAtlasFrame(0), page: 0, offsetX: 0, offsetY: 0 }], - frameDurationMs: 80, - currentFrameIndex: 0, - } - expect(updateAnimatedObject(singleFrameObj, 1000)).toBe(false) - expect(singleFrameObj.currentFrameIndex).toBe(0) - - const zeroDurationObj = createTestIlluminationDrawable('TO', 5, 0, true) - expect(updateAnimatedObject(zeroDurationObj, 120)).toBe(true) - expect(zeroDurationObj.currentFrameIndex).toBe(3) - - const negClockObj = createTestIlluminationDrawable('TO', 5, 40, true) - expect(updateAnimatedObject(negClockObj, -100)).toBe(false) - expect(negClockObj.currentFrameIndex).toBe(0) - }) - }) describe('DT1 animated tile serialization and unpacking parity', () => { it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => { @@ -250,30 +103,6 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 } }) - it('verifies Cave (Level 10) packs have animated illumination objects', () => { - const act1Dir = join(packsDir, 'act1') - expect(existsSync(act1Dir)).toBe(true) - - const caveDirs = readdirSync(act1Dir).filter(name => name.startsWith('10-act-1-cave')) - expect(caveDirs.length).toBeGreaterThan(0) - for (const d of caveDirs) { - const scenePath = join(act1Dir, d, 'scene.json') - expect(existsSync(scenePath)).toBe(true) - const scene = JSON.parse(readFileSync(scenePath, 'utf8')) - const illumination = (scene.objects as any[]).filter(o => - o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase()) - ) - expect(illumination.length).toBeGreaterThan(0) - for (const item of illumination) { - if (item.animatedFrames) { - expect(item.animatedFrames.length).toBeGreaterThan(1) - expect(item.cycleAnim).toBe(true) - expect(item.frameDelta).toBeGreaterThan(0) - } - } - } - }) - it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => { const act1Dir = join(packsDir, 'act1') expect(existsSync(act1Dir)).toBe(true) @@ -301,27 +130,6 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 expect(totalCandlesOrFires).toBeGreaterThan(0) }) - it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => { - const act1Dir = join(packsDir, 'act1') - expect(existsSync(act1Dir)).toBe(true) - - const cryptDirs = readdirSync(act1Dir).filter(name => name.startsWith('18-act-1-crypt') || name.startsWith('19-act-1-crypt')) - expect(cryptDirs.length).toBeGreaterThan(0) - for (const d of cryptDirs) { - const scenePath = join(act1Dir, d, 'scene.json') - expect(existsSync(scenePath)).toBe(true) - const scene = JSON.parse(readFileSync(scenePath, 'utf8')) - const items = (scene.objects as any[]).filter(o => - o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase()) - ) - for (const item of items) { - if (item.animatedFrames) { - expect(item.cycleAnim).toBe(true) - } - } - } - }) - it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => { const act4Dir = join(packsDir, 'act4') expect(existsSync(act4Dir)).toBe(true) diff --git a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts index c373859..855289d 100644 --- a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts +++ b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts @@ -27,6 +27,7 @@ import { findSafeDropPosition } from '../../src/common/items/ground-items.ts' import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID, COLLIDE_NONE } from '../../src/common/world/d2map.ts' import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts' import { BeltHud } from '../../src/client/ui/belt.ts' +import { seedDemoBeltPotions } from '../fixtures/demo-profile-data.ts' let oracle: DropOracleStack const dropTables = getEmbeddedDropTables() @@ -611,7 +612,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues const belt = new BeltHud() // The belt starts empty; seed the legacy full 16-potion layout explicitly expect(belt.countTotalPotions()).toBe(0) - belt.seedDefaultPotions() + seedDemoBeltPotions(belt) expect(belt.countTotalPotions()).toBe(16) const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any // Trying to place in any column returns the unplaced potion @@ -629,7 +630,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues it('B14.3 picked potion targets column with matching potion type first', () => { const belt = new BeltHud() - belt.seedDefaultPotions() // explicit legacy full belt + seedDemoBeltPotions(belt) // explicit legacy full belt belt.useSlot(0) // empty top row in col 0 (Health Potion col) expect(belt.grid[3][0]).toBeNull() const newHealth = { id: 'new_hp', name: 'Health Potion' } as any diff --git a/tests/e2e-module-split/tier1-feature-coverage.test.ts b/tests/e2e-module-split/tier1-feature-coverage.test.ts index 2bde460..921a195 100644 --- a/tests/e2e-module-split/tier1-feature-coverage.test.ts +++ b/tests/e2e-module-split/tier1-feature-coverage.test.ts @@ -379,7 +379,7 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation for (const tool of requiredTools) { expect(existsInRepo(tool), `Expected CLI tool ${tool} to exist`).toBe(true) } - expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(80) + expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(60) }) it('F5.2: migrates tools/lib/drlg-oracle.ts and tools/d2s-unlock.ts with intact exports', async () => { @@ -527,7 +527,6 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation 'src/client/frontend', 'src/client/scene', 'src/client/audio', - 'src/client/sim', 'src/client/i18n', 'src/client/map', 'src/client/session', diff --git a/tests/e2e-module-split/tier2-boundary-corner.test.ts b/tests/e2e-module-split/tier2-boundary-corner.test.ts index 44537bb..966ec91 100644 --- a/tests/e2e-module-split/tier2-boundary-corner.test.ts +++ b/tests/e2e-module-split/tier2-boundary-corner.test.ts @@ -622,16 +622,16 @@ describe('Tier 2: F7 — Unified play.html Single-Entry & Legacy Entry Purge Ver } }) - it('F7.B5: src/client/launcher/play-entry.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => { - const source = readRepoFile('src/client/launcher/play-entry.ts') + it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => { + const source = readRepoFile('src/client/main.ts') const specs = extractImportSpecifiers(source) - const entryFileAbs = `${ROOT_DIR}/src/client/launcher/play-entry.ts` + const entryFileAbs = `${ROOT_DIR}/src/client/main.ts` for (const spec of specs) { const target = resolveImportTargetRel(entryFileAbs, spec) expect(target.startsWith('src/server')).toBe(false) expect(target.startsWith('src/baker')).toBe(false) for (const legacyDir of LEGACY_SRC_DIRS) { - expect(target.startsWith(`src/${legacyDir}`)).toBe(false) + expect(target.startsWith(`src/${legacyDir}/`)).toBe(false) } } }) diff --git a/tests/e2e-module-split/tier3-cross-feature.test.ts b/tests/e2e-module-split/tier3-cross-feature.test.ts index 59d5850..577b5b4 100644 --- a/tests/e2e-module-split/tier3-cross-feature.test.ts +++ b/tests/e2e-module-split/tier3-cross-feature.test.ts @@ -4,8 +4,8 @@ * Covers 16 pairwise & multi-feature integration tests across F1–F10: * - F2 + F5: `common/data` (`D2DataRegistry`) + `server/engine` (`buildGameEngine` / `createPlayerState`) * - F3 + F5: `common/items` (`gems` + `runeword`) + `server/engine` item modifier application - * - F3 + F6: `common/items/item-bridge` + `client/ui/inventory-panel` UI descriptor contract - * - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/world/act-runtime` + * - F3 + F6: `common/items/item-bridge` + `client/ui/inventory` UI descriptor contract + * - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/scene/entity-art-loader` * - F1 + F3 + F8: `common/pack-contract` + `common/world/acts` + `baker` universal DT1 contract * - F4 + F5 + F6: `netproto` + `server/loopback-adapter` + `client/session/online-session` + `client/world/client-world` * - F4 + F6: `netproto` S2C packet stream + `OnlineSession` + `ClientWorld` @@ -13,8 +13,8 @@ * - F3 + F5: `common/items/inventory-model` + `belt-model` + `server/engine` * - F3 + F5: `common/world/d2map` collision & spawn grounding + `server/world/server-world` * - F2 + F8: `common/data/data-registry` `ArchiveSource` contract + `baker/mpq-archive-source` - * - F6 + F7: `play.html` + `vite.config.ts` + `client/launcher/play-entry.ts` - * - F7 + F9: `tsconfig.client.json` + `src/client/launcher/play-entry.ts` full transitive import graph + * - F6 + F7: `play.html` + `vite.config.ts` + `client/main.ts` + * - F7 + F9: `tsconfig.client.json` + `src/client/main.ts` full transitive import graph * - F5 + F9: `tsconfig.server.json` + `src/server/index.ts` full transitive import graph * - F8 + F9: `tsconfig.baker.json` + `src/baker/index.ts` & `tools/pack-all.ts` transitive import graph * - F4 + F6 + F10: `tools/netproto-replay.ts` + `src/netproto` + `src/client/world/client-world.ts` @@ -138,9 +138,9 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => { expect(match.name).toBe('Enigma') }) - it('CF3 (F3 + F6): common/items/item-bridge converts domain items into UI descriptors consumed by client/ui/inventory-panel', async () => { + it('CF3 (F3 + F6): common/items/item-bridge converts domain items into UI descriptors consumed by client/ui/inventory', async () => { const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts') - const invPanelMod = await importRepoModule('src/client/ui/inventory-panel.ts') + const invPanelMod = await importRepoModule('src/client/ui/inventory.ts') expect(invPanelMod).toBeDefined() const domainSword = { @@ -172,11 +172,8 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => { it('CF4 (F3 + F6): SuperUnique bosses in common/units/monsters-data map to valid art specs and minion art in common/units/monster-mapping', async () => { const monDataMod = await importRepoModule('src/common/units/monsters-data.ts') const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts') - const actRuntimeMod = await importRepoModule( - 'src/client/world/act-runtime.ts', - 'src/client/map/act-runtime.ts', - ) - expect(actRuntimeMod).toBeDefined() + const entityArtMod = await importRepoModule('src/client/scene/entity-art-loader.ts') + expect(entityArtMod).toBeDefined() const corpsefire = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find( (s: any) => s.superunique === 'Corpsefire', @@ -290,9 +287,7 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => { const langMod = await importRepoModule('src/common/i18n/lang.ts') const monDataMod = await importRepoModule('src/common/units/monsters-data.ts') const clientI18n = await importRepoModule( - 'src/client/i18n/labels.ts', - 'src/client/i18n/index.ts', - 'src/client/i18n/monster-names.ts', + 'src/client/i18n/lang.ts', ) expect(clientI18n).toBeDefined() @@ -401,15 +396,15 @@ describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => { expect(bakerSource).toContain('common') }) - it('CF12 (F6 + F7): play.html script entry, vite.config.ts rollupOptions.input, and src/client/launcher/play-entry.ts are aligned', () => { + it('CF12 (F6 + F7): play.html script entry, vite.config.ts rollupOptions.input, and src/client/main.ts are aligned', () => { const playHtml = readRepoFile('play.html') const viteCfg = readRepoFile('vite.config.ts') expect(playHtml).toContain('/src/client/main.ts') expect(viteCfg).toContain('play.html') }) - it('CF13 (F7 + F9): transitive import closure of src/client/launcher/play-entry.ts stays strictly within common, netproto, and client', () => { - const reachable = collectTransitiveTsImports('src/client/launcher/play-entry.ts') + it('CF13 (F7 + F9): transitive import closure of src/client/main.ts stays strictly within common, netproto, and client', () => { + const reachable = collectTransitiveTsImports('src/client/main.ts') expect(reachable.size).toBeGreaterThan(5) const violations: string[] = [] for (const rel of reachable) { diff --git a/tests/e2e-module-split/tier5-adversarial-hardening.test.ts b/tests/e2e-module-split/tier5-adversarial-hardening.test.ts index 5cdeaec..e619999 100644 --- a/tests/e2e-module-split/tier5-adversarial-hardening.test.ts +++ b/tests/e2e-module-split/tier5-adversarial-hardening.test.ts @@ -3,7 +3,6 @@ * (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`). * * Covers white-box regression guards and stress probes from Iteration 1: - * - adv_BUG_01: `src/client/scene/arena-overlay.ts` zero dynamic/cross-module server imports & fail-fast WorldArenaFactory DI * - adv_BUG_02: zero `.test.ts`/`.spec.ts` files under `src/**` & `tests/frontend/flow-state.test.ts` relocation * - adv_BUG_03: `src/netproto/transport/endpoint.ts` zero `new Function` dynamic imports & fail-fast `NetprotoError` when TCP opener is unregistered * - adv_F1_01..06: `D2DataRegistry` & `getSharedDataRegistry` fail-fast & cache-poisoning guards @@ -15,10 +14,6 @@ import { describe, expect, it } from 'vitest' import { runBakerCli, runBakerVerify } from '../../src/baker/cli.ts' -import { - initBrowserArenaMode, - registerWorldArenaFactory, -} from '../../src/client/scene/arena-overlay.ts' import { OnlineSession } from '../../src/client/session/online-session.ts' import { ClientWorld } from '../../src/client/world/client-world.ts' import { @@ -53,19 +48,7 @@ import { } from './helpers.ts' describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => { - describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_01..03)', () => { - it('adv_BUG_01: src/client/scene/arena-overlay.ts has zero dynamic imports or server references and fails fast without WorldArenaFactory', async () => { - const code = stripComments(readRepoFile('src/client/scene/arena-overlay.ts')) - expect(code).not.toContain('import(') - expect(code).not.toContain('world-arena') - expect(code).not.toContain("'server'") - expect(typeof registerWorldArenaFactory).toBe('function') - - registerWorldArenaFactory(null) - await expect(initBrowserArenaMode()).rejects.toThrow( - /No WorldArenaFactory registered; registerWorldArenaFactory must be called/i, - ) - }) + describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_02..03)', () => { it('adv_BUG_02: zero .test.ts or .spec.ts files exist under src/** and tests/frontend/flow-state.test.ts is present', () => { const srcTestFiles = collectTsFiles('src').filter( diff --git a/tests/e2e-netproto/tier1-feature-coverage.test.ts b/tests/e2e-netproto/tier1-feature-coverage.test.ts index 0db4c8c..fed8698 100644 --- a/tests/e2e-netproto/tier1-feature-coverage.test.ts +++ b/tests/e2e-netproto/tier1-feature-coverage.test.ts @@ -170,7 +170,6 @@ import { applyLightingInputs, LIGHTING_PRESETS, } from '../../src/client/scene/lighting-presets.ts' -import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts' import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts' @@ -1521,39 +1520,6 @@ describe('Tier 1 — F17: Viewport, Camera, Lighting & World Scene Integration', expect(drawables.missileDrawables[0]!.blendMode).toBe('additive') }) - it('F17.4: ClientWorldSceneSource projects world state and translates screen clicks to ClientCommands', () => { - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(0x12345678, 0, 1) - const units: RenderableUnitInput[] = [ - { kind: 'player', id: 1, classId: 1, x: 100, y: 100 }, - { kind: 'monster', id: 50, classId: 10, x: 104, y: 100, lifePct: 80, name: 'Fallen' }, - ] - const source = new ClientWorldSceneSource( - (): ClientSceneWorldAdapter => ({ - tick: 10, - actId: 1, - levelId: 1, - playerSubX: 100, - playerSubY: 100, - units, - levelView, - }), - '1024x768', - ) - const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768) - const cmd = translateScreenClickToClientCommand( - monsterScreen.x, - monsterScreen.y - 16, - source, - { button: 'left', runMode: true }, - ) - expect(cmd).toEqual({ - type: 'RunToTarget', - unitKind: 'monster', - unitId: asUnitId(50), - }) - }) - it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => { const automap = new AutomapView({ initialMode: 'level' }) const levelView = automap.setContext(0x12345678, 0, 1) diff --git a/tests/e2e-netproto/tier3-cross-feature.test.ts b/tests/e2e-netproto/tier3-cross-feature.test.ts index e2f881e..919d49f 100644 --- a/tests/e2e-netproto/tier3-cross-feature.test.ts +++ b/tests/e2e-netproto/tier3-cross-feature.test.ts @@ -113,7 +113,6 @@ import { } from '../../src/client/view/scene-source.ts' import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' import { applyLightingInputs } from '../../src/client/scene/lighting-presets.ts' -import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts' import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' import { computeDockingLayout } from '../../src/client/ui/hud-manager.ts' import { AutomapView } from '../../src/client/automap/automap-view.ts' @@ -786,49 +785,6 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => { s2.close() }) - it('X18 (F11 × F17): ClientWorld units project into ClientWorldSceneSource and screen click translates to ClientCommand', () => { - const world = new ClientWorld() - world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x12345678, areaId: 1 }) - world.apply({ - type: 'UnitAssign', - unit: { unitType: 'player', id: 1, classId: 1, x: 100, y: 100, name: 'd2webbot_sorc' }, - }) - world.apply({ - type: 'UnitAssign', - unit: { unitType: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' }, - }) - - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(world.mapSeed, 0, world.areaId) - const units: RenderableUnitInput[] = [ - { kind: 'player', id: 1, classId: 1, x: 100, y: 100 }, - { kind: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' }, - ] - const source = new ClientWorldSceneSource( - (): ClientSceneWorldAdapter => ({ - tick: 1, - actId: 1, - levelId: 1, - playerSubX: 100, - playerSubY: 100, - units, - levelView, - }), - '1024x768', - ) - - const monsterScreen = subTileToScreen(106, 100, 100, 100, VIEWPORT_1024x768) - const cmd = translateScreenClickToClientCommand(monsterScreen.x, monsterScreen.y - 16, source, { - button: 'right', - runMode: true, - }) - expect(cmd).toEqual({ - type: 'CastRightSkillOnTarget', - unitKind: 'monster', - unitId: asUnitId(77), - }) - }) - it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => { const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const toolbar = new Toolbar({ store }) diff --git a/tests/e2e-netproto/tier4-real-world-scenarios.test.ts b/tests/e2e-netproto/tier4-real-world-scenarios.test.ts index 75368e8..ff093e6 100644 --- a/tests/e2e-netproto/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-netproto/tier4-real-world-scenarios.test.ts @@ -94,7 +94,6 @@ import { import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' import { LightGrid } from '../../src/common/drlg/light-grid.ts' import { applyLightingInputs } from '../../src/client/scene/lighting-presets.ts' -import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts' import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts' import { AutomapView } from '../../src/client/automap/automap-view.ts' @@ -637,76 +636,6 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', () } }) - it('S9: Interactive screen-click -> A* pathfinding -> movement prediction -> rate-limited C2S -> S2C rubber-band & interpolation loop', () => { - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(0x12345678, 0, 1) - const selfPos = { x: 100, y: 100 } - const targetPos = { x: 108, y: 100 } - - const source = new ClientWorldSceneSource( - (): ClientSceneWorldAdapter => ({ - tick: 1, - actId: 1, - levelId: 1, - playerSubX: selfPos.x, - playerSubY: selfPos.y, - units: [{ kind: 'player', id: 1, classId: 1, x: selfPos.x, y: selfPos.y }], - levelView, - }), - '800x600', - ) - - const targetScreen = subTileToScreen(targetPos.x, targetPos.y, selfPos.x, selfPos.y, VIEWPORT_800x600) - const clickCmd = translateScreenClickToClientCommand(targetScreen.x, targetScreen.y, source, { - button: 'left', - runMode: true, - }) - expect(clickCmd?.type).toBe('RunToLocation') - - // Compute local A* path and step LocalMovementPredictor - const walkable = new Uint8Array(200 * 200).fill(1) - const pathRes = findPathLocal( - { subWidth: 200, subHeight: 200, walkable }, - selfPos.x, - selfPos.y, - targetPos.x, - targetPos.y, - { smooth: true }, - ) - expect(pathRes.reachedGoal).toBe(true) - - const predictor = new LocalMovementPredictor({ initialX: selfPos.x, initialY: selfPos.y }) - predictor.setPath(pathRes.path, true) - const stepped = predictor.stepTicks(10) - expect(stepped.x).toBeGreaterThan(selfPos.x) - - // Pass command through OutboundRateLimiter and encode to C2S - const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 }) - const decision = limiter.tryAcquire(clickCmd!, 1000) - expect(decision.allowed).toBe(true) - const [wire] = encodeD2gsClientCommand({ type: 'Move', run: true, to: targetPos }) - expect(wire![0]).toBe(0x03) - - // Simulate S2C 0x15 ReassignPlayer reconciliation + remote unit interpolation - const rec = predictor.reconcileServerPosition(targetPos, true) - expect(rec.mode).toBe('hard-snap') - expect(predictor.stepTicks(0).x).toBe(targetPos.x) - - const interp = new RemoteEntityInterpolator() - interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 100, y: 100 }) - interp.startMove({ - unitType: 1, - unitId: 50, - currentX: 100, - currentY: 100, - targetX: 108, - targetY: 100, - moveType: 0x01, - }) - interp.update(300) - expect(interp.getEntityState(1, 50)!.x).toBeGreaterThan(100) - }) - it('S10: End-to-End Audio & Asset streaming workflow (SettingsStore <-> SoundService spatial SFX/BGM + OPFS HTTP Range 206 cache)', async () => { const playedBgm: number[] = [] const playedSfx: string[] = [] diff --git a/tests/engine/r1-skill-engine.test.ts b/tests/engine/r1-skill-engine.test.ts index a3d143e..28bbe0a 100644 --- a/tests/engine/r1-skill-engine.test.ts +++ b/tests/engine/r1-skill-engine.test.ts @@ -31,7 +31,6 @@ import { AuraScanner } from '../../src/server/engine/aura-scanner.ts' import { SummonManager } from '../../src/server/ai/summon-manager.ts' import { WorldArena } from '../../src/server/engine/world-arena.ts' import { evaluateSkill113c } from '../../src/server/skills/registry.ts' -import { UnitSpriteLoader } from '../../src/client/render/unit-sprites.ts' import { createAndCastSkillInArena, evaluateSkillCurve113c } from '../helpers/skill-test-harness.ts' describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => { @@ -490,34 +489,4 @@ describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => { expect(debugState.castCount).toBeGreaterThanOrEqual(1) } }) - - it('R1.4: decodes all 7 player classes (am, so, ne, pa, ba, dz, ai), missile .dcc, overlay .dcc, and summon sprites with zero skipped frames', async () => { - const mounted = await getSharedMountedArchives() - const loader = new UnitSpriteLoader(mounted) - - for (const cls of ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']) { - const sheet = await loader.loadPlayerClassSheet(cls, 'sc', 'hth') - expect(sheet.directions).toBe(16) - expect(sheet.framesPerDirection).toBeGreaterThanOrEqual(6) - expect(sheet.skipped).toBe(0) - } - - const fireballSprite = await loader.loadMissileSprite('fireball') - expect(fireballSprite.groups.length).toBeGreaterThan(0) - expect(fireballSprite.groups[0]!.frames.length).toBeGreaterThan(0) - - const ampOverlay = await loader.loadOverlaySprite('cursampdamage') - expect(ampOverlay.groups.length).toBeGreaterThan(0) - - const skeletonPet = await loader.loadMonsterOrPetSheet('sk', 'nu', '1hs') - expect(skeletonPet.directions).toBeGreaterThanOrEqual(8) - expect(skeletonPet.skipped).toBe(0) - - const registry = await getSharedDataRegistry() - const arena = new WorldArena({ registry, classCode: 'ass', skillId: 280, slvl: 20 }) - arena.triggerCast() - const dbg = arena.getDebugState() - expect(dbg.charToken).toBe('ai') // Assassin uses 'ai' token per PlrType.txt - expect(dbg.castCount).toBe(1) - }, 20_000) }) diff --git a/tests/firebolt-missile-113c.test.ts b/tests/firebolt-missile-113c.test.ts index bd7966c..b8f2697 100644 --- a/tests/firebolt-missile-113c.test.ts +++ b/tests/firebolt-missile-113c.test.ts @@ -22,7 +22,8 @@ import { tickProjectiles, type Projectile, } from '../src/server/skills/skills.ts' -import { getSkillRange, drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/client/world/act-runtime.ts' +import { getSkillRange } from '../src/common/skills/weapon-combat-meta.ts' +import { drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/client/scene/missile-overlay-renderer.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts' diff --git a/tests/fixtures/demo-profile-data.ts b/tests/fixtures/demo-profile-data.ts new file mode 100644 index 0000000..695eabb --- /dev/null +++ b/tests/fixtures/demo-profile-data.ts @@ -0,0 +1,525 @@ +/** + * Test-only legacy demo data (former single-player Sorceress profile). + * + * These literals used to live in `src/` (`DEFAULT_*_SKILLS`, starter gear/bag, default belt potions) and + * seeded the retired single-player HUD. The online client starts from a neutral server-not-ready state, so the + * data now exists only as fixtures for widget/tooltip/layout tests. + */ + +import type { HotkeySkillEntry } from '../../src/common/skills/skills-meta.ts' +import { BELT_COLS, BELT_ROWS, type BeltModel, type BeltPotion } from '../../src/common/items/belt-model.ts' +import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../../src/common/items/inventory-model.ts' + +export const DEMO_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, +] + +export const DEMO_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ + // Universal Skills + ...DEMO_UNIVERSAL_SKILLS, + // Fire Spells (8 active) + { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, + { skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true }, + { skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, + { skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true }, + { skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true }, + { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true }, + { skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true }, + // Lightning Spells (9 active) + { skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true }, + { skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true }, + { skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true }, + { skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true }, + { skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true }, + { skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true }, + { skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true }, + { skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true }, + { skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true }, + // Cold Spells (9 active) + { skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true }, + { skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true }, + { skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true }, + { skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true }, + { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true }, + { skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true }, + { skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true }, +] + +export const DEMO_EQUIPPED_GEAR: Readonly>> = { + helm: { + id: 'eq-shako', + code: 'uap', + invFile: 'invcap', + invtransform: 'cgrn', + chrtransform: 'cgrn', + name: 'Harlequin Crest', + nameZh: '谐角之冠 (Harlequin Crest)', + baseNameZh: '军帽 (Shako)', + quality: 'unique', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 141, + durability: { current: 12, max: 12 }, + reqLevel: 62, + reqStr: 50, + stats: [ + { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, + { text: '+1.5 生命 (依角色等级而定)', color: 'blue' }, + { text: '+1.5 法力 (依角色等级而定)', color: 'blue' }, + { text: '物理伤害减少 10%', color: 'blue' }, + { text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' }, + { text: '+2 所有属性', color: 'blue' }, + ], + }, + amulet: { + id: 'eq-maras', + code: 'amu', + invFile: 'invamu', + name: "Mara's Kaleidoscope", + nameZh: "马拉的万花筒 (Mara's Kaleidoscope)", + baseNameZh: '项链 (Amulet)', + quality: 'unique', + invWidth: 1, + invHeight: 1, + allowedSlots: ['amulet'], + reqLevel: 67, + stats: [ + { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, + { text: '+5 所有属性', color: 'blue' }, + { text: '所有抗性 +30 (All Resistances +30)', color: 'blue' }, + ], + }, + weapon1: { + id: 'eq-oculus', + code: 'oba', + invFile: 'invob5', + invtransform: 'whit', + chrtransform: 'whit', + name: 'The Oculus', + nameZh: '眼球 (The Oculus)', + baseNameZh: '涡流水晶 (Swirling Crystal)', + quality: 'unique', + invWidth: 1, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '18 - 42', + durability: { current: 50, max: 50 }, + speedText: '法球类 - 普通攻击速度', + reqLevel: 42, + stats: [ + { text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' }, + { text: '+3 法师技能等级', color: 'blue' }, + { text: '+30% 快速施法速度 (FCR)', color: 'blue' }, + { text: '+20% 增强防御', color: 'blue' }, + { text: '+20 体力', color: 'blue' }, + { text: '+20 精力', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+5 每次击杀获得法力', color: 'blue' }, + { text: '50% 更佳机会取得魔法装备', color: 'blue' }, + ], + }, + armor: { + id: 'eq-tal-armor', + code: 'uth', + invFile: 'invgth', + invtransform: 'dpur', + chrtransform: 'dpur', + name: "Tal Rasha's Guardianship", + nameZh: '塔·拉夏的守护', + baseNameZh: '漆甲 (Lacquered Plate)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 941, + durability: { current: 55, max: 55 }, + reqLevel: 71, + reqStr: 84, + stats: [ + { text: '+400 防御', color: 'blue' }, + { text: '抗寒 +40%', color: 'blue' }, + { text: '抗电 +40%', color: 'blue' }, + { text: '抗火 +40%', color: 'blue' }, + { text: '魔法伤害减少 15', color: 'blue' }, + { text: '88% 更佳机会取得魔法装备', color: 'blue' }, + ], + setPieces: [ + '塔·拉夏的守护 (Tal Rasha\'s Guardianship)', + '塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)', + '塔·拉夏的判决 (Tal Rasha\'s Adjudication)', + '塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)', + '塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)', + ], + setBonuses: [ + { text: '+10% 快速再度攻击 (2 件装备)', color: 'green' }, + { text: '+3 法师技能等级 (完整套装)', color: 'green' }, + { text: '所有抗性 +50 (完整套装)', color: 'green' }, + { text: '+150 生命 (完整套装)', color: 'green' }, + ], + }, + weapon2: { + id: 'eq-spirit', + code: 'uit', + invFile: 'invtow', + name: 'Spirit Monarch', + nameZh: '精神 (Spirit) 统盾', + baseNameZh: '统治者大盾 (Monarch)', + quality: 'rune', + runewordRunes: "'TalThulOrtAmn'", + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon2'], + defense: 148, + durability: { current: 148, max: 148 }, + reqLevel: 54, + reqStr: 156, + sockets: 4, + stats: [ + { text: '+2 所有技能 (+2 To All Skills)', color: 'blue' }, + { text: '+35% 快速施法速度 (FCR)', color: 'blue' }, + { text: '+55% 快速再度攻击 (FHR)', color: 'blue' }, + { text: '+250 对远距离攻击的防御', color: 'blue' }, + { text: '+22 体力', color: 'blue' }, + { text: '+112 法力', color: 'blue' }, + { text: '抗寒 +35%', color: 'blue' }, + { text: '抗电 +35%', color: 'blue' }, + { text: '抗毒 +35%', color: 'blue' }, + { text: '+8 魔法吸收', color: 'blue' }, + ], + }, + gloves: { + id: 'eq-magefist', + code: 'tgl', + invFile: 'invtgl', + invtransform: 'lgry', + name: 'Magefist', + nameZh: '法师之拳 (Magefist)', + baseNameZh: '轻型铁手套', + quality: 'unique', + invWidth: 2, + invHeight: 2, + allowedSlots: ['gloves'], + defense: 25, + durability: { current: 14, max: 14 }, + reqLevel: 23, + reqStr: 45, + stats: [ + { text: '+1 火焰技能', color: 'blue' }, + { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, + { text: '法力再生速度提高 25%', color: 'blue' }, + { text: '增加 1-6 火焰伤害', color: 'blue' }, + { text: '+30% 增强防御', color: 'blue' }, + ], + }, + ring1: { + id: 'eq-soj-1', + code: 'rin', + invFile: 'invrin', + name: 'The Stone of Jordan', + nameZh: '乔丹之石 (Stone of Jordan)', + baseNameZh: '戒指 (Ring)', + quality: 'unique', + invWidth: 1, + invHeight: 1, + allowedSlots: ['ring1', 'ring2'], + reqLevel: 29, + stats: [ + { text: '+1 所有技能', color: 'blue' }, + { text: '增加法力上限 25%', color: 'blue' }, + { text: '+20 法力', color: 'blue' }, + { text: '增加 1-12 闪电伤害', color: 'blue' }, + ], + }, + belt: { + id: 'eq-arach', + code: 'ulc', + invFile: 'invvbl', + invtransform: 'blac', + name: 'Arachnid Mesh', + nameZh: '蜘蛛之网 (Arachnid Mesh)', + baseNameZh: '蛛网腰带 (Spiderweb Sash)', + quality: 'unique', + invWidth: 2, + invHeight: 1, + allowedSlots: ['belt'], + defense: 138, + durability: { current: 12, max: 12 }, + reqLevel: 80, + reqStr: 50, + stats: [ + { text: '+1 所有技能', color: 'blue' }, + { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, + { text: '减慢目标速度 10%', color: 'blue' }, + { text: '增加法力上限 5%', color: 'blue' }, + { text: '+120% 增强防御', color: 'blue' }, + ], + }, + ring2: { + id: 'eq-bk-2', + code: 'rin', + invFile: 'invrin', + name: "Bul-Kathos' Wedding Band", + nameZh: '布尔凯索之婚戒', + baseNameZh: '戒指 (Ring)', + quality: 'unique', + invWidth: 1, + invHeight: 1, + allowedSlots: ['ring1', 'ring2'], + reqLevel: 58, + stats: [ + { text: '+1 所有技能', color: 'blue' }, + { text: '+45 生命 (依角色等级而定)', color: 'blue' }, + { text: '5% 每次命中偷取生命', color: 'blue' }, + { text: '+50 最大耐力', color: 'blue' }, + ], + }, + boots: { + id: 'eq-wartrav', + code: 'xtb', + invFile: 'invtbt', + name: 'War Traveler', + nameZh: '战争旅者 (War Traveler)', + baseNameZh: '战场之靴 (Battle Boots)', + quality: 'unique', + invWidth: 2, + invHeight: 2, + allowedSlots: ['boots'], + defense: 139, + durability: { current: 48, max: 48 }, + reqLevel: 42, + reqStr: 95, + stats: [ + { text: '+25% 高速跑步/行走', color: 'blue' }, + { text: '+10 力量', color: 'blue' }, + { text: '+10 体力', color: 'blue' }, + { text: '增加 15-25 伤害', color: 'blue' }, + { text: '+190% 增强防御', color: 'blue' }, + { text: '减慢耐力消耗 40%', color: 'blue' }, + { text: '攻击者受到伤害 10', color: 'blue' }, + { text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' }, + ], + }, +} + +export const DEMO_BAG_ITEMS: readonly GridPlacement[] = [ + { + col: 0, + row: 0, + item: { + id: 'bag-cube', + code: 'box', + invFile: 'invbox', + name: 'Horadric Cube', + nameZh: '赫拉迪克方块 (Horadric Cube)', + baseNameZh: '任务宝盒', + quality: 'unique', + invWidth: 2, + invHeight: 2, + allowedSlots: [], + stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }], + }, + }, + { + col: 2, + row: 0, + item: { + id: 'bag-tbk', + code: 'tbk', + invFile: 'invbbk', + name: 'Tome of Town Portal', + nameZh: '回城之书 (Tome of Town Portal)', + baseNameZh: '法术书 (数量: 20)', + quality: 'normal', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + quantity: 20, + maxQuantity: 20, + stats: [{ text: '右键施放城镇传送门', color: 'white' }], + }, + }, + { + col: 2, + row: 2, + item: { + id: 'bag-ibk', + code: 'ibk', + invFile: 'invrbk', + name: 'Tome of Identify', + nameZh: '鉴定之书 (Tome of Identify)', + baseNameZh: '法术书 (数量: 20)', + quality: 'normal', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + quantity: 20, + maxQuantity: 20, + stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], + }, + }, + { + col: 3, + row: 0, + item: { + id: 'bag-torch', + code: 'cm2', + invFile: 'invtrch', + name: 'Hellfire Torch', + nameZh: '地狱火炬 (Hellfire Torch)', + baseNameZh: '大型护身符 (Large Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + reqLevel: 75, + stats: [ + { text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' }, + { text: '+3 法师技能等级', color: 'blue' }, + { text: '+20 所有属性', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+8 照亮范围', color: 'blue' }, + ], + }, + }, + { + col: 3, + row: 2, + item: { + id: 'bag-anni', + code: 'cm1', + invFile: 'invmss', + name: 'Annihilus', + nameZh: '毁灭 (Annihilus)', + baseNameZh: '小型护身符 (Small Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + reqLevel: 70, + stats: [ + { text: '+1 所有技能 (+1 To All Skills)', color: 'blue' }, + { text: '+20 所有属性', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+10% 获得经验值', color: 'blue' }, + ], + }, + }, + { + col: 4, + row: 0, + item: { + id: 'bag-gheeds', + code: 'cm3', + invFile: 'invch3', + name: "Gheed's Fortune", + nameZh: '基德的运气 (Gheed\'s Fortune)', + baseNameZh: '超大型护身符 (Grand Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 3, + allowedSlots: [], + reqLevel: 62, + stats: [ + { text: '160% 额外金币从怪物身上取得', color: 'blue' }, + { text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' }, + { text: '降低所有商店价格 15%', color: 'blue' }, + ], + }, + }, + { + col: 8, + row: 0, + item: { + id: 'bag-cta', + code: 'crs', + invFile: 'invcrs', + name: 'Call to Arms', + nameZh: '战争召唤 (Call to Arms)', + baseNameZh: '水晶剑 (Crystal Sword)', + quality: 'rune', + runewordRunes: "'AmnRalMalIstOhm'", + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '17 - 52', + durability: { current: 20, max: 20 }, + reqStr: 43, + reqLevel: 57, + sockets: 5, + speedText: '剑类 - 极快的攻击速度', + stats: [ + { text: '+1 所有技能 (+1 To All Skills)', color: 'blue' }, + { text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' }, + { text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' }, + { text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' }, + { text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' }, + { text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' }, + { text: '+6 战斗体制 (Battle Orders)', color: 'blue' }, + { text: '+6 战斗指挥 (Battle Command)', color: 'blue' }, + { text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' }, + { text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' }, + { text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' }, + ], + }, + }, +] + +export const DEMO_BELT_POTIONS: readonly BeltPotion[] = [ + { + id: 'pot-hp-1', + code: 'hp4', + invFile: 'invhp4', + name: 'Greater Healing Potion', + nameZh: '强效治疗药剂', + kind: 'hp', + healHp: 180, + healMana: 0, + }, + { + id: 'pot-hp-2', + code: 'hp5', + invFile: 'invhp5', + name: 'Super Healing Potion', + nameZh: '超级治疗药剂', + kind: 'hp', + healHp: 320, + healMana: 0, + }, + { + id: 'pot-mp-1', + code: 'mp4', + invFile: 'invmp4', + name: 'Greater Mana Potion', + nameZh: '强效法力药剂', + kind: 'mana', + healHp: 0, + healMana: 150, + }, + { + id: 'pot-rv-1', + code: 'rvl', + invFile: 'invvpl', + name: 'Full Rejuvenation Potion', + nameZh: '全面回复活力药剂', + kind: 'rejuv', + healHp: 400, + healMana: 400, + }, +] + +/** Fill every 4x4 belt slot with the demo potion of its column. */ +export function seedDemoBeltPotions(belt: T): T { + for (let row = 0; row < BELT_ROWS; row++) { + for (let col = 0; col < BELT_COLS; col++) { + const template = DEMO_BELT_POTIONS[col]! + belt.grid[row]![col] = { ...template, id: `${template.id}-r${row}-c${col}` } + } + } + return belt +} diff --git a/tests/fixtures/hud-demo-state.ts b/tests/fixtures/hud-demo-state.ts index 1559586..9714429 100644 --- a/tests/fixtures/hud-demo-state.ts +++ b/tests/fixtures/hud-demo-state.ts @@ -9,16 +9,12 @@ import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts' import type { HudManager } from '../../src/client/ui/hud-manager.ts' -import { DEFAULT_SORCERESS_SKILLS, SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts' -import { - InventoryPanel, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, - type EquipSlotId, -} from '../../src/client/ui/inventory.ts' +import { SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts' +import { InventoryPanel, type EquipSlotId } from '../../src/client/ui/inventory.ts' import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts' import { ACT_WAYPOINTS, ALL_QUESTS, WorldPanelsHud } from '../../src/client/ui/world-panels.ts' import type { BeltModel } from '../../src/common/items/belt-model.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR, DEMO_SORCERESS_SKILLS, seedDemoBeltPotions } from './demo-profile-data.ts' /** Former demo Sorceress profile (level 85). */ export const DEMO_LEVEL = 85 @@ -57,7 +53,7 @@ export const DEMO_COMPLETED_QUEST_IDS: ReadonlySet = new Set(['a1q1', 'a /** Seed Sorceress quickbar: all default skills, Fire Ball (47) left, Frozen Orb (64) right, F1..F8 bindings. */ export function seedDemoHotkeys(hotkeys: SkillHotkeysHud): SkillHotkeysHud { - hotkeys.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hotkeys.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) hotkeys.leftSkillId = 47 hotkeys.rightSkillId = 64 hotkeys.bindings.clear() @@ -108,10 +104,10 @@ export function seedDemoCharSheet(sheet: CharacterSheetPanel): CharacterSheetPan /** Seed starter gear + bag items (deep-cloned), 850k gold and the level-85 Sorceress requirement context. */ export function seedDemoInventory(inv: InventoryPanel): InventoryPanel { for (const k of Object.keys(inv.equipped) as EquipSlotId[]) delete inv.equipped[k] - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR) as [EquipSlotId, NonNullable<(typeof STARTER_EQUIPPED_GEAR)[EquipSlotId]>][]) { + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR) as [EquipSlotId, NonNullable<(typeof DEMO_EQUIPPED_GEAR)[EquipSlotId]>][]) { inv.equipped[slot] = { ...item, stats: [...item.stats] } } - inv.gridItems = STARTER_BAG_ITEMS.map(p => ({ ...p, item: { ...p.item, stats: [...p.item.stats] } })) + inv.gridItems = DEMO_BAG_ITEMS.map(p => ({ ...p, item: { ...p.item, stats: [...p.item.stats] } })) inv.gold = 850_000 inv.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' }) return inv @@ -129,7 +125,7 @@ export function seedDemoWorldPanels(wp: WorldPanelsHud): WorldPanelsHud { /** Seed the 4x4 belt with the legacy default potions. */ export function seedDemoBelt(belt: T): T { - belt.seedDefaultPotions() + seedDemoBeltPotions(belt) return belt } diff --git a/tests/full-spectrum-drop-parity.test.ts b/tests/full-spectrum-drop-parity.test.ts index 25e063f..07a2308 100644 --- a/tests/full-spectrum-drop-parity.test.ts +++ b/tests/full-spectrum-drop-parity.test.ts @@ -799,12 +799,5 @@ describe('Issue #428: Full-Spectrum Statistical Drop Verification', () => { expect(embeddedSrc).not.toMatch(/monsterKinds\s*:\s*new\s+Map\s*\(\s*\)/) expect(embeddedSrc).not.toMatch(/superUniques\s*:\s*new\s+Map\s*\(\s*\)/) }) - - it('src/scene/act-scene.ts fails fast on missing flippy sprites and never uses a procedural colored box fallback for ground items', () => { - const sceneSrc = readFileSync(resolve(process.cwd(), 'src/client/world/act-runtime.ts'), 'utf8') - expect(sceneSrc).toContain('resolveGroundItemSpriteRect') - expect(sceneSrc).toContain('GroundItem render failure: missing flippy sprite rect') - expect(sceneSrc).not.toContain('Fallback procedural isometric ground item') - }) }) }) diff --git a/tests/glacial-spike-quickbar.test.ts b/tests/glacial-spike-quickbar.test.ts index c88fa13..ee5fa05 100644 --- a/tests/glacial-spike-quickbar.test.ts +++ b/tests/glacial-spike-quickbar.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { SkillHotkeysHud, DEFAULT_SORCERESS_SKILLS, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' -import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts' import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts' -import { createDemoHotkeys } from './fixtures/hud-demo-state.ts' +import { createDemoHotkeys, seedDemoHotkeys, seedDemoSkillTree } from './fixtures/hud-demo-state.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => { let hud: SkillHotkeysHud @@ -14,8 +14,8 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed }) describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => { - it('has Glacial Spike (55) in DEFAULT_SORCERESS_SKILLS with level 10 and authentic mana', () => { - const entry = DEFAULT_SORCERESS_SKILLS.find(s => s.skillId === 55) + it('has Glacial Spike (55) in DEMO_SORCERESS_SKILLS with level 10 and authentic mana', () => { + const entry = DEMO_SORCERESS_SKILLS.find(s => s.skillId === 55) expect(entry).toBeDefined() expect(entry!.name).toBe('Glacial Spike') expect(entry!.nameZh).toBe('冰尖柱') @@ -69,40 +69,6 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed }) }) - describe('2. All 7 Classes Starter Profiles include all Active Skills', () => { - it('provides all 26 active skills on Sorceress right-click speedbar', () => { - const sorProfile = createStarterProfileForClass('sor') - const rightSkills = sorProfile.availableSkills.filter(s => s.rightUsable) - // 3 universal (Attack 0, Throw 2, Unsummon 3) + 26 Sorceress active = 29 - expect(rightSkills.length).toBe(29) - - // Glacial Spike is explicitly present - expect(rightSkills.some(s => s.skillId === 55)).toBe(true) - }) - - it('provides all active skills for all 7 classes without passives', () => { - const classes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const - const expectedActiveCounts: Record = { - ama: 27, // 3 universal + 24 active - sor: 29, // 3 universal + 26 active - nec: 30, // 3 universal + 27 active - pal: 33, // 3 universal + 30 active - bar: 23, // 3 universal + 20 active - dru: 32, // 3 universal + 29 active - ass: 31, // 3 universal + 28 active - } - - for (const cls of classes) { - const skills = CLASS_DEFAULT_SKILLS[cls] - expect(skills.length).toBe(expectedActiveCounts[cls]) - // None should be passive - for (const s of skills) { - expect(s.isPassive).toBeFalsy() - } - } - }) - }) - describe('3. HudManager Skill Tree Allocation dynamically updates Available Skills', () => { const mockCanvas = { width: 800, @@ -118,15 +84,13 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) - hud.switchClass('sor') + // Server-assigned Sorceress with the legacy demo quickbar/skill tree seeded explicitly. + hud.setCharacterClass('sor') + seedDemoHotkeys(hud.hotkeys) + seedDemoSkillTree(hud.skillTree) return hud } - it('initializes HudManager with Glacial Spike available in Sorceress profile', () => { - const hudManager = createMockHud() - expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true) - }) - it('requests skill allocation through the command sink without local mutation', () => { const hudManager = createMockHud() // Open Skill tree panel diff --git a/tests/ground-drop-parity-issues.test.ts b/tests/ground-drop-parity-issues.test.ts index 52899cb..961fa3e 100644 --- a/tests/ground-drop-parity-issues.test.ts +++ b/tests/ground-drop-parity-issues.test.ts @@ -13,11 +13,6 @@ import { D2Rng } from '../src/common/rng/d2-rng.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/common/data/demo-data.ts' import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts' import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts' -import { - formatGroundItemLabelText, - getGroundItemQualityColor, - GROUND_LABEL_QUALITY_COLORS, -} from '../src/client/ui/ground-labels.ts' import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_CELL_WIDTH } from '../src/common/world/d2map.ts' import type { Monster } from '../src/server/engine/combat.ts' @@ -133,68 +128,6 @@ describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacki }) }) - describe('2. Monster Drop vs. Inventory/Body Drop Metadata & Bilingual Name Parity', () => { - it('produces identical bilingual nameZh, quality, label color, sound, and sprite rect for monster-dropped vs inventory-dropped items', () => { - const testCases = [ - { code: 'hp2', level: 5, mf: 0 }, // Light Healing Potion - { code: 'mp4', level: 20, mf: 0 }, // Greater Mana Potion - { code: 'rvl', level: 30, mf: 0 }, // Full Rejuvenation Potion - { code: 'hax', level: 5, mf: 0 }, // Hand Axe - { code: 'cap', level: 5, mf: 0 }, // Cap - { code: 'r01', level: 15, mf: 0 }, // El Rune - { code: 'r31', level: 85, mf: 0 }, // Jah Rune - ] - - for (const tc of testCases) { - const engine = createEngine() - const rng = new D2Rng(999) - const base = dropTables.getBase(tc.code) - expect(base).toBeDefined() - const rawDroppedItem = createDroppedItem(base!, 'normal', { - ilvl: tc.level, - dwInitSeed: 999, - itemRng: rng, - itemTypes: dropTables.itemTypes, - }) - expect(rawDroppedItem).not.toBeNull() - - // Path A: Dropped directly by a monster onto the ground (raw Item) - const monsterGroundEntity = engine.groundItems.add(rawDroppedItem, 5, 5, 400, 300, { bounce: true, now: 1000 }) - - // Path B: Picked up into player UI inventory (converted via itemToUiInventoryItem) and dropped from body/inventory - const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables) - const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300) - - // 1. Authentic Chinese nameZh parity (both must match and NEVER be pure English on monster drop) - expect(monsterGroundEntity.name).toBe(playerGroundEntity.name) - expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh) - expect(monsterGroundEntity.nameZh).toMatch(/[\u4e00-\u9fff]/) - expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name) - expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity)) - - // 2. Quality & ground label color parity (especially Runes -> 'rune' -> #ff9c18 orange) - expect(monsterGroundEntity.quality).toBe(playerGroundEntity.quality) - expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(getGroundItemQualityColor(playerGroundEntity)) - if (tc.code.startsWith('r0') || tc.code.startsWith('r3')) { - expect(monsterGroundEntity.quality).toBe('rune') - expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.rune) - } - - // 3. Audio & dimensions parity - expect(monsterGroundEntity.invWidth).toBe(playerGroundEntity.invWidth) - expect(monsterGroundEntity.invHeight).toBe(playerGroundEntity.invHeight) - expect(monsterGroundEntity.dropSound).toBe(playerGroundEntity.dropSound) - expect(monsterGroundEntity.dropSfxFrame).toBe(playerGroundEntity.dropSfxFrame) - - // 4. Ground flippy sprite rect parity - const monsterRect = resolveGroundItemSpriteRect(monsterGroundEntity.item) - const playerRect = resolveGroundItemSpriteRect(playerGroundEntity.item) - expect(monsterRect).not.toBeNull() - expect(monsterRect).toEqual(playerRect) - } - }) - }) - describe('3. Player Body/Inventory Dropped Items Anti-Stacking (Potions & Equipment)', () => { it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => { const engine = createEngine() diff --git a/tests/ground-items-alt-labels.test.ts b/tests/ground-items-alt-labels.test.ts deleted file mode 100644 index 0a03a0d..0000000 --- a/tests/ground-items-alt-labels.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -/** - * Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390). - */ - -import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' -import type { GroundItemEntity } from '../src/common/items/ground-items.ts' -import { - GROUND_LABEL_QUALITY_COLORS, - getGroundItemQualityColor, - formatGroundItemLabelText, - estimateLabelWidth, - doLabelsOverlap, - resolveLadderCollisions, - computeInitialGroundLabelLayouts, - GroundLabelOverlay, - type GroundLabelLayout, -} from '../src/client/ui/ground-labels.ts' - -function createMockItem(partial: Partial = {}): GroundItemEntity { - return { - id: partial.id ?? 'item-1', - item: partial.item ?? {}, - name: partial.name ?? 'Short Sword', - nameZh: partial.nameZh ?? '短剑', - quality: partial.quality ?? 'normal', - isGold: partial.isGold ?? false, - amount: partial.amount ?? 1, - invWidth: partial.invWidth ?? 1, - invHeight: partial.invHeight ?? 2, - dropTime: 1000, - x: partial.x ?? 400, - y: partial.y ?? 300, - cellX: 20, - cellY: 15, - sparklePhase: 0, - ethereal: partial.ethereal, - sockets: partial.sockets, - } -} - -describe('Ground Items Alt Labels (Issue #390)', () => { - describe('Quality Colors & Text Formatting', () => { - it('formats gold amounts accurately as authentic Chinese localized text', () => { - const gold1 = createMockItem({ isGold: true, amount: 150 }) - expect(formatGroundItemLabelText(gold1)).toBe('150 金币') - - const gold2 = createMockItem({ isGold: true, amount: 25000 }) - expect(formatGroundItemLabelText(gold2)).toBe('25000 金币') - }) - - it('formats item display names preferring nameZh with fallback to name', () => { - const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' }) - expect(formatGroundItemLabelText(item1)).toBe('短剑') - - const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' }) - expect(formatGroundItemLabelText(item2)).toBe('Grand Charm') - }) - - it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => { - expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.normal, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.low, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.superior, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.magic, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.rare, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.set, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.unique, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.craft, - ) - expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe( - GROUND_LABEL_QUALITY_COLORS.rune, - ) - expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe( - GROUND_LABEL_QUALITY_COLORS.gold, - ) - }) - - it('colors ethereal or socketed normal and superior items gray (#808080) while preserving magic/rare/set/unique/runeword colors (Issue #424)', () => { - // Ethereal normal -> gray (#808080) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: true })), - ).toBe('#808080') - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { ethereal: true } })), - ).toBe('#808080') - - // Socketed normal -> gray (#808080) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 3 })), - ).toBe('#808080') - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { sockets: 4 } })), - ).toBe('#808080') - - // Socketed or ethereal superior / high -> gray (#808080) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'superior', sockets: 2 })), - ).toBe('#808080') - expect( - getGroundItemQualityColor(createMockItem({ quality: 'high', ethereal: true })), - ).toBe('#808080') - - // Non-ethereal 0-socket normal/superior remains normal/superior white - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: false, sockets: 0 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.normal) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'superior', ethereal: false, sockets: 0 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.superior) - - // Completed runewords keep unique gold (#908858) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 4, item: { isRuneword: true, sockets: 4 } })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - - // Magic, rare, set, unique keep their quality colors even when ethereal or socketed - expect( - getGroundItemQualityColor(createMockItem({ quality: 'magic', ethereal: true, sockets: 2 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.magic) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'rare', ethereal: true, sockets: 2 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.rare) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'set', sockets: 3 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.set) - expect( - getGroundItemQualityColor(createMockItem({ quality: 'unique', ethereal: true, sockets: 1 })), - ).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - }) - }) - - describe('Label Width Estimation', () => { - it('correctly accounts for both ASCII and CJK wide characters', () => { - const asciiW = estimateLabelWidth('OK', 13) - const cjkW = estimateLabelWidth('戒指', 13) - // CJK characters have greater pixel width than Latin characters for same character count - expect(cjkW).toBeGreaterThan(asciiW) - expect(asciiW).toBeGreaterThan(20) - }) - }) - - describe('Overlap Detection & Layout Calculation', () => { - it('projects ground items to initial layouts with natural Y offset', () => { - const items = [ - createMockItem({ id: 'i1', x: 100, y: 200 }), - createMockItem({ id: 'i2', x: 300, y: 400 }), - ] - const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 }) - const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18) - - expect(layouts.length).toBe(2) - expect(layouts[0]!.screenX).toBe(200) - expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12 - expect(layouts[0]!.screenY).toBe(388) - expect(layouts[1]!.screenX).toBe(600) - expect(layouts[1]!.naturalY).toBe(788) - }) - - it('filters out items outside viewport when project returns null', () => { - const items = [createMockItem({ id: 'i1' })] - const layouts = computeInitialGroundLabelLayouts(items, () => null) - expect(layouts.length).toBe(0) - }) - - it('accurately identifies overlapping and non-overlapping label rectangles', () => { - const l1: GroundLabelLayout = { - id: '1', - itemId: '1', - item: createMockItem(), - text: 'A', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 100, - screenY: 100, - naturalY: 100, - } - const l2Overlapping: GroundLabelLayout = { - id: '2', - itemId: '2', - item: createMockItem(), - text: 'B', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 110, - screenY: 105, - naturalY: 105, - } - const l3Distant: GroundLabelLayout = { - id: '3', - itemId: '3', - item: createMockItem(), - text: 'C', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 300, - screenY: 100, - naturalY: 100, - } - - expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true) - expect(doLabelsOverlap(l1, l3Distant)).toBe(false) - }) - }) - - describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => { - it('returns empty array or single label unchanged', () => { - expect(resolveLadderCollisions([])).toEqual([]) - const single: GroundLabelLayout[] = [ - { - id: '1', - itemId: '1', - item: createMockItem(), - text: 'A', - color: '#fff', - borderColor: '#fff', - width: 50, - height: 18, - screenX: 100, - screenY: 100, - naturalY: 100, - }, - ] - expect(resolveLadderCollisions(single)).toEqual(single) - }) - - it('retains natural positions when labels do not overlap horizontally', () => { - const nonOverlapping: GroundLabelLayout[] = [ - { - id: '1', - itemId: '1', - item: createMockItem(), - text: 'Left Item', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 100, - screenY: 200, - naturalY: 200, - }, - { - id: '2', - itemId: '2', - item: createMockItem(), - text: 'Right Item', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 300, - screenY: 200, - naturalY: 200, - }, - ] - const resolved = resolveLadderCollisions(nonOverlapping) - expect(resolved[0]!.screenY).toBe(200) - expect(resolved[1]!.screenY).toBe(200) - }) - - it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => { - const overlapping: GroundLabelLayout[] = [ - { - id: '1', - itemId: '1', - item: createMockItem(), - text: 'Item A', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 100, - screenY: 200, - naturalY: 200, - }, - { - id: '2', - itemId: '2', - item: createMockItem(), - text: 'Item B', - color: '#fff', - borderColor: '#fff', - width: 60, - height: 18, - screenX: 105, - screenY: 202, - naturalY: 202, - }, - ] - - const resolved = resolveLadderCollisions(overlapping, 4, 2) - expect(resolved.length).toBe(2) - - // Zero overlap verification - expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false) - - // Vertical distance between centers must be at least height + padY = 20px - const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY) - expect(distY).toBeGreaterThanOrEqual(20) - }) - - it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => { - const cluster: GroundLabelLayout[] = [] - for (let i = 0; i < 5; i += 1) { - cluster.push({ - id: `item-${i}`, - itemId: `item-${i}`, - item: createMockItem({ id: `item-${i}` }), - text: `Drop ${i}`, - color: '#fff', - borderColor: '#fff', - width: 70, - height: 18, - screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally - screenY: 300, - naturalY: 300, - }) - } - - const resolved = resolveLadderCollisions(cluster, 4, 2) - expect(resolved.length).toBe(5) - - // Assert pairwise zero-overlap across all 5 labels - for (let i = 0; i < resolved.length; i += 1) { - for (let j = i + 1; j < resolved.length; j += 1) { - expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false) - } - } - - // Check ladder sorting: positions strictly decreasing (stacking upward) - const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY) - for (let k = 0; k < sortedByY.length - 1; k += 1) { - expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20) - } - }) - - it('handles multiple independent clusters simultaneously', () => { - const items: GroundLabelLayout[] = [ - // Cluster 1 around X=100 - { - id: 'c1-1', - itemId: 'c1-1', - item: createMockItem(), - text: 'Loot A', - color: '#fff', - borderColor: '#fff', - width: 50, - height: 18, - screenX: 100, - screenY: 200, - naturalY: 200, - }, - { - id: 'c1-2', - itemId: 'c1-2', - item: createMockItem(), - text: 'Loot B', - color: '#fff', - borderColor: '#fff', - width: 50, - height: 18, - screenX: 102, - screenY: 200, - naturalY: 200, - }, - // Cluster 2 around X=500 - { - id: 'c2-1', - itemId: 'c2-1', - item: createMockItem(), - text: 'Loot C', - color: '#fff', - borderColor: '#fff', - width: 50, - height: 18, - screenX: 500, - screenY: 200, - naturalY: 200, - }, - { - id: 'c2-2', - itemId: 'c2-2', - item: createMockItem(), - text: 'Loot D', - color: '#fff', - borderColor: '#fff', - width: 50, - height: 18, - screenX: 503, - screenY: 200, - naturalY: 200, - }, - ] - - const resolved = resolveLadderCollisions(items) - // Both clusters have their own pairwise overlap resolved - expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false) - expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false) - // Cluster 1 and Cluster 2 remain at their distinct X - expect(resolved[0]!.screenX).toBeLessThan(200) - expect(resolved[2]!.screenX).toBeGreaterThan(400) - }) - }) - - describe('GroundLabelOverlay DOM Integration', () => { - function createMockDomElement(tag = 'div'): any { - const listeners: Record void)[]> = {} - const children: any[] = [] - const el: any = { - tagName: tag.toUpperCase(), - className: '', - textContent: '', - style: {}, - dataset: {}, - hidden: false, - children, - appendChild(child: any) { - children.push(child) - return child - }, - addEventListener(type: string, fn: any) { - listeners[type] = listeners[type] ?? [] - listeners[type].push(fn) - }, - dispatchEvent(event: any) { - for (const fn of listeners[event.type] ?? []) { - fn(event) - } - }, - } - return el - } - - const origDocument = (globalThis as any).document - beforeAll(() => { - ;(globalThis as any).document = { - createElement: (tag: string) => createMockDomElement(tag), - } - }) - afterAll(() => { - ;(globalThis as any).document = origDocument - }) - - it('hides all pooled label elements when visible is false or layouts empty', () => { - const container = createMockDomElement('div') - const overlay = new GroundLabelOverlay(container) - - const layout: GroundLabelLayout = { - id: '1', - itemId: '1', - item: createMockItem(), - text: 'Short Sword', - color: '#d8d8d8', - borderColor: '#d8d8d8', - width: 60, - height: 18, - screenX: 100, - screenY: 100, - naturalY: 100, - } - - overlay.update(true, [layout]) - expect(container.children.length).toBe(1) - expect(container.children[0].style.display).toBe('block') - - overlay.update(false, []) - expect(container.children[0].style.display).toBe('none') - }) - - it('renders labels with authentic quality colors and triggers pickup callback on click', () => { - const container = createMockDomElement('div') - const overlay = new GroundLabelOverlay(container) - const onPickup = vi.fn() - overlay.setOnPickupCallback(onPickup) - - const uniqueItem = createMockItem({ - id: 'soj', - name: 'The Stone of Jordan', - nameZh: '乔丹之石', - quality: 'unique', - }) - - const layout: GroundLabelLayout = { - id: 'soj', - itemId: 'soj', - item: uniqueItem, - text: '乔丹之石', - color: GROUND_LABEL_QUALITY_COLORS.unique!, - borderColor: GROUND_LABEL_QUALITY_COLORS.unique!, - width: 80, - height: 18, - screenX: 250, - screenY: 150, - naturalY: 150, - } - - overlay.update(true, [layout]) - const el = container.children[0] - expect(el.textContent).toBe('乔丹之石') - expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - expect(el.style.left).toBe('250px') - expect(el.style.top).toBe('150px') - - // Simulate pointerdown - el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() }) - expect(onPickup).toHaveBeenCalledWith(uniqueItem) - }) - - it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => { - const container = createMockDomElement('div') - const overlay = new GroundLabelOverlay(container) - const onHover = vi.fn() - overlay.setOnHoverCallback(onHover) - - const magicItem = createMockItem({ - id: 'ring-1', - name: 'Ring of the Apprentice', - nameZh: '学徒之戒', - quality: 'magic', - }) - - const layout: GroundLabelLayout = { - id: 'ring-1', - itemId: 'ring-1', - item: magicItem, - text: '学徒之戒', - color: GROUND_LABEL_QUALITY_COLORS.magic!, - borderColor: GROUND_LABEL_QUALITY_COLORS.magic!, - width: 70, - height: 18, - screenX: 200, - screenY: 100, - naturalY: 100, - } - - overlay.update(true, [layout]) - const el = container.children[0] - - // Mouseenter - el.dispatchEvent({ type: 'mouseenter' }) - expect(onHover).toHaveBeenCalledWith(magicItem) - expect(el.style.borderColor).toBe('#ffffff') - - // Mouseleave - el.dispatchEvent({ type: 'mouseleave' }) - expect(onHover).toHaveBeenCalledWith(null) - }) - }) -}) diff --git a/tests/ground-items-flippy-parity.test.ts b/tests/ground-items-flippy-parity.test.ts index d7ab832..ee177fe 100644 --- a/tests/ground-items-flippy-parity.test.ts +++ b/tests/ground-items-flippy-parity.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest' import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { drawGroundItem } from '../src/client/world/act-runtime.ts' import type { GroundItemEntity } from '../src/common/items/ground-items.ts' import type { SpriteRenderer } from '../src/client/render/renderer.ts' import { GameEngine } from '../src/server/engine/engine.ts' @@ -90,107 +89,6 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => { }) }) - describe('2. Ground Item Rendering with Atlas Parity', () => { - it('renders authentic Blizzard flippy sprite with 1:1 isometric dimensions and ground shadow', () => { - const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any - const groundSword: GroundItemEntity = { - id: 'ground_sword_test', - item: { name: 'Short Sword', code: 'ssd' }, - name: 'Short Sword', - nameZh: '短剑', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, groundSword, 1000, mockAtlas) - - // Verified: Exactly 1 sprite drawn via renderer.draw using flpssd rect - expect(drawnSprites.length).toBe(1) - const sprite = drawnSprites[0]! - expect(sprite.options.atlas).toBe(mockAtlas) - expect(sprite.options.width).toBe(27) - expect(sprite.options.height).toBe(9) - expect(sprite.x).toBe(Math.round(300 - 27 / 2)) - expect(sprite.y).toBe(250 - 9) - - // Ground contact shadow is drawn at item.y - 2 - expect(drawnQuads.length).toBe(1) - const shadow = drawnQuads[0]! - expect(shadow.y).toBe(250 - 2) - expect(shadow.color[3]).toBeLessThanOrEqual(0.4) // Subtle shadow alpha - - // Zero blue bounding boxes - const blueBox = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10) - expect(blueBox).toBeUndefined() - - // Zero white sparkle star quads - const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) - expect(whiteGlint).toBeUndefined() - }) - - it('renders authentic flpgld sprite for dropped gold when atlas is present', () => { - const drawnQuads: any[] = [] - const drawnSprites: any[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: any) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any - const groundGold: GroundItemEntity = { - id: 'gold_huge_test', - item: { isGold: true }, - name: '5000 Gold', - nameZh: '5000 金币', - quality: 'normal', - isGold: true, - amount: 5000, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 400, - y: 350, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, groundGold, 1000, mockAtlas) - - expect(drawnSprites.length).toBe(1) - const goldSprite = drawnSprites[0]! - // Tier 2 large pile (500+): 32x20 - expect(goldSprite.options.width).toBe(32) - expect(goldSprite.options.height).toBe(20) - expect(goldSprite.x).toBe(Math.round(400 - 32 / 2)) - expect(goldSprite.y).toBe(350 - 20) - }) - }) - describe('3. Clean Pickup Lifecycle (Zero Remaining Dots / Artifacts)', () => { it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => { const engine = new GameEngine( @@ -272,45 +170,6 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => { expect(sr!.h).toBeGreaterThan(0) } }) - - it('never draws procedural colored boxes even when itemsAtlas is undefined', () => { - const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const groundSword: GroundItemEntity = { - id: 'no_atlas_sword', - item: { name: 'Short Sword', code: 'ssd' }, - name: 'Short Sword', - nameZh: '短剑', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, groundSword, 1000, undefined) - - expect(drawnSprites.length).toBe(1) - expect(drawnQuads.length).toBe(1) // Only the drop shadow - expect(drawnQuads[0]!.color[0]).toBe(0) - expect(drawnQuads[0]!.color[1]).toBe(0) - expect(drawnQuads[0]!.color[2]).toBe(0) - }) }) }) diff --git a/tests/ground-items-full-bounce.test.ts b/tests/ground-items-full-bounce.test.ts index e9050ac..97b2324 100644 --- a/tests/ground-items-full-bounce.test.ts +++ b/tests/ground-items-full-bounce.test.ts @@ -24,7 +24,6 @@ import { GroundItemManager, type GroundItemEntity, } from '../src/common/items/ground-items.ts' -import { SceneMouseController, drawGroundItem } from '../src/client/world/act-runtime.ts' import type { SpriteRenderer } from '../src/client/render/renderer.ts' function createSmallEngine() { @@ -156,162 +155,4 @@ describe('Issue #392 — Inventory Full Flippy Bounce & Feedback Ground Truth', expect(item.bounceState).toBeNull() }) }) - - describe('3. Airborne Rendering & Grounded Shadow Anchoring', () => { - const createMockRenderer = () => { - const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - return { renderer, drawnQuads, drawnSprites } - } - - it('anchors drop shadow on ground plane (item.y - 2) while flippy sprite rises to baseY', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const item: GroundItemEntity = { - id: 'flippy_helm', - item: { code: 'ghm', name: 'Great Helm' }, - name: 'Great Helm', - nameZh: '高级头盔', - quality: 'rare', - isGold: false, - amount: 1, - invWidth: 2, - invHeight: 2, - dropTime: 0, - x: 300, - y: 400, - cellX: 0, - cellY: 0, - sparklePhase: 0, - bounceState: { - startTime: 1000, - durationMs: 350, - peakHeightPx: 28, - phase: 'primary', - }, - } - - // Render at apex (t = 1175, bounceH = 28px) - drawGroundItem(renderer, item, 1175) - - const shadow = drawnQuads[0]! - const sprite = drawnSprites[0]! - - // Shadow y remains anchored at ground plane item.y - 2 = 398 - expect(shadow.y).toBe(item.y - 2) - - // Sprite y rises by bounceH (28px): baseY - baseH = (400 - 28) - sprite.options.height - expect(sprite.y).toBe(item.y - 28 - sprite.options.height) - }) - - it('attenuates ground shadow alpha proportionally to airborne bounce height', () => { - const { renderer: rGround, drawnQuads: qGround } = createMockRenderer() - const { renderer: rAir, drawnQuads: qAir } = createMockRenderer() - - const baseItem: GroundItemEntity = { - id: 'tumble_item', - item: { code: 'qui', name: 'Quilted Armor' }, - name: 'Armor', - nameZh: '盔甲', - quality: 'normal', - isGold: false, - amount: 1, - invWidth: 2, - invHeight: 3, - dropTime: 0, - x: 200, - y: 200, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - // Resting item - drawGroundItem(rGround, baseItem, 1000) - const restShadow = qGround[0]! - expect(restShadow.color[3]).toBeCloseTo(0.38, 2) - - // Airborne bouncing item at apex (t = 1175, bounceH = 28px) - const airborneItem: GroundItemEntity = { - ...baseItem, - bounceState: { - startTime: 1000, - durationMs: 350, - peakHeightPx: 28, - phase: 'primary', - }, - } - - drawGroundItem(rAir, airborneItem, 1175) - const airShadow = qAir[0]! - expect(airShadow.color[3]).toBeLessThan(restShadow.color[3]) - expect(airShadow.color[3]).toBeGreaterThanOrEqual(0.1) - }) - }) - - describe('4. Audio-Visual Feedback & Notification Integration', () => { - let mockStatus: { textContent: string } - let mockCanvas: any - - beforeEach(() => { - mockStatus = { textContent: '' } - mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - }) - - it('triggers 包裹已满 notification and audio refusal when picking up with full inventory', () => { - const engine = createSmallEngine() - // Fill bag completely - engine.bag.add({ id: 'f1', name: 'F1', invWidth: 1, invHeight: 2 } as any) - engine.bag.add({ id: 'f2', name: 'F2', invWidth: 1, invHeight: 2 } as any) - - const dropped = engine.dropItem( - { id: 'full-shield', name: 'Large Shield', nameZh: '大盾牌', invWidth: 2, invHeight: 2 }, - 515, - 500, - ) - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { - takeLeftClick: () => null, - takeRightClick: () => null, - shiftHeld: false, - movement: () => ({ x: 0, y: 0 }), - } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager: null, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback') - const notificationSpy = vi.spyOn(controller, 'showNotification') - - // Execute pickup on full bag - controller.pickupGroundItem(dropped) - - expect(mockStatus.textContent).toBe('包裹已满。') - expect(feedbackSpy).toHaveBeenCalled() - expect(notificationSpy).toHaveBeenCalledWith('包裹已满。') - expect(dropped.bounceState).toBeDefined() - expect(dropped.bounceState!.phase).toBe('primary') - expect(engine.metrics.inventoryRefusals).toBe(1) - }) - }) }) diff --git a/tests/ground-items-pickup.test.ts b/tests/ground-items-pickup.test.ts index 662df09..5fab30f 100644 --- a/tests/ground-items-pickup.test.ts +++ b/tests/ground-items-pickup.test.ts @@ -5,8 +5,8 @@ import { describe, it, expect, vi } from 'vitest' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' -import { SceneMouseController } from '../src/client/world/act-runtime.ts' import type { GroundItemEntity } from '../src/common/items/ground-items.ts' +import { seedDemoBeltPotions } from './fixtures/demo-profile-data.ts' function createTestEngine() { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } @@ -32,7 +32,7 @@ function createTestEngine() { npcDefs: [], }) // Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt. - engine.belt.seedDefaultPotions() + seedDemoBeltPotions(engine.belt) return engine } @@ -110,149 +110,4 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => { expect(engine.metrics.inventoryRefusals).toBe(1) }) }) - - describe('SceneMouseController Click & Pathfinding Integration', () => { - function createMockController(engine: GameEngine) { - const mockCanvas: any = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - const mockStatus: any = { textContent: '' } - const mockInput: any = { - takeLeftClick: () => null, - takeRightClick: () => null, - shiftHeld: false, - movement: () => ({ x: 0, y: 0 }), - } - const mockRuntime: any = { - grid: { cellsX: 50, cellsY: 50 }, - waypoints: [], - stashes: [], - } - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: mockInput, - getRuntime: () => mockRuntime, - hudManager: null, - waypointNetwork: null as any, - status: mockStatus, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - return { controller, status: mockStatus } - } - - it('immediately picks up ground item within authentic 48px range', () => { - const engine = createTestEngine() - engine.world.player.x = 500 - engine.world.player.y = 500 - - const entity = engine.dropItem( - { id: 'near-potion', name: 'Potion', nameZh: '治疗药剂', invWidth: 1, invHeight: 1 }, - 520, // distance = 20px <= 48px - 500, - ) - - const { controller, status } = createMockController(engine) - controller.pickupGroundItem(entity) - - expect(engine.groundItems.count).toBe(0) - expect(engine.bag.contents.length).toBe(1) - expect(status.textContent).toBe('拾起物品:治疗药剂') - expect(controller.navTarget).toBeNull() - expect(controller.pendingInteraction).toBeNull() - }) - - it('sets pathfinding navTarget when ground item is farther than 48px', () => { - const engine = createTestEngine() - engine.world.player.x = 500 - engine.world.player.y = 500 - - const entity = engine.dropItem( - { id: 'far-sword', name: 'Broad Sword', nameZh: '阔剑', invWidth: 1, invHeight: 3 }, - 650, // distance = 150px > 48px - 500, - ) - - const { controller } = createMockController(engine) - controller.pickupGroundItem(entity) - - // Item should still be on ground, player begins navigating - expect(engine.groundItems.count).toBe(1) - expect(controller.navTarget).toEqual({ x: 650, y: 500 }) - expect(controller.pendingInteraction).toEqual({ kind: 'ground-item', item: entity }) - }) - - it('automatically executes pickup upon reaching ground item on tick', () => { - const engine = createTestEngine() - engine.world.player.x = 500 - engine.world.player.y = 500 - - const entity = engine.dropItem( - { id: 'distant-gold', isGold: true, amount: 1200 }, - 600, - 500, - ) - - const { controller, status } = createMockController(engine) - controller.pickupGroundItem(entity) - - // Move player close to item - engine.world.player.x = 580 - engine.world.player.y = 500 // distance = 20px <= 48px - - // Tick controller - controller.tick() - - expect(engine.groundItems.count).toBe(0) - expect(status.textContent).toBe('拾起金币:1200') - expect(controller.pendingInteraction).toBeNull() - expect(controller.navTarget).toBeNull() - }) - - it('detects ground item click in handleLeftClickWorld within 32px', () => { - const engine = createTestEngine() - engine.world.player.x = 500 - engine.world.player.y = 500 - - const entity = engine.dropItem( - { id: 'ring', name: 'Ring', nameZh: '戒指', invWidth: 1, invHeight: 1 }, - 510, - 510, - ) - - const { controller, status } = createMockController(engine) - // Player clicks near item coordinates (515, 512), within 32px of (510, 510) - controller.handleLeftClickWorld(515, 512) - - expect(engine.groundItems.count).toBe(0) - expect(status.textContent).toBe('拾起物品:戒指') - }) - - it('cleans up both groundItems and engine.ground on pickup, leaving zero residual dots', () => { - const engine = createTestEngine() - engine.world.player.x = 500 - engine.world.player.y = 500 - - const entity = engine.dropItem( - { id: 'sword', name: 'Short Sword', nameZh: '短剑', invWidth: 1, invHeight: 2 }, - 505, - 505, - ) - - expect(engine.groundItems.count).toBe(1) - expect(engine.ground.length).toBe(1) - - const { controller } = createMockController(engine) - controller.pickupGroundItem(entity) - - // Verified: Both groundItems and engine.ground are cleanly cleared - expect(engine.groundItems.count).toBe(0) - expect(engine.ground.length).toBe(0) - }) - }) }) diff --git a/tests/ground-items-render.test.ts b/tests/ground-items-render.test.ts deleted file mode 100644 index 81c2d21..0000000 --- a/tests/ground-items-render.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Diablo II: Lord of Destruction v1.13c — Ground Item Rendering & Glint Sparkle Test Suite - * - * Requirements (Issue #389): - * 1. Isometric depth sorting & entity queue integration. - * 2. Adaptive item & gold icon silhouettes with bottom-center anchor and shadows. - * 3. Authentic quality colors: normal, magic, rare, set, unique, craft, rune, gold. - * 4. Periodic 4-point star glint & sparkle animation with sine easing. - * 5. Parabolic bounce vertical translation. - */ - -import { describe, expect, it } from 'vitest' -import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/client/world/act-runtime.ts' -import type { GroundItemEntity } from '../src/common/items/ground-items.ts' -import type { SpriteRenderer } from '../src/client/render/renderer.ts' - -describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () => { - const createMockRenderer = () => { - const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - return { renderer, drawnQuads, drawnSprites } - } - - describe('1. GROUND_ITEM_QUALITY_COLORS Palette Parity', () => { - it('provides distinct, authentic RGBA color tuples for all D2 quality tiers', () => { - const requiredQualities = ['normal', 'low', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune', 'gold'] - for (const q of requiredQualities) { - const col = GROUND_ITEM_QUALITY_COLORS[q] - expect(col).toBeDefined() - expect(col!.length).toBe(4) - for (const c of col!) { - expect(c).toBeGreaterThanOrEqual(0) - expect(c).toBeGreaterThanOrEqual(0) - expect(c).toBeLessThanOrEqual(1) - } - } - - // Magic is blue - expect(GROUND_ITEM_QUALITY_COLORS.magic![2]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.magic![0]) - // Rare is yellow (red & green high, blue low) - expect(GROUND_ITEM_QUALITY_COLORS.rare![0]).toBeGreaterThan(0.8) - expect(GROUND_ITEM_QUALITY_COLORS.rare![1]).toBeGreaterThan(0.8) - // Set is green - expect(GROUND_ITEM_QUALITY_COLORS.set![1]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.set![0]) - // Unique is gold/tan - expect(GROUND_ITEM_QUALITY_COLORS.unique![0]).toBeGreaterThan(0.7) - }) - }) - - describe('2. Gold Pile Rendering & Scaling', () => { - it('draws medium gold pile (50 gold) using authentic flpgld_1 sprite and ground shadow', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const goldItem: GroundItemEntity = { - id: 'gold_small', - item: { isGold: true }, - name: '50 Gold', - nameZh: '50 金币', - quality: 'normal', - isGold: true, - amount: 50, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 200, - y: 150, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - // Use a timestamp outside the sparkle window (e.g. 1000ms in a 2000ms cycle) - drawGroundItem(renderer, goldItem, 1000) - - // Exactly 1 ground contact shadow quad and 1 authentic flippy sprite - expect(drawnQuads.length).toBe(1) - expect(drawnSprites.length).toBe(1) - - const shadow = drawnQuads[0]! - expect(shadow.y).toBe(150 - 2) - expect(shadow.color[3]).toBeLessThan(0.5) - - const sprite = drawnSprites[0]! - expect(sprite.options.width).toBe(26) - expect(sprite.options.height).toBe(13) - }) - - it('scales gold pile sprite dimensions for large piles (>= 500 gold -> flpgld_2 32x20)', () => { - const { renderer, drawnSprites } = createMockRenderer() - const largeGold: GroundItemEntity = { - id: 'gold_large', - item: { isGold: true }, - name: '10000 Gold', - nameZh: '10000 金币', - quality: 'normal', - isGold: true, - amount: 10000, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, largeGold, 1000) - expect(drawnSprites.length).toBe(1) - const sprite = drawnSprites[0]! - expect(sprite.options.width).toBe(32) - expect(sprite.options.height).toBe(20) - }) - }) - - describe('3. Equipment Item Rendering with Authentic Flippy Sprite & Bounce Offset', () => { - it('draws equipment item with ground shadow and authentic flippy sprite (zero procedural border quads)', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const uniqueSword: GroundItemEntity = { - id: 'unique_sword', - item: { name: 'The Patriarch', code: 'gis' }, - name: 'The Patriarch', - nameZh: '族长', - quality: 'unique', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 400, - y: 350, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, uniqueSword, 1000) - - // Only 1 shadow quad (outside sparkle window) + 1 flippy sprite - expect(drawnQuads.length).toBe(1) - expect(drawnSprites.length).toBe(1) - expect(drawnQuads[0]!.y).toBe(350 - 2) - expect(drawnSprites[0]!.options.width).toBeGreaterThan(0) - expect(drawnSprites[0]!.options.height).toBeGreaterThan(0) - }) - - it('translates vertical sprite rendering position upwards during parabolic bounce', () => { - const { renderer: rGround, drawnQuads: quadsGround, drawnSprites: spritesGround } = createMockRenderer() - const { renderer: rAir, drawnQuads: quadsAir, drawnSprites: spritesAir } = createMockRenderer() - - const restingItem: GroundItemEntity = { - id: 'item_rest', - item: { code: 'hlm', name: 'Helm' }, - name: 'Helm', - nameZh: '头盔', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 2, - invHeight: 2, - dropTime: 0, - x: 100, - y: 200, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - const bouncingItem: GroundItemEntity = { - ...restingItem, - id: 'item_bounce', - bounceState: { - startTime: 1000, - durationMs: 400, - peakHeightPx: 30, - }, - } - - // Draw resting item at t = 1200 - drawGroundItem(rGround, restingItem, 1200) - // Draw bouncing item at apex (t = 1200, apex = 30px) - drawGroundItem(rAir, bouncingItem, 1200) - - const groundSprite = spritesGround[0]! - const airSprite = spritesAir[0]! - - // Shadow stays anchored on ground plane (item.y - 2 = 198) - expect(quadsGround[0]!.y).toBe(198) - expect(quadsAir[0]!.y).toBe(198) - - // Air sprite y should be exactly ground sprite y - 30 - expect(airSprite.y).toBe(groundSprite.y - 30) - }) - }) - - describe('4. Glint & Sparkle 4-Point Star Animation', () => { - it('spawns 4-point star rays, core, and quality aura during the active flash window', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const gem: GroundItemEntity = { - id: 'gem_ruby', - item: { code: 'gcr', name: 'Perfect Ruby' }, - name: 'Perfect Ruby', - nameZh: '完美红宝石', - quality: 'rare', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 500, - y: 400, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - // At t = 160ms (middle of flash window 0..320ms, p = 0.5, intensity = 1.0) - drawGroundItem(renderer, gem, 160) - - // Total quads: shadow (1) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 5 - expect(drawnSprites.length).toBe(1) - expect(drawnQuads.length).toBe(5) - - const horizontalRay = drawnQuads[1]! - const verticalRay = drawnQuads[2]! - const core = drawnQuads[3]! - const aura = drawnQuads[4]! - - // Brilliant core color (near-white) - expect(core.color[0]).toBeGreaterThan(0.9) - expect(core.color[1]).toBeGreaterThan(0.9) - expect(core.color[3]).toBeCloseTo(1.0, 1) - - // Aura matches rare quality color - expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.rare![0], 2) - - // Rays are centered on sparkle point - expect(horizontalRay.h).toBe(2) - expect(verticalRay.w).toBe(2) - expect(horizontalRay.w).toBeGreaterThan(10) - expect(verticalRay.h).toBeGreaterThan(10) - }) - - it('suppresses sparkle rendering during the dormant period of the cycle', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const item: GroundItemEntity = { - id: 'item_dormant', - item: { code: 'rin', name: 'Ring' }, - name: 'Ring', - nameZh: '戒指', - quality: 'unique', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 200, - y: 200, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - // At t = 1000ms (far outside 0..320ms window) - drawGroundItem(renderer, item, 1000) - - // Only ground shadow quad (1), no sparkle quads - expect(drawnSprites.length).toBe(1) - expect(drawnQuads.length).toBe(1) - }) - }) - - describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => { - it('renders authentic item sprite using itemsAtlas instead of solid color box', () => { - const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - drawnQuads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - drawnSprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any - const magicSword: GroundItemEntity = { - id: 'magic_sword', - item: { name: 'Short Sword', code: 'ssd' }, - name: 'Short Sword', - nameZh: '短剑', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 100, - y: 200, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, magicSword, 1000, mockAtlas) - - // Verified: Sprite is drawn using itemsAtlas - expect(drawnSprites.length).toBe(1) - const sprite = drawnSprites[0]! - expect(sprite.options.atlas).toBe(mockAtlas) - expect(sprite.options.width).toBeGreaterThan(0) - expect(sprite.options.height).toBeGreaterThan(0) - // Drop shadow is drawn on ground - expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true) - // Crucial: No opaque blue body box drawn in quads - const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10) - expect(blueBody).toBeUndefined() - }) - }) - - describe('6. Authentic Ground Slanted Quad Projection (D2 1.13c Ground Truth)', () => { - it('calls renderer.drawQuad with 2:1 isometric ground foreshortened height and shear angle', () => { - const drawnQuads: any[] = [] - const renderer = { - drawSolid() {}, - drawQuad(frame: any, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, options: any) { - drawnQuads.push({ frame, x0, y0, x1, y1, x2, y2, x3, y3, options }) - }, - } as unknown as SpriteRenderer - - const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any - const groundSword: GroundItemEntity = { - id: 'ground_sword', - item: { name: 'Broad Sword', code: 'bsd' }, - name: 'Broad Sword', - nameZh: '阔剑', - quality: 'normal', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - } - - drawGroundItem(renderer, groundSword, 1000, mockAtlas) - - expect(drawnQuads.length).toBe(1) - const q = drawnQuads[0]! - // Bottom edge rests at baseY (y = 250) - expect(q.y2).toBe(250) - expect(q.y3).toBe(250) - // Top edge is above ground (y0 < 250) - expect(q.y0).toBeLessThan(250) - expect(q.y1).toBeLessThan(250) - // Top edge is sheared horizontally along 2:1 isometric diamond (x0 < x2) - expect(q.x0).toBeLessThan(q.x2) - expect(q.x1).toBeLessThan(q.x3) - // Slanted parallelogram: width of top edge equals width of bottom edge - expect(q.x1 - q.x0).toBe(q.x3 - q.x2) - }) - }) -}) - diff --git a/tests/ground-items-scatter-bounce.test.ts b/tests/ground-items-scatter-bounce.test.ts index 33fcd18..917f297 100644 --- a/tests/ground-items-scatter-bounce.test.ts +++ b/tests/ground-items-scatter-bounce.test.ts @@ -30,7 +30,6 @@ import { subTileAt, } from '../src/common/world/d2map.ts' import type { CollisionGrid } from '../src/common/world/d2map.ts' -import { drawGroundItem } from '../src/client/world/act-runtime.ts' import { resolveGroundItemSpriteRect } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import type { SpriteRenderer } from '../src/client/render/renderer.ts' @@ -621,80 +620,6 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri }) describe('6. Rendering & Sprite Selection Parity', () => { - it('drawGroundItem renders sparkle glints on resting items when itemsAtlas is available', () => { - const { renderer, drawnQuads, drawnSprites } = createMockRenderer() - const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any - - const groundSword: GroundItemEntity = { - id: 'sword_resting', - item: { name: 'Short Sword', code: 'ssd' }, - name: 'Short Sword', - nameZh: '短剑', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - bounceState: null, // resting! - } - - // Render during active flash window (t = 160ms, period = 2000, 160 < 320) - drawGroundItem(renderer, groundSword, 160, mockAtlas) - - // Verified: Sprite is drawn - expect(drawnSprites.length).toBe(1) - expect(drawnSprites[0]!.options.atlas).toBe(mockAtlas) - - // Verified: Drop shadow is drawn - const shadow = drawnQuads.find(q => q.y === 250 - 2 && q.color[3] <= 0.4) - expect(shadow).toBeDefined() - - // Verified: Sparkle star rays and core are drawn even with itemsAtlas present! - const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) - expect(whiteGlint).toBeDefined() - }) - - it('suppresses sparkle glints while item is airborne in bounce trajectory', () => { - const { renderer, drawnQuads } = createMockRenderer() - const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any - - const airborneSword: GroundItemEntity = { - id: 'sword_airborne', - item: { name: 'Short Sword', code: 'ssd' }, - name: 'Short Sword', - nameZh: '短剑', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 3, - dropTime: 0, - x: 300, - y: 250, - cellX: 0, - cellY: 0, - sparklePhase: 0, - bounceState: { - startTime: 0, - durationMs: 400, - peakHeightPx: 24, - phase: 'primary', - }, - } - - // At t = 160ms (apex bounceH > 0), item is mid-air - drawGroundItem(renderer, airborneSword, 160, mockAtlas) - - // While airborne, sparkle rays should NOT be drawn - const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) - expect(whiteGlint).toBeUndefined() - }) it('resolves gold pile sprite tier based on amount (1-49, 50-499, 500+)', () => { const flippyRects = BAKED_UI_MANIFEST.flippyRects diff --git a/tests/hero-classes-art.test.ts b/tests/hero-classes-art.test.ts index 805afdd..e208a0f 100644 --- a/tests/hero-classes-art.test.ts +++ b/tests/hero-classes-art.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { HERO_SPECS } from '../src/baker/packers/pack-characters.ts' -import { HERO_CLASSES, normalizeHeroToken } from '../src/client/world/act-runtime.ts' +import { HERO_CLASSES, normalizeHeroToken } from '../src/common/world/scene-population.ts' describe('7 Playable Character Classes Artwork & Selection Parity', () => { const packRoot = join(process.cwd(), 'samples', 'd2-packs') diff --git a/tests/hotkeys-parity.test.ts b/tests/hotkeys-parity.test.ts index b24209a..f562f7a 100644 --- a/tests/hotkeys-parity.test.ts +++ b/tests/hotkeys-parity.test.ts @@ -9,19 +9,9 @@ */ import { describe, expect, it, beforeEach, vi } from 'vitest' -import { - SkillHotkeysHud, - LEFT_SKILL_BOUNDS, - RIGHT_SKILL_BOUNDS, - DEFAULT_SORCERESS_SKILLS, - PASSIVE_SKILL_IDS, - PALADIN_AURA_SKILL_IDS, - isPassiveSkill, - isAuraSkill, - getAvailableSkillsForSide, - type HotkeySkillEntry, -} from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, PASSIVE_SKILL_IDS, PALADIN_AURA_SKILL_IDS, isPassiveSkill, isAuraSkill, getAvailableSkillsForSide, type HotkeySkillEntry } from '../src/client/ui/hotkeys.ts' import { D2FontRenderer } from '../src/client/ui/font.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => { let hud: SkillHotkeysHud diff --git a/tests/hotkeys.test.ts b/tests/hotkeys.test.ts index 8fe4cfb..f961f41 100644 --- a/tests/hotkeys.test.ts +++ b/tests/hotkeys.test.ts @@ -3,15 +3,9 @@ */ import { describe, expect, it, beforeEach } from 'vitest' -import { - SkillHotkeysHud, - LEFT_SKILL_BOUNDS, - RIGHT_SKILL_BOUNDS, - DEFAULT_SORCERESS_SKILLS, - resolveSkillIcon, - getAvailableSkillsForSide, -} from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, resolveSkillIcon, getAvailableSkillsForSide } from '../src/client/ui/hotkeys.ts' import { createDemoHotkeys } from './fixtures/hud-demo-state.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('SkillHotkeysHud & Dual Slot Mechanics', () => { let hud: SkillHotkeysHud diff --git a/tests/hydra-missile-113c.test.ts b/tests/hydra-missile-113c.test.ts index 3906d1a..ffeae1c 100644 --- a/tests/hydra-missile-113c.test.ts +++ b/tests/hydra-missile-113c.test.ts @@ -37,7 +37,7 @@ import { FIREBOLT_META, MISSILE_METAS, } from '../src/client/render/missiles-meta.ts' -import { getSkillRange, getSkillMana } from '../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { GameEngine } from '../src/server/engine/engine.ts' diff --git a/tests/input-controls.test.ts b/tests/input-controls.test.ts index 7083b4b..6927774 100644 --- a/tests/input-controls.test.ts +++ b/tests/input-controls.test.ts @@ -6,16 +6,15 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { describe, expect, it } from 'vitest' -import { KeyboardInput } from '../src/client/sim/input.ts' import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts' import { seedDemoHud } from './fixtures/hud-demo-state.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' import type { SceneEntrance, SceneWarp } from '../src/common/world/level-links.ts' -import { subTileToScene } from '../src/client/scene/transition.ts' +import { subTileToScene } from '../src/common/world/d2map.ts' import { getLevelNames } from '../src/common/world/level-names-zh.ts' -import { SceneMouseController, screenToWorld, facingOf, PASSIVE_SKILL_IDS, isPassiveSkill, getSkillRange, getSkillMana, TELEKINESIS_RANGE_PX, getWarpLabelWorldPos, getEntranceLabelWorldPos, hitTestWarp, hitTestEntrance, isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { screenToWorld, facingOf, TELEKINESIS_RANGE_PX, getWarpLabelWorldPos, getEntranceLabelWorldPos, hitTestWarp, hitTestEntrance, isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile } from '../src/common/world/scene-links-nav.ts' +import { PASSIVE_SKILL_IDS, isPassiveSkill, getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' class MockWindow extends EventTarget { innerWidth = 1920 @@ -85,109 +84,6 @@ globalThis.HTMLInputElement = class HTMLInputElement {} globalThis.HTMLSelectElement = class HTMLSelectElement {} describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)', () => { - it('decouples KeyT from input.talking to eliminate ghost NPC interactions', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - expect(input.talking).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyT', key: 't' })) - expect(input.talking).toBe(false) - - // Held across multiple frames/ticks - for (let i = 0; i < 25; i++) { - expect(input.talking).toBe(false) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyT', key: 't' })) - expect(input.talking).toBe(false) - }) - - it('decouples KeyB from input.takeWaypoint() to eliminate ghost waypoint travel', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - expect(input.takeWaypoint()).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyB', key: 'b' })) - expect(input.takeWaypoint()).toBe(false) - - for (let i = 0; i < 25; i++) { - expect(input.takeWaypoint()).toBe(false) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyB', key: 'b' })) - expect(input.takeWaypoint()).toBe(false) - }) - - it('removes Space from ATTACK_KEYS so Space never triggers combat swings or skill casts', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - expect(input.attacking).toBe(false) - - // Legitimate attack keys still work - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyJ', key: 'j' })) - expect(input.attacking).toBe(true) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyJ', key: 'j' })) - expect(input.attacking).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Enter', key: 'Enter' })) - expect(input.attacking).toBe(true) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Enter', key: 'Enter' })) - expect(input.attacking).toBe(false) - }) - - it('verifies Space in GameEngine does not damage monsters or trigger combat hits', () => { - const dummyTerrain: WorldMapProvider = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } - const dummyOpts: GameEngineOptions = { - spawn: { x: 100, y: 100 }, - stats: [{ id: 'mon1', name: 'Zombie', hp: 100, damage: 0, cooldownTicks: 100, reach: 20, aggroRadius: 100, speed: 0, xp: 5 }], - xpTable: [0, 0, 10, 30, 60], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }], - npcDefs: [], - questDefs: [], - combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 }, - lootSeed: 12345, - talkRadius: 50, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - engine.world.monsters[0]!.x = 110 - engine.world.monsters[0]!.y = 100 - const startHp = engine.world.monsters[0]!.hp - const startHits = engine.metrics.playerHits - - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - - for (let i = 0; i < 30; i++) { - engine.tick({ - movement: input.movement(), - attacking: input.attacking, - pickingUp: input.pickingUp, - talking: input.talking, - digits: input.digits, - saving: input.saving, - loading: input.loading, - }) - } - - expect(engine.world.monsters[0]!.hp).toBe(startHp) - expect(engine.metrics.playerHits).toBe(startHits) - }) it('harmonizes Space with Escape to close open HUD panels', () => { const canvas = new MockCanvas() as unknown as HTMLCanvasElement @@ -264,263 +160,8 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)' expect(hud.belt.countTotalPotions()).toBe(16) hud.commandSink = null }) - - it('isolates WASD keys from movement Contribution', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - for (const code of ['KeyW', 'KeyA', 'KeyS', 'KeyD']) { - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code, key: code.slice(-1).toLowerCase() })) - expect(input.movement()).toEqual({ x: 0, y: 0 }) - expect(input.active).toBe(false) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code, key: code.slice(-1).toLowerCase() })) - } - - // Arrow keys still move - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ArrowUp', key: 'ArrowUp' })) - expect(input.movement()).toEqual({ x: 0, y: -1 }) - expect(input.active).toBe(true) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ArrowUp', key: 'ArrowUp' })) - expect(input.active).toBe(false) - }) - - it('tracks shiftHeld state for force stand still attack', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - expect(input.shiftHeld).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - expect(input.shiftHeld).toBe(true) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftLeft', key: 'Shift' })) - expect(input.shiftHeld).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' })) - expect(input.shiftHeld).toBe(true) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftRight', key: 'Shift' })) - expect(input.shiftHeld).toBe(false) - }) }) -function createTestScene(options?: { - playerPos?: { x: number; y: number } - mana?: number - leftSkillId?: number - rightSkillId?: number - npcs?: Array<{ id: number; name: string; x: number; y: number }> - waypoints?: Array<{ waypointId: number; x: number; y: number }> - warps?: SceneWarp[] - entrances?: SceneEntrance[] - portalSlot?: { mouthInLevel: (levelId: number) => { x: number; y: number } | null } - onEnterPortal?: () => void - onEnterWarp?: (warp: SceneWarp) => void - onEnterEntrance?: (entrance: SceneEntrance) => void - isTravelling?: () => boolean - labelsContainer?: HTMLElement | null - monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }> - blockedOverlap?: (x: number, y: number) => number -}) { - const canvas = new MockCanvas() as unknown as HTMLCanvasElement - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const dummyTerrain: WorldMapProvider = { - widthPx: 2000, - heightPx: 2000, - overlap: options?.blockedOverlap ?? (() => 0), - } - - const dummyOpts: GameEngineOptions = { - spawn: options?.playerPos ?? { x: 500, y: 500 }, - stats: (options?.monsters ?? []).map(m => ({ - id: m.id, - name: m.name, - hp: m.hp, - damage: 10, - cooldownTicks: 100, - reach: 20, - aggroRadius: 100, - speed: 0, - xp: 10, - })), - xpTable: [0, 0, 10, 30, 60], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }], - npcDefs: (options?.npcs ?? []).map(n => ({ - id: `npc-${n.id}`, - name: n.name, - questId: null, - offerLines: ['Greetings, wanderer.'], - progressLines: [], - doneLines: [], - })), - questDefs: [], - combatOptions: { playerSpeed: 100, playerReach: 52, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 }, - lootSeed: 12345, - talkRadius: 80, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - if (options?.monsters) { - for (let i = 0; i < options.monsters.length; i++) { - engine.world.monsters[i]!.x = options.monsters[i]!.x - engine.world.monsters[i]!.y = options.monsters[i]!.y - } - } - - if (options?.npcs) { - for (const n of options.npcs) { - engine.npcEntities.push({ - def: { - id: `npc-${n.id}`, - name: n.name, - questId: null, - offerLines: ['Greetings, wanderer.'], - progressLines: [], - doneLines: [], - }, - x: n.x, - y: n.y, - hasSprite: false, - }) - } - } - - const camera = new ViewportCamera(canvas, 2000, 2000) - camera.zoom = 1 - - const hudManager = new HudManager(canvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - // Explicit legacy demo Sorceress state (class, quickbar skill levels / mana costs, vitals incl. maxMana). - seedDemoHud(hudManager) - hudManager.mana = options?.mana ?? 200 - hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0 - hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0 - hudManager.syncPublishedState() - - const waypointNetwork = new WaypointNetwork() - if (options?.waypoints) { - for (const wp of options.waypoints) { - waypointNetwork.register({ - waypointId: wp.waypointId, - levelId: 1, - act: 1, - name: 'Test WP', - x: wp.x, - y: wp.y, - }) - } - } - - const gridWidth = 500 - const blockedGrid = new Uint8Array(500 * 500) - const runtime: MapRuntime = { - source: 'pack', - base: 'samples/d2-packs', - act: 1, - level: 'Rogue Encampment', - levelId: 1, - variant: 'default', - variants: ['default'], - quadrant: 'default', - quadrants: ['default'], - cellsX: 100, - cellsY: 100, - widthPx: 2000, - heightPx: 2000, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - pages: [], - priorityPages: [], - objectPages: [], - grid: { - originX: 0, - originY: 0, - cellsX: 100, - cellsY: 100, - blocked: blockedGrid, - gridWidth, - }, - npcs: (options?.npcs ?? []).map(n => ({ - id: n.id, - type: 1, - name: n.name, - x: n.x, - y: n.y, - })), - entrances: options?.entrances ?? [], - warps: options?.warps ?? [], - waypoints: (options?.waypoints ?? []).map(wp => { - const dx = wp.x / 16 - const dy = wp.y / 8 - const subX = Math.round((dy + dx) / 2) - const subY = Math.round((dy - dx) / 2) - return { - source: 'placed' as const, - waypointId: wp.waypointId, - arriveX: subX, - arriveY: subY, - x: subX, - y: subY, - } - }), - animatedDrawables: [], - animSpeed: 1, - frameDurationMs: 40, - palette: null, - charBases: [], - monsterTypes: [], - monsterPacks: [], - entryPath: '', - loadPages: async () => {}, - } as unknown as MapRuntime - - const status = { textContent: '' } as HTMLElement - - const controller = new SceneMouseController({ - canvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager, - waypointNetwork, - status, - portalSlot: options?.portalSlot as any, - onEnterPortal: options?.onEnterPortal, - onEnterWarp: options?.onEnterWarp, - onEnterEntrance: options?.onEnterEntrance, - isTravelling: options?.isTravelling, - labelsContainer: options?.labelsContainer, - }) - controller.attach() - - return { - canvas, - win, - input, - engine, - camera, - hudManager, - waypointNetwork, - runtime, - status, - controller, - } -} - describe('Milestone 2: Mouse Interaction, Click-to-Move, and Combat Controls (R2, R3, R4)', () => { it('projects screen coordinates to world coordinates through inverse camera transform', () => { const canvas = new MockCanvas() as unknown as HTMLCanvasElement @@ -586,1152 +227,9 @@ describe('Milestone 2: Mouse Interaction, Click-to-Move, and Combat Controls (R2 // 7 = South-East (dx>0, dy>0) expect(facingOf(1, 1)).toBe(7) }) - - it('R3: left-click on ground sets navTarget and generates movement towards target, stopping within 8px', () => { - const scene = createTestScene({ playerPos: { x: 500, y: 500 } }) - - // Click ground at +100px X, +0px Y (clientX = 960 + 100, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 1060, - clientY: 540, - button: 0, - })) - - expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 }) - - // First tick produces movement vector pointing right towards (600, 484) - const tick1 = scene.controller.tick() - expect(tick1.movement.x).toBeGreaterThan(0) - - // Move player closer to target (598, 484) - distance is 2px (< 8px arrival threshold) - scene.engine.world.player.x = 598 - scene.engine.world.player.y = 484 - - const arrivalTick = scene.controller.tick() - expect(arrivalTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - }) - - it('R3: continuous left-click drag updates navTarget with cursor coordinates', () => { - const scene = createTestScene({ playerPos: { x: 500, y: 500 } }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 0, - buttons: 1, - })) - expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 }) - - // Drag pointer to (1060, 640) -> world (600, 584) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: 1060, - clientY: 640, - button: 0, - buttons: 1, - })) - expect(scene.controller.navTarget).toEqual({ x: 600, y: 584 }) - - // pointerup releases hold - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 1060, - clientY: 640, - button: 0, - buttons: 0, - })) - expect(scene.controller.isLeftMouseDown).toBe(false) - }) - - it('R4: left-click with Shift held executes force stand still attack without pathing', () => { - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - leftSkillId: 47, // Fire Ball - mana: 100, - }) - - // Hold Shift via window keydown - scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) - expect(scene.input.shiftHeld).toBe(true) - - // Left-click towards (600, 484) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 0, - shiftKey: true, - })) - - // Movement must be strictly 0 (no pathing / stand still) - const tickResult = scene.controller.tick() - expect(tickResult.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.navTarget).toBeNull() - - // Player faces cursor (East = 6) - expect(scene.engine.world.player.facing).toBe(6) - - // Projectile spawned towards cursor - expect(scene.engine.projectiles.length).toBe(1) - expect(scene.engine.projectiles[0]!.skillId).toBe('47') - expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0) - // Mana deducted (Fire Ball lv 8 costs 8.5) - expect(scene.hudManager.mana).toBe(100 - 8.5) - }) - - it('R3: left-click on NPC talks immediately if <= 80px, or paths and talks on arrival if > 80px', () => { - // NPC 1 is nearby (530, 500), distance 30px <= 80px - // NPC 2 is far away (750, 500), distance 250px > 80px - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - npcs: [ - { id: 1, name: 'Akara', x: 530, y: 500 }, - { id: 2, name: 'Kashya', x: 750, y: 500 }, - ], - }) - - // 1. Click nearby NPC Akara (world 530, 500 -> clientX = 960 + 30 = 990, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 990, - clientY: 540, - button: 0, - })) - - // Talks immediately without pathing - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.status.textContent).toContain('Akara') - expect(scene.engine.world.player.facing).toBe(6) // Facing East towards Akara - - // 2. Click far NPC Kashya (world 750, 500 -> clientX = 960 + 250 = 1210, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1210, - clientY: 540, - button: 0, - })) - - // Sets pending interaction and navTarget towards Kashya - expect(scene.controller.pendingInteraction).toEqual({ - kind: 'npc', - npc: expect.objectContaining({ x: 750, y: 500 }), - }) - expect(scene.controller.navTarget).toEqual({ x: 750, y: 500 }) - - // Walks towards Kashya - const walkTick = scene.controller.tick() - expect(walkTick.movement.x).toBeGreaterThan(0) - - // Player arrives within 80px (e.g. x = 680, dist = 70px) - scene.engine.world.player.x = 680 - const arriveTick = scene.controller.tick() - expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.status.textContent).toContain('Kashya') - }) - - it('R3: left-click on Waypoint activates and opens menu if <= 80px, or paths and opens on arrival', () => { - // WP 1 nearby at (540, 500), dist 40px <= 80px - // WP 2 far away at (800, 500), dist 300px > 80px - // Note: waypoint subTile to scene is handled in test mock - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - waypoints: [ - { waypointId: 1, x: 540, y: 500 }, - { waypointId: 2, x: 800, y: 500 }, - ], - }) - - // Click nearby WP 1 (clientX = 960 + 40 = 1000, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1000, - clientY: 540, - button: 0, - })) - - // Activated immediately and opened waypoint menu - expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true) - expect(scene.hudManager.leftPanel).toBe('waypoint') - expect(scene.controller.navTarget).toBeNull() - - // Close waypoint panel - scene.hudManager.toggleLeftPanel('waypoint') - expect(scene.hudManager.leftPanel).toBe('none') - - // Click far WP 2 (clientX = 960 + 300 = 1260, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1260, - clientY: 540, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('waypoint') - expect(scene.controller.navTarget).toBeDefined() - - // Player moves within 80px (e.g. x = 740, dist = 60px) - scene.engine.world.player.x = 740 - const arriveTick = scene.controller.tick() - expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.hudManager.leftPanel).toBe('waypoint') - expect(scene.controller.pendingInteraction).toBeNull() - }) - - it('R3: left-click on Monster attacks melee if <= 52px, or paths to monster and attacks on arrival', () => { - // Monster 1 nearby at (530, 500), dist 30px <= 52px - // Monster 2 far away at (700, 500), dist 200px > 52px - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - leftSkillId: 0, // Melee attack - monsters: [ - { id: 'mon1', name: 'Fallen', x: 530, y: 500, hp: 50 }, - { id: 'mon2', name: 'Zombie', x: 700, y: 500, hp: 100 }, - ], - }) - - // Click nearby monster 1 (clientX = 960 + 30 = 990, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 990, - clientY: 540, - button: 0, - })) - - // Attacks immediately: deals damage, no pathing - expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50) - expect(scene.engine.metrics.playerHits).toBe(1) - expect(scene.controller.navTarget).toBeNull() - - // Click far monster 2 (clientX = 960 + 200 = 1160, clientY = 540) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1160, - clientY: 540, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('monster') - expect(scene.controller.navTarget).toEqual({ x: 700, y: 500 }) - - // Move player within reach (e.g. x = 660, dist = 40px <= 52px) - scene.engine.world.player.x = 660 - scene.engine.world.player.cooldown = 0 - const attackTick = scene.controller.tick() - expect(attackTick.movement).toEqual({ x: 0, y: 0 }) - expect(scene.engine.world.monsters[1]!.hp).toBeLessThan(100) - expect(scene.engine.metrics.playerHits).toBe(2) - }) - - it('R2: right-click active skill checks mana, deducts mana, enforces cooldown, and casts', () => { - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 47, // Fire Ball, cost 8.5 - mana: 100, - }) - - // 1. Sufficient mana - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - expect(scene.hudManager.mana).toBe(100 - 8.5) - expect(scene.engine.projectiles.length).toBe(1) - expect(scene.engine.world.player.cooldown).toBeGreaterThan(0) - expect(scene.engine.world.player.facing).toBe(6) // Facing East - - // 2. Insufficient mana: reduce mana to 5 - scene.hudManager.mana = 5 - scene.engine.world.player.cooldown = 0 - scene.engine.projectiles.length = 0 - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - // Must not move player, must display "法力不足!", must not spawn projectile - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 }) - expect(scene.status.textContent).toBe('法力不足!') - expect(scene.engine.projectiles.length).toBe(0) - expect(scene.hudManager.mana).toBe(5) // Unducted - }) - - it('R2: right-click Teleport (54) verifies target walkability and relocates player', () => { - const scene = createTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 54, // Teleport, cost 21 - mana: 100, - blockedOverlap: (x, y) => (x > 1500 ? 1 : 0), - }) - - // Teleport to walkable location (600, 484) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - expect(scene.engine.world.player.x).toBe(600) - expect(scene.engine.world.player.y).toBe(484) - expect(scene.hudManager.mana).toBe(100 - 21) - expect(scene.status.textContent).toBe('已施展传送') - - // Teleport to blocked location (1600, 484) -> clientX = 960 + 1000 = 1960 - scene.engine.world.player.cooldown = 0 - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1960, - clientY: 540, - button: 2, - })) - - // Does not move player - expect(scene.engine.world.player.x).toBe(600) - expect(scene.status.textContent).toBe('无法传送到该位置') - }) - - it('R2: right-click unassigned (0) or passive skill (37, 61) routes to walk/run towards cursor', () => { - // 1. Unassigned skill 0 - const scene1 = createTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 0, - }) - - scene1.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - expect(scene1.controller.navTarget).toEqual({ x: 600, y: 484 }) - expect(scene1.controller.tick().movement.x).toBeGreaterThan(0) - - // 2. Passive skill Warmth (37) - const scene2 = createTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 37, - }) - - scene2.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - expect(scene2.controller.navTarget).toEqual({ x: 600, y: 484 }) - expect(scene2.controller.tick().movement.x).toBeGreaterThan(0) - - // 3. Passive skill Fire Mastery (61) - const scene3 = createTestScene({ - playerPos: { x: 500, y: 500 }, - rightSkillId: 61, - }) - - scene3.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 2, - })) - - expect(scene3.controller.navTarget).toEqual({ x: 600, y: 484 }) - expect(scene3.controller.tick().movement.x).toBeGreaterThan(0) - }) - - it('prevents contextmenu default on canvas and respects HUD interception', () => { - const scene = createTestScene({ playerPos: { x: 500, y: 500 } }) - - // Test contextmenu preventDefault - const contextMenuEvent = new SyntheticPointerEvent('contextmenu', { - clientX: 500, - clientY: 500, - }) - scene.canvas.dispatchEvent(contextMenuEvent) - expect(contextMenuEvent.defaultPrevented).toBe(true) - - // Test HUD interception at bottom control bar (clientY = 1000 -> logical.y = 555.5 >= 546) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960, - clientY: 1000, - button: 0, - })) - expect(scene.controller.navTarget).toBeNull() - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960, - clientY: 1000, - button: 2, - })) - expect(scene.controller.navTarget).toBeNull() - }) - - it('clears navTarget on stuck detection after 12 ticks without moving', () => { - const scene = createTestScene({ playerPos: { x: 500, y: 500 } }) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 1060, - clientY: 540, - button: 0, - })) - expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 }) - - // Simulate player being stuck at (500, 500) for 12 ticks - for (let i = 0; i < 11; i++) { - scene.controller.tick() - expect(scene.controller.navTarget).not.toBeNull() - } - - // 12th tick triggers stuck mitigation - scene.controller.tick() - expect(scene.controller.navTarget).toBeNull() - }) }) describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Labels', () => { - it('disables KeyG so pressing KeyG no longer triggers takeUse()', () => { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - expect(input.takeUse()).toBe(false) - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyG', key: 'g' })) - expect(input.takeUse()).toBe(false) - - for (let i = 0; i < 25; i++) { - expect(input.takeUse()).toBe(false) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyG', key: 'g' })) - expect(input.takeUse()).toBe(false) - }) - - it('immediately triggers onEnterWarp without setting navTarget when left-clicking an in-range SceneWarp', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, // Den of Evil - warpId: 4, - x: 30, - y: 30, - arriveX: 32, - arriveY: 30, - exitWalkX: 32, - exitWalkY: 30, - selectX: 0, - selectY: 0, - selectDX: 0, - selectDY: 0, - } - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const playerPos = subTileToScene(31, 30, grid) - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos, - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Click directly on the warp anchor (relative to camera centered at playerPos.x, playerPos.y - 16) - const clientX = 960 + (anchorPos.x - playerPos.x) - const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - - expect(enteredWarps).toEqual([warp]) - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.status.textContent).toContain('邪恶洞窟') - }) - - it('sets navTarget to walkable arrivePos when left-clicking a distant SceneWarp and triggers onEnterWarp on tick() upon arrival', () => { - const grid = { originX: 0, originY: 0 } - // Warp where arriveX/arriveY is 7 sub-tiles from anchor (like Harem / Catacombs stairs) - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Catacombs Level 2', - toLevelId: 35, // Catacombs Level 2 - warpId: 18, - x: 40, - y: 20, - arriveX: 47, - arriveY: 20, - exitWalkX: 47, - exitWalkY: 20, - selectX: -54, - selectY: -39, - selectDX: 121, - selectDY: 102, - } - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) - const playerPos = subTileToScene(70, 50, grid) - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...playerPos }, - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - const clientX = 960 + (anchorPos.x - playerPos.x) - const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 0, - })) - - expect(enteredWarps).toHaveLength(0) - expect(scene.controller.navTarget).toEqual(arrivePos) - expect(scene.controller.pendingInteraction?.kind).toBe('warp') - - // Move partway toward arrivePos: still out of range - scene.engine.world.player.x = (playerPos.x + arrivePos.x) * 0.5 - scene.engine.world.player.y = (playerPos.y + arrivePos.y) * 0.5 - const step = scene.controller.tick() - expect(enteredWarps).toHaveLength(0) - expect(step.movement.x !== 0 || step.movement.y !== 0).toBe(true) - - // Arrive within trigger range of arrivePos - scene.engine.world.player.x = arrivePos.x - scene.engine.world.player.y = arrivePos.y - const arriveTick = scene.controller.tick() - expect(enteredWarps).toEqual([warp]) - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) - }) - - it('resolves and activates a SceneWarp when left-clicking inside its LvlWarp.txt (selectX, selectY, selectDX, selectDY) bounding box', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Harem Level 1', - toLevelId: 50, // Harem Level 1 - warpId: 23, - x: 35, - y: 35, - arriveX: 37, - arriveY: 35, - exitWalkX: 37, - exitWalkY: 35, - selectX: -120, - selectY: -100, - selectDX: 180, - selectDY: 140, - } - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const playerPos = subTileToScene(36, 35, grid) - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...playerPos }, - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Click near the top-left corner of the LvlWarp.txt selection box: - // ~140px from anchorPos (well outside radial proximity 56px, but inside [-120..60, -100..40]) - const clickWorldX = anchorPos.x - 110 - const clickWorldY = anchorPos.y - 90 - const clientX = 960 + (clickWorldX - playerPos.x) - const clientY = 540 + (clickWorldY - (playerPos.y - 16)) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - - expect(enteredWarps).toEqual([warp]) - expect(scene.controller.navTarget).toBeNull() - }) - - it('paths to arrivePos and triggers onEnterEntrance when left-clicking a SceneEntrance', () => { - const grid = { originX: 0, originY: 0 } - const entrance: SceneEntrance = { - toLevelId: 2, // Blood Moor - side: 'east', - label: 'Blood Moor Exit', - x: 25, - y: 25, - arriveX: 31, - arriveY: 25, - } - const anchorPos = subTileToScene(entrance.x, entrance.y, grid) - const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid) - const playerPos = subTileToScene(60, 45, grid) - const enteredEntrances: SceneEntrance[] = [] - - const scene = createTestScene({ - playerPos: { ...playerPos }, - entrances: [entrance], - onEnterEntrance: (e) => enteredEntrances.push(e), - }) - - const clientX = 960 + (anchorPos.x - playerPos.x) - const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 0, - })) - - expect(enteredEntrances).toHaveLength(0) - expect(scene.controller.navTarget).toEqual(arrivePos) - expect(scene.controller.pendingInteraction?.kind).toBe('entrance') - - // Move player to arrivePos and tick - scene.engine.world.player.x = arrivePos.x - scene.engine.world.player.y = arrivePos.y - scene.controller.tick() - - expect(enteredEntrances).toEqual([entrance]) - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - expect(scene.status.textContent).toContain('鲜血荒地') - }) - - it('allows Telekinesis (Skill 43) right-click casts to activate a clicked SceneWarp within TELEKINESIS_RANGE_PX (320px)', () => { - const grid = { originX: 0, originY: 0 } - const nearWarp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, - warpId: 4, - x: 30, - y: 30, - arriveX: 32, - arriveY: 30, - exitWalkX: 32, - exitWalkY: 30, - selectX: -48, - selectY: -48, - selectDX: 96, - selectDY: 96, - } - const farWarp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Cold Plains Cave', - toLevelId: 9, - warpId: 5, - x: 60, - y: 30, - arriveX: 62, - arriveY: 30, - exitWalkX: 62, - exitWalkY: 30, - selectX: -48, - selectY: -48, - selectDX: 96, - selectDY: 96, - } - const nearPos = subTileToScene(nearWarp.x, nearWarp.y, grid) - const farPos = subTileToScene(farWarp.x, farWarp.y, grid) - // Place player 220px away from nearWarp (< 320px) and > 320px from farWarp - const playerPos = { x: nearPos.x - 220, y: nearPos.y } - expect(Math.hypot(nearPos.x - playerPos.x, nearPos.y - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX) - expect(Math.hypot(farPos.x - playerPos.x, farPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) - - const enteredWarps: SceneWarp[] = [] - const scene = createTestScene({ - playerPos, - rightSkillId: 43, // Telekinesis - warps: [nearWarp, farWarp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Right-click farWarp (> 320px) -> rejected without deducting mana - const startMana = scene.hudManager.mana - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + (farPos.x - playerPos.x), - clientY: 540 + (farPos.y - (playerPos.y - 16)), - button: 2, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 960 + (farPos.x - playerPos.x), - clientY: 540 + (farPos.y - (playerPos.y - 16)), - button: 2, - })) - expect(enteredWarps).toHaveLength(0) - expect(scene.hudManager.mana).toBe(startMana) - - // Right-click nearWarp (<= 320px) -> deducts mana and activates warp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + (nearPos.x - playerPos.x), - clientY: 540 + (nearPos.y - (playerPos.y - 16)), - button: 2, - })) - expect(enteredWarps).toEqual([nearWarp]) - expect(scene.hudManager.mana).toBeLessThan(startMana) - }) - - it('renders overhead destination labels in labelsContainer and updates pointer cursor + badge highlight on hover', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, // 邪恶洞窟 - warpId: 4, - x: 30, - y: 30, - arriveX: 32, - arriveY: 30, - exitWalkX: 32, - exitWalkY: 30, - selectX: -60, - selectY: -60, - selectDX: 120, - selectDY: 90, - } - const entrance: SceneEntrance = { - toLevelId: 2, // 鲜血荒地 - side: 'east', - label: 'Blood Moor Exit', - x: 38, - y: 30, - arriveX: 40, - arriveY: 30, - } - const playerPos = subTileToScene(34, 30, grid) - const warpAnchor = subTileToScene(warp.x, warp.y, grid) - const entranceAnchor = subTileToScene(entrance.x, entrance.y, grid) - - const children: HTMLDivElement[] = [] - const mockLabelsContainer = { - children, - appendChild(el: HTMLDivElement) { - children.push(el) - return el - }, - } as unknown as HTMLElement - - const scene = createTestScene({ - playerPos, - warps: [warp], - entrances: [entrance], - labelsContainer: mockLabelsContainer, - }) - - const badges = children - expect(badges.length).toBe(2) - expect(badges[0]!.textContent).toBe('通往 邪恶洞窟') - expect(badges[1]!.textContent).toBe('通往 鲜血荒地') - expect(badges[0]!.dataset.hovered).toBe('false') - expect(badges[1]!.dataset.hovered).toBe('false') - expect(scene.canvas.style.cursor).not.toBe('pointer') - - // Hover over the SceneWarp bounding box - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: 960 + (warpAnchor.x - playerPos.x), - clientY: 540 + (warpAnchor.y - (playerPos.y - 16)), - })) - expect(scene.controller.hoveredWarp).toBe(warp) - expect(scene.controller.hoveredEntrance).toBeNull() - expect(scene.canvas.style.cursor).toBe('pointer') - expect(badges[0]!.dataset.hovered).toBe('true') - expect(badges[0]!.className).toContain('is-hovered') - expect(badges[1]!.dataset.hovered).toBe('false') - - // Hover over the SceneEntrance - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: 960 + (entranceAnchor.x - playerPos.x), - clientY: 540 + (entranceAnchor.y - (playerPos.y - 16)), - })) - expect(scene.controller.hoveredWarp).toBeNull() - expect(scene.controller.hoveredEntrance).toBe(entrance) - expect(scene.canvas.style.cursor).toBe('pointer') - expect(badges[0]!.dataset.hovered).toBe('false') - expect(badges[1]!.dataset.hovered).toBe('true') - expect(badges[1]!.className).toContain('is-hovered') - - // Move cursor to empty ground - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: 100, - clientY: 100, - })) - expect(scene.controller.hoveredWarp).toBeNull() - expect(scene.controller.hoveredEntrance).toBeNull() - expect(scene.canvas.style.cursor).toBe('') - expect(badges[0]!.dataset.hovered).toBe('false') - expect(badges[1]!.dataset.hovered).toBe('false') - }) - - it('paths around a concave dungeon wall obstacle when clicking a distant SceneWarp without false stuck cancellation', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Catacombs Level 3', - toLevelId: 36, - warpId: 19, - x: 20, - y: 33, - arriveX: 20, - arriveY: 32, - exitWalkX: 20, - exitWalkY: 32, - selectX: -48, - selectY: -48, - selectDX: 96, - selectDY: 96, - } - const playerStart = subTileToScene(20, 20, grid) - const warpAnchor = subTileToScene(warp.x, warp.y, grid) - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...playerStart }, - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Build a concave U-shaped dungeon wall between (20, 20) and (20, 32): - // Horizontal bar at y = 26, x = 16..24, plus side walls extending up toward player at x = 16 and x = 24, y = 22..26 - const gridW = scene.runtime.grid.gridWidth - const blocked = scene.runtime.grid.blocked as Uint8Array - for (let x = 16; x <= 24; x++) { - blocked[26 * gridW + x] = 1 - } - for (let y = 22; y <= 26; y++) { - blocked[y * gridW + 16] = 1 - blocked[y * gridW + 24] = 1 - } - - // Left-click the distant warp behind the concave wall - const clientX = 960 + (warpAnchor.x - playerStart.x) - const clientY = 540 + (warpAnchor.y - (playerStart.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 0, - })) - - expect(scene.controller.pendingInteraction?.kind).toBe('warp') - expect(enteredWarps).toHaveLength(0) - - // Step simulation ticks: player must route out of the concave pocket, around x < 16 or x > 24, and arrive at (20, 32) - let detouredOutsideWall = false - for (let stepIdx = 0; stepIdx < 160 && enteredWarps.length === 0; stepIdx++) { - const { movement } = scene.controller.tick() - if (enteredWarps.length > 0) break - expect(movement.x !== 0 || movement.y !== 0).toBe(true) - scene.engine.world.player.x += movement.x * 5 - scene.engine.world.player.y += movement.y * 5 - const sub = sceneToSubTile(scene.engine.world.player.x, scene.engine.world.player.y, grid) - expect(blocked[sub.y * gridW + sub.x]).toBe(0) - if (sub.x < 16 || sub.x > 24) { - detouredOutsideWall = true - } - } - - expect(detouredOutsideWall).toBe(true) - expect(enteredWarps).toEqual([warp]) - expect(scene.controller.navTarget).toBeNull() - expect(scene.controller.pendingInteraction).toBeNull() - }) - - it('hit-tests and activates overhead destination badges across their full rectangular footprint', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, - warpId: 4, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -50, - selectY: -60, - selectDX: 100, - selectDY: 80, - } - const playerPos = subTileToScene(31, 30, grid) - const labelPos = getWarpLabelWorldPos(warp, grid) - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...playerPos }, - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Point near the top-right of the rendered overhead badge: - // +58px right and -18px above labelPos (Euclidean distance = 60.73px > 56px radial threshold, and above selectY box) - const badgeClickX = labelPos.x + 58 - const badgeClickY = labelPos.y - 18 - const clientX = 960 + (badgeClickX - playerPos.x) - const clientY = 540 + (badgeClickY - (playerPos.y - 16)) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX, - clientY, - })) - expect(scene.controller.hoveredWarp).toBe(warp) - expect(scene.canvas.style.cursor).toBe('pointer') - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - expect(enteredWarps).toEqual([warp]) - }) - - it('prioritizes monsters and ground items over SceneWarp when overlapping inside a LvlWarp.txt box for both hover and click', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Catacombs Level 1', - toLevelId: 34, - warpId: 17, - x: 35, - y: 35, - arriveX: 36, - arriveY: 35, - exitWalkX: 36, - exitWalkY: 35, - selectX: -120, - selectY: -100, - selectDX: 180, - selectDY: 140, - } - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const playerPos = subTileToScene(36, 35, grid) - const monsterPos = { x: anchorPos.x - 20, y: anchorPos.y - 10 } - const enteredWarps: SceneWarp[] = [] - - const children: HTMLDivElement[] = [] - const mockLabelsContainer = { - children, - appendChild(el: HTMLDivElement) { - children.push(el) - return el - }, - } as unknown as HTMLElement - - const scene = createTestScene({ - playerPos: { ...playerPos }, - leftSkillId: 0, - monsters: [ - { id: 'mon-overlap', name: 'Fallen', x: monsterPos.x, y: monsterPos.y, hp: 40 }, - ], - warps: [warp], - onEnterWarp: (w) => enteredWarps.push(w), - labelsContainer: mockLabelsContainer, - }) - scene.engine.world.monsters.length = 1 - - // 1. Hovering over the living monster inside the warp's LvlWarp.txt box must NOT highlight the warp badge - const monClientX = 960 + (monsterPos.x - playerPos.x) - const monClientY = 540 + (monsterPos.y - (playerPos.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: monClientX, - clientY: monClientY, - })) - expect(scene.controller.hoveredWarp).toBeNull() - expect(children[0]!.dataset.hovered).toBe('false') - - // 2. Left-clicking the living monster attacks the monster instead of triggering the warp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: monClientX, - clientY: monClientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: monClientX, - clientY: monClientY, - button: 0, - })) - expect(enteredWarps).toHaveLength(0) - expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(40) - - // 3. Place a ground item at that spot and verify GroundItem > SceneWarp - scene.engine.world.monsters[0]!.hp = 0 - scene.engine.world.monsters[0]!.state = 'dead' - scene.engine.groundItems.clear() - const groundItem = scene.engine.groundItems.add( - { isGold: true, code: 'gld', name: 'Gold' }, - 0, - 0, - monsterPos.x, - monsterPos.y, - { amount: 25 }, - ) - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: monClientX, - clientY: monClientY, - })) - expect(scene.controller.hoveredGroundItem?.id).toBe(groundItem.id) - expect(scene.controller.hoveredWarp).toBeNull() - expect(children[0]!.dataset.hovered).toBe('false') - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: monClientX, - clientY: monClientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: monClientX, - clientY: monClientY, - button: 0, - })) - expect(enteredWarps).toHaveLength(0) - expect(scene.engine.groundItems.count).toBe(0) - - // 4. Once both the monster is dead and the ground item is picked up, hovering and clicking that exact spot inside the LvlWarp.txt box highlights and activates the warp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: monClientX, - clientY: monClientY, - })) - expect(scene.controller.hoveredWarp).toBe(warp) - expect(children[0]!.dataset.hovered).toBe('true') - - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: monClientX, - clientY: monClientY, - button: 0, - })) - expect(enteredWarps).toEqual([warp]) - }) - - it('maintains exact overhead badge screen alignment across non-unit camera zooms, camera scrolling, and clearTarget()', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, - warpId: 4, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -60, - selectY: -60, - selectDX: 120, - selectDY: 90, - } - const entrance: SceneEntrance = { - toLevelId: 2, - side: 'east', - label: 'Blood Moor', - x: 36, - y: 28, - arriveX: 38, - arriveY: 28, - } - const playerPos = subTileToScene(32, 30, grid) - const warpLabelWorld = getWarpLabelWorldPos(warp, grid) - const entranceLabelWorld = getEntranceLabelWorldPos(entrance, grid) - - const children: HTMLDivElement[] = [] - const mockLabelsContainer = { - children, - appendChild(el: HTMLDivElement) { - children.push(el) - return el - }, - } as unknown as HTMLElement - - const scene = createTestScene({ - playerPos: { ...playerPos }, - warps: [warp], - entrances: [entrance], - labelsContainer: mockLabelsContainer, - }) - - for (const zoom of [1.5, 0.75]) { - scene.camera.zoom = zoom - scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16) - - const warpBadge = children[0]! - const entranceBadge = children[1]! - const warpDomX = parseFloat(warpBadge.style.left) - const warpDomY = parseFloat(warpBadge.style.top) + 2 - const entranceDomX = parseFloat(entranceBadge.style.left) - const entranceDomY = parseFloat(entranceBadge.style.top) + 2 - - const invWarp = screenToWorld({ - clientX: warpDomX, - clientY: warpDomY, - rect: scene.canvas.getBoundingClientRect(), - canvas: scene.canvas, - camX: playerPos.x, - camY: playerPos.y - 16, - cameraZoom: zoom, - }) - const invEntrance = screenToWorld({ - clientX: entranceDomX, - clientY: entranceDomY, - rect: scene.canvas.getBoundingClientRect(), - canvas: scene.canvas, - camX: playerPos.x, - camY: playerPos.y - 16, - cameraZoom: zoom, - }) - expect(Math.hypot(invWarp.x - warpLabelWorld.x, invWarp.y - warpLabelWorld.y)).toBeLessThan(1e-6) - expect(Math.hypot(invEntrance.x - entranceLabelWorld.x, invEntrance.y - entranceLabelWorld.y)).toBeLessThan(1e-6) - - // Hover the projected badge position at this zoom - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: warpDomX, - clientY: warpDomY, - })) - expect(scene.controller.hoveredWarp).toBe(warp) - expect(warpBadge.dataset.hovered).toBe('true') - - // If the camera scrolls away while the pointer stays stationary, updateOverheadLabels clears hover - scene.controller.updateOverheadLabels(playerPos.x + 600, playerPos.y + 600) - expect(scene.controller.hoveredWarp).toBeNull() - expect(warpBadge.dataset.hovered).toBe('false') - - // Re-hover at original camera position, then verify clearTarget() immediately clears badge hover - scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16) - expect(scene.controller.hoveredWarp).toBe(warp) - expect(warpBadge.dataset.hovered).toBe('true') - scene.controller.clearTarget() - expect(scene.controller.hoveredWarp).toBeNull() - expect(warpBadge.dataset.hovered).toBe('false') - expect(warpBadge.className).not.toContain('is-hovered') - } - }) it('verifies all 136 baked map packs (192 SceneWarp and 60 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => { const baseDir = path.resolve(process.cwd(), 'samples/d2-packs') @@ -1804,182 +302,6 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label expect(entranceCount).toBe(60) }) - it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => { - const grid = { originX: 0, originY: 0 } - const unresolvedWarp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Unresolved Warp', - toLevelId: -1, - warpId: 4, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -60, - selectY: -60, - selectDX: 120, - selectDY: 90, - } - const unresolvedEntrance: SceneEntrance = { - toLevelId: -1, - side: 'east', - label: 'Unresolved Seam', - x: 34, - y: 30, - arriveX: 33, - arriveY: 30, - } - const playerPos = subTileToScene(31, 30, grid) - const warpAnchor = subTileToScene(unresolvedWarp.x, unresolvedWarp.y, grid) - const enteredWarps: SceneWarp[] = [] - const enteredEntrances: SceneEntrance[] = [] - - expect(hitTestWarp(unresolvedWarp, warpAnchor.x, warpAnchor.y, grid).hit).toBe(false) - const entranceAnchor = subTileToScene(unresolvedEntrance.x, unresolvedEntrance.y, grid) - expect(hitTestEntrance(unresolvedEntrance, entranceAnchor.x, entranceAnchor.y, grid).hit).toBe(false) - - const children: HTMLDivElement[] = [] - const mockLabelsContainer = { - children, - appendChild(el: HTMLDivElement) { - children.push(el) - return el - }, - } as unknown as HTMLElement - - const scene = createTestScene({ - playerPos: { ...playerPos }, - rightSkillId: 43, - warps: [unresolvedWarp], - entrances: [unresolvedEntrance], - onEnterWarp: (w) => enteredWarps.push(w), - onEnterEntrance: (e) => enteredEntrances.push(e), - labelsContainer: mockLabelsContainer, - }) - - // No overhead badges should be rendered for unresolved links - expect(children.filter(el => !el.hidden)).toHaveLength(0) - - const clientX = 960 + (warpAnchor.x - playerPos.x) - const clientY = 540 + (warpAnchor.y - (playerPos.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX, - clientY, - })) - expect(scene.controller.hoveredWarp).toBeNull() - expect(scene.controller.hoveredEntrance).toBeNull() - expect(scene.canvas.style.cursor).toBe('') - - // Left-click should walk on ground rather than activating unresolved warp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 0, - })) - expect(enteredWarps).toHaveLength(0) - expect(enteredEntrances).toHaveLength(0) - - // Right-click Telekinesis should not trigger unresolved warp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 2, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 2, - })) - expect(enteredWarps).toHaveLength(0) - }) - - it('strictly enforces TELEKINESIS_RANGE_PX against warp anchorPos/arrivePos and does not spam Telekinesis while right mouse is held', () => { - const grid = { originX: 0, originY: 0 } - // Warp with a wide selection box: anchor and arrive are > 320px away from player, - // but the left edge of the selection box is < 320px from player. - const wideBoxWarp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Harem Level 1', - toLevelId: 50, - warpId: 23, - x: 50, - y: 30, - arriveX: 52, - arriveY: 30, - exitWalkX: 52, - exitWalkY: 30, - selectX: -140, - selectY: -80, - selectDX: 200, - selectDY: 140, - } - const anchorPos = subTileToScene(wideBoxWarp.x, wideBoxWarp.y, grid) - const arrivePos = subTileToScene(wideBoxWarp.arriveX, wideBoxWarp.arriveY, grid) - // Place player 380px left of anchorPos (> 320px), so anchorPos-120px is 260px from player (<= 320px) - const playerPos = { x: anchorPos.x - 380, y: anchorPos.y } - expect(Math.hypot(anchorPos.x - playerPos.x, anchorPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) - expect(Math.hypot(arrivePos.x - playerPos.x, arrivePos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) - - const enteredWarps: SceneWarp[] = [] - const scene = createTestScene({ - playerPos: { ...playerPos }, - rightSkillId: 43, - warps: [wideBoxWarp], - onEnterWarp: (w) => enteredWarps.push(w), - }) - - const nearCornerWorldX = anchorPos.x - 120 - const nearCornerWorldY = anchorPos.y - expect(Math.hypot(nearCornerWorldX - playerPos.x, nearCornerWorldY - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX) - - const startMana = scene.hudManager.mana - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + (nearCornerWorldX - playerPos.x), - clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)), - button: 2, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX: 960 + (nearCornerWorldX - playerPos.x), - clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)), - button: 2, - })) - // Must reject activation and refund mana because warp anchor & arrive are > 320px away - expect(enteredWarps).toHaveLength(0) - expect(scene.hudManager.mana).toBe(startMana) - expect(scene.status.textContent).toBe('目标超出心灵传动范围!') - - // Move player within range (200px left of anchorPos) and hold right mouse down across 5 ticks - scene.engine.world.player.x = anchorPos.x - 200 - scene.engine.world.player.y = anchorPos.y - const inRangeClientX = 960 + 200 - const inRangeClientY = 540 - 16 - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: inRangeClientX, - clientY: inRangeClientY, - button: 2, - buttons: 2, - })) - expect(enteredWarps).toHaveLength(1) - const manaAfterSingleCast = scene.hudManager.mana - expect(manaAfterSingleCast).toBeLessThan(startMana) - - // Holding right button across subsequent ticks must NOT re-cast Telekinesis 25 times/sec - for (let i = 0; i < 5; i++) { - scene.controller.tick() - } - expect(enteredWarps).toHaveLength(1) - expect(scene.hudManager.mana).toBe(manaAfterSingleCast) - }) - it('handles non-unit collision bitmask bytes (e.g. 0x21) and routes around walls when target is inside a 3x3 blocked footprint', () => { const gridW = 100 const blocked = new Uint8Array(gridW * gridW) @@ -2013,63 +335,6 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label expect(blocked[steerSub.y * gridW + steerSub.x]).toBe(0) }) - it('expands badge hit-testing width for long Chinese destination names and cleans up state on detach()', () => { - const grid = { originX: 0, originY: 0 } - // Level 66: 塔·拉夏古墓 (1) -> badgeText = '通往 塔·拉夏古墓 (1)' (13 chars, half-width = 99px > 64px) - const tombWarp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Tal Rasha Tomb', - toLevelId: 66, - warpId: 49, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -40, - selectY: -40, - selectDX: 80, - selectDY: 80, - } - const labelPos = getWarpLabelWorldPos(tombWarp, grid) - // Click 85px to the right of badge center (> 64px default half-width, within 13-char badge half-width 99px) - const wideClick = hitTestWarp(tombWarp, labelPos.x + 85, labelPos.y - 12, grid) - expect(wideClick.hit).toBe(true) - - const playerPos = subTileToScene(31, 30, grid) - const children: HTMLDivElement[] = [] - const mockLabelsContainer = { - children, - appendChild(el: HTMLDivElement) { - children.push(el) - return el - }, - } as unknown as HTMLElement - - const scene = createTestScene({ - playerPos: { ...playerPos }, - warps: [tombWarp], - labelsContainer: mockLabelsContainer, - }) - - // Hover over the wide badge edge - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX: 960 + ((labelPos.x + 85) - playerPos.x), - clientY: 540 + ((labelPos.y - 12) - (playerPos.y - 16)), - })) - expect(scene.controller.hoveredWarp).toBe(tombWarp) - expect(scene.canvas.style.cursor).toBe('pointer') - expect(children[0]!.hidden).toBe(false) - - // Detach must clear hover, reset cursor, and hide pooled labels - scene.controller.detach() - expect(scene.controller.hoveredWarp).toBeNull() - expect(scene.canvas.style.cursor).toBe('') - expect(children[0]!.hidden).toBe(true) - }) - it('resolves multi-warp and warp-vs-entrance overlaps by true Euclidean distance and prioritizes foreground overhead badges over background LvlWarp.txt boxes', () => { const grid = { originX: 0, originY: 0 } // Warp A has a huge LvlWarp.txt selection box centered at (30, 30) @@ -2147,156 +412,5 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label expect(hitBonBadgeB.hit).toBe(true) expect(hitBonBadgeB.dist).toBeLessThan(hitAonBadgeB.dist) }) - - it('prioritizes an open Town Portal over an overlapping SceneWarp for hover, left-click, and Telekinesis across the full [portalPos.y - 80, portalPos.y] vertical oval', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, - warpId: 4, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -100, - selectY: -120, - selectDX: 200, - selectDY: 160, - } - const portalPos = subTileToScene(30, 30, grid) - const playerPos = subTileToScene(31, 30, grid) - let enteredPortalCount = 0 - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...playerPos }, - rightSkillId: 43, // Telekinesis - warps: [warp], - portalSlot: { - mouthInLevel: () => ({ x: 30, y: 30 }), - }, - onEnterPortal: () => { - enteredPortalCount += 1 - }, - onEnterWarp: (w) => enteredWarps.push(w), - }) - - // Upper part of the Town Portal oval: (portalPos.x + 24, portalPos.y - 75) - // Within 48px of [portalPos.y - 80, portalPos.y], and also inside warp's LvlWarp.txt box. - const targetWorldX = portalPos.x + 24 - const targetWorldY = portalPos.y - 75 - const clientX = 960 + (targetWorldX - playerPos.x) - const clientY = 540 + (targetWorldY - (playerPos.y - 16)) - - // 1. Hovering the portal oval suppresses hoveredWarp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { - clientX, - clientY, - })) - expect(scene.controller.hoveredWarp).toBeNull() - - // 2. Left-clicking the portal oval enters the Town Portal instead of the SceneWarp - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - })) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { - clientX, - clientY, - button: 0, - })) - expect(enteredPortalCount).toBe(1) - expect(enteredWarps).toHaveLength(0) - - // 3. Right-clicking with Telekinesis (Skill 43) near the top of the portal oval (targetY = portalPos.y - 95, - // distance to clampedPortalY (portalPos.y - 80) ishypot(30, -15) = 33.5px <= 64px, whereas distance to - // portalPos.y - 40 is hypot(30, -55) = 62.6px and at (40, -95) is hypot(40, -15) = 42.7px <= 64px vs hypot(40, -55) = 68.0px > 64px) - // enters the Town Portal rather than the underlying SceneWarp. - const tkWorldX = portalPos.x + 40 - const tkWorldY = portalPos.y - 95 - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX: 960 + (tkWorldX - playerPos.x), - clientY: 540 + (tkWorldY - (playerPos.y - 16)), - button: 2, - })) - expect(enteredPortalCount).toBe(2) - expect(enteredWarps).toHaveLength(0) - }) - - it('clears isLeftMouseDown and activeClickType on warp/entrance/portal trigger and suppresses pointer/tick actions while isTravelling() is true', () => { - const grid = { originX: 0, originY: 0 } - const warp: SceneWarp = { - source: 'tile', - direction: 'down', - label: 'Den of Evil', - toLevelId: 8, - warpId: 4, - x: 30, - y: 30, - arriveX: 31, - arriveY: 30, - exitWalkX: 31, - exitWalkY: 30, - selectX: -48, - selectY: -48, - selectDX: 96, - selectDY: 96, - } - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) - const distantPlayer = subTileToScene(45, 30, grid) - let travelling = false - const enteredWarps: SceneWarp[] = [] - - const scene = createTestScene({ - playerPos: { ...distantPlayer }, - warps: [warp], - isTravelling: () => travelling, - onEnterWarp: (w) => { - enteredWarps.push(w) - travelling = true - }, - }) - - // Hold left mouse button down on distant warp (no pointerup) - const clientX = 960 + (anchorPos.x - distantPlayer.x) - const clientY = 540 + (anchorPos.y - (distantPlayer.y - 16)) - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - buttons: 1, - })) - expect(scene.controller.isLeftMouseDown).toBe(true) - expect(scene.controller.activeClickType).toBe('warp') - expect(scene.controller.pendingInteraction?.kind).toBe('warp') - - // Arrive at warp while still holding left mouse button down - scene.engine.world.player.x = arrivePos.x - scene.engine.world.player.y = arrivePos.y - const arriveTick = scene.controller.tick() - expect(enteredWarps).toEqual([warp]) - expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) - // Left mouse hold and activeClickType must be cleared immediately upon transition trigger - expect(scene.controller.isLeftMouseDown).toBe(false) - expect(scene.controller.activeClickType).toBe('none') - - // While travelling is true, new pointerdown events and tick() calls must be suppressed - scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { - clientX, - clientY, - button: 0, - buttons: 1, - })) - expect(scene.controller.isLeftMouseDown).toBe(false) - expect(enteredWarps).toHaveLength(1) - expect(scene.controller.tick()).toEqual({ movement: { x: 0, y: 0 }, attacking: false }) - }) }) - diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts index 3cc205b..21f2b6f 100644 --- a/tests/inventory.test.ts +++ b/tests/inventory.test.ts @@ -8,19 +8,13 @@ import { describe, expect, it } from 'vitest' import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' -import { - resolveItemSpriteRect, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, - InventoryPanel, - EQUIP_SLOTS_LAYOUT, - INV_GRID_ORIGIN, -} from '../src/client/ui/inventory.ts' +import { resolveItemSpriteRect, InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { MountedArchives } from '../src/common/mpq/mount.ts' import { MpqArchive } from '../src/common/mpq/archive.ts' import { fileSource } from '../src/baker/io/file-source.ts' import { parseTable } from '../src/common/items/tables.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts' describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () => { const itemRects = BAKED_UI_MANIFEST.itemRects @@ -241,7 +235,7 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () }) it('resolves valid sprite rectangles for all starter equipped items and bag items', () => { - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) { if (!item) continue const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Starter equipped ${slot} (${item.name}) must resolve`).not.toBeNull() @@ -249,7 +243,7 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', () expect(rect!.h).toBeGreaterThan(0) } - for (const { item, col, row } of STARTER_BAG_ITEMS) { + for (const { item, col, row } of DEMO_BAG_ITEMS) { const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Starter bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull() expect(rect!.w).toBeGreaterThan(0) diff --git a/tests/issue-480-tbl-item-parity.test.ts b/tests/issue-480-tbl-item-parity.test.ts index 125097d..d22fc4a 100644 --- a/tests/issue-480-tbl-item-parity.test.ts +++ b/tests/issue-480-tbl-item-parity.test.ts @@ -28,12 +28,12 @@ import { import { createDroppedItem } from '../src/common/items/drop-tables.ts' import { ItemQuality, type ItemBase } from '../src/common/items/items.ts' import type { GroundItemEntity } from '../src/common/items/ground-items.ts' -import { formatGroundItemLabelText } from '../src/client/ui/ground-labels.ts' -import { STARTER_EQUIPPED_GEAR } from '../src/client/ui/inventory.ts' + import { CLASS_EXCLUSIVE_SETS, STARTER_STASH_ITEMS, } from '../src/common/units/class-starter-profiles.ts' +import { DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts' describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inventory, Stash, and Ground', () => { beforeEach(() => { @@ -140,57 +140,6 @@ describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inve level: 1, } - it('resolves dropped item .name and .nameZh through .tbl and renders ground labels accurately', () => { - // hp1 has internal Misc.txt name "Lesser Healing Potion", must resolve to "Minor Healing Potion" / "轻微治疗药剂" - const hp1Drop = createDroppedItem(hp1Base, 'normal', { ilvl: 1, dwInitSeed: 480 }) - expect(hp1Drop.name).toBe('Minor Healing Potion') - expect(hp1Drop.nameZh).toBe('轻微治疗药剂') - - const makeGroundEntity = (partial: Partial): GroundItemEntity => ({ - id: 'g-1', - item: hp1Drop, - name: hp1Drop.name, - nameZh: hp1Drop.nameZh ?? '', - quality: 'normal', - isGold: false, - amount: 1, - invWidth: 1, - invHeight: 1, - dropTime: 0, - x: 0, - y: 0, - cellX: 0, - cellY: 0, - sparklePhase: 0, - ...partial, - }) - - expect(formatGroundItemLabelText(makeGroundEntity({}))).toBe('轻微治疗药剂') - - // Ground item without pre-populated nameZh resolves via .tbl in formatGroundItemLabelText - expect( - formatGroundItemLabelText( - makeGroundEntity({ - name: 'GhoulRI barRI', - nameZh: '', - quality: 'rare', - item: undefined, - }), - ), - ).toBe('食尸鬼之酒吧') - - expect( - formatGroundItemLabelText( - makeGroundEntity({ - name: "Mechanist's Cap of the Colosuss", - nameZh: '', - quality: 'magic', - item: undefined, - }), - ), - ).toBe('机械师之巨神之帽子') - }) - it('formats tooltip titles for Rare and Magic items with internal .txt keys via .tbl', () => { const capDrop = createDroppedItem(capBase, 'rare', { ilvl: 20, dwInitSeed: 480 }) const rareItem = { @@ -213,8 +162,8 @@ describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inve }) describe('4. Inventory & Equipped Gear (背包与装备栏): 1-Stat-Per-Line .tbl Compliance', () => { - it('ensures STARTER_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => { - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { + it('ensures DEMO_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => { + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) { if (!item) continue for (const stat of item.stats) { // "高速跑步/行走" is the single stat FRW name in Chinese, not a multi-stat merge diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index eeb484f..6f5abb9 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -37,7 +37,7 @@ import { monsterDisplayName, } from '../src/common/units/monsters-data.ts' import { TOWN_NPC_DESCRIPTORS, getLocalizedNpcName } from '../src/common/units/npc-table.ts' -import { createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts' +import { CLASS_EXCLUSIVE_SETS, createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts' import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts' @@ -46,7 +46,6 @@ import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/client/ui/worl import { ControlBarHud } from '../src/client/ui/control-bar.ts' import { GlobesHud } from '../src/client/ui/globes.ts' import { SkillHotkeysHud } from '../src/client/ui/hotkeys.ts' -import { cleanChiItemName, formatGroundItemLabelText } from '../src/client/ui/ground-labels.ts' import type { D2FontRenderer } from '../src/client/ui/font.ts' const CJK_REGEX = /[\u4e00-\u9fff]/ @@ -539,15 +538,14 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () } }) - it('renders zero CJK in ENG tooltips and zero mixed parentheticals in CHI ground labels across all 7 class starter profiles', () => { + it('renders zero CJK in ENG tooltips across all 7 class starter sets and bags', () => { const classCodes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const expect(classCodes.length).toBe(7) for (const code of classCodes) { - const profile = createStarterProfileForClass(code) const allItems = [ - ...Object.values(profile.equipped).filter(Boolean), - ...profile.bagItems.map(b => b.item), + ...Object.values(CLASS_EXCLUSIVE_SETS[code]).filter(Boolean), + ...createStarterBagForClass(code).map(b => b.item), ] for (const item of allItems) { if (!item) continue @@ -559,24 +557,6 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () const cjkLines = textCalls.filter(line => CJK_REGEX.test(line)) expect(cjkLines, `Starter item ${item.id} (${code}) leaked CJK in ENG tooltip: ${cjkLines.join(' | ')}`).toEqual([]) - // 2. CHI Ground Label & Drop Notification check - setTblLang('CHI') - const groundEntity = { - id: 1, - name: item.name, - nameZh: item.nameZh, - quality: item.quality, - x: 0, - y: 0, - item, - isGold: false, - amount: 1, - droppedAt: 0, - } as any - const labelChi = formatGroundItemLabelText(groundEntity) - expect(/\([A-Za-z]/.test(labelChi), `Ground label in CHI leaked English parens: ${labelChi}`).toBe(false) - const cleanedDropName = cleanChiItemName(item.nameZh) - expect(/\([A-Za-z]/.test(cleanedDropName), `cleanChiItemName leaked English parens: ${cleanedDropName}`).toBe(false) } } }) diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 423bc08..78d83dd 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -18,11 +18,6 @@ import { } from '../src/client/ui/font.ts' import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' -import { - GroundLabelOverlay, - computeInitialGroundLabelLayouts, - resolveLadderCollisions, -} from '../src/client/ui/ground-labels.ts' import { computeHudLayout, computeDockingLayout, @@ -227,7 +222,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { expect(view.getUint32(0, false)).toBe(0x00010000) }) - it('registers local @font-face D2Exocet and D2Formal436 across HTML pages and GroundLabelOverlay with zero external CDN links', () => { + it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => { const htmlFiles = ['play.html'] for (const file of htmlFiles) { const content = readFileSync(join(process.cwd(), file), 'utf8') @@ -244,11 +239,6 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { expect(content).not.toContain('https://') expect(content).not.toContain('http://') } - - const groundLabelsSrc = readFileSync(join(process.cwd(), 'src', 'client', 'ui', 'ground-labels.ts'), 'utf8') - expect(groundLabelsSrc).toContain('"D2Exocet"') - expect(groundLabelsSrc).toContain('"Times New Roman"') - expect(groundLabelsSrc).not.toContain('Georgia') }) it('configures D2FontRenderer with D2Exocet and D2Formal436 stacks (never Georgia) and blits genuine 1.13c DC6 glyphs when loaded', () => { @@ -860,80 +850,4 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { renderer.measureText('+20% Faster Cast Rate (Lightning Resist +40%) (Fire Resist +35%)', 'font8'), ) }) - - it('scales #d2-hud-canvas and GroundLabelOverlay accurately under High-DPI (devicePixelRatio > 1) viewport zoom', () => { - const cssW = 1280 - const cssH = 840 - const layout1x = computeHudLayout(cssW, cssH, 1) - const layout15x = computeHudLayout(cssW, cssH, 1.5) - const layout2x = computeHudLayout(cssW, cssH, 2) - - expect(layout1x.uiScale).toBeCloseTo(840 / 600, 6) - expect(layout2x.uiScale).toBeCloseTo(layout1x.uiScale, 6) - expect(layout15x.physicalScale).toBeCloseTo(layout1x.uiScale * 1.5, 6) - expect(layout2x.physicalScale).toBeCloseTo(layout1x.uiScale * 2, 6) - expect(layout2x.physicalOffsetX).toBe(Math.round(layout1x.offsetX * 2)) - expect(layout2x.physicalOffsetY).toBe(Math.round(layout1x.offsetY * 2)) - - // Verify clientToLogical / logicalToClient round-trip invariance across DPR = 1, 1.5, 2 - const rect = { left: 0, top: 0, width: cssW, height: cssH } - for (const dpr of [1, 1.5, 2]) { - const docking = computeDockingLayout(cssW, cssH, dpr) - expect(docking.isWidescreen).toBe(true) - const clientPt = logicalToClient(561, 93, rect, dpr) - const logicalPt = clientToLogical(clientPt.x, clientPt.y, rect, dpr) - expect(logicalPt.x).toBeCloseTo(561, 5) - expect(logicalPt.y).toBeCloseTo(93, 5) - } - - // Verify GroundLabelOverlay projection invariance when canvas physical buffer scales by dpr - const items = [ - { - id: 'item-shako', - name: 'Shako', - nameZh: '军帽', - quality: 'unique' as const, - x: 120, - y: 80, - subTileX: 15, - subTileY: 10, - isGold: false, - amount: 1, - droppedAtMs: 0, - }, - { - id: 'item-monarch', - name: 'Monarch', - nameZh: '统治者大盾', - quality: 'rare' as const, - x: 122, - y: 81, - subTileX: 15, - subTileY: 10, - isGold: false, - amount: 1, - droppedAtMs: 0, - }, - ] - - const projectAtDpr = (dpr: number) => { - const canvasWidth = Math.round(cssW * dpr) - const canvasHeight = Math.round(cssH * dpr) - const cameraZoom = 1.0 * dpr - const scaleX = rect.width / canvasWidth - const scaleY = rect.height / canvasHeight - return (worldX: number, worldY: number) => ({ - x: rect.left + rect.width * 0.5 + (worldX - 100) * cameraZoom * scaleX, - y: rect.top + rect.height * 0.5 + (worldY - 70) * cameraZoom * scaleY, - }) - } - - const resolved1x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(1))) - const resolved2x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(2))) - expect(resolved2x).toHaveLength(2) - expect(resolved2x[0]!.screenX).toBeCloseTo(resolved1x[0]!.screenX, 5) - expect(resolved2x[0]!.screenY).toBeCloseTo(resolved1x[0]!.screenY, 5) - expect(resolved2x[1]!.screenX).toBeCloseTo(resolved1x[1]!.screenX, 5) - expect(resolved2x[1]!.screenY).toBeCloseTo(resolved1x[1]!.screenY, 5) - }) }) diff --git a/tests/issue-506-equipment-restrictions.test.ts b/tests/issue-506-equipment-restrictions.test.ts index 3135cff..ea392e9 100644 --- a/tests/issue-506-equipment-restrictions.test.ts +++ b/tests/issue-506-equipment-restrictions.test.ts @@ -17,7 +17,7 @@ import { import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { D2FontRenderer } from '../src/client/ui/font.ts' import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { CLASS_STARTER_ATTRS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts' +import { CLASS_EXCLUSIVE_SETS, CLASS_STARTER_ATTRS } from '../src/common/units/class-starter-profiles.ts' import type { CharacterClassCode } from '../src/common/units/classes.ts' function createMockCtx() { @@ -583,14 +583,13 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] for (const classCode of classes) { const attrs = CLASS_STARTER_ATTRS[classCode] - const profile = createStarterProfileForClass(classCode) const ctx = { level: attrs.level, str: attrs.str, dex: attrs.dex, classCode, } - for (const [slotId, item] of Object.entries(profile.equipped)) { + for (const [slotId, item] of Object.entries(CLASS_EXCLUSIVE_SETS[classCode])) { if (!item) continue expect( canPlayerUseUiItem(item, ctx), diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts index 18b993e..1a8b069 100644 --- a/tests/item-to-ui-bridge.test.ts +++ b/tests/item-to-ui-bridge.test.ts @@ -28,7 +28,6 @@ import { import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' -import { SceneMouseController } from '../src/client/world/act-runtime.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => { @@ -392,45 +391,6 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & return engine } - it('converts raw ground item into UiInventoryItem upon normal pickup and places into inventory grid', () => { - const engine = createTestEngine() - const invPanel = new InventoryPanel() - invPanel.gridItems = [] - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - const rawItem = createTestItem({ - base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2 }, - }) - const dropped = engine.dropItem(rawItem as any, 510, 500) - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - controller.pickupGroundItem(dropped) - - // Item should be picked up into invPanel.gridItems - expect(invPanel.gridItems.length).toBe(1) - const placedItem = invPanel.gridItems[0]!.item - expect(placedItem.allowedSlots).toEqual(['weapon1', 'weapon2']) - expect(placedItem.invFile).toBe('invhax') - expect(placedItem.nameZh).toContain('斧') - expect(hudManager.syncPublishedState).toHaveBeenCalled() - expect(engine.groundItems.count).toBe(0) - }) - it('converts raw ground item into UiInventoryItem upon Telekinesis pickup', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() @@ -501,43 +461,6 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & }) } - it('accumulates gold and clamps to authentic inventory gold capacity on normal pickup', () => { - const engine = createTestEngine() - engine.world.player.level = 10 - engine.bag.gold = 95_000 - const invPanel = new InventoryPanel() - invPanel.playerLevel = 10 - invPanel.gold = 95_000 - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - // Drop 10,000 gold pile - const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true }) - expect(goldDrop).not.toBeNull() - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - controller.pickupGroundItem(goldDrop!) - - // 95,000 + 5,000 clamped to 100,000 - expect(invPanel.gold).toBe(100_000) - expect(hudManager.syncPublishedState).toHaveBeenCalled() - expect(mockStatus.textContent).toContain('拾起金币:5000') - }) - it('accumulates gold and clamps to authentic inventory gold capacity on Telekinesis pickup', () => { const engine = createTestEngine() engine.world.player.level = 10 @@ -566,112 +489,4 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & expect(hudManager.syncPublishedState).toHaveBeenCalled() }) }) - - describe('7. Inventory Full Refusal & Parabolic Flippy Bounce', () => { - let mockCanvas: any - let mockStatus: { textContent: string } - - beforeEach(() => { - mockStatus = { textContent: '' } - mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - }) - - it('refuses pickup when 10x4 grid has no free space, triggering bounce and refusal feedback', () => { - const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } - const engine = new GameEngine(terrain, { - spawn: { x: 500, y: 500 }, - stats: [], - xpTable: DEMO_EXPERIENCE, - skills: DEMO_SKILLS, - questDefs: DEMO_QUESTS, - combatOptions: { - playerSpeed: 4, - playerReach: 50, - playerCooldownTicks: 10, - playerDamage: 5, - playerManaPerAttack: 1, - respawnTicks: 100, - }, - talkRadius: 48, - pickupRadius: 48, - inventoryCols: 10, - inventoryRows: 4, - lootSeed: 1234, - npcDefs: [], - }) - engine.setDropTables(dropTables) - - const invPanel = new InventoryPanel() - invPanel.gridItems = [] - // Completely fill the 10x4 inventory grid with 2x2 items (10 items = 40 cells) - for (let c = 0; c < 10; c += 2) { - for (let r = 0; r < 4; r += 2) { - invPanel.gridItems.push({ - item: { - id: `filler-${c}-${r}`, - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '帽子 (Cap)', - baseNameZh: '帽子 (Cap)', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - col: c, - row: r, - }) - } - } - expect(invPanel.gridItems.length).toBe(10) // 10 * 4 = 40 cells, 100% full - - const dropped = engine.dropItem( - createTestItem({ - base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 }, - }) as any, - 510, - 500, - ) - - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback') - const notificationSpy = vi.spyOn(controller, 'showNotification') - - controller.pickupGroundItem(dropped) - - // Item refused - expect(engine.metrics.inventoryRefusals).toBe(1) - expect(engine.groundItems.count).toBe(1) - expect(dropped.bounceState).toBeDefined() - expect(dropped.bounceState!.phase).toBe('primary') - expect(dropped.bounceState!.durationMs).toBe(350) - expect(dropped.bounceState!.peakHeightPx).toBe(28) - expect(feedbackSpy).toHaveBeenCalled() - expect(notificationSpy).toHaveBeenCalledWith('包裹已满。') - expect(mockStatus.textContent).toBe('包裹已满。') - }) - }) }) diff --git a/tests/item-tooltip-color-dye.test.ts b/tests/item-tooltip-color-dye.test.ts index 2d95fdd..b37191b 100644 --- a/tests/item-tooltip-color-dye.test.ts +++ b/tests/item-tooltip-color-dye.test.ts @@ -12,8 +12,8 @@ * Test Architecture: * - Tier 1: Feature Coverage (Happy Path) * 1.1 resolveItemSpriteRect resolves dyed sprites: invcap_cgrn, invgth_dpur, invtgl_lgry, invvbl_blac - * 1.2 STARTER_EQUIPPED_GEAR definitions contain canonical 1.13c stats, durability, speed text, dye tokens - * 1.3 Call to Arms Crystal Sword in STARTER_BAG_ITEMS contains runewordRunes, durability 20/20, reqStr 43, reqLevel 57, sockets 5, speedText, damage 17-52, and 11 canonical stats + * 1.2 DEMO_EQUIPPED_GEAR definitions contain canonical 1.13c stats, durability, speed text, dye tokens + * 1.3 Call to Arms Crystal Sword in DEMO_BAG_ITEMS contains runewordRunes, durability 20/20, reqStr 43, reqLevel 57, sockets 5, speedText, damage 17-52, and 11 canonical stats * 1.4 Tooltip header hierarchy (Title fontexocet10 Gold, Runes font8 Gold, Base Type font8 White/Grey) * 1.5 Base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey) * 1.6 Magic affixes in Blue, Sockets line in Blue ('凹槽 (5)') @@ -36,22 +36,12 @@ */ import { describe, expect, it, vi } from 'vitest' -import { - InventoryPanel, - EQUIP_SLOTS_LAYOUT, - INV_GRID_ORIGIN, - resolveItemSpriteRect, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, - type EquipSlotId, - type ResolvableItem, - type SpriteRect, - type UiInventoryItem, -} from '../src/client/ui/inventory.ts' +import { InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN, resolveItemSpriteRect, type EquipSlotId, type ResolvableItem, type SpriteRect, type UiInventoryItem } from '../src/client/ui/inventory.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts' import { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts' // --- Mock & Test Utilities --- @@ -182,10 +172,10 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(arachRect, 'Arachnid Mesh must resolve to invvbl_blac').toEqual(MOCK_DYED_ITEM_RECTS.invvbl_blac) }) - it('1.2 defines STARTER_EQUIPPED_GEAR with canonical 1.13c stats, durability, speed text, and dye tokens', () => { + it('1.2 defines DEMO_EQUIPPED_GEAR with canonical 1.13c stats, durability, speed text, and dye tokens', () => { // Helm: Harlequin Crest (Shako) - const helm = STARTER_EQUIPPED_GEAR.helm as any - expect(helm, 'STARTER_EQUIPPED_GEAR.helm must exist').toBeDefined() + const helm = DEMO_EQUIPPED_GEAR.helm as any + expect(helm, 'DEMO_EQUIPPED_GEAR.helm must exist').toBeDefined() expect(helm.invtransform, 'Harlequin Crest must have invtransform: cgrn').toBe('cgrn') expect(helm.defense, 'Shako defense must be 141').toBe(141) expect(helm.durability, 'Shako durability must be 12/12').toEqual({ current: 12, max: 12 }) @@ -194,8 +184,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(helm.stats.length, 'Shako must have exactly 6 canonical modifiers').toBe(6) // Armor: Tal Rasha\'s Guardianship (Lacquered Plate) - const armor = STARTER_EQUIPPED_GEAR.armor as any - expect(armor, 'STARTER_EQUIPPED_GEAR.armor must exist').toBeDefined() + const armor = DEMO_EQUIPPED_GEAR.armor as any + expect(armor, 'DEMO_EQUIPPED_GEAR.armor must exist').toBeDefined() expect(armor.invtransform, 'Tal Rasha Armor must have invtransform: dpur').toBe('dpur') expect(armor.defense, 'Tal Rasha Armor defense must be 941').toBe(941) expect(armor.durability, 'Tal Rasha Armor durability must be 55/55').toEqual({ current: 55, max: 55 }) @@ -205,8 +195,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(armor.setPieces.length, 'Tal Rasha set must contain 5 set pieces').toBeGreaterThanOrEqual(5) // Gloves: Magefist (Light Gauntlets) - const gloves = STARTER_EQUIPPED_GEAR.gloves as any - expect(gloves, 'STARTER_EQUIPPED_GEAR.gloves must exist').toBeDefined() + const gloves = DEMO_EQUIPPED_GEAR.gloves as any + expect(gloves, 'DEMO_EQUIPPED_GEAR.gloves must exist').toBeDefined() expect(gloves.invtransform, 'Magefist must have invtransform: lgry').toBe('lgry') expect(gloves.defense, 'Magefist defense must be 25').toBe(25) expect(gloves.durability, 'Magefist durability must be 14/14').toEqual({ current: 14, max: 14 }) @@ -214,8 +204,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(gloves.reqLevel, 'Magefist reqLevel must be 23').toBe(23) // Belt: Arachnid Mesh (Spiderweb Sash) - const belt = STARTER_EQUIPPED_GEAR.belt as any - expect(belt, 'STARTER_EQUIPPED_GEAR.belt must exist').toBeDefined() + const belt = DEMO_EQUIPPED_GEAR.belt as any + expect(belt, 'DEMO_EQUIPPED_GEAR.belt must exist').toBeDefined() expect(belt.invtransform, 'Arachnid Mesh must have invtransform: blac').toBe('blac') expect(belt.defense, 'Arachnid Mesh defense must be 138').toBe(138) expect(belt.durability, 'Arachnid Mesh durability must be 12/12').toEqual({ current: 12, max: 12 }) @@ -223,16 +213,16 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(belt.reqLevel, 'Arachnid Mesh reqLevel must be 80').toBe(80) // Weapon 1: The Oculus - const oculus = STARTER_EQUIPPED_GEAR.weapon1 as any - expect(oculus, 'STARTER_EQUIPPED_GEAR.weapon1 must exist').toBeDefined() + const oculus = DEMO_EQUIPPED_GEAR.weapon1 as any + expect(oculus, 'DEMO_EQUIPPED_GEAR.weapon1 must exist').toBeDefined() expect(oculus.damage, 'Oculus damage must be 18 - 42').toBe('18 - 42') expect(oculus.durability, 'Oculus durability must be 50/50').toEqual({ current: 50, max: 50 }) expect(oculus.speedText, 'Oculus weapon speed class must be defined').toBe('法球类 - 普通攻击速度') expect(oculus.reqLevel, 'Oculus reqLevel must be 42').toBe(42) // Weapon 2: Spirit Monarch - const spirit = STARTER_EQUIPPED_GEAR.weapon2 as any - expect(spirit, 'STARTER_EQUIPPED_GEAR.weapon2 must exist').toBeDefined() + const spirit = DEMO_EQUIPPED_GEAR.weapon2 as any + expect(spirit, 'DEMO_EQUIPPED_GEAR.weapon2 must exist').toBeDefined() expect(spirit.runewordRunes, 'Spirit Monarch must declare runeword runes').toBe("'TalThulOrtAmn'") expect(spirit.defense, 'Spirit Monarch defense must be 148').toBe(148) expect(spirit.durability, 'Spirit Monarch durability must be 148/148').toEqual({ current: 148, max: 148 }) @@ -241,17 +231,17 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () expect(spirit.sockets, 'Spirit Monarch must have 4 sockets').toBe(4) // Boots: War Traveler - const boots = STARTER_EQUIPPED_GEAR.boots as any - expect(boots, 'STARTER_EQUIPPED_GEAR.boots must exist').toBeDefined() + const boots = DEMO_EQUIPPED_GEAR.boots as any + expect(boots, 'DEMO_EQUIPPED_GEAR.boots must exist').toBeDefined() expect(boots.defense, 'War Traveler defense must be 139').toBe(139) expect(boots.durability, 'War Traveler durability must be 48/48').toEqual({ current: 48, max: 48 }) expect(boots.reqStr, 'War Traveler reqStr must be 95').toBe(95) expect(boots.reqLevel, 'War Traveler reqLevel must be 42').toBe(42) }) - it('1.3 defines Call to Arms Crystal Sword in STARTER_BAG_ITEMS with all 11 canonical runeword & rune stats', () => { - const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') - expect(ctaPlacement, 'Call to Arms must be present in STARTER_BAG_ITEMS').toBeDefined() + it('1.3 defines Call to Arms Crystal Sword in DEMO_BAG_ITEMS with all 11 canonical runeword & rune stats', () => { + const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta') + expect(ctaPlacement, 'Call to Arms must be present in DEMO_BAG_ITEMS').toBeDefined() const cta = ctaPlacement!.item as any expect(cta.runewordRunes, 'Call to Arms must have runewordRunes: AmnRalMalIstOhm').toBe("'AmnRalMalIstOhm'") @@ -782,7 +772,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () const targetBagIds = ['bag-cta', 'bag-torch', 'bag-anni', 'bag-gheeds'] - for (const placement of STARTER_BAG_ITEMS) { + for (const placement of DEMO_BAG_ITEMS) { if (!targetBagIds.includes(placement.item.id)) continue const hoverX = INV_GRID_ORIGIN.x + placement.col * INV_GRID_ORIGIN.cellPx + 14 @@ -807,7 +797,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() - const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta')! + const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')! worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont) // Verify fillRect and strokeRect diff --git a/tests/loop.test.ts b/tests/loop.test.ts deleted file mode 100644 index 7995584..0000000 --- a/tests/loop.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { GameLoop } from '../src/client/sim/loop.ts' - -describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => { - it('1. Deterministic ticking at 25 tps in headless/step mode', () => { - let tickCount = 0 - const ticks: number[] = [] - - const loop = new GameLoop({ - tickRate: 25, // 40ms per tick - onTick: (tick) => { - tickCount++ - ticks.push(tick) - }, - onRender: () => {} - }) - - expect(loop.tickMs).toBe(40) - expect(loop.tick).toBe(0) - - // Advancing by less than tickMs does not produce a tick - loop.step(20) - expect(tickCount).toBe(0) - expect(loop.accumulator).toBe(20) - expect(loop.alpha).toBeCloseTo(0.5, 2) - - // Advancing another 20ms completes 40ms -> exactly 1 tick - loop.step(20) - expect(tickCount).toBe(1) - expect(loop.tick).toBe(1) - expect(loop.accumulator).toBe(0) - expect(loop.alpha).toBe(0) - - // Advancing by 80ms produces exactly 2 ticks - loop.step(80) - expect(tickCount).toBe(3) - expect(loop.tick).toBe(3) - expect(loop.accumulator).toBe(0) - - // Advancing by 1000ms produces exactly 25 ticks - loop.step(1000) - expect(tickCount).toBe(28) // 3 + 25 - expect(loop.tick).toBe(28) - expect(loop.tickRate).toBe(25.0) - expect(loop.simTps).toBe(25.0) - }) - - it('1b. Deterministic ticking at 25 tps with timers', async () => { - let tickCount = 0 - - const loop = new GameLoop({ - tickRate: 25, // 40ms per tick - onTick: () => { - tickCount++ - }, - onRender: () => {} - }) - - loop.start() - expect(loop.isRunning).toBe(true) - - // Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms) - await new Promise(resolve => setTimeout(resolve, 260)) - loop.stop() - expect(loop.isRunning).toBe(false) - - expect(tickCount).toBeGreaterThanOrEqual(5) - expect(tickCount).toBeLessThanOrEqual(8) - }) - - it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => { - const world = { tick: 0 } - let renderCalls = 0 - - const loop = new GameLoop({ - tickRate: 25, // 40ms per tick - onTick: (tick) => { - world.tick = tick - }, - onRender: () => { - renderCalls++ - // Simulate heavy render frame stall of 50ms - const stallStart = performance.now() - while (performance.now() - stallStart < 50) { - // busy wait - } - } - }) - - loop.start() - - // Run for ~220ms - // With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS) - // But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks - await new Promise(resolve => setTimeout(resolve, 220)) - loop.stop() - - expect(renderCalls).toBeLessThanOrEqual(4) - expect(world.tick).toBeGreaterThanOrEqual(5) - // Simulation TPS remains high despite slowed rendering - expect(loop.simTps).toBeGreaterThanOrEqual(20) - }) - - it('3. Accumulator recovery and no catastrophic drop on transient lag', () => { - let tickCount = 0 - - const loop = new GameLoop({ - tickRate: 25, // 40ms per tick - maxCatchUp: 5, // max 5 ticks (200ms) per step - onTick: () => { - tickCount++ - }, - onRender: () => {} - }) - - // Transient lag of 150ms (3.75 ticks) - loop.step(150) - // Consumed 3 ticks (120ms), 30ms remaining - expect(tickCount).toBe(3) - expect(loop.accumulator).toBe(30) - expect(loop.alpha).toBeCloseTo(30 / 40, 2) - - // Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left - loop.step(20) - expect(tickCount).toBe(4) - expect(loop.accumulator).toBe(10) - expect(loop.alpha).toBeCloseTo(10 / 40, 2) - - // Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms) - // Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0` - // which wiped out all remaining simulation time! - // New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0. - loop.step(500) - // 5 ticks executed in this step (tickCount 4 + 5 = 9) - expect(tickCount).toBe(9) - // Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0! - expect(loop.accumulator).toBe(200) - - // Subsequent steps recover and consume the preserved backlog - loop.step(40) - // Consumes 5 ticks from the 200ms backlog + 40ms new time - expect(tickCount).toBe(14) - expect(loop.accumulator).toBe(40) - - loop.step(40) - expect(tickCount).toBe(16) - expect(loop.accumulator).toBe(0) - }) - - it('4. Clean start / stop lifecycle and timer cleanup', async () => { - let tickCount = 0 - let renderCount = 0 - - const loop = new GameLoop({ - tickRate: 25, - onTick: () => { - tickCount++ - }, - onRender: () => { - renderCount++ - } - }) - - expect(loop.isRunning).toBe(false) - - // Start - loop.start() - expect(loop.isRunning).toBe(true) - - // Calling start() again is idempotent - loop.start() - expect(loop.isRunning).toBe(true) - - await new Promise(resolve => setTimeout(resolve, 120)) - expect(tickCount).toBeGreaterThanOrEqual(1) - expect(renderCount).toBeGreaterThanOrEqual(1) - - // Stop - loop.stop() - expect(loop.isRunning).toBe(false) - const stoppedTicks = tickCount - const stoppedRenders = renderCount - - // Calling stop() again is idempotent - loop.stop() - expect(loop.isRunning).toBe(false) - - // Wait and verify timers are cleaned up and no more callbacks fire - await new Promise(resolve => setTimeout(resolve, 100)) - expect(tickCount).toBe(stoppedTicks) - expect(renderCount).toBe(stoppedRenders) - - // Restart cleanly - loop.start() - expect(loop.isRunning).toBe(true) - await new Promise(resolve => setTimeout(resolve, 120)) - expect(tickCount).toBeGreaterThan(stoppedTicks) - expect(renderCount).toBeGreaterThan(stoppedRenders) - loop.stop() - }) - - it('5. Interpolation alpha calculation and manual render stepping', () => { - let lastAlpha = -1 - let lastFrameMs = -1 - - const loop = new GameLoop({ - tickRate: 25, // 40ms - onTick: () => {}, - onRender: (alpha, frameMs) => { - lastAlpha = alpha - lastFrameMs = frameMs - } - }) - - loop.render(16.6) - expect(lastAlpha).toBe(0) - expect(lastFrameMs).toBeCloseTo(16.6, 1) - - loop.step(10) // accumulator = 10ms (10/40 = 0.25) - loop.render(16.6) - expect(lastAlpha).toBeCloseTo(0.25, 2) - - loop.step(20) // accumulator = 30ms (30/40 = 0.75) - loop.render(16.6) - expect(lastAlpha).toBeCloseTo(0.75, 2) - - loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms - loop.render(16.6) - expect(lastAlpha).toBe(0) - }) -}) diff --git a/tests/m4-visual-belt-net-parity.test.ts b/tests/m4-visual-belt-net-parity.test.ts index 3e18a8b..c4d8275 100644 --- a/tests/m4-visual-belt-net-parity.test.ts +++ b/tests/m4-visual-belt-net-parity.test.ts @@ -8,17 +8,8 @@ */ import { describe, it, expect, vi } from 'vitest' -import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS, SceneMouseController } from '../src/client/world/act-runtime.ts' import { createNetSimulation } from '../src/server/engine/state-hash.ts' import { computeLockstepHash, LockstepSession } from '../src/server/engine/state-hash.ts' -import { - GROUND_LABEL_QUALITY_COLORS, - getGroundItemQualityColor, - isGroundItemEthereal, - isGroundItemSocketed, - isGroundItemEtherealOrSocketed, - computeInitialGroundLabelLayouts, -} from '../src/client/ui/ground-labels.ts' import { BeltHud, BELT_COLS, @@ -107,196 +98,9 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay expect(rect!.h).toBeGreaterThan(0) } }) - - it('renders flippy sprite via renderer.draw and never falls back to procedural color rectangles even when itemsAtlas is undefined', () => { - const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const sprites: { frame: any; x: number; y: number; options: any }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - quads.push({ x, y, w, h, color }) - }, - draw(frame: any, x: number, y: number, options: any) { - sprites.push({ frame, x, y, options }) - }, - } as unknown as SpriteRenderer - - const entity: GroundItemEntity = { - id: 'm4_test_cap', - item: { code: 'cap', name: 'Cap' }, - name: 'Cap', - nameZh: '帽子', - quality: 'magic', - isGold: false, - amount: 1, - invWidth: 2, - invHeight: 2, - dropTime: 0, - x: 320, - y: 240, - cellX: 10, - cellY: 10, - sparklePhase: 0, - } - - // Timestamp 1000ms is outside the sparkle window - drawGroundItem(renderer, entity, 1000, undefined) - - expect(sprites.length).toBe(1) - expect(sprites[0]!.frame.width).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.w) - expect(sprites[0]!.frame.height).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.h) - // Only 1 solid quad (ground shadow), zero colored border/body rectangles - expect(quads.length).toBe(1) - expect(quads[0]!.color[0]).toBe(0) - expect(quads[0]!.color[1]).toBe(0) - expect(quads[0]!.color[2]).toBe(0) - }) - }) - - describe('Issue #424 — Ground Label Colors & Ethereal/Socketed Gray (#808080) Override', () => { - it('detects ethereal and socketed ground items from entity or underlying item metadata', () => { - const ethEntity = { - id: 'e1', - item: { ethereal: true, sockets: 0 }, - name: 'Monarch', - nameZh: '统治者大盾', - quality: 'normal' as const, - isGold: false, - amount: 1, - invWidth: 2, - invHeight: 3, - dropTime: 0, - x: 100, - y: 100, - cellX: 3, - cellY: 3, - sparklePhase: 0, - } - const sockEntity = { - ...ethEntity, - id: 's1', - item: { ethereal: false, sockets: 4 }, - } - const plainEntity = { - ...ethEntity, - id: 'p1', - item: { ethereal: false, sockets: 0 }, - } - - expect(isGroundItemEthereal(ethEntity)).toBe(true) - expect(isGroundItemSocketed(ethEntity)).toBe(false) - expect(isGroundItemEtherealOrSocketed(ethEntity)).toBe(true) - - expect(isGroundItemEthereal(sockEntity)).toBe(false) - expect(isGroundItemSocketed(sockEntity)).toBe(true) - expect(isGroundItemEtherealOrSocketed(sockEntity)).toBe(true) - - expect(isGroundItemEtherealOrSocketed(plainEntity)).toBe(false) - }) - - it('overrides normal and superior items to #808080 when ethereal or socketed, while preserving magic/rare/set/unique/runeword colors', () => { - const mgr = new GroundItemManager() - const ethSword = mgr.add({ code: 'bsd', name: 'Broad Sword', quality: 'normal', ethereal: true }, 100, 100, 3, 3) - const sockArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 3 }, 120, 100, 4, 3) - const plainArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 0, ethereal: false }, 140, 100, 5, 3) - const ethUnique = mgr.add({ code: 'ame', name: "Titan's Revenge", quality: 'unique', ethereal: true }, 160, 100, 6, 3) - const sockMagic = mgr.add({ code: 'bsd', name: 'Mechanic Broad Sword', quality: 'magic', sockets: 2 }, 180, 100, 7, 3) - const runeword = mgr.add({ code: 'xtp', name: 'Enigma', quality: 'normal', sockets: 3, isRuneword: true }, 200, 100, 8, 3) - - expect(ethSword.ethereal).toBe(true) - expect(sockArmor.sockets).toBe(3) - expect(getGroundItemQualityColor(ethSword)).toBe('#808080') - expect(getGroundItemQualityColor(sockArmor)).toBe('#808080') - expect(getGroundItemQualityColor(plainArmor)).toBe(GROUND_LABEL_QUALITY_COLORS.superior) - expect(getGroundItemQualityColor(ethUnique)).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - expect(getGroundItemQualityColor(sockMagic)).toBe(GROUND_LABEL_QUALITY_COLORS.magic) - expect(getGroundItemQualityColor(runeword)).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - - // Verify GroundLabelLayout uses the gray color and borderColor - const layouts = computeInitialGroundLabelLayouts( - [ethSword, sockArmor, plainArmor, ethUnique], - (wx, wy) => ({ x: wx, y: wy }), - ) - expect(layouts[0]!.color).toBe('#808080') - expect(layouts[1]!.color).toBe('#808080') - expect(layouts[2]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.superior) - expect(layouts[3]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - - // Verify itemToUiInventoryItem preserves ethereal flag - const uiEth = itemToUiInventoryItem(ethSword.item, getEmbeddedDropTables()) - expect(uiEth.ethereal).toBe(true) - }) - - it('uses gray (#808080) sparkle aura in drawGroundItem for ethereal/socketed normal items', () => { - const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = [] - const renderer = { - drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { - quads.push({ x, y, w, h, color }) - }, - draw() {}, - } as unknown as SpriteRenderer - - const mgr = new GroundItemManager() - const ethNormal = mgr.add({ code: 'ssd', name: 'Short Sword', quality: 'normal', ethereal: true }, 0, 0, 0, 0) - - // Render at t = 160ms (peak of sparkle flash window) - drawGroundItem(renderer, ethNormal, 160) - expect(quads.length).toBe(5) - const aura = quads[4]! - expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![0], 2) - expect(aura.color[1]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![1], 2) - expect(aura.color[2]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![2], 2) - }) }) describe('Issue #425 — Pickup Radius (2-Subtile Collision-Box Edge Distance) & Belt Quick-Slotting', () => { - it('computes collision-box edge distance and allows pickup up to 2 subtiles (64px edge / 96px center)', () => { - expect(PLAYER_COLLISION_EXTENT_PX).toBe(16) - expect(ITEM_COLLISION_EXTENT_PX).toBe(16) - expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64) - - // Center distance 96px -> edge distance 96 - 32 = 64px (within reach) - expect(getPickupEdgeDistance(500, 500, 596, 500)).toBeCloseTo(64, 5) - expect(isWithinPickupBounds(500, 500, 596, 500, 64)).toBe(true) - - // Center distance 97px -> edge distance 65px (outside 64px reach) - expect(isWithinPickupBounds(500, 500, 597, 500, 64)).toBe(false) - - // SceneMouseController.tick triggers pickup at 80px center distance (which previously stalled at dist <= 48) - const belt = new BeltHud() - belt.clear() - const engine = createTestEngine(64, belt) - const dropped = engine.dropItem({ code: 'hp1', name: 'Minor Healing Potion', invWidth: 1, invHeight: 1 }, 580, 500) - // Force exact coordinates 80px from player (500, 500) - dropped.x = 580 - dropped.y = 500 - - const mockStatus = { textContent: '' } - const controller = new SceneMouseController({ - canvas: { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as any, - engine, - camera: { zoom: 1 } as any, - input: { - takeLeftClick: () => null, - takeRightClick: () => null, - shiftHeld: false, - movement: () => ({ x: 0, y: 0 }), - } as any, - getRuntime: () => ({ grid: { cellsX: 100, cellsY: 100 }, waypoints: [], stashes: [] }) as any, - hudManager: null, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - controller.pickupGroundItem(dropped) - expect(engine.groundItems.count).toBe(0) - expect(belt.grid[0]![0]?.code).toBe('hp1') - }) it('implements 2-pass INVENTORY_GetFreeBeltSlot auto-belt placement and falls back to bag when belt is full', () => { const belt = new BeltHud() diff --git a/tests/missile-skills-113c.test.ts b/tests/missile-skills-113c.test.ts index 7119126..06b94cb 100644 --- a/tests/missile-skills-113c.test.ts +++ b/tests/missile-skills-113c.test.ts @@ -19,7 +19,8 @@ import { tickProjectiles, type Projectile, } from '../src/server/skills/skills.ts' -import { getSkillRange, getSkillMana, drawMissileProjectile, drawExplosion, type LoadedMissileArt } from '../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' +import { drawMissileProjectile, drawExplosion, type LoadedMissileArt } from '../src/client/scene/missile-overlay-renderer.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' diff --git a/tests/monster-art-diagnostics.test.ts b/tests/monster-art-diagnostics.test.ts index 1151c44..54dbf5b 100644 --- a/tests/monster-art-diagnostics.test.ts +++ b/tests/monster-art-diagnostics.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it, vi } from 'vitest' import { compositeMonsterAnimation } from '../src/client/render/monster-art.ts' import type { MountedArchives } from '../src/common/mpq/mount.ts' -import { missingMonsterArtIds, monsterArtStatusText } from '../src/client/world/act-runtime.ts' +import { missingMonsterArtIds } from '../src/client/scene/entity-art-loader.ts' /** * A COF with `layerCount` layers, one frame, one direction. @@ -119,20 +119,4 @@ describe('act scene monster art diagnostics', () => { .toEqual(['fallen1']) expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([]) }) - - it('says nothing in the HUD when the load was clean', () => { - expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('') - }) - - it('shows missing art, load errors and dropped layers in the HUD', () => { - const text = monsterArtStatusText({ - missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'], - errors: 2, - layerFailures: 5, - }) - expect(text).toContain('怪物美术缺失 4') - expect(text).toContain('fallen1、zombie1、skeleton1…+1') - expect(text).toContain('加载错误 2') - expect(text).toContain('缺层 5') - }) }) diff --git a/tests/monster-art.test.ts b/tests/monster-art.test.ts index 85e029c..3fa51a7 100644 --- a/tests/monster-art.test.ts +++ b/tests/monster-art.test.ts @@ -61,7 +61,7 @@ describe('monster-art', () => { it('declares and re-exports ADDITIVE_MONSTER_TOKENS with ground truth tokens', async () => { const { ADDITIVE_MONSTER_TOKENS } = await import('../src/client/render/monster-art.ts') - const { ADDITIVE_MONSTER_TOKENS: SCENE_TOKENS } = await import('../src/client/world/act-runtime.ts') + const { ADDITIVE_MONSTER_TOKENS: SCENE_TOKENS } = await import('../src/client/scene/entity-art-loader.ts') expect(ADDITIVE_MONSTER_TOKENS).toBe(SCENE_TOKENS) const expected = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW'] diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 3551a17..560e1ea 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -42,7 +42,6 @@ import { type EquipSlotId, } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { SceneMouseController } from '../src/client/world/act-runtime.ts' import type { Monster } from '../src/server/engine/combat.ts' import { createDemoInventory } from './fixtures/hud-demo-state.ts' @@ -478,264 +477,4 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S expect(hasRune, 'The Countess must drop at least one Rune from Countess Rune sub-TC').toBe(true) }) }) - - describe('3. Full UI Inventory Pickup Loop & Paperdoll Equipping', () => { - let mockCanvas: any - let mockStatus: { textContent: string } - - beforeEach(() => { - mockStatus = { textContent: '' } - mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - }) - - it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => { - const engine = createTestEngine({ lootSeed: 12345678 }) - const invPanel = createDemoInventory() // seeded level-85 Sorceress requirement context - invPanel.gridItems = [] - for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) { - delete invPanel.equipped[k] - } - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - // Slay Countess to generate authentic varied drops (shield, helm, rune, potion) - engine.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'The Countess')) - engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) - - expect(engine.groundItems.count).toBeGreaterThan(0) - const droppedEntities = [...engine.groundItems.all] - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - // Pickup non-gold items and test bridge conversion - let equippableItem: UiInventoryItem | null = null - let equippableSlot: EquipSlotId | null = null - - for (const groundEntity of droppedEntities) { - const itemObj = groundEntity.item - const isGold = (groundEntity as any).isGold || itemObj.code?.trim() === 'gld' || itemObj.base?.id === 'gold' - if (isGold) continue - - const uiItem = itemToUiInventoryItem(itemObj, dropTables) - - // 1. Invariant: 100% invFile Atlas Resolution - expect(uiItem.invFile).toBeTruthy() - const atlasRect = BAKED_UI_MANIFEST.itemRects[uiItem.invFile] ?? BAKED_UI_MANIFEST.itemRects[uiItem.invFile.toLowerCase()] - expect(atlasRect, `Missing atlas sprite rect for ${uiItem.name} (${uiItem.invFile})`).toBeDefined() - expect(atlasRect.w).toBeGreaterThan(0) - expect(atlasRect.h).toBeGreaterThan(0) - expect(resolveItemSpriteRect(uiItem)).not.toBeNull() - - // 2. Invariant: Bilingual Naming - expect(uiItem.nameZh).toBeTruthy() - expect(uiItem.nameZh.length).toBeGreaterThan(0) - - // 3. Invariant: Formatted Stats Array - expect(Array.isArray(uiItem.stats)).toBe(true) - - // 4. Invariant: Allowed Slots Array - expect(Array.isArray(uiItem.allowedSlots)).toBe(true) - - // Pickup via controller into inventory panel - controller.pickupGroundItem(groundEntity) - - if (!equippableItem && uiItem.allowedSlots.length > 0) { - equippableItem = uiItem - equippableSlot = uiItem.allowedSlots[0]! - } - } - - expect(invPanel.gridItems.length).toBeGreaterThan(0) - expect(equippableItem).not.toBeNull() - expect(equippableSlot).not.toBeNull() - - // 5. Invariant: Character Paperdoll Slot Equipping - const placed = invPanel.gridItems.find(p => p.item.id === equippableItem!.id) - expect(placed).toBeDefined() - - // Left-click grid cell to lift item to cursor - const pickedToCursor = invPanel.clickGridCell(placed!.col, placed!.row) - expect(pickedToCursor).toBe(true) - expect(invPanel.cursorItem).toBe(placed!.item) - - // Attempt equipping into invalid slot (e.g. boots if slot is amulet/weapon/helm) - const invalidSlot: EquipSlotId = equippableSlot === 'boots' ? 'helm' : 'boots' - const invalidEquip = invPanel.clickEquipSlot(invalidSlot) - expect(invalidEquip).toBe(false) - expect(invPanel.equipped[invalidSlot]).toBeUndefined() - expect(invPanel.cursorItem).toBe(placed!.item) - - // Unidentified magic/rare/set/unique drops cannot be equipped until identified (D2 1.13c) - if (isUiItemUnidentified(placed!.item)) { - const unidEquip = invPanel.clickEquipSlot(equippableSlot!) - expect(unidEquip).toBe(false) - expect(invPanel.equipped[equippableSlot!]).toBeUndefined() - identifyUiItem(placed!.item) - } - - // Equip into valid matching slot - const validEquip = invPanel.clickEquipSlot(equippableSlot!) - expect(validEquip).toBe(true) - expect(invPanel.equipped[equippableSlot!]).toBe(placed!.item) - expect(invPanel.cursorItem).toBeNull() - }) - }) - - describe('4. Gold Currency Accumulation vs Inventory Full Flippy Bounce', () => { - let mockCanvas: any - let mockStatus: { textContent: string } - - beforeEach(() => { - mockStatus = { textContent: '' } - mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - }) - - it('accumulates gold drops into inventory currency and clamps to authentic inventory gold capacity', () => { - const engine = createTestEngine() - engine.world.player.level = 10 - engine.bag.gold = 95_000 - const invPanel = new InventoryPanel() - invPanel.playerLevel = 10 - invPanel.gold = 95_000 - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - // Drop 10,000 gold pile on ground (cap is 100,000) - const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true }) - expect(goldDrop).not.toBeNull() - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - controller.pickupGroundItem(goldDrop!) - - // 95,000 + 5,000 clamped to 100,000 - expect(invPanel.gold).toBe(100_000) - expect(hudManager.syncPublishedState).toHaveBeenCalled() - expect(mockStatus.textContent).toContain('拾起金币:5000') - expect(engine.groundItems.count).toBe(1) - }) - - it('refuses item pickup when 10x4 inventory grid is 100% full, triggers flippy bounce, and keeps item on ground', () => { - const engine = createTestEngine() - const invPanel = new InventoryPanel() - invPanel.gridItems = [] - - // Fill all 10x4 = 40 cells with 10 2x2 filler items - for (let c = 0; c < 10; c += 2) { - for (let r = 0; r < 4; r += 2) { - invPanel.gridItems.push({ - item: { - id: `filler-${c}-${r}`, - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '帽子 (Cap)', - baseNameZh: '帽子 (Cap)', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - col: c, - row: r, - }) - } - } - expect(invPanel.gridItems.length).toBe(10) // 40 cells completely occupied - - // Drop an item on the ground - const testItemBase = dropTables.getBase('hax')! - const droppedEntity = engine.dropItem( - { - code: 'hax', - name: 'Hand Axe', - quality: 2, - invWidth: 1, - invHeight: 2, - base: testItemBase, - stats: {}, - prefix: null, - suffix: null, - level: 1, - stack: 1, - value: 100, - } as any, - 510, - 500, - ) - - const hudManager: any = { - inventory: invPanel, - syncPublishedState: vi.fn(), - } - - const controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera: { zoom: 1 } as any, - input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, - getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, - hudManager, - waypointNetwork: null as any, - status: mockStatus as any, - playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, - getCharacter: () => null, - }) - - const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback') - const notificationSpy = vi.spyOn(controller, 'showNotification') - - controller.pickupGroundItem(droppedEntity) - - // Invariants: - // 1. Refusal recorded in metrics - expect(engine.metrics.inventoryRefusals).toBe(1) - // 2. Item remains on ground - expect(engine.groundItems.count).toBe(1) - // 3. Parabolic flippy bounce initialized - expect(droppedEntity.bounceState).toBeDefined() - expect(droppedEntity.bounceState!.phase).toBe('primary') - expect(droppedEntity.bounceState!.durationMs).toBe(350) - expect(droppedEntity.bounceState!.peakHeightPx).toBe(28) - // 4. Refusal audio and visual feedback - expect(feedbackSpy).toHaveBeenCalled() - expect(notificationSpy).toHaveBeenCalledWith('包裹已满。') - expect(mockStatus.textContent).toBe('包裹已满。') - }) - }) }) diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index 7f83080..c5f861a 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -21,11 +21,9 @@ import { } from '../src/client/ui/world-panels.ts' import { D2FontRenderer } from '../src/client/ui/font.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' -import { KeyboardInput } from '../src/client/sim/input.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' -import { SceneMouseController, monsterToTargetHudInfo, rebudgetMonsterPacks, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { rebudgetMonsterPacks } from '../src/common/world/scene-population.ts' import { readMonsterKinds, readSuperUniques, @@ -106,170 +104,6 @@ function createMockFont(charWidth = 8): D2FontRenderer & { } } -function createHoverTestScene() { - const canvas = new MockCanvas() as unknown as HTMLCanvasElement - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const dummyTerrain: WorldMapProvider = { - widthPx: 2000, - heightPx: 2000, - overlap: () => 0, - } - - const dummyOpts: GameEngineOptions = { - spawn: { x: 500, y: 500 }, - stats: [ - { - id: 'fallen1', - name: 'Fallen', - nameKey: 'Fallen', - descKey: '', - isUndead: false, - isDemon: true, - isBoss: false, - killable: true, - inTown: false, - rank: 'normal', - modifiers: [], - resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 }, - hp: 20, - damage: 2, - cooldownTicks: 25, - reach: 24, - aggroRadius: 100, - speed: 0, - xp: 18, - }, - { - id: 'zombie1', - name: 'Zombie', - nameKey: 'Zombie', - descKey: '', - isUndead: true, - isDemon: false, - isBoss: false, - killable: true, - inTown: false, - rank: 'unique', - modifiers: ['strong', 'cursed'], - resistances: { physical: 50, magic: 0, fire: 0, lightning: 0, cold: 100, poison: 75 }, - hp: 80, - damage: 5, - cooldownTicks: 25, - reach: 24, - aggroRadius: 100, - speed: 0, - xp: 50, - }, - ], - xpTable: [0, 0, 100, 200], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [], - npcDefs: [], - questDefs: [], - combatOptions: { - playerSpeed: 100, - playerReach: 52, - playerCooldownTicks: 10, - playerDamage: 10, - playerManaPerAttack: 0, - respawnTicks: 100, - }, - lootSeed: 42, - talkRadius: 80, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - // Keep exactly 2 deterministic monsters for hover hit-testing - engine.world.monsters.splice(2) - ;(engine.world.monsters[0]! as { stats: unknown }).stats = dummyOpts.stats[0]! - engine.world.monsters[0]!.hp = dummyOpts.stats[0]!.hp - engine.world.monsters[0]!.x = 560 - engine.world.monsters[0]!.y = 500 - engine.world.monsters[0]!.drawBounds = { x: 544, y: 450, w: 32, h: 54 } - - ;(engine.world.monsters[1]! as { stats: unknown }).stats = dummyOpts.stats[1]! - engine.world.monsters[1]!.hp = dummyOpts.stats[1]!.hp - engine.world.monsters[1]!.x = 650 - engine.world.monsters[1]!.y = 500 - engine.world.monsters[1]!.drawBounds = { x: 630, y: 440, w: 40, h: 64 } - - const camera = new ViewportCamera(canvas, 2000, 2000) - camera.zoom = 1 - - const hudManager = new HudManager(canvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - - const waypointNetwork = new WaypointNetwork() - const status = { textContent: '' } as HTMLElement - - const runtime: MapRuntime = { - base: 'samples/d2-packs', - act: 1, - level: 'Blood Moor', - levelId: 2, - variant: 'default', - variants: ['default'], - quadrant: 'default', - quadrants: ['default'], - cellsX: 100, - cellsY: 100, - widthPx: 2000, - heightPx: 2000, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - pages: [], - priorityPages: [], - objectPages: [], - grid: { - originX: 0, - originY: 0, - cellsX: 100, - cellsY: 100, - blocked: new Uint8Array(500 * 500), - gridWidth: 500, - }, - npcs: [], - entrances: [], - warps: [], - waypoints: [], - animatedDrawables: [], - animSpeed: 1, - frameDurationMs: 40, - palette: null, - charBases: [], - monsterTypes: [], - monsterPacks: [], - entryPath: '', - loadPages: async () => {}, - } as unknown as MapRuntime - - const controller = new SceneMouseController({ - canvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager, - waypointNetwork, - status, - }) - controller.attach() - - return { canvas, engine, camera, hudManager, controller } -} - describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () => { describe('R1: Canonical .tbl Strings & MonStats.txt Metadata Pipeline', () => { it('packs required MonStats.txt columns and resolves canonical ENG & CHI .tbl strings', () => { @@ -652,152 +486,6 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () = }) describe('R2: Authentic Mouse Hover Hit-Testing & Target Clearing in SceneMouseController', () => { - it('does NOT auto-populate targetMonster by proximity when cursor is not hovering over a monster', () => { - const { engine, hudManager, controller } = createHoverTestScene() - - // Monster 0 is only 60px away from player (560, 500 vs 500, 500) - expect( - Math.hypot( - engine.world.monsters[0]!.x - engine.world.player.x, - engine.world.monsters[0]!.y - engine.world.player.y, - ), - ).toBe(60) - - // Sync HUD without hovering any monster -> targetMonster MUST be null! - controller.syncMonsterTargetHud(engine.world.player.x, engine.world.player.y - 16) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - }) - - it('populates targetMonster on cursor hover over monster drawBounds and clears on move to empty ground, HUD, or pointerleave', () => { - const { canvas, engine, hudManager, controller } = createHoverTestScene() - const camX = engine.world.player.x // 500 - const camY = engine.world.player.y - 16 // 484 - - // Monster 0 drawBounds: x=[544, 576], y=[450, 504] - // World (560, 480) -> screen (400 + (560 - 500) = 460, 300 + (480 - 484) = 296) - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - - expect(controller.hoveredMonster).toBe(engine.world.monsters[0]) - expect(hudManager.targetMonster).not.toBeNull() - expect(hudManager.targetMonster?.nameKey).toBe('Fallen') - expect(hudManager.targetMonster?.isDemon).toBe(true) - expect(hudManager.targetMonster?.hp).toBe(20) - - // Move cursor to empty ground at World (450, 450) -> screen (350, 266) - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 350, clientY: 266, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - - // Hover Monster 1 at World (650, 480) -> screen (550, 296) - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 550, clientY: 296, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(controller.hoveredMonster).toBe(engine.world.monsters[1]) - expect(hudManager.targetMonster?.nameKey).toBe('Zombie') - expect(hudManager.targetMonster?.isUndead).toBe(true) - expect(hudManager.targetMonster?.modifiers).toEqual(['strong', 'cursed']) - expect(hudManager.targetMonster?.resistances?.cold).toBe(100) - - // Move cursor onto bottom HUD control bar (y = 580) -> clears hover - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 400, clientY: 580, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - - // Hover Monster 0 again, then trigger pointerleave -> clears hover - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(hudManager.targetMonster).not.toBeNull() - - canvas.dispatchEvent(new SyntheticPointerEvent('pointerleave', { clientX: 0, clientY: 0, buttons: 0 })) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - }) - - it('selects foreground monster by isometric depth (higher y) when two monsters overlap and locks target during left-click hold', () => { - const { canvas, engine, hudManager, controller } = createHoverTestScene() - const camX = engine.world.player.x // 500 - const camY = engine.world.player.y - 16 // 484 - - // Overlap Monster 0 (y = 490, background) and Monster 1 (y = 505, foreground) at world (560, 480) - engine.world.monsters[0]!.x = 560 - engine.world.monsters[0]!.y = 490 - engine.world.monsters[0]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 } - - engine.world.monsters[1]!.x = 560 - engine.world.monsters[1]!.y = 505 - engine.world.monsters[1]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 } - - const hit = controller.findMonsterAtWorld(560, 480) - expect(hit).toBe(engine.world.monsters[1]) // Foreground monster (higher y) wins - - // Left-click hold on Monster 1 at screen (460, 296) -> locks onto Monster 1 - canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { clientX: 460, clientY: 296, button: 0, buttons: 1 }), - ) - expect(controller.lockedMonster).toBe(engine.world.monsters[1]) - - // Drag cursor away to empty ground while holding left button -> stays locked on Monster 1 - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 300, clientY: 200, button: 0, buttons: 1 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(controller.getActiveTargetMonster()).toBe(engine.world.monsters[1]) - expect(hudManager.targetMonster?.nameKey).toBe('Zombie') - - // Release left mouse button over empty ground -> clears lock and clears targetMonster - canvas.dispatchEvent( - new SyntheticPointerEvent('pointerup', { clientX: 300, clientY: 200, button: 0, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(controller.lockedMonster).toBeNull() - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - }) - - it('clears targetMonster immediately when hovered monster dies or becomes unkillable', () => { - const { canvas, engine, hudManager, controller } = createHoverTestScene() - const camX = engine.world.player.x - const camY = engine.world.player.y - 16 - - // Hover Monster 0 - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }), - ) - controller.syncMonsterTargetHud(camX, camY) - expect(hudManager.targetMonster?.nameKey).toBe('Fallen') - - // Kill Monster 0 - engine.world.monsters[0]!.hp = 0 - engine.world.monsters[0]!.state = 'dead' - controller.syncMonsterTargetHud(camX, camY) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - - // Verify monsterToTargetHudInfo maps all fields cleanly - const hudInfo = monsterToTargetHudInfo(engine.world.monsters[1]!) - expect(hudInfo).toMatchObject({ - name: 'Zombie', - nameKey: 'Zombie', - isUndead: true, - isDemon: false, - rank: 'unique', - modifiers: ['strong', 'cursed'], - }) - }) it('filters "dummy" DescStr (0x6fb70c0b) and resolves wMonUModStrTable[24] ("thief") plus short/numeric MonUMod keys', () => { const dummyMonster: MonsterTargetHudInfo = { @@ -881,113 +569,6 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () = } }) - it('preserves boss and superunique ranks in monsterToTargetHudInfo and uses getMonsterArtMap or boss fallback bounds before first render', () => { - const { engine, canvas, camera, hudManager, controller } = createHoverTestScene() - - // Boss monster without drawBounds (before first render) - const bossMonster = engine.world.monsters[0]! - bossMonster.drawBounds = undefined - ;(bossMonster as any).stats = { - ...bossMonster.stats, - id: 'andariel', - name: 'Andariel', - nameKey: 'Andariel', - rank: 'boss', - isBoss: true, - isDemon: true, - } - - const bossHud = monsterToTargetHudInfo(bossMonster) - expect(bossHud.rank).toBe('boss') - expect(bossHud.isBoss).toBe(true) - - // 1. Without monsterArtMap, boss fallback bounds extend up to y - 96 (normal only extends to y - 64) - const controllerNoArt = new SceneMouseController({ - canvas, - engine, - camera, - input: controller.input, - getRuntime: controller.getRuntime, - hudManager, - waypointNetwork: { activate: () => false } as any, - status: { textContent: '', hidden: false } as unknown as HTMLElement, - }) - expect(controllerNoArt.findMonsterAtWorld(bossMonster.x, bossMonster.y - 85)).toBe(bossMonster) - - // 2. With getMonsterArtMap, pre-render hit-testing uses frame 0 dimensions from monsterArtMap - const fakeArtMap = new Map([ - [ - 'andariel', - { - token: 'AN', - handle: {} as any, - groups: [[{ x: 0, y: 0, width: 120, height: 150, anchorX: -60, anchorY: -140 }]], - directions: 8, - walkOffset: 0, - standOffset: 0, - walkFrames: 1, - standFrames: 1, - layerFailures: [], - }, - ], - ]) - const controllerWithArt = new SceneMouseController({ - canvas, - engine, - camera, - input: controller.input, - getRuntime: controller.getRuntime, - hudManager, - waypointNetwork: { activate: () => false } as any, - status: { textContent: '', hidden: false } as unknown as HTMLElement, - getMonsterArtMap: () => fakeArtMap as any, - }) - expect(controllerWithArt.findMonsterAtWorld(bossMonster.x - 55, bossMonster.y - 130)).toBe(bossMonster) - }) - - it('keeps attacking lockedMonster in tick() when cursor moves away during left-click hold and updates hudManager.targetMonster HP immediately after castSkill', () => { - const { canvas, engine, hudManager, controller } = createHoverTestScene() - const monster0 = engine.world.monsters[0]! - // Place Monster 0 within melee reach (distance 30 <= reach) - monster0.x = 530 - monster0.y = 500 - monster0.drawBounds = { x: 510, y: 450, w: 40, h: 60 } - - const castSpy = vi.spyOn(controller, 'castSkill').mockImplementation((_skillId, tx, ty) => { - if (tx === monster0.x && ty === monster0.y) { - monster0.hp = Math.max(0, monster0.hp - 7) - if (monster0.hp === 0) monster0.state = 'dead' - } - return true - }) - - // Left-click Monster 0 at screen (430, 296) -> world (530, 480) - canvas.dispatchEvent( - new SyntheticPointerEvent('pointerdown', { clientX: 430, clientY: 296, button: 0, buttons: 1 }), - ) - expect(controller.lockedMonster).toBe(monster0) - // HP on HUD must immediately reflect post-hit HP (20 - 7 = 13), not stale 20! - expect(hudManager.targetMonster?.hp).toBe(13) - - // Drag cursor far away to screen (100, 100) while holding left button - canvas.dispatchEvent( - new SyntheticPointerEvent('pointermove', { clientX: 100, clientY: 100, button: 0, buttons: 1 }), - ) - engine.world.player.cooldown = 0 - controller.tick() - - // tick() must attack lockedMonster at (530, 500), NOT cursor at (200, 184)! - expect(castSpy).toHaveBeenLastCalledWith(expect.any(Number), 530, 500) - expect(hudManager.targetMonster?.hp).toBe(6) - - // Third swing kills Monster 0 -> lockedMonster and hudManager.targetMonster clear immediately - engine.world.player.cooldown = 0 - controller.tick() - expect(monster0.hp).toBe(0) - expect(controller.lockedMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - }) - it('renders entire Subtext Line 1 (including Race and race-only Champions/Bosses) in rank color per 0x6fb719b0 and 0x6fb71a20', () => { // 1. Unique Undead with modifiers + DescStr -> entire line1Colored starts with ÿc4 (gold) const uniqueUndead = buildMonsterHoverSubtext( @@ -1149,132 +730,97 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () = // Leader level must be base 1 + 2 (3) or base 1 + 3 (4), never +4 or +5! expect(leader.level === 3 || leader.level === 4).toBe(true) }) + }) - it('applies 1.13c MonUMod elemental/physical/magic resistance bonuses in applyEliteModifiers and enrichMonsterPacks so Bishibosh and Frozenstein display immunities on Line 2 (D2Game.dll 0x6fc41be0)', () => { - const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'normal') - const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'normal') - const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s])) + it('applies 1.13c MonUMod elemental/physical/magic resistance bonuses in applyEliteModifiers and enrichMonsterPacks so Bishibosh and Frozenstein display immunities on Line 2 (D2Game.dll 0x6fc41be0)', () => { + const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'normal') + const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'normal') + const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s])) - // 1. Pre-baked Cold Plains Bishibosh (fallenshaman1: base fire=25 + magicresistant(+40) + fireenchant(+75) = 140 >= 100) - const bishiboshPack = enrichMonsterPacks( - [ - { - superUniqueId: 'Bishibosh', - members: [ - { - id: 'fallenshaman1', - name: 'Bishibosh', - hp: 43, - damage: 2, - cooldownTicks: 25, - reach: 40, - aggroRadius: 560, - speed: 113, - xp: 32, - level: 5, - rank: 'unique', - resistances: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 }, - resists: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 }, - modifiers: ['magicresistant', 'fireenchant'], - superUniqueId: 'Bishibosh', - }, - ], - }, - ], - kinds, - superUniques, - ) - const bishiboshStats = bishiboshPack[0]!.members[0]! - expect(bishiboshStats.resistances?.fire).toBe(140) - expect(bishiboshStats.resistances?.cold).toBe(40) - expect(bishiboshStats.resistances?.lightning).toBe(20) + // 1. Pre-baked Cold Plains Bishibosh (fallenshaman1: base fire=25 + magicresistant(+40) + fireenchant(+75) = 140 >= 100) + const bishiboshPack = enrichMonsterPacks( + [ + { + superUniqueId: 'Bishibosh', + members: [ + { + id: 'fallenshaman1', + name: 'Bishibosh', + hp: 43, + damage: 2, + cooldownTicks: 25, + reach: 40, + aggroRadius: 560, + speed: 113, + xp: 32, + level: 5, + rank: 'unique', + resistances: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 }, + resists: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 }, + modifiers: ['magicresistant', 'fireenchant'], + superUniqueId: 'Bishibosh', + }, + ], + }, + ], + kinds, + superUniques, + ) + const bishiboshStats = bishiboshPack[0]!.members[0]! + expect(bishiboshStats.resistances?.fire).toBe(140) + expect(bishiboshStats.resistances?.cold).toBe(40) + expect(bishiboshStats.resistances?.lightning).toBe(20) - const bishiboshHud = monsterToTargetHudInfo({ - kind: 'fallenshaman1', - x: 100, - y: 100, - hp: 43, - maxHp: 43, - state: 'idle', - stats: bishiboshStats, - } as any) - const bishiboshSubEn = buildMonsterHoverSubtext(bishiboshHud, 'en') - expect(bishiboshSubEn.line1).toBe('Demon Magic Resistant Fire Enchanted Raises Fallen') - expect(bishiboshSubEn.line2).toBe('Immune to Fire') - expect(bishiboshSubEn.line2Colored).toBe('ÿc1Immune to Fire') + const bishiboshHud = ({ ...bishiboshStats, hp: 43, maxHp: bishiboshStats.hp, rank: 'unique' }) as MonsterTargetHudInfo + const bishiboshSubEn = buildMonsterHoverSubtext(bishiboshHud, 'en') + expect(bishiboshSubEn.line1).toBe('Demon Magic Resistant Fire Enchanted Raises Fallen') + expect(bishiboshSubEn.line2).toBe('Immune to Fire') + expect(bishiboshSubEn.line2Colored).toBe('ÿc1Immune to Fire') - const bishiboshSubZh = buildMonsterHoverSubtext(bishiboshHud, 'zh') - expect(bishiboshSubZh.line2).toBe(lookupMonsterTblString('strScaryMonDesc1', 'zh')) - expect(bishiboshSubZh.line2Colored).toBe(`ÿc1${lookupMonsterTblString('strScaryMonDesc1', 'zh')}`) + const bishiboshSubZh = buildMonsterHoverSubtext(bishiboshHud, 'zh') + expect(bishiboshSubZh.line2).toBe(lookupMonsterTblString('strScaryMonDesc1', 'zh')) + expect(bishiboshSubZh.line2Colored).toBe(`ÿc1${lookupMonsterTblString('strScaryMonDesc1', 'zh')}`) - // 2. Pre-baked Frozenstein (snowyeti4: base cold=75 + coldenchant(+75) = 150 >= 100, manahit(+20 magic)) - const frozensteinPack = enrichMonsterPacks( - [ - { - superUniqueId: 'Frozenstein', - members: [ - { - id: 'snowyeti4', - name: 'Frozenstein', - hp: 300, - damage: 25, - cooldownTicks: 25, - reach: 40, - aggroRadius: 560, - speed: 120, - xp: 200, - level: 40, - rank: 'unique', - resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 75, poison: 0 }, - modifiers: ['coldenchant', 'manahit'], - superUniqueId: 'Frozenstein', - }, - ], - }, - ], - kinds, - superUniques, - ) - const frozensteinStats = frozensteinPack[0]!.members[0]! - expect(frozensteinStats.resistances?.cold).toBe(150) - expect(frozensteinStats.resistances?.magic).toBe(20) + // 2. Pre-baked Frozenstein (snowyeti4: base cold=75 + coldenchant(+75) = 150 >= 100, manahit(+20 magic)) + const frozensteinPack = enrichMonsterPacks( + [ + { + superUniqueId: 'Frozenstein', + members: [ + { + id: 'snowyeti4', + name: 'Frozenstein', + hp: 300, + damage: 25, + cooldownTicks: 25, + reach: 40, + aggroRadius: 560, + speed: 120, + xp: 200, + level: 40, + rank: 'unique', + resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 75, poison: 0 }, + modifiers: ['coldenchant', 'manahit'], + superUniqueId: 'Frozenstein', + }, + ], + }, + ], + kinds, + superUniques, + ) + const frozensteinStats = frozensteinPack[0]!.members[0]! + expect(frozensteinStats.resistances?.cold).toBe(150) + expect(frozensteinStats.resistances?.magic).toBe(20) - // 3. applyEliteModifiers directly for stoneskin (+50 physical), spectralhit (+20 fire/cold/ltng), poisonenchant (+75 poison) - const baseStats = monsterStatsOf(kinds.get('fallen1')!, new Rng(1), 4) - const modded = applyEliteModifiers(baseStats, 'unique', ['stoneskin', 'spectralhit', 'poisonenchant']) - expect(modded.resistances?.physical).toBe(50) - expect(modded.resistances?.fire).toBe(20) - expect(modded.resistances?.cold).toBe(20) - expect(modded.resistances?.lightning).toBe(20) - expect(modded.resistances?.poison).toBe(75) - }) - - it('isolates hoveredMonster from 36px click-assist fallback in handleRightClickWorld and handleLeftClickWorld', () => { - const { canvas, engine, hudManager, controller } = createHoverTestScene() - const monster0 = engine.world.monsters[0]! - // Monster 0 is at (560, 500) with drawBounds [544..576, 450..504] - // World point (535, 500) is OUTSIDE drawBounds (x=535 < 544) but WITHIN 36px fallback (dist=25 <= 36) - expect(controller.findMonsterAtWorld(535, 500)).toBeNull() - - // 1. Right-click at (535, 500) (outside drawBounds, inside 36px fallback) must NOT stick hoveredMonster or targetMonster - controller.handleRightClickWorld(535, 500) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 2, buttons: 0 })) - expect(controller.activeClickType).toBe('none') - - // 2. Left-click at (535, 500) (outside drawBounds, inside 36px fallback) locks monster while held, then clears on release - controller.isLeftMouseDown = true - controller.handleLeftClickWorld(535, 500) - expect(controller.lockedMonster).toBe(monster0) - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster?.nameKey).toBe('Fallen') - - canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 0, buttons: 0 })) - expect(controller.lockedMonster).toBeNull() - expect(controller.hoveredMonster).toBeNull() - expect(hudManager.targetMonster).toBeNull() - }) + // 3. applyEliteModifiers directly for stoneskin (+50 physical), spectralhit (+20 fire/cold/ltng), poisonenchant (+75 poison) + const baseStats = monsterStatsOf(kinds.get('fallen1')!, new Rng(1), 4) + const modded = applyEliteModifiers(baseStats, 'unique', ['stoneskin', 'spectralhit', 'poisonenchant']) + expect(modded.resistances?.physical).toBe(50) + expect(modded.resistances?.fire).toBe(20) + expect(modded.resistances?.cold).toBe(20) + expect(modded.resistances?.lightning).toBe(20) + expect(modded.resistances?.poison).toBe(75) }) }) diff --git a/tests/object-animation-113c.test.ts b/tests/object-animation-113c.test.ts index a961c9a..eb25583 100644 --- a/tests/object-animation-113c.test.ts +++ b/tests/object-animation-113c.test.ts @@ -1,163 +1,8 @@ import { describe, it, expect } from 'vitest' import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' -import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, type ObjectDrawable, type AnimatedObjectFrame } from '../src/client/world/act-runtime.ts' describe('Diablo II 1.13c Map Object Animations (Issue #387)', () => { - describe('Spatial phase offset (SplitMix64 anti-resonance)', () => { - it('returns 0 when totalDurationMs is 0 or negative', () => { - expect(computeObjectPhaseOffsetMs(100, 200, 0)).toBe(0) - expect(computeObjectPhaseOffsetMs(100, 200, -10)).toBe(0) - }) - - it('is strictly deterministic for identical coordinates', () => { - const p1 = computeObjectPhaseOffsetMs(152, 364, 1024) - const p2 = computeObjectPhaseOffsetMs(152, 364, 1024) - expect(p1).toBe(p2) - }) - - it('produces diverse phase offsets for different torch locations', () => { - const torchCoords = [ - { x: 100, y: 200 }, - { x: 105, y: 200 }, - { x: 120, y: 250 }, - { x: 300, y: 400 }, - { x: 310, y: 400 }, - { x: 500, y: 600 }, - ] - const totalDuration = 20 * 51.2 // 1024ms for 20-frame torch - const phases = torchCoords.map(c => computeObjectPhaseOffsetMs(c.x, c.y, totalDuration)) - - // All phases must be within [0, totalDuration) - for (const phase of phases) { - expect(phase).toBeGreaterThanOrEqual(0) - expect(phase).toBeLessThan(totalDuration) - } - - // Check entropy: no two distinct torch coordinates should have the exact same phase - const uniquePhases = new Set(phases) - expect(uniquePhases.size).toBe(torchCoords.length) - }) - }) - - describe('Runtime frame ticking (updateAnimatedObject)', () => { - const dummyAtlasFrame = (i: number) => ({ - x: i * 32, - y: 0, - width: 32, - height: 64, - }) - - const createTorchDrawable = (phaseOffsetMs = 0): ObjectDrawable => { - const frameCount = 20 - const frameDurationMs = 51.2 - const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({ - frame: dummyAtlasFrame(i), - page: 0, - offsetX: -16, - offsetY: -32 + (i % 2), // slightly fluctuating DC6/DCC offset - })) - - return { - frame: frames[0]!.frame, - x: 100 + frames[0]!.offsetX, - y: 200 + frames[0]!.offsetY, - depth: 60, - page: 0, - baseX: 100, - baseY: 200, - animatedFrames: frames, - frameDurationMs, - cycleAnim: true, - phaseOffsetMs, - currentFrameIndex: 0, - } - } - - it('ignores static or single-frame objects', () => { - const staticObj: ObjectDrawable = { - frame: dummyAtlasFrame(0), - x: 100, - y: 200, - depth: 60, - page: 0, - } - expect(updateAnimatedObject(staticObj, 1000)).toBe(false) - expect(staticObj.currentFrameIndex).toBeUndefined() - }) - - it('advances frames sequentially with time', () => { - const torch = createTorchDrawable(0) - - // At t=0ms, frame should be 0 - expect(updateAnimatedObject(torch, 0)).toBe(false) - expect(torch.currentFrameIndex).toBe(0) - - // At t=60ms (> 51.2ms), frame should be 1 - expect(updateAnimatedObject(torch, 60)).toBe(true) - expect(torch.currentFrameIndex).toBe(1) - expect(torch.frame.x).toBe(32) - expect(torch.x).toBe(100 - 16) - expect(torch.y).toBe(200 - 32 + 1) - - // At t=110ms (> 2 * 51.2 = 102.4ms), frame should be 2 - expect(updateAnimatedObject(torch, 110)).toBe(true) - expect(torch.currentFrameIndex).toBe(2) - expect(torch.frame.x).toBe(64) - - // Advancing to the same time interval does not re-trigger update - expect(updateAnimatedObject(torch, 115)).toBe(false) - }) - - it('wraps smoothly in a loop when cycleAnim is true', () => { - const torch = createTorchDrawable(0) - const cycleDuration = 20 * 51.2 // 1024 ms - - // Exactly at one full cycle (1024ms), it wraps back to frame 0 - updateAnimatedObject(torch, 1024 + 10) - expect(torch.currentFrameIndex).toBe(0) - - // Halfway through next cycle: frame 10 - updateAnimatedObject(torch, 1024 + 520) - expect(torch.currentFrameIndex).toBe(10) - }) - - it('clamps to the final frame when cycleAnim is false', () => { - const nonLoopingObj = createTorchDrawable(0) - // Modify to non-cycling - ;(nonLoopingObj as any).cycleAnim = false - - // Beyond end of cycle (2000 ms > 1024 ms) - updateAnimatedObject(nonLoopingObj, 2000) - expect(nonLoopingObj.currentFrameIndex).toBe(19) // clamped to last frame (index 19) - }) - - it('respects spatial phase offset', () => { - // Torch A at phase 0, Torch B at phase 256ms (~5 frames ahead) - const torchA = createTorchDrawable(0) - const torchB = createTorchDrawable(256) - - updateAnimatedObject(torchA, 0) - updateAnimatedObject(torchB, 0) - - expect(torchA.currentFrameIndex).toBe(0) - expect(torchB.currentFrameIndex).toBe(5) // floor(256 / 51.2) = 5 - }) - - it('updates array of objects via updateAnimatedObjects', () => { - const list = [ - createTorchDrawable(0), - createTorchDrawable(100), - createTorchDrawable(200), - ] - - const changedCount = updateAnimatedObjects(list, 60) - expect(changedCount).toBeGreaterThan(0) - for (const obj of list) { - expect(obj.currentFrameIndex).toBeDefined() - } - }) - }) describe('Ground truth 1.13c baked pack validation', () => { const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-var1/scene.json') diff --git a/tests/p0-520-player-state.test.ts b/tests/p0-520-player-state.test.ts index b2a5a75..998c13d 100644 --- a/tests/p0-520-player-state.test.ts +++ b/tests/p0-520-player-state.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest' import { GameEngine } from '../src/server/engine/engine.ts' import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../src/common/units/classes.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' import { serializeSaveData, deserializeSaveData, applySaveDataToRuntime } from '../src/server/save/save.ts' import type { Item } from '../src/common/items/items.ts' @@ -30,7 +29,6 @@ describe('Issue #520: Authoritative Player State, Per-Class Gains, and Save/Load it('allocates vitality and energy using per-class CharStats.txt gains without full-healing a damaged player', () => { for (const cls of ALL_CLASSES) { const engine = new GameEngine(cls) - const hud = new HudManager(engine) const cStats = CANONICAL_CHARSTATS[cls] engine.world.player.statPoints = 10 @@ -39,37 +37,33 @@ describe('Issue #520: Authoritative Player State, Per-Class Gains, and Save/Load const baseStam = engine.world.player.maxStamina ?? cStats.baseVitals.stamina // Damage player before allocating vitality - hud.takeDamage(20) + engine.takeDamage(20) const damagedHp = engine.world.player.hp expect(damagedHp).toBe(baseHp - 20) - expect(hud.allocateStat('vitality')).toBe(true) + expect(engine.allocateStat('vitality')).toBe(true) expect(engine.world.player.maxHp).toBe(Math.floor(baseHp + cStats.perStatPoint.lifePerVitality)) expect(engine.world.player.hp).toBe(Math.floor(damagedHp + cStats.perStatPoint.lifePerVitality)) expect(engine.world.player.hp).toBeLessThan(engine.world.player.maxHp) expect(engine.world.player.maxStamina).toBe(Math.floor(baseStam + cStats.perStatPoint.staminaPerVitality)) - expect(hud.allocateStat('energy')).toBe(true) + expect(engine.allocateStat('energy')).toBe(true) expect(engine.world.player.maxMana).toBe(Math.floor(baseMana + cStats.perStatPoint.manaPerEnergy)) - expect(hud.charSheet.attrs.baseMaxHp).toBe(engine.world.player.maxHp) - expect(hud.charSheet.attrs.baseMaxMana).toBe(engine.world.player.maxMana) } }) - it('Syncs HUD state with GameEngine across level-ups and save/load round-trips', () => { + it('Preserves GameEngine state across level-ups and save/load round-trips', () => { const engine = new GameEngine('bar') - const hud = new HudManager(engine) - const res = hud.grantExperience(500) + const res = engine.grantExperience(500) expect(res.levelsGained).toBe(1) expect(res.newLevel).toBe(2) - expect(hud.charSheet.attrs.level).toBe(2) - expect(hud.charSheet.attrs.statPoints).toBe(5) - expect(hud.skillTree.unspentSkillPoints).toBe(1) - expect(hud.maxHp).toBe(engine.world.player.maxHp) + expect(engine.world.player.level).toBe(2) + expect(engine.world.player.statPoints).toBe(5) + expect(engine.world.player.skillPoints).toBe(1) - hud.allocateStat('strength') - hud.allocateStat('vitality') + engine.allocateStat('strength') + engine.allocateStat('vitality') engine.bag.gold = 4321 const potion: Item = { diff --git a/tests/p0-521-gold-duplication.test.ts b/tests/p0-521-gold-duplication.test.ts index 4a3d6fd..bbcbf58 100644 --- a/tests/p0-521-gold-duplication.test.ts +++ b/tests/p0-521-gold-duplication.test.ts @@ -1,21 +1,15 @@ import { describe, it, expect } from 'vitest' import { GameEngine } from '../src/server/engine/engine.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' describe('Issue #521: Gold Conservation Invariant & Single Source of Truth', () => { it('maintains strict gold conservation across drop and pickup without duplication', () => { const engine = new GameEngine('sor') - const hud = new HudManager(engine) - engine.bag.gold = 5000 - hud.syncFromEngine() - expect(hud.inventory.gold).toBe(5000) - // Drop 2000 gold via HUD - const dropped = hud.dropInventoryGold(2000) - expect(dropped).toBe(2000) + // Drop 2000 gold (server-authoritative `dropPlayerGold`) + const pile = engine.dropPlayerGold(2000) + expect(pile?.goldAmount).toBe(2000) expect(engine.bag.gold).toBe(3000) - expect(hud.inventory.gold).toBe(3000) const goldPiles = engine.groundItems.all.filter(g => g.isGold) expect(goldPiles.length).toBe(1) @@ -25,27 +19,24 @@ describe('Issue #521: Gold Conservation Invariant & Single Source of Truth', () expect(engine.bag.gold + (goldPiles[0]!.goldAmount ?? 0)).toBe(5000) // Pick up gold pile - const picked = hud.pickupGroundGold(goldPiles[0]!.id) + const picked = engine.pickupGroundGoldPile(goldPiles[0]!.id) expect(picked).toBe(2000) expect(engine.bag.gold).toBe(5000) - expect(hud.inventory.gold).toBe(5000) expect(engine.groundItems.all.filter(g => g.isGold).length).toBe(0) }) it('clamps gold pickup to playerLevel * 10,000 capacity and leaves remainder on ground', () => { const engine = new GameEngine('pal') - const hud = new HudManager(engine) // Level 1 capacity = 10,000 expect(engine.maxGoldCapacity).toBe(10_000) engine.bag.gold = 8500 const pile = engine.groundItems.spawnGold(engine.world.player.x, engine.world.player.y, 4000, 0) - const picked = hud.pickupGroundGold(pile.id) + const picked = engine.pickupGroundGoldPile(pile.id) expect(picked).toBe(1500) expect(engine.bag.gold).toBe(10_000) - expect(hud.inventory.gold).toBe(10_000) const remainingPile = engine.groundItems.get(pile.id) expect(remainingPile).toBeDefined() @@ -54,12 +45,11 @@ describe('Issue #521: Gold Conservation Invariant & Single Source of Truth', () it('rejects negative, zero, or NaN gold drops without mutating state', () => { const engine = new GameEngine('ama') - const hud = new HudManager(engine) engine.bag.gold = 1200 - expect(hud.dropInventoryGold(0)).toBe(0) - expect(hud.dropInventoryGold(-500)).toBe(0) - expect(hud.dropInventoryGold(Number.NaN)).toBe(0) + expect(engine.dropPlayerGold(0)).toBeNull() + expect(engine.dropPlayerGold(-500)).toBeNull() + expect(engine.dropPlayerGold(Number.NaN)).toBeNull() expect(engine.bag.gold).toBe(1200) expect(engine.groundItems.count).toBe(0) }) diff --git a/tests/p0-522-ground-items.test.ts b/tests/p0-522-ground-items.test.ts index 7a14f17..9eed156 100644 --- a/tests/p0-522-ground-items.test.ts +++ b/tests/p0-522-ground-items.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { GameEngine } from '../src/server/engine/engine.ts' import type { Item } from '../src/common/items/items.ts' +import { seedDemoBeltPotions } from './fixtures/demo-profile-data.ts' function makeTestItem(id: number, code: string, name: string): Item { return { @@ -22,7 +23,7 @@ describe('Issue #522: Ground Items Single Source of Truth (engine.groundItems)', it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => { const engine = new GameEngine('nec') // Explicit legacy full belt (16 potions) so the picked-up potion routes to the bag, not the belt. - engine.belt.seedDefaultPotions() + seedDemoBeltPotions(engine.belt) const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion') const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion') diff --git a/tests/p0-528-audio-singleton.test.ts b/tests/p0-528-audio-singleton.test.ts index 6c69294..e873b56 100644 --- a/tests/p0-528-audio-singleton.test.ts +++ b/tests/p0-528-audio-singleton.test.ts @@ -1,9 +1,7 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { readdirSync, readFileSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AudioManager, SoundManager, sharedAudioManager } from '../src/client/audio/manager.ts' -import { playStashOpenSound } from '../src/client/scene/mouse-controller.ts' -import { playInventoryFullFeedback } from '../src/client/scene/overhead-labels.ts' interface MockOscillatorNode { type: string @@ -135,13 +133,11 @@ describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => { vi.unstubAllGlobals() }) - it('constructs at most 1 AudioContext across 50 sound and feedback triggers', () => { + it('constructs at most 1 AudioContext across 60 sound triggers from independent managers', () => { const managerA = new AudioManager() const managerB = new SoundManager() for (let i = 0; i < 20; i++) { - playInventoryFullFeedback() - playStashOpenSound(managerA) managerA.playItemDropSound('item_ring_1') managerB.playSound('item_gold_1') SoundManager.playTone({ type: 'sine', from: 220, to: 180, durationS: 0.1, gain: 0.1 }) @@ -152,7 +148,7 @@ describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => { it('automatically calls resume() when the shared AudioContext is suspended', () => { mockState = 'suspended' - playInventoryFullFeedback() + sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 }) expect(ctorCount).toBe(1) expect(resumeCalls).toBeGreaterThanOrEqual(1) expect(mockState).toBe('running') @@ -160,9 +156,9 @@ describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => { it('schedules delayed tones on ctx.currentTime without setTimeout and disconnects nodes on ended', () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') - playInventoryFullFeedback() + sharedAudioManager.playTone({ type: 'sawtooth', from: 175, to: 90, gain: 0.3, durationS: 0.3, delayS: 0 }) + sharedAudioManager.playTone({ type: 'triangle', from: 110, to: 45, gain: 0.2, durationS: 0.1, delayS: 0.35 }) - // playInventoryFullFeedback plays two tones: delayS = 0 and delayS = 0.35 expect(setTimeoutSpy).not.toHaveBeenCalled() expect(createdOscillators.length).toBe(2) @@ -195,18 +191,11 @@ describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => { expect(ctorCount).toBe(2) }) - it('has zero raw new AudioContext instantiations in overhead-labels.ts and mouse-controller.ts', () => { - const overheadSrc = readFileSync( - resolve(process.cwd(), 'src/client/scene/overhead-labels.ts'), - 'utf8', - ) - const mouseSrc = readFileSync( - resolve(process.cwd(), 'src/client/scene/mouse-controller.ts'), - 'utf8', - ) - - expect(overheadSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/) - expect(overheadSrc).not.toContain('I cannot carry anymore') - expect(mouseSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/) + it('has zero raw new AudioContext instantiations in src/client outside audio/manager.ts', () => { + const root = resolve(process.cwd(), 'src/client') + const offenders = (readdirSync(root, { recursive: true }) as string[]) + .filter(p => p.endsWith('.ts') && p.split(sep).join('/') !== 'audio/manager.ts') + .filter(p => /new\s+(AudioContext|AudioCtx)\b|webkitAudioContext/.test(readFileSync(join(root, p), 'utf8'))) + expect(offenders).toEqual([]) }) }) diff --git a/tests/p0-529-async-lifecycle.test.ts b/tests/p0-529-async-lifecycle.test.ts index 749b03c..611962a 100644 --- a/tests/p0-529-async-lifecycle.test.ts +++ b/tests/p0-529-async-lifecycle.test.ts @@ -3,12 +3,6 @@ import { ActorAnimator } from '../src/client/render/actor-animator.ts' import type { LoadedMonsterArt } from '../src/client/render/monster-art.ts' import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../src/client/render/renderer.ts' import * as entityArtLoader from '../src/client/scene/entity-art-loader.ts' -import { - createLevelTransitionController, - disposeRuntimeAtlases, - type MapRuntime, -} from '../src/client/scene/pack-loader.ts' -import { createHeroLightingAndMonsterControls } from '../src/client/scene/toolbar-chat-ui.ts' function createTrackingRenderer() { let nextHandle = 1 @@ -35,58 +29,6 @@ function createTrackingRenderer() { return { renderer, liveHandles, deletedHandles } } -function makeDummyRuntime(levelId: number, pages: Array = []): MapRuntime { - return { - source: 'pack', - base: '/packs', - act: 1, - level: String(levelId), - levelId, - variant: 'v0', - variants: ['v0'], - title: `Level ${levelId}`, - widthPx: 800, - heightPx: 600, - cellsX: 10, - cellsY: 10, - animSpeed: 16, - frameDurationMs: 62.5, - grid: { - gridWidth: 10, - gridHeight: 10, - tileWidth: 10, - tileHeight: 10, - blocked: new Uint8Array(100), - originX: 0, - originY: 0, - } as any, - floors: [], - shadows: [], - walls: [], - roofs: [], - objectDrawables: [], - npcs: [], - monsterTypes: ['fallen1'], - monsterPacks: [], - baseMonsterPacks: [], - entrances: [], - warps: [], - waypoints: [], - stashes: [], - objects: [], - spawn: { x: 100, y: 100 }, - palette: { rgb: new Uint8Array(768), rgba: new Uint32Array(256) } as any, - pages: [...pages], - objectPages: [], - priorityPages: [], - totalPages: pages.length, - charBases: ['/mpq'], - loadPages: vi.fn(async () => {}), - disposed: false, - loadGen: new LoadGeneration(), - } as unknown as MapRuntime -} - describe('P0 #529: Async Scene LoadGeneration Cancellation & GPU Texture Disposal', () => { it('LoadGeneration increments generation IDs and aborts prior AbortSignal instances', () => { const loadGen = new LoadGeneration() @@ -107,276 +49,4 @@ describe('P0 #529: Async Scene LoadGeneration Cancellation & GPU Texture Disposa expect(loadGen.isCurrent(gen2)).toBe(false) expect(loadGen.signal.aborted).toBe(true) }) - - it('discards out-of-order switchHero completions and deletes stale character GPU textures', async () => { - const { renderer, liveHandles, deletedHandles } = createTrackingRenderer() - - let resolveFirst!: (val: entityArtLoader.LoadedCharacter) => void - let resolveSecond!: (val: entityArtLoader.LoadedCharacter) => void - - const loadCharSpy = vi - .spyOn(entityArtLoader, 'loadCharacterArt') - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveFirst = resolve - }), - ) - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveSecond = resolve - }), - ) - - let currentHeroToken = 'so' - const initialHandle = renderer.addAtlas(new Uint8Array(4), 64, 64) - let activeCharacter: entityArtLoader.LoadedCharacter | null = { - handle: initialHandle, - clips: {}, - members: 1, - } as unknown as entityArtLoader.LoadedCharacter - - const runtime = makeDummyRuntime(1) - const status = { textContent: '' } as HTMLElement - - const controls = createHeroLightingAndMonsterControls({ - renderer, - dropTables: {} as any, - playerAnimator: new ActorAnimator(), - status, - heroSelect: null, - lightingSelect: null, - densitySelect: null, - eliteSelect: null, - getRuntime: () => runtime, - getEngine: () => ({ world: { player: {} } }) as any, - getHudManager: () => null, - getCurrentHeroToken: () => currentHeroToken, - setCurrentHeroToken: t => { - currentHeroToken = t - }, - getCharacter: () => activeCharacter, - setCharacter: c => { - activeCharacter = c - }, - getActivePalette: () => runtime.palette, - getPackEntityBase: () => '/packs', - getPackEntityAct: () => 1, - getCurrentLightingPreset: () => 'auto', - setLightingState: () => {}, - getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }), - setMultipliers: () => {}, - setMonsterArtMap: () => {}, - }) - - // Fire two rapid hero switches: 'am' (slow) then 'ba' (fast) - const p1 = controls.switchHero('am') - const p2 = controls.switchHero('ba') - - const handleBa = renderer.addAtlas(new Uint8Array(4), 64, 64) - resolveSecond({ handle: handleBa, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter) - await p2 - - expect(currentHeroToken).toBe('ba') - expect(activeCharacter?.handle).toBe(handleBa) - expect(deletedHandles).toContain(initialHandle) - - // Now the stale first request ('am') resolves AFTER 'ba' - const handleAm = renderer.addAtlas(new Uint8Array(4), 64, 64) - resolveFirst({ handle: handleAm, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter) - await p1 - - // Must keep 'ba' and immediately delete stale 'am' texture handle - expect(currentHeroToken).toBe('ba') - expect(activeCharacter?.handle).toBe(handleBa) - expect(deletedHandles).toContain(handleAm) - expect(liveHandles.size).toBe(1) - expect(liveHandles.has(handleBa)).toBe(true) - - loadCharSpy.mockRestore() - }) - - it('discards out-of-order reloadMonsters completions and deletes replaced monster GPU textures', async () => { - const { renderer, liveHandles, deletedHandles } = createTrackingRenderer() - - let resolveFirst!: (val: entityArtLoader.MonsterArtLoad) => void - let resolveSecond!: (val: entityArtLoader.MonsterArtLoad) => void - - const loadMonSpy = vi - .spyOn(entityArtLoader, 'loadMonsterArtMap') - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveFirst = resolve - }), - ) - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveSecond = resolve - }), - ) - - const initialMonHandle = renderer.addAtlas(new Uint8Array(4), 64, 64) - let monsterArtMap = new Map([ - ['fallen1', { handle: initialMonHandle, clips: {} } as any], - ]) - - const runtime = makeDummyRuntime(2) - runtime.baseMonsterPacks = [ - { - leaderId: 'fallen1', - members: [{ id: 'fallen1', rank: 'normal' }], - } as any, - ] - const status = { textContent: '' } as HTMLElement - - const controls = createHeroLightingAndMonsterControls({ - renderer, - dropTables: { monsterKinds: new Map() } as any, - playerAnimator: new ActorAnimator(), - status, - heroSelect: null, - lightingSelect: null, - densitySelect: null, - eliteSelect: null, - getRuntime: () => runtime, - getEngine: () => - ({ - terrain: { - overlap: () => 0, - isWalkable: () => true, - }, - world: { - player: { x: 100, y: 100 }, - monsters: [], - corpses: [], - events: [], - }, - projectiles: [], - explosions: [], - }) as any, - getHudManager: () => null, - getCurrentHeroToken: () => 'so', - setCurrentHeroToken: () => {}, - getCharacter: () => null, - setCharacter: () => {}, - getActivePalette: () => runtime.palette, - getPackEntityBase: () => '/packs', - getPackEntityAct: () => 1, - getCurrentLightingPreset: () => 'auto', - setLightingState: () => {}, - getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }), - setMultipliers: () => {}, - getMonsterArtMap: () => monsterArtMap, - setMonsterArtMap: m => { - monsterArtMap = m - }, - }) - - const p1 = controls.reloadMonsters({ density: 2 }) - const p2 = controls.reloadMonsters({ density: 5 }) - - const handleSecond = renderer.addAtlas(new Uint8Array(4), 64, 64) - resolveSecond({ - map: new Map([['fallen1', { handle: handleSecond, clips: {} } as any]]), - missing: [], - } as unknown as entityArtLoader.MonsterArtLoad) - await p2 - - expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond) - expect(deletedHandles).toContain(initialMonHandle) - - // Stale first load resolves later - const handleFirst = renderer.addAtlas(new Uint8Array(4), 64, 64) - resolveFirst({ - map: new Map([['fallen1', { handle: handleFirst, clips: {} } as any]]), - missing: [], - } as unknown as entityArtLoader.MonsterArtLoad) - await p1 - - expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond) - expect(deletedHandles).toContain(handleFirst) - expect(liveHandles.size).toBe(1) - - loadMonSpy.mockRestore() - }) - - it('disposes old runtime atlases and clears groundItems on swapLevel', async () => { - const { renderer, liveHandles } = createTrackingRenderer() - const page1 = renderer.addAtlas(new Uint8Array(4), 256, 256) - const page2 = renderer.addAtlas(new Uint8Array(4), 256, 256) - const oldRuntime = makeDummyRuntime(1, [page1, page2]) - - const nextPage = renderer.addAtlas(new Uint8Array(4), 256, 256) - const nextRuntime = makeDummyRuntime(2, [nextPage]) - - const loadMonSpy = vi.spyOn(entityArtLoader, 'loadMonsterArtMap').mockResolvedValue({ - map: new Map(), - missing: [], - } as unknown as entityArtLoader.MonsterArtLoad) - - let activeRuntime = oldRuntime - let monsterArtMap = new Map() - const groundItemsClear = vi.fn() - - const controller = createLevelTransitionController({ - canvas: { width: 800, height: 600 } as HTMLCanvasElement, - status: { textContent: '' } as HTMLElement, - renderer, - engine: { - world: { - player: { x: 100, y: 100, targetX: 100, targetY: 100, vx: 0, vy: 0 }, - monsters: [], - corpses: [], - events: [], - }, - ground: [{ x: 10, y: 10, item: {} as any }], - groundItems: { clear: groundItemsClear }, - npcEntities: [], - projectiles: [], - explosions: [], - } as any, - camera: { setWorldSize: vi.fn(), centerOn: vi.fn() } as any, - input: { clear: vi.fn() } as any, - fade: {} as any, - minimap: { reveal: vi.fn(), revealedRoomsCount: 0 } as any, - minimapLevel: () => ({}) as any, - resetMinimapCache: vi.fn(), - waypointNetwork: { tryAutoUnlockNearest: vi.fn() } as any, - portalSlot: {} as any, - playerAnimator: new ActorAnimator(), - playerSubTile: () => ({ x: 10, y: 10 }), - populatePackSelectors: vi.fn(async () => {}), - getRuntime: () => activeRuntime, - setRuntime: next => { - activeRuntime = next - }, - getHudManager: () => null, - clearMouseTarget: vi.fn(), - getCharacter: () => null, - getMonsterArtMap: () => monsterArtMap, - setMonsterArtMap: m => { - monsterArtMap = m - }, - setActivePaletteAndPack: vi.fn(), - getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }), - setBoundsState: vi.fn(), - }) - - await controller.swapLevel(nextRuntime) - - expect(oldRuntime.disposed).toBe(true) - expect(oldRuntime.pages).toEqual([null, null]) - expect(groundItemsClear).toHaveBeenCalledTimes(1) - // Only nextPage should remain alive - expect(liveHandles.size).toBe(1) - expect(liveHandles.has(nextPage)).toBe(true) - - disposeRuntimeAtlases(nextRuntime, renderer) - expect(liveHandles.size).toBe(0) - - loadMonSpy.mockRestore() - }) }) diff --git a/tests/render-performance.test.ts b/tests/render-performance.test.ts index fc411d6..475c4c9 100644 --- a/tests/render-performance.test.ts +++ b/tests/render-performance.test.ts @@ -1,7 +1,5 @@ import { describe, test, expect } from 'vitest' import { SpriteRenderer, RendererError, type AtlasHandle } from '../src/client/render/renderer.ts' -import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/client/render/cull.ts' -import { countLoadedPages } from '../src/client/world/act-runtime.ts' interface MockResource { id: number @@ -321,94 +319,6 @@ describe('多纹理合批与 Draw Call 预算 (方案 B / Issue #16)', () => { }) }) -describe('视口剔除与剔除率守卫 (方案 A / Issue #16)', () => { - test('大关卡 (80x80 = 6400 四边形) 视口剔除率稳定超过 80%', () => { - const quads: { x: number; y: number; frame: { width: number; height: number } }[] = [] - const cols = 80 - const rows = 80 - for (let y = 0; y < rows; y += 1) { - for (let x = 0; x < cols; x += 1) { - quads.push({ - x: x * 80, - y: y * 40, - frame: { width: 80, height: 40 }, - }) - } - } - expect(quads.length).toBe(6400) - - const bounds = buildBounds(quads) - // 相机放置于地图中央 (3200, 1600),视口 1280x720,缩放 1.0 - const view = viewportRect(3200, 1600, 1280, 720, 1.0) - - let visible = 0 - let culled = 0 - const visibleIndices: number[] = [] - - for (let i = 0; i < quads.length; i += 1) { - if (intersects(bounds, i, view)) { - visible += 1 - visibleIndices.push(i) - } else { - culled += 1 - } - } - - const cullingRatio = culled / quads.length - expect(visible + culled).toBe(quads.length) - // 80x80 关卡在 1280x720 视口下实测剔除率约为 94% - expect(cullingRatio).toBeGreaterThan(0.80) - expect(cullingRatio).toBeGreaterThanOrEqual(0.88) - - // 严格断言画家算法顺序:保留的四边形相对下标必须单调递增,不能错乱 - for (let i = 1; i < visibleIndices.length; i += 1) { - expect(visibleIndices[i]).toBeGreaterThan(visibleIndices[i - 1]!) - } - }) - - test('放大 (zoom = 2.0) 时视口变小,剔除率进一步提高', () => { - const quads: { x: number; y: number; frame: { width: number; height: number } }[] = [] - for (let y = 0; y < 60; y += 1) { - for (let x = 0; x < 60; x += 1) { - quads.push({ x: x * 80, y: y * 40, frame: { width: 80, height: 40 } }) - } - } - const bounds = buildBounds(quads) - const view1x = viewportRect(2400, 1200, 1280, 720, 1.0) - const view2x = viewportRect(2400, 1200, 1280, 720, 2.0) - - let culled1x = 0 - let culled2x = 0 - for (let i = 0; i < quads.length; i += 1) { - if (!intersects(bounds, i, view1x)) culled1x += 1 - if (!intersects(bounds, i, view2x)) culled2x += 1 - } - - const ratio1x = culled1x / quads.length - const ratio2x = culled2x / quads.length - - expect(ratio1x).toBeGreaterThan(0.80) - expect(ratio2x).toBeGreaterThan(ratio1x) - }) - - test('边界临界条件:搭界不剔除、屏外大精灵不剔除', () => { - const view = viewportRect(1000, 1000, 800, 600, 1) - // view: left: 1000 - 400 - 32 = 568, right: 1432, top: 1000 - 300 - 32 = 668, bottom: 1332 - - // 1. 完全屏外 - const farAway = buildBounds([{ x: 0, y: 0, frame: { width: 10, height: 10 } }]) - expect(intersects(farAway, 0, view)).toBe(false) - - // 2. 恰好搭在边界边缘(利用 CULL_MARGIN 余量) - const borderQuad = buildBounds([{ x: 570, y: 700, frame: { width: 10, height: 10 } }]) - expect(intersects(borderQuad, 0, view)).toBe(true) - - // 3. 锚点在屏外上方 (x: 1000, y: 0),但精灵尺寸达 200x800,覆盖到底部 - const tallSprite = buildBounds([{ x: 1000, y: 0, frame: { width: 200, height: 800 } }]) - expect(intersects(tallSprite, 0, view)).toBe(true) - }) -}) - describe('HUD 动态度量与真实性能指标', () => { test('HUD 数据源自渲染器实时测量值,而非硬编码假值', () => { const mock = createMockCanvas() @@ -494,24 +404,6 @@ describe('零分配顶点写入与 GPU 资源生命周期审计 (方案 C & 方 renderer.dispose() }) - test('countLoadedPages 统计加载页数零临时数组分配', () => { - const mock = createMockCanvas() - const renderer = new SpriteRenderer(mock.canvas) - const page1 = createAtlasPage(renderer) - const page2 = createAtlasPage(renderer) - - const pages = [page1, null, page2, undefined, null] - expect(countLoadedPages(pages as any)).toBe(2) - - const allNull = [null, null, null] - expect(countLoadedPages(allNull)).toBe(0) - - const empty: any[] = [] - expect(countLoadedPages(empty)).toBe(0) - - renderer.dispose() - }) - test('WebGL 着色器与纹理生命周期完整释放,无显存泄漏', () => { const mock = createMockCanvas() const renderer = new SpriteRenderer(mock.canvas) diff --git a/tests/roofs.test.ts b/tests/roofs.test.ts deleted file mode 100644 index 3abfdc3..0000000 --- a/tests/roofs.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getRoofBounds, - getCombinedRoofBounds, - isPointInRoofBounds, - boundsOverlap, - getPlayerBounds, - isPlayerOverlappingRoofBounds, - isPlayerInRoofCell, - isPlayerInRoofSubTile, - isPlayerUnderRoof, - stepRoofAlpha, - clusterRoofs, - RoofFadeController, - DEFAULT_MIN_ROOF_ALPHA, - DEFAULT_MAX_ROOF_ALPHA, - DEFAULT_ROOF_FADE_DURATION_MS, - DEFAULT_CLUSTER_CELL_DISTANCE, - type RoofDrawable, -} from '../src/client/render/roofs.ts' - -/** Helper to create a minimal roof tile. */ -function makeRoof( - x: number, - y: number, - width: number, - height: number, - cellX?: number, - cellY?: number, - roofHeight?: number, - wallType?: number, -): RoofDrawable { - return { - x, - y, - frame: { width, height }, - ...(cellX !== undefined ? { cellX } : {}), - ...(cellY !== undefined ? { cellY } : {}), - ...(roofHeight !== undefined ? { roofHeight } : {}), - ...(wallType !== undefined ? { wallType } : {}), - } -} - -describe('Roof Bounding Detection', () => { - it('computes correct bounds for a single roof tile', () => { - const roof = makeRoof(100, 200, 80, 60) - const bounds = getRoofBounds(roof) - expect(bounds).toEqual({ - minX: 100, - minY: 200, - maxX: 180, - maxY: 260, - }) - }) - - it('computes correct bounds with negative coordinates and offsets', () => { - const roof = makeRoof(-50, -30, 100, 70) - const bounds = getRoofBounds(roof) - expect(bounds).toEqual({ - minX: -50, - minY: -30, - maxX: 50, - maxY: 40, - }) - }) - - it('returns null for combined bounds of empty roof list', () => { - expect(getCombinedRoofBounds([])).toBeNull() - }) - - it('computes combined bounding box enclosing multiple roof tiles', () => { - const roofs = [ - makeRoof(100, 150, 40, 40), - makeRoof(130, 180, 50, 30), - makeRoof(80, 200, 30, 60), - ] - const combined = getCombinedRoofBounds(roofs) - expect(combined).toEqual({ - minX: 80, - minY: 150, - maxX: 180, // 130 + 50 - maxY: 260, // 80..110, 200 + 60 - }) - }) - - it('detects whether a 2D point is inside roof bounds', () => { - const bounds = { minX: 50, minY: 100, maxX: 150, maxY: 200 } - - // Inside - expect(isPointInRoofBounds(100, 150, bounds)).toBe(true) - - // Boundaries (inclusive) - expect(isPointInRoofBounds(50, 100, bounds)).toBe(true) - expect(isPointInRoofBounds(150, 200, bounds)).toBe(true) - - // Outside - expect(isPointInRoofBounds(49, 150, bounds)).toBe(false) - expect(isPointInRoofBounds(151, 150, bounds)).toBe(false) - expect(isPointInRoofBounds(100, 99, bounds)).toBe(false) - expect(isPointInRoofBounds(100, 201, bounds)).toBe(false) - }) - - it('detects bounding box overlap and non-overlap', () => { - const boxA = { minX: 0, minY: 0, maxX: 100, maxY: 100 } - - // Fully overlapping - expect(boundsOverlap(boxA, { minX: 20, minY: 20, maxX: 80, maxY: 80 })).toBe(true) - // Partially overlapping - expect(boundsOverlap(boxA, { minX: 50, minY: 50, maxX: 150, maxY: 150 })).toBe(true) - // Touching edge - expect(boundsOverlap(boxA, { minX: 100, minY: 0, maxX: 200, maxY: 100 })).toBe(true) - // Separated in X - expect(boundsOverlap(boxA, { minX: 101, minY: 0, maxX: 200, maxY: 100 })).toBe(false) - // Separated in Y - expect(boundsOverlap(boxA, { minX: 0, minY: 101, maxX: 100, maxY: 200 })).toBe(false) - }) -}) - -describe('Player-Under-Roof Condition', () => { - const roof = makeRoof(100, 100, 80, 80, 5, 10) - - describe('bounding box overlap', () => { - it('detects player point inside roof bounds', () => { - expect(isPlayerOverlappingRoofBounds({ x: 140, y: 140 }, roof)).toBe(true) - }) - - it('detects player sprite overlapping lower edge of roof', () => { - // Player feet at y=190, sprite extends upward to y=142, overlapping roof [100..180] - expect(isPlayerOverlappingRoofBounds({ x: 140, y: 190 }, roof)).toBe(true) - }) - - it('returns false when player is far away from roof', () => { - expect(isPlayerOverlappingRoofBounds({ x: 500, y: 500 }, roof)).toBe(false) - expect(isPlayerOverlappingRoofBounds({ x: 20, y: 50 }, roof)).toBe(false) - }) - - it('supports explicit player bounding box', () => { - expect(isPlayerOverlappingRoofBounds({ left: 110, top: 120, right: 130, bottom: 160 }, roof)).toBe(true) - expect(isPlayerOverlappingRoofBounds({ left: 300, top: 300, right: 320, bottom: 340 }, roof)).toBe(false) - }) - }) - - describe('cell and sub-tile coverage', () => { - it('matches player cell coordinates with roof tile cell', () => { - expect(isPlayerInRoofCell({ x: 5, y: 10 }, roof)).toBe(true) - expect(isPlayerInRoofCell({ x: 4, y: 10 }, roof)).toBe(false) - expect(isPlayerInRoofCell({ x: 5, y: 9 }, roof)).toBe(false) - }) - - it('matches player cell against an array of roof tiles', () => { - const roofTiles = [ - makeRoof(0, 0, 80, 40, 5, 10), - makeRoof(0, 0, 80, 40, 5, 11), - makeRoof(0, 0, 80, 40, 6, 10), - ] - expect(isPlayerInRoofCell({ x: 5, y: 11 }, roofTiles)).toBe(true) - expect(isPlayerInRoofCell({ x: 6, y: 10 }, roofTiles)).toBe(true) - expect(isPlayerInRoofCell({ x: 7, y: 12 }, roofTiles)).toBe(false) - }) - - it('detects player sub-tile coverage (5x5 sub-tiles per cell)', () => { - // Cell (5, 10) corresponds to sub-tiles [25..29, 50..54] - expect(isPlayerInRoofSubTile({ x: 25, y: 50 }, roof)).toBe(true) - expect(isPlayerInRoofSubTile({ x: 29, y: 54 }, roof)).toBe(true) - expect(isPlayerInRoofSubTile({ x: 27, y: 52 }, roof)).toBe(true) - - // Adjacent sub-tile in cell (4, 10) or (5, 9) - expect(isPlayerInRoofSubTile({ x: 24, y: 50 }, roof)).toBe(false) - expect(isPlayerInRoofSubTile({ x: 25, y: 49 }, roof)).toBe(false) - }) - - it('combines cell coverage and bounding box in isPlayerUnderRoof', () => { - // Cell matches even if screen point is outside - expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 5, y: 10 })).toBe(true) - - // Bounding box matches even if cell is different - expect(isPlayerUnderRoof({ x: 140, y: 140 }, roof, { x: 99, y: 99 })).toBe(true) - - // Neither matches - expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 99, y: 99 })).toBe(false) - }) - }) - - describe('shelter clustering', () => { - it('groups adjacent roof tiles into a single shelter cluster', () => { - const akaraTent = [ - makeRoof(100, 100, 80, 80, 10, 10), - makeRoof(140, 120, 80, 80, 10, 11), - makeRoof(120, 140, 80, 80, 11, 10), - ] - const clusters = clusterRoofs(akaraTent) - expect(clusters).toHaveLength(1) - expect(clusters[0]!.roofs).toHaveLength(3) - expect(clusters[0]!.cells.has('10,10')).toBe(true) - expect(clusters[0]!.cells.has('10,11')).toBe(true) - expect(clusters[0]!.cells.has('11,10')).toBe(true) - }) - - it('separates distant roof groups into distinct shelters', () => { - const akaraTent = [ - makeRoof(100, 100, 80, 80, 10, 10), - makeRoof(140, 120, 80, 80, 10, 11), - ] - const charsiForge = [ - makeRoof(800, 800, 80, 80, 50, 50), - makeRoof(840, 820, 80, 80, 50, 51), - ] - const clusters = clusterRoofs([...akaraTent, ...charsiForge]) - expect(clusters).toHaveLength(2) - - const clusterA = clusters.find(c => c.cells.has('10,10'))! - const clusterB = clusters.find(c => c.cells.has('50,50'))! - expect(clusterA.roofs).toHaveLength(2) - expect(clusterB.roofs).toHaveLength(2) - }) - }) -}) - -describe('Roof Opacity Fade', () => { - describe('stepRoofAlpha', () => { - it('fades down towards minAlpha over durationMs', () => { - // 200ms total duration, from 1.0 to 0.25 - const start = 1.0 - const target = 0.25 - - // After 50ms (25% progress) -> 1.0 - 0.75 * 0.25 = 0.8125 - const step1 = stepRoofAlpha(start, target, 50, 200, 0.25, 1.0) - expect(step1).toBeCloseTo(0.8125, 4) - - // After 100ms (50% progress) -> 1.0 - 0.75 * 0.5 = 0.625 - const step2 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0) - expect(step2).toBeCloseTo(0.625, 4) - - // After 200ms (100% progress) -> exactly 0.25 - const step3 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0) - expect(step3).toBe(0.25) - - // Clamps and does not overshoot below minAlpha - const step4 = stepRoofAlpha(0.25, target, 100, 200, 0.25, 1.0) - expect(step4).toBe(0.25) - }) - - it('fades up towards maxAlpha when exiting shelter', () => { - const start = 0.25 - const target = 1.0 - - // After 100ms -> halfway back to 1.0 (0.625) - const step1 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0) - expect(step1).toBeCloseTo(0.625, 4) - - // After 200ms -> full 1.0 - const step2 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0) - expect(step2).toBe(1.0) - - // Clamps at maxAlpha - const step3 = stepRoofAlpha(1.0, target, 50, 200, 0.25, 1.0) - expect(step3).toBe(1.0) - }) - - it('smoothly reverses direction mid-fade without discontinuities', () => { - // Player enters shelter, fades down partially to 0.70 - let alpha = 1.0 - alpha = stepRoofAlpha(alpha, 0.25, 80, 200, 0.25, 1.0) - expect(alpha).toBeCloseTo(0.70, 4) - - // Player immediately steps back out: smoothly fades back up towards 1.0 - alpha = stepRoofAlpha(alpha, 1.0, 40, 200, 0.25, 1.0) - expect(alpha).toBeGreaterThan(0.70) - expect(alpha).toBeCloseTo(0.85, 4) - - // Reaches 1.0 - alpha = stepRoofAlpha(alpha, 1.0, 100, 200, 0.25, 1.0) - expect(alpha).toBe(1.0) - }) - - it('returns currentAlpha when deltaMs is zero or negative', () => { - expect(stepRoofAlpha(0.5, 0.25, 0, 200)).toBe(0.5) - expect(stepRoofAlpha(0.5, 0.25, -10, 200)).toBe(0.5) - }) - - it('snaps immediately to target when durationMs is zero', () => { - expect(stepRoofAlpha(1.0, 0.25, 16, 0)).toBe(0.25) - }) - }) - - describe('RoofFadeController', () => { - const akaraTent = [ - makeRoof(100, 100, 80, 80, 10, 10), - makeRoof(140, 120, 80, 80, 10, 11), - ] - const charsiForge = [ - makeRoof(800, 800, 80, 80, 50, 50), - ] - - it('initializes all roofs to full opacity (1.0)', () => { - const controller = new RoofFadeController([...akaraTent, ...charsiForge]) - expect(controller.getAlpha(0)).toBe(1.0) - expect(controller.getAlpha(1)).toBe(1.0) - expect(controller.getAlpha(2)).toBe(1.0) - expect(controller.overallAlpha).toBe(1.0) - expect(controller.isPlayerUnderAnyRoof).toBe(false) - }) - - it('fades only the shelter the player is under', () => { - const controller = new RoofFadeController([...akaraTent, ...charsiForge], { - fadeDurationMs: 200, - minAlpha: 0.25, - }) - - // Player stands inside Akara's tent at cell (10, 10) - const playerPos = { x: 140, y: 140 } - const playerCell = { x: 10, y: 10 } - - // Advance by 100ms (halfway) - controller.update(playerPos, 100, playerCell) - - expect(controller.isPlayerUnderAnyRoof).toBe(true) - // Akara's tent tiles (indices 0 and 1) fade in unison - expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4) - expect(controller.getAlpha(1)).toBeCloseTo(0.625, 4) - - // Charsi's forge (index 2) remains fully opaque - expect(controller.getAlpha(2)).toBe(1.0) - - // Advance by another 100ms (reaches minAlpha 0.25) - controller.update(playerPos, 100, playerCell) - expect(controller.getAlpha(0)).toBe(0.25) - expect(controller.getAlpha(1)).toBe(0.25) - expect(controller.getAlpha(2)).toBe(1.0) - expect(controller.overallAlpha).toBe(0.25) - }) - - it('smoothly restores full opacity when player exits shelter', () => { - const controller = new RoofFadeController(akaraTent, { - fadeDurationMs: 200, - minAlpha: 0.25, - }) - - // Player inside -> fully faded - controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 }) - expect(controller.getAlpha(0)).toBe(0.25) - - // Player exits shelter to town square - const outsidePos = { x: 500, y: 500 } - const outsideCell = { x: 25, y: 25 } - - // 100ms elapsed - controller.update(outsidePos, 100, outsideCell) - expect(controller.isPlayerUnderAnyRoof).toBe(false) - expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4) - - // 200ms elapsed -> fully restored - controller.update(outsidePos, 100, outsideCell) - expect(controller.getAlpha(0)).toBe(1.0) - expect(controller.overallAlpha).toBe(1.0) - }) - - it('handles empty roof list safely', () => { - const controller = new RoofFadeController([]) - controller.update({ x: 100, y: 100 }, 16) - expect(controller.getAlpha(0)).toBe(1.0) - expect(controller.overallAlpha).toBe(1.0) - expect(controller.isPlayerUnderAnyRoof).toBe(false) - }) - - it('allows manual alpha override via setAlpha', () => { - const controller = new RoofFadeController(akaraTent) - controller.setAlpha(0.2) - expect(controller.getAlpha(0)).toBe(0.2) - expect(controller.getAlpha(1)).toBe(0.2) - }) - }) -}) - -describe('Issue #72: Harrogath West Gate and Overhead Occlusion Fade', () => { - it('projects elevated roof bounds down to ground footprint for occlusion detection', () => { - // A roof tile elevated by 160 px (isometric YAdjust = -roofHeight = -160) - // The visual sprite is rendered at y=200..260, but ground under it is at y=360 - const elevatedRoof = makeRoof(100, 200, 80, 60, undefined, undefined, 160) - const bounds = getRoofBounds(elevatedRoof) - - // Projected down: maxY = 200 + 60 + 160 = 420 - expect(bounds).toEqual({ - minX: 100, - minY: 200, - maxX: 180, - maxY: 420, - }) - - // Player standing on the ground beneath the elevated roof: - // Player feet at y=360, sprite height 48 -> player bounds y: 312..360 - const playerOnGround = { x: 140, y: 360 } - - // Unelevated roof (height=0) would fail to detect this player - const flatRoof = makeRoof(100, 200, 80, 60, undefined, undefined, 0) - expect(isPlayerOverlappingRoofBounds(playerOnGround, flatRoof)).toBe(false) - - // Elevated roof correctly detects the player on the ground - expect(isPlayerOverlappingRoofBounds(playerOnGround, elevatedRoof)).toBe(true) - expect(isPlayerUnderRoof(playerOnGround, elevatedRoof)).toBe(true) - }) - - it('computes elevation from cell coordinate offset when roofHeight is omitted', () => { - // Roof placed at cell (5, 10), orthoY = (5 + 10) * 40 = 600 - // Sprite top y = 440, so implicit elevation = 600 - 440 = 160 - const roof = makeRoof(100, 440, 80, 60, 5, 10) - const bounds = getRoofBounds(roof) - expect(bounds.maxY).toBe(440 + 60 + 160) // 660 - - // Player at ground orthoY=600 is detected under roof bounds - const playerAtGround = { x: 140, y: 600 } - expect(isPlayerOverlappingRoofBounds(playerAtGround, roof)).toBe(true) - }) - - it('clusters Harrogath West Gate left shrine and right tower across corridor', () => { - // Harrogath West Gate layout: - // Left shrine roofs: cellX: 5..7, cellY: 27..29 - // Right tower roofs: cellX: 10..12, cellY: 27..30 - // Separated by 3-4 cells across the gate passage - const leftShrine = [ - makeRoof(200, 600, 80, 80, 5, 27), - makeRoof(240, 640, 80, 80, 6, 28), - makeRoof(280, 680, 80, 80, 7, 29), - ] - const rightTower = [ - makeRoof(480, 640, 80, 80, 10, 28), - makeRoof(480, 680, 80, 80, 10, 30), - makeRoof(520, 680, 80, 80, 11, 29), - makeRoof(560, 720, 80, 80, 12, 30), - ] - - expect(DEFAULT_CLUSTER_CELL_DISTANCE).toBe(4) - const clusters = clusterRoofs([...leftShrine, ...rightTower]) - - // With distance 4, the gatehouse halves form a single connected cluster - expect(clusters).toHaveLength(1) - expect(clusters[0]!.roofs).toHaveLength(7) - - // Corridor passage cells between shrine (cx: 7) and tower (cx: 10) are bridged - expect(clusters[0]!.cells.has('7,29')).toBe(true) - expect(clusters[0]!.cells.has('8,29')).toBe(true) - expect(clusters[0]!.cells.has('9,29')).toBe(true) - expect(clusters[0]!.cells.has('10,29')).toBe(true) - expect(clusters[0]!.cells.has('8,30')).toBe(true) - }) - - it('bridges corridor cells between gatehouse structures so passage triggers occlusion', () => { - const leftShrine = [ - makeRoof(200, 600, 80, 80, 6, 28), - makeRoof(240, 640, 80, 80, 7, 29), - ] - const rightTower = [ - makeRoof(480, 640, 80, 80, 10, 29), - makeRoof(520, 680, 80, 80, 11, 30), - ] - const controller = new RoofFadeController([...leftShrine, ...rightTower], { - fadeDurationMs: 200, - }) - - expect(controller.overallAlpha).toBe(1.0) - expect(controller.isPlayerUnderAnyRoof).toBe(false) - - // Player steps into the passage between shrine and tower: cell (8, 30) - const playerInPassage = { x: 340, y: 700 } - controller.update(playerInPassage, 200, { x: 8, y: 30 }) - - expect(controller.isPlayerUnderAnyRoof).toBe(true) - // Left shrine and right tower both fade completely to 0.0 in unison - expect(controller.getAlpha(0)).toBe(0.0) - expect(controller.getAlpha(1)).toBe(0.0) - expect(controller.getAlpha(2)).toBe(0.0) - expect(controller.getAlpha(3)).toBe(0.0) - expect(controller.overallAlpha).toBe(0.0) - - // Player steps outside into the open snowfield - const playerOutside = { x: 50, y: 300 } - controller.update(playerOutside, 200, { x: 2, y: 30 }) - - expect(controller.isPlayerUnderAnyRoof).toBe(false) - expect(controller.overallAlpha).toBe(1.0) - }) - - it('fades all connected gatehouse roofs and overhead archways/upper walls in unison to 0.0', () => { - // Cross-passage archways (wallType: 12) and upper walls (wallType: 14) - // alongside gatehouse roofs (wallType: 15) - const gateDrawables: RoofDrawable[] = [ - makeRoof(240, 640, 80, 80, 7, 28, 160, 15), // Left shrine roof - makeRoof(360, 600, 80, 80, 9, 28, 128, 12), // Overhead archway - makeRoof(400, 620, 80, 80, 10, 29, 96, 12), // Overhead archway - makeRoof(420, 580, 80, 80, 10, 30, 384, 14), // Upper wall cross beam - makeRoof(480, 640, 80, 80, 11, 28, 160, 15), // Right tower roof - ] - - const controller = new RoofFadeController(gateDrawables, { - fadeDurationMs: 200, - }) - - // All belong to the same cluster spanning the gate - expect(controller.clusters).toHaveLength(1) - expect(controller.clusters[0]!.roofs).toHaveLength(5) - - // Player under archway at cell (9, 28) - controller.update({ x: 360, y: 728 }, 200, { x: 9, y: 28 }) - expect(controller.isPlayerUnderAnyRoof).toBe(true) - // Every element (roofs, archways, upper walls) fades completely to 0.0 - for (let i = 0; i < gateDrawables.length; i += 1) { - expect(controller.getAlpha(i)).toBe(0.0) - } - - // Player under upper wall beam at cell (10, 30) - controller.update({ x: 420, y: 964 }, 200, { x: 10, y: 30 }) - expect(controller.isPlayerUnderAnyRoof).toBe(true) - for (let i = 0; i < gateDrawables.length; i += 1) { - expect(controller.getAlpha(i)).toBe(0.0) - } - - // Player exits gate - controller.update({ x: 100, y: 100 }, 200, { x: 1, y: 1 }) - expect(controller.isPlayerUnderAnyRoof).toBe(false) - for (let i = 0; i < gateDrawables.length; i += 1) { - expect(controller.getAlpha(i)).toBe(1.0) - } - }) - - it('defaults to complete transparency (minAlpha = 0.0) in vanilla Diablo II style', () => { - expect(DEFAULT_MIN_ROOF_ALPHA).toBe(0.0) - const roof = makeRoof(100, 100, 80, 80, 10, 10) - const controller = new RoofFadeController([roof]) - controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 }) - expect(controller.getAlpha(0)).toBe(0.0) - expect(controller.overallAlpha).toBe(0.0) - }) -}) - diff --git a/tests/scene-drop-preload.test.ts b/tests/scene-drop-preload.test.ts index 169c585..321e9bc 100644 --- a/tests/scene-drop-preload.test.ts +++ b/tests/scene-drop-preload.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest' import * as fs from 'fs' -import { getSharedDropTables } from '../src/client/world/act-runtime.ts' +import { getSharedDropTables } from '../src/common/world/scene-population.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { loadDropTables, type DropTables } from '../src/common/items/drop-tables.ts' diff --git a/tests/scene-lighting.test.ts b/tests/scene-lighting.test.ts index 0d7e8ae..a2ae79a 100644 --- a/tests/scene-lighting.test.ts +++ b/tests/scene-lighting.test.ts @@ -383,7 +383,7 @@ describe('Scene Lighting & Environment Integration', () => { }) it('supports interactive scene lighting presets and #lighting selector in acts.html', async () => { - const { LIGHTING_PRESETS, normalizeLightingPreset } = await import('../src/client/world/act-runtime.ts') + const { LIGHTING_PRESETS, normalizeLightingPreset } = await import('../src/client/scene/lighting-presets.ts') const fs = await import('node:fs') expect(normalizeLightingPreset(undefined)).toBe('auto') diff --git a/tests/skill-quickbar-tooltip.test.ts b/tests/skill-quickbar-tooltip.test.ts index ddc79a5..cd5f62a 100644 --- a/tests/skill-quickbar-tooltip.test.ts +++ b/tests/skill-quickbar-tooltip.test.ts @@ -1,14 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { - SkillHotkeysHud, - LEFT_SKILL_BOUNDS, - RIGHT_SKILL_BOUNDS, - DEFAULT_SORCERESS_SKILLS, -} from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.ts' import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts' +import { CLASS_STARTER_HARD_POINTS } from '../src/common/units/class-starter-profiles.ts' +import type { CharacterClassCode } from '../src/common/units/classes.ts' import { createDemoHotkeys } from './fixtures/hud-demo-state.ts' +import { DEMO_SORCERESS_SKILLS } from './fixtures/demo-profile-data.ts' describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => { let hud: SkillHotkeysHud @@ -327,15 +325,18 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti removeEventListener: () => {}, getContext: () => null, } as unknown as HTMLCanvasElement + const starterHardPoints = (cls: CharacterClassCode): Map => + new Map(Object.entries(CLASS_STARTER_HARD_POINTS[cls]).map(([id, pts]): [number, number] => [Number(id), pts])) - it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on class switch', () => { + it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on server class assignment', () => { const hudMgr = new HudManager(mockCanvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) - // Switch to Paladin: Left is Sacrifice (96), Right is Blessed Hammer (112) - hudMgr.switchClass('pal') + // Server assigns Paladin (starter hard points): Left is Sacrifice (96), Right is Blessed Hammer (112) + hudMgr.setCharacterClass('pal') + hudMgr.skillTree.loadProfile('pal', starterHardPoints('pal'), 0) expect(hudMgr.hotkeys.tooltipResolver).toBeDefined() const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false) @@ -348,8 +349,9 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti expect(hudMgr.hotkeys.formatTooltipLines(112, 'right', false, 'ENG')[0]!.text).toBe('Blessed Hammer') expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true) - // Switch to Barbarian: Whirlwind (151) - hudMgr.switchClass('bar') + // Server assigns Barbarian: Whirlwind (151) + hudMgr.setCharacterClass('bar') + hudMgr.skillTree.loadProfile('bar', starterHardPoints('bar'), 0) const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false) expect(wwLines[0]!.text).toContain('旋風') expect(hudMgr.hotkeys.formatTooltipLines(151, 'right', false, 'ENG')[0]!.text).toBe('Whirlwind') diff --git a/tests/skills/nec/adv-pnb-synergy-stress.test.ts b/tests/skills/nec/adv-pnb-synergy-stress.test.ts index 40ca26f..fcc8a58 100644 --- a/tests/skills/nec/adv-pnb-synergy-stress.test.ts +++ b/tests/skills/nec/adv-pnb-synergy-stress.test.ts @@ -43,7 +43,7 @@ import { calculateSkillDamage, ISO_GROUND_ASPECT_RATIO, } from '../../../src/server/skills/skills.ts' -import { getAttackWeaponInfo } from '../../../src/client/world/act-runtime.ts' +import { getAttackWeaponInfo } from '../../../src/common/skills/weapon-combat-meta.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { UnitStatList, FIXED_ONE } from '../../../src/server/engine/stat-list.ts' diff --git a/tests/skills/sor/adv-cold-fire-boundaries.test.ts b/tests/skills/sor/adv-cold-fire-boundaries.test.ts index 3385175..393ff51 100644 --- a/tests/skills/sor/adv-cold-fire-boundaries.test.ts +++ b/tests/skills/sor/adv-cold-fire-boundaries.test.ts @@ -53,7 +53,7 @@ import { executeSkill, type SkillExecutionContext, } from '../../../src/server/skills/skills.ts' -import { getSkillMana } from '../../../src/client/world/act-runtime.ts' +import { getSkillMana } from '../../../src/common/skills/weapon-combat-meta.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import { createPlayer } from '../../../src/server/engine/combat.ts' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' diff --git a/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts b/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts index 4f28b67..d747e18 100644 --- a/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts +++ b/tests/skills/sor/issue-437-meteor-fire-extinction.test.ts @@ -12,8 +12,8 @@ import { describe, expect, it, vi } from 'vitest' import { tickProjectiles } from '../../../src/server/skills/skills.ts' import type { Projectile } from '../../../src/server/skills/skills.ts' -import { drawMissileProjectile } from '../../../src/client/world/act-runtime.ts' -import type { LoadedMissileArt } from '../../../src/client/world/act-runtime.ts' +import { drawMissileProjectile } from '../../../src/client/scene/missile-overlay-renderer.ts' +import type { LoadedMissileArt } from '../../../src/client/scene/missile-overlay-renderer.ts' import { METEORFIRE_META } from '../../../src/client/render/missiles-meta.ts' import type { SpriteRenderer, AtlasHandle } from '../../../src/client/render/renderer.ts' diff --git a/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts b/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts index b41ad5a..32088be 100644 --- a/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts +++ b/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts @@ -13,9 +13,9 @@ */ import { describe, expect, it, vi } from 'vitest' import { GameEngine } from '../../../src/server/engine/engine.ts' -import { drawMissileProjectile } from '../../../src/client/world/act-runtime.ts' +import { drawMissileProjectile } from '../../../src/client/scene/missile-overlay-renderer.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' -import type { LoadedMissileArt } from '../../../src/client/world/act-runtime.ts' +import type { LoadedMissileArt } from '../../../src/client/scene/missile-overlay-renderer.ts' import type { SpriteRenderer, AtlasHandle } from '../../../src/client/render/renderer.ts' import { CANONICAL_113C_MISSILES, type Projectile } from '../../../src/server/skills/skills.ts' import { FIREWALL_META } from '../../../src/client/render/missiles-meta.ts' diff --git a/tests/skills/sor/skill-056-meteor.test.ts b/tests/skills/sor/skill-056-meteor.test.ts index fed027e..c8755e5 100644 --- a/tests/skills/sor/skill-056-meteor.test.ts +++ b/tests/skills/sor/skill-056-meteor.test.ts @@ -42,7 +42,7 @@ import { METEORFIRE_META, MISSILE_METAS, } from '../../../src/client/render/missiles-meta.ts' -import { drawMissileProjectile, type LoadedMissileArt } from '../../../src/client/world/act-runtime.ts' +import { drawMissileProjectile, type LoadedMissileArt } from '../../../src/client/scene/missile-overlay-renderer.ts' import type { SpriteRenderer, AtlasHandle } from '../../../src/client/render/renderer.ts' describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => { diff --git a/tests/skills/sor/skill-057-thunder-storm.test.ts b/tests/skills/sor/skill-057-thunder-storm.test.ts index 39ca41d..375d443 100644 --- a/tests/skills/sor/skill-057-thunder-storm.test.ts +++ b/tests/skills/sor/skill-057-thunder-storm.test.ts @@ -29,7 +29,7 @@ import { calculateLightningMasteryBonusPct, BATCH1_SKILLS, } from '../../../src/server/skills/skills.ts' -import { getSkillRange, getSkillMana } from '../../../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../../../src/common/skills/weapon-combat-meta.ts' import { THUNDERSTORM1_META, MISSILE_METAS, diff --git a/tests/skills/sor/skill-058-energy-shield.test.ts b/tests/skills/sor/skill-058-energy-shield.test.ts index 9208f78..42178c8 100644 --- a/tests/skills/sor/skill-058-energy-shield.test.ts +++ b/tests/skills/sor/skill-058-energy-shield.test.ts @@ -28,7 +28,7 @@ import { calculateSkillDamage, BATCH1_SKILLS, } from '../../../src/server/skills/skills.ts' -import { getSkillRange, getSkillMana } from '../../../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../../../src/common/skills/weapon-combat-meta.ts' import { ENERGY_SHIELD_OVERLAY_META, ENERGY_SHIELD_HIT_OVERLAY_META, diff --git a/tests/skills/sor/skill-063-lightning-mastery.test.ts b/tests/skills/sor/skill-063-lightning-mastery.test.ts index 2dc6e0d..6380b5f 100644 --- a/tests/skills/sor/skill-063-lightning-mastery.test.ts +++ b/tests/skills/sor/skill-063-lightning-mastery.test.ts @@ -30,7 +30,7 @@ import { calculateSkillDamage, BATCH1_SKILLS, } from '../../../src/server/skills/skills.ts' -import { getSkillRange, getSkillMana } from '../../../src/client/world/act-runtime.ts' +import { getSkillRange, getSkillMana } from '../../../src/common/skills/weapon-combat-meta.ts' describe('[Skill #063] Lightning Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #217)', () => { const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') diff --git a/tests/speedbar-authentic-parity.test.ts b/tests/speedbar-authentic-parity.test.ts index 5c6cd19..c77f23d 100644 --- a/tests/speedbar-authentic-parity.test.ts +++ b/tests/speedbar-authentic-parity.test.ts @@ -1,20 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { - SkillHotkeysHud, - DEFAULT_UNIVERSAL_SKILLS, - DEFAULT_SORCERESS_SKILLS, - isLeftUsableSkill, - isPassiveSkill, - isAuraSkill, - getSkillListRow, - getSkillReqLevel, - LEFT_SKILL_BOUNDS, - RIGHT_SKILL_BOUNDS, -} from '../src/client/ui/hotkeys.ts' +import { SkillHotkeysHud, isLeftUsableSkill, isPassiveSkill, isAuraSkill, getSkillListRow, getSkillReqLevel, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts' import type { HotkeySkillEntry } from '../src/client/ui/hotkeys.ts' import { SKILLS_CATALOG, SKILLS_BY_CLASS } from '../src/common/data/skills-catalog.ts' import type { SkillCatalogEntry } from '../src/common/data/skills-catalog.ts' import { CHARACTER_CLASS_CODES } from '../src/common/units/classes.ts' +import { DEMO_SORCERESS_SKILLS, DEMO_UNIVERSAL_SKILLS } from './fixtures/demo-profile-data.ts' describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { let hud: SkillHotkeysHud @@ -148,7 +138,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { CHARACTER_CLASS_CODES.forEach(cls => { it(`enforces authentic 1.13c left and right filters for ${cls.toUpperCase()}`, () => { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) - const allSkills: HotkeySkillEntry[] = [...DEFAULT_UNIVERSAL_SKILLS, ...classSkills] + const allSkills: HotkeySkillEntry[] = [...DEMO_UNIVERSAL_SKILLS, ...classSkills] hud.setAvailableSkills(allSkills) // --- Left Click Speedbar --- @@ -181,7 +171,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { }) it('strictly excludes Meteor (56) and Blizzard (59) from Sorceress left speedbar', () => { - hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) const leftCells = hud.getPopupCells('left') const leftIds = leftCells.map(c => c.skill.skillId) @@ -200,7 +190,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { it('strictly includes Maul (233) on Druid left speedbar', () => { const druidSkills = createClassSkillEntries(SKILLS_BY_CLASS.dru) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...druidSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...druidSkills]) const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) expect(leftIds).toContain(233) // Maul expect(leftIds).not.toContain(228) // Cyclone Armor (buff, leftskill=0) @@ -209,7 +199,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { it('strictly excludes all Paladin auras and Holy Shield from left speedbar', () => { const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...paladinSkills]) const leftCells = hud.getPopupCells('left') const leftIds = leftCells.map(c => c.skill.skillId) @@ -245,7 +235,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { it('strictly excludes Barbarian combat mastery passives from both speedbars', () => { const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...barbarianSkills]) const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) const rightIds = hud.getPopupCells('right').map(c => c.skill.skillId) @@ -263,7 +253,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { describe('3. Multi-Row Grouping & Row Compaction', () => { it('compacts Barbarian right-click speedbar from [0, 1, 3] into contiguous visualRows [0, 1, 2]', () => { const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...barbarianSkills]) const rightCells = hud.getPopupCells('right') // Barbarian has: @@ -287,7 +277,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { it('compacts Paladin left-click speedbar to contiguous visualRows [0, 1]', () => { const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...paladinSkills]) const leftCells = hud.getPopupCells('left') // ListRow 0 (Universal), ListRow 1 (Combat skills). ListRow 2 and 3 are auras (filtered out). @@ -302,7 +292,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { // Character that only learned Lightning Spells (Tab 2 => ListRow 2) const lightSpells = createClassSkillEntries(SKILLS_BY_CLASS.sor.filter(s => s.tabIndex === 1 && !isPassiveSkill(s.id))) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...lightSpells]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...lightSpells]) const rightCells = hud.getPopupCells('right') // ListRow 0 (Universal) and ListRow 2 (Lightning) @@ -326,7 +316,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { ] for (const cls of CHARACTER_CLASS_CODES) { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills]) const rightCells = hud.getPopupCells('right') const visualRows = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) for (let i = 0; i < visualRows.length; i++) { @@ -353,7 +343,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { leftUsable: false, rightUsable: true, } - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem]) const rightCells = hud.getPopupCells('right') const tpCell = rightCells.find(c => c.skill.skillId === 54) expect(tpCell).toBeDefined() @@ -370,7 +360,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { it('strictly sorts skills within each row by reqlevel ascending, then skillId ascending', () => { for (const cls of CHARACTER_CLASS_CODES) { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) - hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills]) + hud.setAvailableSkills([...DEMO_UNIVERSAL_SKILLS, ...classSkills]) for (const side of ['left', 'right'] as const) { const cells = hud.getPopupCells(side) @@ -412,7 +402,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { // ========================================================================= describe('5. Adaptive Alignment & Canvas Bounds Clamping', () => { it('expands left speedbar rightward and right speedbar leftward', () => { - hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) const leftCells = hud.getPopupCells('left') const rightCells = hud.getPopupCells('right') @@ -468,7 +458,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { describe('6. Item Skills & Tooltips', () => { it('positions item skills at the top row (ListRow = 4)', () => { const skillsWithItems: HotkeySkillEntry[] = [ - ...DEFAULT_SORCERESS_SKILLS, + ...DEMO_SORCERESS_SKILLS, { skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, { skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, ] @@ -505,7 +495,7 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { // ========================================================================= describe('7. Interactive Assignments & Guardrails', () => { beforeEach(() => { - hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + hud.setAvailableSkills([...DEMO_SORCERESS_SKILLS]) }) it('rejects assigning Meteor (56) and Blizzard (59) to left slot', () => { diff --git a/tests/starter-items-inv-size-parity.test.ts b/tests/starter-items-inv-size-parity.test.ts index 5c7773d..267eadb 100644 --- a/tests/starter-items-inv-size-parity.test.ts +++ b/tests/starter-items-inv-size-parity.test.ts @@ -2,7 +2,7 @@ * tests/starter-items-inv-size-parity.test.ts * * Regression guard: every hand-authored starter item (equipped gear, bag, stash, for all 7 - * classes, plus the legacy `STARTER_EQUIPPED_GEAR` / `STARTER_BAG_ITEMS` UI defaults) must + * classes, plus the legacy demo gear/bag fixtures in `tests/fixtures/demo-profile-data.ts`) must * declare exactly the `InvWidth` x `InvHeight` recorded in the canonical 1.13c * weapons.txt / armor.txt / misc.txt tables (embedded in `canonical-drop-data.ts`). * @@ -17,14 +17,10 @@ import { CLASS_EXCLUSIVE_SETS, STARTER_STASH_ITEMS, createStarterBagForClass, - createStarterProfileForClass, } from '../src/common/units/class-starter-profiles.ts' -import { - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, - type UiInventoryItem, -} from '../src/client/ui/inventory.ts' +import type { UiInventoryItem } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR } from './fixtures/demo-profile-data.ts' interface CanonSize { readonly w: number @@ -46,22 +42,16 @@ function canonicalSize(code: string): CanonSize | undefined { function collectAllStarterItems(): ReadonlyArray<{ readonly source: string; readonly item: UiInventoryItem }> { const out: Array<{ source: string; item: UiInventoryItem }> = [] - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { - if (item) out.push({ source: `STARTER_EQUIPPED_GEAR.${slot}`, item }) + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) { + if (item) out.push({ source: `DEMO_EQUIPPED_GEAR.${slot}`, item }) } - STARTER_BAG_ITEMS.forEach((p, i) => out.push({ source: `STARTER_BAG_ITEMS[${i}]`, item: p.item })) + DEMO_BAG_ITEMS.forEach((p, i) => out.push({ source: `DEMO_BAG_ITEMS[${i}]`, item: p.item })) STARTER_STASH_ITEMS.forEach((p, i) => out.push({ source: `STARTER_STASH_ITEMS[${i}]`, item: p.item })) for (const cls of CHARACTER_CLASS_CODES) { for (const [slot, item] of Object.entries(CLASS_EXCLUSIVE_SETS[cls])) { if (item) out.push({ source: `CLASS_EXCLUSIVE_SETS.${cls}.${slot}`, item }) } createStarterBagForClass(cls).forEach((p, i) => out.push({ source: `bag(${cls})[${i}]`, item: p.item })) - const profile = createStarterProfileForClass(cls) - for (const [slot, item] of Object.entries(profile.equipped)) { - if (item) out.push({ source: `profile(${cls}).equipped.${slot}`, item }) - } - profile.bagItems.forEach((p, i) => out.push({ source: `profile(${cls}).bag[${i}]`, item: p.item })) - profile.stashItems.forEach((p, i) => out.push({ source: `profile(${cls}).stash[${i}]`, item: p.item })) } return out } @@ -95,7 +85,7 @@ describe('Starter item inventory sizes match canonical 1.13c InvWidth/InvHeight' it('Swirling Crystal (oba) is 1x3 per weapons.txt (The Oculus / Tal Rasha\'s Lidless Eye)', () => { const canon = canonicalSize('oba') expect(canon).toEqual({ w: 1, h: 3, name: 'Swirling Crystal' }) - const oculus = STARTER_EQUIPPED_GEAR.weapon1! + const oculus = DEMO_EQUIPPED_GEAR.weapon1! expect(oculus.code).toBe('oba') expect([oculus.invWidth, oculus.invHeight]).toEqual([1, 3]) const talOrb = CLASS_EXCLUSIVE_SETS.sor.weapon1! diff --git a/tests/stash.test.ts b/tests/stash.test.ts index d358542..de54028 100644 --- a/tests/stash.test.ts +++ b/tests/stash.test.ts @@ -24,11 +24,9 @@ import { type UiInventoryItem, INV_GRID_ORIGIN, } from '../src/client/ui/inventory.ts' -import { type TownStashEntity, CANONICAL_TOWN_STASHES, SceneMouseController, type MapRuntime } from '../src/client/world/act-runtime.ts' +import { type TownStashEntity, CANONICAL_TOWN_STASHES } from '../src/common/world/scene-population.ts' import { GameEngine } from '../src/server/engine/engine.ts' import { createIsoTerrain } from '../src/common/world/iso-terrain.ts' -import { ViewportCamera } from '../src/client/sim/camera.ts' -import { KeyboardInput } from '../src/client/sim/input.ts' import { WaypointNetwork } from '../src/common/world/portal.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' @@ -426,161 +424,6 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x }) }) - describe('6. SceneMouseController Stash Click & Interaction', () => { - let engine: GameEngine - let camera: ViewportCamera - let input: KeyboardInput - let waypointNetwork: WaypointNetwork - let statusElem: HTMLElement - let controller: SceneMouseController - let runtime: MapRuntime - let stashOpenCount: number - - beforeEach(() => { - stashOpenCount = 0 - const grid = { - originX: 0, - originY: 0, - cellsX: 100, - cellsY: 100, - blocked: new Uint8Array(500 * 500), - gridWidth: 500, - } - const terrain = createIsoTerrain(grid, 8000, 4000, { width: 20, height: 10 }) - engine = new GameEngine(terrain, { - spawn: { x: 4500, y: 1700 }, - stats: [], - monsterCount: 0, - questDefs: [], - npcDefs: [], - } as any) - const mockCanvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), - clientWidth: 800, - clientHeight: 600, - width: 800, - height: 600, - addEventListener: () => {}, - removeEventListener: () => {}, - getContext: () => null, - style: { cursor: '' }, - } as unknown as HTMLCanvasElement - - camera = new ViewportCamera(mockCanvas, 8000, 4000) - input = new KeyboardInput() - waypointNetwork = new WaypointNetwork() - statusElem = { textContent: '' } as unknown as HTMLElement - - const mockHudManager = { - leftPanel: 'none', - toggleLeftPanel: (panel: any) => { - if (panel === 'stash') stashOpenCount++ - mockHudManager.leftPanel = panel - }, - clientToLogical: (x: number, y: number) => ({ x, y }), - isPointInterceptedByHud: () => false, - } as unknown as HudManager - - runtime = { - source: 'pack', - base: 'samples/d2-packs', - level: 'Rogue Encampment', - act: 1, - levelId: 1, - entryPath: 'act1-towne1', - entrances: [], - warps: [], - waypoints: [], - stashes: [CANONICAL_TOWN_STASHES[1]!], - variants: ['towne1'], - variantParam: 'level', - variant: 'towne1', - quadrants: ['towne1'], - quadrantParam: 'level', - quadrant: 'towne1', - cellsX: 100, - cellsY: 100, - widthPx: 8000, - heightPx: 4000, - grid, - floors: [], - walls: [], - roofs: [], - objectDrawables: [], - spawn: { x: 4500, y: 1700 }, - npcs: [], - monsterPacks: [], - monsterTypes: [], - objects: 1, - frames: 0, - notes: [], - pages: [], - objectPages: [], - charBases: [], - palette: null, - priorityPages: [], - loadOrder: [], - loadPages: async () => {}, - } - - controller = new SceneMouseController({ - canvas: mockCanvas, - engine, - camera, - input, - getRuntime: () => runtime, - hudManager: mockHudManager, - waypointNetwork, - status: statusElem, - }) - }) - - it('finds stash within 48px click radius and ignores clicks further away', () => { - const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) - expect(controller.findNearestStash(stash.x, stash.y, 48)).toEqual(stash) - expect(controller.findNearestStash(stash.x + 30, stash.y + 20, 48)).toEqual(stash) - - // 60px away exceeds 48px radius - expect(controller.findNearestStash(stash.x + 50, stash.y + 50, 48)).toBeNull() - }) - - it('opens stash immediately when player is within 80px interaction range', () => { - const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) - engine.world.player.x = stash.x - 30 - engine.world.player.y = stash.y - - controller.handleLeftClickWorld(stash.x, stash.y) - - expect(controller.activeClickType).toBe('stash') - expect(controller.pendingInteraction).toBeNull() - expect(controller.navTarget).toBeNull() - expect(stashOpenCount).toBe(1) - expect(statusElem.textContent).toContain('储物箱') - }) - - it('walks toward stash when player is outside 80px range, then opens upon arrival in tick()', () => { - const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) - engine.world.player.x = stash.x - 200 - engine.world.player.y = stash.y - - controller.handleLeftClickWorld(stash.x, stash.y) - - expect(controller.activeClickType).toBe('stash') - expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) - expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) - expect(stashOpenCount).toBe(0) - - // Simulate player walking and arriving within 80px - engine.world.player.x = stash.x - 60 - controller.tick() - - expect(controller.pendingInteraction).toBeNull() - expect(controller.navTarget).toBeNull() - expect(stashOpenCount).toBe(1) - expect(statusElem.textContent).toContain('储物箱') - }) - }) - describe('7. HUD Manager Integration & Split Dock Synergy', () => { let hudCanvas: HTMLCanvasElement let hud: HudManager diff --git a/tests/town-portal.test.ts b/tests/town-portal.test.ts index c7d39c0..af088d8 100644 --- a/tests/town-portal.test.ts +++ b/tests/town-portal.test.ts @@ -11,16 +11,11 @@ import { TOWN_PORTAL_SUBTILES_BY_VARIANT, DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL, } from '../src/common/world/portal.ts' -import { - InventoryPanel, - INV_GRID_ORIGIN, - STARTER_BAG_ITEMS, - getTomeQuantity, - withTomeQuantity, -} from '../src/client/ui/inventory.ts' +import { InventoryPanel, INV_GRID_ORIGIN, getTomeQuantity, withTomeQuantity } from '../src/client/ui/inventory.ts' import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts' import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts' import { createDemoInventory } from './fixtures/hud-demo-state.ts' +import { DEMO_BAG_ITEMS } from './fixtures/demo-profile-data.ts' describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => { it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => { @@ -132,8 +127,8 @@ describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal' }) it('initializes starter Tome of Town Portal (tbk) and Tome of Identify (ibk) with 20 charges across all classes', () => { - const starterTbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'tbk') - const starterIbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'ibk') + const starterTbk = DEMO_BAG_ITEMS.find(p => p.item.code === 'tbk') + const starterIbk = DEMO_BAG_ITEMS.find(p => p.item.code === 'ibk') expect(starterTbk?.item.quantity).toBe(20) expect(starterIbk?.item.quantity).toBe(20) expect(getTomeQuantity(starterTbk!.item)).toBe(20) diff --git a/tests/treasure-engine.test.ts b/tests/treasure-engine.test.ts index bf41a01..d9793e1 100644 --- a/tests/treasure-engine.test.ts +++ b/tests/treasure-engine.test.ts @@ -24,7 +24,7 @@ import { type DropContext, type QualityFactors, } from '../src/common/items/treasure-engine.ts' -import { handleActSceneChatCommand } from '../src/client/world/act-runtime.ts' +import { handleActSceneChatCommand } from '../src/common/world/chat-commands.ts' const hasD2 = fs.existsSync('samples/d2/d2data.mpq') diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index a98103f..8b8d366 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -15,17 +15,7 @@ import { EXP_BAR_HOVER_BOUNDS, STAMINA_BAR_BOUNDS, } from '../src/client/ui/control-bar.ts' -import { - InventoryPanel, - INV_PANEL_ORIGIN, - INV_GRID_ORIGIN, - INV_GOLD_BTN_BOUNDS, - INV_GOLD_BOX_RECESS, - EQUIP_SLOTS_LAYOUT, - resolveItemSpriteRect, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, -} from '../src/client/ui/inventory.ts' +import { InventoryPanel, INV_PANEL_ORIGIN, INV_GRID_ORIGIN, INV_GOLD_BTN_BOUNDS, INV_GOLD_BOX_RECESS, EQUIP_SLOTS_LAYOUT, resolveItemSpriteRect } from '../src/client/ui/inventory.ts' import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/client/ui/character-sheet.ts' import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/client/ui/skill-tree-panel.ts' import { @@ -39,7 +29,6 @@ import { } from '../src/client/ui/world-panels.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts' -import { projectNpcLabel } from '../src/client/world/act-runtime.ts' import { createDemoHotkeys, createDemoInventory } from './fixtures/hud-demo-state.ts' import { getLevelNames, @@ -53,6 +42,7 @@ import { createInitialState, allocatePoint, } from '../src/common/skills/skill-calc-engine.ts' +import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR, seedDemoBeltPotions } from './fixtures/demo-profile-data.ts' describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => { @@ -172,7 +162,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { const belt = new BeltHud() // The belt starts empty (server-authoritative); seed the legacy 16-potion layout explicitly. expect(belt.countTotalPotions()).toBe(0) - belt.seedDefaultPotions() + seedDemoBeltPotions(belt) expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS) // Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null @@ -275,82 +265,6 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(worldPanels.areaBanner?.titleZh).toBe('罗格营地') }) - it('projects NPC labels to sprite quad top-centers across 0.4x..8.0x zoom and DPR (Issue #135)', () => { - // Deckard Cain from scene.json: width: 43, height: 75, offsetX: -45, offsetY: -54 - const cainNpc = { - x: 4768, - y: 1752, - frame: { width: 43, height: 75, offsetX: -45, offsetY: -54 }, - } - // Camera centered on Cain - const camX = 4768 - const camY = 1752 - 16 - - // 1. Standard 1080p, DPR = 1.0, Zoom = 1.0x - const res1 = projectNpcLabel({ - npc: cainNpc, - camX, - camY, - cameraZoom: 1.0, - canvas: { width: 1920, height: 1080 }, - rect: { left: 0, top: 0, width: 1920, height: 1080 }, - }) - // Expected topX = 4768 - 45 + 21.5 = 4744.5. (topX - camX) = -23.5 - // Expected topY = 1752 - 54 = 1698. (topY - camY) = 1698 - 1736 = -38 - expect(res1.domX).toBeCloseTo(1920 * 0.5 - 23.5, 4) - expect(res1.domY).toBeCloseTo(1080 * 0.5 - 38 - 2, 4) - expect(res1.fontSize).toBe(13) - - // 2. Retina 4K (DPR = 2.0, css 1920x1080, buffer 3840x2160), Zoom = 2.0x - const res2 = projectNpcLabel({ - npc: cainNpc, - camX, - camY, - cameraZoom: 2.0, - canvas: { width: 3840, height: 2160 }, - rect: { left: 0, top: 0, width: 1920, height: 1080 }, - }) - // Delta in CSS pixels is: deltaWorld * zoom * (css / buffer) = deltaWorld * 2.0 * 0.5 = deltaWorld * 1.0 - expect(res2.domX).toBeCloseTo(1920 * 0.5 - 23.5, 4) - expect(res2.domY).toBeCloseTo(1080 * 0.5 - 38 - 2, 4) - expect(res2.fontSize).toBe(18) - - // 3. Extreme Zoom Max 8.0x, font size clamped to 24px - const res8 = projectNpcLabel({ - npc: cainNpc, - camX, - camY, - cameraZoom: 8.0, - canvas: { width: 1920, height: 1080 }, - rect: { left: 0, top: 0, width: 1920, height: 1080 }, - }) - expect(res8.fontSize).toBe(24) - - // 4. Extreme Zoom Min 0.4x, font size clamped to 11px - const res04 = projectNpcLabel({ - npc: cainNpc, - camX, - camY, - cameraZoom: 0.4, - canvas: { width: 1920, height: 1080 }, - rect: { left: 0, top: 0, width: 1920, height: 1080 }, - }) - expect(res04.fontSize).toBe(11) - - // 5. Artless NPC fallback matches MARKER_HEIGHT (26px) - const artlessNpc = { x: 3000, y: 2000, frame: null } - const resArtless = projectNpcLabel({ - npc: artlessNpc, - camX: 3000, - camY: 2000, - cameraZoom: 1.0, - canvas: { width: 800, height: 600 }, - rect: { left: 0, top: 0, width: 800, height: 600 }, - }) - expect(resArtless.domX).toBeCloseTo(400, 4) - expect(resArtless.domY).toBeCloseTo(300 - 26 - 2, 4) - }) - it('enforces 1:1 uniform aspect ratio HUD scaling without horizontal distortion (Issue #136)', () => { // 16:9 Widescreen (1920x1080) const layout1080p = computeHudLayout(1920, 1080) @@ -511,7 +425,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { const itemRects = BAKED_UI_MANIFEST.itemRects // Starter equipped items must all resolve to valid sprite rects - for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) { + for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) { if (!item) continue const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Equipped item ${slot} (${item.code}) should resolve`).not.toBeNull() @@ -520,7 +434,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { } // Starter bag items must all resolve to valid sprite rects - for (const placement of STARTER_BAG_ITEMS) { + for (const placement of DEMO_BAG_ITEMS) { const item = placement.item const rect = resolveItemSpriteRect(item, itemRects) expect(rect, `Bag item ${item.name} (${item.code}) should resolve`).not.toBeNull() @@ -1184,4 +1098,3 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { }) }) - diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts index df469c7..82a303f 100644 --- a/tests/unidentified-drops-identify.test.ts +++ b/tests/unidentified-drops-identify.test.ts @@ -23,11 +23,6 @@ import { } from '../src/common/items/vendor-generator.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/client/ui/font.ts' -import { - formatGroundItemLabelText, - getGroundItemQualityColor, - GROUND_LABEL_QUALITY_COLORS, -} from '../src/client/ui/ground-labels.ts' import { EQUIP_SLOTS_LAYOUT, identifyUiItem, @@ -214,32 +209,6 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System' }) }) - describe('3. Ground Item Metadata & Alt Label Masking', () => { - it('masks unidentified ground item labels to base item name while preserving quality color and underlying secret name', () => { - const uniqueShako = makeDroppedShako('unique', 11) - expect(isGroundSourceUnidentified(uniqueShako)).toBe(true) - - const meta = resolveGroundItemMetadata(uniqueShako) - expect(meta.quality).toBe('unique') - expect(meta.name).toBe('Shako') - expect(meta.nameZh).toContain('军帽') - expect(meta.nameZh).not.toContain('Harlequin Crest') - expect(meta.nameZh).not.toContain('谐角之冠') - - const manager = new GroundItemManager() - const groundEntity = manager.add(uniqueShako, 10, 10, 320, 240) - expect(getGroundItemQualityColor(groundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.unique) - - const labelText = formatGroundItemLabelText(groundEntity) - expect(labelText).toContain('军帽') - expect(labelText).not.toContain('Harlequin Crest') - expect(labelText).not.toContain('谐角之冠') - - // Underlying item's secret identity is untouched so picking it up and identifying it works - expect(uniqueShako.name).toBe('Harlequin Crest') - }) - }) - describe('4. UI Item Bridge, Sprite/Dye Masking, Tooltip Masking & Equipping Gate', () => { it('masks unique dye transform and custom unique invFile while unidentified and restores them upon identification', () => { const uniqueShako = makeDroppedShako('unique', 19) diff --git a/tools/execute-r3-all-221-skills.ts b/tools/execute-r3-all-221-skills.ts deleted file mode 100644 index 6636a3c..0000000 --- a/tools/execute-r3-all-221-skills.ts +++ /dev/null @@ -1,697 +0,0 @@ -/** - * Complete R3 Execution Pipeline for All 221 Player Skills (Milestones M3 & M4). - * - * Performs: - * 1. Persistent Vite + Headless Chromium (`chromium-1228`) WebGL2 arena casting & PNG screenshot - * capture (`/tmp/d2-skill-screenshots/skill--.png`) across all 221 player skills. - * 2. Strict isolated `git worktree` lifecycle (`../wt-skill-` on branch `feat/skill-`): - * - Generates `src/server/skills/impl//skill--.ts` (`SkillModule`) - * - Generates `tests/skills//skill--.test.ts` (1.13c `slvl` 1/10/20 + `blvl` synergy + `WorldArena` runtime effect) - * - Commits on `feat/skill-` inside `../wt-skill-` - * - Uploads genuine PNG screenshot to Gitea (`POST /issues/{issueNumber}/assets`) - * - Posts 1.13c numerical + Vitest + Chromium telemetry comment (`POST /issues/{issueNumber}/comments`) - * - Merges `feat/skill-` into `main` (`--no-ff`, `fixes #`) - * - Immediately deletes worktree (`git worktree remove ../wt-skill-`) and branch (`git branch -d feat/skill-`) - * - Pushes `main` to `origin/main` in batches of 25 skills and verifies Milestone #9 has 0 open issues and 221 closed issues. - */ -import { execFileSync, spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { createServer } from 'vite' -import { getSharedDataRegistry, type SkillRecord } from '../src/baker/mpq-archive-source.ts' -import { UnitStatList } from '../src/server/engine/stat-list.ts' -import { evaluateSkillCore113c } from '../src/server/skills/registry.ts' -import { closeGiteaIssue, postGiteaIssueComment, uploadPngToGiteaIssue } from './gitea-skill-workflow.ts' - -const REPO_ROOT = '/usr/local/google/home/taodao/diablo2-web' -const SCREENSHOT_DIR = '/tmp/d2-skill-screenshots' -const TELEMETRY_CACHE_PATH = '/tmp/d2-skill-screenshots/telemetry-221.json' -const PROGRESS_MD_PATH = '/usr/local/google/home/taodao/diablo2-web/.agents/sub_orch_m3_m4_r3/progress.md' -const GITEA_BASE_URL = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web' -const GITEA_TOKEN = '94c84cc3bd2eab804346cb889650c843d0cda244' - -const CHROME_CANDIDATES = [ - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/usr/bin/google-chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) - -interface IssueEntry { - skillId: number - issueNumber: number - issueId: number - title: string - charClass: string - slug: string - milestoneId: number -} - -interface ArenaTelemetrySnap { - skillId: number - skillName: string - classCode: string - charToken: string - slvl: number - castCount: number - playerAnimMode: string - playerAnimFrame: number - manaCost: number - minDmg: number - maxDmg: number - totalDamageDealt: number - missilesSpawned: number - petsCount: number - corpsesRemaining: number - activeStates: string[] - activeOverlays: string[] - pngBytes: number -} - -function runGit(args: string[], cwd = REPO_ROOT): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -} - -function findFirstSynergySkillId(expr: string, registry: Awaited>): number | null { - const m = /skill\('([^']+)'\.blvl\)/.exec(expr) - if (!m?.[1]) return null - const rec = registry.getSkillByName(m[1]) - return rec ? rec.id : null -} - -function generateSkillImplSource(entry: IssueEntry, skill: SkillRecord, id3: string): string { - const escapedName = JSON.stringify(skill.name) - const escapedMissile = JSON.stringify(skill.srvMissile || skill.srvMissileA || skill.cltMissile || '') - const escapedState = JSON.stringify(skill.auraTgtState || skill.auraState || skill.passiveState || '') - const escapedSummon = JSON.stringify(skill.summon || '') - - return `/** - * Diablo II: Lord of Destruction v1.13c — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()}) - * - * Authoritative 1.13c Ground Truth (` + '`Patch_D2.mpq`' + ` -> ` + '`data\\\\global\\\\excel\\\\Skills.txt`' + `): - * - Skill ID: ${String(skill.id)} (${escapedName}) - * - Class: ${JSON.stringify(entry.charClass)} - * - Required Level: ${String(skill.reqLevel)} - * - Server Functions: srvstfunc=${String(skill.srvStFunc)}, srvdofunc=${String(skill.srvDoFunc)}, srvprgfunc1=${String(skill.srvPrgFunc1)}, srvprgfunc2=${String(skill.srvPrgFunc2)}, srvprgfunc3=${String(skill.srvPrgFunc3)} - * - Associated Missile / State / Summon: missile=${escapedMissile}, state=${escapedState}, summon=${escapedSummon} - * - 1.13c Synergy Formulas: EDmgSymPerCalc=${JSON.stringify(skill.eDmgSymPerCalc)}, DmgSymPerCalc=${JSON.stringify(skill.dmgSymPerCalc)}, ELenSymPerCalc=${JSON.stringify(skill.eLenSymPerCalc)} - */ -import type { - SkillEvalContext, - SkillEvalResult, - SkillExecContext, - SkillExecOutcome, - SkillModule, -} from '../../types.ts' -import { evaluateSkillCore113c, executeSkillCore113c } from '../../registry.ts' - -export const skillModule: SkillModule = { - skillId: ${String(skill.id)}, - name: ${escapedName}, - charClass: ${JSON.stringify(entry.charClass)}, - srvStFunc: ${String(skill.srvStFunc)}, - srvDoFunc: ${String(skill.srvDoFunc)}, - - evaluate(ctx: SkillEvalContext): SkillEvalResult { - return evaluateSkillCore113c(ctx) - }, - - executeDo(ctx: SkillExecContext): SkillExecOutcome { - const outcome = executeSkillCore113c(ctx) - return { - ...outcome, - notes: [ - ...outcome.notes, - 'skill_module:skill-${id3}-${entry.slug}', - ], - } - }, -} - -export default skillModule -` -} - -function generateSkillTestSource( - entry: IssueEntry, - skill: SkillRecord, - id3: string, - ev1: ReturnType, - ev10: ReturnType, - ev20: ReturnType, - synSkillId: number | null, - ev20WithSyn: ReturnType, -): string { - const escapedName = JSON.stringify(skill.name) - return `/** - * 1.13c Numerical & Runtime Verification Suite — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()}) - * Gitea Tracking Issue: #${String(entry.issueNumber)} - */ -import { describe, expect, it } from 'vitest' -import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' -import { UnitStatList } from '../../../src/server/engine/stat-list.ts' -import { WorldArena } from '../../../src/server/engine/world-arena.ts' -import { skillModule } from '../../../src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts' - -describe('[Skill #${id3}] ${skill.name.replace(/'/g, "\\'")} (${entry.charClass.toUpperCase()}) — 1.13c Parity (Issue #${String(entry.issueNumber)})', () => { - it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(${String(skill.id)})! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(${String(skill.id)}) - expect(skillModule.name).toBe(${escapedName}) - expect(skillModule.charClass).toBe(${JSON.stringify(entry.charClass)}) - - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(${String(skill.id)}, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(${String(ev1.manaCost)}) - expect(r1.manaCost256).toBe(${String(ev1.manaCost256)}) - expect(r1.minPhysDmg).toBe(${String(ev1.minPhysDmg)}) - expect(r1.maxPhysDmg).toBe(${String(ev1.maxPhysDmg)}) - expect(r1.minElemDmg256).toBe(${String(ev1.minElemDmg256)}) - expect(r1.maxElemDmg256).toBe(${String(ev1.maxElemDmg256)}) - - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(${String(skill.id)}, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(${String(ev10.manaCost)}) - expect(r10.manaCost256).toBe(${String(ev10.manaCost256)}) - expect(r10.minPhysDmg).toBe(${String(ev10.minPhysDmg)}) - expect(r10.maxPhysDmg).toBe(${String(ev10.maxPhysDmg)}) - expect(r10.minElemDmg256).toBe(${String(ev10.minElemDmg256)}) - expect(r10.maxElemDmg256).toBe(${String(ev10.maxElemDmg256)}) - - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(${String(skill.id)}, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(${String(ev20.manaCost)}) - expect(r20.manaCost256).toBe(${String(ev20.manaCost256)}) - expect(r20.minPhysDmg).toBe(${String(ev20.minPhysDmg)}) - expect(r20.maxPhysDmg).toBe(${String(ev20.maxPhysDmg)}) - expect(r20.minElemDmg256).toBe(${String(ev20.minElemDmg256)}) - expect(r20.maxElemDmg256).toBe(${String(ev20.maxElemDmg256)}) - expect(r20.durationFrames).toBe(${String(ev20.durationFrames)}) - expect(r20.radiusSubtiles).toBe(${String(ev20.radiusSubtiles)}) - expect(r20.petMax).toBe(${String(ev20.petMax)}) - expect(r20.cooldownFrames).toBe(${String(ev20.cooldownFrames)}) - }) - - it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(${String(skill.id)})! - const synId = ${synSkillId !== null ? String(synSkillId) : 'null'} - - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(${String(skill.id)}, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(${String(ev20WithSyn.synergyBonusPct)}) - - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(${String(skill.id)}, 20) - statsSoftOnly.addStat('item_allskills', 20) - if (synId !== null) { - statsSoftOnly.setChargedSkillLevel(synId, 33) - } - const withSoftOnly = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsSoftOnly, - }) - expect(withSoftOnly.synergyBonusPct).toBe(0) - }) - - it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => { - const registry = await getSharedDataRegistry() - const arena = new WorldArena({ - registry, - classCode: ${JSON.stringify(entry.charClass)}, - skillId: ${String(skill.id)}, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(${String(skill.id)}) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) - }) -}) -` -} - -async function captureAll221ScreenshotsInHeadlessChrome( - entries: IssueEntry[], -): Promise> { - mkdirSync(SCREENSHOT_DIR, { recursive: true }) - let cached: Record = {} - if (existsSync(TELEMETRY_CACHE_PATH)) { - try { - cached = JSON.parse(readFileSync(TELEMETRY_CACHE_PATH, 'utf8')) as Record - } catch {} - } - - const missingEntries = entries.filter(e => { - const id3 = String(e.skillId).padStart(3, '0') - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${e.slug}.png` - return !existsSync(pngPath) || statSync(pngPath).size < 10_000 || !cached[e.skillId] - }) - - if (missingEntries.length === 0) { - console.log(`All 221 Chromium PNG screenshots already exist in ${SCREENSHOT_DIR}.`) - return cached - } - - console.log(`Capturing ${String(missingEntries.length)} genuine Chromium screenshots via persistent Vite + chromium-1228...`) - const chosenPort = 5280 + Math.floor(Math.random() * 400) - const viteServer = await createServer({ - root: REPO_ROOT, - logLevel: 'silent', - server: { - host: '127.0.0.1', - port: chosenPort, - strictPort: false, - }, - }) - await viteServer.listen() - const addr = viteServer.httpServer?.address() - const port = typeof addr === 'object' && addr ? addr.port : chosenPort - const baseUrl = `http://127.0.0.1:${String(port)}` - const initialUrl = `${baseUrl}/acts.html?arena=1&class=sor&skill=47&slvl=20&autocast=1` - - const chromeBin = resolveChromeBinary() - const debugPort = 9400 + Math.floor(Math.random() * 400) - const chrome = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1024,680', - `--remote-debugging-port=${String(debugPort)}`, - initialUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - try { - let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null - for (let i = 0; i < 80; i += 1) { - await sleep(150) - try { - const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as { - type: string - url: string - webSocketDebuggerUrl: string - }[] - const matched = list.find(x => x.type === 'page' && x.url.includes('acts.html')) - if (matched?.webSocketDebuggerUrl) { - pageTarget = matched - break - } - } catch {} - } - if (!pageTarget) { - throw new Error(`Headless Chromium failed to open ${initialUrl}`) - } - - const ws = new WebSocket(pageTarget.webSocketDebuggerUrl) - await new Promise((res, rej) => { - ws.onopen = res - ws.onerror = rej - }) - - let msgId = 0 - const pending = new Map) => void>() - const consoleErrors: string[] = [] - - ws.onmessage = event => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['method'] === 'Runtime.exceptionThrown') { - consoleErrors.push(JSON.stringify(msg['params'])) - } - const id = msg['id'] as number | undefined - if (id && pending.has(id)) { - pending.get(id)!(msg) - pending.delete(id) - } - } - - const send = (method: string, cdpParams: Record = {}) => - new Promise>(res => { - const id = ++msgId - pending.set(id, res) - ws.send(JSON.stringify({ id, method, params: cdpParams })) - }) - - await send('Runtime.enable') - await send('Page.enable') - - // Wait for initial arena ready - for (let i = 0; i < 50; i += 1) { - await sleep(250) - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') { - await window.__initBrowserArenaMode(); - } - return Boolean(window.__d2webArena && window.__d2webArena.ready); - })()`, - awaitPromise: true, - returnByValue: true, - }) - const ready = (evalRes['result'] as { result?: { value?: boolean } } | undefined)?.result?.value - if (ready) break - } - // Wait 500ms for WebGL2 map background tiles to finish rendering - await sleep(500) - - for (let idx = 0; idx < missingEntries.length; idx += 1) { - const entry = missingEntries[idx]! - const id3 = String(entry.skillId).padStart(3, '0') - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png` - - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - const a = window.__d2webArena; - if (!a || typeof a.switchAndCastSkill !== 'function') { - throw new Error('window.__d2webArena.switchAndCastSkill missing'); - } - const dbg = a.switchAndCastSkill(${JSON.stringify(entry.charClass)}, ${String(entry.skillId)}, 20); - await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); - return { - skillId: dbg.skillId, - skillName: dbg.skillName, - classCode: dbg.classCode, - charToken: dbg.charToken, - slvl: dbg.slvl, - castCount: dbg.castCount, - playerAnimMode: dbg.playerAnimMode, - playerAnimFrame: dbg.playerAnimFrame, - manaCost: dbg.tooltipStats.manaCost, - minDmg: dbg.tooltipStats.minDmg, - maxDmg: dbg.tooltipStats.maxDmg, - totalDamageDealt: dbg.totalDamageDealt, - missilesSpawned: dbg.missilesSpawned, - petsCount: dbg.petsActive.length, - corpsesRemaining: dbg.corpsesRemaining, - activeStates: dbg.activeStates, - activeOverlays: dbg.activeOverlays, - }; - })()`, - awaitPromise: true, - returnByValue: true, - }) - - const snap = (evalRes['result'] as { result?: { value?: Omit } } | undefined) - ?.result?.value - if (!snap || snap.skillId !== entry.skillId || snap.castCount < 1) { - throw new Error(`Arena switchAndCastSkill failed for skill #${id3} (${entry.slug})`) - } - - const shot = await send('Page.captureScreenshot', { format: 'png' }) - const base64Data = (shot['result'] as { data?: string } | undefined)?.data - if (!base64Data) { - throw new Error(`Empty screenshot for skill #${id3}`) - } - const pngBuf = Buffer.from(base64Data, 'base64') - writeFileSync(pngPath, pngBuf) - - cached[entry.skillId] = { - ...snap, - pngBytes: pngBuf.byteLength, - } - - if ((idx + 1) % 25 === 0 || idx === missingEntries.length - 1) { - console.log(` [Chromium Screenshot] ${String(idx + 1)}/${String(missingEntries.length)} captured (latest: skill-${id3}-${entry.slug}.png, ${String(pngBuf.byteLength)} bytes)`) - writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2)) - } - } - - if (consoleErrors.length > 0) { - throw new Error(`Chromium console/runtime errors occurred: ${consoleErrors.slice(0, 3).join('; ')}`) - } - ws.close() - } finally { - chrome.kill() - await viteServer.close() - } - - writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2)) - return cached -} - -function updateProgressFile(completedCount: number, totalCount: number, lastSkillLabel: string): void { - const now = new Date().toISOString() - const md = `# Progress — worker_m3_m4_r3_all_skills - -Last visited: ${now} - -## Status -- Completed isolated worktree + Vitest + Chromium screenshot + Gitea upload/comment/merge/cleanup: **${String(completedCount)} / ${String(totalCount)}** skills -- Latest completed skill: \`${lastSkillLabel}\` -` - writeFileSync(PROGRESS_MD_PATH, md) -} - -async function main(): Promise { - const registry = await getSharedDataRegistry() - const issueMap = JSON.parse( - readFileSync(resolve(REPO_ROOT, 'src/data/gitea-skill-issues.json'), 'utf8'), - ) as Record - - const entries = Object.values(issueMap).sort((a, b) => a.skillId - b.skillId) - console.log(`Loaded ${String(entries.length)} player skill entries from src/data/gitea-skill-issues.json.`) - - // Step 1: Capture all 221 genuine Chromium screenshots - const telemetryBySkill = await captureAll221ScreenshotsInHeadlessChrome(entries) - - // Step 2: Execute strict isolated git worktree lifecycle for every remaining skill - const remaining = entries.filter(entry => { - const id3 = String(entry.skillId).padStart(3, '0') - const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts` - const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts` - return !existsSync(resolve(REPO_ROOT, implRelPath)) || !existsSync(resolve(REPO_ROOT, testRelPath)) - }) - - console.log(`Remaining skills to complete via isolated worktree + Gitea upload/comment/merge: ${String(remaining.length)} / ${String(entries.length)}`) - - // Precompute all evaluation results for all 221 skills - const precomputed = new Map< - number, - { - id3: string - skill: SkillRecord - implRelPath: string - testRelPath: string - ev1: ReturnType - ev10: ReturnType - ev20: ReturnType - synSkillId: number | null - ev20WithSyn: ReturnType - } - >() - - for (const entry of entries) { - const id3 = String(entry.skillId).padStart(3, '0') - const skill = registry.getSkillById(entry.skillId)! - const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts` - const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts` - - const s1 = new UnitStatList() - s1.setBaseSkillLevel(skill.id, 1) - const ev1 = evaluateSkillCore113c({ registry, skill, slvl: 1, blvl: 1, statList: s1 }) - - const s10 = new UnitStatList() - s10.setBaseSkillLevel(skill.id, 10) - const ev10 = evaluateSkillCore113c({ registry, skill, slvl: 10, blvl: 10, statList: s10 }) - - const s20 = new UnitStatList() - s20.setBaseSkillLevel(skill.id, 20) - const ev20 = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20 }) - - const synExpr = skill.eDmgSymPerCalc || skill.dmgSymPerCalc || (skill.id === 260 ? "skill('Claw Mastery'.blvl)*par8" : '') - const synSkillId = findFirstSynergySkillId(synExpr, registry) - const s20Syn = new UnitStatList() - s20Syn.setBaseSkillLevel(skill.id, 20) - if (synSkillId !== null) s20Syn.setBaseSkillLevel(synSkillId, 20) - const ev20WithSyn = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20Syn }) - - precomputed.set(entry.skillId, { - id3, - skill, - implRelPath, - testRelPath, - ev1, - ev10, - ev20, - synSkillId, - ev20WithSyn, - }) - } - - // 2a. Execute isolated git worktree add -> write -> commit -> merge --no-ff -> worktree remove -> branch -d - let completedTotal = entries.length - remaining.length - let batchSincePush = 0 - for (let i = 0; i < remaining.length; i += 1) { - const entry = remaining[i]! - const data = precomputed.get(entry.skillId)! - const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, synSkillId, ev20WithSyn } = data - const wtPath = `/usr/local/google/home/taodao/wt-skill-${id3}` - const branchName = `feat/skill-${id3}` - - try { - if (existsSync(wtPath)) runGit(['worktree', 'remove', '--force', wtPath]) - } catch {} - try { - runGit(['branch', '-D', branchName]) - } catch {} - - // 1. Create isolated git worktree from main - runGit(['worktree', 'add', wtPath, '-b', branchName, 'main']) - - // 2. Write implementation & Vitest file inside worktree - const wtImplAbs = resolve(wtPath, implRelPath) - const wtTestAbs = resolve(wtPath, testRelPath) - mkdirSync(resolve(wtPath, `src/server/skills/impl/${entry.charClass}`), { recursive: true }) - mkdirSync(resolve(wtPath, `tests/skills/${entry.charClass}`), { recursive: true }) - writeFileSync(wtImplAbs, generateSkillImplSource(entry, skill, id3)) - writeFileSync(wtTestAbs, generateSkillTestSource(entry, skill, id3, ev1, ev10, ev20, synSkillId, ev20WithSyn)) - - // 3. Stage and commit on branch feat/skill- - runGit(['add', implRelPath, testRelPath], wtPath) - runGit( - ['commit', '-m', `feat(skill-${id3}): implement 1.13c ${skill.name} module and Vitest suite (#${String(entry.issueNumber)})`], - wtPath, - ) - - // 4. Merge feat/skill- into main (--no-ff, fixes #) - runGit([ - 'merge', - '--no-ff', - branchName, - '-m', - `feat(skills): implement 1.13c ${skill.name} [Skill #${id3}] (fixes #${String(entry.issueNumber)})`, - ]) - - // 5. Immediately remove worktree and branch - runGit(['worktree', 'remove', wtPath]) - runGit(['branch', '-d', branchName]) - - completedTotal += 1 - batchSincePush += 1 - - if (batchSincePush >= 25 || i === remaining.length - 1) { - runGit(['push', 'origin', 'main']) - batchSincePush = 0 - updateProgressFile(completedTotal, entries.length, `[Skill #${id3}] ${skill.name} (Issue #${String(entry.issueNumber)})`) - console.log(` [R3 Worktree+Merge+Push] Completed ${String(completedTotal)}/${String(entries.length)} (latest: #${id3} ${skill.name} -> Issue #${String(entry.issueNumber)})`) - } - } - - // 2b. Query Gitea Milestone #9 for any issues that still need PNG screenshot + verification comment or closure - const uploadedTrackerPath = `${SCREENSHOT_DIR}/gitea-commented-221.json` - const commentedIssues = new Set( - existsSync(uploadedTrackerPath) ? (JSON.parse(readFileSync(uploadedTrackerPath, 'utf8')) as number[]) : [], - ) - // Mark the first 35 issues that already had comments posted during Phase 1 - for (let i = 0; i < 35; i += 1) { - commentedIssues.add(entries[i]!.issueNumber) - } - - const toUpload = entries.filter(e => !commentedIssues.has(e.issueNumber)) - console.log(`Uploading PNG screenshot + posting verification comment for ${String(toUpload.length)} remaining Gitea issues (4-way concurrency)...`) - - const CONCURRENCY = 4 - let cursor = 0 - let uploadedCount = 0 - async function giteaWorker(): Promise { - while (cursor < toUpload.length) { - const idx = cursor++ - const entry = toUpload[idx]! - const data = precomputed.get(entry.skillId)! - const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, ev20WithSyn } = data - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png` - const asset = await uploadPngToGiteaIssue(entry.issueNumber, pngPath) - const snap = telemetryBySkill[entry.skillId] - - const commentMd = `### ✅ Completed: [Skill #${id3}] ${skill.name} (\`${entry.charClass.toUpperCase()}\`) — 1.13c Parity Verified - -#### 1. Isolated Worktree & Module Implementation -- **Worktree & Branch**: \`../wt-skill-${id3}\` (\`feat/skill-${id3}\`) -- **Skill Module**: \`${implRelPath}\` -- **Vitest Suite**: \`${testRelPath}\` -- **1.13c Dispatch**: \`srvstfunc=${String(skill.srvStFunc)}\`, \`srvdofunc=${String(skill.srvDoFunc)}\`, \`missile=${skill.srvMissile || skill.srvMissileA || 'none'}\`, \`state=${skill.auraTgtState || skill.auraState || skill.passiveState || 'none'}\`, \`summon=${skill.summon || 'none'}\` - -#### 2. 1.13c Numerical & Synergy Verification Table -| Metric | \`slvl = 1\` | \`slvl = 10\` | \`slvl = 20\` (0 Syn) | \`slvl = 20\` (+20 \`blvl\` Syn) | -| :--- | :---: | :---: | :---: | :---: | -| **Mana Cost (\`usmc\`)** | \`${String(ev1.manaCost)}\` (\`${String(ev1.manaCost256)}\`) | \`${String(ev10.manaCost)}\` (\`${String(ev10.manaCost256)}\`) | \`${String(ev20.manaCost)}\` (\`${String(ev20.manaCost256)}\`) | \`${String(ev20WithSyn.manaCost)}\` (\`${String(ev20WithSyn.manaCost256)}\`) | -| **Phys Dmg (\`Min-Max\`)** | \`${String(ev1.minPhysDmg)}-${String(ev1.maxPhysDmg)}\` | \`${String(ev10.minPhysDmg)}-${String(ev10.maxPhysDmg)}\` | \`${String(ev20.minPhysDmg)}-${String(ev20.maxPhysDmg)}\` | \`${String(ev20WithSyn.minPhysDmg)}-${String(ev20WithSyn.maxPhysDmg)}\` | -| **Elem Dmg (\`Min-Max\`)** | \`${String(ev1.minElemDmg)}-${String(ev1.maxElemDmg)}\` (\`${ev1.elemType}\`) | \`${String(ev10.minElemDmg)}-${String(ev10.maxElemDmg)}\` (\`${ev10.elemType}\`) | \`${String(ev20.minElemDmg)}-${String(ev20.maxElemDmg)}\` (\`${ev20.elemType}\`) | \`${String(ev20WithSyn.minElemDmg)}-${String(ev20WithSyn.maxElemDmg)}\` (+${String(ev20WithSyn.synergyBonusPct)}%) | -| **AR % / Duration / Radius** | \`+${String(ev1.toHitBonusPct)}%\` / \`${String(ev1.durationFrames)}f\` / \`${String(ev1.radiusSubtiles)}\` | \`+${String(ev10.toHitBonusPct)}%\` / \`${String(ev10.durationFrames)}f\` / \`${String(ev10.radiusSubtiles)}\` | \`+${String(ev20.toHitBonusPct)}%\` / \`${String(ev20.durationFrames)}f\` / \`${String(ev20.radiusSubtiles)}\` | \`+${String(ev20WithSyn.toHitBonusPct)}%\` / \`${String(ev20WithSyn.durationFrames)}f\` / \`${String(ev20WithSyn.radiusSubtiles)}\` | - -#### 3. Headless Chromium (\`chromium-1228\`) Runtime Telemetry & Screenshot -- **Arena State**: \`ready=true\`, \`classCode=${snap?.classCode ?? entry.charClass}\` (\`${snap?.charToken ?? 'so'}\`), \`castCount=${String(snap?.castCount ?? 1)}\`, \`anim=${snap?.playerAnimMode ?? 'SC'}:${String(snap?.playerAnimFrame ?? 5)}\`, \`totalDamageDealt=${String(snap?.totalDamageDealt ?? 0)}\`, \`consoleErrors=0\` -- **Captured PNG Size**: \`${String(snap?.pngBytes ?? 0)}\` bytes - -![Skill #${id3} ${skill.name}](${asset.browser_download_url}) -` - await postGiteaIssueComment(entry.issueNumber, commentMd) - await closeGiteaIssue(entry.issueNumber) - commentedIssues.add(entry.issueNumber) - writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2)) - uploadedCount += 1 - if (uploadedCount % 10 === 0 || uploadedCount === toUpload.length) { - console.log(` [Gitea Upload+Comment+Close] ${String(uploadedCount)}/${String(toUpload.length)} completed`) - } - } - } - - await Promise.all(Array.from({ length: CONCURRENCY }, () => giteaWorker())) - writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2)) - - // Ensure any remaining open issues in Milestone #9 are closed - for (let page = 1; page <= 5; page += 1) { - const openRes = await fetch(`${GITEA_BASE_URL}/issues?state=open&type=issues&milestones=9&limit=50&page=1`, { - headers: { Authorization: `token ${GITEA_TOKEN}` }, - signal: AbortSignal.timeout(10000), - }) - const openList = (await openRes.json()) as { number: number }[] - if (openList.length === 0) break - for (const iss of openList) { - await closeGiteaIssue(iss.number) - } - } - - // Final check on Milestone #9 open/closed count - const msRes = await fetch(`${GITEA_BASE_URL}/milestones/9`, { - headers: { Authorization: `token ${GITEA_TOKEN}` }, - }) - const msJson = (await msRes.json()) as { open_issues: number; closed_issues: number } - console.log(`Milestone #9 status: open_issues=${String(msJson.open_issues)}, closed_issues=${String(msJson.closed_issues)}`) -} - -void main() diff --git a/tools/forensic-audit-m3.ts b/tools/forensic-audit-m3.ts deleted file mode 100644 index c21a4a0..0000000 --- a/tools/forensic-audit-m3.ts +++ /dev/null @@ -1,242 +0,0 @@ - -import { - WorldPanelsHud, - ACT_WAYPOINTS, - ACT_QUESTS, - ALL_QUESTS, - findWaypointBySlug, -} from '../src/client/ui/world-panels.ts' -import { - InventoryPanel, - resolveItemSpriteRect, - INV_GRID_ORIGIN, - EQUIP_SLOTS_LAYOUT, - STARTER_EQUIPPED_GEAR, - STARTER_BAG_ITEMS, -} from '../src/client/ui/inventory.ts' -import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' - -console.log('--- STARTING ADVERSARIAL BOUNDARY AUDIT ---') - -// 1. Act Tab Switching & Act 4 Socket Clamping -const wp = new WorldPanelsHud() - -// Default state -console.assert(wp.selectedActTab === 1, 'Initial act tab should be 1') -console.assert(wp.selectedQuestIdx === 0, 'Initial quest index should be 0') - -// Test clicking Act tabs at boundary X coordinates -// ox = 80, oy = 60. Tab Y: oy + 6 .. oy + 42 = 66 .. 102. -// Tab 1: 88 .. 146 -// Tab 2: 149 .. 207 -// Tab 3: 210 .. 268 -// Tab 4: 271 .. 329 -// Tab 5: 332 .. 390 -const callbacks = { - onClose: () => {}, - onWaypointTeleport: (act: number, slug: string, levelId: number) => { - state.dispatchedWp = { act, slug, levelId } - }, -} -const state = { - dispatchedWp: null as { act: number; slug: string; levelId: number } | null, - cubeOpened: false, - tpCasted: false, -} -const getDispatchedWp = () => state.dispatchedWp -const isCubeOpened = () => state.cubeOpened -const isTpCasted = () => state.tpCasted - -// Select quest 5 in Act 1 -wp.handleLeftDockClick('quest', 80 + 28 + 2 * 92 + 10, 60 + 64 + 1 * 100 + 10, callbacks) -console.assert(wp.selectedQuestIdx === 5, `Expected quest index 5, got ${wp.selectedQuestIdx}`) - -// Switch to Act 4 (tab 4: x = 80 + 8 + 3 * 61 + 10 = 281, y = 75) -wp.handleLeftDockClick('quest', 281, 75, callbacks) -console.assert(wp.selectedActTab === 4, `Expected Act 4, got ${wp.selectedActTab}`) -// BOUNDARY CHECK: selectedQuestIdx MUST clamp to 0 because Act 4 only has 3 quests (0, 1, 2) -console.assert( - wp.selectedQuestIdx === 0, - `Act 4 clamping failed! selectedQuestIdx should be 0, got ${wp.selectedQuestIdx}` -) - -// In Act 4, click on empty socket 3 (row 1, col 0: x = 80 + 28 + 10 = 118, y = 60 + 64 + 100 + 10 = 234) -// This socket does not exist in Act 4 (only 3 quests exist: indices 0, 1, 2) -wp.handleLeftDockClick('quest', 118, 234, callbacks) -console.assert( - wp.selectedQuestIdx === 0, - `Act 4 socket 3 should be unassigned/unclickable, but selectedQuestIdx changed to ${wp.selectedQuestIdx}` -) - -// In Act 4, click on empty socket 4 (row 1, col 1: x = 80 + 28 + 92 + 10 = 210, y = 234) -wp.handleLeftDockClick('quest', 210, 234, callbacks) -console.assert( - wp.selectedQuestIdx === 0, - `Act 4 socket 4 should be unassigned/unclickable, but selectedQuestIdx changed to ${wp.selectedQuestIdx}` -) - -// In Act 4, click on empty socket 5 (row 1, col 2: x = 80 + 28 + 184 + 10 = 302, y = 234) -wp.handleLeftDockClick('quest', 302, 234, callbacks) -console.assert( - wp.selectedQuestIdx === 0, - `Act 4 socket 5 should be unassigned/unclickable, but selectedQuestIdx changed to ${wp.selectedQuestIdx}` -) - -// Select valid quest 2 in Act 4 (row 0, col 2: x = 302, y = 134) -wp.handleLeftDockClick('quest', 302, 134, callbacks) -console.assert( - wp.selectedQuestIdx === 2, - `Act 4 quest 2 selection failed, got ${wp.selectedQuestIdx}` -) - -// Extreme out-of-bounds clicks for act tabs -// Far left (< 88): should clamp to Act 1 -wp.handleLeftDockClick('quest', 50, 75, callbacks) -console.assert(wp.selectedActTab === 1, `Expected Act 1 on left clamp, got ${wp.selectedActTab}`) - -// Far right (395): should clamp to Act 5 -wp.handleLeftDockClick('quest', 395, 75, callbacks) -console.assert(wp.selectedActTab === 5, `Expected Act 5 on right clamp, got ${wp.selectedActTab}`) - -// Beyond left panel boundary (> 400): should return false without state change -const handledBeyond = wp.handleLeftDockClick('quest', 450, 75, callbacks) -console.assert(handledBeyond === false, 'Click beyond 400 should return false') -console.assert(wp.selectedActTab === 5, 'Act tab should not change on click beyond 400') - -// 2. Waypoint Menu Act switching & clicks across all 5 acts -for (let act = 1; act <= 5; act++) { - wp.selectedActTab = act - const list = ACT_WAYPOINTS[act]! - const expectedCount = act === 4 ? 3 : 9 - console.assert( - list.length === expectedCount, - `Act ${act} should have ${expectedCount} waypoints, got ${list.length}` - ) - - // Click each waypoint - for (let i = 0; i < list.length; i++) { - state.dispatchedWp = null - const wy = 60 + 62 + i * 36 + 10 - const clicked = wp.handleLeftDockClick('waypoint', 80 + 50, wy, callbacks) - console.assert(clicked === true, `Waypoint click failed for Act ${act} index ${i}`) - console.assert(getDispatchedWp() !== null, `No waypoint dispatched for Act ${act} index ${i}`) - console.assert( - getDispatchedWp()?.act === act, - `Expected act ${act}, got ${getDispatchedWp()?.act}` - ) - console.assert( - getDispatchedWp()?.levelId === list[i]!.levelId, - `Expected levelId ${list[i]!.levelId}, got ${getDispatchedWp()?.levelId}` - ) - console.assert( - getDispatchedWp()?.slug === list[i]!.slug, - `Expected slug ${list[i]!.slug}, got ${getDispatchedWp()?.slug}` - ) - } - - // In Act 4, clicking rows 3..8 should NOT dispatch any waypoint - if (act === 4) { - for (let i = 3; i < 9; i++) { - state.dispatchedWp = null - const wy = 60 + 62 + i * 36 + 10 - wp.handleLeftDockClick('waypoint', 80 + 50, wy, callbacks) - console.assert( - getDispatchedWp() === null, - `Act 4 row ${i} should be empty, but dispatched waypoint!` - ) - } - } -} - -// 3. Waypoint slug lookups -console.assert( - findWaypointBySlug('halls-of-pain')?.levelId === 123, - 'Halls of Pain levelId should be 123' -) -console.assert( - findWaypointBySlug('the-pandemonium-fortress')?.levelId === 103, - 'The Pandemonium Fortress levelId should be 103' -) -console.assert( - findWaypointBySlug('nonexistent') === undefined, - 'Nonexistent slug should return undefined' -) - -// 4. Inventory item sprite resolution: Adversarial inputs -const itemRects = BAKED_UI_MANIFEST.itemRects -console.assert( - resolveItemSpriteRect({}, itemRects) !== null, - 'Empty item should resolve fallback rect' -) -console.assert( - resolveItemSpriteRect({ invFile: 'NON_EXISTENT_DC6' }, itemRects) !== null, - 'Invalid invFile should fallback' -) -console.assert( - resolveItemSpriteRect({ width: 0, height: 0 }, itemRects) !== null, - 'Zero dimensions should fallback' -) -console.assert( - resolveItemSpriteRect({ invWidth: -5, invHeight: 100 }, itemRects) !== null, - 'Negative/weird dimensions should fallback' -) - -// 5. Inventory panel drag, drop, swap, out of bounds -const inv = new InventoryPanel() -inv.visible = true - -// Out of bounds click in grid -console.assert(inv.cursorItem === null, 'Cursor should start null') -const beforeCount = inv.gridItems.length - -// Pick up Horadric Cube at (0, 0) -inv.handleClick(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10, { - onOpenCube: () => {}, - onCastTownPortal: () => {}, -}) -// Cube right-click or left-click without cursor opens cube if code is box -// But handleClick with hit?.item.code === 'box' triggers onOpenCube -state.cubeOpened = false -inv.handleClick(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10, { - onOpenCube: () => { state.cubeOpened = true }, - onCastTownPortal: () => {}, -}) -console.assert(isCubeOpened() === true, 'Clicking cube without cursorItem should open cube') - -// Click on normal item to pick up (Tome of Town Portal at col 2, row 0: x = origin + 2 * 29 + 10) -state.tpCasted = false -inv.handleClick(INV_GRID_ORIGIN.x + 2 * 29 + 10, INV_GRID_ORIGIN.y + 10, { - onOpenCube: () => {}, - onCastTownPortal: () => { state.tpCasted = true }, -}) -console.assert(isTpCasted() === true, 'Clicking tbk should trigger town portal') - -// Directly pick up an item via clickGridCell -inv.clickGridCell(3, 0) // Hellfire torch -console.assert(inv.cursorItem?.code === 'cm2', 'Hellfire torch should be on cursor') - -// Tooltip hover check: when cursorItem is held, hoveredItem should still be set by mouseMove, -// but HudManager suppresses tooltip drawing via `!this.inventory.cursorItem` -inv.handleMouseMove(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10) -console.assert(inv.hoveredItem !== null, 'Hovered item detected in inventory') - -// Place torch in empty space (col 6, row 0) -const placedEmpty = inv.clickGridCell(6, 0) -console.assert(placedEmpty === true, 'Placing torch in empty space should succeed') -console.assert(inv.cursorItem === null, 'Cursor item should now be null') - -// Now pick up torch again from (6, 0) and swap with Call to Arms at (8, 0) -inv.clickGridCell(6, 0) -console.assert(inv.cursorItem?.code === 'cm2', 'Picked up torch again') -const swapped = inv.clickGridCell(8, 0) // Overlaps with Call to Arms (crs) -console.assert(swapped === true, 'Swapping torch with Call to Arms should succeed') -console.assert(inv.cursorItem?.code === 'crs', 'Cursor item should now be Call to Arms') - -// Weapon swap I <-> II -console.assert(inv.weaponSwapSet === 0, 'Initial weapon swap set is 0') -inv.toggleWeaponSwap() -console.assert(inv.weaponSwapSet === 1, 'Weapon swap set is now 1') -inv.toggleWeaponSwap() -console.assert(inv.weaponSwapSet === 0, 'Weapon swap set is now 0') - -console.log('--- ALL ADVERSARIAL BOUNDARY AUDIT ASSERTIONS PASSED! ---') diff --git a/tools/verify-animation-browser.ts b/tools/verify-animation-browser.ts deleted file mode 100644 index e173d2f..0000000 --- a/tools/verify-animation-browser.ts +++ /dev/null @@ -1,1070 +0,0 @@ -/** - * Headless audit of the SHIPPED GAME BUNDLE (`dist-game/`). - * - * WHAT THIS AUDITS, AND WHY IT IS `dist-game/` AND NOT A DEV SERVER - * ----------------------------------------------------------------- - * The Zero-Runtime-MPQ invariant (Iron Law #2) constrains the artifact that - * actually ships. Auditing a dev server would prove "no MPQ requests in - * development form" while the production bundle could still contain the - * `httpRangeSource('/d2char.mpq')` path — a fully green report over a violated - * invariant, which is the worst available false negative. So this harness - * serves the real built output over a plain `node:http` static server and - * never transforms a source file. - * - * The static server is deliberately dependency-free (`node:http`, not vite): - * the dependency tree is part of what is under test, and if it were also the - * test carrier then "dependencies broke" would masquerade as "no requests were - * made". - * - * This pairs with `verify-bundle-no-mpq.ts`: - * - * bundle grep -> the bundle *cannot* download an archive (structural) - * this harness -> the bundle *did not* download one while being played - * - * Neither alone is sufficient. The grep cannot see a runtime fetch assembled - * from fragments; the runtime count cannot see a path that this particular run - * did not reach. - * - * ANTI-VACUITY: THE POINT OF THE `precondition` FIELD - * --------------------------------------------------- - * Most of these assertions are of the form "this counter is zero". Such an - * assertion passes trivially when the thing being counted never had a chance to - * happen: `missingMonsterArt.length === 0` is equally true on a level that - * rendered no monsters at all. A harness that reports success in that state is - * worse than no harness, because it manufactures confidence. - * - * So every assertion carries preconditions that establish the measurement was - * live — the scene rendered, the sim ticked, monsters were planned, the - * character draw path ran. An assertion whose preconditions are unmet is - * reported as **VACUOUS** and counts as a harness FAILURE, never as a pass. - * - * `act-scene.ts:254` is the cautionary tale: `state.characterGroup` is declared - * and published but never written, so any audit asserting on it would have been - * asserting on a constant. This harness checks that trap explicitly. - * - * NEGATIVE CONTROLS - * ----------------- - * An assertion never observed failing is not evidence. `--negative=` injects - * a specific, real fault and the run is then expected to go red on exactly the - * targeted assertion. See NEGATIVE_CONTROLS below. - * - * npm run verify:animation # audit current dist-game/ - * npm run verify:animation -- --negative=list # show the controls - * npm run verify:animation -- --negative=mpq-request - * - * OUTPUT PATH SAFETY - * ------------------ - * `scripts/verify-challenger-m3.ts` L28-29 hardcodes an output directory into a - * different worktree and writes screenshots there (L155/L159). That worktree is - * off-limits and is actively being worked in by another team. Every path this - * script writes is therefore passed through `assertWritablePath`, which throws - * unless the resolved path is inside this worktree. It is a guard rather than a - * convention so it cannot regress silently. - */ - -import { spawn } from 'node:child_process' -import { createServer } from 'node:http' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { extname, join, relative, resolve } from 'node:path' - -// --------------------------------------------------------------------------- -// Paths -// --------------------------------------------------------------------------- - -/** Repo root. This script lives in `/scripts`. */ -const ROOT = resolve(process.cwd()) -const DIST_DIR = join(ROOT, 'dist-game') -const PACKS_DIR = join(ROOT, 'samples', 'd2-packs') -const SRC_DIR = join(ROOT, 'src') - -/** - * Refuse to write anywhere outside this worktree. - * - * The forked-from script wrote screenshots into a sibling worktree that is - * explicitly off-limits and currently has another team's processes running in - * it. Enforcing this in code, on every write, is the only version of that rule - * that cannot rot. - */ -function assertWritablePath(candidate: string): string { - const full = resolve(candidate) - const rel = relative(ROOT, full) - if (rel.startsWith('..') || resolve(rel) === rel) { - throw new Error( - `verify-animation-browser: refusing to write outside the worktree.\n` + - ` requested: ${full}\n worktree : ${ROOT}`, - ) - } - return full -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -interface Options { - readonly negative: string - readonly outDir: string - readonly keepOpenMs: number -} - -function parseArgs(argv: readonly string[]): Options { - let negative = '' - let outDir = join(ROOT, '.agents', 'worker_mv', 'evidence') - let keepOpenMs = 0 - for (const arg of argv) { - if (arg.startsWith('--negative=')) negative = arg.slice('--negative='.length) - else if (arg.startsWith('--out=')) outDir = resolve(arg.slice('--out='.length)) - else if (arg.startsWith('--keep-open-ms=')) keepOpenMs = Number(arg.slice('--keep-open-ms='.length)) - } - return { negative, outDir, keepOpenMs } -} - -/** - * The deliberate faults. Each names the assertion it must turn red. - * - * `page-*` controls run inside the page; `artifact-*` controls would require - * mutating `dist-game/`, which is done by the separate bundle-gate controls - * rather than here, so that this harness never writes to the build output. - */ -const NEGATIVE_CONTROLS: Readonly> = { - 'mpq-request': 'page fetches a *.mpq URL -> must turn ZERO_MPQ_DLL_REQUESTS red', - 'dll-request': 'page fetches a *.dll URL -> must turn ZERO_MPQ_DLL_REQUESTS red', - 'console-error': 'page emits console.error -> must turn ZERO_CONSOLE_ERRORS red', - 'missing-art': 'push a fake id into missingMonsterArt -> must turn NO_RED_PLACEHOLDER_SQUARES red', - 'monster-art-error': 'bump monsterArtErrors -> must turn NO_RED_PLACEHOLDER_SQUARES red', - 'freeze-facing': 'ignore facing writes -> must turn EIGHT_DIRECTIONS_EXERCISED red', - 'broken-page': 'navigate to a URL that does not exist -> must turn SCENE_BOOTED red', -} - -// --------------------------------------------------------------------------- -// Static server for the built artifact -// --------------------------------------------------------------------------- - -const MIME: Readonly> = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.wasm': 'application/wasm', - '.map': 'application/json; charset=utf-8', - '.r8': 'application/octet-stream', - '.bin': 'application/octet-stream', - '.dat': 'application/octet-stream', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.ogg': 'audio/ogg', -} - -interface ServedRequest { - readonly url: string - readonly status: number -} - -/** - * Serve the production layout. - * - * The game is built with `--base=/diablo2/`, so the bundle must live under - * `/diablo2/`. `DEFAULT_PACKS` (`act-scene.ts`) resolves its second entry to - * `/diablo2/packs`, so the baked packs are mounted there — this reproduces the - * deployed shape rather than inventing one. - * - * Note what is deliberately NOT mounted: nothing serves `samples/d2`, so no - * `*.mpq` is reachable. That does not weaken the audit — a forbidden request is - * counted when it is *issued*, regardless of the response — and it matches a - * real deployment, where the archives are not published. - */ -function createStaticServer(served: ServedRequest[]): ReturnType { - const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ - { prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR }, - { prefix: '/diablo2/packs/', dir: PACKS_DIR }, - { prefix: '/diablo2/', dir: DIST_DIR }, - { prefix: '/packs/', dir: PACKS_DIR }, - { prefix: '/', dir: DIST_DIR }, - ] - - return createServer((req: IncomingMessage, res: ServerResponse) => { - const rawUrl = req.url ?? '/' - const path = decodeURIComponent(rawUrl.split('?')[0] ?? '/') - - const finish = (status: number, body: Buffer | string, type: string): void => { - served.push({ url: rawUrl, status }) - res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }) - res.end(body) - } - - if (path === '/favicon.ico') { - finish(204, '', 'image/x-icon') - return - } - - if (path === '/' || path === '/index.html') { - finish(302, '', 'text/plain') - return - } - - for (const mount of mounts) { - if (!path.startsWith(mount.prefix)) continue - const rest = path.slice(mount.prefix.length) - // Path traversal guard: the resolved file must stay inside the mount. - const target = resolve(mount.dir, rest) - if (!target.startsWith(mount.dir)) { - finish(403, 'forbidden', 'text/plain') - return - } - if (!existsSync(target) || !statSync(target).isFile()) continue - const body = readFileSync(target) - finish(200, body, MIME[extname(target).toLowerCase()] ?? 'application/octet-stream') - return - } - - finish(404, 'not found', 'text/plain') - }) -} - -// --------------------------------------------------------------------------- -// Assertion framework -// --------------------------------------------------------------------------- - -type Verdict = 'PASS' | 'FAIL' | 'VACUOUS' - -interface Assertion { - readonly id: string - /** What acceptance-criterion text this maps to. */ - readonly criterion: string - readonly verdict: Verdict - readonly detail: string - /** Precondition results, so a reader can see the measurement was live. */ - readonly preconditions: readonly { readonly name: string; readonly met: boolean; readonly value: string }[] - /** - * True when the assertion is expected to be red on current `main` because it - * documents a pre-existing defect another milestone owns. Reported loudly and - * excluded from the exit code, so a genuine regression is not buried under a - * known baseline. Turning green is reported as "baseline improved". - */ - readonly knownRedBaseline?: string -} - -class AssertionLog { - readonly items: Assertion[] = [] - - add(a: Assertion): void { - this.items.push(a) - } - - /** - * Build an assertion, evaluating preconditions first. - * - * If any precondition is unmet the verdict is VACUOUS regardless of the - * predicate, because a green from an unexercised code path is not evidence. - */ - check(args: { - id: string - criterion: string - preconditions: readonly { name: string; met: boolean; value: string }[] - pass: boolean - detail: string - knownRedBaseline?: string - }): void { - const unmet = args.preconditions.filter(p => !p.met) - const verdict: Verdict = unmet.length > 0 ? 'VACUOUS' : args.pass ? 'PASS' : 'FAIL' - const detail = - unmet.length > 0 - ? `${args.detail} | VACUOUS: preconditions unmet -> ${unmet.map(p => `${p.name}=${p.value}`).join(', ')}` - : args.detail - this.add({ - id: args.id, - criterion: args.criterion, - verdict, - detail, - preconditions: args.preconditions, - ...(args.knownRedBaseline === undefined ? {} : { knownRedBaseline: args.knownRedBaseline }), - }) - } -} - -// --------------------------------------------------------------------------- -// CDP plumbing -// --------------------------------------------------------------------------- - -const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) - -interface NetworkRequestLog { - url: string - method: string - resourceType: string -} - -interface ConsoleErrorLog { - source: string - text: string -} - -interface CdpSession { - send: (method: string, params?: Record, timeoutMs?: number) => Promise - evalJs: (expression: string) => Promise - close: () => void -} - -async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { - let wsUrl: string | null = null - let lastErr = 'none' - for (let i = 0; i < 80; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page') - if (page?.webSocketDebuggerUrl !== undefined) { - wsUrl = page.webSocketDebuggerUrl - break - } - lastErr = 'no page target yet' - } catch (err) { - // Bounded retry while Chrome boots. This is not a silent swallow: the - // last error is retained and thrown if the loop never succeeds. - lastErr = err instanceof Error ? err.message : String(err) - } - } - if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) - - const ws = new WebSocket(wsUrl) - await new Promise((res, rej) => { - ws.onopen = () => res() - ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) - }) - - let idCounter = 1 - const send = ( - method: string, - params: Record = {}, - timeoutMs = 20000, - ): Promise => - new Promise((res, rej) => { - const id = idCounter++ - const timer = setTimeout(() => { - ws.removeEventListener('message', handler) - rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) - }, timeoutMs) - const handler = (event: MessageEvent): void => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['id'] === id) { - clearTimeout(timer) - ws.removeEventListener('message', handler) - if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) - else res(msg['result'] as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - onEvent(JSON.parse(String(event.data)) as Record) - }) - - const evalJs = async (expression: string): Promise => { - const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails !== undefined) { - throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) - } - return res.result.value - } - - return { send, evalJs, close: () => ws.close() } -} - -// --------------------------------------------------------------------------- -// Scene state shape (the subset this audit reads) -// --------------------------------------------------------------------------- - -interface SceneSnapshot { - present: boolean - ready: boolean - error: string | null - frames: number - tick: number - character: boolean - characterFrames: number - characterGroup: number - missingMonsterArt: string[] - monsterArtErrors: number - monsterArtLayerFailures: number - monstersPlanned: number - facing: number - x: number - y: number - npcs: number - playerDrawRect?: { x: number; y: number; w: number; h: number } | null - weaponClass?: string | null - playerClip?: string | null -} - -const SNAPSHOT_EXPR = `(() => { - const s = window.__d2webAct - if (!s) return { present: false } - return { - present: true, - ready: !!s.ready, - error: s.error ?? null, - frames: s.frames ?? -1, - tick: s.tick ?? -1, - character: !!s.character, - characterFrames: s.characterFrames ?? -1, - characterGroup: s.characterGroup ?? -999, - missingMonsterArt: Array.isArray(s.missingMonsterArt) ? s.missingMonsterArt.slice() : null, - monsterArtErrors: s.monsterArtErrors ?? -1, - monsterArtLayerFailures: s.monsterArtLayerFailures ?? -1, - monstersPlanned: s.monstersPlanned ?? -1, - facing: s.facing ?? -1, - x: s.x ?? -1, - y: s.y ?? -1, - npcs: s.npcs ?? -1, - playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null, - weaponClass: s.weaponClass ?? null, - playerClip: s.playerClip ?? null, - } -})()` - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main(): Promise { - const opts = parseArgs(process.argv.slice(2)) - - if (opts.negative === 'list') { - console.log('Negative controls:') - for (const [id, what] of Object.entries(NEGATIVE_CONTROLS)) console.log(` --negative=${id.padEnd(20)} ${what}`) - process.exit(0) - } - if (opts.negative !== '' && NEGATIVE_CONTROLS[opts.negative] === undefined) { - console.error(`Unknown negative control: ${opts.negative}`) - console.error(`Known: ${Object.keys(NEGATIVE_CONTROLS).join(', ')}`) - process.exit(2) - } - - console.log('======================================================================') - console.log('HEADLESS AUDIT of the SHIPPED BUNDLE (dist-game/)') - if (opts.negative !== '') console.log(`NEGATIVE CONTROL ACTIVE: ${opts.negative} — ${NEGATIVE_CONTROLS[opts.negative]}`) - console.log('======================================================================') - - // --- Precondition: a real, fresh build exists ----------------------------- - // Auditing a missing or stale artifact is the silent-nothing failure mode. - assertBuildPresentAndFresh() - - const outDir = assertWritablePath(opts.negative === '' ? opts.outDir : join(opts.outDir, `negative-${opts.negative}`)) - mkdirSync(outDir, { recursive: true }) - console.log(`Evidence directory: ${outDir}`) - - const log = new AssertionLog() - const served: ServedRequest[] = [] - const networkRequests: NetworkRequestLog[] = [] - const consoleErrors: ConsoleErrorLog[] = [] - - const server = createStaticServer(served) - await new Promise(r => server.listen(0, '127.0.0.1', r)) - const addr = server.address() - const port = typeof addr === 'object' && addr !== null ? addr.port : 0 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`Static server (node:http, serving dist-game/): ${baseUrl}/diablo2/`) - - const debugPort = 9246 - const chrome = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - 'about:blank', - ]) - - let exitCode = 0 - try { - const cdp = await connectCdp(debugPort, msg => { - const method = msg['method'] as string | undefined - if (method === 'Network.requestWillBeSent') { - const req = msg['params'].request as { url: string; method: string } - networkRequests.push({ url: req.url, method: req.method, resourceType: String(msg['params'].type) }) - } else if (method === 'Runtime.consoleAPICalled') { - // This capture does not exist anywhere else in the repo; it is the - // whole mechanism behind the "console.error count is 0" criterion. - if (msg['params'].type === 'error') { - const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] - const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') - consoleErrors.push({ source: 'console.error', text }) - } - } else if (method === 'Runtime.exceptionThrown') { - const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } - consoleErrors.push({ source: 'uncaught', text: d.exception?.description ?? d.text ?? 'unknown exception' }) - } else if (method === 'Log.entryAdded') { - // Browser-level errors (failed subresource loads, CORS, WebGL) never - // reach `consoleAPICalled`. Without this the count would miss exactly - // the failures that matter most for an asset-loading invariant. - const e = msg['params'].entry as { level: string; text: string; source: string } - if (e.level === 'error') consoleErrors.push({ source: `log:${e.source}`, text: e.text }) - } - }) - - await cdp.send('Page.enable') - await cdp.send('Runtime.enable') - await cdp.send('Network.enable') - await cdp.send('Log.enable') - - const saveScreenshot = async (filename: string): Promise => { - const shot = await cdp.send<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = assertWritablePath(join(outDir, filename)) - const buf = Buffer.from(shot.data, 'base64') - writeFileSync(filePath, buf) - console.log(` screenshot: ${filename} (${buf.length} bytes)`) - return filePath - } - - // --- Navigate ----------------------------------------------------------- - const targetUrl = - opts.negative === 'broken-page' - ? `${baseUrl}/diablo2/this-page-does-not-exist.html` - : `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4` - console.log(`\n--- Navigating to ${targetUrl}`) - await cdp.send('Page.navigate', { url: targetUrl }) - - // The freeze-facing control must be installed before the scene can be - // driven, so it goes in as soon as the document exists. - if (opts.negative === 'freeze-facing') { - await sleep(1500) - await cdp.evalJs(`(() => { window.__mvFreezeFacing = true; return 1 })()`) - } - - let snap: SceneSnapshot = { present: false } as SceneSnapshot - for (let i = 0; i < 150; i++) { - snap = await cdp.evalJs(SNAPSHOT_EXPR) - if (snap.present && (snap.ready || snap.error !== null)) break - await sleep(400) - } - - console.log(` scene present=${snap.present} ready=${snap.ready} error=${String(snap.error)}`) - - log.check({ - id: 'SCENE_BOOTED', - criterion: 'audit precondition: the scene under test actually loaded and rendered', - preconditions: [{ name: '__d2webAct published', met: snap.present === true, value: String(snap.present) }], - pass: snap.ready === true && snap.error === null, - detail: `ready=${snap.ready} error=${String(snap.error)} frames=${snap.frames} tick=${snap.tick}`, - }) - - if (snap.present && snap.ready) { - // --- Drive gameplay --------------------------------------------------- - // Walking and swinging is what makes the audit cover "during play" rather - // than "at load": it exercises the walk clip, the attack path, and the - // monster draw path that produces the red placeholder square. - console.log('\n--- Driving gameplay (walk + attack)') - await cdp.evalJs(`(() => { - const sleep = ms => new Promise(r => setTimeout(r, ms)) - const press = (code, down) => - window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true })) - window.__mvDrive = (async () => { - const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'] - for (let round = 0; round < 4; round += 1) { - const dir = dirs[round % dirs.length] - press(dir, true) - await sleep(700) - press(dir, false) - for (let i = 0; i < 3; i += 1) { - press('Space', true); await sleep(160); press('Space', false); await sleep(80) - } - } - return true - })() - return true - })()`) - await cdp.evalJs(`window.__mvDrive`) - await sleep(500) - - const played = await cdp.evalJs(SNAPSHOT_EXPR) - console.log( - ` after play: frames=${played.frames} tick=${played.tick} characterFrames=${played.characterFrames} ` + - `monstersPlanned=${played.monstersPlanned} missingMonsterArt=${JSON.stringify(played.missingMonsterArt)}`, - ) - - // --- Inject the requested fault -------------------------------------- - await injectNegativeControl(cdp, opts.negative) - await sleep(600) - - // --- Assertions ------------------------------------------------------- - const live = await cdp.evalJs(SNAPSHOT_EXPR) - const rendered = { name: 'frames rendered', met: live.frames > 0, value: String(live.frames) } - const ticked = { name: 'sim ticked', met: live.tick > 0, value: String(live.tick) } - - // 1. Zero runtime MPQ / DLL requests. - const forbidden = networkRequests.filter(r => { - const u = r.url.toLowerCase().split('?')[0] ?? '' - return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq/') || u.includes('/dll/') - }) - log.check({ - id: 'ZERO_MPQ_DLL_REQUESTS', - criterion: 'AC: 加载并游玩场景期间,对 *.mpq / *.dll 的网络请求数为 0', - preconditions: [ - rendered, - ticked, - // Without this, "0 forbidden requests" could simply mean the network - // layer was never observed at all. - { name: 'network capture live', met: networkRequests.length > 0, value: String(networkRequests.length) }, - ], - pass: forbidden.length === 0, - detail: - `${forbidden.length} forbidden of ${networkRequests.length} total requests` + - (forbidden.length > 0 ? ` -> ${forbidden.map(f => f.url).join(', ')}` : ''), - }) - - // 2. Zero console.error. - log.check({ - id: 'ZERO_CONSOLE_ERRORS', - criterion: 'AC: 审计期间 console.error 计数为 0', - preconditions: [rendered, ticked], - pass: consoleErrors.length === 0, - detail: - `${consoleErrors.length} error-level messages` + - (consoleErrors.length > 0 - ? ` -> ${consoleErrors.slice(0, 8).map(e => `[${e.source}] ${e.text.slice(0, 200)}`).join(' || ')}` - : ''), - }) - - // 3. No red placeholder squares. - // - // Read from published state, not pixels: `act-scene.ts:3352` already - // records every monster id drawn as a red box. The precondition that - // monsters were actually planned is what stops this being vacuous on a - // town level, where zero red squares is true but meaningless. - const missing = live.missingMonsterArt ?? [] - log.check({ - id: 'NO_RED_PLACEHOLDER_SQUARES', - criterion: 'AC: 无红色占位方块', - preconditions: [ - rendered, - ticked, - { name: 'monsters planned on this level', met: live.monstersPlanned > 0, value: String(live.monstersPlanned) }, - { name: 'missingMonsterArt field exists', met: live.missingMonsterArt !== null, value: String(live.missingMonsterArt !== null) }, - ], - pass: missing.length === 0 && live.monsterArtErrors === 0 && live.monsterArtLayerFailures === 0, - detail: - `missingMonsterArt=${JSON.stringify(missing)} monsterArtErrors=${live.monsterArtErrors} ` + - `monsterArtLayerFailures=${live.monsterArtLayerFailures}`, - }) - - // 3b. The published-state trap. - // - // `state.characterGroup` is declared (L254) and initialised to -1 (L328) - // and never written. It is proof that a published field can be a - // constant, so any audit reading state must verify the fields it trusts - // are actually written. This assertion documents the trap rather than - // depending on it. - log.add({ - id: 'PUBLISHED_STATE_TRAP_CHARACTERGROUP', - criterion: 'harness self-check: a published field can lie (act-scene.ts:254 characterGroup)', - verdict: live.characterGroup === -1 ? 'PASS' : 'PASS', - detail: - `characterGroup=${live.characterGroup} (init -1 at act-scene.ts:328, never written). ` + - `${live.characterGroup === -1 ? 'Confirmed still dead — no assertion may depend on it.' : 'It is now written; it became usable.'}`, - preconditions: [rendered], - }) - - // 4. Eight-direction foot-anchor evidence. - await captureEightDirections(cdp, log, saveScreenshot, live, opts) - - // 5. Weapon-swap evidence. - await captureWeaponSwap(cdp, log, saveScreenshot, outDir) - } - - // --- Report ------------------------------------------------------------- - exitCode = report(log, opts, outDir, { networkRequests, consoleErrors, served }) - - if (opts.keepOpenMs > 0) await sleep(opts.keepOpenMs) - cdp.close() - } finally { - chrome.kill('SIGKILL') - await new Promise(r => server.close(() => r())) - } - - process.exit(exitCode) -} - -/** - * Fail loudly when there is nothing real to audit. - * - * A missing `dist-game/` must not degrade into "audited nothing, all green", - * and a stale one silently audits code that no longer exists. - */ -function assertBuildPresentAndFresh(): void { - if (!existsSync(DIST_DIR) || !existsSync(join(DIST_DIR, 'acts.html'))) { - console.error(`\nFAIL: no built artifact at ${DIST_DIR} (acts.html missing).`) - console.error(' This harness audits the SHIPPED bundle. Build it first:') - console.error(' npm run build:game') - process.exit(1) - } - const newest = (dir: string): number => { - let best = 0 - const walk = (d: string): void => { - for (const e of readdirSync(d)) { - const f = join(d, e) - const st = statSync(f) - if (st.isDirectory()) walk(f) - else best = Math.max(best, st.mtimeMs) - } - } - walk(dir) - return best - } - const distTime = newest(DIST_DIR) - const srcTime = newest(SRC_DIR) - if (srcTime > distTime) { - console.error('\nFAIL: dist-game/ is STALE — a file under src/ is newer than the build.') - console.error(` newest src/ : ${new Date(srcTime).toISOString()}`) - console.error(` newest dist-game/ : ${new Date(distTime).toISOString()}`) - console.error(' Auditing a stale bundle proves nothing about the current code.') - console.error(' Rebuild: npm run build:game') - process.exit(1) - } - console.log(`Artifact OK: dist-game/ built ${new Date(distTime).toISOString()} (newer than src/)`) -} - -async function injectNegativeControl(cdp: CdpSession, negative: string): Promise { - if (negative === '') return - console.log(`\n--- Injecting negative control: ${negative}`) - switch (negative) { - case 'mpq-request': - // A genuine network request for an archive, issued by the page. It is - // expected to 404 — the invariant is about the request being made. - await cdp.evalJs(`fetch('/diablo2/data/d2char.mpq').then(()=>1).catch(()=>1)`) - break - case 'dll-request': - await cdp.evalJs(`fetch('/diablo2/data/D2Common.dll').then(()=>1).catch(()=>1)`) - break - case 'console-error': - await cdp.evalJs(`(() => { console.error('MV negative control: synthetic console.error'); return 1 })()`) - break - case 'missing-art': - await cdp.evalJs( - `(() => { window.__d2webAct.missingMonsterArt.push('mv-synthetic-missing-monster'); return 1 })()`, - ) - break - case 'monster-art-error': - await cdp.evalJs(`(() => { window.__d2webAct.monsterArtErrors += 1; return 1 })()`) - break - case 'freeze-facing': - case 'broken-page': - // Handled at their own point in the flow. - break - default: - throw new Error(`unhandled negative control ${negative}`) - } -} - -/** - * Drive the player through all eight facings and capture one screenshot each. - * - * WHY THE MACHINE ASSERTION IS NOT "the screenshots differ". - * The background has animated tiles and independently moving monsters, so any - * two full-frame captures differ even if the player never turned. An assertion - * on image difference would therefore pass unconditionally — vacuous in the - * most dangerous way, since it *looks* like pixel evidence. - * - * What is actually checkable is the mechanism: that all eight distinct facings - * were really reached, and that the character draw path was live at each one - * (`characterFrames` strictly increasing). The screenshots are then human - * review material for anchor drift, which is a judgement a script cannot make - * without a per-frame draw rect the runtime does not yet publish. - */ -async function captureEightDirections( - cdp: CdpSession, - log: AssertionLog, - saveScreenshot: (name: string) => Promise, - before: SceneSnapshot, - opts: Options, -): Promise { - console.log('\n--- Capturing 8 facings') - const observed: number[] = [] - const charFrames: number[] = [] - const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = [] - const playerPositions: Array<{ x: number; y: number }> = [] - - for (let dir = 0; dir < 8; dir++) { - await cdp.evalJs(`(() => { - if (window.__mvFreezeFacing) return 1 - const e = window.__d2webEngine - if (!e) throw new Error('__d2webEngine is not published; cannot set facing') - e.world.player.facing = ${dir} - return 1 - })()`) - // Two sim ticks at 25 Hz plus a render. - await sleep(160) - const s = await cdp.evalJs(SNAPSHOT_EXPR) - observed.push(s.facing) - charFrames.push(s.characterFrames) - playerDrawRects.push(s.playerDrawRect ?? null) - playerPositions.push({ x: s.x, y: s.y }) - await saveScreenshot(`anchor_facing_${dir}.png`) - } - - const uniqueFacings = new Set(observed) - const charPathLive = charFrames.every((v, i) => i === 0 || v > (charFrames[i - 1] ?? -1)) - - console.log(` facings observed: ${JSON.stringify(observed)} (unique=${uniqueFacings.size})`) - console.log(` characterFrames : ${JSON.stringify(charFrames)} (strictly increasing=${charPathLive})`) - - log.check({ - id: 'EIGHT_DIRECTIONS_EXERCISED', - criterion: 'AC: 8 个方向下角色脚底位置稳定(锚点正确),有截图证据 — mechanism half', - preconditions: [ - { name: '__d2webEngine published', met: true, value: 'act-scene.ts:2677' }, - { name: 'character art loaded (not the marker box)', met: before.character === true, value: String(before.character) }, - ], - pass: uniqueFacings.size === 8 && charPathLive, - detail: - `observed facings=${JSON.stringify(observed)} unique=${uniqueFacings.size}/8; ` + - `characterFrames=${JSON.stringify(charFrames)} strictlyIncreasing=${charPathLive}; ` + - `8 screenshots written (anchor_facing_0..7.png)`, - }) - - const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null) - let maxFootDelta = -1 - if (allRectsPublished) { - const footDeltas = playerDrawRects.map((r, i) => { - const py = playerPositions[i]!.y - return Math.abs((r!.y + r!.h) - py) - }) - maxFootDelta = Math.max(...footDeltas) - } - - log.check({ - id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', - criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', - preconditions: [ - { name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' }, - ], - pass: allRectsPublished && playerDrawRects.every((r, i) => { - const p = playerPositions[i]! - return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15 - }), - detail: allRectsPublished - ? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` + - `max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor` - : 'playerDrawRect missing on some facings', - }) - - if (opts.negative === 'freeze-facing') { - console.log(' (freeze-facing control active: facing writes were suppressed in-page)') - } -} - -/** - * Capture evidence for "swapping weapon class changes the attack animation". - * - * On current `main` the player COF is hardcoded to `hth` (tier-3 char load - * passes the literal `'hth'`, `act-scene.ts:2388-2389`), and no weapon class - * reaches the clip choice at all, so this is a known-red baseline that M3 owns. - * The harness still captures the before/after so the change is reviewable, and - * records the equipped codes structurally rather than claiming a screenshot - * proves something it does not. - */ -async function captureWeaponSwap( - cdp: CdpSession, - log: AssertionLog, - saveScreenshot: (name: string) => Promise, - outDir: string, -): Promise { - console.log('\n--- Weapon-swap evidence') - - const readWeapon = `(() => { - const hud = window.__d2webHudInstance - const inv = hud && hud.inventory - const eq = inv && inv.equipped - const w1 = eq && eq.weapon1 - return { - hudPresent: !!hud, - inventoryPresent: !!inv, - weapon1: w1 ? { code: w1.code, name: w1.name } : null, - // The runtime does not publish a resolved weapon class or the COF clip in - // use. Recorded as null so the gap is visible in the artifact rather than - // implied by omission. - resolvedWeaponClass: (window.__d2webAct && window.__d2webAct.weaponClass) ?? null, - activeClip: (window.__d2webAct && window.__d2webAct.playerClip) ?? null, - } - })()` - - interface WeaponEvidence { - hudPresent: boolean - inventoryPresent: boolean - weapon1: { code: string; name: string } | null - resolvedWeaponClass: string | null - activeClip: string | null - } - - const before = await cdp.evalJs(readWeapon) - await saveScreenshot('weapon_before.png') - - // Swap to a two-handed weapon so the weapon class genuinely differs. `2hs` - // vs `hth` is the coarsest possible distinction, which is the right one for a - // first regression signal. - const swapped = await cdp.evalJs(`(() => { - const inv = window.__d2webHudInstance && window.__d2webHudInstance.inventory - if (!inv) return false - inv.equipped.weapon1 = { - id: 'mv-swap-2hs', code: '7wa', invFile: 'invgpa', name: 'Colossus Blade', - quality: 'normal', invWidth: 2, invHeight: 4, allowedSlots: ['weapon1'], - } - return true - })()`) - await sleep(600) - const after = await cdp.evalJs(readWeapon) - await saveScreenshot('weapon_after.png') - - const evidencePath = assertWritablePath(join(outDir, 'weapon-swap-evidence.json')) - writeFileSync(evidencePath, JSON.stringify({ swapped, before, after }, null, 2)) - console.log(` weapon evidence: ${relative(ROOT, evidencePath)}`) - console.log(` before=${JSON.stringify(before)}`) - console.log(` after =${JSON.stringify(after)}`) - - log.check({ - id: 'WEAPON_SWAP_EVIDENCE_CAPTURED', - criterion: 'AC: 换装不同武器类后…有截图或日志证据 — capture half', - preconditions: [{ name: 'HUD inventory reachable', met: before.inventoryPresent, value: String(before.inventoryPresent) }], - pass: swapped && before.weapon1 !== null && after.weapon1 !== null && before.weapon1.code !== after.weapon1.code, - detail: `equipped weapon1 ${before.weapon1?.code ?? 'none'} -> ${after.weapon1?.code ?? 'none'}; screenshots + JSON written`, - }) - - log.check({ - id: 'WEAPON_SWAP_CHANGES_ANIMATION', - criterion: 'AC: 换装不同武器类后,攻击动作随之变化', - preconditions: [ - { - name: 'runtime publishes the resolved weapon class / active clip', - met: after.resolvedWeaponClass !== null || after.activeClip !== null, - value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, - }, - ], - pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip, - detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`, - }) -} - -function report( - log: AssertionLog, - opts: Options, - outDir: string, - raw: { networkRequests: NetworkRequestLog[]; consoleErrors: ConsoleErrorLog[]; served: ServedRequest[] }, -): number { - console.log('\n======================================================================') - console.log('RESULTS') - console.log('======================================================================') - - let hardFail = 0 - let vacuous = 0 - let knownRed = 0 - let baselineImproved = 0 - - for (const a of log.items) { - const isKnownRed = a.knownRedBaseline !== undefined - let tag: string - if (a.verdict === 'PASS') { - if (isKnownRed) { - tag = 'BASELINE-IMPROVED' - baselineImproved++ - } else tag = 'PASS' - } else if (a.verdict === 'VACUOUS') { - if (isKnownRed) { - tag = 'KNOWN-RED' - knownRed++ - } else { - tag = 'VACUOUS(=FAIL)' - vacuous++ - } - } else { - if (isKnownRed) { - tag = 'KNOWN-RED' - knownRed++ - } else { - tag = 'FAIL' - hardFail++ - } - } - console.log(`\n[${tag}] ${a.id}`) - console.log(` criterion: ${a.criterion}`) - console.log(` detail : ${a.detail}`) - for (const p of a.preconditions) { - console.log(` precond : ${p.met ? 'met' : 'UNMET'} — ${p.name} = ${p.value}`) - } - if (a.knownRedBaseline !== undefined) console.log(` baseline : ${a.knownRedBaseline}`) - } - - const summaryPath = assertWritablePath(join(outDir, 'audit-report.json')) - writeFileSync( - summaryPath, - JSON.stringify( - { - negativeControl: opts.negative === '' ? null : opts.negative, - generated: new Date().toISOString(), - assertions: log.items, - totals: { hardFail, vacuous, knownRed, baselineImproved }, - networkRequestCount: raw.networkRequests.length, - forbiddenRequests: raw.networkRequests - .filter(r => { - const u = r.url.toLowerCase().split('?')[0] ?? '' - return u.endsWith('.mpq') || u.endsWith('.dll') - }) - .map(r => r.url), - consoleErrors: raw.consoleErrors, - serverRequestCount: raw.served.length, - server404s: raw.served.filter(s => s.status === 404).map(s => s.url), - }, - null, - 2, - ), - ) - - console.log('\n----------------------------------------------------------------------') - console.log( - `SUMMARY: ${log.items.length} assertions | FAIL=${hardFail} VACUOUS=${vacuous} ` + - `KNOWN-RED=${knownRed} BASELINE-IMPROVED=${baselineImproved}`, - ) - console.log(`Report: ${relative(ROOT, summaryPath)}`) - - if (baselineImproved > 0) { - console.log('\nNOTE: a known-red baseline turned green. Update the baseline annotation in this script.') - } - - if (opts.negative !== '') { - // Under a negative control the run is SUPPOSED to be red. Report the - // inversion explicitly so a control that failed to bite is not mistaken - // for a clean run. - const red = hardFail + vacuous - console.log('\n----------------------------------------------------------------------') - if (red > 0) { - console.log(`NEGATIVE CONTROL '${opts.negative}': harness went RED as required (${red} failing assertion(s)).`) - return 0 - } - console.log(`NEGATIVE CONTROL '${opts.negative}': ***HARNESS STAYED GREEN — THE CONTROL DID NOT BITE***`) - console.log('This means the assertion it targets cannot detect its own failure mode.') - return 1 - } - - return hardFail + vacuous > 0 ? 1 : 0 -} - -main().catch((err: unknown) => { - console.error('\nHARNESS ERROR:', err) - process.exit(1) -}) diff --git a/tools/verify-challenger-m1-2.ts b/tools/verify-challenger-m1-2.ts deleted file mode 100644 index 3dcb701..0000000 --- a/tools/verify-challenger-m1-2.ts +++ /dev/null @@ -1,618 +0,0 @@ -/** - * Comprehensive Empirical Stress-Testing Harness for Milestone 1 - * Ghost Interaction Decoupling & Combat Safety (Requirement R1) - * - * Playbook: solution_stress_testing (SKILL.md) - * Archetype: EMPIRICAL CHALLENGER - */ - -import { KeyboardInput } from '../src/client/sim/input.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' -import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' - -// Mock DOM Environment for Node -class MockWindow extends EventTarget { - innerWidth = 1920 - innerHeight = 1080 - devicePixelRatio = 1 -} - -class MockCanvas extends EventTarget { - clientWidth = 1920 - clientHeight = 1080 - width = 1920 - height = 1080 - style = { pointerEvents: 'none' } - getBoundingClientRect() { - return { left: 0, top: 0, width: 1920, height: 1080 } - } - getContext() { - return null - } -} - -class SyntheticKeyboardEvent extends Event { - readonly code: string - readonly key: string - - constructor(type: string, init: { code: string; key: string }) { - super(type, { cancelable: true }) - this.code = init.code - this.key = init.key - } -} - -// Global DOM shims for HudManager -const mockWin = new MockWindow() -// @ts-ignore -globalThis.window = mockWin -// @ts-ignore -globalThis.HTMLInputElement = class HTMLInputElement {} -// @ts-ignore -globalThis.HTMLSelectElement = class HTMLSelectElement {} - -let totalAssertions = 0 -let passedAssertions = 0 -let failedAssertions = 0 - -function assert(condition: boolean, message: string): void { - totalAssertions++ - if (condition) { - passedAssertions++ - } else { - failedAssertions++ - console.error(`❌ ASSERTION FAILED: ${message}`) - throw new Error(`Assertion failed: ${message}`) - } -} - -async function runTestSuite(): Promise { - console.log('=================================================================') - console.log('🚀 RUNNING EMPIRICAL CHALLENGER STRESS-TEST HARNESS (M1 / R1)') - console.log('=================================================================') - - // ----------------------------------------------------------------------------- - // SUITE 1: Ghost Interaction Decoupling: KeyT & KeyB - // ----------------------------------------------------------------------------- - console.log('\n--- [Suite 1] Ghost Interaction Decoupling: KeyT & KeyB ---') - { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - // 1.1 Initial state - assert(input.talking === false, 'Initial input.talking must be false') - assert(input.takeWaypoint() === false, 'Initial input.takeWaypoint() must be false') - - // 1.2 KeyT dispatch (single, held, repeated) - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyT', key: 't' })) - assert(input.talking === false, 'KeyT down must NOT set input.talking to true') - - for (let tick = 0; tick < 100; tick++) { - assert(input.talking === false, `input.talking must remain false across 100 ticks of KeyT held (tick ${tick})`) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyT', key: 't' })) // repeat - assert(input.talking === false, 'Repeated KeyT keydown must NOT set input.talking') - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyT', key: 't' })) - assert(input.talking === false, 'KeyT keyup must leave input.talking false') - - // 1.3 KeyB dispatch (single, held, repeated) - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyB', key: 'b' })) - assert(input.takeWaypoint() === false, 'KeyB down must NOT trigger input.takeWaypoint()') - - for (let tick = 0; tick < 100; tick++) { - assert(input.takeWaypoint() === false, `input.takeWaypoint() must remain false across 100 ticks of KeyB held (tick ${tick})`) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyB', key: 'b' })) - assert(input.takeWaypoint() === false, 'Repeated KeyB keydown must NOT trigger input.takeWaypoint()') - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyB', key: 'b' })) - assert(input.takeWaypoint() === false, 'KeyB keyup must leave input.takeWaypoint() false') - - // 1.4 Test every printable key on the keyboard to ensure TALK_KEYS and WAYPOINT_KEYS are 100% empty - const testCodes = [ - 'KeyA', 'KeyB', 'KeyC', 'KeyD', 'KeyE', 'KeyF', 'KeyG', 'KeyH', 'KeyI', 'KeyJ', - 'KeyK', 'KeyL', 'KeyM', 'KeyN', 'KeyO', 'KeyP', 'KeyQ', 'KeyR', 'KeyS', 'KeyT', - 'KeyU', 'KeyV', 'KeyW', 'KeyX', 'KeyY', 'KeyZ', 'Digit1', 'Digit2', 'Digit3', 'Digit4', - 'Space', 'Enter', 'Tab', 'Escape', 'Backspace' - ] - for (const code of testCodes) { - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code, key: code.toLowerCase() })) - assert(input.talking === false, `Key ${code} must NOT set input.talking to true`) - assert(input.takeWaypoint() === false, `Key ${code} must NOT trigger input.takeWaypoint()`) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code, key: code.toLowerCase() })) - } - } - - // ----------------------------------------------------------------------------- - // SUITE 2: Combat Safety: Space vs Combat Swings & Panel Dismissal - // ----------------------------------------------------------------------------- - console.log('\n--- [Suite 2] Combat Safety: Space Removal from ATTACK_KEYS & Panel Dismissal ---') - { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - // 2.1 Space does not set input.attacking - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - assert(input.attacking === false, 'Space down must NOT set input.attacking to true') - - for (let tick = 0; tick < 100; tick++) { - assert(input.attacking === false, `Space held must remain attacking=false across 100 ticks (tick ${tick})`) - } - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Space', key: ' ' })) - assert(input.attacking === false, 'Space up must remain attacking=false') - - // 2.2 Legitimate attack keys DO set input.attacking (Positive Control) - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyJ', key: 'j' })) - assert(input.attacking === true, 'KeyJ down MUST set input.attacking to true') - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyJ', key: 'j' })) - assert(input.attacking === false, 'KeyJ up must clear input.attacking') - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Enter', key: 'Enter' })) - assert(input.attacking === true, 'Enter down MUST set input.attacking to true') - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Enter', key: 'Enter' })) - assert(input.attacking === false, 'Enter up must clear input.attacking') - - // 2.3 GameEngine combat verification: Space does not swing or damage monsters - const dummyTerrain: WorldMapProvider = { - widthPx: 1000, - heightPx: 1000, - overlap: () => 0, - } - const dummyOpts: GameEngineOptions = { - spawn: { x: 100, y: 100 }, - stats: [ - { id: 'mon1', name: 'Demon', hp: 100, damage: 0, cooldownTicks: 100, reach: 20, aggroRadius: 100, speed: 0, xp: 5 } - ], - xpTable: [0, 0, 10, 30, 60], - itemBases: [ - { id: 'sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 10, damage: 10, defense: 0, tags: ['weap'], level: 1 } - ], - prefixAffixes: [], - suffixAffixes: [], - skills: [ - { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }, - ], - npcDefs: [ - { id: 'akara', name: 'Akara', questId: null, offerLines: [], progressLines: [], doneLines: [] } - ], - questDefs: [], - combatOptions: { - playerSpeed: 100, - playerReach: 50, - playerCooldownTicks: 1, - playerDamage: 10, - playerManaPerAttack: 0, - respawnTicks: 100, - }, - lootSeed: 12345, - talkRadius: 50, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - - const engine = new GameEngine(dummyTerrain, dummyOpts) - // Place monster right next to player (player at 100, 100; monster at 110, 100) - assert(engine.world.monsters.length > 0, 'Engine must have spawned monsters') - engine.world.monsters[0]!.x = 110 - engine.world.monsters[0]!.y = 100 - const initialMonsterHp = engine.world.monsters[0]!.hp - const initialHits = engine.metrics.playerHits - - // Tick engine with Space held via KeyboardInput - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - for (let tick = 0; tick < 50; tick++) { - engine.tick({ - movement: input.movement(), - attacking: input.attacking, - pickingUp: input.pickingUp, - talking: input.talking, - digits: input.digits, - saving: input.saving, - loading: input.loading, - }) - } - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Space', key: ' ' })) - - assert(engine.world.monsters[0]!.hp === initialMonsterHp, `Space key MUST NOT cause damage to monster (HP was ${initialMonsterHp}, now ${engine.world.monsters[0]!.hp})`) - assert(engine.metrics.playerHits === initialHits, `Space key MUST NOT register combat hits (hits was ${initialHits}, now ${engine.metrics.playerHits})`) - - // Compare with KeyJ: ticking with KeyJ MUST damage monster - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyJ', key: 'j' })) - for (let tick = 0; tick < 10; tick++) { - engine.tick({ - movement: input.movement(), - attacking: input.attacking, - pickingUp: input.pickingUp, - talking: input.talking, - digits: input.digits, - saving: input.saving, - loading: input.loading, - }) - } - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyJ', key: 'j' })) - - assert(engine.world.monsters[0]!.hp < initialMonsterHp, 'KeyJ attack MUST inflict damage on adjacent monster (Positive Control)') - assert(engine.metrics.playerHits > initialHits, 'KeyJ attack MUST register combat hits in metrics (Positive Control)') - - // 2.4 Panel Dismissal in HudManager: Space and Escape - const hudCanvas = new MockCanvas() as unknown as HTMLCanvasElement - const hud = new HudManager(hudCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hud['bindEvents']() - - // Test Space closing Character Sheet - hud.toggleLeftPanel('char') - assert(hud.leftPanel === 'char', 'Left panel should be char') - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - assert(hud.leftPanel === 'none', 'Space MUST close Character Sheet') - - // Test Space closing Inventory Panel - hud.toggleRightPanel('inv') - assert(hud.rightPanel === 'inv', 'Right panel should be inv') - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - assert(hud.rightPanel === 'none', 'Space MUST close Inventory Panel') - - // Test Space closing Skill Tree Panel - hud.toggleRightPanel('skill') - assert(hud.rightPanel === 'skill', 'Right panel should be skill') - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - assert(hud.rightPanel === 'none', 'Space MUST close Skill Tree Panel') - - // Test Space closing both panels + belt + popup concurrently - hud.toggleLeftPanel('quest') - hud.toggleRightPanel('skill') - hud.belt.lockedOpen = true - hud.hotkeys.openPopup = 'right' - assert(hud.leftPanel === 'quest' && hud.rightPanel === 'skill', 'Panels should be open') - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' })) - assert(hud.leftPanel === 'none', 'Space MUST close leftPanel') - assert(hud.rightPanel === 'none', 'Space MUST close rightPanel') - assert((hud.belt.lockedOpen as boolean) === false, 'Space MUST close lockedOpen belt') - assert(hud.hotkeys.openPopup === null, 'Space MUST close hotkey popup') - - // Test Escape parity with Space - hud.toggleLeftPanel('waypoint') - hud.toggleRightPanel('inv') - assert(hud.leftPanel === 'waypoint' && hud.rightPanel === 'inv', 'Panels open before Escape') - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Escape', key: 'Escape' })) - assert(hud.leftPanel === 'none', 'Escape MUST close leftPanel') - assert(hud.rightPanel === 'none', 'Escape MUST close rightPanel') - } - - // ----------------------------------------------------------------------------- - // SUITE 3: Belt Potion Drinking & Skill Preservation (Keys 1..4) - // ----------------------------------------------------------------------------- - console.log('\n--- [Suite 3] Belt Drinking & Skill Preservation (Keys 1..4) ---') - { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - // 3.1 Digit keys on KeyboardInput return [] - for (const code of ['Digit1', 'Digit2', 'Digit3', 'Digit4']) { - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code, key: code.slice(-1) })) - assert(input.digits.length === 0, `input.digits MUST return [] when ${code} is pressed`) - } - assert(input.digits.length === 0, 'input.digits must be [] when all 1..4 are held') - for (const code of ['Digit1', 'Digit2', 'Digit3', 'Digit4']) { - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code, key: code.slice(-1) })) - } - - // 3.2 GameEngine skill selection invariance - const dummyTerrain: WorldMapProvider = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } - const dummyOpts: GameEngineOptions = { - spawn: { x: 100, y: 100 }, - stats: [], - xpTable: [0, 0, 10, 30, 60], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [ - { id: 'sk0', name: 'Skill 0', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 1, baseMaxDamage: 2, damagePerLevel: 1, radius: 20 }, - { id: 'sk1', name: 'Skill 1', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 1, baseMaxDamage: 2, damagePerLevel: 1, radius: 20 }, - { id: 'sk2', name: 'Skill 2', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 1, baseMaxDamage: 2, damagePerLevel: 1, radius: 20 }, - { id: 'sk3', name: 'Skill 3', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 1, baseMaxDamage: 2, damagePerLevel: 1, radius: 20 }, - ], - npcDefs: [], - questDefs: [], - combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 }, - lootSeed: 12345, - talkRadius: 50, - pickupRadius: 50, - inventoryCols: 10, - inventoryRows: 4, - } - const engine = new GameEngine(dummyTerrain, dummyOpts) - engine.selectedSkill = 2 // Choose Skill 2 - assert(engine.selectedSkill === 2, 'Initial selectedSkill should be 2') - - // Tick engine with Digit1..Digit4 pressed - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' })) - engine.tick({ - movement: input.movement(), - attacking: input.attacking, - pickingUp: input.pickingUp, - talking: input.talking, - digits: input.digits, - saving: input.saving, - loading: input.loading, - }) - assert(engine.selectedSkill === 2, 'engine.selectedSkill MUST remain 2 after Digit1 tick') - - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' })) - engine.tick({ - movement: input.movement(), - attacking: input.attacking, - pickingUp: input.pickingUp, - talking: input.talking, - digits: input.digits, - saving: input.saving, - loading: input.loading, - }) - assert(engine.selectedSkill === 2, 'engine.selectedSkill MUST remain 2 after Digit4 tick') - - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Digit1', key: '1' })) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Digit4', key: '4' })) - - // 3.3 HudManager Potion Drinking & Skill Preservation - const hudCanvas = new MockCanvas() as unknown as HTMLCanvasElement - const hud = new HudManager(hudCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hud['bindEvents']() - - // Configure distinct active skills: Left = 47 (Fire Ball), Right = 64 (Frozen Orb) - hud.hotkeys.leftSkillId = 47 - hud.hotkeys.rightSkillId = 64 - hud.syncPublishedState() - assert(hud.state.leftSkillId === 47, 'Initial published leftSkillId should be 47') - assert(hud.state.rightSkillId === 64, 'Initial published rightSkillId should be 64') - - // Lower HP and Mana to verify drinking effects - hud.hp = 300 - hud.mana = 200 - hud.syncPublishedState() - - const initialTotalPotions = hud.belt.countTotalPotions() - assert(initialTotalPotions === 16, 'Full belt should have 16 potions (4 cols x 4 rows)') - - // --- Drink Column 0 (Key '1') --- - const hpBefore1 = hud.hp - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' })) - assert(hud.hp > hpBefore1, `Key 1 MUST consume health potion and heal HP (was ${hpBefore1}, now ${hud.hp})`) - assert(hud.hotkeys.leftSkillId === 47, 'Key 1 MUST NOT alter hotkeys.leftSkillId (remains 47)') - assert(hud.hotkeys.rightSkillId === 64, 'Key 1 MUST NOT alter hotkeys.rightSkillId (remains 64)') - assert(hud.state.leftSkillId === 47, 'Key 1 MUST NOT alter state.leftSkillId (remains 47)') - assert(hud.state.rightSkillId === 64, 'Key 1 MUST NOT alter state.rightSkillId (remains 64)') - assert(hud.belt.countTotalPotions() === initialTotalPotions - 1, 'Belt potion count must decrement by 1') - - // --- Drink Column 1 (Key '2') --- - const hpBefore2 = hud.hp - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' })) - assert(hud.hp >= hpBefore2, `Key 2 MUST consume column 1 potion (was ${hpBefore2}, now ${hud.hp})`) - assert(hud.hotkeys.leftSkillId === 47, 'Key 2 MUST NOT alter leftSkillId') - assert(hud.hotkeys.rightSkillId === 64, 'Key 2 MUST NOT alter rightSkillId') - assert(hud.state.leftSkillId === 47, 'Key 2 MUST NOT alter state leftSkillId') - assert(hud.state.rightSkillId === 64, 'Key 2 MUST NOT alter state rightSkillId') - assert(hud.belt.countTotalPotions() === initialTotalPotions - 2, 'Belt potion count must decrement by 2') - - // --- Drink Column 2 (Key '3') --- - const manaBefore3 = hud.mana - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' })) - assert(hud.mana > manaBefore3, `Key 3 MUST consume mana potion and restore Mana (was ${manaBefore3}, now ${hud.mana})`) - assert(hud.hotkeys.leftSkillId === 47, 'Key 3 MUST NOT alter leftSkillId') - assert(hud.hotkeys.rightSkillId === 64, 'Key 3 MUST NOT alter rightSkillId') - assert(hud.state.leftSkillId === 47, 'Key 3 MUST NOT alter state leftSkillId') - assert(hud.state.rightSkillId === 64, 'Key 3 MUST NOT alter state rightSkillId') - assert(hud.belt.countTotalPotions() === initialTotalPotions - 3, 'Belt potion count must decrement by 3') - - // --- Drink Column 3 (Key '4') --- - hud.hp = 500 - hud.mana = 200 - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' })) - assert(hud.hp === 500 + 400 && hud.mana === 200 + 400, `Key 4 MUST consume rejuv potion and heal 400 HP & Mana (hp: ${hud.hp}, mana: ${hud.mana})`) - assert(hud.hotkeys.leftSkillId === 47, 'Key 4 MUST NOT alter leftSkillId') - assert(hud.hotkeys.rightSkillId === 64, 'Key 4 MUST NOT alter rightSkillId') - assert(hud.state.leftSkillId === 47, 'Key 4 MUST NOT alter state leftSkillId') - assert(hud.state.rightSkillId === 64, 'Key 4 MUST NOT alter state rightSkillId') - assert(hud.belt.countTotalPotions() === initialTotalPotions - 4, 'Belt potion count must decrement by 4') - - // 3.4 Exhaust remaining belt slots (drink all 12 remaining potions) - for (let c = 0; c < 4; c++) { - for (let r = 0; r < 3; r++) { - hud.drinkBeltSlot(c) - } - } - assert(hud.belt.countTotalPotions() === 0, 'Belt should now be completely empty (0 potions)') - - // Adversarial test: pressing 1..4 on empty belt - for (const key of ['1', '2', '3', '4']) { - const consumed = hud.drinkBeltSlot(Number(key) - 1) - assert(consumed === false, `Drinking from empty slot column ${Number(key) - 1} must return false`) - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key })) - assert(hud.hotkeys.leftSkillId === 47, `Empty belt press ${key} must NOT alter leftSkillId`) - assert(hud.hotkeys.rightSkillId === 64, `Empty belt press ${key} must NOT alter rightSkillId`) - assert(!Number.isNaN(hud.hp) && !Number.isNaN(hud.mana), `Empty belt press ${key} must NOT cause NaN`) - } - - // Boundary column tests: out-of-range columns - assert(hud.drinkBeltSlot(-1) === false, 'drinkBeltSlot(-1) must return false') - assert(hud.drinkBeltSlot(4) === false, 'drinkBeltSlot(4) must return false') - assert(hud.drinkBeltSlot(100) === false, 'drinkBeltSlot(100) must return false') - assert(hud.hotkeys.leftSkillId === 47 && hud.hotkeys.rightSkillId === 64, 'Out-of-range drinkBeltSlot must not mutate skills') - } - - // ----------------------------------------------------------------------------- - // SUITE 4: Adversarial Fuzzing & Differential Invariant Testing (5,000 Iterations) - // ----------------------------------------------------------------------------- - console.log('\n--- [Suite 4] Adversarial Fuzzing & Differential Testing (5,000 Iterations) ---') - { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const hudCanvas = new MockCanvas() as unknown as HTMLCanvasElement - const hud = new HudManager(hudCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hud['bindEvents']() - hud.hotkeys.leftSkillId = 47 - hud.hotkeys.rightSkillId = 64 - hud.syncPublishedState() - - const candidateKeys = [ - { code: 'KeyT', key: 't' }, - { code: 'KeyB', key: 'b' }, - { code: 'Space', key: ' ' }, - { code: 'Digit1', key: '1' }, - { code: 'Digit2', key: '2' }, - { code: 'Digit3', key: '3' }, - { code: 'Digit4', key: '4' }, - { code: 'KeyW', key: 'w' }, - { code: 'KeyA', key: 'a' }, - { code: 'KeyS', key: 's' }, - { code: 'KeyD', key: 'd' }, - { code: 'KeyC', key: 'c' }, - { code: 'KeyJ', key: 'j' }, - { code: 'Enter', key: 'Enter' }, - { code: 'ShiftLeft', key: 'Shift' }, - { code: 'ShiftRight', key: 'Shift' }, - { code: 'ArrowUp', key: 'ArrowUp' }, - { code: 'ArrowDown', key: 'ArrowDown' }, - { code: 'ArrowLeft', key: 'ArrowLeft' }, - { code: 'ArrowRight', key: 'ArrowRight' }, - { code: 'Escape', key: 'Escape' }, - ] - - const currentlyHeld = new Set() - - // Run 5,000 randomized state transitions - const NUM_ITERATIONS = 5000 - for (let i = 0; i < NUM_ITERATIONS; i++) { - const candidate = candidateKeys[Math.floor(Math.random() * candidateKeys.length)]! - const isDown = Math.random() > 0.45 // 55% keydown, 45% keyup - - if (isDown) { - currentlyHeld.add(candidate.code) - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', candidate)) - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', candidate)) - } else { - currentlyHeld.delete(candidate.code) - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', candidate)) - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keyup', candidate)) - } - - // --- INVARIANT ORACLE VERIFICATIONS --- - - // Invariant 1: input.talking is ALWAYS false (KeyT decoupled) - assert(input.talking === false, `[Fuzz Step ${i}] input.talking MUST be false (held: ${Array.from(currentlyHeld).join(',')})`) - - // Invariant 2: input.takeWaypoint() is ALWAYS false (KeyB decoupled) - assert(input.takeWaypoint() === false, `[Fuzz Step ${i}] input.takeWaypoint() MUST be false (held: ${Array.from(currentlyHeld).join(',')})`) - - // Invariant 3: Space NEVER contributes to attacking - const expectedAttacking = currentlyHeld.has('KeyJ') || currentlyHeld.has('Enter') - assert(input.attacking === expectedAttacking, `[Fuzz Step ${i}] input.attacking mismatch: expected ${expectedAttacking}, got ${input.attacking}`) - - // Invariant 4: input.digits is ALWAYS [] - assert(input.digits.length === 0, `[Fuzz Step ${i}] input.digits MUST be empty array`) - - // Invariant 5: WASD NEVER contributes to movement - const mov = input.movement() - if (!currentlyHeld.has('ArrowUp') && !currentlyHeld.has('ArrowDown') && !currentlyHeld.has('ArrowLeft') && !currentlyHeld.has('ArrowRight')) { - assert(mov.x === 0 && mov.y === 0, `[Fuzz Step ${i}] Movement must be {0,0} when no arrow keys held, even if WASD are held. Got {${mov.x}, ${mov.y}}`) - } - - // Invariant 6: Shift tracking - const expectedShift = currentlyHeld.has('ShiftLeft') || currentlyHeld.has('ShiftRight') - assert(input.shiftHeld === expectedShift, `[Fuzz Step ${i}] ShiftHeld mismatch: expected ${expectedShift}, got ${input.shiftHeld}`) - - // Invariant 7: Active skill IDs in HudManager remain strictly untouched - assert(hud.hotkeys.leftSkillId === 47, `[Fuzz Step ${i}] leftSkillId mutated! Got ${hud.hotkeys.leftSkillId}`) - assert(hud.hotkeys.rightSkillId === 64, `[Fuzz Step ${i}] rightSkillId mutated! Got ${hud.hotkeys.rightSkillId}`) - assert(hud.state.leftSkillId === 47, `[Fuzz Step ${i}] state.leftSkillId mutated! Got ${hud.state.leftSkillId}`) - assert(hud.state.rightSkillId === 64, `[Fuzz Step ${i}] state.rightSkillId mutated! Got ${hud.state.rightSkillId}`) - } - - console.log(`✅ Passed 5,000 randomized fuzzing iterations with 7 live invariant checks per step (${NUM_ITERATIONS * 7} assertions)`) - } - - // ----------------------------------------------------------------------------- - // SUITE 5: Simultaneous Chord Stress (All problematic keys pressed at once) - // ----------------------------------------------------------------------------- - console.log('\n--- [Suite 5] Simultaneous Chord Stress (Space + KeyT + KeyB + 1..4 + WASD) ---') - { - const win = new MockWindow() - const input = new KeyboardInput(win as unknown as Window) - input.attach() - - const hudCanvas = new MockCanvas() as unknown as HTMLCanvasElement - const hud = new HudManager(hudCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - hud['bindEvents']() - hud.hotkeys.leftSkillId = 47 - hud.hotkeys.rightSkillId = 64 - hud.syncPublishedState() - - const chord = [ - { code: 'KeyA', key: 'a' }, - { code: 'KeyT', key: 't' }, - { code: 'KeyB', key: 'b' }, - { code: 'Digit1', key: '1' }, - { code: 'Digit2', key: '2' }, - { code: 'Digit3', key: '3' }, - { code: 'Digit4', key: '4' }, - { code: 'KeyW', key: 'w' }, - { code: 'KeyS', key: 's' }, - { code: 'KeyD', key: 'd' }, - { code: 'Space', key: ' ' }, - ] - - for (const evt of chord) { - win.dispatchEvent(new SyntheticKeyboardEvent('keydown', evt)) - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', evt)) - } - - assert(input.talking === false, 'Simultaneous chord: talking MUST be false') - assert(input.takeWaypoint() === false, 'Simultaneous chord: takeWaypoint MUST be false') - assert(input.attacking === false, 'Simultaneous chord: attacking MUST be false (Space does not attack)') - assert(input.digits.length === 0, 'Simultaneous chord: digits MUST be []') - assert(input.movement().x === 0 && input.movement().y === 0, 'Simultaneous chord: movement MUST be {0, 0}') - assert(input.active === false, 'Simultaneous chord: active MUST be false') - assert(hud.leftPanel === 'none', 'Simultaneous chord: Space closes leftPanel') - assert(hud.rightPanel === 'none', 'Simultaneous chord: Space closes rightPanel') - assert(hud.hotkeys.leftSkillId === 47, 'Simultaneous chord: leftSkillId is 47') - assert(hud.hotkeys.rightSkillId === 64, 'Simultaneous chord: rightSkillId is 64') - - for (const evt of chord) { - win.dispatchEvent(new SyntheticKeyboardEvent('keyup', evt)) - mockWin.dispatchEvent(new SyntheticKeyboardEvent('keyup', evt)) - } - } - - console.log('\n=================================================================') - console.log(`🎉 EMPIRICAL STRESS TEST RESULTS:`) - console.log(`Total Assertions Executed: ${totalAssertions}`) - console.log(`Assertions Passed: ${passedAssertions}`) - console.log(`Assertions Failed: ${failedAssertions}`) - console.log('Verdict: EMPIRICAL PROOF OF CORRECTNESS CONFIRMED') - console.log('=================================================================') -} - -runTestSuite().catch((err) => { - console.error('Fatal error in stress test harness:', err) - process.exit(1) -}) diff --git a/tools/verify-challenger-m1.ts b/tools/verify-challenger-m1.ts deleted file mode 100644 index 881fd65..0000000 --- a/tools/verify-challenger-m1.ts +++ /dev/null @@ -1,729 +0,0 @@ -/** - * Challenger & Empirical Stress Testing Suite for Milestone M1 (Requirement R1). - * - * Comprehensive empirical verification against `src/sim/input.ts` and `src/ui/hud-manager.ts`: - * 1. Unit & Boundary Assertions on KeyboardInput (WASD, Arrows, Diagonals, Opposites, Shift, Digits, Space, Talk, Waypoint) - * 2. Differential Fuzzing: 1,000 randomized keyboard states against an independent oracle - * 3. In-Memory HUD Manager State Transitions (Space/Escape dismissal, KeyS/KeyT Skill Tree, KeyW Weapon Swap, 1..4 Potions, Input Element Suppression) - * 4. Headless Chrome CDP In-Engine Live Gameplay Verification (`acts.html?act=1`): - * - KeyW, KeyA, KeyS, KeyD zero player movement verification - * - Real keyboard hotkey panel toggling & dismissal - * - Weapon swap verification - * - Accessibility arrow key navigation - * - Visual screenshot capture - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' -import { KeyboardInput, directionOf, DIRECTION, type Movement } from '../src/client/sim/input.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' - -const HANDOFF_DIR = '/usr/local/google/home/taodao/d2w-wg1-main/.agents/teamwork_preview_challenger_m1_1' -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface TestResult { - name: string - passed: boolean - error?: string -} - -const results: TestResult[] = [] - -function assert(condition: boolean, message: string): void { - if (!condition) { - throw new Error(`Assertion failed: ${message}`) - } -} - -function assertClose(a: number, b: number, epsilon = 1e-4, msg = ''): void { - if (Math.abs(a - b) > epsilon) { - throw new Error(`Assertion failed: expected ${a} to be close to ${b} (diff=${Math.abs(a - b)} > ${epsilon}). ${msg}`) - } -} - -// ============================================================================ -// PART 1: IN-MEMORY KEYBOARDINPUT UNIT & ADVERSARIAL STRESS -// ============================================================================ -function runKeyboardInputTests(): void { - console.log('\n--- PART 1: In-Memory KeyboardInput Empirical Verification ---') - - const listeners = new Map void>() - const mockWindow = { - addEventListener: (type: string, fn: any) => listeners.set(type, fn), - removeEventListener: (type: string, fn: any) => listeners.delete(type), - } - - const input = new KeyboardInput(mockWindow as unknown as Window) - input.attach() - - const press = (code: string) => { - let prevented = false - const evt = { - code, - preventDefault: () => { prevented = true }, - } - listeners.get('keydown')?.(evt) - return prevented - } - - const release = (code: string) => { - listeners.get('keyup')?.({ code }) - } - - const blur = () => { - listeners.get('blur')?.({}) - } - - // 1.1 WASD keys produce {x: 0, y: 0} and active === false - console.log(' 1.1 Testing WASD keys individual and combined...') - for (const code of ['KeyW', 'KeyA', 'KeyS', 'KeyD']) { - press(code) - const m = input.movement() - assert(m.x === 0 && m.y === 0, `${code} produced non-zero movement: ${JSON.stringify(m)}`) - assert(input.active === false, `${code} set active to true`) - assert(directionOf(m) === null, `${code} produced direction: ${directionOf(m)}`) - release(code) - } - - // Combinations of WASD - press('KeyW') - press('KeyA') - assert(input.movement().x === 0 && input.movement().y === 0, 'WA produced movement') - assert(input.active === false, 'WA produced active: true') - press('KeyS') - press('KeyD') - assert(input.movement().x === 0 && input.movement().y === 0, 'WASD produced movement') - assert(input.active === false, 'WASD produced active: true') - release('KeyW') - release('KeyA') - release('KeyS') - release('KeyD') - - // 1.2 Arrow keys produce correct unit vectors and active === true - console.log(' 1.2 Testing Arrow keys unit vectors and active === true...') - // Up - press('ArrowUp') - assert(input.active === true, 'ArrowUp active is false') - assertClose(input.movement().x, 0, 1e-4, 'ArrowUp x') - assertClose(input.movement().y, -1, 1e-4, 'ArrowUp y') - assert(directionOf(input.movement()) === DIRECTION.North, 'ArrowUp direction not North') - release('ArrowUp') - assert(input.active === false, 'ArrowUp release active still true') - - // Down - press('ArrowDown') - assert(input.active === true, 'ArrowDown active is false') - assertClose(input.movement().x, 0, 1e-4, 'ArrowDown x') - assertClose(input.movement().y, 1, 1e-4, 'ArrowDown y') - assert(directionOf(input.movement()) === DIRECTION.South, 'ArrowDown direction not South') - release('ArrowDown') - - // Left - press('ArrowLeft') - assert(input.active === true, 'ArrowLeft active is false') - assertClose(input.movement().x, -1, 1e-4, 'ArrowLeft x') - assertClose(input.movement().y, 0, 1e-4, 'ArrowLeft y') - assert(directionOf(input.movement()) === DIRECTION.West, 'ArrowLeft direction not West') - release('ArrowLeft') - - // Right - press('ArrowRight') - assert(input.active === true, 'ArrowRight active is false') - assertClose(input.movement().x, 1, 1e-4, 'ArrowRight x') - assertClose(input.movement().y, 0, 1e-4, 'ArrowRight y') - assert(directionOf(input.movement()) === DIRECTION.East, 'ArrowRight direction not East') - release('ArrowRight') - - // 1.3 Diagonal normalisation (hypot === 1.0, not √2) - console.log(' 1.3 Testing Diagonal arrows normalisation...') - press('ArrowUp') - press('ArrowRight') - const diagNE = input.movement() - assert(input.active === true, 'Diag active is false') - assertClose(Math.hypot(diagNE.x, diagNE.y), 1.0, 1e-4, 'DiagNE magnitude not 1.0') - assertClose(diagNE.x, Math.SQRT1_2, 1e-4, 'DiagNE x') - assertClose(diagNE.y, -Math.SQRT1_2, 1e-4, 'DiagNE y') - assert(directionOf(diagNE) === DIRECTION.NorthEast, 'DiagNE direction') - release('ArrowUp') - release('ArrowRight') - - press('ArrowDown') - press('ArrowLeft') - const diagSW = input.movement() - assertClose(Math.hypot(diagSW.x, diagSW.y), 1.0, 1e-4, 'DiagSW magnitude') - assertClose(diagSW.x, -Math.SQRT1_2, 1e-4, 'DiagSW x') - assertClose(diagSW.y, Math.SQRT1_2, 1e-4, 'DiagSW y') - assert(directionOf(diagSW) === DIRECTION.SouthWest, 'DiagSW direction') - release('ArrowDown') - release('ArrowLeft') - - // 1.4 Opposing arrows cancellation - console.log(' 1.4 Testing Opposing arrows cancellation...') - press('ArrowUp') - press('ArrowDown') - assert(input.active === true, 'Opposing up/down active should be true') - assert(input.movement().x === 0 && input.movement().y === 0, 'Opposing up/down non-zero vector') - assert(directionOf(input.movement()) === null, 'Opposing up/down direction not null') - release('ArrowUp') - release('ArrowDown') - - // 1.5 ShiftLeft / ShiftRight tracking - console.log(' 1.5 Testing ShiftLeft / ShiftRight state...') - assert(input.shiftHeld === false, 'Shift initially held') - press('ShiftLeft') - assert(input.shiftHeld === true, 'ShiftLeft down did not set shiftHeld') - release('ShiftLeft') - assert(input.shiftHeld === false, 'ShiftLeft up did not clear shiftHeld') - - press('ShiftRight') - assert(input.shiftHeld === true, 'ShiftRight down did not set shiftHeld') - release('ShiftRight') - assert(input.shiftHeld === false, 'ShiftRight up did not clear shiftHeld') - - press('ShiftLeft') - press('ShiftRight') - assert(input.shiftHeld === true, 'Both shifts down') - release('ShiftLeft') - assert(input.shiftHeld === true, 'One shift released, other still down') - release('ShiftRight') - assert(input.shiftHeld === false, 'Both shifts released') - - // 1.6 Digit1..4 do not appear in digits - console.log(' 1.6 Testing Digit1..4 exclusion from digits...') - for (const digit of ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5']) { - press(digit) - assert(input.digits.length === 0, `${digit} appeared in digits: ${JSON.stringify(input.digits)}`) - release(digit) - } - - // 1.7 Space does not set attacking === true - console.log(' 1.7 Testing Space key attacking suppression...') - press('Space') - assert(input.attacking === false, 'Space key set attacking to true!') - release('Space') - assert(input.attacking === false, 'After Space release') - - // Verify KeyJ and Enter do set attacking === true - press('KeyJ') - assert(input.attacking === true, 'KeyJ failed to set attacking: true') - release('KeyJ') - assert(input.attacking === false, 'KeyJ release failed to clear attacking') - - press('Enter') - assert(input.attacking === true, 'Enter failed to set attacking: true') - release('Enter') - assert(input.attacking === false, 'Enter release failed to clear attacking') - - // 1.8 Ghost interaction decoupling (KeyT and KeyB) - console.log(' 1.8 Testing Ghost interaction keys decoupling (KeyT, KeyB)...') - press('KeyT') - assert(input.talking === false, 'KeyT set talking: true') - release('KeyT') - - press('KeyB') - assert(input.takeWaypoint() === false, 'KeyB triggered takeWaypoint()') - release('KeyB') - - // 1.9 Blur resets all held state - console.log(' 1.9 Testing blur event resets all held state...') - press('ArrowUp') - press('ShiftLeft') - press('KeyJ') - assert(input.active === true, 'Pre-blur active') - assert(input.shiftHeld === true, 'Pre-blur shiftHeld') - assert(input.attacking === true, 'Pre-blur attacking') - blur() - assert(input.active === false, 'Post-blur active not false') - assert(input.shiftHeld === false, 'Post-blur shiftHeld not false') - assert(input.attacking === false, 'Post-blur attacking not false') - assert(input.movement().x === 0 && input.movement().y === 0, 'Post-blur movement not zero') - - input.detach() - console.log(' ✅ All In-Memory KeyboardInput tests passed!') -} - -// ============================================================================ -// PART 2: DIFFERENTIAL FUZZING (ORACLE VS IMPLEMENTATION) -// ============================================================================ -function runDifferentialFuzzing(iterations = 1000): void { - console.log(`\n--- PART 2: Differential Fuzzing (${iterations} iterations) ---`) - - const ARROWS: Record = { - ArrowUp: [0, -1], - ArrowDown: [0, 1], - ArrowLeft: [-1, 0], - ArrowRight: [1, 0], - } - - const ALL_TEST_KEYS = [ - 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'KeyW', 'KeyA', 'KeyS', 'KeyD', - 'Digit1', 'Digit2', 'Digit3', 'Digit4', - 'Space', 'ShiftLeft', 'ShiftRight', 'KeyJ', 'Enter', 'KeyT', 'KeyB', 'KeyE', 'Tab', - 'KeyQ', 'KeyC', 'KeyV', 'KeyI', 'KeyR', 'KeyG', 'KeyP', 'KeyK', 'KeyL', - ] - - const listeners = new Map void>() - const mockWindow = { - addEventListener: (type: string, fn: any) => listeners.set(type, fn), - removeEventListener: (type: string, fn: any) => listeners.delete(type), - } - - const input = new KeyboardInput(mockWindow as unknown as Window) - input.attach() - - // Simple, obviously correct Oracle implementation - function oracle(heldKeys: Set) { - let ox = 0 - let oy = 0 - let active = false - for (const k of heldKeys) { - const v = ARROWS[k] - if (v) { - ox += v[0] - oy += v[1] - active = true - } - } - const mag = Math.hypot(ox, oy) - const movement: Movement = mag === 0 ? { x: 0, y: 0 } : { x: ox / mag, y: oy / mag } - const shiftHeld = heldKeys.has('ShiftLeft') || heldKeys.has('ShiftRight') - const attacking = heldKeys.has('KeyJ') || heldKeys.has('Enter') - const digits: number[] = [] // Invariant: must be empty - const talking = false // Invariant: must be false - return { movement, active, shiftHeld, attacking, digits, talking } - } - - // Seeded pseudo-random generator - let seed = 1337 - const rand = () => { - seed = (seed * 1664525 + 1013904223) % 4294967296 - return seed / 4294967296 - } - - const currentlyHeld = new Set() - - for (let iter = 0; iter < iterations; iter++) { - // Pick 1-5 keys to toggle - const numToggles = Math.floor(rand() * 5) + 1 - for (let t = 0; t < numToggles; t++) { - const key = ALL_TEST_KEYS[Math.floor(rand() * ALL_TEST_KEYS.length)]! - if (currentlyHeld.has(key)) { - currentlyHeld.delete(key) - listeners.get('keyup')?.({ code: key }) - } else { - currentlyHeld.add(key) - listeners.get('keydown')?.({ code: key, preventDefault: () => {} }) - } - } - - const exp = oracle(currentlyHeld) - const actMov = input.movement() - - assertClose(actMov.x, exp.movement.x, 1e-4, `Iter ${iter} Movement X mismatch on keys: ${Array.from(currentlyHeld).join(',')}`) - assertClose(actMov.y, exp.movement.y, 1e-4, `Iter ${iter} Movement Y mismatch on keys: ${Array.from(currentlyHeld).join(',')}`) - assert(input.active === exp.active, `Iter ${iter} Active mismatch: actual ${input.active} vs oracle ${exp.active}`) - assert(input.shiftHeld === exp.shiftHeld, `Iter ${iter} ShiftHeld mismatch`) - assert(input.attacking === exp.attacking, `Iter ${iter} Attacking mismatch`) - assert(input.digits.length === 0, `Iter ${iter} Digits must be empty`) - assert(input.talking === false, `Iter ${iter} Talking must be false`) - } - - // Clear all - listeners.get('blur')?.({}) - input.detach() - console.log(` ✅ Differential fuzzing passed all ${iterations} iterations with 0 discrepancies!`) -} - -// ============================================================================ -// PART 3: IN-MEMORY HUD MANAGER KEY HANDLING & STATE TRANSITIONS -// ============================================================================ -function runHudManagerKeyTests(): void { - console.log('\n--- PART 3: In-Memory HudManager Key Handling & State Transitions ---') - - const windowListeners = new Map void>() - const mockWindow = { - addEventListener: (type: string, fn: any) => windowListeners.set(type, fn), - removeEventListener: (type: string, fn: any) => windowListeners.delete(type), - devicePixelRatio: 1.0, - innerWidth: 1920, - innerHeight: 1080, - } - - // Mock global DOM constructors if running in pure Node - if (typeof (globalThis as any).window === 'undefined') { - ;(globalThis as any).window = mockWindow - } - if (typeof (globalThis as any).HTMLInputElement === 'undefined') { - ;(globalThis as any).HTMLInputElement = class {} - } - if (typeof (globalThis as any).HTMLSelectElement === 'undefined') { - ;(globalThis as any).HTMLSelectElement = class {} - } - - const mockCanvas = { - width: 1920, - height: 1080, - clientWidth: 1920, - clientHeight: 1080, - style: { pointerEvents: 'none' }, - getBoundingClientRect: () => ({ left: 0, top: 0, width: 1920, height: 1080 }), - getContext: () => null, - addEventListener: () => {}, - } as unknown as HTMLCanvasElement - - const hud = new HudManager(mockCanvas, { - onToggleAutomap: () => {}, - onWaypointTeleport: () => {}, - }) - - // Wire events via private bindEvents() - ;(hud as any).bindEvents() - - const fireKey = (key: string, code: string, target?: any) => { - let prevented = false - const evt = { - key, - code, - target: target ?? {}, - preventDefault: () => { prevented = true }, - } - windowListeners.get('keydown')?.(evt) - return prevented - } - - // 3.1 KeyS and KeyT toggle Skill Tree - console.log(' 3.1 Testing KeyS and KeyT toggle Skill Tree...') - assert(hud.rightPanel === 'none', 'Initial right panel') - fireKey('s', 'KeyS') - assert(hud.rightPanel === 'skill', 'KeyS did not open skill tree') - assert(hud.skillTree.visible === true, 'Skill tree not visible') - - fireKey('s', 'KeyS') - assert(hud.rightPanel === 'none', 'KeyS did not close skill tree') - assert(hud.skillTree.visible === false, 'Skill tree still visible') - - fireKey('t', 'KeyT') - assert(hud.rightPanel === 'skill', 'KeyT did not open skill tree') - - fireKey('t', 'KeyT') - assert(hud.rightPanel === 'none', 'KeyT did not close skill tree') - - // Alternate: KeyS opens, KeyT closes - fireKey('s', 'KeyS') - assert(hud.rightPanel === 'skill', 'KeyS open') - fireKey('t', 'KeyT') - assert(hud.rightPanel === 'none', 'KeyT close') - - // 3.2 KeyW toggles Weapon Swap - console.log(' 3.2 Testing KeyW toggles Weapon Swap...') - assert(hud.inventory.weaponSwapSet === 0, 'Initial weaponSwapSet is not 0') - fireKey('w', 'KeyW') - assert(hud.inventory.weaponSwapSet === 1, 'KeyW did not switch to weapon set II (1)') - fireKey('w', 'KeyW') - assert(hud.inventory.weaponSwapSet === 0, 'KeyW did not switch back to weapon set I (0)') - - // 3.3 Space and Escape call closeAllPanels() - console.log(' 3.3 Testing Space and Escape panel closing...') - // Test Character Sheet dismissal via Space - fireKey('a', 'KeyA') - assert(hud.leftPanel === 'char', 'KeyA open char sheet') - const preventedSpace = fireKey(' ', 'Space') - assert(hud.leftPanel === 'none', 'Space failed to close char sheet') - assert(preventedSpace === true, 'Space should preventDefault when closing panel') - - // Test Skill Tree dismissal via Escape - fireKey('s', 'KeyS') - assert(hud.rightPanel === 'skill', 'KeyS open skill tree') - const preventedEscape = fireKey('Escape', 'Escape') - assert(hud.rightPanel === 'none', 'Escape failed to close skill tree') - assert(preventedEscape === true, 'Escape should preventDefault when closing panel') - - // Test Multi-Panel dismissal via Space - fireKey('c', 'KeyC') - fireKey('i', 'KeyI') - assert(hud.leftPanel === 'char', 'Char open') - assert(hud.rightPanel === 'inv', 'Inv open') - fireKey(' ', 'Space') - assert(hud.leftPanel === 'none', 'Space failed to close left panel') - assert(hud.rightPanel === 'none', 'Space failed to close right panel') - - // Test Space when no panels open returns false - const preventedNoOp = fireKey(' ', 'Space') - assert(preventedNoOp === false, 'Space when no panels open should not preventDefault') - - // 3.4 Number keys 1..4 drink belt potions - console.log(' 3.4 Testing Number keys 1..4 drink belt potions without altering skills...') - hud.hp = 500 - hud.mana = 400 - const initialLeftSkill = hud.hotkeys.leftSkillId - const initialRightSkill = hud.hotkeys.rightSkillId - const initialPotions = hud.belt.countTotalPotions() - - fireKey('1', 'Digit1') - assert(hud.belt.countTotalPotions() === initialPotions - 1, 'Potion 1 not consumed') - assert(hud.hp > 500, 'HP did not increase after drinking potion 1') - assert(hud.hotkeys.leftSkillId === initialLeftSkill, 'leftSkillId changed!') - assert(hud.hotkeys.rightSkillId === initialRightSkill, 'rightSkillId changed!') - - fireKey('2', 'Digit2') - assert(hud.belt.countTotalPotions() === initialPotions - 2, 'Potion 2 not consumed') - - // 3.5 Target element filtering (input / select) - console.log(' 3.5 Testing Input / Select element key event suppression...') - const inputEl = new (globalThis as any).HTMLInputElement() - fireKey('s', 'KeyS', inputEl) - assert(hud.rightPanel === 'none', 'KeyS inside input element toggled skill tree!') - - fireKey('w', 'KeyW', inputEl) - assert(hud.inventory.weaponSwapSet === 0, 'KeyW inside input element toggled weapon swap!') - - console.log(' ✅ All In-Memory HudManager tests passed!') -} - -// ============================================================================ -// PART 4: HEADLESS CHROME CDP IN-ENGINE LIVE GAMEPLAY VERIFICATION -// ============================================================================ -async function runChromeCdpVerification(): Promise { - console.log('\n--- PART 4: Headless Chrome CDP Live Gameplay Invariant Verification ---') - mkdirSync(HANDOFF_DIR, { recursive: true }) - - const server = await createServer({ - root: process.cwd(), - server: { port: 5199, host: '127.0.0.1', strictPort: false }, - }) - await server.listen() - const port = (server.httpServer?.address() as any)?.port ?? 5199 - const baseUrl = `http://127.0.0.1:${port}` - console.log(` Vite server running at: ${baseUrl}`) - - const debugPort = 9255 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - 'about:blank', - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as any[] - const p = pages.find(x => x.type === 'page') - if (p?.webSocketDebuggerUrl) { - wsUrl = p.webSocketDebuggerUrl - break - } - } catch {} - } - - if (!wsUrl) throw new Error('Could not connect to Chrome CDP') - console.log(' Connected to Chrome CDP WebSocket.') - - const ws = new WebSocket(wsUrl) - await new Promise(res => { ws.onopen = () => res() }) - - let idCounter = 1 - const send = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(`CDP ${method} failed: ${JSON.stringify(msg.error)}`)) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - await send('Page.enable') - await send('Runtime.enable') - - const evalJs = async (expr: string): Promise => { - const res = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }) - if (res.exceptionDetails) throw new Error(`JS error: ${JSON.stringify(res.exceptionDetails)}`) - return res.result.value - } - - const saveScreenshot = async (name: string): Promise => { - const shot = await send('Page.captureScreenshot', { format: 'png' }) - const p = join(HANDOFF_DIR, name) - writeFileSync(p, Buffer.from(shot.data, 'base64')) - console.log(` 📸 Saved screenshot: ${name}`) - return p - } - - const pressKeyCdp = async (key: string, code: string, holdMs = 50) => { - await send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: key.charCodeAt(0) }) - if (holdMs > 0) await sleep(holdMs) - await send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: key.charCodeAt(0) }) - } - - await send('Page.navigate', { url: `${baseUrl}/acts.html?act=1` }) - - // Wait for engine and HUD readiness - console.log(' Waiting for __d2webAct and __d2webHud...') - for (let i = 0; i < 100; i++) { - await sleep(250) - const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (ready) break - } - await sleep(500) - - const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - assert(ready === true, 'Timed out waiting for scene readiness') - console.log(' ✅ Engine & HUD ready.') - - // 4.1 Test WASD produces ZERO player movement - console.log(' 4.1 Testing WASD keys produce 0 player displacement in live engine...') - const posInitial = await evalJs<{ x: number; y: number }>(`({ x: window.__d2webAct.x, y: window.__d2webAct.y })`) - - // Press W, A, S, D repeatedly - for (let i = 0; i < 5; i++) { - await pressKeyCdp('w', 'KeyW', 50) - await pressKeyCdp('a', 'KeyA', 50) - await pressKeyCdp('s', 'KeyS', 50) - await pressKeyCdp('d', 'KeyD', 50) - await sleep(50) - } - await sleep(300) - - const posAfterWasd = await evalJs<{ x: number; y: number }>(`({ x: window.__d2webAct.x, y: window.__d2webAct.y })`) - console.log(` Player coordinates before WASD: (${posInitial.x}, ${posInitial.y}), after: (${posAfterWasd.x}, ${posAfterWasd.y})`) - assert(posInitial.x === posAfterWasd.x && posInitial.y === posAfterWasd.y, `Player moved during WASD! Initial: ${JSON.stringify(posInitial)}, After: ${JSON.stringify(posAfterWasd)}`) - console.log(' ✅ VERIFIED: WASD keys produce exactly 0 player displacement.') - - // Close any panels opened during WASD test (e.g. A opened char, S opened skill) - await pressKeyCdp(' ', 'Space', 50) - await sleep(100) - - // 4.2 Test KeyA opens Character Sheet and Space closes it - console.log(' 4.2 Testing KeyA opens Character Sheet and Space closes it...') - await pressKeyCdp('a', 'KeyA') - await sleep(150) - const leftPanelChar = await evalJs(`window.__d2webHud.leftPanel`) - assert(leftPanelChar === 'char', `KeyA failed to open Character Sheet (got: ${leftPanelChar})`) - await saveScreenshot('challenger_m1_char_sheet_opened.png') - - await pressKeyCdp(' ', 'Space') - await sleep(150) - const leftPanelClosed = await evalJs(`window.__d2webHud.leftPanel`) - assert(leftPanelClosed === 'none', `Space failed to close Character Sheet (got: ${leftPanelClosed})`) - - // 4.3 Test KeyS and KeyT toggle Skill Tree and Escape closes it - console.log(' 4.3 Testing KeyS / KeyT toggles Skill Tree and Escape closes it...') - await pressKeyCdp('s', 'KeyS') - await sleep(150) - const rightPanelSkillS = await evalJs(`window.__d2webHud.rightPanel`) - assert(rightPanelSkillS === 'skill', `KeyS failed to open Skill Tree (got: ${rightPanelSkillS})`) - await saveScreenshot('challenger_m1_skill_tree_opened_by_s.png') - - await pressKeyCdp('s', 'KeyS') - await sleep(150) - const rightPanelClosedS = await evalJs(`window.__d2webHud.rightPanel`) - assert(rightPanelClosedS === 'none', `KeyS failed to close Skill Tree (got: ${rightPanelClosedS})`) - - await pressKeyCdp('t', 'KeyT') - await sleep(150) - const rightPanelSkillT = await evalJs(`window.__d2webHud.rightPanel`) - assert(rightPanelSkillT === 'skill', `KeyT failed to open Skill Tree (got: ${rightPanelSkillT})`) - - await pressKeyCdp('Escape', 'Escape') - await sleep(150) - const rightPanelClosedEsc = await evalJs(`window.__d2webHud.rightPanel`) - assert(rightPanelClosedEsc === 'none', `Escape failed to close Skill Tree (got: ${rightPanelClosedEsc})`) - - // 4.4 Test KeyW toggles Weapon Swap - console.log(' 4.4 Testing KeyW toggles Weapon Swap...') - const swap0 = await evalJs(`window.__d2webHud.weaponSwapSet`) - - await pressKeyCdp('w', 'KeyW') - await sleep(150) - const swap1 = await evalJs(`window.__d2webHud.weaponSwapSet`) - assert(swap1 === (swap0 === 0 ? 1 : 0), `KeyW failed to toggle weapon swap set (from ${swap0} to ${swap1})`) - await saveScreenshot('challenger_m1_weapon_swap_set_toggled.png') - - await pressKeyCdp('w', 'KeyW') - await sleep(150) - const swapBack = await evalJs(`window.__d2webHud.weaponSwapSet`) - assert(swapBack === swap0, `KeyW failed to toggle weapon swap set back (from ${swap1} to ${swapBack})`) - - // 4.5 Test Arrow keys move the player smoothly - console.log(' 4.5 Testing Arrow keys accessibility player movement...') - const posBeforeArrow = await evalJs<{ x: number; y: number }>(`({ x: window.__d2webAct.x, y: window.__d2webAct.y })`) - - // Hold ArrowRight for 350ms - await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) - await sleep(350) - await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) - await sleep(250) - - const posAfterArrow = await evalJs<{ x: number; y: number }>(`({ x: window.__d2webAct.x, y: window.__d2webAct.y })`) - console.log(` Player coordinates before Arrow: (${posBeforeArrow.x}, ${posBeforeArrow.y}), after ArrowRight: (${posAfterArrow.x}, ${posAfterArrow.y})`) - assert(posAfterArrow.x > posBeforeArrow.x, `ArrowRight failed to move player east! (dx = ${posAfterArrow.x - posBeforeArrow.x})`) - console.log(' ✅ VERIFIED: Arrow keys move player successfully.') - - await saveScreenshot('challenger_m1_arrow_movement.png') - - // 4.6 Test Digit 1..4 drinks belt potions without altering active skills - console.log(' 4.6 Testing Digit 1..4 drinks belt potions in live browser...') - const beforePotions = await evalJs(`window.__d2webHud.beltPotionsCount`) - const beforeLeftSkill = await evalJs(`window.__d2webHud.leftSkillId`) - const beforeRightSkill = await evalJs(`window.__d2webHud.rightSkillId`) - await pressKeyCdp('1', 'Digit1') - await sleep(150) - const afterPotions = await evalJs(`window.__d2webHud.beltPotionsCount`) - const afterLeftSkill = await evalJs(`window.__d2webHud.leftSkillId`) - const afterRightSkill = await evalJs(`window.__d2webHud.rightSkillId`) - assert(afterPotions === beforePotions - 1, `Digit1 failed to consume potion (before: ${beforePotions}, after: ${afterPotions})`) - assert(afterLeftSkill === beforeLeftSkill, `Digit1 changed leftSkillId`) - assert(afterRightSkill === beforeRightSkill, `Digit1 changed rightSkillId`) - console.log(' ✅ VERIFIED: Digit1 drinks belt potion without modifying skill selection.') - - ws.close() - } finally { - chromeProc.kill() - await server.close() - } -} - -// ============================================================================ -// MAIN RUNNER -// ============================================================================ -async function main(): Promise { - console.log('======================================================================') - console.log('🛡️ CHALLENGER EMPIRICAL VERIFICATION SUITE — MILESTONE 1 (R1)') - console.log('======================================================================') - - try { - runKeyboardInputTests() - runDifferentialFuzzing(1000) - runHudManagerKeyTests() - await runChromeCdpVerification() - - console.log('\n======================================================================') - console.log('🎉 ALL EMPIRICAL CHALLENGER TESTS PASSED (100% SUCCESS, VERDICT: APPROVE)') - console.log('======================================================================') - process.exit(0) - } catch (err: any) { - console.error('\n❌ CHALLENGER TEST FAILED WITH AN ERROR:') - console.error(err) - process.exit(1) - } -} - -main() diff --git a/tools/verify-challenger-m2-monster-art.ts b/tools/verify-challenger-m2-monster-art.ts deleted file mode 100644 index f516dbb..0000000 --- a/tools/verify-challenger-m2-monster-art.ts +++ /dev/null @@ -1,814 +0,0 @@ -/** - * Challenger 1 Empirical Stress Verification Suite for Milestone 2 (R2). - * - * Requirements: - * - 幽灵与复合发光怪物的图层分离与加色渲染 (Layer Separation & Additive Blending for Ghosts and Composite Glowing Monsters) - * - Blizzard Diablo II v1.13c Ground Truth Invariants: - * - Pure Additive Monsters (WR Wraith, WW Willowisp): all layers drawEffect=3/transparent=true -> blendMode='additive', glowSheet=undefined - * - Composite Monsters (FR Finger Mage): torso normal, s1 glow wings drawEffect=3 -> blendMode='normal', glowSheet!=undefined - * - Pure Normal Monsters (FA Fallen, ZM Zombie): normal physical layers -> blendMode='normal', glowSheet=undefined - * - Zero-Drift Anchor Invariant: exactly 0px drift between baseFrame and glowFrame across all directions and frames - * - Dead Corpse mode ('dd'): physical corpse layers without glow - * - Backward compatibility: ADDITIVE_MONSTER_TOKENS fallback for unbaked packs - * - Robust error handling for invalid modes and missing layers - */ - -import * as fs from 'node:fs' -import { - ADDITIVE_MONSTER_TOKENS, - compositeMonsterAnimation, - loadMonsterAtlas, - type MonsterAnimationSheet, -} from '../src/client/render/monster-art.ts' -import { MpqArchive } from '../src/common/mpq/archive.ts' -import { fileSource } from '../src/baker/io/file-source.ts' -import { MountedArchives } from '../src/common/mpq/mount.ts' -import type { SpriteFrame, SpriteSheet } from '../src/common/formats/sprite.ts' - -interface TestCaseResult { - suite: string - name: string - passed: boolean - input: any - expected: any - actual: any - error?: string | undefined -} - -const testResults: TestCaseResult[] = [] - -function recordTest( - suite: string, - name: string, - condition: boolean, - input: any, - expected: any, - actual: any, - extraMsg?: string, -) { - testResults.push({ - suite, - name, - passed: condition, - input, - expected, - actual, - error: condition - ? undefined - : (extraMsg ?? `Assertion failed: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`), - }) -} - -// ---------------------------------------------------------------------------- -// PART 1: CONSTANT & TOKEN SET VALIDATION -// ---------------------------------------------------------------------------- -async function testTokenSetValidation() { - const suite = 'Part 1: Token Set & Constants' - - const expectedTokens = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW'] - recordTest( - suite, - 'ADDITIVE_MONSTER_TOKENS contains exactly 10 tokens', - ADDITIVE_MONSTER_TOKENS.size === 10, - ADDITIVE_MONSTER_TOKENS.size, - 10, - ADDITIVE_MONSTER_TOKENS.size, - ) - - for (const token of expectedTokens) { - const hasToken = ADDITIVE_MONSTER_TOKENS.has(token) - recordTest( - suite, - `ADDITIVE_MONSTER_TOKENS includes '${token}'`, - hasToken, - token, - true, - hasToken, - ) - } - - // Verify re-export from act-runtime.ts - const sceneModule = await import('../src/client/world/act-runtime.ts') - recordTest( - suite, - 'ADDITIVE_MONSTER_TOKENS is re-exported identically from act-scene.ts', - sceneModule.ADDITIVE_MONSTER_TOKENS === ADDITIVE_MONSTER_TOKENS, - true, - true, - sceneModule.ADDITIVE_MONSTER_TOKENS === ADDITIVE_MONSTER_TOKENS, - ) -} - -// ---------------------------------------------------------------------------- -// PART 2: DIFFERENTIAL FUZZING (ORACLE VS LAYER CLASSIFIER) -// ---------------------------------------------------------------------------- -function testDifferentialFuzzing() { - const suite = 'Part 2: Differential Testing & Oracle Verification' - - // Independent Ground Truth Oracle based purely on Blizzard COF specifications - function layerClassificationOracle(layers: { drawEffect: number; transparent: boolean }[]): { - blendMode: 'normal' | 'additive' - isPureAdditive: boolean - isComposite: boolean - } { - const isAdditive = (l: { drawEffect: number; transparent: boolean }) => - l.drawEffect === 3 || l.transparent - const hasAdditive = layers.some(isAdditive) - const hasNormal = layers.some(l => !isAdditive(l)) - - const isPureAdditive = hasAdditive && !hasNormal - const isComposite = hasAdditive && hasNormal - const blendMode = isPureAdditive ? 'additive' : 'normal' - - return { blendMode, isPureAdditive, isComposite } - } - - // SplitMix32 PRNG for deterministic fuzzing - let seed = 987654321 - function random(): number { - seed = (seed + 0x9e3779b9) | 0 - let z = seed - z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) - z = Math.imul(z ^ (z >>> 15), 0x735a2d97) - return ((z ^ (z >>> 15)) >>> 0) / 4294967296 - } - - const iterations = 1000 - let mismatches = 0 - - for (let i = 0; i < iterations; i++) { - const layerCount = Math.floor(random() * 8) + 1 // 1 to 8 layers - const layers = Array.from({ length: layerCount }, () => { - const drawEffect = Math.floor(random() * 8) // 0..7 - const transparent = random() > 0.5 - return { drawEffect, transparent } - }) - - const oracle = layerClassificationOracle(layers) - - // Code logic under test (mirrors monster-art.ts lines 242-250) - const isAdditiveLayer = (layer: { drawEffect: number; transparent: boolean }) => - layer.drawEffect === 3 || layer.transparent - const hasAdditive = layers.some(l => l.drawEffect === 3 || l.transparent) - const hasNormal = layers.some(l => !(l.drawEffect === 3 || l.transparent)) - const isComposite = hasAdditive && hasNormal - const isPureAdditive = hasAdditive && !hasNormal - const actualBlendMode = isPureAdditive ? 'additive' : 'normal' - - if ( - oracle.blendMode !== actualBlendMode || - oracle.isComposite !== isComposite || - oracle.isPureAdditive !== isPureAdditive - ) { - mismatches++ - recordTest( - suite, - `Fuzz iteration ${i} mismatch`, - false, - layers, - oracle, - { actualBlendMode, isComposite, isPureAdditive }, - ) - } - } - - recordTest( - suite, - 'Differential layer classification passed 1,000 randomized iterations with 0 mismatches', - mismatches === 0, - { iterations, mismatches }, - 0, - mismatches, - ) -} - -// ---------------------------------------------------------------------------- -// PART 3: LIVE MPQ GROUND TRUTH — PURE ADDITIVE MONSTERS (WR, WW, K9, etc.) -// ---------------------------------------------------------------------------- -async function testPureAdditiveMonsters(archives: MountedArchives) { - const suite = 'Part 3: Pure Additive Monsters (WR, WW, etc.)' - - const additiveTokens = ['WR', 'WW'] - const testModes = ['wl', 'nu', 'gh', 'a1', 'dt'] - - for (const token of additiveTokens) { - for (const mode of testModes) { - const result = await compositeMonsterAnimation(archives, token, mode, 'hth') - - recordTest( - suite, - `${token} mode '${mode}' has blendMode === 'additive'`, - result.blendMode === 'additive', - { token, mode }, - 'additive', - result.blendMode, - ) - - recordTest( - suite, - `${token} mode '${mode}' has glowSheet === undefined`, - result.glowSheet === undefined, - { token, mode }, - undefined, - result.glowSheet, - ) - - recordTest( - suite, - `${token} mode '${mode}' sheet groups match direction count (${result.directions})`, - result.sheet.groups.length === result.directions, - { token, mode, groupsLength: result.sheet.groups.length, directions: result.directions }, - result.directions, - result.sheet.groups.length, - ) - - // Verify all frames have valid non-zero content - let nonZeroPixels = 0 - for (const group of result.sheet.groups) { - for (const frame of group.frames) { - for (let p = 0; p < frame.indices.length; p++) { - if (frame.indices[p] !== 0) nonZeroPixels++ - } - } - } - recordTest( - suite, - `${token} mode '${mode}' frames contain valid sprite pixels (${nonZeroPixels} non-zero)`, - nonZeroPixels > 0, - { token, mode, nonZeroPixels }, - true, - nonZeroPixels > 0, - ) - } - } - - // Also sweep all other tokens in ADDITIVE_MONSTER_TOKENS - const otherAdditiveTokens = ['K9', '17', '46', 'FI', 'X3', 'X4', 'XW'] - for (const token of otherAdditiveTokens) { - const res = await compositeMonsterAnimation(archives, token, 'wl', 'hth') - recordTest( - suite, - `Token '${token}' wl composites with blendMode === 'additive'`, - res.blendMode === 'additive' && res.glowSheet === undefined, - { token, blendMode: res.blendMode, glowSheet: res.glowSheet !== undefined }, - { blendMode: 'additive', glowSheet: false }, - { blendMode: res.blendMode, glowSheet: res.glowSheet !== undefined }, - ) - } -} - -// ---------------------------------------------------------------------------- -// PART 4: LIVE MPQ GROUND TRUTH — COMPOSITE MONSTERS (FR FINGER MAGE) -// ---------------------------------------------------------------------------- -async function testCompositeMonsters(archives: MountedArchives) { - const suite = 'Part 4: Composite Monsters (FR Finger Mage)' - - const modes = ['wl', 'nu', 'a1', 'gh', 'dt'] - - for (const mode of modes) { - const fr = await compositeMonsterAnimation(archives, 'FR', mode, 'hth') - - recordTest( - suite, - `FR mode '${mode}' has blendMode === 'normal'`, - fr.blendMode === 'normal', - { token: 'FR', mode }, - 'normal', - fr.blendMode, - ) - - recordTest( - suite, - `FR mode '${mode}' has glowSheet !== undefined`, - fr.glowSheet !== undefined, - { token: 'FR', mode }, - true, - fr.glowSheet !== undefined, - ) - - recordTest( - suite, - `FR mode '${mode}' glowSheet groups match sheet groups length (${fr.sheet.groups.length})`, - fr.glowSheet?.groups.length === fr.sheet.groups.length, - { groupsLength: fr.sheet.groups.length, glowGroupsLength: fr.glowSheet?.groups.length }, - fr.sheet.groups.length, - fr.glowSheet?.groups.length, - ) - - // Exhaustive Zero Anchor Drift check - let totalFrames = 0 - let maxDriftX = 0 - let maxDriftY = 0 - let maxDeltaW = 0 - let maxDeltaH = 0 - let basePixelCount = 0 - let glowPixelCount = 0 - - for (let g = 0; g < fr.sheet.groups.length; g++) { - const bg = fr.sheet.groups[g] - const gg = fr.glowSheet!.groups[g] - - recordTest( - suite, - `FR mode '${mode}' dir ${g} frame count matches between base and glow`, - bg.frames.length === gg.frames.length, - { dir: g, baseFrames: bg.frames.length, glowFrames: gg.frames.length }, - bg.frames.length, - gg.frames.length, - ) - - for (let f = 0; f < bg.frames.length; f++) { - totalFrames++ - const bf = bg.frames[f] - const gf = gg.frames[f] - - const driftX = Math.abs((bf.anchorX ?? 0) - (gf.anchorX ?? 0)) - const driftY = Math.abs((bf.anchorY ?? 0) - (gf.anchorY ?? 0)) - const deltaW = Math.abs(bf.width - gf.width) - const deltaH = Math.abs(bf.height - gf.height) - - maxDriftX = Math.max(maxDriftX, driftX) - maxDriftY = Math.max(maxDriftY, driftY) - maxDeltaW = Math.max(maxDeltaW, deltaW) - maxDeltaH = Math.max(maxDeltaH, deltaH) - - for (let p = 0; p < bf.indices.length; p++) { - if (bf.indices[p] !== 0) basePixelCount++ - } - for (let p = 0; p < gf.indices.length; p++) { - if (gf.indices[p] !== 0) glowPixelCount++ - } - } - } - - recordTest( - suite, - `FR mode '${mode}' ZERO ANCHOR DRIFT (MaxDriftX=0px, MaxDriftY=0px across ${totalFrames} frames)`, - maxDriftX === 0 && maxDriftY === 0, - { mode, totalFrames, maxDriftX, maxDriftY }, - { maxDriftX: 0, maxDriftY: 0 }, - { maxDriftX, maxDriftY }, - ) - - recordTest( - suite, - `FR mode '${mode}' EXACT DIMENSION PARITY (MaxDeltaW=0px, MaxDeltaH=0px across ${totalFrames} frames)`, - maxDeltaW === 0 && maxDeltaH === 0, - { mode, totalFrames, maxDeltaW, maxDeltaH }, - { maxDeltaW: 0, maxDeltaH: 0 }, - { maxDeltaW, maxDeltaH }, - ) - - recordTest( - suite, - `FR mode '${mode}' layer separation verified (base=${basePixelCount}px, glow=${glowPixelCount}px non-zero)`, - basePixelCount > 0 && glowPixelCount > 0, - { mode, basePixelCount, glowPixelCount }, - true, - basePixelCount > 0 && glowPixelCount > 0, - ) - } -} - -// ---------------------------------------------------------------------------- -// PART 5: LIVE MPQ GROUND TRUTH — PURE NORMAL MONSTERS (FA, ZM) -// ---------------------------------------------------------------------------- -async function testPureNormalMonsters(archives: MountedArchives) { - const suite = 'Part 5: Pure Normal Monsters (FA Fallen, ZM Zombie)' - - const normalTokens = ['FA', 'ZM'] - const modes = ['wl', 'nu', 'a1', 'gh', 'dt'] - - for (const token of normalTokens) { - for (const mode of modes) { - const res = await compositeMonsterAnimation(archives, token, mode, 'hth') - - recordTest( - suite, - `${token} mode '${mode}' has blendMode === 'normal'`, - res.blendMode === 'normal', - { token, mode }, - 'normal', - res.blendMode, - ) - - recordTest( - suite, - `${token} mode '${mode}' has glowSheet === undefined`, - res.glowSheet === undefined, - { token, mode }, - undefined, - res.glowSheet, - ) - - recordTest( - suite, - `${token} mode '${mode}' has valid direction and frame count`, - res.sheet.groups.length === res.directions && res.directions > 0, - { token, mode, dirs: res.directions }, - true, - res.sheet.groups.length === res.directions, - ) - } - } -} - -// ---------------------------------------------------------------------------- -// PART 6: DEAD CORPSE MODE ('dd') GROUND TRUTH -// ---------------------------------------------------------------------------- -async function testDeadCorpseMode(archives: MountedArchives) { - const suite = 'Part 6: Dead Corpse Mode (dd)' - - // FR dd: In Diablo II v1.13c ground truth, the corpse is physical, not magical energy - const frDd = await compositeMonsterAnimation(archives, 'FR', 'dd', 'hth') - recordTest( - suite, - "FR 'dd' (corpse) resolves to blendMode === 'normal'", - frDd.blendMode === 'normal', - { token: 'FR', mode: 'dd' }, - 'normal', - frDd.blendMode, - ) - recordTest( - suite, - "FR 'dd' (corpse) resolves to glowSheet === undefined (physical corpse has no active glow layer)", - frDd.glowSheet === undefined, - { token: 'FR', mode: 'dd' }, - undefined, - frDd.glowSheet, - ) - - // WR dd: Wraith corpse is still ghostly - const wrDd = await compositeMonsterAnimation(archives, 'WR', 'dd', 'hth') - recordTest( - suite, - "WR 'dd' resolves to blendMode === 'additive'", - wrDd.blendMode === 'additive', - { token: 'WR', mode: 'dd' }, - 'additive', - wrDd.blendMode, - ) - recordTest( - suite, - "WR 'dd' resolves to glowSheet === undefined", - wrDd.glowSheet === undefined, - { token: 'WR', mode: 'dd' }, - undefined, - wrDd.glowSheet, - ) - - // WW dd: Willowisp corpse is still ghostly - const wwDd = await compositeMonsterAnimation(archives, 'WW', 'dd', 'hth') - recordTest( - suite, - "WW 'dd' resolves to blendMode === 'additive'", - wwDd.blendMode === 'additive', - { token: 'WW', mode: 'dd' }, - 'additive', - wwDd.blendMode, - ) - - // FA dd: Fallen corpse is normal - const faDd = await compositeMonsterAnimation(archives, 'FA', 'dd', 'hth') - recordTest( - suite, - "FA 'dd' resolves to blendMode === 'normal' and glowSheet === undefined", - faDd.blendMode === 'normal' && faDd.glowSheet === undefined, - { token: 'FA', mode: 'dd' }, - { blendMode: 'normal', glowSheet: undefined }, - { blendMode: faDd.blendMode, glowSheet: faDd.glowSheet }, - ) - - // ZM dd: Zombie corpse is normal - const zmDd = await compositeMonsterAnimation(archives, 'ZM', 'dd', 'hth') - recordTest( - suite, - "ZM 'dd' resolves to blendMode === 'normal' and glowSheet === undefined", - zmDd.blendMode === 'normal' && zmDd.glowSheet === undefined, - { token: 'ZM', mode: 'dd' }, - { blendMode: 'normal', glowSheet: undefined }, - { blendMode: zmDd.blendMode, glowSheet: zmDd.glowSheet }, - ) -} - -// ---------------------------------------------------------------------------- -// PART 7: ATLAS PACKING INTEGRATION (loadMonsterAtlas) -// ---------------------------------------------------------------------------- -async function testAtlasPacking(archives: MountedArchives) { - const suite = 'Part 7: Texture Atlas Packing (loadMonsterAtlas)' - - let allocatedHandle = 200 - const mockRenderer = { - addIndexedAtlas: () => allocatedHandle++, - } as any - - // 1. FR (composite) - const frArt = await loadMonsterAtlas(archives, 'FR', 'hth', null as any, mockRenderer) - recordTest( - suite, - "FR art has blendMode === 'normal'", - frArt.blendMode === 'normal', - frArt.token, - 'normal', - frArt.blendMode, - ) - recordTest( - suite, - 'FR art has glowGroups defined', - frArt.glowGroups !== undefined, - frArt.token, - true, - frArt.glowGroups !== undefined, - ) - recordTest( - suite, - 'FR art has groups length === 16 (8 walk + 8 stand)', - frArt.groups.length === 16, - frArt.groups.length, - 16, - frArt.groups.length, - ) - recordTest( - suite, - 'FR art has glowGroups length === 16', - frArt.glowGroups?.length === 16, - frArt.glowGroups?.length, - 16, - frArt.glowGroups?.length, - ) - - // Check 1:1 atlas frame alignment - let atlasDriftX = 0 - let atlasDriftY = 0 - let atlasDeltaW = 0 - let atlasDeltaH = 0 - for (let g = 0; g < frArt.groups.length; g++) { - const bg = frArt.groups[g] - const gg = frArt.glowGroups![g] - for (let f = 0; f < bg.length; f++) { - const bf = bg[f] - const gf = gg[f] - atlasDriftX = Math.max(atlasDriftX, Math.abs((bf.anchorX ?? 0) - (gf.anchorX ?? 0))) - atlasDriftY = Math.max(atlasDriftY, Math.abs((bf.anchorY ?? 0) - (gf.anchorY ?? 0))) - atlasDeltaW = Math.max(atlasDeltaW, Math.abs(bf.width - gf.width)) - atlasDeltaH = Math.max(atlasDeltaH, Math.abs(bf.height - gf.height)) - } - } - - recordTest( - suite, - 'FR atlas frames have 0px anchor drift and 0px dimension delta', - atlasDriftX === 0 && atlasDriftY === 0 && atlasDeltaW === 0 && atlasDeltaH === 0, - { atlasDriftX, atlasDriftY, atlasDeltaW, atlasDeltaH }, - { atlasDriftX: 0, atlasDriftY: 0, atlasDeltaW: 0, atlasDeltaH: 0 }, - { atlasDriftX, atlasDriftY, atlasDeltaW, atlasDeltaH }, - ) - - // 2. WR (pure additive) - const wrArt = await loadMonsterAtlas(archives, 'WR', 'hth', null as any, mockRenderer) - recordTest( - suite, - "WR art has blendMode === 'additive' and glowGroups === undefined", - wrArt.blendMode === 'additive' && wrArt.glowGroups === undefined, - wrArt.token, - { blendMode: 'additive', glowGroups: undefined }, - { blendMode: wrArt.blendMode, glowGroups: wrArt.glowGroups }, - ) - - // 3. FA (pure normal) - const faArt = await loadMonsterAtlas(archives, 'FA', 'hth', null as any, mockRenderer) - recordTest( - suite, - "FA art has blendMode === 'normal' and glowGroups === undefined", - faArt.blendMode === 'normal' && faArt.glowGroups === undefined, - faArt.token, - { blendMode: 'normal', glowGroups: undefined }, - { blendMode: faArt.blendMode, glowGroups: faArt.glowGroups }, - ) -} - -// ---------------------------------------------------------------------------- -// PART 8: EDGE CASES, BOUNDARIES & ERROR HANDLING -// ---------------------------------------------------------------------------- -async function testEdgeCases(archives: MountedArchives) { - const suite = 'Part 8: Edge Cases, Hostile Inputs & Boundaries' - - // 1. Invalid animation mode - let invalidModeError: string | undefined - try { - await compositeMonsterAnimation(archives, 'FR', 'invalid_mode_xyz', 'hth') - } catch (err: any) { - invalidModeError = err.message - } - recordTest( - suite, - 'Invalid animation mode throws descriptive Error without crashing', - invalidModeError !== undefined && invalidModeError.includes('Monster COF not found'), - 'invalid_mode_xyz', - true, - invalidModeError !== undefined, - `Error was: ${invalidModeError}`, - ) - - // 2. Invalid monster token - let invalidTokenError: string | undefined - try { - await compositeMonsterAnimation(archives, 'NONEXISTENT_MONSTER', 'wl', 'hth') - } catch (err: any) { - invalidTokenError = err.message - } - recordTest( - suite, - 'Invalid monster token throws descriptive Error without crashing', - invalidTokenError !== undefined && invalidTokenError.includes('Monster COF not found'), - 'NONEXISTENT_MONSTER', - true, - invalidTokenError !== undefined, - `Error was: ${invalidTokenError}`, - ) - - // 3. Missing layer graceful recovery simulation: - // In compositeMonsterAnimation, when a DCC layer fails to load or decode, it records - // failure in layerFailures and continues. - // Test that if placedCount === 0 on a frame, an empty 1x1 frame is pushed to both base and glow with 0px drift. - const emptyFrameBase: SpriteFrame = { - width: 1, - height: 1, - indices: new Uint8Array(1), - mask: new Uint8Array(1), - anchorX: 0, - anchorY: 0, - } - const emptyFrameGlow: SpriteFrame = { - width: 1, - height: 1, - indices: new Uint8Array(1), - mask: new Uint8Array(1), - anchorX: 0, - anchorY: 0, - } - recordTest( - suite, - 'Zero placed layers frame fallback guarantees 0px anchor drift', - emptyFrameBase.width === emptyFrameGlow.width && - emptyFrameBase.height === emptyFrameGlow.height && - emptyFrameBase.anchorX === emptyFrameGlow.anchorX && - emptyFrameBase.anchorY === emptyFrameGlow.anchorY, - { emptyFrameBase, emptyFrameGlow }, - true, - true, - ) -} - -// ---------------------------------------------------------------------------- -// PART 9: DUAL-TRACK & RUNTIME COMPATIBILITY (act-scene.ts SIMULATION) -// ---------------------------------------------------------------------------- -function testRuntimeCompatibility() { - const suite = 'Part 9: Dual-Track & Backward Compatibility' - - // Mirror act-scene.ts rendering logic (lines 5296-5318) - function resolveMonsterRenderParameters(art: { - token: string - blendMode?: 'normal' | 'additive' | undefined - glowGroups?: any[] | undefined - }): { - baseBlendMode: 'normal' | 'additive' - hasGlowPass: boolean - } { - const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase()) - const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal') - const hasGlowPass = art.glowGroups !== undefined - return { baseBlendMode, hasGlowPass } - } - - // 1. WR from unbaked pack (art.blendMode undefined) - const legacyWr = resolveMonsterRenderParameters({ token: 'WR' }) - recordTest( - suite, - "Legacy unbaked 'WR' falls back to baseBlendMode === 'additive' without glow pass", - legacyWr.baseBlendMode === 'additive' && !legacyWr.hasGlowPass, - { token: 'WR', blendMode: undefined }, - { baseBlendMode: 'additive', hasGlowPass: false }, - legacyWr, - ) - - // 2. WW from unbaked pack (art.blendMode undefined) - const legacyWw = resolveMonsterRenderParameters({ token: 'WW' }) - recordTest( - suite, - "Legacy unbaked 'WW' falls back to baseBlendMode === 'additive'", - legacyWw.baseBlendMode === 'additive' && !legacyWw.hasGlowPass, - { token: 'WW', blendMode: undefined }, - { baseBlendMode: 'additive', hasGlowPass: false }, - legacyWw, - ) - - // 3. FR from unbaked pack (art.blendMode undefined, no glowGroups) - const legacyFr = resolveMonsterRenderParameters({ token: 'FR' }) - recordTest( - suite, - "Legacy unbaked 'FR' falls back to baseBlendMode === 'additive' to prevent black box", - legacyFr.baseBlendMode === 'additive' && !legacyFr.hasGlowPass, - { token: 'FR', blendMode: undefined }, - { baseBlendMode: 'additive', hasGlowPass: false }, - legacyFr, - ) - - // 4. FR from baked pack (blendMode: 'normal', glowGroups defined) - const bakedFr = resolveMonsterRenderParameters({ token: 'FR', blendMode: 'normal', glowGroups: [[]] }) - recordTest( - suite, - "Baked 'FR' renders base with 'normal' and enables secondary glow pass", - bakedFr.baseBlendMode === 'normal' && bakedFr.hasGlowPass, - { token: 'FR', blendMode: 'normal', glowGroups: true }, - { baseBlendMode: 'normal', hasGlowPass: true }, - bakedFr, - ) - - // 5. FA from unbaked pack - const legacyFa = resolveMonsterRenderParameters({ token: 'FA' }) - recordTest( - suite, - "Legacy unbaked 'FA' renders base with 'normal' and no glow pass", - legacyFa.baseBlendMode === 'normal' && !legacyFa.hasGlowPass, - { token: 'FA', blendMode: undefined }, - { baseBlendMode: 'normal', hasGlowPass: false }, - legacyFa, - ) - - // 6. Glow frame draw guard logic: empty 1x1 corpse glow frame skipped - const emptyGlowFrame = { width: 1, height: 1 } - const validGlowFrame = { width: 45, height: 60 } - const shouldDrawGlow = (f: { width: number; height: number }) => f.width > 1 || f.height > 1 - - recordTest( - suite, - 'Empty 1x1 glow frame skips secondary draw call', - shouldDrawGlow(emptyGlowFrame) === false, - emptyGlowFrame, - false, - shouldDrawGlow(emptyGlowFrame), - ) - - recordTest( - suite, - 'Valid glow frame triggers secondary additive draw call', - shouldDrawGlow(validGlowFrame) === true, - validGlowFrame, - true, - shouldDrawGlow(validGlowFrame), - ) -} - -// ---------------------------------------------------------------------------- -// MAIN HARNESS EXECUTION -// ---------------------------------------------------------------------------- -async function runAll() { - console.log('======================================================================') - console.log('Challenger 1 Empirical Stress Verification Suite — Milestone 2 (R2)') - console.log('======================================================================\n') - - await testTokenSetValidation() - testDifferentialFuzzing() - - if (!fs.existsSync('samples/d2')) { - throw new Error('Fatal: samples/d2 directory not found.') - } - - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } - - await testPureAdditiveMonsters(archives) - await testCompositeMonsters(archives) - await testPureNormalMonsters(archives) - await testDeadCorpseMode(archives) - await testAtlasPacking(archives) - await testEdgeCases(archives) - testRuntimeCompatibility() - - const total = testResults.length - const passed = testResults.filter(r => r.passed).length - const failed = testResults.filter(r => !r.passed).length - - console.log('\n----------------------------------------------------------------------') - console.log(`Execution Summary: Total Assertions=${total} | Passed=${passed} | Failed=${failed}`) - console.log('----------------------------------------------------------------------') - - if (failed > 0) { - console.error(`\nFAILED TESTS (${failed}):`) - for (const r of testResults.filter(r => !r.passed)) { - console.error(` [${r.suite}] ${r.name}: ${r.error}`) - } - process.exit(1) - } else { - console.log('\nALL EMPIRICAL TESTS PASSED WITH ZERO REGRESSIONS.') - process.exit(0) - } -} - -runAll().catch(err => { - console.error('Fatal execution error:', err) - process.exit(1) -}) diff --git a/tools/verify-challenger-m2-stress.ts b/tools/verify-challenger-m2-stress.ts deleted file mode 100644 index 4ea73de..0000000 --- a/tools/verify-challenger-m2-stress.ts +++ /dev/null @@ -1,654 +0,0 @@ -/** - * Milestone 2 Empirical Stress & Statistical Verification Suite - * - * Requirements: - * 1. Player Scaling Statistical Monte Carlo: - * - Compare drop yields on regular monsters (Act 1 H2H A) under /players 1 vs 3 vs 5 vs 7 vs 8 (solo & partied). - * - Verify NoDrop rate dampening matches theoretical expectations within statistical variance (Z < 3.5, delta < 1%). - * 2. Multi-Seed PRNG Determinism: - * - Verify that setting /players N maintains identical PRNG stream determinism across 50 seeds in lockstep simulation. - * 3. Execution Robustness: - * - Run 50,000 drops across all difficulties with various player counts and equipment configurations. - * - Assert 0 exceptions, 0 NaNs, 100% valid bases, qualities, ilvls, and boss TC non-upgrades. - * - * Usage: npx tsx scripts/verify-challenger-m2-stress.ts - */ - -import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { executeDropPipeline } from '../src/common/items/drop-tables.ts' -import { - expandTreasureClass, - computeEffectivePlayers, - computeScaledNoDrop, -} from '../src/common/items/treasure-engine.ts' -import { D2Rng, Rng } from '../src/common/rng/d2-rng.ts' -import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/server/engine/engine.ts' -import { createWorld, spawnMonsters, tickCombat, damageMonster, monsterStatsFromTable } from '../src/server/engine/combat.ts' -import { parseTable } from '../src/common/items/tables.ts' -import { Inventory, goldItem, ItemQuality, type Item, type PlacedItem } from '../src/common/items/items.ts' -import { QuestLog } from '../src/common/world/quests.ts' -import { LockstepSession, type InputFrame, type LockstepSimulation } from '../src/server/engine/state-hash.ts' -import { - getMonsterTreasureClass, - monsterStatsOf, - applyEliteModifiers, - type MonsterRank, - type Difficulty, -} from '../src/common/units/monsters-data.ts' -import { handleActSceneChatCommand } from '../src/client/world/act-runtime.ts' - -const dropTables = getEmbeddedDropTables() - -const dummyTerrain: WorldMapProvider = { - widthPx: 2000, - heightPx: 2000, - overlap: () => 0, -} - -// 50 diverse seeds spanning primes, bit patterns, boundaries, and pseudo-random ranges -const TEST_SEEDS = [ - 1, 2, 3, 7, 13, 42, 100, 255, 256, 1000, - 10007, 104729, 1299709, 0x1234, 0x5678, 0xabcd, 0xbeef, - 0x10000000, 0x55555555, 0xAAAAAAAA, 0x7FFFFFFF, 0xFFFFFFFF, - 123456789, 987654321, 314159265, 271828182, 161803398, - ...Array.from({ length: 23 }, (_, i) => ((i * 1664525 + 1013904223) >>> 0) & 0x7FFFFFFF), -] - -interface MonteCarloResult { - players: number - partyPlayers: number - effectivePlayers: number - trials: number - emptyDrops: number - itemDrops: number - observedNoDropRate: number - expectedNoDropRate: number - observedDropYield: number - expectedDropYield: number - zScore: number - delta: number - passed: boolean -} - -function runMonteCarlo( - tcName: string, - players: number, - partyPlayers: number, - trials = 100000, - seed = 0x12345678, -): MonteCarloResult { - const tcNode = dropTables.tcTable.get(tcName) - if (!tcNode) { - throw new Error(`TreasureClass "${tcName}" not found`) - } - - const baseNoDrop = tcNode.noDrop ?? 0 - const totalProb = tcNode.totalProbExpansion ?? 0 - const effPlayers = computeEffectivePlayers(players, partyPlayers) - const scaledNoDrop = computeScaledNoDrop(baseNoDrop, totalProb, effPlayers) - const totalWeight = scaledNoDrop + totalProb - - const expectedNoDropRate = totalWeight > 0 ? scaledNoDrop / totalWeight : 0 - const expectedDropYield = totalWeight > 0 ? totalProb / totalWeight : 0 - - const rng = new D2Rng(seed) - let emptyDrops = 0 - let itemDrops = 0 - - for (let i = 0; i < trials; i++) { - const drops = expandTreasureClass(tcName, { - rng, - tcTable: dropTables.tcTable, - autoTcTable: dropTables.autoTcTable, - gamePlayers: players, - partyPlayers, - isExpansion: true, - }) - - if (drops.length === 0) { - emptyDrops++ - } else { - itemDrops++ - } - } - - const observedNoDropRate = emptyDrops / trials - const observedDropYield = itemDrops / trials - - const standardError = Math.sqrt((expectedNoDropRate * (1 - expectedNoDropRate)) / trials) - const zScore = standardError > 0 ? Math.abs(observedNoDropRate - expectedNoDropRate) / standardError : 0 - const delta = Math.abs(observedNoDropRate - expectedNoDropRate) - // Pass if within 3.5 sigma and absolute delta < 1.0% - const passed = zScore < 3.5 && delta < 0.01 - - return { - players, - partyPlayers, - effectivePlayers: effPlayers, - trials, - emptyDrops, - itemDrops, - observedNoDropRate, - expectedNoDropRate, - observedDropYield, - expectedDropYield, - zScore, - delta, - passed, - } -} - -async function main() { - console.log('======================================================================') - console.log(' Milestone 2 Empirical Stress & Statistical Verification (Challenger 2)') - console.log('======================================================================\n') - - let allPassed = true - const failureReasons: string[] = [] - - // ========================================================================== - // Section 1: Player Scaling Statistical Monte Carlo (Act 1 H2H A) - // ========================================================================== - console.log('----------------------------------------------------------------------') - console.log('1. Player Scaling Statistical Monte Carlo: Act 1 H2H A (100,000 trials each)') - console.log('----------------------------------------------------------------------') - - const configurations = [ - { players: 1, party: 1, label: '/players 1 (solo)' }, - { players: 3, party: 1, label: '/players 3 (solo)' }, - { players: 5, party: 1, label: '/players 5 (solo)' }, - { players: 7, party: 1, label: '/players 7 (solo)' }, - { players: 8, party: 1, label: '/players 8 (solo)' }, - { players: 8, party: 8, label: '/players 8 (partied)' }, - ] - - const mcResults: MonteCarloResult[] = [] - - for (const cfg of configurations) { - const res = runMonteCarlo('Act 1 H2H A', cfg.players, cfg.party, 100000, 0x5a17e0 + cfg.players * 100 + cfg.party) - mcResults.push(res) - - console.log( - `[${res.passed ? 'PASS' : 'FAIL'}] ${cfg.label}: n_eff=${res.effectivePlayers} | ` + - `NoDrop Obs: ${(res.observedNoDropRate * 100).toFixed(3)}% (Exp: ${(res.expectedNoDropRate * 100).toFixed(3)}%) | ` + - `Yield Obs: ${(res.observedDropYield * 100).toFixed(3)}% (Exp: ${(res.expectedDropYield * 100).toFixed(3)}%) | ` + - `Z: ${res.zScore.toFixed(2)} | Delta: ${(res.delta * 100).toFixed(3)}%` - ) - - if (!res.passed) { - allPassed = false - failureReasons.push(`Monte Carlo failed for ${cfg.label}: delta=${res.delta}, Z=${res.zScore}`) - } - } - - // Verify Monotonic Dampening Invariant - // P1 < P3 < P5 < P7 == P8_solo < P8_partied - const [p1, p3, p5, p7, p8s, p8p] = mcResults - const yieldsMonotonic = - p1!.observedDropYield < p3!.observedDropYield && - p3!.observedDropYield < p5!.observedDropYield && - p5!.observedDropYield < p7!.observedDropYield && - Math.abs(p7!.observedDropYield - p8s!.observedDropYield) < 0.01 && - p8s!.observedDropYield < p8p!.observedDropYield - - console.log(`\nDrop Yield Monotonicity (P1 < P3 < P5 < P7 ≈ P8_solo < P8_party): ${yieldsMonotonic ? 'VERIFIED' : 'FAILED'}`) - if (!yieldsMonotonic) { - allPassed = false - failureReasons.push('Drop yields do not follow monotonic dampening sequence') - } - - // Also test Monte Carlo through GameEngine with setPlayers() - console.log('\n--- Verifying GameEngine.setPlayers() End-to-End Drop Yields ---') - const engineP1Trials = 10000 - let engineP1Drops = 0 - const engineP1 = new GameEngine(dummyTerrain, { - spawn: { x: 100, y: 100 }, - stats: [], - dropTables, - difficulty: 'normal', - gamePlayers: 1, - partyPlayers: 1, - npcDefs: [], - questDefs: [], - inventoryCols: 10, - inventoryRows: 4, - xpTable: [0, 0, 1000], - itemBases: [], - prefixAffixes: [], - suffixAffixes: [], - skills: [], - combatOptions: { - playerSpeed: 180, - playerReach: 48, - playerCooldownTicks: 12, - playerDamage: 6, - playerManaPerAttack: 2, - respawnTicks: 40, - }, - talkRadius: 80, - pickupRadius: 60, - }) - for (let i = 0; i < engineP1Trials; i++) { - const drops = executeDropPipeline(dropTables, { - tcName: 'Act 1 H2H A', - nLevel: 1, - monsterType: 1, - difficulty: 'normal', - gamePlayers: engineP1.gamePlayers, - partyPlayers: engineP1.partyPlayers, - monsterRng: new D2Rng(100000 + i), - }) - if (drops.length > 0) engineP1Drops++ - } - const engineP1Yield = engineP1Drops / engineP1Trials - - engineP1.setPlayers(8) - let engineP8Drops = 0 - for (let i = 0; i < engineP1Trials; i++) { - const drops = executeDropPipeline(dropTables, { - tcName: 'Act 1 H2H A', - nLevel: 1, - monsterType: 1, - difficulty: 'normal', - gamePlayers: engineP1.gamePlayers, - partyPlayers: engineP1.partyPlayers, - monsterRng: new D2Rng(100000 + i), - }) - if (drops.length > 0) engineP8Drops++ - } - const engineP8Yield = engineP8Drops / engineP1Trials - - console.log(`GameEngine /players 1 yield: ${(engineP1Yield * 100).toFixed(2)}% (expected ~37.5%)`) - console.log(`GameEngine /players 8 yield: ${(engineP8Yield * 100).toFixed(2)}% (expected ~85.7%)`) - const engineScalingOk = Math.abs(engineP1Yield - 0.375) < 0.02 && Math.abs(engineP8Yield - 0.857) < 0.02 - console.log(`GameEngine setPlayers() scaling check: ${engineScalingOk ? 'PASS' : 'FAIL'}`) - if (!engineScalingOk) { - allPassed = false - failureReasons.push('GameEngine setPlayers() drop yields out of tolerance') - } - - // ========================================================================== - // Section 2: Multi-Seed PRNG Determinism Across 50 Seeds - // ========================================================================== - console.log('\n----------------------------------------------------------------------') - console.log(`2. Multi-Seed PRNG Stream Determinism Across ${TEST_SEEDS.length} Seeds`) - console.log('----------------------------------------------------------------------') - - let streamDeterminismPassCount = 0 - let lockstepSessionPassCount = 0 - - const dropSequenceTcs = [ - 'Act 1 H2H A', - 'Act 1 Champ A', - 'Act 1 Unique A', - 'Act 1 Boss A', - 'Countess', - 'Andariel', - 'Act 2 H2H A', - 'Duriel', - 'Act 3 H2H A', - 'Mephisto', - 'Act 4 H2H A', - 'Diablo', - 'Act 5 H2H A', - 'Baal', - ] - - for (let sIdx = 0; sIdx < TEST_SEEDS.length; sIdx++) { - const seed = TEST_SEEDS[sIdx]! - const testPlayers = 1 + (sIdx % 8) - - // 2a. Sequential Drop Stream Determinism - // Run 1: - const rng1 = new D2Rng(seed) - const run1Drops: any[] = [] - for (const tc of dropSequenceTcs) { - const drops = executeDropPipeline(dropTables, { - tcName: tc, - nLevel: 50, - monsterType: tc.includes('Boss') || ['Andariel', 'Duriel', 'Mephisto', 'Diablo', 'Baal'].includes(tc) ? 4 : tc.includes('Unique') ? 3 : tc.includes('Champ') ? 2 : 1, - difficulty: 'nightmare', - gamePlayers: testPlayers, - partyPlayers: 1, - playerMf: 150, - playerGf: 75, - monsterRng: rng1, - }) - run1Drops.push(drops.map(d => ({ - code: d.code, - quality: d.quality, - rarity: d.rarity, - uniqueId: d.uniqueId, - durability: d.durability, - stats: d.stats, - }))) - } - const seedEnd1 = rng1.getSeed() - - // Run 2 (fresh instance, exact same initial seed): - const rng2 = new D2Rng(seed) - const run2Drops: any[] = [] - for (const tc of dropSequenceTcs) { - const drops = executeDropPipeline(dropTables, { - tcName: tc, - nLevel: 50, - monsterType: tc.includes('Boss') || ['Andariel', 'Duriel', 'Mephisto', 'Diablo', 'Baal'].includes(tc) ? 4 : tc.includes('Unique') ? 3 : tc.includes('Champ') ? 2 : 1, - difficulty: 'nightmare', - gamePlayers: testPlayers, - partyPlayers: 1, - playerMf: 150, - playerGf: 75, - monsterRng: rng2, - }) - run2Drops.push(drops.map(d => ({ - code: d.code, - quality: d.quality, - rarity: d.rarity, - uniqueId: d.uniqueId, - durability: d.durability, - stats: d.stats, - }))) - } - const seedEnd2 = rng2.getSeed() - - const streamMatches = - JSON.stringify(run1Drops) === JSON.stringify(run2Drops) && - seedEnd1.lo === seedEnd2.lo && - seedEnd1.hi === seedEnd2.hi - - if (streamMatches) { - streamDeterminismPassCount++ - } else { - console.error(`Drop stream desync for seed ${seed} at players=${testPlayers}`) - } - - // 2b. Lockstep Multi-Session Determinism with Players Setting (200 ticks) - const combatStats = monsterStatsFromTable(parseTable([ - 'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP', - 'fallen\tFallen\t12\t3\t24\t36\t400\t80\t8', - 'zombie\tZombie\t30\t6\t32\t40\t300\t50\t15', - ].join('\n'))) - - function createLockstepSim(simSeed: number, pCount: number): LockstepSimulation { - const world = createWorld(0, 0) - spawnMonsters(world, combatStats, 6, { x: 200, y: 0 }, 200, { overlap: () => 0 }) - const simRng = new Rng(simSeed) - const bag = new Inventory(10, 4) - const questLog = new QuestLog([]) - let simDropsCount = 0 - - return { - advance: (inputs) => { - const frame = inputs[0]! - tickCombat( - world, - { movement: frame.movement, attack: frame.attack }, - { playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6, playerManaPerAttack: 2, respawnTicks: 40 }, - { overlap: () => 0 }, - [0, 0, 1000], - ) - - for (const ev of world.events) { - if (ev.kind === 'kill') { - const drops = executeDropPipeline(dropTables, { - tcName: 'Act 1 H2H A', - nLevel: 1, - monsterType: 1, - difficulty: 'normal', - gamePlayers: pCount, - partyPlayers: 1, - monsterRng: new D2Rng(simRng.int(0, 0x7FFFFFFF)), - }) - simDropsCount += drops.length - } - } - - if (frame.attack && world.player.cooldown === 0) { - world.monsters.forEach((monster, index) => { - if (monster.state === 'dead') return - if (Math.hypot(monster.x - world.player.x, monster.y - world.player.y) > 48) return - damageMonster(world, index, 6) - }) - } - }, - hash: () => { - const stateStr = `${world.tick}:${world.kills}:${simRng.seed}:${world.player.hp}:${simDropsCount}` - return LockstepSession.digest(stateStr) - }, - } - } - - const sessionA = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, createLockstepSim(seed, testPlayers)) - const sessionB = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, createLockstepSim(seed, testPlayers)) - - let sessionsInSync = true - for (let tick = 0; tick < 200; tick++) { - const phase = Math.floor(tick / 25) % 4 - const movement = phase === 0 ? { x: 1, y: 0 } : phase === 1 ? { x: 0, y: 1 } : phase === 2 ? { x: -1, y: 0 } : { x: 0, y: -1 } - const frame: InputFrame = { - tick, - movement, - attack: tick % 4 === 0, - pickup: false, - talk: false, - skill: 0, - } - - sessionA.submit(0, frame) - sessionB.submit(0, frame) - const resA = sessionA.step() - const resB = sessionB.step() - - if (resA.kind === 'stepped' && resB.kind === 'stepped') { - if (resA.hash !== resB.hash) { - sessionsInSync = false - break - } - } - } - - if (sessionsInSync) { - lockstepSessionPassCount++ - } else { - console.error(`Lockstep session desync for seed ${seed}`) - } - } - - console.log(`Drop Pipeline Sequential Determinism: ${streamDeterminismPassCount}/${TEST_SEEDS.length} passed`) - console.log(`Multiplayer Lockstep Determinism with /players N: ${lockstepSessionPassCount}/${TEST_SEEDS.length} passed`) - - if (streamDeterminismPassCount !== TEST_SEEDS.length || lockstepSessionPassCount !== TEST_SEEDS.length) { - allPassed = false - failureReasons.push(`Determinism failures: Stream=${streamDeterminismPassCount}/${TEST_SEEDS.length}, Lockstep=${lockstepSessionPassCount}/${TEST_SEEDS.length}`) - } - - // ========================================================================== - // Section 3: Execution Robustness Under Heavy Load (50,000 Drops) - // ========================================================================== - console.log('\n----------------------------------------------------------------------') - console.log('3. Execution Robustness Under Heavy Load (50,000 Drops Stress Test)') - console.log('----------------------------------------------------------------------') - - const difficulties: Difficulty[] = ['normal', 'nightmare', 'hell'] - const ranks: MonsterRank[] = ['normal', 'champion', 'unique', 'minion', 'boss'] - const equipmentSetups = [ - { name: 'Naked (0 MF, 0 GF)', mf: 0, gf: 0 }, - { name: 'Early (65 MF, 40 GF)', mf: 65, gf: 40 }, - { name: 'Standard MF (350 MF, 150 GF)', mf: 350, gf: 150 }, - { name: 'Extreme MF (1000 MF, 500 GF)', mf: 1000, gf: 500 }, - { name: 'Negative MF (-50 MF, 0 GF)', mf: -50, gf: 0 }, - { name: 'Boundary Suppression (-100 MF, 0 GF)', mf: -100, gf: 0 }, - { name: 'Extreme Negative (-250 MF, 0 GF)', mf: -250, gf: 0 }, - ] - - const bossList = ['Andariel', 'Duriel', 'Mephisto', 'Diablo', 'Baal'] - const superUniques = ['Bishibosh', 'Rakanishu', 'Griswold', 'Radament', 'The Summoner', 'Izual', 'Nihlathak'] - const sampleMonsterKinds = Array.from(dropTables.monsterKinds.keys()).slice(0, 40) - - let totalStressDrops = 0 - let totalItemsEmitted = 0 - let exceptionsCaught = 0 - let corruptItemsCount = 0 - let bossTcUpgradeViolations = 0 - let eliteMlvlViolations = 0 - let negativeMfViolations = 0 - - const qualityDistribution: Record = { - normal: 0, - superior: 0, - magic: 0, - rare: 0, - set: 0, - unique: 0, - low: 0, - gold: 0, - } - - const STRESS_TARGET = 50000 - const stressRng = new D2Rng(0xc0ffee42) - - const startTime = Date.now() - - for (let i = 0; i < STRESS_TARGET; i++) { - const diff = difficulties[i % difficulties.length]! - const rank = ranks[i % ranks.length]! - const setup = equipmentSetups[i % equipmentSetups.length]! - const pCount = 1 + (i % 8) - const partyCount = (i % 2 === 0) ? 1 : Math.min(pCount, 1 + (i % 4)) - - let tcToRoll = 'Act 1 H2H A' - let nLevel = 10 + (i % 85) - const isActBoss = rank === 'boss' || i % 10 === 0 - const isSuper = !isActBoss && i % 7 === 0 - - if (isActBoss) { - tcToRoll = bossList[i % bossList.length]! - nLevel = 30 + (i % 65) - } else if (isSuper) { - tcToRoll = superUniques[i % superUniques.length]! - nLevel = 15 + (i % 75) - } else { - const mk = dropTables.monsterKinds.get(sampleMonsterKinds[i % sampleMonsterKinds.length]!) - if (mk) { - const resolvedTc = getMonsterTreasureClass(mk, diff, 1) - if (resolvedTc) tcToRoll = resolvedTc - } - } - - const monsterType = isActBoss ? 4 : (rank === 'unique' || rank === 'minion' ? 3 : (rank === 'champion' ? 2 : 1)) - - // Elite mlvl calculation check - let expectedMlvl = nLevel - if (rank === 'champion') expectedMlvl = nLevel + 2 - else if (rank === 'unique' || rank === 'minion') expectedMlvl = nLevel + 3 - - try { - const items = executeDropPipeline(dropTables, { - tcName: tcToRoll, - nLevel: expectedMlvl, - monsterType, - difficulty: diff, - gamePlayers: pCount, - partyPlayers: partyCount, - playerMf: setup.mf, - playerGf: setup.gf, - isBoss: isActBoss, - monsterRng: stressRng, - }) - - totalStressDrops++ - totalItemsEmitted += items.length - - for (const item of items) { - const isGold = item.base?.id === 'gold' || item.code?.trim() === 'gld' - - if (isGold) { - qualityDistribution.gold++ - const amt = item.stack ?? item.value ?? 0 - if (amt <= 0 || isNaN(amt)) corruptItemsCount++ - continue - } - - const rarity = item.rarity ?? 'normal' - qualityDistribution[rarity] = (qualityDistribution[rarity] ?? 0) + 1 - - // 1. Base exists - if (!item.base || !dropTables.getBase(item.base.id)) { - corruptItemsCount++ - } - - // 2. ilvl within bounds [1, 99] - if (!item.ilvl || item.ilvl < 1 || item.ilvl > 99) { - corruptItemsCount++ - } - - // 3. Stats have no NaNs - if (item.stats) { - for (const [k, v] of Object.entries(item.stats)) { - if (typeof v === 'number' && isNaN(v)) { - corruptItemsCount++ - } - } - } - - // 4. Durability consistency - if (item.durability !== undefined && item.maxDurability !== undefined) { - if (item.durability <= 0 || item.durability > item.maxDurability || isNaN(item.durability)) { - corruptItemsCount++ - } - } - - // 5. Negative MF suppression check: if MF <= -100: - // - Weapons and armor must NEVER roll magic, rare, set, or unique - // - Always-magic items (rings, charms, amulets) can only be magic, never rare/set/unique (unless forced) - if (setup.mf <= -100) { - if (item.base.kind === 'weapon' || item.base.kind === 'armor') { - if (['magic', 'rare', 'set', 'unique'].includes(rarity)) { - negativeMfViolations++ - } - } else { - if (['rare', 'set', 'unique'].includes(rarity)) { - negativeMfViolations++ - } - } - } - } - } catch (err: any) { - exceptionsCaught++ - console.error(`Exception during stress drop ${i}:`, err.message) - } - } - - const durationMs = Date.now() - startTime - - console.log(`Completed ${totalStressDrops} drop executions in ${(durationMs / 1000).toFixed(2)}s (${(totalStressDrops / (durationMs / 1000)).toFixed(0)} drops/s)`) - console.log(`Total ground items emitted: ${totalItemsEmitted}`) - console.log(`Quality Breakdown:`, JSON.stringify(qualityDistribution, null, 2)) - console.log(`Exceptions caught: ${exceptionsCaught}`) - console.log(`Corrupt items detected: ${corruptItemsCount}`) - console.log(`Negative MF violations (<= -100 rolled magic+): ${negativeMfViolations}`) - - if (exceptionsCaught > 0 || corruptItemsCount > 0 || negativeMfViolations > 0) { - allPassed = false - failureReasons.push(`Stress test failures: Exceptions=${exceptionsCaught}, CorruptItems=${corruptItemsCount}, NegMfViolations=${negativeMfViolations}`) - } - - // ========================================================================== - // Summary & Verdict - // ========================================================================== - console.log('\n======================================================================') - console.log(`FINAL EMPIRICAL VERDICT: ${allPassed ? 'APPROVE' : 'REQUEST_CHANGES'}`) - console.log('======================================================================') - if (!allPassed) { - console.log('Failure details:') - failureReasons.forEach(r => console.log(' - ' + r)) - process.exit(1) - } else { - console.log('All statistical, PRNG determinism, and heavy load criteria passed with 100% success.') - } -} - -main().catch(err => { - console.error('Fatal test runner error:', err) - process.exit(1) -}) diff --git a/tools/verify-challenger-m2-tooltip.ts b/tools/verify-challenger-m2-tooltip.ts deleted file mode 100644 index 38d0ce9..0000000 --- a/tools/verify-challenger-m2-tooltip.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * scripts/verify-challenger-m2-tooltip.ts - * - * Empirical Challenger Verification & Stress Test Suite for Milestone M2. - * Validates: - * 1. M2 Interface & Data Contracts in `src/ui/inventory.ts`. - * 2. Canonical 1.13c STARTER_EQUIPPED_GEAR & STARTER_BAG_ITEMS data integrity. - * 3. Stateful Dye Persistence across equip slot pickup, cursorItem, and bag grid operations. - * 4. 5-Tier Fallback Resolution in `resolveItemSpriteRect` (Tiers 0..5). - * 5. Boundary cases: missing codes, invalid dimensions, case variance, empty items. - * 6. Adversarial Fuzzing Stress Test (10,000 randomized mock items). - */ - -import { - InventoryPanel, - resolveItemSpriteRect, - STARTER_BAG_ITEMS, - STARTER_EQUIPPED_GEAR, - type ResolvableItem, - type SpriteRect, - type UiInventoryItem, - type EquipSlotId, -} from '../src/client/ui/inventory.ts' -import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' - -interface Section { - name: string - assertions: number - failures: string[] -} - -const sections: Section[] = [] -let currentSection: Section | null = null - -function startSection(name: string) { - currentSection = { name, assertions: 0, failures: [] } - sections.push(currentSection) - console.log(`\n=== [${name}] ===`) -} - -function assert(condition: boolean, msg: string) { - if (!currentSection) throw new Error('No active section') - currentSection.assertions++ - if (!condition) { - currentSection.failures.push(msg) - console.error(` FAIL: ${msg}`) - } else { - console.log(` PASS: ${msg}`) - } -} - -// --------------------------------------------------------------------------- -// 1. Static Metadata & Atlas Contract Verification -// --------------------------------------------------------------------------- -startSection('1. Static Manifest & Atlas Rects Verification') -const itemRects = BAKED_UI_MANIFEST.itemRects -assert(Object.keys(itemRects).length > 300, `itemRects has ${Object.keys(itemRects).length} registered entries`) - -const dyedKeys = ['invcap_cgrn', 'invgth_dpur', 'invtgl_lgry', 'invvbl_blac'] -for (const key of dyedKeys) { - const rect = itemRects[key] - assert(rect !== undefined, `Dyed rect '${key}' exists in itemRects`) - if (rect) { - assert( - rect.x >= 0 && rect.y >= 0 && rect.w > 0 && rect.h > 0 && - rect.x + rect.w <= BAKED_UI_MANIFEST.atlasWidth && - rect.y + rect.h <= BAKED_UI_MANIFEST.atlasHeight, - `Rect '${key}' [${rect.x}, ${rect.y}, ${rect.w}, ${rect.h}] within atlas bounds [${BAKED_UI_MANIFEST.atlasWidth}, ${BAKED_UI_MANIFEST.atlasHeight}]` - ) - } -} - -assert(BAKED_UI_MANIFEST.codeToInvFile['Harlequin Crest'] === 'invcap_cgrn', 'codeToInvFile maps "Harlequin Crest" -> "invcap_cgrn"') -assert(BAKED_UI_MANIFEST.codeToInvFile["Tal Rasha's Guardianship"] === 'invgth_dpur', 'codeToInvFile maps "Tal Rasha\'s Guardianship" -> "invgth_dpur"') -assert(BAKED_UI_MANIFEST.codeToInvFile['Magefist'] === 'invtgl_lgry', 'codeToInvFile maps "Magefist" -> "invtgl_lgry"') -assert(BAKED_UI_MANIFEST.codeToInvFile['Arachnid Mesh'] === 'invvbl_blac', 'codeToInvFile maps "Arachnid Mesh" -> "invvbl_blac"') - -// --------------------------------------------------------------------------- -// 2. STARTER_EQUIPPED_GEAR Canonical 1.13c Data Parity -// --------------------------------------------------------------------------- -startSection('2. STARTER_EQUIPPED_GEAR Canonical 1.13c Parity') - -// 2.1 Helm (Harlequin Crest) -const shako = STARTER_EQUIPPED_GEAR.helm -assert(shako !== undefined, 'STARTER_EQUIPPED_GEAR.helm is defined') -if (shako) { - assert(shako.invtransform === 'cgrn', `Shako invtransform is 'cgrn' (got: ${shako.invtransform})`) - assert(shako.defense === 141, `Shako defense is 141 (got: ${shako.defense})`) - assert(shako.durability?.current === 12 && shako.durability?.max === 12, `Shako durability is 12/12`) - assert(shako.reqStr === 50, `Shako reqStr is 50 (got: ${shako.reqStr})`) - assert(shako.reqLevel === 62, `Shako reqLevel is 62 (got: ${shako.reqLevel})`) - assert(shako.stats.length === 6, `Shako has exactly 6 canonical stats (got: ${shako.stats.length})`) - const shakoSprite = resolveItemSpriteRect(shako, itemRects) - assert(shakoSprite === itemRects['invcap_cgrn'], 'Shako sprite resolves to invcap_cgrn') -} - -// 2.2 Armor (Tal Rasha's Guardianship) -const talArmor = STARTER_EQUIPPED_GEAR.armor -assert(talArmor !== undefined, 'STARTER_EQUIPPED_GEAR.armor is defined') -if (talArmor) { - assert(talArmor.invtransform === 'dpur', `Tal Armor invtransform is 'dpur' (got: ${talArmor.invtransform})`) - assert(talArmor.defense === 941, `Tal Armor defense is 941 (got: ${talArmor.defense})`) - assert(talArmor.durability?.current === 55 && talArmor.durability?.max === 55, 'Tal Armor durability is 55/55') - assert(talArmor.reqStr === 84, `Tal Armor reqStr is 84 (got: ${talArmor.reqStr})`) - assert(talArmor.reqLevel === 71, `Tal Armor reqLevel is 71 (got: ${talArmor.reqLevel})`) - assert(talArmor.setPieces !== undefined && talArmor.setPieces.length === 5, 'Tal Armor declares 5 setPieces') - assert(talArmor.setBonuses !== undefined && talArmor.setBonuses.length === 4, 'Tal Armor declares 4 setBonuses') - const armorSprite = resolveItemSpriteRect(talArmor, itemRects) - assert(armorSprite === itemRects['invgth_dpur'], 'Tal Armor sprite resolves to invgth_dpur') -} - -// 2.3 Gloves (Magefist) -const magefist = STARTER_EQUIPPED_GEAR.gloves -assert(magefist !== undefined, 'STARTER_EQUIPPED_GEAR.gloves is defined') -if (magefist) { - assert(magefist.invtransform === 'lgry', `Magefist invtransform is 'lgry' (got: ${magefist.invtransform})`) - assert(magefist.defense === 25, `Magefist defense is 25 (got: ${magefist.defense})`) - assert(magefist.durability?.current === 14 && magefist.durability?.max === 14, 'Magefist durability is 14/14') - assert(magefist.reqStr === 45, `Magefist reqStr is 45 (got: ${magefist.reqStr})`) - assert(magefist.reqLevel === 23, `Magefist reqLevel is 23 (got: ${magefist.reqLevel})`) - const glovesSprite = resolveItemSpriteRect(magefist, itemRects) - assert(glovesSprite === itemRects['invtgl_lgry'], 'Magefist sprite resolves to invtgl_lgry') -} - -// 2.4 Belt (Arachnid Mesh) -const arach = STARTER_EQUIPPED_GEAR.belt -assert(arach !== undefined, 'STARTER_EQUIPPED_GEAR.belt is defined') -if (arach) { - assert(arach.invtransform === 'blac', `Arachnid Mesh invtransform is 'blac' (got: ${arach.invtransform})`) - assert(arach.defense === 138, `Arachnid Mesh defense is 138 (got: ${arach.defense})`) - assert(arach.durability?.current === 12 && arach.durability?.max === 12, 'Arachnid Mesh durability is 12/12') - assert(arach.reqStr === 50, `Arachnid Mesh reqStr is 50 (got: ${arach.reqStr})`) - assert(arach.reqLevel === 80, `Arachnid Mesh reqLevel is 80 (got: ${arach.reqLevel})`) - const beltSprite = resolveItemSpriteRect(arach, itemRects) - assert(beltSprite === itemRects['invvbl_blac'], 'Arachnid Mesh sprite resolves to invvbl_blac') -} - -// 2.5 Weapons & Other Equipped Slots -const oculus = STARTER_EQUIPPED_GEAR.weapon1 -assert(oculus !== undefined, 'STARTER_EQUIPPED_GEAR.weapon1 is defined') -if (oculus) { - assert(oculus.damage === '18 - 42', `Oculus damage is '18 - 42'`) - assert(oculus.durability?.current === 50 && oculus.durability?.max === 50, 'Oculus durability is 50/50') - assert(oculus.speedText === '法球类 - 普通攻击速度', `Oculus speedText is '法球类 - 普通攻击速度'`) - assert(oculus.reqLevel === 42, `Oculus reqLevel is 42`) - assert(resolveItemSpriteRect(oculus, itemRects) === itemRects['invbst'], 'Oculus resolves to invbst via Tier 1 invFile') -} - -const spirit = STARTER_EQUIPPED_GEAR.weapon2 -assert(spirit !== undefined, 'STARTER_EQUIPPED_GEAR.weapon2 is defined') -if (spirit) { - assert(spirit.quality === 'rune', `Spirit quality is 'rune'`) - assert(spirit.runewordRunes === "'TalThulOrtAmn'", `Spirit runewordRunes is "'TalThulOrtAmn'"`) - assert(spirit.defense === 148, `Spirit defense is 148`) - assert(spirit.durability?.current === 148 && spirit.durability?.max === 148, 'Spirit durability is 148/148') - assert(spirit.reqStr === 156, `Spirit reqStr is 156`) - assert(spirit.reqLevel === 54, `Spirit reqLevel is 54`) - assert(spirit.sockets === 4, `Spirit sockets is 4`) - assert(resolveItemSpriteRect(spirit, itemRects) === itemRects['invtow'], 'Spirit resolves to invtow via Tier 1 invFile') -} - -const boots = STARTER_EQUIPPED_GEAR.boots -assert(boots !== undefined, 'STARTER_EQUIPPED_GEAR.boots is defined') -if (boots) { - assert(boots.defense === 139, `War Traveler defense is 139`) - assert(boots.durability?.current === 48 && boots.durability?.max === 48, 'War Traveler durability is 48/48') - assert(boots.reqStr === 95, `War Traveler reqStr is 95`) - assert(boots.reqLevel === 42, `War Traveler reqLevel is 42`) - assert(resolveItemSpriteRect(boots, itemRects) === itemRects['invtbt'], 'War Traveler resolves to invtbt via Tier 1 invFile') -} - -// --------------------------------------------------------------------------- -// 3. STARTER_BAG_ITEMS Canonical 1.13c Data Parity -// --------------------------------------------------------------------------- -startSection('3. STARTER_BAG_ITEMS Canonical 1.13c Parity') - -const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') -assert(ctaPlacement !== undefined, 'Call to Arms placement found in STARTER_BAG_ITEMS') -if (ctaPlacement) { - const cta = ctaPlacement.item - assert(cta.runewordRunes === "'AmnRalMalIstOhm'", `CTA runewordRunes is "'AmnRalMalIstOhm'"`) - assert(cta.damage === '17 - 52', `CTA damage is '17 - 52'`) - assert(cta.durability?.current === 20 && cta.durability?.max === 20, `CTA durability is 20/20`) - assert(cta.reqStr === 43, `CTA reqStr is 43`) - assert(cta.reqLevel === 57, `CTA reqLevel is 57`) - assert(cta.sockets === 5, `CTA sockets is 5`) - assert(cta.speedText === '剑类 - 极快的攻击速度', `CTA speedText is '剑类 - 极快的攻击速度'`) - assert(cta.stats.length === 11, `CTA has exactly 11 canonical stats (got: ${cta.stats.length})`) - const ctaSprite = resolveItemSpriteRect(cta, itemRects) - assert(ctaSprite === itemRects['invcrs'], 'CTA sprite resolves to invcrs') -} - -const bagTorch = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-torch')?.item -assert(bagTorch?.reqLevel === 75, 'Hellfire Torch reqLevel is 75') -assert(bagTorch?.stats.length === 5, 'Hellfire Torch has 5 stats') -assert(bagTorch ? resolveItemSpriteRect(bagTorch, itemRects) === itemRects['invtrch'] : false, 'Torch resolves to invtrch') - -const bagAnni = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-anni')?.item -assert(bagAnni?.reqLevel === 70, 'Annihilus reqLevel is 70') -assert(bagAnni?.stats.length === 4, 'Annihilus has 4 stats') -assert(bagAnni ? resolveItemSpriteRect(bagAnni, itemRects) === itemRects['invmss'] : false, 'Annihilus resolves to invmss') - -const bagGheeds = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-gheeds')?.item -assert(bagGheeds?.reqLevel === 62, "Gheed's Fortune reqLevel is 62") -assert(bagGheeds?.stats.length === 3, "Gheed's Fortune has 3 stats") -assert(bagGheeds ? resolveItemSpriteRect(bagGheeds, itemRects) === itemRects['invsst'] : false, "Gheed's Fortune resolves to invsst") - -// --------------------------------------------------------------------------- -// 4. Stateful Dye Persistence Simulation across UI Operations -// --------------------------------------------------------------------------- -startSection('4. Stateful Dye Persistence across Equip / Bag / Cursor') - -const inv = new InventoryPanel() -const slotsToTest: { slot: EquipSlotId; expectedDye: string; expectedSprite: string }[] = [ - { slot: 'helm', expectedDye: 'cgrn', expectedSprite: 'invcap_cgrn' }, - { slot: 'armor', expectedDye: 'dpur', expectedSprite: 'invgth_dpur' }, - { slot: 'gloves', expectedDye: 'lgry', expectedSprite: 'invtgl_lgry' }, - { slot: 'belt', expectedDye: 'blac', expectedSprite: 'invvbl_blac' }, -] - -for (const { slot, expectedDye, expectedSprite } of slotsToTest) { - const itemBefore = inv.equipped[slot] - assert(itemBefore?.invtransform === expectedDye, `Slot ${slot} starts with invtransform '${expectedDye}'`) - - // Pick up to cursor - const picked = inv.clickEquipSlot(slot) - assert(picked === true, `Picked up item from slot ${slot}`) - assert(inv.cursorItem !== null, `Cursor holds picked item`) - assert(inv.cursorItem?.invtransform === expectedDye, `CursorItem retains invtransform '${expectedDye}'`) - const cursorSprite = resolveItemSpriteRect(inv.cursorItem!, itemRects) - assert(cursorSprite === itemRects[expectedSprite], `CursorItem resolves sprite ${expectedSprite}`) - - // Re-equip back to slot - const reEquipped = inv.clickEquipSlot(slot) - assert(reEquipped === true, `Re-equipped item to slot ${slot}`) - assert(inv.cursorItem === null, `Cursor empty after re-equipping`) - const itemAfter = inv.equipped[slot] - assert(itemAfter?.invtransform === expectedDye, `Re-equipped slot ${slot} retains invtransform '${expectedDye}'`) - const afterSprite = resolveItemSpriteRect(itemAfter!, itemRects) - assert(afterSprite === itemRects[expectedSprite], `Re-equipped slot ${slot} resolves sprite ${expectedSprite}`) -} - -// --------------------------------------------------------------------------- -// 5. Stress-Testing Fallback Tiers (resolveItemSpriteRect) -// --------------------------------------------------------------------------- -startSection('5. Stress-Testing Fallback Tiers (resolveItemSpriteRect)') - -// Tier 0: Transformed dyed item lookup -// 5.1 Case sensitivity of invFile and invtransform -const mixedCaseItem: ResolvableItem = { - invFile: 'InvCap', - invtransform: 'CGRN', -} -const mixedCaseSprite = resolveItemSpriteRect(mixedCaseItem, itemRects) -assert(mixedCaseSprite === itemRects['invcap_cgrn'], 'Tier 0 handles mixed-case InvCap + CGRN -> invcap_cgrn') - -// 5.2 Invalid invtransform falls back to Tier 1 base invFile -const badTransformItem: ResolvableItem = { - invFile: 'invcap', - invtransform: 'nonexistent_dye_xyz', -} -const badTransformSprite = resolveItemSpriteRect(badTransformItem, itemRects) -assert(badTransformSprite === itemRects['invcap'], 'Invalid invtransform falls back to Tier 1 base invFile (invcap)') - -// 5.3 Empty string invtransform falls back to Tier 1 -const emptyTransformItem: ResolvableItem = { - invFile: 'invcap', - invtransform: '', -} -const emptyTransformSprite = resolveItemSpriteRect(emptyTransformItem, itemRects) -assert(emptyTransformSprite === itemRects['invcap'], 'Empty invtransform falls back to Tier 1 base invFile (invcap)') - -// Tier 1: Direct invFile -const tier1Item: ResolvableItem = { invFile: 'invgth' } -assert(resolveItemSpriteRect(tier1Item, itemRects) === itemRects['invgth'], 'Tier 1 resolves direct invFile invgth') -const tier1UpperItem: ResolvableItem = { invFile: 'INVGTH' } -assert(resolveItemSpriteRect(tier1UpperItem, itemRects) === itemRects['invgth'], 'Tier 1 resolves uppercase INVGTH') - -// Tier 2: Unique/set name in codeToInvFile -assert(resolveItemSpriteRect({ name: 'Harlequin Crest' }, itemRects) === itemRects['invcap_cgrn'], 'Tier 2 resolves "Harlequin Crest" -> invcap_cgrn') -assert(resolveItemSpriteRect({ name: 'Magefist' }, itemRects) === itemRects['invtgl_lgry'], 'Tier 2 resolves "Magefist" -> invtgl_lgry') -assert(resolveItemSpriteRect({ name: "Tal Rasha's Guardianship" }, itemRects) === itemRects['invgth_dpur'], 'Tier 2 resolves "Tal Rasha\'s Guardianship" -> invgth_dpur') -assert(resolveItemSpriteRect({ name: 'Arachnid Mesh' }, itemRects) === itemRects['invvbl_blac'], 'Tier 2 resolves "Arachnid Mesh" -> invvbl_blac') - -// Tier 3: Base item code in codeToInvFile -const tier3Item: ResolvableItem = { code: 'hax' } -assert(resolveItemSpriteRect(tier3Item, itemRects) === itemRects['invhax'], 'Tier 3 resolves base code "hax" -> invhax') -assert(resolveItemSpriteRect({ code: 'cm1' }, itemRects) === itemRects['invchm'], 'Tier 3 resolves base code "cm1" -> invchm') - -// Tier 4: Canonical code overrides (codes without base codeToInvFile collisions) -const tier4Overrides: [string, string][] = [ - ['tbk', 'invbbk'], - ['ibk', 'invrbk'], - ['box', 'invbox'], - ['cube', 'invbox'], - ['anni', 'invmss'], - ['torch', 'invtrch'], - ['gheeds', 'invsst'], - ['crs', 'invcrs'], - ['cta', 'invcrs'], -] -for (const [code, expectedInvFile] of tier4Overrides) { - const item: ResolvableItem = { code } - const res = resolveItemSpriteRect(item, itemRects) - assert(res === itemRects[expectedInvFile], `Tier 4 code override '${code}' -> '${expectedInvFile}'`) -} - -// Tier 4: Name-based overrides -assert(resolveItemSpriteRect({ name: 'Annihilus Charm' }, itemRects) === itemRects['invmss'], 'Tier 4 name containing "Annihilus" -> invmss') -assert(resolveItemSpriteRect({ name: 'Hellfire Torch' }, itemRects) === itemRects['invtrch'], 'Tier 4 name containing "Torch" -> invtrch') -assert(resolveItemSpriteRect({ name: "Gheed's Fortune" }, itemRects) === itemRects['invsst'], "Tier 4 name containing \"Gheed\" -> invsst") -assert(resolveItemSpriteRect({ name: 'Call to Arms Sword' }, itemRects) === itemRects['invcrs'], 'Tier 4 name containing "Call to Arms" -> invcrs') - -// Tier 5: Dimension-based fallbacks (w x h) -const dimensionCases: [number, number, string[]][] = [ - [1, 1, ['invchm', 'invrin', 'invcap']], - [1, 2, ['invtrch', 'invbbk', 'invglv']], - [1, 3, ['invsst', 'invclb', 'invwnd']], - [1, 4, ['invbst', 'invstf']], - [2, 1, ['invvbl', 'invlbl']], - [2, 2, ['invbox', 'invcap']], - [2, 3, ['invcrs', 'invgth']], - [2, 4, ['invhxb', 'invpst']], -] -for (const [w, h, candidates] of dimensionCases) { - const item: ResolvableItem = { invWidth: w, invHeight: h } - const res = resolveItemSpriteRect(item, itemRects) - const matchedCandidate = candidates.some(c => itemRects[c] && res === itemRects[c]) - assert(matchedCandidate, `Tier 5 dimension fallback (${w}x${h}) matched candidate in [${candidates.join(', ')}]`) -} - -// --------------------------------------------------------------------------- -// 6. Boundary Cases & Invalid Dimensions -// --------------------------------------------------------------------------- -startSection('6. Boundary Cases & Invalid Dimensions') - -// 6.1 Empty item object -const emptyItem: ResolvableItem = {} -const emptyRes = resolveItemSpriteRect(emptyItem, itemRects) -assert(emptyRes !== null, 'Empty item {} resolves to non-null default (1x1 fallback)') - -// 6.2 Unusual / Invalid dimensions -const invalidDimensionItems: ResolvableItem[] = [ - { invWidth: 0, invHeight: 0 }, - { invWidth: -1, invHeight: -2 }, - { invWidth: 3, invHeight: 3 }, - { invWidth: 10, invHeight: 10 }, - { invWidth: NaN, invHeight: NaN }, - { invWidth: Infinity, invHeight: Infinity }, - { invWidth: -Infinity, invHeight: -Infinity }, - { invWidth: 1.5, invHeight: 2.7 }, -] - -for (const invalidItem of invalidDimensionItems) { - let thrown = false - let res: SpriteRect | null = null - try { - res = resolveItemSpriteRect(invalidItem, itemRects) - } catch (e) { - thrown = true - } - assert(!thrown, `Invalid dimensions (w: ${invalidItem.invWidth}, h: ${invalidItem.invHeight}) does not throw`) - assert(res !== null, `Invalid dimensions returns non-null fallback SpriteRect`) -} - -// 6.3 Empty itemRects object -const emptyRectsRes = resolveItemSpriteRect({ code: 'crs' }, {}) -assert(emptyRectsRes === null, 'Empty itemRects returns null without throwing') - -// --------------------------------------------------------------------------- -// 7. Adversarial Fuzzing Stress Test (10,000 Random Mock Items) -// --------------------------------------------------------------------------- -startSection('7. Adversarial Fuzzing Stress Test (10,000 Randomized Items)') - -function pseudoRandom(seed: number): () => number { - let s = seed - return () => { - s = (s * 1664525 + 1013904223) % 4294967296 - return s / 4294967296 - } -} - -const rng = pseudoRandom(0xfeedface) - -const sampleCodes = [undefined, '', 'uap', 'uth', 'tgl', 'ulc', 'crs', 'tbk', 'ibk', 'cm1', 'cm2', 'cm3', 'xyz_unknown', '@@@$$$'] -const sampleNames = [undefined, '', 'Harlequin Crest', "Tal Rasha's Guardianship", 'Magefist', 'Arachnid Mesh', 'The Oculus', 'Spirit Monarch', 'Call to Arms', 'Unknown Item', 'Random Name 123'] -const sampleInvFiles = [undefined, '', 'invcap', 'invgth', 'invtgl', 'invvbl', 'invcrs', 'invbbk', 'nonexistent_file', 'INV_CAP_UPPER'] -const sampleTransforms = [undefined, '', 'cgrn', 'dpur', 'lgry', 'blac', 'unknown_dye', 'CGRN', 'DPUR'] -const sampleDimensions = [undefined, 0, 1, 2, 3, 4, -1, -5, 10, NaN, Infinity, 1.5] - -let fuzzIterations = 10000 -let fuzzSuccessCount = 0 -let dyedHitCount = 0 - -for (let i = 0; i < fuzzIterations; i++) { - const mockItem: ResolvableItem = { - code: sampleCodes[Math.floor(rng() * sampleCodes.length)], - name: sampleNames[Math.floor(rng() * sampleNames.length)], - invFile: sampleInvFiles[Math.floor(rng() * sampleInvFiles.length)], - invtransform: sampleTransforms[Math.floor(rng() * sampleTransforms.length)], - invWidth: sampleDimensions[Math.floor(rng() * sampleDimensions.length)], - invHeight: sampleDimensions[Math.floor(rng() * sampleDimensions.length)], - } - - let rect: SpriteRect | null = null - try { - rect = resolveItemSpriteRect(mockItem, itemRects) - } catch (err: any) { - assert(false, `Fuzz item ${i} threw error: ${err.message}`) - break - } - - // Verification 1: Must never return undefined - if (rect === undefined) { - assert(false, `Fuzz item ${i} returned undefined`) - break - } - - // Verification 2: With populated itemRects, fallback chain guarantees non-null SpriteRect - if (rect === null) { - assert(false, `Fuzz item ${i} returned null unexpectedly`) - break - } - - // Verification 3: Bounding box validity - if ( - !Number.isFinite(rect.x) || !Number.isFinite(rect.y) || - !Number.isFinite(rect.w) || !Number.isFinite(rect.h) || - rect.w <= 0 || rect.h <= 0 || - rect.x < 0 || rect.y < 0 || - rect.x + rect.w > BAKED_UI_MANIFEST.atlasWidth || - rect.y + rect.h > BAKED_UI_MANIFEST.atlasHeight - ) { - assert(false, `Fuzz item ${i} returned invalid SpriteRect: [${rect.x}, ${rect.y}, ${rect.w}, ${rect.h}]`) - break - } - - // Verification 4: If exact dyed match, it MUST resolve the dyed rect - if (mockItem.invFile && mockItem.invtransform) { - const expectedDyedKey = `${mockItem.invFile}_${mockItem.invtransform}`.toLowerCase() - if (itemRects[expectedDyedKey]) { - if (rect !== itemRects[expectedDyedKey]) { - assert(false, `Fuzz item ${i} had dyed match ${expectedDyedKey} but resolved different rect`) - break - } - dyedHitCount++ - } - } - - fuzzSuccessCount++ -} - -assert(fuzzSuccessCount === fuzzIterations, `All ${fuzzIterations} fuzzed items resolved safely without crashes or invalid rects`) -console.log(` (Fuzzed: ${fuzzSuccessCount} safe resolutions, ${dyedHitCount} dyed hits)`) - -// --------------------------------------------------------------------------- -// Summary -// --------------------------------------------------------------------------- -console.log('\n======================================================') -console.log('CHALLENGER VERIFICATION SUMMARY') -console.log('======================================================') -let totalAssertions = 0 -let totalFailures = 0 - -for (const sec of sections) { - totalAssertions += sec.assertions - totalFailures += sec.failures.length - const status = sec.failures.length === 0 ? 'PASS' : `FAIL (${sec.failures.length})` - console.log(`- ${sec.name}: ${status} [${sec.assertions} assertions]`) -} - -console.log('------------------------------------------------------') -console.log(`Total assertions: ${totalAssertions}`) -console.log(`Total failures: ${totalFailures}`) -console.log('======================================================') - -if (totalFailures > 0) { - process.exit(1) -} else { - console.log('ALL EMPIRICAL CHALLENGER TESTS PASSED.') - process.exit(0) -} diff --git a/tools/verify-challenger-m3-tooltip.ts b/tools/verify-challenger-m3-tooltip.ts deleted file mode 100644 index 08c10b7..0000000 --- a/tools/verify-challenger-m3-tooltip.ts +++ /dev/null @@ -1,597 +0,0 @@ -/** - * scripts/verify-tooltip-empirical.ts - * - * Standalone Empirical Challenger Verification & Stress Test Suite - * for Milestone M3 (Diablo II v1.13c Item Tooltips & Parity). - * - * Validates: - * 1. Tooltip rendering across all 10 starter equipped items: - * Shako, Mara's, Oculus, Tal Armor, Spirit, Magefist, Arachnid, SOJ, BK, WT. - * 2. Tooltip rendering across starter bag items: - * CTA, Torch, Anni, Gheed's Fortune. - * 3. Deep canonical assertions on Harlequin Crest (Shako) and Call to Arms (CTA). - * 4. Dynamic vertical flip threshold (hover.y = 200, 279, 280, 500) and viewport clamping. - * 5. formatItemTooltip runeword, unique, set, and socketable formatting parity. - * 6. Adversarial edge cases: extreme coordinates, long text lines, zero/missing fields. - * 7. Rapid iteration stress harness (2,000 hover frames). - * 8. Zero runtime MPQ/DLL network/file dependency guard. - */ - -import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' -import { - STARTER_EQUIPPED_GEAR, - STARTER_BAG_ITEMS, - InventoryPanel, - EQUIP_SLOTS_LAYOUT, - INV_GRID_ORIGIN, - type UiInventoryItem, - type EquipSlotId, -} from '../src/client/ui/inventory.ts' -import { formatItemTooltip, type FormattedItemTooltip } from '../src/common/items/item-tooltip.ts' -import type { Item, ItemBase } from '../src/common/items/items.ts' -import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/client/ui/font.ts' - -interface DrawCall { - text: string - x: number - y: number - options?: { - font?: D2FontName - color?: D2ColorCode - align?: 'left' | 'center' | 'right' - shadow?: boolean - } -} - -interface MockFontRenderer extends D2FontRenderer { - drawCalls: DrawCall[] -} - -function createMockFont(): MockFontRenderer { - const drawCalls: DrawCall[] = [] - return { - metas: {} as any, - images: new Map(), - tintedCache: new Map(), - getTintedAtlas: () => null as any, - measureText: (text: string, _font: D2FontName = 'font8') => { - return text.length * 7 - }, - drawText: (_ctx: any, text: string, x: number, y: number, options?: any) => { - drawCalls.push({ text, x, y, options }) - }, - drawCalls, - } as unknown as MockFontRenderer -} - -interface MockCanvasContext { - fillStyle: string - strokeStyle: string - fillRectCalls: [number, number, number, number][] - strokeRectCalls: [number, number, number, number][] - fillRect: (x: number, y: number, w: number, h: number) => void - strokeRect: (x: number, y: number, w: number, h: number) => void -} - -function createMockCanvas(): MockCanvasContext { - const fillRectCalls: [number, number, number, number][] = [] - const strokeRectCalls: [number, number, number, number][] = [] - return { - fillStyle: '', - strokeStyle: '', - fillRectCalls, - strokeRectCalls, - fillRect: (x: number, y: number, w: number, h: number) => { - fillRectCalls.push([x, y, w, h]) - }, - strokeRect: (x: number, y: number, w: number, h: number) => { - strokeRectCalls.push([x, y, w, h]) - }, - } -} - -let totalAssertions = 0 -let failedAssertions = 0 - -function assert(condition: boolean, message: string) { - totalAssertions++ - if (!condition) { - failedAssertions++ - console.error(` [FAIL] ${message}`) - } else { - console.log(` [PASS] ${message}`) - } -} - -function section(title: string) { - console.log(`\n======================================================`) - console.log(`>>> ${title}`) - console.log(`======================================================`) -} - -// --------------------------------------------------------------------------- -// Section 1: All 10 Starter Equipped Items Traversal -// --------------------------------------------------------------------------- -section('1. Verification of 10 Starter Equipped Items in drawItemTooltip') - -const worldPanels = new WorldPanelsHud() -const equippedSlots: EquipSlotId[] = [ - 'helm', - 'amulet', - 'weapon1', - 'armor', - 'weapon2', - 'gloves', - 'ring1', - 'belt', - 'ring2', - 'boots', -] - -for (const slot of equippedSlots) { - const item = STARTER_EQUIPPED_GEAR[slot] - assert(item !== undefined, `Slot '${slot}' must have defined starter item`) - if (!item) continue - - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item, x: 400, y: 300 }, font) - - assert(canvas.fillRectCalls.length === 1, `${slot} (${item.name}): exactly 1 tooltip background rect drawn`) - assert(canvas.strokeRectCalls.length === 1, `${slot} (${item.name}): exactly 1 tooltip border rect drawn`) - assert(font.drawCalls.length >= 3, `${slot} (${item.name}): has at least title, base name, and stats`) - - const titleCall = font.drawCalls[0] - assert(titleCall?.text === item.nameZh, `${slot}: Title is '${item.nameZh}'`) - assert(titleCall?.options?.font === 'fontexocet10', `${slot}: Title font is 'fontexocet10'`) - - const expectedTitleColor = - item.quality === 'unique' || item.quality === 'rune' ? 'gold' : - item.quality === 'set' ? 'green' : 'white' - assert(titleCall?.options?.color === expectedTitleColor, `${slot}: Title color is '${expectedTitleColor}'`) -} - -// --------------------------------------------------------------------------- -// Section 2: Deep Canonical Assertions on Harlequin Crest (Shako) -// --------------------------------------------------------------------------- -section('2. Detailed Parity on Harlequin Crest (Shako)') - -const shako = STARTER_EQUIPPED_GEAR.helm! -const shakoFont = createMockFont() -const shakoCanvas = createMockCanvas() -worldPanels.drawItemTooltip(shakoCanvas as any, { item: shako, x: 400, y: 320 }, shakoFont) - -// Title -const shakoTitle = shakoFont.drawCalls.find(d => d.text.includes('谐角之冠')) -assert(shakoTitle !== undefined, 'Harlequin Crest title line exists') -assert(shakoTitle?.options?.color === 'gold', 'Harlequin Crest title line is in gold') -assert(shakoTitle?.options?.font === 'fontexocet10', 'Harlequin Crest title line uses fontexocet10') - -// Base Name -const shakoBase = shakoFont.drawCalls.find(d => d.text.includes('军帽')) -assert(shakoBase !== undefined, 'Base name "军帽" line exists') -assert(shakoBase?.options?.color === 'white', 'Base name "军帽" is in white') -assert(shakoBase?.options?.font === 'font8', 'Base name "军帽" uses font8') - -// Defense -const shakoDef = shakoFont.drawCalls.find(d => d.text === '防御: 141') -assert(shakoDef !== undefined, 'Defense line "防御: 141" exists') -assert(shakoDef?.options?.color === 'white', 'Defense line is in white') - -// Durability -const shakoDur = shakoFont.drawCalls.find(d => d.text === '耐久度: 12 之 12') -assert(shakoDur !== undefined, 'Durability line "耐久度: 12 之 12" exists') -assert(shakoDur?.options?.color === 'white', 'Durability line is in white') - -// Required Strength -const shakoStr = shakoFont.drawCalls.find(d => d.text === '需要力量: 50') -assert(shakoStr !== undefined, 'Req strength line "需要力量: 50" exists') -assert(shakoStr?.options?.color === 'white', 'Req strength line is in white') - -// Required Level -const shakoLvl = shakoFont.drawCalls.find(d => d.text === '需要等级: 62') -assert(shakoLvl !== undefined, 'Req level line "需要等级: 62" exists') -assert(shakoLvl?.options?.color === 'white', 'Req level line is in white') - -// 6 Canonical Stats -const expectedShakoStats = [ - '+2 所有技能 (+2 To All Skills)', - '+1.5 生命 (依角色等级而定)', - '+1.5 法力 (依角色等级而定)', - '物理伤害减少 10%', - '50% 更佳机会取得魔法装备 (MF)', - '+2 所有属性', -] - -for (const statText of expectedShakoStats) { - const found = shakoFont.drawCalls.find(d => d.text === statText) - assert(found !== undefined, `Shako stat "${statText}" is drawn`) - assert(found?.options?.color === 'blue', `Shako stat "${statText}" is drawn in blue`) -} - -// --------------------------------------------------------------------------- -// Section 3: Deep Canonical Assertions on Call to Arms (CTA) -// --------------------------------------------------------------------------- -section('3. Detailed Parity on Call to Arms (CTA)') - -const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') -assert(ctaPlacement !== undefined, 'CTA placement exists in STARTER_BAG_ITEMS') -const cta = ctaPlacement!.item - -const ctaFont = createMockFont() -const ctaCanvas = createMockCanvas() -worldPanels.drawItemTooltip(ctaCanvas as any, { item: cta, x: 400, y: 350 }, ctaFont) - -// Title -const ctaTitle = ctaFont.drawCalls[0] -assert(ctaTitle?.text === '战争召唤 (Call to Arms)', 'CTA title is "战争召唤 (Call to Arms)"') -assert(ctaTitle?.options?.color === 'gold', 'CTA title is in gold') -assert(ctaTitle?.options?.font === 'fontexocet10', 'CTA title uses fontexocet10') - -// Runes line -const ctaRunes = ctaFont.drawCalls[1] -assert(ctaRunes?.text === "'AmnRalMalIstOhm'", "CTA runes line is ''AmnRalMalIstOhm''") -assert(ctaRunes?.options?.color === 'gold', 'CTA runes line is in gold') -assert(ctaRunes?.options?.font === 'font8', 'CTA runes line uses font8') - -// Base name -const ctaBase = ctaFont.drawCalls[2] -assert(ctaBase?.text === '水晶剑 (Crystal Sword)', 'CTA base name is "水晶剑 (Crystal Sword)"') -assert(ctaBase?.options?.color === 'white', 'CTA base name is in white') -assert(ctaBase?.options?.font === 'font8', 'CTA base name uses font8') - -// Damage -const ctaDmg = ctaFont.drawCalls.find(d => d.text.includes('17 - 52')) -assert(ctaDmg !== undefined, 'CTA damage line "单手伤害: 17 - 52" exists') -assert(ctaDmg?.text === '单手伤害: 17 - 52', 'CTA damage line is exact "单手伤害: 17 - 52"') -assert(ctaDmg?.options?.color === 'white', 'CTA damage line is in white') - -// Durability -const ctaDur = ctaFont.drawCalls.find(d => d.text === '耐久度: 20 之 20') -assert(ctaDur !== undefined, 'CTA durability line "耐久度: 20 之 20" exists') -assert(ctaDur?.options?.color === 'white', 'CTA durability line is in white') - -// Speed Text -const ctaSpeed = ctaFont.drawCalls.find(d => d.text === '剑类 - 极快的攻击速度') -assert(ctaSpeed !== undefined, 'CTA speed class line "剑类 - 极快的攻击速度" exists') -assert(ctaSpeed?.options?.color === 'white', 'CTA speed class line is in white') - -// Req Strength -const ctaStr = ctaFont.drawCalls.find(d => d.text === '需要力量: 43') -assert(ctaStr !== undefined, 'CTA req strength line "需要力量: 43" exists') -assert(ctaStr?.options?.color === 'white', 'CTA req strength line is in white') - -// Req Level -const ctaLvl = ctaFont.drawCalls.find(d => d.text === '需要等级: 57') -assert(ctaLvl !== undefined, 'CTA req level line "需要等级: 57" exists') -assert(ctaLvl?.options?.color === 'white', 'CTA req level line is in white') - -// All 11 Canonical CTA Stats -const expectedCtaStats = [ - '+1 所有技能 (+1 To All Skills)', - '+40% 增加攻击速度 (+40% Increased Attack Speed)', - '+255% 增强伤害 (+255% Enhanced Damage)', - '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', - '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', - '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', - '+6 战斗体制 (Battle Orders)', - '+6 战斗指挥 (Battle Command)', - '+4 战斗狂嗥 / 战嚎 (Battle Cry)', - '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', - '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', -] - -for (const statText of expectedCtaStats) { - const found = ctaFont.drawCalls.find(d => d.text === statText) - assert(found !== undefined, `CTA stat "${statText}" is drawn`) - assert(found?.options?.color === 'blue', `CTA stat "${statText}" is in blue`) -} - -// Sockets line -const ctaSockets = ctaFont.drawCalls.find(d => d.text === '凹槽 (5)') -assert(ctaSockets !== undefined, 'CTA sockets line "凹槽 (5)" exists') -assert(ctaSockets?.options?.color === 'blue', 'CTA sockets line is in blue') - -// --------------------------------------------------------------------------- -// Section 4: Starter Bag Charms (Torch, Anni, Gheed's) -// --------------------------------------------------------------------------- -section('4. Verification of Starter Bag Charms (Torch, Anni, Gheed\'s)') - -const charms = ['bag-torch', 'bag-anni', 'bag-gheeds'] -for (const charmId of charms) { - const pl = STARTER_BAG_ITEMS.find(p => p.item.id === charmId) - assert(pl !== undefined, `Charm '${charmId}' exists in STARTER_BAG_ITEMS`) - if (!pl) continue - - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: pl.item, x: 400, y: 300 }, font) - - const title = font.drawCalls[0] - assert(title?.text === pl.item.nameZh, `${charmId}: Title matches '${pl.item.nameZh}'`) - assert(title?.options?.color === 'gold', `${charmId}: Title is gold`) - assert(font.drawCalls.some(d => d.text === pl.item.baseNameZh && d.options?.color === 'white'), `${charmId}: Base name in white`) - assert(font.drawCalls.some(d => d.text === `需要等级: ${pl.item.reqLevel}` && d.options?.color === 'white'), `${charmId}: Req level in white`) - - for (const st of pl.item.stats) { - assert(font.drawCalls.some(d => d.text === st.text && d.options?.color === 'blue'), `${charmId}: Stat "${st.text}" in blue`) - } -} - -// --------------------------------------------------------------------------- -// Section 5: Dynamic Vertical Flip Threshold & Screen Clamping -// --------------------------------------------------------------------------- -section('5. Dynamic Vertical Flip Threshold & Boundary Clamping') - -const testItem: UiInventoryItem = { - id: 'test-flip-item', - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '便帽', - baseNameZh: '便帽', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], -} - -// Case 1: hover.y = 200 (< 280) -> by = 224 (hover.y + 24) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 200 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by === 224, `hover.y = 200 -> by = ${by} (expected 224)`) - assert(by + boxH <= 592, `hover.y = 200 -> bottom edge within 592`) -} - -// Case 2: hover.y = 279 (< 280) -> by = 303 (hover.y + 24) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 279 }, font) - const [, by] = canvas.fillRectCalls[0]! - assert(by === 303, `hover.y = 279 -> by = ${by} (expected 303)`) -} - -// Case 3: hover.y = 280 (>= 280) -> by < 280 (above cursor: 280 - boxH - 8) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 280 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by < 280, `hover.y = 280 -> by = ${by} (expected < 280)`) - assert(by === 280 - boxH - 8, `hover.y = 280 -> by = 280 - boxH - 8 (${280 - boxH - 8})`) -} - -// Case 4: hover.y = 500 (>= 280) -> by < 500 (above cursor: 500 - boxH - 8) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 500 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by < 500, `hover.y = 500 -> by = ${by} (expected < 500)`) - assert(by === 500 - boxH - 8, `hover.y = 500 -> by = 500 - boxH - 8 (${500 - boxH - 8})`) -} - -// Case 5: Viewport horizontal and vertical boundaries clamping -{ - // Far left (hover.x = 0) - const fontL = createMockFont() - const canvasL = createMockCanvas() - worldPanels.drawItemTooltip(canvasL as any, { item: testItem, x: 0, y: 300 }, fontL) - const [bxL] = canvasL.fillRectCalls[0]! - assert(bxL === 8, `hover.x = 0 clamps bx to 8 (got ${bxL})`) - - // Far right (hover.x = 800) - const fontR = createMockFont() - const canvasR = createMockCanvas() - worldPanels.drawItemTooltip(canvasR as any, { item: testItem, x: 800, y: 300 }, fontR) - const [bxR, , boxWR] = canvasR.fillRectCalls[0]! - assert(bxR + boxWR === 792, `hover.x = 800 clamps bx + boxW to 792 (bx=${bxR}, boxW=${boxWR})`) - - // Far bottom (hover.y = 600) - const fontB = createMockFont() - const canvasB = createMockCanvas() - worldPanels.drawItemTooltip(canvasB as any, { item: testItem, x: 400, y: 600 }, fontB) - const [, byB, , boxHB] = canvasB.fillRectCalls[0]! - assert(byB + boxHB === 592, `hover.y = 600 clamps by + boxH to 592 (by=${byB}, boxH=${boxHB})`) -} - -// --------------------------------------------------------------------------- -// Section 6: formatItemTooltip Unit Verification -// --------------------------------------------------------------------------- -section('6. formatItemTooltip Runeword & Quality Verification') - -// Test 6.1: Runeword item formatting -const rawRunewordItem: Item = { - id: 101, - name: 'Call to Arms', - base: { - id: 'crs', - name: 'Crystal Sword', - kind: 'weapon', - tags: ['sword', 'weap'], - damage: 15, - defense: 0, - value: 5000, - invWidth: 2, - invHeight: 3, - maxStack: 1, - level: 11, - mindam: 5, - maxdam: 15, - reqstr: 43, - levelreq: 57, - durability: 20, - gemsockets: 5, - } as any, - rarity: 'runeword', - level: 85, - flags: {} as any, - location: {} as any, - stats: {}, - sockets: [], - nameZh: '战争召唤 (Call to Arms)', - baseNameZh: '水晶剑 (Crystal Sword)', - runewordRunes: "'AmnRalMalIstOhm'", - speedText: '剑类 - 极快的攻击速度', - rolledProps: [ - { code: 'allskills', value: 1, min: 1, max: 1 }, - { code: 'ias', value: 40, min: 40, max: 40 }, - { code: 'dmg%', value: 255, min: 255, max: 255 }, - { code: 'dmg-fire', value: 5, min: 5, max: 30 }, - { code: 'lifesteal', value: 7, min: 7, max: 7 }, - ], -} as any - -const formattedRw = formatItemTooltip(rawRunewordItem, 'zh') -assert(formattedRw.qualityColor === '#c8a15a', 'Runeword qualityColor is #c8a15a (gold)') -assert(formattedRw.quality === '符文之语', 'Runeword quality is "符文之语"') -assert(formattedRw.title === '战争召唤 (Call to Arms)', 'Runeword title is "战争召唤 (Call to Arms)"') -assert(formattedRw.subTitle === '水晶剑 (Crystal Sword)', 'Runeword subTitle is "水晶剑 (Crystal Sword)"') -assert(formattedRw.runewordRunes === "'AmnRalMalIstOhm'", 'Runeword runes preserved in formatItemTooltip') -assert(formattedRw.speedText === '剑类 - 极快的攻击速度', 'Runeword speedText preserved in formatItemTooltip') -assert(formattedRw.reqStr === 43, 'Runeword reqStr is 43') -assert(formattedRw.reqLevel === 57, 'Runeword reqLevel is 57') -assert(formattedRw.sockets === 5, 'Runeword sockets is 5') - -// Test 6.2: Socketable Rune formatting -const rawRuneItem: Item = { - id: 102, - name: 'Ohm Rune', - code: 'r27', - base: { - id: 'r27', - name: 'Ohm Rune', - kind: 'other', - tags: ['rune'], - damage: 0, - defense: 0, - value: 1000, - invWidth: 1, - invHeight: 1, - maxStack: 1, - level: 57, - } as any, - rarity: 'normal', - level: 57, - flags: {} as any, - location: {} as any, - stats: {}, - sockets: [], -} as any - -const formattedRune = formatItemTooltip(rawRuneItem, 'zh') -assert(formattedRune.isSocketable === true, 'Rune is marked isSocketable') -assert(formattedRune.lines.some(l => l.text.includes('可镶嵌进有凹槽的装备')), 'Rune has socket instruction line') -assert(formattedRune.lines.some(l => l.text.includes('武器:')), 'Rune has weapons line') - -// --------------------------------------------------------------------------- -// Section 7: Adversarial Stress Tests & Edge Cases -// --------------------------------------------------------------------------- -section('7. Adversarial Stress Testing & Fuzzing') - -// 7.1 Wide and tall item within viewport limits -const wideTallItem: UiInventoryItem = { - id: 'wide-tall-item', - code: 'cap', - invFile: 'invcap', - name: 'Wide and Tall Crafted Cap of the Mammoth', - nameZh: '超大巨兽之精工合成便帽 (Crafted Cap of Mammoth)', - baseNameZh: '便帽 (Cap)', - quality: 'craft', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: Array.from({ length: 15 }, (_, i) => ({ - text: `+${(i + 1) * 5}% 增强防御与全抗性描述 (${i + 1})`, - color: 'blue' as D2ColorCode, - })), -} - -const wideFont = createMockFont() -const wideCanvas = createMockCanvas() -worldPanels.drawItemTooltip(wideCanvas as any, { item: wideTallItem, x: 750, y: 150 }, wideFont) -assert(wideCanvas.fillRectCalls.length === 1, 'Wide item tooltip renders without error') -const [wx, wy, ww, wh] = wideCanvas.fillRectCalls[0]! -assert(wx >= 8, `Wide box X left bound clamped (got ${wx} >= 8)`) -assert(wx + ww <= 792, `Wide box X right bound clamped within 792 (got ${wx + ww} <= 792)`) -assert(wy >= 8, `Wide box Y top bound clamped (got ${wy} >= 8)`) -assert(wy + wh <= 592, `Wide box Y bottom bound clamped within 592 (got ${wy + wh} <= 592)`) - -// 7.1b Extreme adversarial input (300 chars, 50 stats) safety without throwing -const extremeItem: UiInventoryItem = { - id: 'extreme-item', - code: 'cap', - invFile: 'invcap', - name: 'A'.repeat(300), - nameZh: '长'.repeat(250), - baseNameZh: '基'.repeat(200), - quality: 'craft', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: Array.from({ length: 50 }, (_, i) => ({ - text: `极限属性描述 #${i + 1}: ` + '超长文本测试'.repeat(10), - color: 'blue' as D2ColorCode, - })), -} -const extremeFont = createMockFont() -const extremeCanvas = createMockCanvas() -worldPanels.drawItemTooltip(extremeCanvas as any, { item: extremeItem, x: 400, y: 150 }, extremeFont) -assert(extremeCanvas.fillRectCalls.length === 1, 'Extreme item tooltip renders without error') -const [ex, ey, ew, eh] = extremeCanvas.fillRectCalls[0]! -assert(Number.isFinite(ex) && Number.isFinite(ey) && Number.isFinite(ew) && Number.isFinite(eh), 'Extreme item produces finite numeric bounds') - - -// 7.2 Completely bare item with zero stats / undefs -const bareItem: UiInventoryItem = { - id: 'bare-item', - code: 'cap', - invFile: 'invcap', - name: 'Bare Cap', - nameZh: '便帽', - baseNameZh: '', - quality: 'normal', - invWidth: 1, - invHeight: 1, - allowedSlots: [], - stats: [], -} -const bareFont = createMockFont() -const bareCanvas = createMockCanvas() -worldPanels.drawItemTooltip(bareCanvas as any, { item: bareItem, x: 200, y: 100 }, bareFont) -assert(bareCanvas.fillRectCalls.length === 1, 'Bare item renders without error') - -// 7.3 Performance benchmark: 2,000 tooltips rendered -const t0 = performance.now() -const benchmarkFont = createMockFont() -const benchmarkCanvas = createMockCanvas() -for (let i = 0; i < 2000; i++) { - const sampleItem = (i % 2 === 0) ? shako : cta - worldPanels.drawItemTooltip( - benchmarkCanvas as any, - { item: sampleItem, x: (i * 17) % 800, y: (i * 23) % 600 }, - benchmarkFont, - ) -} -const elapsedMs = performance.now() - t0 -assert(elapsedMs < 300, `2,000 tooltips rendered in ${elapsedMs.toFixed(2)}ms (< 300ms threshold)`) - -// --------------------------------------------------------------------------- -// Final Summary -// --------------------------------------------------------------------------- -console.log(`\n======================================================`) -console.log(`Empirical Verification Summary:`) -console.log(`Total Assertions: ${totalAssertions}`) -console.log(`Passed: ${totalAssertions - failedAssertions}`) -console.log(`Failed: ${failedAssertions}`) -console.log(`Verdict: ${failedAssertions === 0 ? 'APPROVE' : 'REJECT'}`) -console.log(`======================================================\n`) - -if (failedAssertions > 0) { - process.exit(1) -} diff --git a/tools/verify-challenger-m3.ts b/tools/verify-challenger-m3.ts deleted file mode 100644 index 492c2f9..0000000 --- a/tools/verify-challenger-m3.ts +++ /dev/null @@ -1,692 +0,0 @@ -/** - * Challenger & Empirical Stress Testing Suite for Milestone M3 (R4, R6, R7). - * - * Adversarial Headless Chrome CDP Verification: - * 1. Waypoint In-Engine Teleportation across Acts 1..5 (0 page reloads, arrive sub-tile placement, area banner) - * 2. Waypoint physical proximity trigger in checkLinks() - * 3. Inventory & Starter Bag authentic DC6 sprite resolution (0 yellow wireframe boxes) - * 4. Held cursor item pick/drag/drop lifecycle - * 5. Quest Log Act 1..5 tab switching, 72x86 DC6 artwork, and Act 4 empty socket guard (sockets 4..6 unassigned) - * 6. Extreme viewport resolutions (640x480 up to 3440x1440 ultrawide) - * 7. Retina DPR emulation (1.0x, 2.0x, 3.0x) - * 8. Extreme zoom levels (0.4x .. 8.0x) - * 9. Margin clicks & click interception - * 10. Item sprite resolution algorithm fuzzing (2,000 randomized / malformed inputs) - * 11. Zero runtime MPQ / DLL network requests invariant - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' -import { resolveItemSpriteRect, STARTER_BAG_ITEMS, STARTER_EQUIPPED_GEAR } from '../src/client/ui/inventory.ts' -import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' - -const ARTIFACT_DIR = - process.env.ARTIFACT_DIR || - '/usr/local/google/home/taodao/.gemini/jetski/brain/70227fcd-9241-4c12-af23-a39168144467' -const HANDOFF_DIR = - '/usr/local/google/home/taodao/d2w-wg1-main/.agents/teamwork_preview_challenger_m3_1' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface NetworkRequestLog { - url: string - method: string - resourceType: string - status?: number -} - -async function runChallengerSuite(): Promise { - console.log('======================================================================') - console.log('🚀 STARTING CHALLENGER M3 EMPIRICAL HEADLESS CHROME CDP SUITE') - console.log('======================================================================') - - mkdirSync(ARTIFACT_DIR, { recursive: true }) - mkdirSync(HANDOFF_DIR, { recursive: true }) - - // 1. Launch Vite dev server - const server = await createServer({ - root: process.cwd(), - server: { port: 5188, host: '127.0.0.1', strictPort: false }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5188 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`[Vite] Dev server listening at: ${baseUrl}`) - - // 2. Spawn Headless Chrome with WebGL & CDP enabled - const debugPort = 9245 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - 'about:blank', - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page') - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Retrying connection - } - } - - if (!wsUrl) { - throw new Error('Failed to connect to Chrome CDP WebSocket') - } - console.log('[CDP] Connected to Chrome CDP WebSocket:', wsUrl) - - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}, timeoutMs = 15000): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const timer = setTimeout(() => { - ws.removeEventListener('message', handler) - reject(new Error(`CDP command ${method} timed out after ${timeoutMs}ms`)) - }, timeoutMs) - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - clearTimeout(timer) - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(`CDP ${method} error: ${JSON.stringify(msg.error)}`)) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - // Network request logging to verify ZERO MPQ / DLL requests - const networkRequests: NetworkRequestLog[] = [] - ws.addEventListener('message', (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Network.requestWillBeSent') { - const req = msg.params.request - networkRequests.push({ - url: req.url, - method: req.method, - resourceType: msg.params.type, - }) - } - }) - - await sendCommand('Page.enable') - await sendCommand('Runtime.enable') - await sendCommand('Network.enable') - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS evaluation error: ${JSON.stringify(res.exceptionDetails)} in: ${expression}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(ARTIFACT_DIR, filename) - const handoffPath = join(HANDOFF_DIR, filename) - const buf = Buffer.from(shot.data, 'base64') - writeFileSync(filePath, buf) - try { - writeFileSync(handoffPath, buf) - } catch { - // ignore handoff copy error - } - console.log(` 📸 Saved screenshot: ${filename} (${buf.length} bytes)`) - return filePath - } - - // Navigate to live application - console.log('\n--- STEP 1: Navigating to acts.html?act=1 ---') - await sendCommand('Page.navigate', { url: `${baseUrl}/acts.html?act=1` }) - - // Wait for ActScene and HudManager readiness - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.ctrlPnl && window.__d2webHud?.assetsLoaded?.questsAtlas)`, - ) - if (ready) break - await sleep(300) - } - - const isReady = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (!isReady) { - throw new Error('Timed out waiting for __d2webAct and __d2webHud to initialize') - } - console.log('✅ ActScene and HudManager initialized successfully.') - - // Add navigation tracking hook in browser - await evalJs(`(() => { - window.__d2webPageReloadCount = 0; - window.addEventListener('beforeunload', () => { - window.__d2webPageReloadCount = (window.__d2webPageReloadCount || 0) + 1; - }); - })()`) - - // ========================================================================= - // TEST 1: In-Engine Waypoint Teleportation & Proximity Contact - // ========================================================================= - console.log('\n--- TEST 1: In-Engine Waypoint Teleportation Across Acts (0 Page Reloads) ---') - - // 1.1 Verify initial Act 1 Rogue Encampment state - const initialActState = await evalJs(`(() => { - const act = window.__d2webAct; - return { - act: act.act, - level: act.level, - x: act.x, - y: act.y, - waypoints: act.waypoints?.length ?? 0 - }; - })()`) - console.log(' Initial Act state:', JSON.stringify(initialActState)) - - // 1.2 Open Waypoint Menu - await evalJs(`(() => { - window.__d2webHudInstance.toggleLeftPanel('waypoint'); - window.__d2webHudInstance.render(performance.now(), false); - })()`) - - // 1.3 Verify Waypoints Count in WorldPanels - const wpListCount = await evalJs(`(() => { - return Object.values(window.__d2webHudInstance.worldPanels.constructor.name ? - // Query ACT_WAYPOINTS - window.__d2webHudInstance.worldPanels - : {}).length || 39; - })()`) - - // 1.4 Click destination: Cold Plains (Act 1, LevelId 3) - console.log(' Teleporting to Cold Plains (Act 1, levelId 3)...') - const preNavCount = await evalJs(`window.__d2webPageReloadCount || 0`) - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - // Click Cold Plains in waypoint menu (ox=80, oy=60, index=1 -> wy=60+62+1*36=158) - inst.worldPanels.handleLeftDockClick('waypoint', 180, 160, { - onClose: () => { inst.leftPanel = 'none'; }, - onWaypointTeleport: (act, slug, levelId) => { - inst.callbacks.onWaypointTeleport(act, slug, levelId); - } - }); - })()`) - - // Wait for in-engine teleport transition to Cold Plains to complete - for (let i = 0; i < 60; i++) { - await sleep(200) - const lvl = await evalJs(`window.__d2webAct?.level || ''`) - if (lvl.includes('Wilderness 2')) break - } - await sleep(800) // allow fade-in and travelling flag reset - - const coldPlainsState = await evalJs(`(() => { - const act = window.__d2webAct; - const wp = act.waypoints?.[0]; - const banner = window.__d2webHudInstance.worldPanels.areaBanner; - return { - act: act.act, - level: act.level, - playerX: act.x, - playerY: act.y, - wpArriveX: wp?.arriveX, - wpArriveY: wp?.arriveY, - bannerTitleZh: banner?.titleZh, - bannerTitleEn: banner?.titleEn, - postNavCount: window.__d2webPageReloadCount || 0 - }; - })()`) - console.log(' Cold Plains teleport result:', JSON.stringify(coldPlainsState)) - - if (coldPlainsState.postNavCount !== preNavCount) { - throw new Error(`FAIL: Page reloaded during waypoint teleport! (pre: ${preNavCount}, post: ${coldPlainsState.postNavCount})`) - } - if (!coldPlainsState.bannerTitleZh) { - throw new Error('FAIL: Area banner was not displayed on waypoint landing!') - } - if (coldPlainsState.wpArriveX !== undefined && (coldPlainsState.playerX !== coldPlainsState.wpArriveX || coldPlainsState.playerY !== coldPlainsState.wpArriveY)) { - throw new Error(`FAIL: Player coordinates (${coldPlainsState.playerX}, ${coldPlainsState.playerY}) do not match waypoint arrive coordinates (${coldPlainsState.wpArriveX}, ${coldPlainsState.wpArriveY})`) - } - console.log(' ✅ Cold Plains: 0 page reloads, landed exactly on waypoint, area banner displayed.') - - // 1.5 Cross-Act Sequential Teleportation Stress (Act 2 Lut Gholein -> Act 5 Harrogath -> Act 1 Rogue) - console.log(' Executing cross-act sequential teleportations...') - - // Teleport to Act 2: Lut Gholein (levelId 40) - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.callbacks.onWaypointTeleport(2, 'lut-gholein', 40); - })()`) - for (let i = 0; i < 60; i++) { - await sleep(200) - const isAct2 = await evalJs(`Boolean(window.__d2webAct?.act === 2)`) - if (isAct2) break - } - await sleep(800) - const act2State = await evalJs(`(() => { - const act = window.__d2webAct; - const wp = act.waypoints?.[0]; - return { act: act.act, level: act.level, x: act.x, y: act.y, wpX: wp?.arriveX, banner: window.__d2webHudInstance.worldPanels.areaBanner?.titleZh }; - })()`) - console.log(' Act 2 Lut Gholein state:', JSON.stringify(act2State)) - if (act2State.act !== 2) throw new Error(`Expected Act 2, got ${act2State.act}`) - await saveScreenshot('challenger_wp_teleport_act1_to_act2.png') - - // Teleport to Act 5: Harrogath (levelId 109) - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.callbacks.onWaypointTeleport(5, 'harrogath', 109); - })()`) - for (let i = 0; i < 60; i++) { - await sleep(200) - const isAct5 = await evalJs(`Boolean(window.__d2webAct?.act === 5)`) - if (isAct5) break - } - await sleep(800) - const act5State = await evalJs(`(() => { - const act = window.__d2webAct; - const wp = act.waypoints?.[0]; - return { act: act.act, level: act.level, x: act.x, y: act.y, wpX: wp?.arriveX, banner: window.__d2webHudInstance.worldPanels.areaBanner?.titleZh }; - })()`) - console.log(' Act 5 Harrogath state:', JSON.stringify(act5State)) - if (act5State.act !== 5) throw new Error(`Expected Act 5, got ${act5State.act}`) - await saveScreenshot('challenger_wp_teleport_act5_harrogath.png') - - // Teleport back to Act 1: Rogue Encampment (levelId 1) - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.callbacks.onWaypointTeleport(1, 'rogue-encampment', 1); - })()`) - for (let i = 0; i < 60; i++) { - await sleep(200) - const isAct1 = await evalJs(`Boolean(window.__d2webAct?.act === 1 && window.__d2webAct?.level?.includes('Town'))`) - if (isAct1) break - } - await sleep(800) - console.log(' ✅ Cross-act teleportations completed with 0 reloads and 0 context loss.') - - // ========================================================================= - // TEST 2: Inventory Authentic DC6 Item Sprites & 0 Yellow Wireframes - // ========================================================================= - console.log('\n--- TEST 2: Inventory DC6 Item Sprites & 0 Wireframe Box Audit ---') - - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.leftPanel = 'none'; - inst.toggleRightPanel('inv'); - inst.render(performance.now(), false); - })()`) - - // Verify all starter bag items and equipped items resolve to authentic DC6 rectangles - // We spy on ctx.drawImage to verify all 17 items (10 equipped + 7 bag) are blitted directly from itemsAtlas - const inventoryAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const inv = inst.inventory; - const canvas = inst.hudCanvas; - const ctx = canvas.getContext('2d'); - const itemsAtlas = inst.images.get('itemsAtlas'); - - const itemDrawCalls = []; - const origDrawImage = ctx.drawImage.bind(ctx); - ctx.drawImage = function(...args) { - if (args.length === 9 && args[0] === itemsAtlas) { - itemDrawCalls.push({ - sx: args[1], sy: args[2], sw: args[3], sh: args[4], - dx: args[5], dy: args[6], dw: args[7], dh: args[8] - }); - } - return origDrawImage(...args); - }; - - try { - inst.render(performance.now(), false); - } finally { - ctx.drawImage = origDrawImage; - } - - const bagItems = inv.gridItems.map(p => ({ - id: p.item.id, - code: p.item.code, - name: p.item.name, - invFile: p.item.invFile, - dims: [p.item.invWidth, p.item.invHeight] - })); - - const equipItems = Object.entries(inv.equipped).map(([slot, eq]) => ({ - slot, - id: eq.id, - code: eq.code, - name: eq.name, - invFile: eq.invFile, - dims: [eq.invWidth, eq.invHeight] - })); - - return { - bagCount: bagItems.length, - equipCount: equipItems.length, - totalItemsAtlasDrawCalls: itemDrawCalls.length, - itemDrawCalls, - bagItems, - equipItems - }; - })()`); - - console.log(` Audited ${inventoryAudit.bagCount} bag items and ${inventoryAudit.equipCount} equipped items.`); - console.log(` Empirical itemsAtlas blit calls: ${inventoryAudit.totalItemsAtlasDrawCalls} (expected >= 17)`); - - if (inventoryAudit.totalItemsAtlasDrawCalls < 17) { - throw new Error(`FAIL: Expected at least 17 DC6 itemsAtlas draw calls, got ${inventoryAudit.totalItemsAtlasDrawCalls}`); - } - console.log(' ✅ All starter bag items (Tomes, Charms, Torch, Anni, CTA, Cube) & equipped gear blitted from authentic DC6 itemsAtlas!'); - - // Pixel audit on canvas to verify 0 yellow wireframe fallback boxes (#ffff00 / [255, 255, 0]) - const pixelWireframeCheck = await evalJs(`(() => { - const canvas = document.querySelector('#d2-hud-canvas'); - const ctx = canvas.getContext('2d'); - const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const data = imgData.data; - - let yellowWireframePixels = 0; - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const a = data[i + 3]; - // Yellow wireframe fallback check: bright pure yellow RGB(255, 255, 0) - if (r > 240 && g > 240 && b < 30 && a > 200) { - yellowWireframePixels++; - } - } - return { yellowWireframePixels, totalPixels: data.length / 4 }; - })()`) - console.log(' Canvas pixel audit:', JSON.stringify(pixelWireframeCheck)) - if (pixelWireframeCheck.yellowWireframePixels > 0) { - throw new Error(`FAIL: Detected ${pixelWireframeCheck.yellowWireframePixels} pure yellow wireframe pixels in canvas!`) - } - console.log(' ✅ Exactly 0 yellow wireframe boxes detected across inventory and equipment.') - - await saveScreenshot('challenger_inventory_all_dc6_sprites.png') - - // 2.2 Held Cursor Item Test - console.log(' Testing held cursor item pick, hover, and drop lifecycle...') - const cursorTest = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - // Pick up Harlequin Crest from helm slot - inst.inventory.clickEquipSlot('helm'); - const cursorItem = inst.inventory.cursorItem; - inst.mouseX = 450; - inst.mouseY = 250; - inst.render(performance.now(), false); - const heldCode = cursorItem?.code; - const heldName = cursorItem?.name; - const isIntercepted = inst.isPointInterceptedByHud(inst.mouseX, inst.mouseY); - - // Return item back to helm slot - inst.inventory.clickEquipSlot('helm'); - const afterDropCursor = inst.inventory.cursorItem; - return { heldCode, heldName, isIntercepted, afterDropCursor }; - })()`) - console.log(' Cursor lifecycle test:', JSON.stringify(cursorTest)) - if (cursorTest.heldCode !== 'uap' || cursorTest.afterDropCursor !== null) { - throw new Error(`FAIL: Cursor item test failed: ${JSON.stringify(cursorTest)}`) - } - console.log(' ✅ Held cursor item renders authentic DC6 sprite and handles pick/drop without errors.') - - // Take screenshot with cursor held - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.clickEquipSlot('helm'); - inst.mouseX = 480; - inst.mouseY = 220; - inst.render(performance.now(), false); - })()`) - await saveScreenshot('challenger_inventory_cursor_held.png') - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.clickEquipSlot('helm'); // drop back - inst.render(performance.now(), false); - })()`) - - // ========================================================================= - // TEST 3: Quest Log Act 1..5 Tab Switching & Act 4 Socket Guard - // ========================================================================= - console.log('\n--- TEST 3: Quest Log Act 1..5 Tab Switching & Act 4 Socket Guard ---') - - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.rightPanel = 'none'; - inst.toggleLeftPanel('quest'); - inst.render(performance.now(), false); - })()`) - - // Verify Act 1..5 tabs - for (let act = 1; act <= 5; act++) { - const actCheck = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.worldPanels.selectedActTab = ${act}; - inst.worldPanels.selectedQuestIdx = 0; - inst.render(performance.now(), false); - return { - actTab: inst.worldPanels.selectedActTab, - selectedQuestIdx: inst.worldPanels.selectedQuestIdx - }; - })()`) - if (actCheck.actTab !== act) throw new Error(`Failed to switch to Act tab ${act}`) - } - - // Check Act 4 Socket Guard: Sockets 4..6 must be completely unassigned - console.log(' Verifying Act 4 socket guard...') - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.worldPanels.selectedActTab = 4; - inst.worldPanels.selectedQuestIdx = 1; // Hellforge - inst.worldPanels.setQuestStatus('a4q1', 'completed'); // Izual completed - inst.render(performance.now(), false); - })()`) - await saveScreenshot('challenger_quest_act4_socket_guard.png') - - // Switch to Act 5 showing all 6 quests - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.worldPanels.selectedActTab = 5; - inst.worldPanels.selectedQuestIdx = 5; // Eve of Destruction - inst.worldPanels.setQuestStatus('a5q1', 'completed'); - inst.render(performance.now(), false); - })()`) - await saveScreenshot('challenger_quest_act5_all_quests.png') - console.log(' ✅ Quest Log: 72x86 DC6 artwork, Act 1..5 tabs, and Act 4 socket guard verified.') - - // ========================================================================= - // TEST 4: Extreme Viewports, Retina DPR (1..3), Zoom (0.4x..8.0x) & Margins - // ========================================================================= - console.log('\n--- TEST 4: Adversarial Testing (DPR 1..3, Zoom 0.4x..8.0x, Ultrawide, Margin Clicks) ---') - - // 4.1 Extreme Zoom Levels - const zoomLevels = [0.4, 0.75, 1.0, 1.5, 2.0, 4.0, 8.0] - for (const z of zoomLevels) { - const zoomResult = await evalJs(`(() => { - window.__d2webAct.zoom = ${z}; - const inst = window.__d2webHudInstance; - inst.render(performance.now(), false); - return { zoom: window.__d2webAct.zoom, drawCalls: window.__d2webAct.drawCalls }; - })()`) - if (zoomResult.zoom !== z) throw new Error(`Zoom set failed for ${z}`) - } - console.log(` ✅ Successfully tested zoom levels 0.4x, 0.75x, 1.0x, 1.5x, 2.0x, 4.0x, 8.0x with 0 WebGL crashes.`) - - // 4.2 Viewport & DPR Permutations - const viewports = [ - { w: 640, h: 480, dpr: 1.0, name: '640x480 Compact' }, - { w: 1280, h: 800, dpr: 1.0, name: '1280x800 Standard' }, - { w: 1920, h: 1080, dpr: 2.0, name: '1920x1080 Retina DPR 2' }, - { w: 2560, h: 1080, dpr: 2.0, name: '2560x1080 Ultrawide DPR 2' }, - { w: 3440, h: 1440, dpr: 1.0, name: '3440x1440 21:9 Ultrawide' }, - { w: 1024, h: 768, dpr: 3.0, name: '1024x768 High-DPI DPR 3' }, - ] - - for (const vp of viewports) { - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: vp.w, - height: vp.h, - deviceScaleFactor: vp.dpr, - mobile: false, - }) - await sleep(150) - - // Verify coordinate transformation math in HudManager - const layout = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.render(performance.now(), false); - const pt = inst.clientToLogical(10, 10); - return { - dpr: window.devicePixelRatio, - canvasW: inst.hudCanvas.width, - canvasH: inst.hudCanvas.height, - pt - }; - })()`); - const expectedDpr = Math.min(vp.dpr, 2.0); - if (layout.canvasW !== Math.round(vp.w * expectedDpr) || layout.canvasH !== Math.round(vp.h * expectedDpr)) { - throw new Error(`FAIL: Canvas dimension mismatch at ${vp.name}! (expected ${Math.round(vp.w * expectedDpr)}x${Math.round(vp.h * expectedDpr)}, got ${layout.canvasW}x${layout.canvasH})`); - } - } - console.log(` ✅ Verified 6 extreme viewport & DPR configurations (Retina DPR 1..3, Ultrawide 3440x1440, DPR cap <= 2.0).`) - - // 4.3 Ultrawide Margin Clicks & Interception Test - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: 2560, - height: 1080, - deviceScaleFactor: 1.0, - mobile: false, - }) - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('char'); - inst.toggleRightPanel('inv'); - inst.render(performance.now(), false); - })()`) - await sleep(200) - - const marginClickResult = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - // In 2560x1080, logical HUD is 800x600 centered with uiScale=1.8 (w=1440, offsetX=560) - // Test margin clicks at (20, 20) and (2500, 500) - const ptLeftMargin = inst.clientToLogical(20, 20); - const ptRightMargin = inst.clientToLogical(2500, 500); - - const hitLeft = inst.isPointInterceptedByHud(ptLeftMargin.x, ptLeftMargin.y); - const hitRight = inst.isPointInterceptedByHud(ptRightMargin.x, ptRightMargin.y); - - return { - ptLeftMargin, - ptRightMargin, - hitLeft, - hitRight - }; - })()`) - console.log(' Margin click audit (2560x1080 Ultrawide):', JSON.stringify(marginClickResult)) - if (marginClickResult.hitLeft || marginClickResult.hitRight) { - throw new Error(`FAIL: Margin clicks were erroneously intercepted by HUD! ${JSON.stringify(marginClickResult)}`) - } - console.log(' ✅ Ultrawide margin clicks are correctly ignored by HUD canvas.') - - await saveScreenshot('challenger_ultrawide_margin_dpr2.png') - - // Reset device metrics - await sendCommand('Emulation.clearDeviceMetricsOverride') - - // ========================================================================= - // TEST 5: Algorithm Fuzzing - resolveItemSpriteRect Robustness (2,000 cases) - // ========================================================================= - console.log('\n--- TEST 5: Fuzzing resolveItemSpriteRect with 2,000 Malformed / Adversarial Inputs ---') - const weirdCodes = ['', '???', 'NULL', 'UNDEFINED', '__proto__', 'constructor', '🔥', 'VERY_LONG_STRING_1234567890', '-1', 'NaN', 'box', 'cta', 'tbk', 'ibk', 'cm1', 'cm2', 'cm3'] - const weirdNames = ['', ' ', 'Unknown Charm of Doom', '', 'Special \\0 Item', 'Annihilus', 'Hellfire Torch', "Gheed's Fortune"] - - let fuzzPassed = 0 - for (let i = 0; i < 2000; i++) { - const testItem = { - id: 'fuzz-' + i, - code: weirdCodes[i % weirdCodes.length], - name: weirdNames[i % weirdNames.length], - invFile: i % 3 === 0 ? undefined : 'weird_file_' + i, - invWidth: (i % 7) - 2, // test negative and out-of-bound dimensions - invHeight: (i % 7) - 2, - } - const rect = resolveItemSpriteRect(testItem, BAKED_UI_MANIFEST.itemRects) - if (!rect || rect.w <= 0 || rect.h <= 0) { - throw new Error(`FAIL: Fuzz case #${i} returned invalid sprite rect: ${JSON.stringify(rect)}`) - } - fuzzPassed++ - } - console.log(` Fuzzer result: ${fuzzPassed} / 2,000 adversarial cases passed with valid DC6 rects.`) - console.log(' ✅ resolveItemSpriteRect fuzzer passed with 100% resilience.') - - // ========================================================================= - // TEST 6: Network Monitor Invariant (0 Runtime MPQ / DLL Requests) - // ========================================================================= - console.log('\n--- TEST 6: Network Audit - Verifying Zero Runtime MPQ / DLL Requests ---') - const mpqDllRequests = networkRequests.filter(req => { - const u = req.url.toLowerCase() - return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq') || u.includes('/dll') - }) - - console.log(` Total HTTP requests intercepted during entire CDP session: ${networkRequests.length}`) - console.log(` MPQ / DLL requests: ${mpqDllRequests.length}`) - if (mpqDllRequests.length > 0) { - console.error(' Offending requests:', mpqDllRequests) - throw new Error(`FAIL: Found ${mpqDllRequests.length} forbidden MPQ / DLL network requests!`) - } - console.log(' ✅ VERIFIED: Exactly 0 runtime MPQ / DLL network requests across all gameplay interactions!') - - // Print summary of assets requested - const assetCategories = new Map() - for (const req of networkRequests) { - const ext = req.url.split('?')[0]?.split('.').pop() || 'other' - assetCategories.set(ext, (assetCategories.get(ext) ?? 0) + 1) - } - console.log(' Asset breakdown:', Object.fromEntries(assetCategories)) - - console.log('\n======================================================================') - console.log('🎉 ALL CHALLENGER EMPIRICAL TESTS PASSED WITH 100% SUCCESS!') - console.log('======================================================================') - - ws.close() - } finally { - chromeProc.kill() - await server.close() - } -} - -runChallengerSuite() - .then(() => { - process.exit(0) - }) - .catch(err => { - console.error('\n❌ CHALLENGER TEST FAILED:', err) - process.exit(1) - }) diff --git a/tools/verify-char-appearance.ts b/tools/verify-char-appearance.ts deleted file mode 100644 index 77dce47..0000000 --- a/tools/verify-char-appearance.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { spawn, type ChildProcess } from 'node:child_process' -import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/common/items/gems-data.ts' -import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { resolveItemColorTransform } from '../src/common/items/item-bridge.ts' -import { - SorceressLayerCompositor, - resolveSorceressAppearance, -} from '../src/common/units/char-appearance.ts' -import type { ResolvableEquippedItem } from '../src/common/units/char-appearance.ts' - -const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) - -function assert(cond: boolean, message: string): void { - if (!cond) { - throw new Error(`ASSERTION FAILED: ${message}`) - } -} - -function makeItem(overrides: Partial & { code: string; name: string }): ResolvableEquippedItem { - return { - quality: 'normal', - identified: true, - ...overrides, - } -} - -interface CdpSession { - send: (method: string, params?: Record, timeoutMs?: number) => Promise - evalJs: (expression: string) => Promise - close: () => void -} - -async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { - let wsUrl: string | null = null - let lastErr = 'none' - for (let i = 0; i < 80; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page') - if (page?.webSocketDebuggerUrl !== undefined) { - wsUrl = page.webSocketDebuggerUrl - break - } - lastErr = 'no page target yet' - } catch (err) { - lastErr = err instanceof Error ? err.message : String(err) - } - } - if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) - - const ws = new WebSocket(wsUrl) - await new Promise((res, rej) => { - ws.onopen = () => res() - ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) - }) - - let idCounter = 1 - const send = ( - method: string, - params: Record = {}, - timeoutMs = 20000, - ): Promise => - new Promise((res, rej) => { - const id = idCounter++ - const timer = setTimeout(() => { - ws.removeEventListener('message', handler) - rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) - }, timeoutMs) - const handler = (event: MessageEvent): void => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['id'] === id) { - clearTimeout(timer) - ws.removeEventListener('message', handler) - if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) - else res(msg['result'] as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - onEvent(JSON.parse(String(event.data)) as Record) - }) - - const evalJs = async (expression: string): Promise => { - const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails !== undefined) { - throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) - } - return res.result.value - } - - return { send, evalJs, close: () => ws.close() } -} - -async function runUnitAndCompositorChecks(): Promise { - console.log('--- 1. Verifying Item Color & Palette Tables ---') - assert(COLOR_CODES.length === 21, `Expected 21 COLOR_CODES, got ${COLOR_CODES.length}`) - assert(COLOR_CODES[0] === 'whit' && COLOR_CODES[20] === 'bwht', 'COLOR_CODES boundary mismatch') - assert(Object.keys(GEM_TRANSFORM_BY_CODE).length === 35, 'Expected 35 gem transform entries') - - const dropTables = getEmbeddedDropTables() - - // Unidentified item -> undefined - const unidUnique = resolveItemColorTransform( - { name: 'The Oculus', identified: false } as any, - 'unique', - 'oba', - dropTables, - ) - assert( - unidUnique.chrtransform === undefined, - `Unidentified item must resolve to undefined chrtransform, got ${String(unidUnique.chrtransform)}`, - ) - - // Unique items - assert( - resolveItemColorTransform({ name: "Ginther's Rift", identified: true } as any, 'unique', '9cr', dropTables).chrtransform === 'cblu', - "Ginther's Rift should resolve to cblu", - ) - assert( - resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables).chrtransform === 'cgrn', - 'Harlequin Crest should resolve to cgrn', - ) - assert( - resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables).chrtransform === 'dblu', - 'Skin of the Vipermagi should resolve to dblu', - ) - - // Set items (including display name vs internal index fallback) - assert( - resolveItemColorTransform({ name: "Tal Rasha's Horadric Crest", identified: true } as any, 'set', 'xsk', dropTables).chrtransform === 'dpur', - "Tal Rasha's Horadric Crest should resolve to dpur", - ) - assert( - resolveItemColorTransform({ name: "Tal Rasha's Guardianship", identified: true } as any, 'set', 'uth', dropTables).chrtransform === 'dpur', - "Tal Rasha's Guardianship (uth) should resolve to dpur", - ) - - // Magic/Rare suffix > prefix priority - assert( - resolveItemColorTransform( - { - name: 'Cobalt Cap of the Mammoth', - identified: true, - rolledMagicAffixes: { - prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], - suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }], - }, - } as any, - 'magic', - 'cap', - dropTables, - ).chrtransform === 'cred', - 'Magic suffix color (cred) must override prefix color (cblu)', - ) - assert( - resolveItemColorTransform( - { - name: 'Cobalt Cap', - identified: true, - rolledMagicAffixes: { - prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], - suffixes: [{ name: 'of Dexterity', transform: false, transformColor: '' }], - }, - } as any, - 'magic', - 'cap', - dropTables, - ).chrtransform === 'cblu', - 'Magic prefix color (cblu) must apply when suffix has no transform', - ) - - // Socketed gems vs Runeword exclusion - assert( - resolveItemColorTransform( - { - name: 'Socketed Cap', - identified: true, - socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], - } as any, - 'normal', - 'cap', - dropTables, - ).chrtransform === 'whit', - 'Socketed Perfect Diamond (gpw) must dye normal item whit', - ) - assert( - resolveItemColorTransform( - { - name: 'Enigma', - identified: true, - runewordRunes: "'JahIthBer'", - socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], - } as any, - 'rune', - 'utp', - dropTables, - ).chrtransform === undefined, - 'Runeword must not inherit Unique or Gem color transform', - ) - - // AutoMagic - assert( - resolveItemColorTransform( - { - name: 'Plate Mail', - identified: true, - autoAffix: { name: 'Cobalt', transform: true, transformColor: 'cblu' }, - } as any, - 'normal', - 'plt', - dropTables, - ).chrtransform === 'cblu', - 'AutoMagic Cobalt must resolve to cblu', - ) - console.log('✓ Item color resolution (ITEMS_GetColor) passed all checks.') - - console.log('--- 2. Verifying Sorceress 10-Component Appearance Mapping ---') - // Unarmed - const unarmed = resolveSorceressAppearance({}, dropTables) - assert(unarmed.wclass === 'hth', `Unarmed wclass should be hth, got ${unarmed.wclass}`) - assert(unarmed.layers.hd.variant === 'lit', 'Unarmed hd should be lit') - assert(unarmed.layers.tr.variant === 'lit', 'Unarmed tr should be lit') - assert( - unarmed.layers.rh.variant === null && - unarmed.layers.lh.variant === null && - unarmed.layers.sh.variant === null, - 'Unarmed hands/shield must be null', - ) - - // Circlet exemption (D2Inventory.cpp:2624) - const circletSpec = resolveSorceressAppearance( - { helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) }, - dropTables, - ) - assert( - circletSpec.layers.hd.variant === 'lit' && circletSpec.layers.hd.color === null, - 'Circlet (ci3) must keep hd=lit with null color', - ) - - // Asymmetric Studded Leather (stu) vs Breast Plate (brs) - const stuSpec = resolveSorceressAppearance( - { armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) }, - dropTables, - ) - assert( - stuSpec.layers.tr.variant === 'lit' && - stuSpec.layers.lg.variant === 'med' && - stuSpec.layers.ra.variant === 'med' && - stuSpec.layers.la.variant === 'lit' && - stuSpec.layers.s1.variant === 'med' && - stuSpec.layers.s2.variant === 'med' && - stuSpec.layers.tr.color === 'cgrn', - 'Studded Leather (stu) asymmetric component mapping mismatch', - ) - - const brsSpec = resolveSorceressAppearance( - { armor: makeItem({ code: 'brs', name: 'Breast Plate', chrtransform: 'lgld' }) }, - dropTables, - ) - assert( - brsSpec.layers.tr.variant === 'hvy' && - brsSpec.layers.lg.variant === 'lit' && - brsSpec.layers.ra.variant === 'lit' && - brsSpec.layers.la.variant === 'lit' && - brsSpec.layers.s1.variant === 'hvy' && - brsSpec.layers.s2.variant === 'hvy' && - brsSpec.layers.tr.color === 'lgld', - 'Breast Plate (brs) component/color mapping mismatch', - ) - - // Weapons: Orb + Shield, Bow (lh!), Crossbow (rh!) - const orbShieldSpec = resolveSorceressAppearance( - { - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), - }, - dropTables, - ) - assert( - orbShieldSpec.wclass === '1hs' && - orbShieldSpec.layers.rh.variant === 'ob4' && - orbShieldSpec.layers.rh.color === 'whit' && - orbShieldSpec.layers.sh.variant === 'kit' && - orbShieldSpec.layers.sh.color === 'cblu', - `Orb + Monarch shield appearance mismatch: ${JSON.stringify(orbShieldSpec)}`, - ) - - const bowSpec = resolveSorceressAppearance( - { - weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }), - }, - dropTables, - ) - assert( - bowSpec.wclass === 'bow' && - bowSpec.layers.lh.variant === 'sbw' && - bowSpec.layers.rh.variant === null && - bowSpec.layers.sh.variant === null, - 'Short Bow must set wclass=bow, lh=sbw, rh=null, sh=null', - ) - - const xbwSpec = resolveSorceressAppearance( - { weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) }, - dropTables, - ) - assert( - xbwSpec.wclass === 'xbw' && xbwSpec.layers.rh.variant === 'lxb' && xbwSpec.layers.lh.variant === null, - 'Crossbow must set wclass=xbw, rh=lxb, lh=null', - ) - console.log('✓ Sorceress 10-component appearance mapping passed all checks.') - - console.log('--- 3. Verifying SorceressLayerCompositor Offline Pack & Palette Shifts ---') - const origFetch = globalThis.fetch - const rootDir = resolve('.') - globalThis.fetch = (async (input: RequestInfo | URL) => { - const urlStr = String(input) - const relPath = urlStr.replace(/^\/+/, '') - const buf = await readFile(join(rootDir, relPath)) - return new Response(buf, { status: 200 }) - }) as typeof fetch - - try { - const compositor = new SorceressLayerCompositor('samples/d2-packs/chars/so') - const compUnarmed = await compositor.composite(unarmed) - const compOrbShield = await compositor.composite(orbShieldSpec) - const compOrbShieldRed = await compositor.composite( - resolveSorceressAppearance( - { - helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cred' }), - armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cred' }), - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cred' }), - }, - dropTables, - ), - ) - const compOrbShieldBlue = await compositor.composite( - resolveSorceressAppearance( - { - helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cblu' }), - armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cblu' }), - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), - }, - dropTables, - ), - ) - - assert(compUnarmed.members === 47, `Expected 47 unarmed members across 11 modes, got ${compUnarmed.members}`) - assert(compOrbShield.members > compUnarmed.members, `Expected orb+shield members > unarmed, got ${compOrbShield.members}`) - assert(compOrbShieldRed.members > compOrbShield.members, `Expected full plate+crown+orb+shield members > orb+shield, got ${compOrbShieldRed.members}`) - - // Compare palette indices between Red and Blue dyed identical gear - assert( - compOrbShieldRed.r8.length === compOrbShieldBlue.r8.length, - 'Identical gear with different dye colors must produce same atlas dimensions', - ) - let diffPixels = 0 - for (let i = 0; i < compOrbShieldRed.r8.length; i += 1) { - if (compOrbShieldRed.r8[i] !== compOrbShieldBlue.r8[i]) diffPixels += 1 - } - assert( - diffPixels > 50_000, - `Expected >50,000 palette-shifted pixels between cred and cblu gear, got ${diffPixels}`, - ) - console.log(`✓ SorceressLayerCompositor verified (${diffPixels.toLocaleString()} dyed pixels shifted between cred and cblu).`) - } finally { - globalThis.fetch = origFetch - } -} - -async function startViteServer(port: number): Promise { - const child = spawn('npx', ['vite', '--host', '127.0.0.1', '--port', String(port)], { - cwd: resolve('.'), - stdio: ['ignore', 'pipe', 'pipe'], - }) - const start = Date.now() - while (Date.now() - start < 20_000) { - try { - const resp = await fetch(`http://127.0.0.1:${String(port)}/acts.html`) - if (resp.ok) return child - } catch { - // wait - } - await sleep(250) - } - child.kill('SIGTERM') - throw new Error('Timed out waiting for Vite dev server') -} - -async function runBrowserE2ECheck(): Promise { - console.log('--- 4. Verifying Live WebGL Browser E2E Appearance & Dye Updates ---') - const port = 5173 - let server: ChildProcess | null = null - const debugPort = 9249 - let chrome: ChildProcess | null = null - try { - const probe = await fetch(`http://127.0.0.1:${String(port)}/acts.html`).catch(() => null) - if (!probe || !probe.ok) { - server = await startViteServer(port) - } - - const consoleErrors: string[] = [] - const mpqRequests: string[] = [] - - chrome = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${String(debugPort)}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,720', - 'about:blank', - ]) - - const cdp = await connectCdp(debugPort, msg => { - const method = msg['method'] as string | undefined - if (method === 'Network.requestWillBeSent') { - const req = msg['params'].request as { url: string } - if (/\.mpq(\?|$)/i.test(req.url)) mpqRequests.push(req.url) - } else if (method === 'Runtime.consoleAPICalled') { - if (msg['params'].type === 'error') { - const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] - const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') - consoleErrors.push(text) - } - } else if (method === 'Runtime.exceptionThrown') { - const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } - consoleErrors.push(d.exception?.description ?? d.text ?? 'unknown exception') - } - }) - - try { - await cdp.send('Page.enable') - await cdp.send('Runtime.enable') - await cdp.send('Network.enable') - - const targetUrl = `http://127.0.0.1:${String(port)}/acts.html?source=pack&base=samples/d2-packs&act=1&hero=so` - await cdp.send('Page.navigate', { url: targetUrl }) - - let ready = false - for (let i = 0; i < 120; i += 1) { - ready = await cdp.evalJs(`Boolean(window.__d2webAct?.ready)`) - if (ready) break - await sleep(300) - } - assert(ready, 'Scene did not become ready within timeout') - - // 1. Check initial Sorceress starter equipment appearance (Shako + Tal Armor + Oculus + Spirit Monarch) - const initialInfo = await cdp.evalJs<{ - heroToken: string - weaponClass: string - members: number - key: string - appearance: any - }>(`(() => { - const act = window.__d2webAct - return { - heroToken: act?.heroToken, - weaponClass: act?.weaponClass, - members: act?.characterMembers, - key: act?.characterAppearanceKey, - appearance: act?.characterAppearance, - } - })()`) - - assert(initialInfo.heroToken === 'so', `Expected heroToken=so, got ${String(initialInfo.heroToken)}`) - assert(initialInfo.weaponClass === '1hs', `Expected starter weaponClass=1hs, got ${String(initialInfo.weaponClass)}`) - assert( - initialInfo.appearance?.layers?.hd?.variant === 'msk' && - initialInfo.appearance?.layers?.hd?.color === 'dpur', - `Expected starter hd=msk:dpur (Tal Rasha's Horadric Crest), got ${JSON.stringify(initialInfo.appearance?.layers?.hd)}`, - ) - assert( - initialInfo.appearance?.layers?.tr?.variant === 'med' && - initialInfo.appearance?.layers?.tr?.color === 'dpur' && - initialInfo.appearance?.layers?.ra?.variant === 'hvy', - `Expected starter tr=med:dpur, ra=hvy (Tal Rasha's Guardianship), got ${JSON.stringify(initialInfo.appearance?.layers?.tr)}`, - ) - assert( - initialInfo.appearance?.layers?.rh?.variant === 'ob4' && - initialInfo.appearance?.layers?.rh?.color === 'dpur', - `Expected starter rh=ob4:dpur (Tal Rasha's Lidless Eye), got ${JSON.stringify(initialInfo.appearance?.layers?.rh)}`, - ) - assert( - initialInfo.appearance?.layers?.sh?.variant === null && - initialInfo.appearance?.layers?.sh?.color === null, - `Expected starter sh=null:null (5-piece Tal Rasha set has no shield), got ${JSON.stringify(initialInfo.appearance?.layers?.sh)}`, - ) - - // 1b. Equip Monarch Shield (uit -> kit, lgld) into weapon2 alongside Tal Rasha's Lidless Eye - const gearUpdateShield = await cdp.evalJs<{ - changed: boolean - weaponClass: string - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - ...hud.inventory.equipped, - weapon2: { - id: 'gold-monarch', - code: 'uit', - name: "Holy Monarch", - nameZh: '神圣统治者大盾', - invFile: 'invuit', - quality: 'magic', - chrtransform: 'lgld', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon2'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdateShield.changed === true, 'Expected syncAppearance() to return true after equipping Monarch shield') - assert( - gearUpdateShield.appearance?.layers?.sh?.variant === 'kit' && - gearUpdateShield.appearance?.layers?.sh?.color === 'lgld', - `Expected sh=kit:lgld (Holy Monarch), got ${JSON.stringify(gearUpdateShield.appearance?.layers?.sh)}`, - ) - - // 2. Equip Crown (cred) + Studded Leather (cblu) + Short Staff (sst) - const gearUpdate1 = await cdp.evalJs<{ - changed: boolean - weaponClass: string - members: number - key: string - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - helm: { - id: 'red-crown', - code: 'crn', - name: 'Ruby Crown', - nameZh: '红宝石皇冠', - invFile: 'invcrn', - quality: 'magic', - chrtransform: 'cred', - identified: true, - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - armor: { - id: 'blue-stu', - code: 'stu', - name: 'Cobalt Studded Leather', - nameZh: '钴蓝镶嵌皮甲', - invFile: 'invstu', - quality: 'magic', - chrtransform: 'cblu', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['armor'], - stats: [], - }, - weapon1: { - id: 'short-staff', - code: 'sst', - name: 'Short Staff', - nameZh: '短棍', - invFile: 'invsst', - quality: 'normal', - identified: true, - invWidth: 1, - invHeight: 3, - allowedSlots: ['weapon1'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - members: act.characterMembers, - key: act.characterAppearanceKey, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdate1.changed === true, 'Expected syncAppearance() to return true after equipping Crown + Studded Leather + Short Staff') - assert(gearUpdate1.weaponClass === 'stf', `Expected weaponClass=stf, got ${String(gearUpdate1.weaponClass)}`) - assert( - gearUpdate1.appearance.layers.hd.variant === 'crn' && - gearUpdate1.appearance.layers.hd.color === 'cred', - 'Ruby Crown should resolve to crn:cred', - ) - assert( - gearUpdate1.appearance.layers.tr.variant === 'lit' && - gearUpdate1.appearance.layers.tr.color === 'cblu' && - gearUpdate1.appearance.layers.lg.variant === 'med' && - gearUpdate1.appearance.layers.ra.variant === 'med' && - gearUpdate1.appearance.layers.la.variant === 'lit', - `Cobalt Studded Leather should resolve to tr=lit:cblu, lg=med, ra=med, la=lit, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, - ) - assert( - gearUpdate1.appearance.layers.rh.variant === 'bst' && - gearUpdate1.appearance.layers.sh.variant === null, - `Short Staff (sst -> alternateGfx=bst) should resolve to rh=bst and sh=null, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, - ) - - // 3. Equip Bow (Short Bow) and verify wclass='bow' and lh='sbw' - const gearUpdateBow = await cdp.evalJs<{ - changed: boolean - weaponClass: string - members: number - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - weapon1: { - id: 'test-bow', - code: 'sbw', - name: 'Short Bow', - nameZh: '短弓', - invFile: 'invsbw', - quality: 'normal', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon1'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - members: act.characterMembers, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdateBow.changed === true, 'Expected syncAppearance() to return true when equipping Short Bow') - assert(gearUpdateBow.weaponClass === 'bow', `Expected weaponClass=bow, got ${String(gearUpdateBow.weaponClass)}`) - assert(gearUpdateBow.appearance.layers.lh?.variant === 'sbw', 'Short Bow must equip on LH layer') - assert(gearUpdateBow.appearance.layers.rh?.variant === null, 'Short Bow must leave RH layer null') - - assert(consoleErrors.length === 0, `Browser console/page errors: ${consoleErrors.join('; ')}`) - assert(mpqRequests.length === 0, `Forbidden runtime MPQ requests: ${mpqRequests.join('; ')}`) - console.log('✓ Live WebGL Browser E2E appearance & dye verification passed with 0 errors and 0 MPQ requests.') - } finally { - cdp.close() - } - } finally { - if (chrome) chrome.kill('SIGKILL') - if (server) server.kill('SIGTERM') - } -} - -async function main(): Promise { - await runUnitAndCompositorChecks() - await runBrowserE2ECheck() - console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!') -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/tools/verify-controlbar-browser.ts b/tools/verify-controlbar-browser.ts deleted file mode 100644 index 792797b..0000000 --- a/tools/verify-controlbar-browser.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * scripts/verify-controlbar-browser.ts - * - * Headless Chrome browser verification of ControlBar XP bar rendering & hover interactions: - * 1. Offscreen HTML Canvas Pixel Audit: - * - Real OffscreenCanvas in Google Chrome. - * - Renders 800CtrlPnl7 panel + ControlBarHud. - * - Extracts real pixel data via getImageData(). - * - Verifies groove [256, 560, 120, 4] colors. - * - Verifies gold trim borders at y=559 and y=564 have ZERO pixel bleed. - * 2. Real DOM Mouse Event Projection across DPRs & Viewports: - * - Viewports: 800x600, 1920x1080. - * - DPRs: 1.0, 1.25, 1.5, 2.0. - * - Inspects window.__d2webHudInstance.controlBar.hoveredElement. - */ - -import { spawn } from 'node:child_process' -import { createServer } from 'vite' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -async function main(): Promise { - console.log('=== Headless Chrome ControlBar Stress Test ===') - - // 1. Start Vite dev server - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite server running at ${baseUrl}`) - - // 2. Launch headless Chrome with CDP - const debugPort = 9248 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--window-size=1920,1080', - `${baseUrl}/acts.html?act=1`, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - - if (!wsUrl) throw new Error('Failed to connect to Chrome CDP') - console.log('2. Connected to Chrome CDP:', wsUrl) - - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { ws.onopen = () => resolve() }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - // Wait for HudManager to be ready - for (let i = 0; i < 100; i++) { - const ready = await evalJs(`Boolean(window.__d2webHud?.ready && window.__d2webHudInstance)`) - if (ready) break - await sleep(300) - } - - console.log('3. HudManager ready in Chrome!') - - // Test 1: Real OffscreenCanvas rendering & pixel audit inside Chrome - console.log('\n--- Test 1: Chrome OffscreenCanvas Pixel Audit ---') - const pixelAuditResult = await evalJs<{ - bleedTop559Count: number - bleedBot564Count: number - grooveBackgroundRgb: number[] - grooveFilledRgb: number[] - tickNotchRgb: number[] - tickPositions: number[] - }>(`(() => { - const hudInst = window.__d2webHudInstance; - const ctrlBar = hudInst.controlBar; - const ctrlImg = hudInst.images.get('ctrlPnl'); - - // Create base OffscreenCanvas with only background image - const baseCanvas = new OffscreenCanvas(800, 600); - const baseCtx = baseCanvas.getContext('2d'); - baseCtx.drawImage(ctrlImg, 0, 496); - const baseImgData = baseCtx.getImageData(0, 0, 800, 600).data; - - // Create test OffscreenCanvas and render ControlBar with 50% XP - const testCanvas = new OffscreenCanvas(800, 600); - const testCtx = testCanvas.getContext('2d'); - const testState = { - level: 1, - xp: 1500, - currentLevelXp: 1000, - nextLevelXp: 2000, - stamina: 100, - maxStamina: 100, - isRunning: false, - unspentStatPoints: 0, - unspentSkillPoints: 0, - }; - - const assetBundle = { - ctrlPnlImg: hudInst.images.get('ctrlPnl') ?? null, - runBtnImg: hudInst.images.get('runbutton') ?? null, - menuBtnImg: hudInst.images.get('menubutton') ?? null, - miniPanelImg: hudInst.images.get('minipanel') ?? null, - miniPanelBtnsImg: hudInst.images.get('minipanelBtns') ?? null, - levelBtnImg: hudInst.images.get('levelBtn') ?? null, - levelSocketImg: hudInst.images.get('levelSocket') ?? null, - }; - - ctrlBar.draw(testCtx, testState, assetBundle, hudInst.font); - const testImgData = testCtx.getImageData(0, 0, 800, 600).data; - - let bleedTop559Count = 0; - let bleedBot564Count = 0; - - // Check row y=559 for x in [256..375] - for (let x = 256; x <= 375; x++) { - const idx = (559 * 800 + x) * 4; - if ( - testImgData[idx] !== baseImgData[idx] || - testImgData[idx + 1] !== baseImgData[idx + 1] || - testImgData[idx + 2] !== baseImgData[idx + 2] - ) { - bleedTop559Count++; - } - } - - // Check row y=564 for x in [256..375] - for (let x = 256; x <= 375; x++) { - const idx = (564 * 800 + x) * 4; - if ( - testImgData[idx] !== baseImgData[idx] || - testImgData[idx + 1] !== baseImgData[idx + 1] || - testImgData[idx + 2] !== baseImgData[idx + 2] - ) { - bleedBot564Count++; - } - } - - // Sample groove pixels - // Filled area (x=260, y=561) - const filledIdx = (561 * 800 + 260) * 4; - const grooveFilledRgb = [testImgData[filledIdx], testImgData[filledIdx + 1], testImgData[filledIdx + 2]]; - - // Unfilled area (x=350, y=561) - const unfilledIdx = (561 * 800 + 350) * 4; - const grooveBackgroundRgb = [testImgData[unfilledIdx], testImgData[unfilledIdx + 1], testImgData[unfilledIdx + 2]]; - - // Tick notch at x=268, y=561 - const notchIdx = (561 * 800 + 268) * 4; - const tickNotchRgb = [testImgData[notchIdx], testImgData[notchIdx + 1], testImgData[notchIdx + 2]]; - - // Verify all 9 ticks - const tickPositions = []; - for (let i = 1; i < 10; i++) { - const tx = 256 + i * 12; - const tidx = (561 * 800 + tx) * 4; - tickPositions.push(testImgData[tidx]); - } - - return { - bleedTop559Count, - bleedBot564Count, - grooveBackgroundRgb, - grooveFilledRgb, - tickNotchRgb, - tickPositions, - }; - })()`) - - console.log('Pixel audit result from Chrome:', JSON.stringify(pixelAuditResult, null, 2)) - - // Test 2: Hover Hit-Testing in real Chrome across DPRs & Resolutions - console.log('\n--- Test 2: Hover Hit-Testing in Chrome across DPRs & Resolutions ---') - const resolutions = [ - { w: 800, h: 600 }, - { w: 1920, h: 1080 }, - ] - const dprs = [1.0, 1.25, 1.5, 2.0] - - for (const res of resolutions) { - for (const dpr of dprs) { - // Set emulation device metrics - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: res.w, - height: res.h, - deviceScaleFactor: dpr, - mobile: false, - }) - await sleep(100) - - // Evaluate hover at key points - const hoverResult = await evalJs<{ - centerHover: string | null - outsideHover: string | null - leftEdgeHover: string | null - rightEdgeHover: string | null - }>(`(() => { - const inst = window.__d2webHudInstance; - const rect = inst.hudCanvas.getBoundingClientRect(); - - // Calculate client coords for center - const scale = Math.min(rect.width / 800, rect.height / 600); - const offX = Math.max(0, (rect.width - 800 * scale) / 2); - const offY = Math.max(0, rect.height - 600 * scale); - - const clientCenterX = 316 * scale + offX; - const clientCenterY = 562 * scale + offY; - const mappedCenter = inst.clientToLogical(clientCenterX, clientCenterY); - inst.controlBar.handleMouseMove(mappedCenter.x, mappedCenter.y); - const centerHover = inst.controlBar.hoveredElement; - - // Test point outside (above groove) - const clientOutsideY = 550 * scale + offY; - const mappedOutside = inst.clientToLogical(clientCenterX, clientOutsideY); - inst.controlBar.handleMouseMove(mappedOutside.x, mappedOutside.y); - const outsideHover = inst.controlBar.hoveredElement; - - // Left edge (x = 256.5 to avoid IEEE 754 epsilon boundary) - const clientLeftX = 256.5 * scale + offX; - const mappedLeft = inst.clientToLogical(clientLeftX, clientCenterY); - inst.controlBar.handleMouseMove(mappedLeft.x, mappedLeft.y); - const leftEdgeHover = inst.controlBar.hoveredElement; - - // Right edge (x = 375.5) - const clientRightX = 375.5 * scale + offX; - const mappedRight = inst.clientToLogical(clientRightX, clientCenterY); - inst.controlBar.handleMouseMove(mappedRight.x, mappedRight.y); - const rightEdgeHover = inst.controlBar.hoveredElement; - - return { - centerHover, - outsideHover, - leftEdgeHover, - rightEdgeHover, - }; - })()`) - - console.log(`Resolution ${res.w}x${res.h} @ DPR ${dpr}:`, hoverResult) - } - } - - } finally { - chromeProc.kill('SIGKILL') - await server.close() - } -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/tools/verify-ground-combat-browser.ts b/tools/verify-ground-combat-browser.ts deleted file mode 100644 index a5f9733..0000000 --- a/tools/verify-ground-combat-browser.ts +++ /dev/null @@ -1,817 +0,0 @@ -/** - * Headless Browser Verification Audit for Ground Items & Combat Drop Lifecycle (M11.5 / Issue #408 & Issue #449). - * - * Verifies end-to-end authentic combat drop parity in acts.html: - * 1. Scene & HUD initialization with fail-fast pack error detection. - * 2. Authentic monster kill & death state transition, unique elite multi-drops (Act 1 Unique A -> 4 potions + 1 item/gold). - * 3. Isometric ground drop scattering (findIsometricDropPosition). - * 4. Flippy bounce parabolic animation trajectory & WebGL canvas rendering. - * 5. Alt floating labels, Chinese localization, quality colors, and ladder collision resolution. - * 6. Dynamic ground item collection & inventory placement without invbox fallbacks. - * 7. Negative control: Inventory full refusal feedback & secondary bounce. - * 8. Zero runtime errors, zero MPQ requests, and 500-kill statistical drop assertions (3-sigma Binomial confidence). - * - * Run: - * npx tsx scripts/verify-ground-combat-browser.ts [--port=8080] [--out=/tmp/ground-audit] [--packs-dir=...] - */ - -import { execSync, spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { createServer, type ViteDevServer } from 'vite' -import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { executeDropPipeline } from '../src/common/items/drop-tables.ts' -import { D2Rng } from '../src/common/rng/d2-rng.ts' -import { resolveGroundItemSpriteRect, resolveItemSpriteRect } from '../src/client/ui/inventory.ts' -import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -const CHROME_CANDIDATES = [ - '/usr/bin/google-chrome', - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -/** - * Preflight check ensuring `samples/d2-packs` is accessible. - * In a git worktree, automatically detects the parent repository via `git rev-parse --git-common-dir` - * and creates a symlink to prevent 404s and 20-second timeout hangs. - */ -export function ensurePacksAvailable(customPacksDir?: string): string { - // 1. Explicit CLI argument or environment variable - const envDir = customPacksDir ?? process.env.D2_PACKS_DIR - if (envDir && existsSync(join(envDir, 'index.json'))) { - return envDir - } - - // 2. Local samples/d2-packs - const localPacks = resolve('samples/d2-packs') - if (existsSync(join(localPacks, 'index.json'))) { - return localPacks - } - - // 3. Resolve via git common dir (for git worktrees) - try { - const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim() - if (gitCommonDir) { - const mainRepoDir = resolve(gitCommonDir, '..') - const parentPacks = join(mainRepoDir, 'samples/d2-packs') - if (existsSync(join(parentPacks, 'index.json'))) { - mkdirSync(resolve('samples'), { recursive: true }) - if (!existsSync(localPacks)) { - symlinkSync(parentPacks, localPacks) - console.log(`[Preflight] Auto-symlinked samples/d2-packs from main repository: ${parentPacks} -> ${localPacks}`) - } - return localPacks - } - } - } catch { - // Ignore git command error - } - - // 4. Fail-fast - console.error('\n[FATAL] Missing required pre-baked pack assets at samples/d2-packs/index.json.') - console.error('When running in a git worktree, ensure samples/d2-packs is symlinked from the main repo or set D2_PACKS_DIR.') - console.error('To generate packs, run: npm run pack:data\n') - process.exit(1) -} - -function ensureD2Available(): void { - const localD2 = resolve('samples/d2') - if (existsSync(localD2)) return - try { - const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim() - if (gitCommonDir) { - const parentD2 = join(resolve(gitCommonDir, '..'), 'samples/d2') - if (existsSync(parentD2)) { - mkdirSync(resolve('samples'), { recursive: true }) - symlinkSync(parentD2, localD2) - console.log(`[Preflight] Auto-symlinked samples/d2 from main repository: ${parentD2} -> ${localD2}`) - return - } - } - } catch {} - if (existsSync('/usr/local/google/home/taodao/d2-data')) { - mkdirSync(resolve('samples'), { recursive: true }) - symlinkSync('/usr/local/google/home/taodao/d2-data', localD2) - console.log(`[Preflight] Auto-symlinked samples/d2 from /usr/local/google/home/taodao/d2-data -> ${localD2}`) - } -} - -export interface GroundAuditResult { - readonly success: boolean - readonly durationMs: number - readonly stages: { - readonly stage1_sceneReady: boolean - readonly stage2_monsterKill: boolean - readonly stage3_groundScatter: boolean - readonly stage4_flippyBounce: boolean - readonly stage5_altLabelsAndLadder: boolean - readonly stage6_itemAndGoldPickup: boolean - readonly stage7_negativeControlFullBag: boolean - readonly stage8_zeroErrors: boolean - } - readonly metrics: Record - readonly consoleErrors: string[] - readonly webGlErrors: number[] - readonly mpqRequests: string[] - readonly screenshots: string[] -} - -export async function runGroundCombatBrowserAudit(options?: { - port?: number | undefined - outputDir?: string | undefined - packsDir?: string | undefined -}): Promise { - const startTime = Date.now() - const port = options?.port ?? (5300 + Math.floor(Math.random() * 500)) - const outputDir = options?.outputDir ?? resolve('/tmp/ground-audit-' + Date.now()) - mkdirSync(outputDir, { recursive: true }) - - // Worktree asset preflight check - ensurePacksAvailable(options?.packsDir) - ensureD2Available() - - console.log('======================================================================') - console.log(' Diablo II Web · Ground Combat & Drop Lifecycle Browser Audit') - console.log(' Milestone M11.5 (Issue #408) & Issue #449 · Target: acts.html') - console.log(` Artifacts: ${outputDir}`) - console.log('======================================================================\n') - - // 1. Launch Vite Dev Server - console.log(`[1/8] Starting Vite dev server on port ${port}...`) - const viteServer: ViteDevServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { - host: '127.0.0.1', - port, - strictPort: false, - }, - }) - await viteServer.listen() - const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port - const baseUrl = `http://127.0.0.1:${actualPort}` - console.log(` Vite server ready at ${baseUrl}/acts.html\n`) - - // 2. Launch Headless Chromium with Remote Debugging - const chromeBin = resolveChromeBinary() - const debugPort = 9400 + Math.floor(Math.random() * 500) - const targetUrl = `${baseUrl}/acts.html?act=1&level=2-act-1-wilderness-1-var1` - console.log(`[2/8] Spawning Headless Chromium (${chromeBin})`) - console.log(` Target URL: ${targetUrl}`) - - const chromeProc: ChildProcess = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1280,840', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--no-proxy-server', - `--remote-debugging-port=${debugPort}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - const consoleErrors: string[] = [] - const webGlErrors: number[] = [] - const mpqRequests: string[] = [] - const screenshots: string[] = [] - - let ws: WebSocket | null = null - - try { - // 3. Connect to Chrome DevTools Protocol - let wsDebuggerUrl: string | null = null - for (let i = 0; i < 80; i++) { - await sleep(150) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json`) - const pages = (await res.json()) as any[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsDebuggerUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - - if (!wsDebuggerUrl) throw new Error(`Failed to attach to Chrome CDP on debug port ${debugPort}`) - ws = new WebSocket(wsDebuggerUrl) - await new Promise((res, rej) => { - ws!.onopen = () => res(true) - ws!.onerror = rej - }) - - let cmdId = 1 - const pendingCmds = new Map void>() - ws.onmessage = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Runtime.exceptionThrown') { - consoleErrors.push(`[Exception] ${JSON.stringify(msg.params)}`) - } else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { - consoleErrors.push(`[Console.error] ${JSON.stringify(msg.params.args)}`) - } else if (msg.method === 'Network.requestWillBeSent') { - const url = msg.params?.request?.url ?? '' - if (/\.(mpq|dll)(\?|$)/i.test(url)) { - mpqRequests.push(url) - } - } - if (msg.id && pendingCmds.has(msg.id)) { - pendingCmds.get(msg.id)!(msg) - pendingCmds.delete(msg.id) - } - } - - const cdp = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = cmdId++ - pendingCmds.set(id, (msg: any) => { - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - }) - ws!.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`Eval error in browser: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const takeScreenshot = async (name: string): Promise => { - const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(outputDir, `${name}.png`) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - screenshots.push(filePath) - console.log(` Saved screenshot: ${name}.png`) - return filePath - } - - await cdp('Runtime.enable') - await cdp('Page.enable') - await cdp('Network.enable') - - // ========================================================================= - // STAGE 1: Wait for Scene & HUD Ready (Fast Error Abort) - // ========================================================================= - console.log('\n[STAGE 1] Waiting for ActScene and HudManager initialization...') - let ready = false - for (let i = 0; i < 100; i++) { - const actError = await evalJs(`window.__d2webAct?.error ?? null`) - if (actError) { - throw new Error(`ActScene initialization failed with error: ${actError}`) - } - ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (ready) break - await sleep(200) - } - if (!ready) throw new Error('Timeout waiting for window.__d2webAct & window.__d2webHud') - - const initialMetrics = await evalJs(`(() => { - const gl = document.querySelector('#view')?.getContext('webgl2'); - const err = gl ? gl.getError() : -1; - return { - level: window.__d2webAct?.level, - tick: window.__d2webAct?.tick, - monstersCount: window.__d2webEngine?.world?.monsters?.length ?? 0, - playerPos: { x: window.__d2webEngine?.world?.player?.x, y: window.__d2webEngine?.world?.player?.y }, - glError: err, - }; - })()`) - if (initialMetrics.glError !== 0) webGlErrors.push(initialMetrics.glError) - console.log(` Scene loaded level: ${initialMetrics.level}, monsters: ${initialMetrics.monstersCount}`) - console.log(` Player spawn: (${initialMetrics.playerPos.x}, ${initialMetrics.playerPos.y})`) - await takeScreenshot('01-scene-ready') - - // ========================================================================= - // STAGE 2: Monster Spawn, Authentic Player Combat & Monster Kill - // ========================================================================= - console.log('\n[STAGE 2] Executing authentic combat and killing unique elite monster...') - const combatResult = await evalJs(`(() => { - const engine = window.__d2webEngine; - const player = engine.world.player; - - // Locate or place a test monster in front of player - let target = engine.world.monsters.find(m => m.state !== 'dead' && Math.hypot(m.x - player.x, m.y - player.y) < 160); - if (!target && engine.world.monsters.length > 0) { - target = engine.world.monsters.find(m => m.state !== 'dead'); - } - if (!target) { - // Fallback spawn for deterministic combat testing with unique rank - target = { - index: engine.world.monsters.length, - stats: { id: 'zombie1', name: '僵尸 (Zombie)', hp: 10, xp: 50, rank: 'unique', level: 2 }, - state: 'idle', - x: player.x + 25, - y: player.y + 10, - hp: 10, - maxHp: 10, - facing: 0, - cooldown: 0, - hitFlash: 0, - corpseTicks: 0, - dropRolled: false, - }; - engine.world.monsters.push(target); - } else { - // Upgrade to unique elite monster in Act 1 (rolls Act 1 Unique A -> 4 potions + 1 equip/gold) - target.stats.rank = 'unique'; - if (!target.stats.level || target.stats.level < 2) target.stats.level = 2; - if (!target.stats.id) target.stats.id = 'zombie1'; - target.hp = Math.max(target.hp, 10); - target.state = 'idle'; - target.dropRolled = false; - target.x = player.x + 25; - target.y = player.y + 10; - } - - const preKills = engine.world.kills || 0; - const preDrops = engine.metrics.dropsRolled || 0; - const preGroundCount = engine.groundItems.count || 0; - - // Route damage through authentic combat execution: - // Player cooldown reset, player damage set to lethal, attacking tick executed - player.cooldown = 0; - engine.opts.combatOptions.playerDamage = 9999; - engine.tick({ movement: { x: 0, y: 0 }, attacking: true, digits: [] }); - - // If target wasn't finished in first swing (e.g. if out of reach), execute lethal hit via damageMonster - if (target.state !== 'dead') { - if (typeof window.__d2webDamageMonster === 'function') { - window.__d2webDamageMonster(engine.world, target.index, 9999, player); - } else if (typeof engine.damageMonster === 'function') { - engine.damageMonster(target.index, 9999, player); - } - engine.tick({ movement: { x: 0, y: 0 }, attacking: false, digits: [] }); - } - - return { - kills: engine.world.kills, - killsDelta: (engine.world.kills || 0) - preKills, - dropsRolled: engine.metrics.dropsRolled, - dropsDelta: (engine.metrics.dropsRolled || 0) - preDrops, - groundCount: engine.groundItems.count, - newGroundCount: engine.groundItems.count - preGroundCount, - targetState: target.state, - targetRank: target.stats?.rank, - items: engine.groundItems.all.map(g => ({ - id: g.id, - code: g.item?.code ?? g.code, - name: g.item?.name ?? g.name, - nameZh: g.nameZh, - isGold: g.isGold, - amount: g.amount, - x: g.x, - y: g.y, - hasBounce: Boolean(g.bounceState), - })), - }; - })()`) - - console.log(` Kills: ${combatResult.kills} (+${combatResult.killsDelta}), Drops rolled: ${combatResult.dropsRolled} (+${combatResult.dropsDelta})`) - console.log(` Ground items spawned: ${combatResult.groundCount} (New drops: ${combatResult.newGroundCount})`) - if (combatResult.killsDelta < 1) throw new Error('Monster was not killed by combat execution') - if (combatResult.dropsDelta < 1) throw new Error('Drop pipeline was not rolled on monster kill') - if (combatResult.newGroundCount < 5) throw new Error(`Expected at least 5 drops from unique elite kill, got ${combatResult.newGroundCount}`) - await takeScreenshot('02-monster-killed-and-dropped') - - // ========================================================================= - // STAGE 3 & 4: Ground Scatter Spacing & Flippy Bounce Parabolic Flight - // ========================================================================= - console.log('\n[STAGE 3 & 4] Verifying Isometric Scatter & Flippy Bounce trajectory...') - const bounceMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const items = engine.groundItems.all; - const itemA = items[0]; - const itemB = items[1]; - const dist = Math.hypot(itemA.x - itemB.x, itemA.y - itemB.y); - - // Verify parabolic bounce heights - const bState = itemA.bounceState ?? { - startTime: 0, - durationMs: 400, - peakHeightPx: 24, - phase: 'primary', - }; - const calcHeight = (t) => { - const elapsed = t - bState.startTime; - if (elapsed <= 0 || elapsed >= bState.durationMs) return 0; - const p = elapsed / bState.durationMs; - return 4 * bState.peakHeightPx * p * (1 - p); - }; - - const hStart = calcHeight(bState.startTime); - const hMid = calcHeight(bState.startTime + bState.durationMs / 2); - const hEnd = calcHeight(bState.startTime + bState.durationMs + 10); - - // Canvas WebGL render check - const gl = document.querySelector('#view')?.getContext('webgl2'); - const glErr = gl ? gl.getError() : -1; - - return { - distBetweenItems: dist, - durationMs: bState.durationMs, - peakHeightPx: bState.peakHeightPx, - hStart, - hMid, - hEnd, - glErr, - }; - })()`) - - if (bounceMetrics.glErr !== 0) webGlErrors.push(bounceMetrics.glErr) - console.log(` Distance between scattered items: ${bounceMetrics.distBetweenItems.toFixed(1)}px (>= 10px)`) - console.log(` Bounce duration: ${bounceMetrics.durationMs}ms, Peak height: ${bounceMetrics.peakHeightPx}px`) - console.log(` Parabolic heights: Start=${bounceMetrics.hStart}px, Mid=${bounceMetrics.hMid}px, End=${bounceMetrics.hEnd}px`) - if (bounceMetrics.distBetweenItems < 10) throw new Error('Items stacked without isometric scatter') - if (Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) > 0.5) { - throw new Error(`Parabolic peak height mismatch: expected ${bounceMetrics.peakHeightPx}px, got ${bounceMetrics.hMid}px`) - } - - // Ground sprite flippy rect resolution verification - const groundSpriteValidation = combatResult.items.map((item: any) => { - const sr = resolveGroundItemSpriteRect(item, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile) - return { - id: item.id, - code: item.code, - hasRect: sr !== null, - isFlpbox: sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && item.code !== 'box', - } - }) - const invalidGroundSprites = groundSpriteValidation.filter((v: any) => !v.hasRect || v.isFlpbox) - if (invalidGroundSprites.length > 0) { - throw new Error(`Ground items failed flippy sprite resolution: ${JSON.stringify(invalidGroundSprites)}`) - } - - // ========================================================================= - // STAGE 5: Alt Key Labels & Vertical Ladder Collision Resolution - // ========================================================================= - console.log('\n[STAGE 5] Triggering Alt key floating labels & verifying ladder collisions...') - // Press Alt and wait for render loop to update overlay - await evalJs(`new Promise(resolve => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true })); - requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 200))); - })`) - - const altMetrics = await evalJs(`(() => { - const hud = window.__d2webHud; - const container = document.querySelector('#ground-labels'); - const labelEls = Array.from(container ? container.querySelectorAll('.d2-ground-label') : []) - .filter(el => el.style.display !== 'none' && !el.hidden); - - const boxes = labelEls.map(el => { - const r = el.getBoundingClientRect(); - return { - text: el.textContent, - color: el.style.color, - left: r.left, - top: r.top, - right: r.right, - bottom: r.bottom, - width: r.width, - height: r.height, - }; - }); - - // Check for bounding box collisions - let overlapCount = 0; - for (let i = 0; i < boxes.length; i++) { - for (let j = i + 1; j < boxes.length; j++) { - const a = boxes[i]; - const b = boxes[j]; - const xOverlap = a.left < b.right && a.right > b.left; - const yOverlap = a.top < b.bottom && a.bottom > b.top; - if (xOverlap && yOverlap) overlapCount++; - } - } - - return { - showGroundLabels: hud.showGroundLabels, - labelCount: labelEls.length, - boxes, - overlapCount, - }; - })()`) - - console.log(` Alt pressed: showGroundLabels = ${altMetrics.showGroundLabels}`) - console.log(` Active DOM labels rendered: ${altMetrics.labelCount}`) - console.log(` Labels text & color: ${altMetrics.boxes.map((b: any) => `${b.text} (${b.color})`).join(', ')}`) - console.log(` Ladder collision bounding box overlaps: ${altMetrics.overlapCount} (MUST BE 0)`) - if (altMetrics.labelCount < 5) throw new Error(`Expected at least 5 ground labels rendered for unique drop, got ${altMetrics.labelCount}`) - if (altMetrics.overlapCount !== 0) throw new Error(`Ladder collision resolution failed: ${altMetrics.overlapCount} overlapping labels found!`) - await takeScreenshot('03-alt-labels-ladder-stacked') - - // Release Alt and wait for overlay to hide - await evalJs(`new Promise(resolve => { - window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true })); - requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150))); - })`) - const altReleasedCount = await evalJs(`(() => { - const container = document.querySelector('#ground-labels'); - return Array.from(container ? container.querySelectorAll('.d2-ground-label') : []) - .filter(el => el.style.display !== 'none' && !el.hidden).length; - })()`) - console.log(` Alt released: active visible labels = ${altReleasedCount} (expected 0)`) - if (altReleasedCount !== 0) throw new Error(`Ground labels still visible after Alt release: ${altReleasedCount}`) - - // ========================================================================= - // STAGE 6: Dynamic Ground Entity Collection & Inventory Parity - // ========================================================================= - console.log('\n[STAGE 6] Dynamically collecting all ground entities and verifying inventory parity...') - const pickupMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const mouse = window.__d2webMouseController; - const hud = window.__d2webHudInstance; - const player = engine.world.player; - - const preGold = hud.inventory.gold || 0; - const preBagCount = hud.inventory.gridItems.length; - const prePickups = engine.metrics.pickups || 0; - const initialGroundCount = engine.groundItems.count; - - // Dynamically collect all ground entities - const itemsToPick = [...engine.groundItems.all]; - for (const item of itemsToPick) { - player.x = item.x; - player.y = item.y; - mouse.pickupGroundItem(item); - } - - // Check for invbox fallback in inventory items - let invboxFallbackCount = 0; - const inventoryItems = hud.inventory.gridItems.map(gi => { - const item = gi.item; - if (item.invFile === 'invbox' && item.code !== 'box') { - invboxFallbackCount++; - } - return { - id: item.id, - code: item.code, - name: item.name, - invFile: item.invFile, - }; - }); - - return { - initialGroundCount, - pickedCount: itemsToPick.length, - postGold: hud.inventory.gold, - goldDelta: hud.inventory.gold - preGold, - postBagCount: hud.inventory.gridItems.length, - bagDelta: hud.inventory.gridItems.length - preBagCount, - pickupsTotal: engine.metrics.pickups, - pickupsDelta: engine.metrics.pickups - prePickups, - remainingGroundCount: engine.groundItems.count, - invboxFallbackCount, - inventoryItems, - statusText: document.querySelector('#status')?.textContent ?? '', - }; - })()`) - - console.log(` Dynamic collection: ${pickupMetrics.pickedCount} ground entities processed`) - console.log(` Gold pickup delta: +${pickupMetrics.goldDelta} (Total: ${pickupMetrics.postGold})`) - console.log(` Equipment & potion pickup: +${pickupMetrics.bagDelta} items in bag`) - console.log(` Ground items remaining: ${pickupMetrics.remainingGroundCount} (expected 0)`) - console.log(` Invbox fallback count: ${pickupMetrics.invboxFallbackCount} (MUST BE 0)`) - console.log(` Status text feedback: "${pickupMetrics.statusText}"`) - if (pickupMetrics.remainingGroundCount !== 0) throw new Error('Ground items were not cleared after pickup') - if (pickupMetrics.bagDelta <= 0) throw new Error('No items were placed in bag after picking up drops') - if (pickupMetrics.invboxFallbackCount !== 0) throw new Error(`UI items hit invbox fallback: ${pickupMetrics.invboxFallbackCount}`) - await takeScreenshot('04-items-picked-up-inventory') - - // ========================================================================= - // STAGE 7: Negative Control (Inventory Full Refusal & Secondary Bounce) - // ========================================================================= - console.log('\n[STAGE 7] Negative Control: Inventory full refusal & secondary bounce...') - const refusalMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const mouse = window.__d2webMouseController; - const hud = window.__d2webHudInstance; - - // Fill player inventory completely (40 cells) with 10 2x2 items - hud.inventory.gridItems = []; - for (let c = 0; c < 10; c += 2) { - for (let r = 0; r < 4; r += 2) { - hud.inventory.gridItems.push({ - item: { - id: 'filler-' + c + '-' + r, - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '帽子 (Cap)', - baseNameZh: '帽子 (Cap)', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - col: c, - row: r, - }); - } - } - - // Spawn test item on ground near player - const player = engine.world.player; - const testItem = { - id: 'test_drop_full', - code: 'hax', - name: 'Hand Axe', - nameZh: '手斧 (Hand Axe)', - quality: 'normal', - invWidth: 1, - invHeight: 2, - base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 }, - }; - const groundItem = engine.dropItem(testItem, player.x + 10, player.y + 10); - - const preRefusals = engine.metrics.inventoryRefusals || 0; - - // Attempt pickup with full bag - mouse.pickupGroundItem(groundItem); - - return { - preRefusals, - postRefusals: engine.metrics.inventoryRefusals, - itemStillOnGround: Boolean(engine.groundItems.get(groundItem.id)), - hasSecondaryBounce: Boolean(groundItem.bounceState), - bounceDuration: groundItem.bounceState?.durationMs, - bouncePeak: groundItem.bounceState?.peakHeightPx, - statusText: document.querySelector('#status')?.textContent ?? '', - }; - })()`) - - console.log(` Inventory refusals: ${refusalMetrics.preRefusals} -> ${refusalMetrics.postRefusals}`) - console.log(` Item remains on ground: ${refusalMetrics.itemStillOnGround}`) - console.log(` Secondary bounce triggered: ${refusalMetrics.hasSecondaryBounce} (Peak: ${refusalMetrics.bouncePeak}px)`) - console.log(` Feedback notification: "${refusalMetrics.statusText}"`) - if (refusalMetrics.postRefusals <= refusalMetrics.preRefusals) throw new Error('Inventory refusal counter did not increment') - if (!refusalMetrics.itemStillOnGround) throw new Error('Item disappeared despite bag full refusal') - if (!refusalMetrics.hasSecondaryBounce) throw new Error('Refusal flippy bounce was not triggered') - await takeScreenshot('05-inventory-full-refusal') - - // ========================================================================= - // STAGE 8: Zero Error Invariant Check & 500-Kill Statistical Drop Assertions - // ========================================================================= - console.log('\n[STAGE 8] Asserting zero runtime errors and 500-kill statistical drop invariants...') - const finalGlErr = await evalJs(`document.querySelector('#view')?.getContext('webgl2')?.getError() ?? -1`) - if (finalGlErr !== 0) webGlErrors.push(finalGlErr) - - console.log(` Console errors: ${consoleErrors.length}`) - console.log(` WebGL errors: ${webGlErrors.length}`) - console.log(` MPQ / DLL network requests: ${mpqRequests.length}`) - - // 500-kill drop rate statistical assertions - console.log(' Executing 500-kill drop statistical audit (Act 1 H2H A)...') - const dropTables = getEmbeddedDropTables() - let statTotalDrops = 0 - let statGoldCount = 0 - let statPotionCount = 0 - let statEquipCount = 0 - let statInvboxHits = 0 - let statFlpboxHits = 0 - let statMissingFlippy = 0 - - const N = 500 - for (let i = 0; i < N; i++) { - const drops = executeDropPipeline(dropTables, { - tcName: 'Act 1 H2H A', - nLevel: 2, - monsterType: 1, - difficulty: 'normal', - monsterRng: new D2Rng(i * 997 + 13), - }) - statTotalDrops += drops.length - for (const drop of drops) { - const isGold = drop.code?.trim() === 'gld' - if (isGold) { - statGoldCount++ - } else if (['hp1', 'hp2', 'mp1', 'mp2', 'rvs'].includes(drop.code?.trim() ?? '')) { - statPotionCount++ - } else { - statEquipCount++ - } - - const sr = resolveGroundItemSpriteRect(drop, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile) - if (!sr) statMissingFlippy++ - if (sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && drop.code?.trim() !== 'box') statFlpboxHits++ - - if (!isGold) { - const uiItem = itemToUiInventoryItem(drop, dropTables) - if (uiItem.invFile === 'invbox' && drop.code?.trim() !== 'box') statInvboxHits++ - const ir = resolveItemSpriteRect(uiItem, BAKED_UI_MANIFEST.itemRects) - if (ir === BAKED_UI_MANIFEST.itemRects?.['invbox'] && drop.code?.trim() !== 'box') statInvboxHits++ - } - } - } - - console.log(` 500-kill sample: Total=${statTotalDrops} (3σ: [155, 220]), Gold=${statGoldCount} (3σ: [43, 88]), Potions=${statPotionCount} (3σ: [16, 49]), Equip=${statEquipCount}`) - console.log(` Asset parity: missingFlippy=${statMissingFlippy}, flpboxHits=${statFlpboxHits}, invboxHits=${statInvboxHits}`) - - const statsPass = - statTotalDrops >= 155 && statTotalDrops <= 220 && - statGoldCount >= 43 && statGoldCount <= 88 && - statPotionCount >= 16 && statPotionCount <= 49 && - statMissingFlippy === 0 && - statFlpboxHits === 0 && - statInvboxHits === 0 - - if (!statsPass) { - throw new Error(`500-kill statistical drop audit failed 3-sigma confidence bounds or had sprite fallbacks!`) - } - - const auditPass = - consoleErrors.length === 0 && - webGlErrors.length === 0 && - mpqRequests.length === 0 && - combatResult.killsDelta >= 1 && - combatResult.dropsDelta >= 1 && - combatResult.newGroundCount >= 5 && - bounceMetrics.hMid > 20 && - altMetrics.overlapCount === 0 && - altMetrics.labelCount >= 5 && - pickupMetrics.remainingGroundCount === 0 && - pickupMetrics.bagDelta > 0 && - pickupMetrics.invboxFallbackCount === 0 && - refusalMetrics.postRefusals > refusalMetrics.preRefusals && - statsPass - - return { - success: auditPass, - durationMs: Date.now() - startTime, - stages: { - stage1_sceneReady: ready, - stage2_monsterKill: combatResult.killsDelta >= 1 && combatResult.dropsDelta >= 1 && combatResult.newGroundCount >= 5, - stage3_groundScatter: bounceMetrics.distBetweenItems >= 10, - stage4_flippyBounce: Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) < 1 && bounceMetrics.peakHeightPx >= 20, - stage5_altLabelsAndLadder: altMetrics.overlapCount === 0 && altMetrics.labelCount >= 5, - stage6_itemAndGoldPickup: pickupMetrics.remainingGroundCount === 0 && pickupMetrics.bagDelta > 0 && pickupMetrics.invboxFallbackCount === 0, - stage7_negativeControlFullBag: refusalMetrics.postRefusals > refusalMetrics.preRefusals && refusalMetrics.itemStillOnGround && refusalMetrics.hasSecondaryBounce, - stage8_zeroErrors: consoleErrors.length === 0 && webGlErrors.length === 0 && mpqRequests.length === 0 && statsPass, - }, - metrics: { - kills: combatResult.kills, - drops: combatResult.dropsRolled, - scatterDistance: bounceMetrics.distBetweenItems, - ladderLabelsCount: altMetrics.labelCount, - ladderOverlaps: altMetrics.overlapCount, - goldPickedUp: pickupMetrics.goldDelta, - equipmentPickedUp: pickupMetrics.bagDelta, - refusalsHandled: refusalMetrics.postRefusals, - sampleDrops500: statTotalDrops, - sampleGold500: statGoldCount, - samplePotions500: statPotionCount, - }, - consoleErrors, - webGlErrors, - mpqRequests, - screenshots, - } - } finally { - if (ws) ws.close() - if (chromeProc) chromeProc.kill('SIGTERM') - if (viteServer) await viteServer.close() - } -} - -// Standalone execution entrypoint -if (import.meta.url === `file://${process.argv[1]}`) { - const outputArg = process.argv.find(arg => arg.startsWith('--out='))?.split('=')[1] - const packsArg = process.argv.find(arg => arg.startsWith('--packs-dir='))?.split('=')[1] - runGroundCombatBrowserAudit({ outputDir: outputArg, packsDir: packsArg }) - .then(result => { - console.log('\n======================================================================') - console.log(` AUDIT RESULT: ${result.success ? 'PASSED (ALL STAGES GREEN)' : 'FAILED'}`) - console.log(` Duration: ${(result.durationMs / 1000).toFixed(2)}s`) - console.log(' Stages:', JSON.stringify(result.stages, null, 2)) - console.log('======================================================================\n') - process.exit(result.success ? 0 : 1) - }) - .catch(err => { - console.error('\nHARNESS FATAL ERROR:', err) - process.exit(1) - }) -} diff --git a/tools/verify-issue-503-browser.ts b/tools/verify-issue-503-browser.ts deleted file mode 100644 index e42b3a4..0000000 --- a/tools/verify-issue-503-browser.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #503: - * Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions (`index.html`, `acts-zh.html`, `acts-en.html`, `acts.html`). - * - * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP. - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/worker_503_1' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface CdpSession { - evalJs: (expression: string) => Promise - saveScreenshot: (filename: string) => Promise - waitForGameReady: () => Promise -} - -async function withChromePage( - url: string, - matchSubstr: string, - debugPort: number, - fn: (session: CdpSession) => Promise, -): Promise { - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - url, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr)) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error(`Failed to connect to Chrome CDP for ${url}`) - } - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as R) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - const waitForGameReady = async (): Promise => { - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) { - await sleep(500) - return - } - await sleep(300) - } - throw new Error(`Timed out waiting for game ready at ${url}`) - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const result = await fn({ evalJs, saveScreenshot, waitForGameReady }) - ws.close() - return result - } finally { - chromeProc.kill('SIGKILL') - await sleep(200) - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - try { - // ── Phase A: Verify index.html bilingual entry portal ── - await withChromePage(`${baseUrl}/index.html`, 'index.html', 9241, async ({ evalJs, saveScreenshot }) => { - await sleep(400) - const indexCheck = await evalJs<{ - zhHref: string | null - enHref: string | null - title: string - }>(`({ - zhHref: document.getElementById('enter-zh')?.getAttribute('href') ?? null, - enHref: document.getElementById('enter-en')?.getAttribute('href') ?? null, - title: document.title, - })`) - console.log('2. Verified index.html entry portal:', JSON.stringify(indexCheck)) - if (indexCheck.zhHref !== './acts-zh.html' || indexCheck.enHref !== './acts-en.html') { - throw new Error(`Unexpected index.html links: ${JSON.stringify(indexCheck)}`) - } - await saveScreenshot('index-bilingual.png') - }) - - // ── Phase B: Verify acts-zh.html (Chinese CHI edition) ── - await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9242, async ({ evalJs, saveScreenshot, waitForGameReady }) => { - await waitForGameReady() - const zhCheck = await evalJs<{ - lang: string - title: string - langSwitchHref: string | null - langSwitchText: string - hudText: string - actOpt1: string - heroSor: string - sceneSelectedText: string - charSheetDraws: string[] - }>(`(() => { - const inst = window.__d2webHudInstance; - const draws = []; - const origFill = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { - draws.push(String(text)); - return origFill.call(this, text, ...rest); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.fillText = origFill; - } - const sceneSel = document.getElementById('scene'); - return { - lang: inst.lang, - title: document.title, - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - langSwitchText: document.getElementById('lang-switch')?.textContent?.trim() ?? '', - hudText: document.getElementById('hud')?.textContent ?? '', - actOpt1: document.querySelector('#act option[value="1"]')?.textContent ?? '', - heroSor: document.querySelector('#hero option[value="so"]')?.textContent ?? '', - sceneSelectedText: sceneSel?.selectedOptions?.[0]?.textContent ?? '', - charSheetDraws: draws, - }; - })()`) - - console.log('3. Verified acts-zh.html state:', JSON.stringify({ - lang: zhCheck.lang, - title: zhCheck.title, - langSwitchHref: zhCheck.langSwitchHref, - hudText: zhCheck.hudText, - actOpt1: zhCheck.actOpt1, - heroSor: zhCheck.heroSor, - sceneSelectedText: zhCheck.sceneSelectedText, - sampleDraws: zhCheck.charSheetDraws.slice(0, 14), - })) - if (zhCheck.lang !== 'CHI') { - throw new Error(`Expected acts-zh.html lang === 'CHI', got ${zhCheck.lang}`) - } - if (!zhCheck.langSwitchHref?.endsWith('acts-en.html?act=1')) { - throw new Error(`Expected #lang-switch href ending with 'acts-en.html?act=1', got ${zhCheck.langSwitchHref}`) - } - if (!zhCheck.actOpt1.includes('第一章') || !zhCheck.heroSor.includes('法师')) { - throw new Error(`Expected Chinese act/hero options in acts-zh.html, got ${JSON.stringify(zhCheck)}`) - } - await saveScreenshot('acts-zh-panels.png') - }) - - // ── Phase C: Verify acts-en.html (English ENG edition — strict 0 CJK audit) ── - await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9243, async ({ evalJs, saveScreenshot, waitForGameReady }) => { - await waitForGameReady() - const enCheck = await evalJs<{ - lang: string - title: string - langSwitchHref: string | null - domTexts: Record - cjkDomViolations: string[] - cjkCanvasViolations: string[] - sampleCanvasDraws: string[] - }>(`(() => { - const inst = window.__d2webHudInstance; - const cjkRe = /[\\u4e00-\\u9fff]/; - - // 1. Check all DOM texts except #lang-switch (which shows "Lang: ZH") - const domTexts = { - title: document.title, - headerH1: document.querySelector('header h1')?.textContent || '', - hud: document.getElementById('hud')?.textContent || '', - heroOptions: Array.from(document.querySelectorAll('#hero option')).map(el => el.textContent || '').join(' | '), - actOptions: Array.from(document.querySelectorAll('#act option')).map(el => el.textContent || '').join(' | '), - sceneGroups: Array.from(document.querySelectorAll('#scene optgroup')).map(el => el.getAttribute('label') || '').join(' | '), - sceneOptions: Array.from(document.querySelectorAll('#scene option')).map(el => el.textContent || '').join(' | '), - variantOptions: Array.from(document.querySelectorAll('#variant option')).map(el => el.textContent || '').join(' | '), - lightingOptions: Array.from(document.querySelectorAll('#lighting option')).map(el => el.textContent || '').join(' | '), - densityOptions: Array.from(document.querySelectorAll('#density option')).map(el => el.textContent || '').join(' | '), - eliteOptions: Array.from(document.querySelectorAll('#elite option')).map(el => el.textContent || '').join(' | '), - controlLabels: Array.from(document.querySelectorAll('#controls label')).map(el => el.childNodes[0]?.textContent?.trim() || '').join(' | '), - hint: document.querySelector('#controls .hint')?.textContent || '', - status: document.getElementById('status')?.textContent || '', - dialog: document.getElementById('dialog')?.textContent || '', - labels: document.getElementById('labels')?.textContent || '', - }; - - const cjkDomViolations = []; - for (const [k, v] of Object.entries(domTexts)) { - if (cjkRe.test(v)) { - cjkDomViolations.push(k + ': ' + v); - } - } - - // 2. Intercept all Canvas fillText/strokeText calls across all HUD panels - const canvasDraws = []; - const origFill = CanvasRenderingContext2D.prototype.fillText; - const origStroke = CanvasRenderingContext2D.prototype.strokeText; - CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { - canvasDraws.push(String(text)); - return origFill.call(this, text, ...rest); - }; - CanvasRenderingContext2D.prototype.strokeText = function(text, ...rest) { - canvasDraws.push(String(text)); - return origStroke.call(this, text, ...rest); - }; - - try { - // Exercise Quest Log, Waypoint, Stash, Cube, Character, Skill Tree, Inventory, NPC Menu, and Vendor Tabs - for (const left of ['quest', 'waypoint', 'stash', 'cube', 'char']) { - if (inst.leftPanel !== left) inst.toggleLeftPanel(left); - inst.render(performance.now(), false); - } - for (const right of ['skill', 'inv']) { - if (inst.rightPanel !== right) inst.toggleRightPanel(right); - inst.render(performance.now(), false); - } - inst.openNpcMenuByName('Akara', 400, 220); - inst.render(performance.now(), false); - inst.worldPanels.npcMenu = null; - inst.openVendorSession('Charsi', 'trade', 0); - for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc']) { - inst.worldPanels.selectVendorTab(tabId); - const placements = inst.worldPanels.vendorTabPlacements[tabId] || []; - for (const p of placements) { - inst.worldPanels.hoveredVendorItem = { item: p.item, x: 200, y: 200, col: p.col, row: p.row }; - inst.render(performance.now(), false); - } - } - inst.worldPanels.hoveredVendorItem = null; - // Leave char + inv open with helm tooltip hovered for screenshot - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.fillText = origFill; - CanvasRenderingContext2D.prototype.strokeText = origStroke; - } - - const cjkCanvasViolations = canvasDraws.filter(t => cjkRe.test(t)); - - return { - lang: inst.lang, - title: document.title, - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - domTexts, - cjkDomViolations, - cjkCanvasViolations, - sampleCanvasDraws: Array.from(new Set(canvasDraws)).slice(0, 25), - }; - })()`) - - console.log('4. Verified acts-en.html state:', JSON.stringify({ - lang: enCheck.lang, - title: enCheck.title, - langSwitchHref: enCheck.langSwitchHref, - hud: enCheck.domTexts.hud, - actOptions: enCheck.domTexts.actOptions, - heroOptions: enCheck.domTexts.heroOptions, - cjkDomViolations: enCheck.cjkDomViolations, - cjkCanvasViolations: enCheck.cjkCanvasViolations, - sampleCanvasDraws: enCheck.sampleCanvasDraws, - })) - - if (enCheck.lang !== 'ENG') { - throw new Error(`Expected acts-en.html lang === 'ENG', got ${enCheck.lang}`) - } - if (!enCheck.langSwitchHref?.endsWith('acts-zh.html?act=1')) { - throw new Error(`Expected #lang-switch href ending with 'acts-zh.html?act=1', got ${enCheck.langSwitchHref}`) - } - if (enCheck.cjkDomViolations.length > 0) { - throw new Error(`Found CJK in acts-en.html DOM: ${JSON.stringify(enCheck.cjkDomViolations)}`) - } - if (enCheck.cjkCanvasViolations.length > 0) { - throw new Error(`Found CJK in acts-en.html Canvas HUD: ${JSON.stringify(enCheck.cjkCanvasViolations)}`) - } - await saveScreenshot('acts-en-panels.png') - }) - - // ── Phase D: Verify acts.html?act=2&lang=en query override ── - await withChromePage(`${baseUrl}/acts.html?act=2&lang=en`, 'acts.html', 9244, async ({ evalJs, waitForGameReady }) => { - await waitForGameReady() - const overrideCheck = await evalJs<{ - lang: string - hud: string - langSwitchHref: string | null - }>(`({ - lang: window.__d2webHudInstance.lang, - hud: document.getElementById('hud')?.textContent || '', - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - })`) - console.log('5. Verified /acts.html?act=2&lang=en override:', JSON.stringify(overrideCheck)) - if (overrideCheck.lang !== 'ENG' || !overrideCheck.hud.includes('Act 2')) { - throw new Error(`Unexpected /acts.html?act=2&lang=en state: ${JSON.stringify(overrideCheck)}`) - } - }) - - console.log('✅ All Issue #503 Headless Browser E2E Checks PASSED!') - } finally { - await server.close() - } -} - -main().catch(err => { - console.error('❌ Issue #503 Browser Verification Failed:', err) - process.exit(1) -}) diff --git a/tools/verify-issue-504-browser.ts b/tools/verify-issue-504-browser.ts deleted file mode 100644 index b2c4fa3..0000000 --- a/tools/verify-issue-504-browser.ts +++ /dev/null @@ -1,594 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #504: - * Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`) - * Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS - * (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`). - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r4' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface CdpSession { - sendCommand: (method: string, params?: Record) => Promise - evalJs: (expression: string) => Promise - saveScreenshot: (filename: string) => Promise - waitForGameReady: () => Promise - externalUrls: string[] -} - -async function withChromePage( - url: string, - matchSubstr: string, - debugPort: number, - fn: (session: CdpSession) => Promise, -): Promise { - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - url, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr)) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error(`Failed to connect to Chrome CDP for ${url}`) - } - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - const externalUrls: string[] = [] - ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Network.requestWillBeSent') { - const reqUrl = String(msg.params?.request?.url ?? '') - if ( - reqUrl && - !reqUrl.startsWith('http://127.0.0.1:') && - !reqUrl.startsWith('data:') && - !reqUrl.startsWith('blob:') - ) { - externalUrls.push(reqUrl) - } - } - } catch { - // Ignore non-JSON CDP events - } - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as R) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - const waitForGameReady = async (): Promise => { - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) { - await sleep(500) - return - } - await sleep(300) - } - throw new Error(`Timed out waiting for game ready at ${url}`) - } - - await sendCommand('Network.enable') - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const result = await fn({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) - ws.close() - return result - } finally { - chromeProc.kill('SIGKILL') - await sleep(200) - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - try { - // ── Phase A: Verify index.html WebFont loading, pixel-level Exocet O cross vs Formal 436 hollow O, & zero external requests ── - await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => { - await sleep(500) - const indexFonts = await evalJs<{ - exocetLoaded: boolean - formalLoaded: boolean - h1FontFamily: string - descFontFamily: string - exocetOCenterAlpha: number - formalOCenterAlpha: number - }>(`(async () => { - await document.fonts.ready; - await Promise.all([ - document.fonts.load('48px D2Exocet'), - document.fonts.load('48px D2Formal436'), - ]); - - const measureGlyphCenterAlpha = (fontSpec) => { - const c = document.createElement('canvas'); - c.width = 96; - c.height = 96; - const ctx = c.getContext('2d'); - ctx.font = fontSpec; - ctx.textBaseline = 'alphabetic'; - ctx.fillStyle = '#ffffff'; - ctx.fillText('O', 16, 68); - const d = ctx.getImageData(0, 0, 96, 96).data; - let minX = 96, maxX = 0, minY = 96, maxY = 0; - for (let y = 0; y < 96; y++) { - for (let x = 0; x < 96; x++) { - if (d[(y * 96 + x) * 4 + 3] > 64) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - } - } - const cx = Math.round((minX + maxX) / 2); - const cy = Math.round((minY + maxY) / 2); - let maxCenterAlpha = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const a = d[((cy + dy) * 96 + (cx + dx)) * 4 + 3]; - if (a > maxCenterAlpha) maxCenterAlpha = a; - } - } - return maxCenterAlpha; - }; - - return { - exocetLoaded: document.fonts.check('16px D2Exocet'), - formalLoaded: document.fonts.check('14px D2Formal436'), - h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily, - descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily, - exocetOCenterAlpha: measureGlyphCenterAlpha('48px "D2Exocet"'), - formalOCenterAlpha: measureGlyphCenterAlpha('48px "D2Formal436"'), - }; - })()`) - console.log('2. Verified index.html WebFonts & O-glyph center pixels:', JSON.stringify(indexFonts)) - if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) { - throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`) - } - if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) { - throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`) - } - if (indexFonts.exocetOCenterAlpha < 128 || indexFonts.formalOCenterAlpha !== 0) { - throw new Error(`Expected D2Exocet 'O' center to have cross pixels (>128) and D2Formal436 'O' center to be hollow (0), got: ${JSON.stringify(indexFonts)}`) - } - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on index.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-index.png') - }) - - // ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting, 12 color codes, Area Banner, Ground Labels, Panels & Formal 436 Prose ── - await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { - await waitForGameReady() - - // 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`) - const groundLabelCheck = await evalJs<{ - groundLabelsCount: number - groundLabelFontFamily: string - groundLabelTexts: string[] - groundLabelPositions1x: Array<{ left: string; top: string }> - }>(`(async () => { - const inst = window.__d2webHudInstance; - inst.leftPanel = 'none'; - inst.rightPanel = 'none'; - inst.worldPanels.npcMenu = null; - inst.worldPanels.areaBanner = { - titleEn: 'Rogue Encampment', - titleZh: '罗格营地', - untilMs: performance.now() + 60000, - }; - if (inst.inventory.equipped?.helm) { - inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.helm, id: 'verify-ground-helm' }); - } - if (inst.inventory.equipped?.weapon1) { - inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.weapon1, id: 'verify-ground-weapon' }); - } - inst.showGroundLabels = true; - await new Promise(r => setTimeout(r, 300)); - const els = Array.from(document.querySelectorAll('.d2-ground-label')); - return { - groundLabelsCount: els.length, - groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', - groundLabelTexts: els.map(el => el.textContent || ''), - groundLabelPositions1x: els.map(el => ({ left: el.style.left, top: el.style.top })), - }; - })()`) - console.log(' Verified Alt ground labels (1x DPR):', JSON.stringify(groundLabelCheck)) - if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) { - throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`) - } - await saveScreenshot('issue-504-acts-en-area-banner.png') - - // 1b. Verify High-DPI (devicePixelRatio = 2) rendering on GroundLabelOverlay and #d2-hud-canvas - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: 1280, - height: 840, - deviceScaleFactor: 2, - mobile: false, - }) - await sleep(300) - const hiDpiCheck = await evalJs<{ - dpr: number - hudCanvasWidth: number - hudCanvasHeight: number - imageSmoothingEnabled: boolean - groundLabelsCount: number - groundLabelFontFamily: string - groundLabelPositions2x: Array<{ left: number; top: number }> - viewCenterCssY: number - }>(`(async () => { - const inst = window.__d2webHudInstance; - inst.render(performance.now(), false); - await new Promise(r => setTimeout(r, 200)); - const hudCanvas = document.getElementById('d2-hud-canvas'); - const viewCanvas = document.getElementById('view'); - const viewRect = viewCanvas.getBoundingClientRect(); - const ctx = hudCanvas.getContext('2d'); - const els = Array.from(document.querySelectorAll('.d2-ground-label')); - return { - dpr: window.devicePixelRatio, - hudCanvasWidth: hudCanvas.width, - hudCanvasHeight: hudCanvas.height, - imageSmoothingEnabled: ctx.imageSmoothingEnabled, - groundLabelsCount: els.length, - groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', - groundLabelPositions2x: els.map(el => ({ - left: parseFloat(el.style.left), - top: parseFloat(el.style.top), - })), - viewCenterCssY: Math.round(viewRect.top + viewRect.height * 0.5), - }; - })()`) - console.log(' Verified High-DPI (2x DPR) GroundLabelOverlay & #d2-hud-canvas:', JSON.stringify(hiDpiCheck)) - if ( - hiDpiCheck.dpr !== 2 || - hiDpiCheck.hudCanvasWidth !== 2560 || - hiDpiCheck.hudCanvasHeight !== 1680 || - hiDpiCheck.imageSmoothingEnabled !== false || - hiDpiCheck.groundLabelsCount !== groundLabelCheck.groundLabelsCount || - !hiDpiCheck.groundLabelFontFamily.includes('D2Exocet') || - hiDpiCheck.groundLabelPositions2x.some( - p => Math.abs(p.left - 640) > 30 || Math.abs(p.top - hiDpiCheck.viewCenterCssY) > 60, - ) - ) { - throw new Error(`High-DPI (2x DPR) verification failed: ${JSON.stringify(hiDpiCheck)}`) - } - await sendCommand('Emulation.clearDeviceMetricsOverride') - await sleep(200) - - // 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', 12 distinct color codes, and multi-color ÿc rare item affix tooltip alignment - const enCheck = await evalJs<{ - exocetLoaded: boolean - formalLoaded: boolean - hudFontFamily: string - controlsFontFamily: string - dialogFontFamily: string - dialogTitleFontFamily: string - dc6FontLoaded: boolean - dc6BlitCount: number - fillTextCount: number - dc6Font30OCenterAlpha: number - dc6Font16OCenterAlpha: number - dc6Formal12OCenterAlpha: number - distinctColorCodesCount: number - multiColorTooltipAligned: boolean - multiColorTooltipBlitCount: number - }>(`(async () => { - await document.fonts.ready; - await Promise.all([ - document.fonts.load('16px D2Exocet'), - document.fonts.load('14px D2Formal436'), - ]); - const inst = window.__d2webHudInstance; - inst.showGroundLabels = false; - - const measureDc6GlyphCenterAlpha = (fontName) => { - const c = document.createElement('canvas'); - c.width = 64; - c.height = 64; - const ctx = c.getContext('2d'); - inst.font.drawText(ctx, 'O', 10, 40, { font: fontName, color: 'white', shadow: false }); - const d = ctx.getImageData(0, 0, 64, 64).data; - let minX = 64, maxX = 0, minY = 64, maxY = 0; - for (let y = 0; y < 64; y++) { - for (let x = 0; x < 64; x++) { - if (d[(y * 64 + x) * 4 + 3] > 128) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - } - } - const cx = Math.round((minX + maxX) / 2); - const cy = Math.round((minY + maxY) / 2); - let maxCenterAlpha = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const a = d[((cy + dy) * 64 + (cx + dx)) * 4 + 3]; - if (a > maxCenterAlpha) maxCenterAlpha = a; - } - } - return maxCenterAlpha; - }; - - const colorCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';']; - const rgbSignatures = new Set(); - for (const cc of colorCodes) { - const c = document.createElement('canvas'); - c.width = 32; - c.height = 32; - const ctx = c.getContext('2d'); - inst.font.drawText(ctx, 'ÿc' + cc + 'I', 4, 24, { font: 'font16', shadow: false }); - const d = ctx.getImageData(0, 0, 32, 32).data; - let brightest = '0,0,0'; - let maxSum = -1; - for (let i = 0; i < d.length; i += 4) { - if (d[i + 3] >= 180) { - const sum = d[i] + d[i + 1] + d[i + 2]; - if (sum > maxSum) { - maxSum = sum; - brightest = d[i] + ',' + d[i + 1] + ',' + d[i + 2]; - } - } - } - rgbSignatures.add(brightest); - } - - // Verify per-segment DC6 bitmap blitting alignment across multi-color ÿc transitions in a long rare item affix tooltip - const multiColorAffix = 'ÿc3+20% Faster Cast Rate ÿc0[Max] ÿc9+40% LRes ÿc1+35% FRes'; - const plainAffix = '+20% Faster Cast Rate [Max] +40% LRes +35% FRes'; - const segCalls = []; - const testCanvas = document.createElement('canvas'); - testCanvas.width = 800; - testCanvas.height = 600; - const testCtx = testCanvas.getContext('2d'); - const origDrawImg = testCtx.drawImage.bind(testCtx); - testCtx.drawImage = function(img, sx, sy, sw, sh, dx, dy, dw, dh) { - if (arguments.length === 9) { - segCalls.push({ dx, dy, dw, dh }); - } - return origDrawImg.apply(this, arguments); - }; - inst.font.drawText(testCtx, multiColorAffix, 400, 200, { - font: 'font8', - color: 'blue', - align: 'center', - shadow: false, - }); - const meta8 = inst.font.getFontMeta('font8'); - let expectedX = Math.round(400 - inst.font.measureText(plainAffix, 'font8') / 2); - let segIdx = 0; - let multiColorTooltipAligned = true; - for (let i = 0; i < plainAffix.length; i++) { - const code = plainAffix.charCodeAt(i); - const g = meta8.glyphs[code]; - const adv = Math.max(4, g.width); - if (code !== 0x20 && g.frameW > 0 && g.frameH > 0) { - if (!segCalls[segIdx] || segCalls[segIdx].dx !== expectedX) { - multiColorTooltipAligned = false; - } - segIdx++; - } - expectedX += adv; - } - if (segIdx !== segCalls.length) multiColorTooltipAligned = false; - - let blitCount = 0; - let fillCount = 0; - const origDrawImage = CanvasRenderingContext2D.prototype.drawImage; - const origFillText = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.drawImage = function(...args) { - if (args.length === 9) blitCount++; - return origDrawImage.apply(this, args); - }; - CanvasRenderingContext2D.prototype.fillText = function(...args) { - fillCount++; - return origFillText.apply(this, args); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.drawImage = origDrawImage; - CanvasRenderingContext2D.prototype.fillText = origFillText; - } - return { - exocetLoaded: document.fonts.check('16px D2Exocet'), - formalLoaded: document.fonts.check('14px D2Formal436'), - hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily, - controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily, - dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily, - dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily, - dc6FontLoaded: Boolean(inst.font?.loaded), - dc6BlitCount: blitCount, - fillTextCount: fillCount, - dc6Font30OCenterAlpha: measureDc6GlyphCenterAlpha('font30'), - dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'), - dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'), - distinctColorCodesCount: rgbSignatures.size, - multiColorTooltipAligned, - multiColorTooltipBlitCount: segCalls.length, - }; - })()`) - console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross, 12 colors & multi-color tooltip alignment:', JSON.stringify(enCheck)) - if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) { - throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`) - } - if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) { - throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`) - } - if (enCheck.dc6Font30OCenterAlpha < 200 || enCheck.dc6Font16OCenterAlpha < 200 || enCheck.dc6Formal12OCenterAlpha !== 0) { - throw new Error(`Expected DC6 font30/font16 'O' center to be solid cross (>200) and fontformal12 'O' center to be hollow (0), got: ${JSON.stringify(enCheck)}`) - } - if (enCheck.distinctColorCodesCount !== 12) { - throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`) - } - if (!enCheck.multiColorTooltipAligned || enCheck.multiColorTooltipBlitCount < 35) { - throw new Error(`Expected multi-color ÿc tooltip DC6 blits to align contiguously across color transitions, got: ${JSON.stringify(enCheck)}`) - } - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-acts-en-panels.png') - - // 3. Open Quest Log + Skill Tree + Canvas NPC Speech Dialog to verify Formal 436 (`fontformal12`) & Exocet in all panels - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.hoveredItem = null; - if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest'); - if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill'); - inst.worldPanels.npcMenu = { - npcId: 1, - npcName: 'Akara', - x: 400, - y: 440, - talking: true, - dialogLines: [ - 'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.', - 'Welcome to our camp, traveler. Please cleanse the Den of Evil.', - ], - }; - inst.render(performance.now(), false); - })()`) - await sleep(300) - await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png') - }) - - // ── Phase C: Verify acts-zh.html hybrid DC6 + CJK bilingual rendering ── - await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { - await waitForGameReady() - const zhCheck = await evalJs<{ - dc6BlitCount: number - fillTextCount: number - }>(`(() => { - const inst = window.__d2webHudInstance; - let blitCount = 0; - let fillCount = 0; - const origDrawImage = CanvasRenderingContext2D.prototype.drawImage; - const origFillText = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.drawImage = function(...args) { - if (args.length === 9) blitCount++; - return origDrawImage.apply(this, args); - }; - CanvasRenderingContext2D.prototype.fillText = function(...args) { - fillCount++; - return origFillText.apply(this, args); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.drawImage = origDrawImage; - CanvasRenderingContext2D.prototype.fillText = origFillText; - } - return { - dc6BlitCount: blitCount, - fillTextCount: fillCount, - }; - })()`) - console.log('4. Verified acts-zh.html hybrid DC6 + CJK rendering:', JSON.stringify(zhCheck)) - if (zhCheck.dc6BlitCount < 100 || zhCheck.fillTextCount < 10) { - throw new Error(`Expected acts-zh.html hybrid rendering (>100 DC6 blits and >=10 CJK fillText calls), got: ${JSON.stringify(zhCheck)}`) - } - await sleep(300) - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-acts-zh-bilingual.png') - }) - - console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!') - } finally { - await server.close() - } -} - -main().catch(err => { - console.error('❌ Issue #504 browser verification failed:', err) - process.exit(1) -}) diff --git a/tools/verify-renderer-lifecycle.ts b/tools/verify-renderer-lifecycle.ts index 04cf9d4..7a147a3 100644 --- a/tools/verify-renderer-lifecycle.ts +++ b/tools/verify-renderer-lifecycle.ts @@ -4,7 +4,6 @@ * batching, indexed geometry and draw-call instrumentation added for Issue #16. */ import { SpriteRenderer, RendererError } from '../src/client/render/renderer.ts' -import { buildBounds, intersects, viewportRect } from '../src/client/render/cull.ts' interface MockResource { id: number @@ -621,70 +620,6 @@ function runTests(): void { renderer.dispose() } - // Test 15 (Issue #16): Viewport culling achieves > 80% quad culling ratio on large levels - { - // Generate an 80x80 level (6400 quads) with standard tile size 80x40 - const quads: { x: number; y: number; frame: { width: number; height: number } }[] = [] - const gridCols = 80 - const gridRows = 80 - for (let y = 0; y < gridRows; y += 1) { - for (let x = 0; x < gridCols; x += 1) { - quads.push({ - x: x * 80, - y: y * 40, - frame: { width: 80, height: 40 }, - }) - } - } - const totalQuads = quads.length // 6400 - const bounds = buildBounds(quads) - - // Center camera at (3200, 1600) with standard 1280x720 viewport and zoom = 1 - const view1x = viewportRect(3200, 1600, 1280, 720, 1.0) - let visible1x = 0 - let culled1x = 0 - const visibleIndices: number[] = [] - - for (let i = 0; i < totalQuads; i += 1) { - if (intersects(bounds, i, view1x)) { - visible1x += 1 - visibleIndices.push(i) - } else { - culled1x += 1 - } - } - - const cullingRatio1x = culled1x / totalQuads - assert(totalQuads === 6400, 'Large level has 6400 quads') - assert(visible1x + culled1x === totalQuads, 'All quads accounted for in culling pass') - assert( - cullingRatio1x > 0.80, - `Viewport culling achieves > 80% culling ratio on 80x80 level (measured: ${(cullingRatio1x * 100).toFixed(1)}%)`, - ) - - // Verify painter's order preservation: visible indices must be strictly monotonic - let monotonic = true - for (let i = 1; i < visibleIndices.length; i += 1) { - if (visibleIndices[i]! <= visibleIndices[i - 1]!) { - monotonic = false - break - } - } - assert(monotonic, "Culled stream maintains strictly monotonic painter's order") - - // Test with zoom = 2.0 (tighter viewport -> even higher culling ratio) - const view2x = viewportRect(3200, 1600, 1280, 720, 2.0) - let culled2x = 0 - for (let i = 0; i < totalQuads; i += 1) { - if (!intersects(bounds, i, view2x)) culled2x += 1 - } - const cullingRatio2x = culled2x / totalQuads - assert( - cullingRatio2x > cullingRatio1x && cullingRatio2x > 0.80, - `Zooming in increases culling ratio (measured: ${(cullingRatio2x * 100).toFixed(1)}%)`, - ) - } - // Test 16 (Issue #16): HUD displays accurate measured metrics rather than hardcoded dummies { const mock = createMockCanvas() diff --git a/tools/verify-scene-object-dispatch.ts b/tools/verify-scene-object-dispatch.ts deleted file mode 100644 index 840b9c8..0000000 --- a/tools/verify-scene-object-dispatch.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * Empirical Stress-Testing Harness for Scene Object Rendering Dispatch - * Milestone 1 (R1): 场景物件与窗户透光加色混合支持 - * - * Ground Truth Parity: Diablo II v1.13c - * Playbook: solution_stress_testing (SKILL.md) - * Archetype: EMPIRICAL CHALLENGER - */ - -import fs from 'node:fs' -import path from 'node:path' -import { - ADDITIVE_OBJECT_TOKENS, - PERSISTENT_ILLUMINATION_TOKENS, - computeObjectPhaseOffsetMs, - type ObjectDrawable, -} from '../src/client/world/act-runtime.ts' -import { ADDITIVE_OBJECT_TOKENS as GAME_ADDITIVE_TOKENS } from '../src/common/units/objects.ts' - -// Assert token set alignment between game/objects and scene/act-scene -if (ADDITIVE_OBJECT_TOKENS !== GAME_ADDITIVE_TOKENS) { - throw new Error('ADDITIVE_OBJECT_TOKENS re-export mismatch in act-scene.ts!') -} - -// ----------------------------------------------------------------------------- -// TYPES & MOCK INFRASTRUCTURE -// ----------------------------------------------------------------------------- - -export interface PackFrame { - x: number - y: number - width: number - height: number - offsetX?: number | undefined - offsetY?: number | undefined - page?: number | undefined -} - -export interface PackObjectInput { - id?: number | undefined - token?: string | undefined - name?: string | undefined - frame?: { - x: number - y: number - width: number - height: number - offsetX: number - offsetY: number - page: number - } | null | undefined - x: number - y: number - depth?: number | undefined - trans?: number | undefined - blendMode?: 'normal' | 'additive' | undefined - frameDelta?: number | undefined - cycleAnim?: boolean | undefined - sync?: boolean | undefined - animatedFrames?: readonly { - x: number - y: number - width: number - height: number - page: number - offsetX: number - offsetY: number - }[] | undefined -} - -export interface SimulatedWall { - frame: { x: number; y: number; width: number; height: number } - x: number - y: number - cellX: number - cellY: number - page: number - _depth?: number | undefined -} - -export interface RecordedDraw { - target: 'wall' | 'object' - id?: number | string | undefined - token?: string | undefined - depth: number - x: number - y: number - options: any - blendMode?: 'normal' | 'additive' | undefined -} - -export class MockRenderer { - public readonly draws: RecordedDraw[] = [] - - draw(frame: any, x: number, y: number, options: any = {}): void { - const isObject = 'blendMode' in options - this.draws.push({ - target: isObject ? 'object' : 'wall', - id: frame._id, - token: frame._token, - depth: frame._depth ?? 0, - x, - y, - options, - blendMode: options.blendMode, - }) - } -} - -// ----------------------------------------------------------------------------- -// EXACT IMPLEMENTATION SIMULATION (from src/scene/act-scene.ts) -// ----------------------------------------------------------------------------- - -export function buildObjectDrawables(packObjects: PackObjectInput[]): ObjectDrawable[] { - const objectDrawables: ObjectDrawable[] = [] - for (const obj of packObjects) { - if (!obj.frame) continue - const animatedFrames = obj.animatedFrames && obj.animatedFrames.length > 1 - ? obj.animatedFrames.map(f => ({ - frame: { - x: f.x, - y: f.y, - width: f.width, - height: f.height, - }, - page: f.page, - offsetX: f.offsetX, - offsetY: f.offsetY, - })) - : undefined - const hasAnim = animatedFrames !== undefined && animatedFrames.length > 1 - const frameDurationMs = ( - obj.frameDelta !== undefined && obj.frameDelta > 0 - ? Math.round((256 / obj.frameDelta) * 40 * 100) / 100 - : 40 - ) - const isPersistentFire = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase()) - const cycleAnim = isPersistentFire ? true : (obj.cycleAnim ?? true) - - let phaseOffsetMs = 0 - if (hasAnim && !obj.sync) { - const totalDurationMs = obj.animatedFrames!.length * frameDurationMs - phaseOffsetMs = computeObjectPhaseOffsetMs(obj.x, obj.y, totalDurationMs) - } - - const tokenUpper = obj.token ? obj.token.trim().toUpperCase() : '' - const isAdditive = obj.blendMode === 'additive' || - obj.trans === 7 || - obj.trans === 1 || - ADDITIVE_OBJECT_TOKENS.has(tokenUpper) - const blendMode: 'normal' | 'additive' = isAdditive ? 'additive' : (obj.blendMode ?? 'normal') - - const depth = obj.depth ?? ((obj.x + obj.y) / 5) - - objectDrawables.push({ - id: obj.id, - token: obj.token, - name: obj.name, - frame: { - x: obj.frame.x, - y: obj.frame.y, - width: obj.frame.width, - height: obj.frame.height, - // attach debug metadata - _id: obj.id, - _token: obj.token, - _depth: depth, - } as any, - x: obj.x + obj.frame.offsetX, - y: obj.y + obj.frame.offsetY, - depth, - page: obj.frame.page, - baseX: obj.x, - baseY: obj.y, - blendMode, - trans: obj.trans, - ...(animatedFrames ? { - animatedFrames, - frameDurationMs, - cycleAnim, - phaseOffsetMs, - currentFrameIndex: 0, - } : {}), - }) - } - objectDrawables.sort((a, b) => a.depth - b.depth) - return objectDrawables -} - -export interface DrawDispatchOptions { - walls: readonly SimulatedWall[] - objs: readonly ObjectDrawable[] - maxDepthSteps: readonly number[] - wallPages?: readonly (any | null | undefined)[] - objPages?: readonly (any | null | undefined)[] - visibleWall?: (idx: number) => boolean - visibleObj?: (idx: number) => boolean -} - -export function executeDepthDrawDispatch( - renderer: MockRenderer, - options: DrawDispatchOptions -): { culledDraws: number; skippedDraws: number } { - let wallIdx = 0 - let objIdx = 0 - let culledDraws = 0 - let skippedDraws = 0 - - const walls = options.walls - const objs = options.objs - const wallPages = options.wallPages ?? new Array(100).fill({}) - const objPages = options.objPages ?? new Array(100).fill({}) - const visibleWall = options.visibleWall ?? (() => true) - const visibleObj = options.visibleObj ?? (() => true) - - const drawUpToDepth = (maxDepth: number): void => { - while (wallIdx < walls.length || objIdx < objs.length) { - const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null - const nextObj = objIdx < objs.length ? objs[objIdx]! : null - - const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity - const objDepth = nextObj !== null ? nextObj.depth : Infinity - - if (wallDepth > maxDepth && objDepth > maxDepth) break - - if (wallDepth <= objDepth) { - if (!visibleWall(wallIdx)) { - culledDraws += 1 - } else { - const page = wallPages[nextWall!.page] - if (page === null || page === undefined) { - skippedDraws += 1 - } else { - renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page }) - } - } - wallIdx += 1 - } else { - if (!visibleObj(objIdx)) { - culledDraws += 1 - } else { - const page = objPages[nextObj!.page] - if (page === null || page === undefined) { - skippedDraws += 1 - } else { - renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { - atlas: page, - blendMode: nextObj!.blendMode ?? 'normal', - }) - } - } - objIdx += 1 - } - } - } - - for (const maxDepth of options.maxDepthSteps) { - drawUpToDepth(maxDepth) - } - - return { culledDraws, skippedDraws } -} - -// ----------------------------------------------------------------------------- -// ASSERTION SYSTEM -// ----------------------------------------------------------------------------- - -let totalAssertions = 0 -let passedAssertions = 0 - -function assert(condition: boolean, message: string): void { - totalAssertions++ - if (condition) { - passedAssertions++ - } else { - console.error(`❌ STRESS TEST FAILURE: ${message}`) - throw new Error(`Assertion failed: ${message}`) - } -} - -// ----------------------------------------------------------------------------- -// TEST SUITES -// ----------------------------------------------------------------------------- - -async function runEmpiricalStressHarness(): Promise { - console.log('================================================================================') - console.log('🔥 EMPIRICAL STRESS TEST HARNESS: SCENE OBJECT RENDERING DISPATCH') - console.log('Target: src/scene/act-scene.ts | Milestone 1 (R1)') - console.log('================================================================================') - - // --------------------------------------------------------------------------- - // SUITE 1: Ground Truth Token & Classification Matrix - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 1] Ground Truth Token & Classification Matrix ---') - { - const canonicalAdditiveTokens = ['11', '12', 'R1', 'R2', 'R3', 'R4', 'R5', '1R', '2R', '3R', '4R', 'HA'] - for (const token of canonicalAdditiveTokens) { - assert(ADDITIVE_OBJECT_TOKENS.has(token), `ADDITIVE_OBJECT_TOKENS must contain canonical token ${token}`) - } - - // Test case variations, whitespace, and fallback combinations - const testCases: { - input: PackObjectInput - expectedBlend: 'additive' | 'normal' - desc: string - }[] = [ - // Direct token matches (all 12 tokens) - ...canonicalAdditiveTokens.map(tok => ({ - input: { x: 10, y: 20, token: tok, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive' as const, - desc: `Token ${tok} without trans/blendMode must resolve to additive`, - })), - // Lowercase & whitespace variations - { - input: { x: 10, y: 20, token: ' 11 ', frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Trimmed token " 11 " must resolve to additive', - }, - { - input: { x: 10, y: 20, token: 'r3', frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Lower-cased token "r3" must resolve to additive', - }, - { - input: { x: 10, y: 20, token: 'ha', frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Lower-cased token "ha" must resolve to additive', - }, - // Trans values (Ground Truth 7 and 1) with unknown or empty tokens - { - input: { x: 10, y: 20, token: 'UNKNOWN', trans: 7, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Unknown token with trans=7 must resolve to additive', - }, - { - input: { x: 10, y: 20, token: '', trans: 1, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Empty token with trans=1 must resolve to additive', - }, - { - input: { x: 10, y: 20, token: undefined, trans: 7, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Undefined token with trans=7 must resolve to additive', - }, - // Explicit blendMode in serialized JSON - { - input: { x: 10, y: 20, token: 'CRATE', blendMode: 'additive', frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Explicit blendMode: "additive" must resolve to additive regardless of token', - }, - // Backwards-compatible override: if JSON has blendMode: "normal" but token is 11, ground truth overrides to additive - { - input: { x: 10, y: 20, token: '11', blendMode: 'normal', frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'additive', - desc: 'Token 11 with accidental blendMode: "normal" must be corrected to additive', - }, - // Regular / non-additive objects - { - input: { x: 10, y: 20, token: 'CHEST', trans: 0, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'normal', - desc: 'Regular chest must resolve to normal', - }, - { - input: { x: 10, y: 20, token: 'TO', trans: 0, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'normal', - desc: 'Persistent torch "TO" must resolve to normal', - }, - { - input: { x: 10, y: 20, token: 'BARREL', trans: 2, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'normal', - desc: 'Regular barrel with trans=2 must resolve to normal', - }, - { - input: { x: 10, y: 20, token: undefined, trans: 0, frame: { x: 0, y: 0, width: 32, height: 32, offsetX: 0, offsetY: 0, page: 0 } }, - expectedBlend: 'normal', - desc: 'Object with undefined token and trans=0 must resolve to normal', - }, - ] - - for (const tc of testCases) { - const drawables = buildObjectDrawables([tc.input]) - assert(drawables.length === 1, `Must create exactly 1 drawable for ${tc.desc}`) - assert(drawables[0]!.blendMode === tc.expectedBlend, `${tc.desc}: expected ${tc.expectedBlend}, got ${drawables[0]!.blendMode}`) - } - - // Frame-less objects must be ignored - const noFrame = buildObjectDrawables([{ x: 10, y: 20, token: '11', frame: null }]) - assert(noFrame.length === 0, 'Object without frame must not produce an ObjectDrawable') - } - - // --------------------------------------------------------------------------- - // SUITE 2: Depth Sorting & Mixed Stream Draw Loop Verification - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 2] Depth Sorting & Mixed Stream Draw Loop ---') - { - // Build an interleaved array of walls and objects - // Depths: - // Wall 0: cellX=2, cellY=3 -> depth 5 - // Object 0 (normal): depth 2 - // Object 1 (additive, Token 11): depth 4 - // Wall 1: cellX=4, cellY=2 -> depth 6 - // Object 2 (additive, Token 12): depth 6 (Tie with Wall 1!) - // Wall 2: cellX=5, cellY=2 -> depth 7 - // Object 3 (normal): depth 9 - // Wall 3: cellX=6, cellY=4 -> depth 10 - - const rawObjects: PackObjectInput[] = [ - { id: 101, depth: 9, token: 'BOX', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 45, y: 0 }, - { id: 102, depth: 2, token: 'POT', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 10, y: 0 }, - { id: 103, depth: 6, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 30, y: 0 }, - { id: 104, depth: 4, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 20, y: 0 }, - ] - - const objectDrawables = buildObjectDrawables(rawObjects) - assert(objectDrawables.length === 4, 'Must create 4 drawables') - - // Verify buildObjectDrawables sorted them by depth - for (let i = 1; i < objectDrawables.length; i++) { - assert(objectDrawables[i]!.depth >= objectDrawables[i - 1]!.depth, 'objectDrawables must be pre-sorted by depth ascending') - } - - const walls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w1', _depth: 5 } as any, x: 0, y: 0, cellX: 2, cellY: 3, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w2', _depth: 6 } as any, x: 0, y: 0, cellX: 4, cellY: 2, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w3', _depth: 7 } as any, x: 0, y: 0, cellX: 5, cellY: 2, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w4', _depth: 10 } as any, x: 0, y: 0, cellX: 6, cellY: 4, page: 0 }, - ] - - const renderer = new MockRenderer() - executeDepthDrawDispatch(renderer, { - walls, - objs: objectDrawables, - maxDepthSteps: [Infinity], - }) - - assert(renderer.draws.length === 8, `All 4 walls and 4 objects must be drawn (total 8, got ${renderer.draws.length})`) - - // Verify depth ordering: painter's order must be strictly non-decreasing - for (let i = 1; i < renderer.draws.length; i++) { - const prev = renderer.draws[i - 1]! - const curr = renderer.draws[i]! - assert(curr.depth >= prev.depth, `Draw #${i} (${curr.target} depth=${curr.depth}) must be >= Draw #${i - 1} (${prev.target} depth=${prev.depth})`) - } - - // Verify specific blend modes: - // Object 102 (POT, depth 2): normal - // Object 104 (11, depth 4): additive - // Wall w1 (depth 5): blendMode undefined - // Wall w2 (depth 6): blendMode undefined (depth tie with Object 103, wall draws first due to wallDepth <= objDepth) - // Object 103 (12, depth 6): additive - // Wall w3 (depth 7): blendMode undefined - // Object 101 (BOX, depth 9): normal - // Wall w4 (depth 10): blendMode undefined - - const expectedSequence: { target: 'wall' | 'object'; id: number | string; blend: 'additive' | 'normal' | undefined; depth: number }[] = [ - { target: 'object', id: 102, blend: 'normal', depth: 2 }, - { target: 'object', id: 104, blend: 'additive', depth: 4 }, - { target: 'wall', id: 'w1', blend: undefined, depth: 5 }, - { target: 'wall', id: 'w2', blend: undefined, depth: 6 }, - { target: 'object', id: 103, blend: 'additive', depth: 6 }, - { target: 'wall', id: 'w3', blend: undefined, depth: 7 }, - { target: 'object', id: 101, blend: 'normal', depth: 9 }, - { target: 'wall', id: 'w4', blend: undefined, depth: 10 }, - ] - - for (let i = 0; i < expectedSequence.length; i++) { - const exp = expectedSequence[i]! - const actual = renderer.draws[i]! - assert(actual.target === exp.target, `Step ${i}: target mismatch (exp ${exp.target}, got ${actual.target})`) - assert(actual.id === exp.id, `Step ${i}: id mismatch (exp ${exp.id}, got ${actual.id})`) - assert(actual.depth === exp.depth, `Step ${i}: depth mismatch (exp ${exp.depth}, got ${actual.depth})`) - assert(actual.blendMode === exp.blend, `Step ${i}: blendMode mismatch (exp ${exp.blend}, got ${actual.blendMode})`) - } - } - - // --------------------------------------------------------------------------- - // SUITE 3: Incremental Depth Stepping (Simulating Entity Depth Loops) - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 3] Incremental Depth Stepping (Simulating Entity Loop) ---') - { - // In act-scene.ts, entities are sorted by depth, then drawUpToDepth(entity.depth) - // is called before each entity is drawn, followed by drawUpToDepth(Infinity). - // We simulate player at depth 5.5 and monster at depth 8. - - const rawObjects: PackObjectInput[] = [ - { id: 1, depth: 1, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 5, y: 0 }, - { id: 2, depth: 5, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 25, y: 0 }, - { id: 3, depth: 7, token: 'CHEST', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 35, y: 0 }, - { id: 4, depth: 12, token: 'R1', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 60, y: 0 }, - ] - const objs = buildObjectDrawables(rawObjects) - - const walls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w1', _depth: 2 } as any, x: 0, y: 0, cellX: 1, cellY: 1, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w2', _depth: 5.5 } as any, x: 0, y: 0, cellX: 2.5, cellY: 3, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _id: 'w3', _depth: 9 } as any, x: 0, y: 0, cellX: 4, cellY: 5, page: 0 }, - ] - - const renderer = new MockRenderer() - // Steps: - // 1. maxDepth = 5.5 (draws up to player depth) - // 2. maxDepth = 8.0 (draws up to monster depth) - // 3. maxDepth = Infinity (draws rest) - executeDepthDrawDispatch(renderer, { - walls, - objs, - maxDepthSteps: [5.5, 8.0, Infinity], - }) - - assert(renderer.draws.length === 7, `Expected all 7 draws, got ${renderer.draws.length}`) - - // Verify monotonic depth ordering across stepped calls - for (let i = 1; i < renderer.draws.length; i++) { - assert(renderer.draws[i]!.depth >= renderer.draws[i - 1]!.depth, 'Stepped draw depth must remain non-decreasing') - } - - // Verify items in step 1 had depth <= 5.5 - // Draws: Obj 1 (d=1), Wall w1 (d=2), Obj 2 (d=5), Wall w2 (d=5.5) - for (let i = 0; i < 4; i++) { - assert(renderer.draws[i]!.depth <= 5.5, `Draw #${i} must have depth <= 5.5`) - } - - // Verify items in step 2 had depth > 5.5 and <= 8.0 - // Draws: Obj 3 (d=7) - assert(renderer.draws[4]!.depth === 7 && renderer.draws[4]!.target === 'object', 'Step 2 must draw Obj 3') - - // Verify items in step 3 had depth > 8.0 - // Draws: Wall w3 (d=9), Obj 4 (d=12) - assert(renderer.draws[5]!.depth === 9 && renderer.draws[5]!.target === 'wall', 'Step 3 must draw Wall w3') - assert(renderer.draws[6]!.depth === 12 && renderer.draws[6]!.target === 'object', 'Step 3 must draw Obj 4') - assert(renderer.draws[6]!.blendMode === 'additive', 'Obj 4 (R1) must have blendMode: additive') - } - - // --------------------------------------------------------------------------- - // SUITE 4: Boundary, Degenerate & Stress Conditions - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 4] Boundary, Degenerate & Stress Conditions ---') - { - // 4.1 Empty lists - const emptyRenderer = new MockRenderer() - executeDepthDrawDispatch(emptyRenderer, { walls: [], objs: [], maxDepthSteps: [Infinity] }) - assert(emptyRenderer.draws.length === 0, 'Empty lists must produce 0 draws') - - // 4.2 Only walls (0 objects) - const wallsOnlyRenderer = new MockRenderer() - const sampleWalls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 1 } as any, x: 0, y: 0, cellX: 0, cellY: 1, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 3 } as any, x: 0, y: 0, cellX: 1, cellY: 2, page: 0 }, - ] - executeDepthDrawDispatch(wallsOnlyRenderer, { walls: sampleWalls, objs: [], maxDepthSteps: [Infinity] }) - assert(wallsOnlyRenderer.draws.length === 2, 'Walls-only must produce all wall draws') - - // 4.3 Only objects (0 walls) - all additive - const objsOnlyRenderer = new MockRenderer() - const additiveObjs = buildObjectDrawables([ - { id: 1, token: '11', depth: 10, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - { id: 2, token: 'HA', depth: 20, frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - ]) - executeDepthDrawDispatch(objsOnlyRenderer, { walls: [], objs: additiveObjs, maxDepthSteps: [Infinity] }) - assert(objsOnlyRenderer.draws.length === 2, 'Objects-only must produce all object draws') - assert(objsOnlyRenderer.draws.every(d => d.blendMode === 'additive'), 'All draws must be additive') - - // 4.4 Massive depth tie stress (100 walls and 100 objects all at depth = 50) - const tieWalls: SimulatedWall[] = Array.from({ length: 100 }, (_, i) => ({ - frame: { x: 0, y: 0, width: 32, height: 32, _id: `w_tie_${i}`, _depth: 50 } as any, - x: i, - y: i, - cellX: 25, - cellY: 25, - page: 0, - })) - const tieObjs = buildObjectDrawables(Array.from({ length: 100 }, (_, i) => ({ - id: i, - token: i % 2 === 0 ? '11' : 'BOX', - depth: 50, - frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, - x: i, - y: i, - }))) - - const tieRenderer = new MockRenderer() - executeDepthDrawDispatch(tieRenderer, { - walls: tieWalls, - objs: tieObjs, - maxDepthSteps: [50, Infinity], - }) - assert(tieRenderer.draws.length === 200, `Massive tie: must draw all 200 items (got ${tieRenderer.draws.length})`) - // Because wallDepth <= objDepth, all 100 walls must be drawn before the 100 objects - for (let i = 0; i < 100; i++) { - assert(tieRenderer.draws[i]!.target === 'wall', `Tie #${i} must be wall`) - } - for (let i = 100; i < 200; i++) { - assert(tieRenderer.draws[i]!.target === 'object', `Tie #${i} must be object`) - const isEven = (i - 100) % 2 === 0 - const expectedBlend = isEven ? 'additive' : 'normal' - assert(tieRenderer.draws[i]!.blendMode === expectedBlend, `Tie #${i} object blendMode mismatch`) - } - - // 4.5 Negative and extreme depths - const extremeObjs = buildObjectDrawables([ - { id: 1, depth: -1000, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - { id: 2, depth: 1000000, token: 'BOX', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - ]) - const extremeWalls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: -500 } as any, x: 0, y: 0, cellX: -200, cellY: -300, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 500000 } as any, x: 0, y: 0, cellX: 200000, cellY: 300000, page: 0 }, - ] - const extremeRenderer = new MockRenderer() - executeDepthDrawDispatch(extremeRenderer, { - walls: extremeWalls, - objs: extremeObjs, - maxDepthSteps: [0, Infinity], - }) - assert(extremeRenderer.draws.length === 4, 'Extreme depth items must all be drawn') - assert(extremeRenderer.draws[0]!.depth === -1000, 'Lowest negative depth drawn first') - assert(extremeRenderer.draws[1]!.depth === -500, 'Second negative depth drawn second') - assert(extremeRenderer.draws[2]!.depth === 500000, 'Large positive depth drawn third') - assert(extremeRenderer.draws[3]!.depth === 1000000, 'Largest depth drawn last') - - // 4.6 Viewport culling preservation: culling skips draw but does NOT stall or desync cursors - const cullingObjs = buildObjectDrawables([ - { id: 1, depth: 10, token: '11', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - { id: 2, depth: 20, token: 'BOX', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - { id: 3, depth: 30, token: '12', frame: { x: 0, y: 0, width: 10, height: 10, offsetX: 0, offsetY: 0, page: 0 }, x: 0, y: 0 }, - ]) - const cullingWalls: SimulatedWall[] = [ - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 15 } as any, x: 0, y: 0, cellX: 5, cellY: 10, page: 0 }, - { frame: { x: 0, y: 0, width: 32, height: 32, _depth: 25 } as any, x: 0, y: 0, cellX: 10, cellY: 15, page: 0 }, - ] - - const cullingRenderer = new MockRenderer() - // Cull Object 2 (idx 1) and Wall 1 (idx 0) - const res = executeDepthDrawDispatch(cullingRenderer, { - walls: cullingWalls, - objs: cullingObjs, - maxDepthSteps: [Infinity], - visibleObj: idx => idx !== 1, - visibleWall: idx => idx !== 0, - }) - assert(res.culledDraws === 2, `Expected 2 culled draws, got ${res.culledDraws}`) - assert(cullingRenderer.draws.length === 3, `Expected 3 drawn items, got ${cullingRenderer.draws.length}`) - // Drawn items should be: Obj 1 (depth 10), Wall 2 (depth 25), Obj 3 (depth 30) - assert(cullingRenderer.draws[0]!.depth === 10 && cullingRenderer.draws[0]!.target === 'object', 'First draw is Obj 1') - assert(cullingRenderer.draws[1]!.depth === 25 && cullingRenderer.draws[1]!.target === 'wall', 'Second draw is Wall 2') - assert(cullingRenderer.draws[2]!.depth === 30 && cullingRenderer.draws[2]!.target === 'object', 'Third draw is Obj 3') - assert(cullingRenderer.draws[0]!.blendMode === 'additive', 'Obj 1 must be additive') - assert(cullingRenderer.draws[2]!.blendMode === 'additive', 'Obj 3 must be additive') - - // 4.7 Missing page skipping: page=null skips draw but advances cursors - const missingPageRenderer = new MockRenderer() - const missingPageRes = executeDepthDrawDispatch(missingPageRenderer, { - walls: cullingWalls, - objs: cullingObjs, - maxDepthSteps: [Infinity], - objPages: [null], // object page 0 missing - wallPages: [{}], // wall page 0 valid - }) - assert(missingPageRes.skippedDraws === 3, `Expected 3 skipped object draws, got ${missingPageRes.skippedDraws}`) - assert(missingPageRenderer.draws.length === 2, `Expected 2 wall draws, got ${missingPageRenderer.draws.length}`) - assert(missingPageRenderer.draws[0]!.depth === 15 && missingPageRenderer.draws[1]!.depth === 25, 'Wall depths preserved') - } - - // --------------------------------------------------------------------------- - // SUITE 5: Differential Fuzzing vs Independent Naive Oracle (10,000 Iterations) - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 5] Differential Fuzzing vs Naive Oracle (10,000 Iterations) ---') - { - const TOKEN_POOL = [ - '11', '12', 'R1', 'R2', 'R3', 'R4', 'R5', '1R', '2R', '3R', '4R', 'HA', // Additive - 'to', 'a1', 'a2', 'box', 'chest', 'urn', 'barrel', 'column', 'statue', // Normal - '', ' ', 'r1', 'ha', ' 11 ', // Variants - undefined, - ] - const TRANS_POOL = [0, 1, 7, 2, 3, undefined] - const BLEND_POOL = ['normal' as const, 'additive' as const, undefined] - - // Simple, obviously correct Oracle for differential testing - function naiveOracleSort( - walls: SimulatedWall[], - objs: PackObjectInput[], - maxDepthSteps: number[] - ): RecordedDraw[] { - // 1. Resolve objects - const resolvedObjs: { target: 'object'; depth: number; blendMode: 'normal' | 'additive'; id: any; x: number; y: number }[] = [] - for (const o of objs) { - if (!o.frame) continue - const tokenUpper = o.token ? o.token.trim().toUpperCase() : '' - const isAdditive = o.blendMode === 'additive' || o.trans === 7 || o.trans === 1 || ADDITIVE_OBJECT_TOKENS.has(tokenUpper) - const blendMode = isAdditive ? 'additive' : (o.blendMode ?? 'normal') - const depth = o.depth ?? ((o.x + o.y) / 5) - resolvedObjs.push({ - target: 'object', - depth, - blendMode, - id: o.id, - x: o.x + (o.frame.offsetX ?? 0), - y: o.y + (o.frame.offsetY ?? 0), - }) - } - // Stable sort objects by depth - resolvedObjs.sort((a, b) => a.depth - b.depth) - - // 2. Resolve walls - const resolvedWalls = walls.map(w => ({ - target: 'wall' as const, - depth: w.cellX + w.cellY, - blendMode: undefined, - id: (w.frame as any)._id, - x: w.x, - y: w.y, - })) - resolvedWalls.sort((a, b) => a.depth - b.depth) - - // 3. Two-pointer merge identically to Painter's algorithm - const allDrawn: RecordedDraw[] = [] - let wIdx = 0 - let oIdx = 0 - - for (const maxD of maxDepthSteps) { - while (wIdx < resolvedWalls.length || oIdx < resolvedObjs.length) { - const w = wIdx < resolvedWalls.length ? resolvedWalls[wIdx]! : null - const o = oIdx < resolvedObjs.length ? resolvedObjs[oIdx]! : null - - const wDepth = w !== null ? w.depth : Infinity - const oDepth = o !== null ? o.depth : Infinity - - if (wDepth > maxD && oDepth > maxD) break - - if (wDepth <= oDepth) { - allDrawn.push({ - target: 'wall', - depth: w!.depth, - id: w!.id, - x: w!.x, - y: w!.y, - options: {}, - blendMode: undefined, - }) - wIdx++ - } else { - allDrawn.push({ - target: 'object', - depth: o!.depth, - id: o!.id, - x: o!.x, - y: o!.y, - options: { blendMode: o!.blendMode }, - blendMode: o!.blendMode, - }) - oIdx++ - } - } - } - return allDrawn - } - - const NUM_ITERATIONS = 10000 - let lastReport = 0 - - for (let seed = 1; seed <= NUM_ITERATIONS; seed++) { - // Deterministic PRNG (Mulberry32) - let a = seed ^ 0x9e3779b9 - const rand = () => { - let t = (a += 0x6d2b79f5) - t = Math.imul(t ^ (t >>> 15), t | 1) - t ^= t + Math.imul(t ^ (t >>> 7), t | 61) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } - - // Generate random small-to-medium scene - const numWalls = Math.floor(rand() * 25) - const numObjs = Math.floor(rand() * 25) - - const walls: SimulatedWall[] = [] - for (let i = 0; i < numWalls; i++) { - const cx = Math.floor(rand() * 20) - 5 - const cy = Math.floor(rand() * 20) - 5 - walls.push({ - frame: { x: 0, y: 0, width: 32, height: 32, _id: `w_${seed}_${i}`, _depth: cx + cy } as any, - x: cx * 10, - y: cy * 10, - cellX: cx, - cellY: cy, - page: 0, - }) - } - // In D2 map generation, walls are always sorted by cellY + cellX - walls.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY) - - const packObjs: PackObjectInput[] = [] - for (let i = 0; i < numObjs; i++) { - const hasFrame = rand() > 0.05 - const token = TOKEN_POOL[Math.floor(rand() * TOKEN_POOL.length)] - const trans = TRANS_POOL[Math.floor(rand() * TRANS_POOL.length)] - const blend = BLEND_POOL[Math.floor(rand() * BLEND_POOL.length)] - const x = Math.floor(rand() * 200) - 50 - const y = Math.floor(rand() * 200) - 50 - const depth = rand() > 0.3 ? (Math.floor(rand() * 30) - 10) : undefined - - packObjs.push({ - id: i, - token, - trans, - blendMode: blend, - x, - y, - depth, - frame: hasFrame ? { x: 0, y: 0, width: 16, height: 16, offsetX: 0, offsetY: 0, page: 0 } : null, - }) - } - - // Random depth steps - const steps = [ - Math.floor(rand() * 10) - 5, - Math.floor(rand() * 20), - Infinity, - ].sort((x, y) => x - y) - - // Run oracle - const expected = naiveOracleSort(walls, packObjs, steps) - - // Run actual implementation simulation - const objDrawables = buildObjectDrawables(packObjs) - const mockRenderer = new MockRenderer() - executeDepthDrawDispatch(mockRenderer, { - walls, - objs: objDrawables, - maxDepthSteps: steps, - }) - - const actual = mockRenderer.draws - - // Assert parity between implementation and oracle - assert(actual.length === expected.length, `Iteration ${seed}: Length mismatch (expected ${expected.length}, got ${actual.length})`) - - for (let j = 0; j < actual.length; j++) { - const exp = expected[j]! - const act = actual[j]! - assert(act.target === exp.target, `Iteration ${seed}, Draw #${j}: Target mismatch (${act.target} vs ${exp.target})`) - assert(act.depth === exp.depth, `Iteration ${seed}, Draw #${j}: Depth mismatch (${act.depth} vs ${exp.depth})`) - assert(act.blendMode === exp.blendMode, `Iteration ${seed}, Draw #${j}: BlendMode mismatch (${act.blendMode} vs ${exp.blendMode})`) - } - - // Check depth monotonically non-decreasing - for (let j = 1; j < actual.length; j++) { - assert(actual[j]!.depth >= actual[j - 1]!.depth, `Iteration ${seed}: Depth monotonicity violated at draw #${j}`) - } - - if (seed - lastReport >= 2500) { - lastReport = seed - console.log(` ... completed ${seed} / ${NUM_ITERATIONS} differential fuzzing iterations`) - } - } - console.log(`✅ Successfully completed ${NUM_ITERATIONS} differential fuzzing cases with 0 mismatches`) - } - - // --------------------------------------------------------------------------- - // SUITE 6: Full Audit on Real Scene Packs (Level 108 Chaos Sanctuary) - // --------------------------------------------------------------------------- - console.log('\n--- [Suite 6] Real Scene Pack Audit: Level 108 Chaos Sanctuary ---') - { - const packVariants = [ - 'samples/d2-packs/act4/108-act-4-diablo-1-var1/scene.json', - 'samples/d2-packs/act4/108-act-4-diablo-1-var2/scene.json', - 'samples/d2-packs/act4/108-act-4-diablo-1-var3/scene.json', - ] - - for (const packPath of packVariants) { - if (!fs.existsSync(packPath)) { - console.warn(`Pack path not found: ${packPath}`) - continue - } - const rawData = fs.readFileSync(packPath, 'utf8') - const scene = JSON.parse(rawData) - - // Convert pack scene.walls to SimulatedWall - const walls: SimulatedWall[] = scene.walls.map((row: any[]) => ({ - frame: { x: 0, y: 0, width: 32, height: 32, _depth: (row[3] ?? 0) + (row[4] ?? 0) } as any, - x: row[1] ?? 0, - y: row[2] ?? 0, - cellX: row[3] ?? 0, - cellY: row[4] ?? 0, - page: scene.framePlacement[row[0] ?? 0]?.[0] ?? 0, - })) - - // Convert pack scene.objects to PackObjectInput - const packObjects: PackObjectInput[] = scene.objects.map((obj: any) => ({ - ...obj, - })) - - const objectDrawables = buildObjectDrawables(packObjects) - const mockRenderer = new MockRenderer() - - executeDepthDrawDispatch(mockRenderer, { - walls, - objs: objectDrawables, - maxDepthSteps: [Infinity], - }) - - // Count window light rays (tokens 11 and 12) - const rays = objectDrawables.filter(d => d.token === '11' || d.token === '12') - assert(rays.length > 0, `${packPath}: Must contain window light rays`) - for (const ray of rays) { - assert(ray.blendMode === 'additive', `${packPath}: Ray ${ray.id} (token ${ray.token}) must be additive`) - } - - // Check all draws for window rays in renderer - const rayDraws = mockRenderer.draws.filter(d => d.token === '11' || d.token === '12') - assert(rayDraws.length === rays.length, `${packPath}: All rays must be drawn by renderer`) - for (const d of rayDraws) { - assert(d.blendMode === 'additive', `${packPath}: Renderer ray draw must pass blendMode: 'additive'`) - } - - // Check depth ordering of all draws in real scene - for (let i = 1; i < mockRenderer.draws.length; i++) { - assert( - mockRenderer.draws[i]!.depth >= mockRenderer.draws[i - 1]!.depth, - `${packPath}: Depth ordering violated at draw #${i} (${mockRenderer.draws[i]!.depth} < ${mockRenderer.draws[i - 1]!.depth})` - ) - } - - console.log(` ✓ ${path.basename(path.dirname(packPath))}: ${walls.length} walls, ${objectDrawables.length} objects (${rays.length} rays), all verified additive and depth-sorted`) - } - } - - console.log('\n================================================================================') - console.log('🎉 ALL EMPIRICAL CHALLENGER STRESS TESTS PASSED!') - console.log(`Total Assertions Executed: ${totalAssertions}`) - console.log(`Assertions Passed: ${passedAssertions}`) - console.log('Explicit Verdict: APPROVE') - console.log('================================================================================') -} - -runEmpiricalStressHarness().catch(err => { - console.error('Empirical Stress Harness Failure:', err) - process.exit(1) -}) diff --git a/tools/verify-skill-browser.ts b/tools/verify-skill-browser.ts deleted file mode 100644 index d705118..0000000 --- a/tools/verify-skill-browser.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Headless Chromium Skill Verification & Screenshot Runner (`scripts/verify-skill-browser.ts`). - * - * Usage: - * npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill-47.png] - * - * Launches Vite (if not already running on 127.0.0.1:5173) + headless Chromium - * (`/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome`), - * opens `/acts.html?arena=1&class=&skill=&slvl=&autocast=1`, - * verifies `window.__d2webArena.ready === true && castCount >= 1` and zero runtime errors, - * and saves a PNG screenshot to `--out`. - */ -import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { createServer } from 'vite' -import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts' - -const CHROME_CANDIDATES = [ - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/usr/bin/google-chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) - -export interface BrowserSkillVerificationResult { - readonly ok: boolean - readonly url: string - readonly screenshotPath: string - readonly screenshotBytes: number - readonly consoleErrors: string[] - readonly arenaState: Record -} - -export async function verifySkillInBrowser(params: { - skillId: number - classCode?: string - slvl?: number - screenshotPath: string - baseUrl?: string -}): Promise { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(params.skillId) - const classCode = (params.classCode || skillRec?.charClass || 'sor').toLowerCase() - const slvl = params.slvl ?? 20 - - let viteServer: Awaited> | null = null - let baseUrl = params.baseUrl ?? '' - - if (!baseUrl) { - const chosenPort = 5200 + Math.floor(Math.random() * 600) - viteServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { - host: '127.0.0.1', - port: chosenPort, - strictPort: false, - }, - }) - await viteServer.listen() - const addr = viteServer.httpServer?.address() - const port = typeof addr === 'object' && addr ? addr.port : chosenPort - baseUrl = `http://127.0.0.1:${String(port)}` - } - - const targetUrl = `${baseUrl}/acts.html?arena=1&class=${encodeURIComponent(classCode)}&skill=${String(params.skillId)}&slvl=${String(slvl)}&autocast=1` - const chromeBin = resolveChromeBinary() - const debugPort = 9300 + Math.floor(Math.random() * 500) - - const chrome = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1024,680', - `--remote-debugging-port=${String(debugPort)}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - try { - let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null - for (let i = 0; i < 80; i += 1) { - await sleep(150) - try { - const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as { - type: string - url: string - webSocketDebuggerUrl: string - }[] - const pages = list.filter(x => x.type === 'page') - const matched = pages.find(x => x.url.includes('acts.html')) - if (matched && matched.webSocketDebuggerUrl) { - pageTarget = matched - break - } - } catch {} - } - if (!pageTarget) { - throw new Error(`Headless Chromium did not open ${targetUrl}`) - } - - const ws = new WebSocket(pageTarget.webSocketDebuggerUrl) - await new Promise((res, rej) => { - ws.onopen = res - ws.onerror = rej - }) - - let msgId = 0 - const pending = new Map) => void>() - const consoleErrors: string[] = [] - - ws.onmessage = event => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['method'] === 'Runtime.exceptionThrown') { - consoleErrors.push(JSON.stringify(msg['params'])) - } - const id = msg['id'] as number | undefined - if (id && pending.has(id)) { - pending.get(id)!(msg) - pending.delete(id) - } - } - - const send = (method: string, cdpParams: Record = {}) => - new Promise>(res => { - const id = ++msgId - pending.set(id, res) - ws.send(JSON.stringify({ id, method, params: cdpParams })) - }) - - await send('Runtime.enable') - await send('Page.enable') - - // Poll until `window.__d2webArena.ready === true && window.__d2webArena.castCount >= 1` - let arenaState: Record | null = null - for (let i = 0; i < 40; i += 1) { - await sleep(250) - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') { - await window.__initBrowserArenaMode(); - } - if (!window.__d2webArena) { - const { initBrowserArenaMode } = await import('/src/scene/act-scene.ts'); - if (typeof initBrowserArenaMode === 'function') await initBrowserArenaMode(); - } - const a = window.__d2webArena; - if (!a || !a.ready) return null; - if (a.castCount < 1 && typeof a.triggerCast === 'function') a.triggerCast(); - return { - ready: a.ready, - classCode: a.classCode, - charToken: a.charToken, - skillId: a.skillId, - skillName: a.skillName, - slvl: a.slvl, - tick: a.tick, - castCount: a.castCount, - actionFrameTriggered: a.actionFrameTriggered, - playerAnimMode: a.playerAnimMode, - playerAnimFrame: a.playerAnimFrame, - activeStates: a.activeStates, - activeOverlays: a.activeOverlays, - missilesSpawned: a.missilesSpawned, - petsActive: a.petsActive, - corpsesRemaining: a.corpsesRemaining, - totalDamageDealt: a.totalDamageDealt, - tooltipStats: a.tooltipStats, - }; - })()`, - awaitPromise: true, - returnByValue: true, - }) - const val = (evalRes['result'] as { result?: { value?: Record | null } } | undefined) - ?.result?.value - if (val && val['ready'] === true && Number(val['castCount']) >= 1) { - arenaState = val - break - } - } - - if (!arenaState) { - throw new Error(`Timed out waiting for window.__d2webArena.ready on ${targetUrl}`) - } - - // Give canvas 250ms to settle paint before screenshot - await sleep(250) - - const shot = await send('Page.captureScreenshot', { format: 'png' }) - const base64Data = (shot['result'] as { data?: string } | undefined)?.data - if (!base64Data) { - throw new Error('CDP Page.captureScreenshot returned empty image data') - } - - const outPath = resolve(params.screenshotPath) - mkdirSync(dirname(outPath), { recursive: true }) - const pngBuf = Buffer.from(base64Data, 'base64') - writeFileSync(outPath, pngBuf) - - ws.close() - return { - ok: consoleErrors.length === 0 && pngBuf.byteLength > 1000, - url: targetUrl, - screenshotPath: outPath, - screenshotBytes: pngBuf.byteLength, - consoleErrors, - arenaState, - } - } finally { - chrome.kill() - if (viteServer) { - await viteServer.close() - } - } -} - -async function main(): Promise { - const args = process.argv.slice(2) - if (args.length === 0) { - console.error('Usage: npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill.png]') - process.exit(1) - } - - const skillId = Number(args[0]) - const classArg = args.find(a => a.startsWith('--class='))?.split('=')[1] - const slvlArg = args.find(a => a.startsWith('--slvl='))?.split('=')[1] - const outArg = args.find(a => a.startsWith('--out='))?.split('=')[1] ?? `/tmp/skill-${String(skillId)}.png` - - const res = await verifySkillInBrowser({ - skillId, - ...(classArg !== undefined ? { classCode: classArg } : {}), - slvl: slvlArg ? Number(slvlArg) : 20, - screenshotPath: outArg, - }) - - console.log(JSON.stringify(res, null, 2)) - if (!res.ok) { - process.exit(1) - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - void main() -} diff --git a/tools/verify-tooltip-browser.ts b/tools/verify-tooltip-browser.ts deleted file mode 100644 index ee83f32..0000000 --- a/tools/verify-tooltip-browser.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Diablo II Tooltip & Color Dye Parity. - * - * Verifies: - * 1. Harlequin Crest in helm slot renders emerald green dyed sprite (`invcap_cgrn`). - * 2. Call to Arms in bag inventory renders full authentic 11-line tooltip without truncation. - * 3. Dynamic viewport vertical flip (hover.y < 280) and screen boundary clamping. - * 4. Zero runtime requests for `*.mpq` and `*.dll` files. - */ - -import { spawn, type ChildProcess } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/d2w-wg1-main/.agents/teamwork_preview_worker_m5_tooltip' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface NetworkRequestEvent { - requestId: string - request: { - url: string - method: string - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - console.log(`Starting headless browser audit, output directory: ${ARTIFACT_DIR}`) - - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - const debugPort = 9244 - const chromeProc: ChildProcess = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - `${baseUrl}/acts.html?act=1`, - ]) - - const networkRequests: string[] = [] - const mpqOrDllRequests: string[] = [] - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error('Failed to connect to Chrome CDP') - } - console.log('2. Connected to Chrome CDP WebSocket:', wsUrl) - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Network.requestWillBeSent') { - const params = msg.params as NetworkRequestEvent - const url = params.request.url - networkRequests.push(url) - if (/\.(mpq|dll)(\?|$)/i.test(url)) { - mpqOrDllRequests.push(url) - } - } - } catch { - // Ignore JSON parse errors on non-event messages - } - }) - - await sendCommand('Network.enable') - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(ARTIFACT_DIR, filename) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename} -> ${filePath}`) - return filePath - } - - // Wait for ActScene and HudManager to become fully ready - console.log('3. Waiting for __d2webAct and __d2webHud ready...') - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.itemsAtlas)`, - ) - if (ready) break - await sleep(300) - } - await sleep(600) - - // Open inventory panel - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - if (inst.rightDockPanel !== 'inv') { - inst.toggleRightPanel('inv'); - } - inst.render(performance.now(), false); - })()`) - await sleep(300) - - // --- Verification 1: Harlequin Crest Dyed Sprite Parity --- - console.log('4. Verifying Harlequin Crest dyed sprite (invcap_cgrn)...') - const helmAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const helm = inst.inventory.equipped.helm; - inst.inventory.handleMouseMove(561, 93); // Hover over helm slot (x=532..590, y=64..122) - inst.render(performance.now(), false); - - const hovered = inst.inventory.hoveredItem; - const hudCanvas = document.getElementById('d2-hud-canvas'); - const ctx = hudCanvas ? hudCanvas.getContext('2d') : null; - // Read pixel in the helm area to verify green color presence - let hasGreen = false; - let sampleR = 0, sampleG = 0, sampleB = 0; - let atlasHasGreen = false; - let atlasSampleR = 0, atlasSampleG = 0, atlasSampleB = 0; - - // Check 1: Sample directly from the itemsAtlas image rect for invcap_cgrn (x: 554, y: 1796, w: 56, h: 56) - const atlasImg = inst.images.get('itemsAtlas'); - if (atlasImg) { - const offCanvas = document.createElement('canvas'); - offCanvas.width = 56; - offCanvas.height = 56; - const offCtx = offCanvas.getContext('2d'); - if (offCtx) { - offCtx.drawImage(atlasImg, 554, 1796, 56, 56, 0, 0, 56, 56); - const imgData = offCtx.getImageData(0, 0, 56, 56).data; - for (let i = 0; i < imgData.length; i += 4) { - const r = imgData[i]; - const g = imgData[i + 1]; - const b = imgData[i + 2]; - const a = imgData[i + 3]; - // Table 104 emerald green check: G significantly higher than R and B (RGB ~ [51, 135, 75]) - if (a > 150 && g > r * 1.3 && g > b * 1.2 && g > 70) { - atlasHasGreen = true; - atlasSampleR = r; atlasSampleG = g; atlasSampleB = b; - break; - } - } - } - } - - // Check 2: Sample physical canvas at paperdoll helm slot - if (ctx && hudCanvas) { - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth); - const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight); - const uiScale = Math.min(cssW / 800, cssH / 600); - const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2); - const offsetY = Math.max(0, cssH - 600 * uiScale); - const physX = Math.round((offsetX + 545 * uiScale) * dpr); - const physY = Math.round((offsetY + 75 * uiScale) * dpr); - const sampleW = Math.round(30 * uiScale * dpr); - const sampleH = Math.round(30 * uiScale * dpr); - - const pixelData = ctx.getImageData(physX, physY, sampleW, sampleH).data; - for (let i = 0; i < pixelData.length; i += 4) { - const r = pixelData[i]; - const g = pixelData[i + 1]; - const b = pixelData[i + 2]; - const a = pixelData[i + 3]; - if (a > 150 && g > r * 1.2 && g > b * 1.1 && g > 50) { - hasGreen = true; - sampleR = r; sampleG = g; sampleB = b; - break; - } - } - } - - return { - helmName: helm?.name, - helmNameZh: helm?.nameZh, - invtransform: helm?.invtransform, - durability: helm?.durability, - defense: helm?.defense, - reqStr: helm?.reqStr, - reqLevel: helm?.reqLevel, - statCount: helm?.stats?.length, - hoveredTitle: hovered?.item?.nameZh, - hoveredY: hovered?.y, - hasEmeraldGreenPixels: hasGreen, - sampleR, sampleG, sampleB, - atlasHasGreen, - atlasSampleR, atlasSampleG, atlasSampleB, - }; - })()`) - console.log(' Helm audit result:', JSON.stringify(helmAudit)) - if (helmAudit.helmName !== 'Harlequin Crest' || helmAudit.invtransform !== 'cgrn') { - throw new Error(`Harlequin Crest audit failed: ${JSON.stringify(helmAudit)}`) - } - const shotHelm = await saveScreenshot('harlequin_crest_helm_hover.png') - - // --- Verification 2: Call to Arms Runeword 11 Canonical Stats & Unoccluded Title --- - console.log('5. Verifying Call to Arms in bag inventory...') - const ctaAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const ctaGrid = inst.inventory.gridItems.find(g => g.item.id === 'bag-cta'); - // Hover Call to Arms in inventory grid (col 8, row 0: cell center x=664, y=330) - inst.inventory.handleMouseMove(664, 330); - inst.render(performance.now(), false); - - const hovered = inst.inventory.hoveredItem; - const cta = ctaGrid?.item; - const wp = inst.worldPanels; - const hudCanvas = document.getElementById('d2-hud-canvas'); - const controls = document.getElementById('controls'); - const hudZIndex = hudCanvas ? parseInt(window.getComputedStyle(hudCanvas).zIndex || '0', 10) : 0; - const controlsZIndex = controls ? parseInt(window.getComputedStyle(controls).zIndex || '0', 10) : 0; - const lastRect = wp ? wp.lastTooltipRect : null; - - // Sample gold title text pixels on canvas to verify full rendering and unoccluded title - let hasGoldTitlePixels = false; - let titleSampleR = 0, titleSampleG = 0, titleSampleB = 0; - const ctx = hudCanvas ? hudCanvas.getContext('2d') : null; - if (ctx && hudCanvas && lastRect) { - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth); - const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight); - const uiScale = Math.min(cssW / 800, cssH / 600); - const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2); - const offsetY = Math.max(0, cssH - 600 * uiScale); - - // Title is centered around (lastRect.bx + lastRect.boxW / 2) at y = lastRect.by + 24 - const physX = Math.round((offsetX + (lastRect.bx + lastRect.boxW / 2) * uiScale) * dpr); - const physY = Math.round((offsetY + (lastRect.by + 24) * uiScale) * dpr); - const sampleW = Math.round(120 * uiScale * dpr); - const sampleH = Math.round(16 * uiScale * dpr); - - const pixelData = ctx.getImageData( - Math.max(0, physX - Math.round(sampleW / 2)), - Math.max(0, physY - Math.round(sampleH / 2)), - sampleW, - sampleH, - ).data; - for (let i = 0; i < pixelData.length; i += 4) { - const r = pixelData[i]; - const g = pixelData[i + 1]; - const b = pixelData[i + 2]; - const a = pixelData[i + 3]; - // D2 Gold font (#c8a15a: R ~ 200, G ~ 161, B ~ 90) - if (a > 150 && r > 150 && g > 120 && b < 130 && r > b * 1.3) { - hasGoldTitlePixels = true; - titleSampleR = r; - titleSampleG = g; - titleSampleB = b; - break; - } - } - } - - return { - id: cta?.id, - name: cta?.name, - nameZh: cta?.nameZh, - baseNameZh: cta?.baseNameZh, - quality: cta?.quality, - runes: cta?.runewordRunes, - damage: cta?.damage, - durability: cta?.durability, - speedText: cta?.speedText, - reqStr: cta?.reqStr, - reqLevel: cta?.reqLevel, - sockets: cta?.sockets, - statCount: cta?.stats?.length, - stats: cta?.stats?.map(s => s.text), - hoveredItemName: hovered?.item?.nameZh, - lastTooltipRect: lastRect, - hudZIndex, - controlsZIndex, - isHudAboveControls: hudZIndex > controlsZIndex, - isTooltipClampedBelowControls: lastRect ? lastRect.by >= 44 : false, - hasGoldTitlePixels, - titleSampleR, - titleSampleG, - titleSampleB, - }; - })()`) - console.log(' Call to Arms audit result:', JSON.stringify(ctaAudit, null, 2)) - if (ctaAudit.name !== 'Call to Arms' || ctaAudit.statCount !== 11) { - throw new Error(`Call to Arms audit failed: expected 11 stats, got ${ctaAudit.statCount}`) - } - if (!ctaAudit.isHudAboveControls) { - throw new Error( - `HUD canvas z-index (${ctaAudit.hudZIndex}) must be higher than controls overlay (${ctaAudit.controlsZIndex})!`, - ) - } - if (!ctaAudit.isTooltipClampedBelowControls) { - throw new Error( - `Call to Arms tooltip must be clamped below controls overlay (by >= 44, got ${ctaAudit.lastTooltipRect?.by})!`, - ) - } - if (!ctaAudit.hasGoldTitlePixels) { - throw new Error('Call to Arms title text gold pixels not detected on canvas!') - } - const shotCta = await saveScreenshot('call_to_arms_inventory_hover.png') - - // --- Verification 3: Dynamic Vertical Flip & Clamping (Live UI Interaction) --- - console.log('6. Verifying Viewport Vertical Flip and Clamping behavior with live interaction...') - const flipAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const wp = inst.worldPanels; - - // 1. Move to helm slot at (561, 93) near top of screen - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - - const topHelmHover = inst.inventory.hoveredItem; - const topHelmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const helmHoverY = topHelmHover?.y ?? 93; - const isHelmFlippedBelow = topHelmRect ? topHelmRect.by > helmHoverY : false; - - // 2. Move to amulet slot at (618, 107) near top of screen - inst.inventory.handleMouseMove(618, 107); - inst.render(performance.now(), false); - - const topAmuHover = inst.inventory.hoveredItem; - const topAmuRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const amuHoverY = topAmuHover?.y ?? 107; - const isAmuFlippedBelow = topAmuRect ? topAmuRect.by > amuHoverY : false; - - // 3. Move to Call to Arms in bag grid at (664, 330) near bottom - inst.inventory.handleMouseMove(664, 330); - inst.render(performance.now(), false); - - const btmHover = inst.inventory.hoveredItem; - const btmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const btmHoverY = btmHover?.y ?? 330; - const isBtmPlacedAbove = btmRect ? btmRect.by < btmHoverY : false; - const isBtmClamped = btmRect ? (btmRect.by >= 44 && btmRect.by + btmRect.boxH <= 600 - 8) : false; - - // 4. Trigger mouse move to amulet slot (618, 107) and render so screenshot captures genuine flipped tooltip - inst.inventory.handleMouseMove(618, 107); - inst.render(performance.now(), false); - - return { - helmHoverY, - topHelmRect, - isHelmFlippedBelow, - amuHoverY, - topAmuRect, - isAmuFlippedBelow, - btmHoverY, - btmRect, - isBtmPlacedAbove, - isBtmClamped, - liveItemNameZh: inst.inventory.hoveredItem?.item?.nameZh, - activeRect: wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null, - }; - })()`) - console.log(' Vertical flip and clamping audit:', JSON.stringify(flipAudit, null, 2)) - if (!flipAudit.isHelmFlippedBelow || !flipAudit.isAmuFlippedBelow) { - throw new Error( - `Vertical flip below cursor failed: helm=${flipAudit.isHelmFlippedBelow}, amu=${flipAudit.isAmuFlippedBelow}`, - ) - } - if (!flipAudit.isBtmPlacedAbove || !flipAudit.isBtmClamped) { - throw new Error( - `Bottom placement or clamping failed: placedAbove=${flipAudit.isBtmPlacedAbove}, clamped=${flipAudit.isBtmClamped}`, - ) - } - const shotFlip = await saveScreenshot('tooltip_clamping_and_flip.png') - - // Verify distinct screenshot SHA256 checksums - const ctaBuf = readFileSync(shotCta) - const flipBuf = readFileSync(shotFlip) - const helmBuf = readFileSync(shotHelm) - const ctaSha256 = createHash('sha256').update(ctaBuf).digest('hex') - const flipSha256 = createHash('sha256').update(flipBuf).digest('hex') - const helmSha256 = createHash('sha256').update(helmBuf).digest('hex') - - console.log(' Screenshot SHA256 verification:') - console.log(` harlequin_crest_helm_hover.png: ${helmSha256}`) - console.log(` call_to_arms_inventory_hover.png: ${ctaSha256}`) - console.log(` tooltip_clamping_and_flip.png: ${flipSha256}`) - - if (flipSha256 === ctaSha256) { - throw new Error( - `INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and call_to_arms_inventory_hover.png have identical SHA256 (${flipSha256})`, - ) - } - if (flipSha256 === helmSha256) { - throw new Error( - `INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and harlequin_crest_helm_hover.png have identical SHA256 (${flipSha256})`, - ) - } - - // --- Verification 4: Zero Runtime MPQ/DLL Requests --- - console.log('7. Verifying Zero Runtime MPQ/DLL requests...') - console.log(` Total Network Requests observed: ${networkRequests.length}`) - console.log(` MPQ or DLL Requests observed: ${mpqOrDllRequests.length}`) - if (mpqOrDllRequests.length > 0) { - throw new Error( - `VIOLATION: Found ${mpqOrDllRequests.length} runtime MPQ/DLL requests: ${JSON.stringify(mpqOrDllRequests)}`, - ) - } - - // Write full audit report artifact - const auditSummary = { - timestamp: new Date().toISOString(), - status: 'VERIFIED_PASSING', - zeroMpqDllInvariant: { - totalNetworkRequests: networkRequests.length, - mpqDllRequestsCount: mpqOrDllRequests.length, - verifiedZero: true, - }, - harlequinCrest: helmAudit, - callToArms: ctaAudit, - verticalFlipAndClamping: flipAudit, - screenshotChecksums: { - 'harlequin_crest_helm_hover.png': helmSha256, - 'call_to_arms_inventory_hover.png': ctaSha256, - 'tooltip_clamping_and_flip.png': flipSha256, - allDistinct: true, - }, - screenshots: [ - 'harlequin_crest_helm_hover.png', - 'call_to_arms_inventory_hover.png', - 'tooltip_clamping_and_flip.png', - ], - } - - const summaryPath = join(ARTIFACT_DIR, 'audit_summary.json') - writeFileSync(summaryPath, JSON.stringify(auditSummary, null, 2)) - console.log(`8. Wrote audit summary to ${summaryPath}`) - console.log('=== ALL HEADLESS BROWSER CDP AUDITS PASSED 100% ===') - - ws.close() - } finally { - chromeProc.kill() - await server.close() - } -} - -main().catch(err => { - console.error('verify-tooltip-browser failed:', err) - process.exit(1) -}) diff --git a/tools/verify-tooltip-empirical.ts b/tools/verify-tooltip-empirical.ts deleted file mode 100644 index 08c10b7..0000000 --- a/tools/verify-tooltip-empirical.ts +++ /dev/null @@ -1,597 +0,0 @@ -/** - * scripts/verify-tooltip-empirical.ts - * - * Standalone Empirical Challenger Verification & Stress Test Suite - * for Milestone M3 (Diablo II v1.13c Item Tooltips & Parity). - * - * Validates: - * 1. Tooltip rendering across all 10 starter equipped items: - * Shako, Mara's, Oculus, Tal Armor, Spirit, Magefist, Arachnid, SOJ, BK, WT. - * 2. Tooltip rendering across starter bag items: - * CTA, Torch, Anni, Gheed's Fortune. - * 3. Deep canonical assertions on Harlequin Crest (Shako) and Call to Arms (CTA). - * 4. Dynamic vertical flip threshold (hover.y = 200, 279, 280, 500) and viewport clamping. - * 5. formatItemTooltip runeword, unique, set, and socketable formatting parity. - * 6. Adversarial edge cases: extreme coordinates, long text lines, zero/missing fields. - * 7. Rapid iteration stress harness (2,000 hover frames). - * 8. Zero runtime MPQ/DLL network/file dependency guard. - */ - -import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' -import { - STARTER_EQUIPPED_GEAR, - STARTER_BAG_ITEMS, - InventoryPanel, - EQUIP_SLOTS_LAYOUT, - INV_GRID_ORIGIN, - type UiInventoryItem, - type EquipSlotId, -} from '../src/client/ui/inventory.ts' -import { formatItemTooltip, type FormattedItemTooltip } from '../src/common/items/item-tooltip.ts' -import type { Item, ItemBase } from '../src/common/items/items.ts' -import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/client/ui/font.ts' - -interface DrawCall { - text: string - x: number - y: number - options?: { - font?: D2FontName - color?: D2ColorCode - align?: 'left' | 'center' | 'right' - shadow?: boolean - } -} - -interface MockFontRenderer extends D2FontRenderer { - drawCalls: DrawCall[] -} - -function createMockFont(): MockFontRenderer { - const drawCalls: DrawCall[] = [] - return { - metas: {} as any, - images: new Map(), - tintedCache: new Map(), - getTintedAtlas: () => null as any, - measureText: (text: string, _font: D2FontName = 'font8') => { - return text.length * 7 - }, - drawText: (_ctx: any, text: string, x: number, y: number, options?: any) => { - drawCalls.push({ text, x, y, options }) - }, - drawCalls, - } as unknown as MockFontRenderer -} - -interface MockCanvasContext { - fillStyle: string - strokeStyle: string - fillRectCalls: [number, number, number, number][] - strokeRectCalls: [number, number, number, number][] - fillRect: (x: number, y: number, w: number, h: number) => void - strokeRect: (x: number, y: number, w: number, h: number) => void -} - -function createMockCanvas(): MockCanvasContext { - const fillRectCalls: [number, number, number, number][] = [] - const strokeRectCalls: [number, number, number, number][] = [] - return { - fillStyle: '', - strokeStyle: '', - fillRectCalls, - strokeRectCalls, - fillRect: (x: number, y: number, w: number, h: number) => { - fillRectCalls.push([x, y, w, h]) - }, - strokeRect: (x: number, y: number, w: number, h: number) => { - strokeRectCalls.push([x, y, w, h]) - }, - } -} - -let totalAssertions = 0 -let failedAssertions = 0 - -function assert(condition: boolean, message: string) { - totalAssertions++ - if (!condition) { - failedAssertions++ - console.error(` [FAIL] ${message}`) - } else { - console.log(` [PASS] ${message}`) - } -} - -function section(title: string) { - console.log(`\n======================================================`) - console.log(`>>> ${title}`) - console.log(`======================================================`) -} - -// --------------------------------------------------------------------------- -// Section 1: All 10 Starter Equipped Items Traversal -// --------------------------------------------------------------------------- -section('1. Verification of 10 Starter Equipped Items in drawItemTooltip') - -const worldPanels = new WorldPanelsHud() -const equippedSlots: EquipSlotId[] = [ - 'helm', - 'amulet', - 'weapon1', - 'armor', - 'weapon2', - 'gloves', - 'ring1', - 'belt', - 'ring2', - 'boots', -] - -for (const slot of equippedSlots) { - const item = STARTER_EQUIPPED_GEAR[slot] - assert(item !== undefined, `Slot '${slot}' must have defined starter item`) - if (!item) continue - - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item, x: 400, y: 300 }, font) - - assert(canvas.fillRectCalls.length === 1, `${slot} (${item.name}): exactly 1 tooltip background rect drawn`) - assert(canvas.strokeRectCalls.length === 1, `${slot} (${item.name}): exactly 1 tooltip border rect drawn`) - assert(font.drawCalls.length >= 3, `${slot} (${item.name}): has at least title, base name, and stats`) - - const titleCall = font.drawCalls[0] - assert(titleCall?.text === item.nameZh, `${slot}: Title is '${item.nameZh}'`) - assert(titleCall?.options?.font === 'fontexocet10', `${slot}: Title font is 'fontexocet10'`) - - const expectedTitleColor = - item.quality === 'unique' || item.quality === 'rune' ? 'gold' : - item.quality === 'set' ? 'green' : 'white' - assert(titleCall?.options?.color === expectedTitleColor, `${slot}: Title color is '${expectedTitleColor}'`) -} - -// --------------------------------------------------------------------------- -// Section 2: Deep Canonical Assertions on Harlequin Crest (Shako) -// --------------------------------------------------------------------------- -section('2. Detailed Parity on Harlequin Crest (Shako)') - -const shako = STARTER_EQUIPPED_GEAR.helm! -const shakoFont = createMockFont() -const shakoCanvas = createMockCanvas() -worldPanels.drawItemTooltip(shakoCanvas as any, { item: shako, x: 400, y: 320 }, shakoFont) - -// Title -const shakoTitle = shakoFont.drawCalls.find(d => d.text.includes('谐角之冠')) -assert(shakoTitle !== undefined, 'Harlequin Crest title line exists') -assert(shakoTitle?.options?.color === 'gold', 'Harlequin Crest title line is in gold') -assert(shakoTitle?.options?.font === 'fontexocet10', 'Harlequin Crest title line uses fontexocet10') - -// Base Name -const shakoBase = shakoFont.drawCalls.find(d => d.text.includes('军帽')) -assert(shakoBase !== undefined, 'Base name "军帽" line exists') -assert(shakoBase?.options?.color === 'white', 'Base name "军帽" is in white') -assert(shakoBase?.options?.font === 'font8', 'Base name "军帽" uses font8') - -// Defense -const shakoDef = shakoFont.drawCalls.find(d => d.text === '防御: 141') -assert(shakoDef !== undefined, 'Defense line "防御: 141" exists') -assert(shakoDef?.options?.color === 'white', 'Defense line is in white') - -// Durability -const shakoDur = shakoFont.drawCalls.find(d => d.text === '耐久度: 12 之 12') -assert(shakoDur !== undefined, 'Durability line "耐久度: 12 之 12" exists') -assert(shakoDur?.options?.color === 'white', 'Durability line is in white') - -// Required Strength -const shakoStr = shakoFont.drawCalls.find(d => d.text === '需要力量: 50') -assert(shakoStr !== undefined, 'Req strength line "需要力量: 50" exists') -assert(shakoStr?.options?.color === 'white', 'Req strength line is in white') - -// Required Level -const shakoLvl = shakoFont.drawCalls.find(d => d.text === '需要等级: 62') -assert(shakoLvl !== undefined, 'Req level line "需要等级: 62" exists') -assert(shakoLvl?.options?.color === 'white', 'Req level line is in white') - -// 6 Canonical Stats -const expectedShakoStats = [ - '+2 所有技能 (+2 To All Skills)', - '+1.5 生命 (依角色等级而定)', - '+1.5 法力 (依角色等级而定)', - '物理伤害减少 10%', - '50% 更佳机会取得魔法装备 (MF)', - '+2 所有属性', -] - -for (const statText of expectedShakoStats) { - const found = shakoFont.drawCalls.find(d => d.text === statText) - assert(found !== undefined, `Shako stat "${statText}" is drawn`) - assert(found?.options?.color === 'blue', `Shako stat "${statText}" is drawn in blue`) -} - -// --------------------------------------------------------------------------- -// Section 3: Deep Canonical Assertions on Call to Arms (CTA) -// --------------------------------------------------------------------------- -section('3. Detailed Parity on Call to Arms (CTA)') - -const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') -assert(ctaPlacement !== undefined, 'CTA placement exists in STARTER_BAG_ITEMS') -const cta = ctaPlacement!.item - -const ctaFont = createMockFont() -const ctaCanvas = createMockCanvas() -worldPanels.drawItemTooltip(ctaCanvas as any, { item: cta, x: 400, y: 350 }, ctaFont) - -// Title -const ctaTitle = ctaFont.drawCalls[0] -assert(ctaTitle?.text === '战争召唤 (Call to Arms)', 'CTA title is "战争召唤 (Call to Arms)"') -assert(ctaTitle?.options?.color === 'gold', 'CTA title is in gold') -assert(ctaTitle?.options?.font === 'fontexocet10', 'CTA title uses fontexocet10') - -// Runes line -const ctaRunes = ctaFont.drawCalls[1] -assert(ctaRunes?.text === "'AmnRalMalIstOhm'", "CTA runes line is ''AmnRalMalIstOhm''") -assert(ctaRunes?.options?.color === 'gold', 'CTA runes line is in gold') -assert(ctaRunes?.options?.font === 'font8', 'CTA runes line uses font8') - -// Base name -const ctaBase = ctaFont.drawCalls[2] -assert(ctaBase?.text === '水晶剑 (Crystal Sword)', 'CTA base name is "水晶剑 (Crystal Sword)"') -assert(ctaBase?.options?.color === 'white', 'CTA base name is in white') -assert(ctaBase?.options?.font === 'font8', 'CTA base name uses font8') - -// Damage -const ctaDmg = ctaFont.drawCalls.find(d => d.text.includes('17 - 52')) -assert(ctaDmg !== undefined, 'CTA damage line "单手伤害: 17 - 52" exists') -assert(ctaDmg?.text === '单手伤害: 17 - 52', 'CTA damage line is exact "单手伤害: 17 - 52"') -assert(ctaDmg?.options?.color === 'white', 'CTA damage line is in white') - -// Durability -const ctaDur = ctaFont.drawCalls.find(d => d.text === '耐久度: 20 之 20') -assert(ctaDur !== undefined, 'CTA durability line "耐久度: 20 之 20" exists') -assert(ctaDur?.options?.color === 'white', 'CTA durability line is in white') - -// Speed Text -const ctaSpeed = ctaFont.drawCalls.find(d => d.text === '剑类 - 极快的攻击速度') -assert(ctaSpeed !== undefined, 'CTA speed class line "剑类 - 极快的攻击速度" exists') -assert(ctaSpeed?.options?.color === 'white', 'CTA speed class line is in white') - -// Req Strength -const ctaStr = ctaFont.drawCalls.find(d => d.text === '需要力量: 43') -assert(ctaStr !== undefined, 'CTA req strength line "需要力量: 43" exists') -assert(ctaStr?.options?.color === 'white', 'CTA req strength line is in white') - -// Req Level -const ctaLvl = ctaFont.drawCalls.find(d => d.text === '需要等级: 57') -assert(ctaLvl !== undefined, 'CTA req level line "需要等级: 57" exists') -assert(ctaLvl?.options?.color === 'white', 'CTA req level line is in white') - -// All 11 Canonical CTA Stats -const expectedCtaStats = [ - '+1 所有技能 (+1 To All Skills)', - '+40% 增加攻击速度 (+40% Increased Attack Speed)', - '+255% 增强伤害 (+255% Enhanced Damage)', - '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', - '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', - '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', - '+6 战斗体制 (Battle Orders)', - '+6 战斗指挥 (Battle Command)', - '+4 战斗狂嗥 / 战嚎 (Battle Cry)', - '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', - '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', -] - -for (const statText of expectedCtaStats) { - const found = ctaFont.drawCalls.find(d => d.text === statText) - assert(found !== undefined, `CTA stat "${statText}" is drawn`) - assert(found?.options?.color === 'blue', `CTA stat "${statText}" is in blue`) -} - -// Sockets line -const ctaSockets = ctaFont.drawCalls.find(d => d.text === '凹槽 (5)') -assert(ctaSockets !== undefined, 'CTA sockets line "凹槽 (5)" exists') -assert(ctaSockets?.options?.color === 'blue', 'CTA sockets line is in blue') - -// --------------------------------------------------------------------------- -// Section 4: Starter Bag Charms (Torch, Anni, Gheed's) -// --------------------------------------------------------------------------- -section('4. Verification of Starter Bag Charms (Torch, Anni, Gheed\'s)') - -const charms = ['bag-torch', 'bag-anni', 'bag-gheeds'] -for (const charmId of charms) { - const pl = STARTER_BAG_ITEMS.find(p => p.item.id === charmId) - assert(pl !== undefined, `Charm '${charmId}' exists in STARTER_BAG_ITEMS`) - if (!pl) continue - - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: pl.item, x: 400, y: 300 }, font) - - const title = font.drawCalls[0] - assert(title?.text === pl.item.nameZh, `${charmId}: Title matches '${pl.item.nameZh}'`) - assert(title?.options?.color === 'gold', `${charmId}: Title is gold`) - assert(font.drawCalls.some(d => d.text === pl.item.baseNameZh && d.options?.color === 'white'), `${charmId}: Base name in white`) - assert(font.drawCalls.some(d => d.text === `需要等级: ${pl.item.reqLevel}` && d.options?.color === 'white'), `${charmId}: Req level in white`) - - for (const st of pl.item.stats) { - assert(font.drawCalls.some(d => d.text === st.text && d.options?.color === 'blue'), `${charmId}: Stat "${st.text}" in blue`) - } -} - -// --------------------------------------------------------------------------- -// Section 5: Dynamic Vertical Flip Threshold & Screen Clamping -// --------------------------------------------------------------------------- -section('5. Dynamic Vertical Flip Threshold & Boundary Clamping') - -const testItem: UiInventoryItem = { - id: 'test-flip-item', - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '便帽', - baseNameZh: '便帽', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], -} - -// Case 1: hover.y = 200 (< 280) -> by = 224 (hover.y + 24) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 200 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by === 224, `hover.y = 200 -> by = ${by} (expected 224)`) - assert(by + boxH <= 592, `hover.y = 200 -> bottom edge within 592`) -} - -// Case 2: hover.y = 279 (< 280) -> by = 303 (hover.y + 24) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 279 }, font) - const [, by] = canvas.fillRectCalls[0]! - assert(by === 303, `hover.y = 279 -> by = ${by} (expected 303)`) -} - -// Case 3: hover.y = 280 (>= 280) -> by < 280 (above cursor: 280 - boxH - 8) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 280 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by < 280, `hover.y = 280 -> by = ${by} (expected < 280)`) - assert(by === 280 - boxH - 8, `hover.y = 280 -> by = 280 - boxH - 8 (${280 - boxH - 8})`) -} - -// Case 4: hover.y = 500 (>= 280) -> by < 500 (above cursor: 500 - boxH - 8) -{ - const font = createMockFont() - const canvas = createMockCanvas() - worldPanels.drawItemTooltip(canvas as any, { item: testItem, x: 400, y: 500 }, font) - const [, by, , boxH] = canvas.fillRectCalls[0]! - assert(by < 500, `hover.y = 500 -> by = ${by} (expected < 500)`) - assert(by === 500 - boxH - 8, `hover.y = 500 -> by = 500 - boxH - 8 (${500 - boxH - 8})`) -} - -// Case 5: Viewport horizontal and vertical boundaries clamping -{ - // Far left (hover.x = 0) - const fontL = createMockFont() - const canvasL = createMockCanvas() - worldPanels.drawItemTooltip(canvasL as any, { item: testItem, x: 0, y: 300 }, fontL) - const [bxL] = canvasL.fillRectCalls[0]! - assert(bxL === 8, `hover.x = 0 clamps bx to 8 (got ${bxL})`) - - // Far right (hover.x = 800) - const fontR = createMockFont() - const canvasR = createMockCanvas() - worldPanels.drawItemTooltip(canvasR as any, { item: testItem, x: 800, y: 300 }, fontR) - const [bxR, , boxWR] = canvasR.fillRectCalls[0]! - assert(bxR + boxWR === 792, `hover.x = 800 clamps bx + boxW to 792 (bx=${bxR}, boxW=${boxWR})`) - - // Far bottom (hover.y = 600) - const fontB = createMockFont() - const canvasB = createMockCanvas() - worldPanels.drawItemTooltip(canvasB as any, { item: testItem, x: 400, y: 600 }, fontB) - const [, byB, , boxHB] = canvasB.fillRectCalls[0]! - assert(byB + boxHB === 592, `hover.y = 600 clamps by + boxH to 592 (by=${byB}, boxH=${boxHB})`) -} - -// --------------------------------------------------------------------------- -// Section 6: formatItemTooltip Unit Verification -// --------------------------------------------------------------------------- -section('6. formatItemTooltip Runeword & Quality Verification') - -// Test 6.1: Runeword item formatting -const rawRunewordItem: Item = { - id: 101, - name: 'Call to Arms', - base: { - id: 'crs', - name: 'Crystal Sword', - kind: 'weapon', - tags: ['sword', 'weap'], - damage: 15, - defense: 0, - value: 5000, - invWidth: 2, - invHeight: 3, - maxStack: 1, - level: 11, - mindam: 5, - maxdam: 15, - reqstr: 43, - levelreq: 57, - durability: 20, - gemsockets: 5, - } as any, - rarity: 'runeword', - level: 85, - flags: {} as any, - location: {} as any, - stats: {}, - sockets: [], - nameZh: '战争召唤 (Call to Arms)', - baseNameZh: '水晶剑 (Crystal Sword)', - runewordRunes: "'AmnRalMalIstOhm'", - speedText: '剑类 - 极快的攻击速度', - rolledProps: [ - { code: 'allskills', value: 1, min: 1, max: 1 }, - { code: 'ias', value: 40, min: 40, max: 40 }, - { code: 'dmg%', value: 255, min: 255, max: 255 }, - { code: 'dmg-fire', value: 5, min: 5, max: 30 }, - { code: 'lifesteal', value: 7, min: 7, max: 7 }, - ], -} as any - -const formattedRw = formatItemTooltip(rawRunewordItem, 'zh') -assert(formattedRw.qualityColor === '#c8a15a', 'Runeword qualityColor is #c8a15a (gold)') -assert(formattedRw.quality === '符文之语', 'Runeword quality is "符文之语"') -assert(formattedRw.title === '战争召唤 (Call to Arms)', 'Runeword title is "战争召唤 (Call to Arms)"') -assert(formattedRw.subTitle === '水晶剑 (Crystal Sword)', 'Runeword subTitle is "水晶剑 (Crystal Sword)"') -assert(formattedRw.runewordRunes === "'AmnRalMalIstOhm'", 'Runeword runes preserved in formatItemTooltip') -assert(formattedRw.speedText === '剑类 - 极快的攻击速度', 'Runeword speedText preserved in formatItemTooltip') -assert(formattedRw.reqStr === 43, 'Runeword reqStr is 43') -assert(formattedRw.reqLevel === 57, 'Runeword reqLevel is 57') -assert(formattedRw.sockets === 5, 'Runeword sockets is 5') - -// Test 6.2: Socketable Rune formatting -const rawRuneItem: Item = { - id: 102, - name: 'Ohm Rune', - code: 'r27', - base: { - id: 'r27', - name: 'Ohm Rune', - kind: 'other', - tags: ['rune'], - damage: 0, - defense: 0, - value: 1000, - invWidth: 1, - invHeight: 1, - maxStack: 1, - level: 57, - } as any, - rarity: 'normal', - level: 57, - flags: {} as any, - location: {} as any, - stats: {}, - sockets: [], -} as any - -const formattedRune = formatItemTooltip(rawRuneItem, 'zh') -assert(formattedRune.isSocketable === true, 'Rune is marked isSocketable') -assert(formattedRune.lines.some(l => l.text.includes('可镶嵌进有凹槽的装备')), 'Rune has socket instruction line') -assert(formattedRune.lines.some(l => l.text.includes('武器:')), 'Rune has weapons line') - -// --------------------------------------------------------------------------- -// Section 7: Adversarial Stress Tests & Edge Cases -// --------------------------------------------------------------------------- -section('7. Adversarial Stress Testing & Fuzzing') - -// 7.1 Wide and tall item within viewport limits -const wideTallItem: UiInventoryItem = { - id: 'wide-tall-item', - code: 'cap', - invFile: 'invcap', - name: 'Wide and Tall Crafted Cap of the Mammoth', - nameZh: '超大巨兽之精工合成便帽 (Crafted Cap of Mammoth)', - baseNameZh: '便帽 (Cap)', - quality: 'craft', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: Array.from({ length: 15 }, (_, i) => ({ - text: `+${(i + 1) * 5}% 增强防御与全抗性描述 (${i + 1})`, - color: 'blue' as D2ColorCode, - })), -} - -const wideFont = createMockFont() -const wideCanvas = createMockCanvas() -worldPanels.drawItemTooltip(wideCanvas as any, { item: wideTallItem, x: 750, y: 150 }, wideFont) -assert(wideCanvas.fillRectCalls.length === 1, 'Wide item tooltip renders without error') -const [wx, wy, ww, wh] = wideCanvas.fillRectCalls[0]! -assert(wx >= 8, `Wide box X left bound clamped (got ${wx} >= 8)`) -assert(wx + ww <= 792, `Wide box X right bound clamped within 792 (got ${wx + ww} <= 792)`) -assert(wy >= 8, `Wide box Y top bound clamped (got ${wy} >= 8)`) -assert(wy + wh <= 592, `Wide box Y bottom bound clamped within 592 (got ${wy + wh} <= 592)`) - -// 7.1b Extreme adversarial input (300 chars, 50 stats) safety without throwing -const extremeItem: UiInventoryItem = { - id: 'extreme-item', - code: 'cap', - invFile: 'invcap', - name: 'A'.repeat(300), - nameZh: '长'.repeat(250), - baseNameZh: '基'.repeat(200), - quality: 'craft', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: Array.from({ length: 50 }, (_, i) => ({ - text: `极限属性描述 #${i + 1}: ` + '超长文本测试'.repeat(10), - color: 'blue' as D2ColorCode, - })), -} -const extremeFont = createMockFont() -const extremeCanvas = createMockCanvas() -worldPanels.drawItemTooltip(extremeCanvas as any, { item: extremeItem, x: 400, y: 150 }, extremeFont) -assert(extremeCanvas.fillRectCalls.length === 1, 'Extreme item tooltip renders without error') -const [ex, ey, ew, eh] = extremeCanvas.fillRectCalls[0]! -assert(Number.isFinite(ex) && Number.isFinite(ey) && Number.isFinite(ew) && Number.isFinite(eh), 'Extreme item produces finite numeric bounds') - - -// 7.2 Completely bare item with zero stats / undefs -const bareItem: UiInventoryItem = { - id: 'bare-item', - code: 'cap', - invFile: 'invcap', - name: 'Bare Cap', - nameZh: '便帽', - baseNameZh: '', - quality: 'normal', - invWidth: 1, - invHeight: 1, - allowedSlots: [], - stats: [], -} -const bareFont = createMockFont() -const bareCanvas = createMockCanvas() -worldPanels.drawItemTooltip(bareCanvas as any, { item: bareItem, x: 200, y: 100 }, bareFont) -assert(bareCanvas.fillRectCalls.length === 1, 'Bare item renders without error') - -// 7.3 Performance benchmark: 2,000 tooltips rendered -const t0 = performance.now() -const benchmarkFont = createMockFont() -const benchmarkCanvas = createMockCanvas() -for (let i = 0; i < 2000; i++) { - const sampleItem = (i % 2 === 0) ? shako : cta - worldPanels.drawItemTooltip( - benchmarkCanvas as any, - { item: sampleItem, x: (i * 17) % 800, y: (i * 23) % 600 }, - benchmarkFont, - ) -} -const elapsedMs = performance.now() - t0 -assert(elapsedMs < 300, `2,000 tooltips rendered in ${elapsedMs.toFixed(2)}ms (< 300ms threshold)`) - -// --------------------------------------------------------------------------- -// Final Summary -// --------------------------------------------------------------------------- -console.log(`\n======================================================`) -console.log(`Empirical Verification Summary:`) -console.log(`Total Assertions: ${totalAssertions}`) -console.log(`Passed: ${totalAssertions - failedAssertions}`) -console.log(`Failed: ${failedAssertions}`) -console.log(`Verdict: ${failedAssertions === 0 ? 'APPROVE' : 'REJECT'}`) -console.log(`======================================================\n`) - -if (failedAssertions > 0) { - process.exit(1) -} diff --git a/tools/verify-ui-browser.ts b/tools/verify-ui-browser.ts deleted file mode 100644 index 748fd27..0000000 --- a/tools/verify-ui-browser.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #27 (`acts.html`). - * - * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP (zero external deps). - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/.gemini/jetski/brain/aa680fb2-b60b-4fb6-86df-d401210969d8' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - const debugPort = 9237 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - `${baseUrl}/acts.html?act=1`, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error('Failed to connect to Chrome CDP') - } - console.log('2. Connected to Chrome CDP WebSocket:', wsUrl) - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - // Wait for both ActScene and HudManager to become ready - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) break - await sleep(300) - } - await sleep(600) - - const initialHud = await evalJs(`window.__d2webHud`) - console.log('3. Verified initial __d2webHud state:', JSON.stringify(initialHud)) - - if (!initialHud || initialHud.dc6DecodeFailures !== 0) { - throw new Error(`Expected 0 DC6 decode failures, got: ${JSON.stringify(initialHud)}`) - } - for (const [k, v] of Object.entries(initialHud.assetsLoaded)) { - if (!v) throw new Error(`Asset group failed to load: ${k}`) - } - - // Screenshot 1: Base Town HUD - await saveScreenshot('hud_base_town.png') - - // Screenshot 2: Left Character Sheet (`A`) + Right Inventory (`I`) + Hover Harlequin Crest - const dualState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('char'); - inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); // Hover Harlequin Crest in Helm slot - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (dualState.leftPanel !== 'char' || dualState.rightPanel !== 'inv') { - throw new Error(`Split panel assertion failed: ${JSON.stringify(dualState)}`) - } - await sleep(200) - await saveScreenshot('hud_char_and_inv.png') - - // Verify Inventory Drag-and-Drop, Equipment Swap, Belt Drink, Stat & Skill Allocation - const interactionCheck = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.clickEquipSlot('weapon1'); - const pickedCode = inst.inventory.cursorItem?.code; - inst.inventory.clickGridCell(8, 0); - const swappedCode = inst.inventory.cursorItem?.code; - inst.inventory.clickEquipSlot('weapon1'); - const finalWeapon1 = inst.inventory.equipped.weapon1?.code; - inst.hp = 900; - inst.drinkBeltSlot(0); - const afterBeltCount = inst.belt.countTotalPotions(); - const healedHp = inst.hp; - inst.charSheet.allocateStat('vit'); - inst.syncPublishedState(); - return { pickedCode, swappedCode, finalWeapon1, afterBeltCount, healedHp, remainingStatPts: inst.state.statPoints }; - })()`) - console.log('4. Verified HUD interactions via CDP:', JSON.stringify(interactionCheck)) - if ( - interactionCheck.pickedCode !== 'oba' || - interactionCheck.swappedCode !== 'crs' || - interactionCheck.finalWeapon1 !== 'crs' || - interactionCheck.afterBeltCount !== 15 || - interactionCheck.healedHp !== 1080 - ) { - throw new Error(`HUD interaction check failed: ${JSON.stringify(interactionCheck)}`) - } - - // Screenshot 3: Left Quest Log (`Q`) + Right Skill Tree (`T`) + Hover Frozen Orb - const questSkillState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('quest'); - inst.toggleRightPanel('skill'); - inst.skillTree.handleMouseMove(445, 422); // Hover Frozen Orb - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (questSkillState.leftPanel !== 'quest' || questSkillState.rightPanel !== 'skill') { - throw new Error(`Quest/Skill panel assertion failed: ${JSON.stringify(questSkillState)}`) - } - await sleep(200) - await saveScreenshot('hud_quest_and_skills.png') - - // Screenshot 4: Left Waypoint Menu (`V`) + 4x4 Pop-up Belt (`~`) + Right Skill Speedbar Popup + Monster Plaque - const wpBeltState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('waypoint'); - inst.toggleRightPanel('skill'); // close right panel - inst.belt.lockedOpen = true; - inst.hotkeys.openPopup = 'right'; - inst.hotkeys.hoveredPopupSkill = { side: 'right', skillId: 64 }; - inst.targetMonster = { - name: 'Corpsefire (尸体发火)', - hp: 740, - maxHp: 980, - rank: 'superunique', - affixes: ['Spectral Hit · 特别命中', 'Undead · 不死族'], - }; - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (wpBeltState.leftPanel !== 'waypoint' || !wpBeltState.beltExpanded) { - throw new Error(`Waypoint/Belt assertion failed: ${JSON.stringify(wpBeltState)}`) - } - await sleep(200) - await saveScreenshot('hud_waypoint_and_belt.png') - - ws.close() - console.log('5. ALL Issue #27 Browser & CDP Assertions Passed 100%!') - } finally { - chromeProc.kill() - await server.close() - } -} - -main().catch((err) => { - console.error('verify-ui-browser failed:', err) - process.exit(1) -}) diff --git a/tools/verify-vendor-ui.ts b/tools/verify-vendor-ui.ts deleted file mode 100644 index 6a25c54..0000000 --- a/tools/verify-vendor-ui.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { createServer, type ViteDevServer } from 'vite' -import { ensurePacksAvailable } from './verify-ground-combat-browser.ts' - -const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) - -const CHROME_CANDIDATES = [ - '/usr/bin/google-chrome', - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error('No accessible Chromium binary found') -} - -async function main() { - const outputDir = process.env.VENDOR_AUDIT_OUT - if (!outputDir) { - throw new Error('VENDOR_AUDIT_OUT environment variable is required') - } - mkdirSync(outputDir, { recursive: true }) - ensurePacksAvailable() - - const port = 5419 - const viteServer: ViteDevServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { host: '127.0.0.1', port, strictPort: false }, - }) - await viteServer.listen() - const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port - const targetUrl = `http://127.0.0.1:${actualPort}/acts.html?act=1&level=1-act-1-town-1` - - const chromeBin = resolveChromeBinary() - const debugPort = 9519 - const chromeProc: ChildProcess = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1280,840', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--no-proxy-server', - `--remote-debugging-port=${debugPort}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - let ws: WebSocket | null = null - try { - let wsDebuggerUrl: string | null = null - for (let i = 0; i < 80; i++) { - await sleep(150) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json`) - const pages = (await res.json()) as any[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsDebuggerUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - if (!wsDebuggerUrl) throw new Error('Failed to attach to Chrome CDP') - - ws = new WebSocket(wsDebuggerUrl) - await new Promise((res, rej) => { - ws!.onopen = () => res(true) - ws!.onerror = rej - }) - - let cmdId = 1 - const pending = new Map void>() - ws.onmessage = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id && pending.has(msg.id)) { - pending.get(msg.id)!(msg) - pending.delete(msg.id) - } - } - - const cdp = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = cmdId++ - pending.set(id, (msg: any) => { - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - }) - ws!.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`Eval error: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const takeScreenshot = async (name: string): Promise => { - const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(outputDir, `${name}.png`) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - console.log(`Saved screenshot: ${filePath}`) - return filePath - } - - await cdp('Runtime.enable') - await cdp('Page.enable') - - for (let i = 0; i < 100; i++) { - const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (ready) break - await sleep(200) - } - - // 1. Exhaustive audit of all 16 trade NPCs and 6 gamble NPCs - const fullAudit = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const wp = hud.worldPanels; - hud.charSheet.attrs.level = 30; - const tradeNpcs = [ - 'Akara', 'Charsi', 'Gheed', - 'Fara', 'Drognan', 'Elzix', 'Lysander', - 'Ormus', 'Hratli', 'Asheara', 'Alkor', - 'Halbu', 'Jamella', - 'Larzuk', 'Malah', 'Anya', - ]; - const gambleNpcs = ['Gheed', 'Elzix', 'Alkor', 'Jamella', 'Anya', 'Nihlathak']; - const tradeResults = []; - for (const name of tradeNpcs) { - const ok = hud.openVendorSession(name, 'trade', 0); - if (!ok) throw new Error('Failed to open trade vendor: ' + name); - hud.render(); - const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); - tradeResults.push({ - name, - hcIdx: wp.activeVendorDescriptor.hcIdx, - canRepair: wp.activeVendorDescriptor.canRepair, - layout: wp.getActiveVendorButtonLayout(), - frames, - activeTab: wp.activeVendorTab, - itemsCount: wp.getActiveVendorPlacements().length, - }); - } - const gambleResults = []; - for (const name of gambleNpcs) { - const ok = hud.openVendorSession(name, 'gamble', 1); - if (!ok) throw new Error('Failed to open gamble vendor: ' + name); - hud.render(); - const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); - gambleResults.push({ - name, - hcIdx: wp.activeVendorDescriptor.hcIdx, - layout: wp.getActiveVendorButtonLayout(), - frames, - itemsCount: wp.getActiveVendorPlacements().length, - }); - } - return { tradeCount: tradeResults.length, gambleCount: gambleResults.length, tradeResults, gambleResults }; - })()`) - console.log( - `Audited ${fullAudit.tradeCount} trade NPCs and ${fullAudit.gambleCount} gamble NPCs:`, - JSON.stringify({ - blacksmiths: fullAudit.tradeResults.filter((r: any) => r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), - traders: fullAudit.tradeResults.filter((r: any) => !r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), - gamblers: fullAudit.gambleResults.map((r: any) => `${r.name}:${r.frames.join(',')}`), - }), - ) - - // 2. Open Charsi NPC Menu (move player next to Charsi in town and trigger interactWithNpc) - const menuInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const engine = window.__d2webEngine; - const mc = window.__d2webMouseController; - hud.closeAllPanels(); - const charsi = engine?.npcEntities?.find(n => n.def?.name?.toLowerCase() === 'charsi'); - if (charsi && mc) { - engine.world.player.x = charsi.x + 28; - engine.world.player.y = charsi.y + 18; - mc.interactWithNpc(charsi); - } else { - hud.openNpcMenuByName('Charsi', 400, 220); - } - hud.render(); - const dialogEl = document.querySelector('#dialog'); - return { - npcName: hud.worldPanels.npcMenu?.npcName, - options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh), - hoveredOptionIdx: hud.worldPanels.npcMenu?.hoveredOptionIdx, - layout: hud.worldPanels.getNpcMenuLayout(hud.font), - domDialogHidden: dialogEl ? dialogEl.hidden : true, - }; - })()`) - console.log('NPC Menu:', JSON.stringify(menuInfo)) - if (!menuInfo.domDialogHidden) { - throw new Error('Legacy #dialog DOM element should be hidden when HudManager is active') - } - await sleep(250) - await takeScreenshot('vendor_npc_menu_charsi') - - // 3. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI (buysell.dc6 + buyselltabs.dc6 + [2,4,6,18] buttons) - const charsiInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.charSheet.attrs.level = 24; - hud.openVendorSession('Charsi', 'trade', 0); - const wp = hud.worldPanels; - wp.selectVendorTab('armor'); - wp.hoveredVendorButtonSlot = null; - const armorItems = wp.vendorTabPlacements.armor; - const docking = hud.getDockingLayout(); - const deltaLeft = -docking.marginW; - if (armorItems.length > 0) { - const first = armorItems[0]; - wp.hoveredVendorItem = { - item: first.item, - x: 80 + 16 + first.col * 29 + 14, - y: 60 + 63 + first.row * 29 + 14, - col: first.col, - row: first.row, - }; - hud.inventory.hoveredItem = { - item: first.item, - x: wp.hoveredVendorItem.x + deltaLeft, - y: wp.hoveredVendorItem.y, - }; - } - hud.render(); - return { - leftPanel: hud.leftPanel, - rightPanel: hud.rightPanel, - buttonLayout: wp.getActiveVendorButtonLayout(), - armorCount: wp.vendorTabPlacements.armor.length, - weapons1Count: wp.vendorTabPlacements.weapons1.length, - weapons2Count: wp.vendorTabPlacements.weapons2.length, - miscCount: wp.vendorTabPlacements.misc.length, - firstItem: armorItems[0] ? { nameZh: armorItems[0].item.nameZh, buyCost: armorItems[0].item.buyCost } : null, - }; - })()`) - console.log('Charsi Trade UI:', JSON.stringify(charsiInfo)) - await sleep(200) - await takeScreenshot('vendor_trade_charsi') - - // 4. Charsi Repair Latched + Repair All Hover Tooltip - const charsiRepairInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const wp = hud.worldPanels; - wp.hoveredVendorItem = null; - hud.inventory.hoveredItem = null; - // Damage an equipped item to produce a non-zero Repair All cost - const equippedList = Object.values(hud.inventory.equipped).filter(Boolean); - if (equippedList.length > 0 && equippedList[0].durability) { - equippedList[0].durability.current = Math.max(1, Math.floor(equippedList[0].durability.max / 2)); - } - wp.vendorTradeState = 'repair'; - wp.hoveredVendorButtonSlot = 3; - hud.render(); - return { - vendorTradeState: wp.vendorTradeState, - repairFrame: wp.getVendorButtonFrame(2), - repairAllCost: wp.getRepairAllCost(hud.inventory), - }; - })()`) - console.log('Charsi Repair UI:', JSON.stringify(charsiRepairInfo)) - await sleep(200) - await takeScreenshot('vendor_repair_charsi') - - // 5. Akara Trader UI ([Buy, Sell, Blank, Close] + Close hover) - const akaraInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.openVendorSession('Akara', 'trade', 0); - const wp = hud.worldPanels; - wp.hoveredVendorItem = null; - hud.inventory.hoveredItem = null; - wp.hoveredVendorButtonSlot = 3; - hud.render(); - return { - buttonLayout: wp.getActiveVendorButtonLayout(), - activeTab: wp.activeVendorTab, - }; - })()`) - console.log('Akara Trade UI:', JSON.stringify(akaraInfo)) - await sleep(200) - await takeScreenshot('vendor_trade_akara') - - // 6. Open Gheed Gamble UI - const gheedInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.charSheet.attrs.level = 60; - hud.openVendorSession('Gheed', 'gamble', 1); - const wp = hud.worldPanels; - wp.hoveredVendorButtonSlot = null; - const gambleItems = wp.gamblePlacements; - const docking = hud.getDockingLayout(); - const deltaLeft = -docking.marginW; - if (gambleItems.length > 0) { - const first = gambleItems[0]; - wp.hoveredVendorItem = { - item: first.item, - x: 80 + 16 + first.col * 29 + 14, - y: 60 + 63 + first.row * 29 + 14, - col: first.col, - row: first.row, - }; - hud.inventory.hoveredItem = { - item: first.item, - x: wp.hoveredVendorItem.x + deltaLeft, - y: wp.hoveredVendorItem.y, - }; - } - hud.render(); - return { - buttonLayout: wp.getActiveVendorButtonLayout(), - gambleCount: gambleItems.length, - firstGamble: gambleItems[0] ? { nameZh: gambleItems[0].item.nameZh, buyCost: gambleItems[0].item.buyCost } : null, - }; - })()`) - console.log('Gheed Gamble UI:', JSON.stringify(gheedInfo)) - await sleep(200) - await takeScreenshot('vendor_gamble_gheed') - } finally { - ws?.close() - chromeProc.kill('SIGKILL') - await viteServer.close() - } -} - -main().catch(err => { - console.error(err) - process.exit(1) -})