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
-
-
-`
- 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