diff --git a/data/src/auto/auto.ts b/data/src/auto/auto.ts index 05c8139..69cd86d 100644 --- a/data/src/auto/auto.ts +++ b/data/src/auto/auto.ts @@ -38,11 +38,15 @@ export async function autoLabelTowers( let ignoredFloorsWall = 0; let ignoredFloorsResource = 0; let ignoredFloorsDoor = 0; - let ignoredFloorsFish = 0; let ignoredFloorsEntry = 0; let ignoredFloorsCustom = 0; + let ignoredFloorsIdle = 0; + let ignoredFloorsIdleDoor = 0; + let ignoredFloorsIdleEnemy = 0; let ignoredFloorsUseless = 0; - let ignoredFloorsStd = 0; + let ignoredFloorsContinuous = 0; + let ignoredFloorsContinuousDoor = 0; + let ignoredFloorsContinuousEnemy = 0; const towers = await parseTowerInfo(towerInfo); const paths: string[] = []; @@ -172,12 +176,40 @@ export async function autoLabelTowers( ignoredFloorsEntry++; continue; } - if (!config.allowUselessBranch && floorInfo.hasUselessBranch) { - ignoredFloorsUseless++; + if (floorInfo.hasLargeDoorCluster) { + ignoredFloorsContinuousDoor++; + } + if (floorInfo.hasLargeEnemyCluster) { + ignoredFloorsContinuousEnemy++; + } + if ( + floorInfo.hasLargeDoorCluster || + floorInfo.hasLargeEnemyCluster + ) { + ignoredFloorsContinuous++; continue; } - if (floorInfo.wallDensityStd > config.maxWallDensityStd) { - ignoredFloorsStd++; + if ( + floorInfo.idleDoorBranchCount > 0 || + floorInfo.repeatedGuardDoorBranchCount > 0 + ) { + ignoredFloorsIdleDoor++; + } + if ( + floorInfo.idleEnemyBranchCount > 0 || + floorInfo.repeatedGuardEnemyBranchCount > 0 + ) { + ignoredFloorsIdleEnemy++; + } + if ( + floorInfo.hasIdleBranch || + floorInfo.hasRepeatedGuardIdleBranch + ) { + ignoredFloorsIdle++; + continue; + } + if (!config.allowUselessBranch && floorInfo.hasUselessBranch) { + ignoredFloorsUseless++; continue; } // 自定义过滤楼层 @@ -206,8 +238,9 @@ export async function autoLabelTowers( ignoredFloorsWall + ignoredFloorsResource + ignoredFloorsDoor + - ignoredFloorsFish + ignoredFloorsEntry + + ignoredFloorsContinuous + + ignoredFloorsIdle + ignoredFloorsUseless + ignoredFloorsCustom; @@ -224,10 +257,13 @@ export async function autoLabelTowers( console.log(`墙壁过滤:${ignoredFloorsWall} 层`); console.log(`资源过滤:${ignoredFloorsResource} 层`); console.log(`门过滤:${ignoredFloorsDoor} 层`); - console.log(`咸鱼过滤:${ignoredFloorsFish} 层`); console.log(`入口过滤:${ignoredFloorsEntry} 层`); + console.log(`连续门过滤:${ignoredFloorsContinuousDoor} 层`); + console.log(`连续怪过滤:${ignoredFloorsContinuousEnemy} 层`); + console.log(`闲置门过滤:${ignoredFloorsIdleDoor} 层`); + console.log(`闲置怪过滤:${ignoredFloorsIdleEnemy} 层`); + console.log(`闲置节点过滤:${ignoredFloorsIdle} 层`); console.log(`无用节点过滤:${ignoredFloorsUseless} 层`); - console.log(`标准差过滤:${ignoredFloorsStd} 层`); console.log(`自定义过滤:${ignoredFloorsCustom} 层`); return labelResult; diff --git a/data/src/auto/info.test.ts b/data/src/auto/info.test.ts new file mode 100644 index 0000000..dd837a2 --- /dev/null +++ b/data/src/auto/info.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { parseFloorInfo } from './info'; +import { + IAutoLabelConfig, + IMapTileConverter, + ITowerInfo, + ResourceType, + TowerColor +} from './types'; + +class MockTileConverter implements IMapTileConverter { + getLabeledTile(tile: number): number { + return tile; + } + + isEmpty(tile: number): boolean { + return tile === 0; + } + + isEntry(tile: number): boolean { + return tile === 5; + } + + isDoor(tile: number): boolean { + return tile === 2; + } + + isEnemy(tile: number): boolean { + return tile === 4; + } + + isResource(tile: number): boolean { + return tile === 3; + } + + getNoPass(tile: number): boolean { + return tile === 1; + } + + getCannotIn(): number { + return 0; + } + + getCannotOut(): number { + return 0; + } + + getResource(tile: number): Map { + if (tile === 3) { + return new Map([[ResourceType.Item, 1]]); + } + return new Map(); + } +} + +const tower: ITowerInfo = { + authorId: 1, + color: TowerColor.Blue, + comment: 0, + competition: false, + floors: 1, + id: 1, + name: 'test-tower', + people: 1000, + tag: [], + title: 'test tower', + topuser: [], + win: 0, + designrate: [], + hardrate: [] +}; + +const config: IAutoLabelConfig = { + classes: { + empty: 0, + wall: 1, + decoration: 16, + commonDoors: [2], + specialDoors: [6, 7], + keys: [3], + redGems: [3], + blueGems: [3], + greenGems: [3], + potions: [3], + items: [3], + enemies: [4], + entry: 5 + }, + allowedSize: [[5, 5]], + allowUselessBranch: false, + minEnemyRatio: 0, + maxEnemyRatio: 1, + minWallRatio: 0, + maxWallRatio: 1, + minResourceRatio: 0, + maxResourceRatio: 1, + minDoorRatio: 0, + maxDoorRatio: 1, + minFishCount: 0, + maxFishCount: 10, + minEntryCount: 0, + maxEntryCount: 10, + maxWallDensityStd: Infinity, + maxEmptyArea: Infinity, + maxResourceArea: Infinity, + heatmapKernel: 1, + guassainRadius: 0, + ignoreIssues: true +}; + +function parseTestFloor(map: number[][]) { + return parseFloorInfo( + tower, + map, + map, + [], + config, + new MockTileConverter(), + 'F1' + ); +} + +describe('parseFloorInfo useless branch detection', () => { + it('marks a branch with only one grid-level passable direction as useless', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 5, 2, 1, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.hasUselessBranch).toBe(true); + }); + + it('marks a branch as useless when every backside candidate loses entry reachability and has no resource', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 5, 2, 0, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.hasUselessBranch).toBe(true); + }); + + it('keeps a branch when its disconnected backside can still reach resource through other branches', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1, 1], + [1, 5, 2, 4, 3, 1], + [1, 1, 1, 1, 1, 1] + ]); + + expect(floor.hasUselessBranch).toBe(false); + }); +}); + +describe('parseFloorInfo continuous branch cluster detection', () => { + it('marks a door cluster larger than 3 using same-type BFS connectivity', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 5, 2, 2, 1], + [1, 1, 2, 2, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.maxDoorClusterSize).toBe(4); + expect(floor.hasLargeDoorCluster).toBe(true); + expect(floor.hasLargeEnemyCluster).toBe(false); + }); + + it('keeps a same-type branch cluster whose size is exactly 3', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 5, 4, 4, 1], + [1, 1, 1, 4, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.maxEnemyClusterSize).toBe(3); + expect(floor.hasLargeEnemyCluster).toBe(false); + expect(floor.hasLargeDoorCluster).toBe(false); + }); + + it('does not merge mixed door-enemy adjacency into one same-type cluster', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1, 1, 1], + [1, 5, 4, 4, 2, 4, 4], + [1, 1, 1, 1, 1, 1, 1] + ]); + + expect(floor.maxEnemyClusterSize).toBe(2); + expect(floor.maxDoorClusterSize).toBe(1); + expect(floor.hasLargeEnemyCluster).toBe(false); + expect(floor.hasLargeDoorCluster).toBe(false); + }); +}); + +describe('parseFloorInfo idle branch detection', () => { + it('marks a door branch with exactly one topology neighbor as idle', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 0, 2, 1, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.idleDoorBranchCount).toBe(1); + expect(floor.idleEnemyBranchCount).toBe(0); + expect(floor.hasIdleBranch).toBe(true); + }); + + it('marks an enemy branch with exactly one topology neighbor as idle', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 0, 4, 1, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.idleDoorBranchCount).toBe(0); + expect(floor.idleEnemyBranchCount).toBe(1); + expect(floor.hasIdleBranch).toBe(true); + }); + + it('does not mark a branch idle when passing it exposes multiple topology neighbors', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 0, 4, 0, 1], + [1, 1, 0, 1, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.idleDoorBranchCount).toBe(0); + expect(floor.idleEnemyBranchCount).toBe(0); + expect(floor.hasIdleBranch).toBe(false); + }); +}); + +describe('parseFloorInfo repeated guard idle detection', () => { + it('marks same-type branches that repeatedly guard the same merged regions', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 3, 4, 0, 1], + [1, 3, 3, 4, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.repeatedGuardDoorBranchCount).toBe(0); + expect(floor.repeatedGuardEnemyBranchCount).toBe(2); + expect(floor.hasRepeatedGuardIdleBranch).toBe(true); + }); + + it('does not merge mixed door-enemy guards into one repeated guard pattern', () => { + const floor = parseTestFloor([ + [1, 1, 1, 1, 1], + [1, 3, 2, 0, 1], + [1, 3, 3, 4, 1], + [1, 1, 1, 1, 1] + ]); + + expect(floor.repeatedGuardDoorBranchCount).toBe(0); + expect(floor.repeatedGuardEnemyBranchCount).toBe(0); + expect(floor.hasRepeatedGuardIdleBranch).toBe(false); + }); +}); diff --git a/data/src/auto/info.ts b/data/src/auto/info.ts index 9f0796f..f82c48b 100644 --- a/data/src/auto/info.ts +++ b/data/src/auto/info.ts @@ -1,5 +1,7 @@ import { readFile } from 'fs/promises'; import { + BranchType, + CannotInOut, GraphNodeType, IAutoLabelConfig, IFloorInfo, @@ -25,6 +27,14 @@ import { import { gaussainHeatmap, generateHeatmap } from './heatmap'; import { MapTopology } from './topo'; +// 格子层四方向通行检查,供无用分支主算法复用。 +const branchCheckDirs: [number, number, CannotInOut, CannotInOut][] = [ + [-1, 0, CannotInOut.Left, CannotInOut.Right], + [1, 0, CannotInOut.Right, CannotInOut.Left], + [0, -1, CannotInOut.Top, CannotInOut.Bottom], + [0, 1, CannotInOut.Bottom, CannotInOut.Top] +]; + interface IRawTowerInfo { /** 作者 id */ readonly authorId: string; @@ -330,9 +340,576 @@ function computeHighDegBranchCount(graph: IMapGraph): number { } /** - * 根据地图矩阵解析出地图数据 - * @param tower 地图所属塔信息 - * @param map 地图矩阵 + * 从拓扑节点中取出它对应的代表格子坐标。 + * + * 当前新增的几条局部结构规则都需要把拓扑节点重新映射回格子层, + * 例如: + * 1. 统计格子层四方向可通行数 + * 2. 调用拓扑上的入口连通性接口 + * + * 对于 Branch / Entry 节点,它本来就是单格节点; + * 对于 Empty / Resource 节点,这里只需要拿其中任意一个格子作为搜索起点即可。 + */ +function getNodeTile(node: MapGraphNode): number { + return node.tiles.values().next().value as number; +} + +/** + * 判断格子层上两个相邻格子之间是否至少存在一个可通行方向。 + * + * 这里显式回到格子层,而不是直接看拓扑图邻接关系,原因是: + * 同一个 Empty / Resource 拓扑节点可能从多个方向贴住分支节点, + * 但在“死胡同分支”这条快捷规则里,我们关心的是分支格子本身到底有几个可走方向。 + * + * @param topo 当前楼层的拓扑信息 + * @param from 起点格子,使用 y * width + x 的平坦坐标 + * @param to 终点格子,必须是与起点四邻接的格子 + * @param outFlag 从起点离开时对应的方向标记 + * @param inFlag 进入终点时对应的方向标记 + * @returns 只要 from -> to 或 to -> from 任一方向可走,就视为这两个格子之间存在通路 + */ +function hasGridPassage( + topo: MapTopology, + from: number, + to: number, + outFlag: CannotInOut, + inFlag: CannotInOut +): boolean { + const width = topo.convertedMap[0]?.length ?? 0; + const fromX = from % width; + const fromY = (from - fromX) / width; + const toX = to % width; + const toY = (to - toX) / width; + + if ( + topo.noPass[fromY]?.[fromX] || + topo.noPass[toY]?.[toX] || + topo.convertedMap[toY]?.[toX] == null + ) { + return false; + } + + const canGo = + !(topo.cannotOut[fromY][fromX] & outFlag) && + !(topo.cannotIn[toY][toX] & inFlag); + const canCome = + !(topo.cannotOut[toY][toX] & inFlag) && + !(topo.cannotIn[fromY][fromX] & outFlag); + + // 与构图逻辑保持一致:双向只要任一方向能通过,就视为这两个格子存在通路。 + return canGo || canCome; +} + +/** + * 统计某个分支格子在格子层的四方向可通行数。 + * + * 这是无用分支主算法的第一阶段快捷判定: + * 如果一个分支格子只有一个可通行方向,那么它在局部结构上就是典型的走廊尽头/死胡同, + * 可以直接命中“无用分支”,不必再做后侧候选区域分析。 + * + * @param topo 当前楼层的拓扑信息 + * @param tile 目标分支格子的平坦坐标 + * @returns 该格子四个方向中实际可走的方向数量 + */ +function countGridPassableDirections(topo: MapTopology, tile: number): number { + const width = topo.convertedMap[0]?.length ?? 0; + const height = topo.convertedMap.length; + const x = tile % width; + const y = (tile - x) / width; + let count = 0; + + for (const [dx, dy, outFlag, inFlag] of branchCheckDirs) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) { + continue; + } + + const nextTile = ny * width + nx; + if (hasGridPassage(topo, tile, nextTile, outFlag, inFlag)) { + count++; + } + } + + return count; +} + +/** + * 在“移除目标分支节点”的前提下,收集某个后侧候选区域能到达的所有拓扑节点。 + * + * 这里的搜索允许经过其他分支节点,因为文档已经明确: + * 我们只禁止再次穿过当前正在评估的目标分支, + * 不禁止后侧区域继续经过其他门/怪去连接资源。 + * + * @param startNode 后侧候选区域中的任意起点节点 + * @param ignoredNode 当前正在评估的目标分支节点,搜索时视为删除 + * @returns 移除目标分支后,从起点仍可到达的所有节点 + */ +function collectReachableNodes( + startNode: MapGraphNode, + ignoredNode: MapGraphNode +): Set { + // 从后侧候选区域出发做一次搜索,并把目标分支当作已删除处理。 + const visited = new Set([startNode]); + const queue: MapGraphNode[] = [startNode]; + + while (queue.length > 0) { + const current = queue.shift()!; + for (const neighbor of current.neighbors) { + if (neighbor === ignoredNode || visited.has(neighbor)) { + continue; + } + visited.add(neighbor); + queue.push(neighbor); + } + } + + return visited; +} + +/** + * 判断单个分支节点是否命中“无用分支”主算法。 + * + * 判定流程对应设计文档中的两阶段: + * 1. 先看格子层四方向可通行数,若 <= 1 则直接按死胡同命中。 + * 2. 否则把该分支从图中临时移除,检查它周围哪些相邻区域会失去入口连通性; + * 这些区域就是“后侧候选区域”。 + * 3. 对每个后侧候选区域做搜索,只要任意一个候选区域还能到达资源节点, + * 就说明该分支仍然承担了资源守护作用,不应视为无用分支。 + * 4. 只有当存在后侧候选区域,且所有后侧候选区域都无法通向资源时,才返回 true。 + * + * 这个实现刻意不处理“整块怪环只有一个出口”这类模式四, + * 因为那属于另一类环状子图问题,不适合继续套单节点规则。 + * + * @param topo 当前楼层的拓扑信息 + * @param branchNode 目标分支节点,必须是门或怪这类 Branch 节点 + * @returns 如果该分支满足无用分支定义,则返回 true + */ +function isUselessBranchNode( + topo: MapTopology, + branchNode: MapGraphNode +): boolean { + if (branchNode.type !== GraphNodeType.Branch) { + return false; + } + + const branchTile = getNodeTile(branchNode); + if (countGridPassableDirections(topo, branchTile) <= 1) { + // 格子层只有一个可通行方向时,直接按死胡同分支处理。 + return true; + } + + // 记录已经并入后侧候选区域的节点,避免同一块区域从多个邻居重复搜索。 + const handledNodes = new Set(); + let hasBacksideCandidate = false; + + for (const neighbor of branchNode.neighbors) { + if (handledNodes.has(neighbor)) { + // 多个邻居可能指向同一个后侧连通区域,已处理过就不重复搜索。 + continue; + } + + const neighborTile = getNodeTile(neighbor); + if (topo.connectedToAnyEntry(neighborTile, [branchNode])) { + // 删除目标分支后,这个方向仍能回到任意入口,因此它属于前侧或旁路区域。 + continue; + } + + // 失去入口连通性的相邻区域视为该分支的后侧候选区域。 + hasBacksideCandidate = true; + + const reachableNodes = collectReachableNodes(neighbor, branchNode); + for (const node of reachableNodes) { + handledNodes.add(node); + } + + // 这里按“整块后侧候选区域”聚合判断,而不是只看相邻的单个节点。 + for (const node of reachableNodes) { + if (node.type === GraphNodeType.Resource) { + // 任意一个后侧候选区域还能通向资源,就不算无用分支。 + return false; + } + } + } + + // 若根本不存在失去入口连通性的相邻区域,则这个分支按当前定义不属于无用分支。 + return hasBacksideCandidate; +} + +/** + * 统计同类门团 / 怪团的最大 BFS 连通块大小。 + * + * 这里的“连续门 / 连续怪”严格按拓扑图上的分支节点邻接来定义: + * 1. 起点必须是一个 Branch 节点 + * 2. 只沿“仍然是 Branch,且 branch 类型与起点相同”的邻接边继续 BFS + * 3. 门和怪分别统计,绝不把混合结构合并为一个连通块 + * + * 输出里既保留最大门团/怪团大小,也直接给出“是否超过 3”的布尔结果, + * 这样过滤层和后续统计层都可以直接复用,不需要再次写阈值判断。 + * + * @param branchNodes 当前楼层里所有分支节点的去重集合 + * @returns 门团/怪团的最大连通块大小,以及是否命中过大连通块 + */ +function computeBranchClusterStats(branchNodes: Iterable): { + maxDoorClusterSize: number; + maxEnemyClusterSize: number; + hasLargeDoorCluster: boolean; + hasLargeEnemyCluster: boolean; +} { + const visited = new Set(); + let maxDoorClusterSize = 0; + let maxEnemyClusterSize = 0; + + for (const startNode of branchNodes) { + if (visited.has(startNode) || startNode.type !== GraphNodeType.Branch) { + continue; + } + + // 每次从一个尚未访问的分支节点出发,求出它所属的同类连通块大小。 + visited.add(startNode); + let clusterSize = 0; + const queue: MapGraphNode[] = [startNode]; + + // 只沿同类分支邻接边做 BFS,门和怪分开统计。 + while (queue.length > 0) { + const current = queue.shift()!; + clusterSize++; + + for (const neighbor of current.neighbors) { + if ( + visited.has(neighbor) || + neighbor.type !== GraphNodeType.Branch || + neighbor.branch !== startNode.branch + ) { + continue; + } + + visited.add(neighbor); + queue.push(neighbor); + } + } + + // 门团和怪团分别维护各自的最大连通块大小。 + if (startNode.branch === BranchType.Door) { + maxDoorClusterSize = Math.max(maxDoorClusterSize, clusterSize); + } else { + maxEnemyClusterSize = Math.max(maxEnemyClusterSize, clusterSize); + } + } + + return { + maxDoorClusterSize, + maxEnemyClusterSize, + // 当前版本阈值固定为 > 3,大小恰好为 3 的团块默认保留。 + hasLargeDoorCluster: maxDoorClusterSize > 3, + hasLargeEnemyCluster: maxEnemyClusterSize > 3 + }; +} + +/** + * 统计拓扑图上“只连接到一个邻居节点”的闲置分支。 + * + * 这条规则对应闲置节点章节中的硬规则: + * 如果一个分支节点在拓扑图上的 `neighbors.size === 1`,说明玩家只能从同一个拓扑节点到达它, + * 而穿过该分支后也不会暴露新的拓扑节点,因此它属于“连通但无影响”的闲置节点。 + * + * 这里刻意使用拓扑图邻居数,而不是格子层可通行方向数,因为这条规则关注的是 + * “是否会暴露新的拓扑节点”,语义上不同于无用分支里的死胡同快捷规则。 + * + * @param branchNodes 当前楼层里所有分支节点的去重集合 + * @returns 闲置门/怪数量,以及该楼层是否存在闲置分支 + */ +function computeIdleBranchStats(branchNodes: Iterable): { + idleDoorBranchCount: number; + idleEnemyBranchCount: number; + hasIdleBranch: boolean; +} { + let idleDoorBranchCount = 0; + let idleEnemyBranchCount = 0; + + for (const node of branchNodes) { + if (node.type !== GraphNodeType.Branch || node.neighbors.size !== 1) { + continue; + } + + if (node.branch === BranchType.Door) { + idleDoorBranchCount++; + } else { + idleEnemyBranchCount++; + } + } + + return { + idleDoorBranchCount, + idleEnemyBranchCount, + hasIdleBranch: idleDoorBranchCount + idleEnemyBranchCount > 0 + }; +} + +interface IMergedNonBranchArea { + readonly index: number; + readonly nodes: Set; + readonly tileCount: number; + readonly hasResource: boolean; +} + +interface IRepeatedGuardCandidate { + readonly node: MapGraphNode; + readonly branch: BranchType; + readonly areaA: IMergedNonBranchArea; + readonly areaB: IMergedNonBranchArea; +} + +function getNodeRepresentativeTile(node: MapGraphNode): number { + return getNodeTile(node); +} + +function buildMergedNonBranchAreas(graph: IMapGraph): { + areas: IMergedNonBranchArea[]; + areaMap: Map; +} { + const visited = new Set(); + const areas: IMergedNonBranchArea[] = []; + const areaMap = new Map(); + let areaIndex = 0; + + for (const startNode of graph.nodeMap.values()) { + if ( + visited.has(startNode) || + (startNode.type !== GraphNodeType.Empty && + startNode.type !== GraphNodeType.Resource) + ) { + continue; + } + + const nodes = new Set(); + const queue: MapGraphNode[] = [startNode]; + visited.add(startNode); + let tileCount = 0; + let hasResource = false; + + while (queue.length > 0) { + const current = queue.shift()!; + nodes.add(current); + tileCount += current.tiles.size; + if (current.type === GraphNodeType.Resource) { + hasResource = true; + } + + for (const neighbor of current.neighbors) { + if ( + visited.has(neighbor) || + (neighbor.type !== GraphNodeType.Empty && + neighbor.type !== GraphNodeType.Resource) + ) { + continue; + } + + visited.add(neighbor); + queue.push(neighbor); + } + } + + const area: IMergedNonBranchArea = { + index: areaIndex++, + nodes, + tileCount, + hasResource + }; + areas.push(area); + for (const node of nodes) { + areaMap.set(node, area); + } + } + + return { areas, areaMap }; +} + +function buildRepeatedGuardCandidates( + branchNodes: Iterable, + areaMap: Map +): IRepeatedGuardCandidate[] { + const candidates: IRepeatedGuardCandidate[] = []; + + for (const node of branchNodes) { + if (node.type !== GraphNodeType.Branch) { + continue; + } + + const distinctAreas = new Map(); + for (const neighbor of node.neighbors) { + const area = areaMap.get(neighbor); + if (area) { + distinctAreas.set(area.index, area); + } + } + + if (distinctAreas.size !== 2) { + continue; + } + + const [areaA, areaB] = [...distinctAreas.values()].sort( + (a, b) => a.index - b.index + ); + + // 保守例外:若贴着的是单格资源点,则暂不按重复守卫结构过滤。 + if ( + (areaA.tileCount === 1 && areaA.hasResource) || + (areaB.tileCount === 1 && areaB.hasResource) + ) { + continue; + } + + candidates.push({ + node, + branch: node.branch, + areaA, + areaB + }); + } + + return candidates; +} + +function areBranchTilesEightConnected( + a: number, + b: number, + width: number +): boolean { + const ax = a % width; + const ay = (a - ax) / width; + const bx = b % width; + const by = (b - bx) / width; + + return Math.abs(ax - bx) <= 1 && Math.abs(ay - by) <= 1; +} + +/** + * 统计“多个同类分支重复守同一连通区域”的闲置节点模式。 + * + * 实现口径对应文档里的保守版本: + * 1. 先把 Empty / Resource 节点临时合并为更大的非分支连通区域。 + * 2. 若某个分支正好连接到两个不同的非分支区域,则把这两个区域和分支类型组成结构签名。 + * 3. 具有相同结构签名的同类分支,再按格子层 8 邻接做聚类。 + * 4. 当某个聚类大小 >= 2 时,视为“重复守同一连通区域”的闲置节点模式命中。 + * + * @param graph 当前楼层的拓扑图 + * @param branchNodes 当前楼层里所有分支节点的去重集合 + * @param width 地图宽度,用于做 8 邻接聚类 + * @returns 重复守卫模式命中的门/怪数量和布尔标签 + */ +function computeRepeatedGuardIdleStats( + graph: IMapGraph, + branchNodes: Iterable, + width: number +): { + repeatedGuardDoorBranchCount: number; + repeatedGuardEnemyBranchCount: number; + hasRepeatedGuardIdleBranch: boolean; +} { + const { areaMap } = buildMergedNonBranchAreas(graph); + const candidates = buildRepeatedGuardCandidates(branchNodes, areaMap); + const groupedCandidates = new Map(); + + for (const candidate of candidates) { + const key = `${candidate.branch}:${candidate.areaA.index}:${candidate.areaB.index}`; + const group = groupedCandidates.get(key) ?? []; + group.push(candidate); + groupedCandidates.set(key, group); + } + + const repeatedGuardNodes = new Set(); + + for (const group of groupedCandidates.values()) { + const visited = new Set(); + + for (const candidate of group) { + if (visited.has(candidate.node)) { + continue; + } + + const cluster: IRepeatedGuardCandidate[] = []; + const queue: IRepeatedGuardCandidate[] = [candidate]; + visited.add(candidate.node); + + while (queue.length > 0) { + const current = queue.shift()!; + cluster.push(current); + const currentTile = getNodeRepresentativeTile(current.node); + + for (const neighbor of group) { + if (visited.has(neighbor.node)) { + continue; + } + + const neighborTile = getNodeRepresentativeTile( + neighbor.node + ); + if ( + !areBranchTilesEightConnected( + currentTile, + neighborTile, + width + ) + ) { + continue; + } + + visited.add(neighbor.node); + queue.push(neighbor); + } + } + + if (cluster.length >= 2) { + for (const member of cluster) { + repeatedGuardNodes.add(member.node); + } + } + } + } + + let repeatedGuardDoorBranchCount = 0; + let repeatedGuardEnemyBranchCount = 0; + for (const node of repeatedGuardNodes) { + if (node.type !== GraphNodeType.Branch) { + continue; + } + + if (node.branch === BranchType.Door) { + repeatedGuardDoorBranchCount++; + } else { + repeatedGuardEnemyBranchCount++; + } + } + + return { + repeatedGuardDoorBranchCount, + repeatedGuardEnemyBranchCount, + hasRepeatedGuardIdleBranch: + repeatedGuardDoorBranchCount + repeatedGuardEnemyBranchCount > 0 + }; +} + +/** + * 解析单层地图的统计信息、拓扑结构标签以及局部异常标签。 + * + * 这是楼层清洗阶段最核心的入口之一。它会在一次扫描里同时产出三类信息: + * 1. 全局统计量,例如门/怪/资源密度、入口数量、最大空地区域等。 + * 2. 结构标签,例如对称性、房间数、高连接度分支数。 + * 3. 局部异常标签,例如无用分支、连续门团、连续怪团。 + * + * 其中“连续门/怪”和“无用分支”是两套互相独立的规则: + * 前者只看同类分支在拓扑图上的连通块大小, + * 后者看删除某个分支后,后侧区域是否失去入口连通且没有资源收益。 + * + * @param tower 当前楼层所属的塔信息 + * @param originMap 原始楼层地图,用于识别真实图块语义 + * @param map 转换后的标签地图,用于做密度统计与热力图计算 + * @param otherLayers 背景层/前景层等附加图层,用于补充不可入不可出信息 + * @param config 自动清洗配置 + * @param converter 原始图块到标签语义的转换器 + * @param floorId 当前楼层 id,用于入口识别等逻辑 + * @returns 当前楼层可供过滤与训练使用的完整解析结果 */ export function parseFloorInfo( tower: ITowerInfo, @@ -355,7 +932,7 @@ export function parseFloorInfo( const area = flattened.length; const width = map[0]?.length ?? 0; - // ── 结构标签计算 ───────────────────────────────── + // ---- 结构标签计算 ---- const { symmetryH, symmetryV, symmetryC } = computeSymmetry(map); const outerWall = computeOuterWall( map, @@ -365,7 +942,33 @@ export function parseFloorInfo( const roomCount = computeRoomCount(topo.graph, width); const highDegBranchCount = computeHighDegBranchCount(topo.graph); - let hasUselessBranch = false; + // 分支节点在拓扑图中是单格节点,先去重后再做局部结构分析。 + const branchNodes = new Set(); + topo.graph.nodeMap.forEach(node => { + if (node.type === GraphNodeType.Branch) { + branchNodes.add(node); + } + }); + + const { + maxDoorClusterSize, + maxEnemyClusterSize, + hasLargeDoorCluster, + hasLargeEnemyCluster + } = computeBranchClusterStats(branchNodes); + + const { idleDoorBranchCount, idleEnemyBranchCount, hasIdleBranch } = + computeIdleBranchStats(branchNodes); + const { + repeatedGuardDoorBranchCount, + repeatedGuardEnemyBranchCount, + hasRepeatedGuardIdleBranch + } = computeRepeatedGuardIdleStats(topo.graph, branchNodes, width); + + // 无用分支逐点判定:只要任意一个分支命中,整层就带有无用分支标签。 + const hasUselessBranch = [...branchNodes].some(node => + isUselessBranchNode(topo, node) + ); // 统计拓扑图信息 let maxEmptyArea = 0; @@ -373,38 +976,6 @@ export function parseFloorInfo( topo.graph.areas.forEach(area => { area.nodes.forEach(v => { if (v.type === GraphNodeType.Empty) { - let branchConnection = 0; - v.neighbors.forEach(v => { - // 对节点的每个邻居遍历,如果邻居是分支节点,且直接相连的分支节点数小于 2, - // 说明这个连接可能会导致无用节点 - // 至于为什么要多一次额外的邻居节点判断: - // |---|---|---|---|---| - // | W | W | D | W | W | - // |---|---|---|---|---| - // | W | | E | | W | - // |---|---|---|---|---| - // | W | W | D | W | W | - // |---|---|---|---|---| - if (v.type === GraphNodeType.Branch) { - let directBranch = 0; - for (const n of v.neighbors) { - if (n.type === GraphNodeType.Branch) { - directBranch++; - } - } - if (directBranch < 2) { - branchConnection++; - } - } - }); - // 如果连接的分支数与邻居数相同,且小于等于 0,说明是门或怪物后面连接了一整片空地,是无用分支 - // 如果连接的分支数与邻居数不相同,说明可能连接了资源节点、入口节点等,这些显然不应该算入无用分支 - if ( - branchConnection <= 1 && - v.neighbors.size === branchConnection - ) { - hasUselessBranch = true; - } if (v.tiles.size > maxEmptyArea) { maxEmptyArea = v.tiles.size; } @@ -416,13 +987,13 @@ export function parseFloorInfo( }); }); + // 把全局统计、结构标签、局部异常标签统一整理成楼层信息对象。 const floorInfo: IFloorInfo = { tower, topo, map, maxEmptyArea, maxResourceArea, - hasUselessBranch, globalDensity: count(flattened, nonEmptyTiles) / area, wallDensity: count(flattened, wallTiles) / area, doorDensity: count(flattened, doorTiles) / area, @@ -434,6 +1005,17 @@ export function parseFloorInfo( itemDensity: count(flattened, itemTiles) / area, entryCount: count(flattened, entryTiles), specialDoorCount: count(flattened, specialDoorTiles), + maxDoorClusterSize, + maxEnemyClusterSize, + hasLargeDoorCluster, + hasLargeEnemyCluster, + idleDoorBranchCount, + idleEnemyBranchCount, + hasIdleBranch, + repeatedGuardDoorBranchCount, + repeatedGuardEnemyBranchCount, + hasRepeatedGuardIdleBranch, + hasUselessBranch, wallDensityStd: computeWallDensityStd(map, wallTiles, 5), wallHeatmap: gaussainHeatmap( generateHeatmap(map, wallTiles, config.heatmapKernel), diff --git a/data/src/auto/types.ts b/data/src/auto/types.ts index 57137f5..6e3f98c 100644 --- a/data/src/auto/types.ts +++ b/data/src/auto/types.ts @@ -89,7 +89,27 @@ export interface IFloorInfo { readonly entryCount: number; /** 机关门数量 */ readonly specialDoorCount: number; - /** 是否包含只连接了一个节点的空白节点。这种节点相当于门或怪物后面什么都不加,多数是无用的。 */ + /** 同类门分支连通块的最大大小 */ + readonly maxDoorClusterSize: number; + /** 同类怪分支连通块的最大大小 */ + readonly maxEnemyClusterSize: number; + /** 是否存在大小超过 3 的同类门分支连通块 */ + readonly hasLargeDoorCluster: boolean; + /** 是否存在大小超过 3 的同类怪分支连通块 */ + readonly hasLargeEnemyCluster: boolean; + /** 拓扑图上只连接到一个邻居节点的闲置门数量 */ + readonly idleDoorBranchCount: number; + /** 拓扑图上只连接到一个邻居节点的闲置怪数量 */ + readonly idleEnemyBranchCount: number; + /** 重复守同一连通区域的闲置门数量 */ + readonly repeatedGuardDoorBranchCount: number; + /** 重复守同一连通区域的闲置怪数量 */ + readonly repeatedGuardEnemyBranchCount: number; + /** 是否存在只连接到一个邻居节点的闲置分支 */ + readonly hasIdleBranch: boolean; + /** 是否存在重复守同一连通区域的闲置分支 */ + readonly hasRepeatedGuardIdleBranch: boolean; + /** 是否包含无资源收益的后侧分支。 */ readonly hasUselessBranch: boolean; /** 墙壁密度标准差 */ readonly wallDensityStd: number; diff --git a/docs/dataset-clean-design.md b/docs/dataset-clean-design.md new file mode 100644 index 0000000..eb05911 --- /dev/null +++ b/docs/dataset-clean-design.md @@ -0,0 +1,490 @@ +# 数据集进一步清洗需求文档 + +## 背景 + +当前数据集清洗流程已经具备一轮较强的硬过滤,包括尺寸、密度、最大空地区域、最大资源区域、墙壁分布标准差、不可入不可出图块、粗粒度无用分支等规则,过滤入口主要位于 `data/src/auto/info.ts` 和 `data/src/auto/auto.ts`。 + +但从当前模型生成结果看,仍然存在两类局部结构噪声会被明显放大: + +| 类型 | 典型现象 | 当前规则不足 | +| --------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 连续门 / 连续怪 | 多个同类门或同类怪在局部互相连通,形成规模过大的门团或怪团 | 现有规则主要看全局密度和区域大小,无法识别这种少量但高显著性的局部模式 | +| 闲置门 / 闲置怪 | 门或怪物看起来在阻挡路径,但实际上开不开、打不打都几乎没有区别 | 当前 `hasUselessBranch` 仅覆盖一小部分“门/怪后面基本什么都没有”的情况,漏检仍然较多 | + +这两类样本在数据集中占比不高,但都具有很强的视觉和结构辨识度,容易让模型学到不希望保留的局部模式,因此需要做进一步清洗。 + +--- + +## 目标 + +本轮数据集清洗的目标只有两个: + +1. 过滤掉包含“同类门连通块大小超过 3”或“同类怪连通块大小超过 3”的地图。 +2. 强化对闲置门、闲置怪的过滤,减少“看似阻挡、实际无意义”的分支结构。 + +本轮工作暂不直接修改模型结构,也不调整其他全局统计过滤规则。重点是把这两类局部结构问题单独拎出来,形成可讨论、可实现、可验证的清洗标准。 + +--- + +## 当前清洗现状 + +从现有实现看,相关逻辑大致分为两层: + +1. `parseFloorInfo` 负责从单张地图中提取拓扑与统计信息。 +2. `autoLabelTowers` 负责基于这些信息对楼层做最终过滤。 + +其中已经存在的“无用分支”过滤有两个明显特征: + +1. 它是布尔型规则,只回答“这张图是否存在至少一个无用分支”,没有进一步区分无用门、无用怪、无用原因。 +2. 它偏向识别“门/怪背后连接一片纯空地,并且这片空地没有其他意义”的简单情形,对更隐蔽的“可绕过”“可忽略”“收益不足”的分支覆盖不够。 + +因此,本次需求不是从零开始新增一套清洗体系,而是在现有拓扑分析基础上,把“连续分支异常”和“闲置分支异常”补成更完整的一组规则。 + +--- + +## 需求一:过滤规模超过 3 的同类门团 / 怪团 + +### 问题描述 + +这里真正需要关注的,不是“正好 3 个门”或“正好 3 个怪”本身。三连门、三连怪在正常地图中相对常见,而且可能承载合理的资源等价交换,例如“三个黄门后面放一个蓝钥匙”。因此,大小恰好为 3 的同类门团 / 怪团默认不应删除。 + +真正需要清洗的是:多个同类门或多个同类怪在局部通过相邻关系连成规模过大的连通块。这类结构不一定沿同一方向排成直线,但会在玩家感知上形成“整团连续阻挡”。 + +### 连续性的正式定义 + +本文中的“连续门 / 连续怪”统一采用 BFS 连通判定,具体定义如下: + +1. 在地图拓扑图中,以单个分支节点作为起点进行判定。 +2. 如果起点是门,则只沿着“与当前节点相邻且同为门”的分支节点做 BFS;如果起点是怪,则只沿着“与当前节点相邻且同为怪”的分支节点做 BFS。 +3. BFS 最终访问到的同类分支节点总数,定义为该起点所属连通块的大小。 +4. 判定只看同类连通块总大小,不要求这些节点位于同一行、同一列或同一条直线路径上。 + +这里的“门”按门类大类统一处理,包括普通门、特殊门、机关门;“怪”按怪物大类统一处理,不区分具体怪物种类。 + +### 需求定义 + +本次清洗需要满足以下要求: + +1. 大小恰好为 3 的门连通块或怪连通块默认保留,不作为过滤条件。 +2. 只要一张地图中存在“门连通块大小超过 3”,该地图就应被视为异常样本并过滤。 +3. 只要一张地图中存在“怪连通块大小超过 3”,该地图就应被视为异常样本并过滤。 +4. 门和怪物分别统计,不把“门-怪-门”或“怪-门-怪”这类混合连接结构合并为同类连通块。 +5. 这个规则关注的是玩家感知到的局部阻挡团块,而不是简单的全图门密度、怪物密度,或某个方向上的直线连续长度。 + +### 典型反例 + +以下模式应被视为高优先级清洗对象: + +```text +空地 -> 门 -> 门 -> 门 -> 门 -> 空地 + + 怪 +空地 -> 怪 -> 怪 + | + 怪 + +门 -> 门 -> 门 + | + 门 +``` + +上面第二、第三种情况都体现了 BFS 定义下的“连成一团”:即使没有某个单一方向上的四连,也应按同类连通块大小来判断。 + +### 典型保留样例 + +以下模式默认不应因为“连续门 / 连续怪”规则被删除: + +```text +空地 -> 黄门 -> 黄门 -> 黄门 -> 蓝钥匙 + +空地 -> 怪 -> 怪 -> 怪 -> 关键资源 +``` + +这类结构虽然也存在三连,但规模没有超过阈值,而且可能承担明确的资源交换或推进作用。 + +### 非目标与边界 + +以下内容作为当前版本的边界条件: + +1. “连续性”定义已经固定为同类分支节点的 BFS 连通块,不再使用“某个方向上连续三个及以上”作为过滤标准。 +2. 当前门按门大类统一处理,怪按怪物大类统一处理,不再按门颜色、门机制或具体怪物种类拆分。 +3. 当前阈值固定为“超过 3 才过滤”,即大小为 4 及以上的同类连通块过滤,大小为 3 及以下保留。 +4. 如果后续观察到模型明显偏向生成三连门或三连怪,再考虑追加更严格规则;当前版本不提前收紧。 + +本轮文档在这一点上已经定稿:连续门 / 连续怪的判定采用 BFS,同类连通块大小 `> 3` 才过滤。 + +--- + +## 需求二:加强闲置门和闲置怪的清洗 + +### 问题描述 + +当前已经有“无用分支”过滤,但实际观察到的数据污染比这个规则覆盖的范围更大。除了最简单的“门后面是一片没意义空地”之外,还存在不少更隐蔽的情形: + +1. 门或怪物背后几乎是墙,或者只连着非常小的死胡同。 +2. 门或怪物背后没有资源、没有入口、没有关键路径推进价值。 +3. 玩家即使不开这个门、不打这个怪,也几乎不会损失什么。 +4. 存在替代路径,导致这个门或怪物在结构上只是“看起来在拦路”,但实际上不承担有效阻挡作用。 + +这些节点会让地图局部结构显得啰嗦、虚假或无意义,也会让模型学到“放一个门/怪占位就算有设计”的错误模式。 + +### 需求定义 + +本次清洗需要把“闲置门 / 闲置怪”理解为一类更广义的异常分支,满足以下要求: + +1. 如果一个门或怪物不承担明显的资源守护、路径控制、区域分隔或推进节奏作用,应当倾向于将其视为清洗对象。 +2. 门和怪物都要覆盖,不能只处理门或只处理怪。 +3. 判定时不能只看节点本身,还要结合其背后的区域、可达性、是否存在收益、是否存在替代路径等上下文。 +4. 相比当前 `hasUselessBranch`,新规则需要覆盖更多“开不开都可以 / 打不打都可以”的情况。 + +更具体的结构模式见下文“无用分支章节”和“闲置节点章节”。 + +### 典型可疑特征 + +以下特征是后续算法设计时需要重点覆盖的样本类型: + +1. 身后为墙,或身后只接一小块纯空地。 +2. 身后没有守护任何资源,包括宝石、血瓶、钥匙、道具等。 +3. 身后不守护入口、出口、关键门链、关键怪链或关键区域切换。 +4. 即使移除该门或怪物,玩家主要行动路线和收益结构几乎不变。 +5. 看似是一个分支节点,但它既不制造代价,也不制造选择,只是形式上占了一个阻挡位置。 + +### 需要谨慎保留的情况 + +为了避免过清洗,以下情况默认不应轻易判为闲置门 / 闲置怪: + +1. 明确守护资源、钥匙、道具、入口、出口的门或怪物。 +2. 虽然身后区域不大,但承担了必要的路线切分或节奏控制作用。 +3. 位于环路、并路、捷径结构中,会改变玩家选择的门或怪物。 +4. 作为多段推进链条中必要一环的门或怪物,即使其直接收益不大,也不能简单当作“闲置”。 + +换句话说,本次需求要清的是“没有实际设计意义的分支”,不是所有“收益不高的分支”。 + +--- + +## 无用分支章节 + +本章节专门描述“无用分支”的典型结构模式。这里的“分支”统一指门或怪物,示意图中默认用 `E = Enemy` 表示怪物;若替换成门,结构判断方式相同。记号约定如下: + +1. `W = Wall` +2. `N = Empty` +3. `E = Enemy` + +后续如果需要,也可以把 `E` 进一步泛化为“Branch”。 + +### 模式一:走廊尽头的单分支,身后只有一个空位死胡同 + +```text +| W | W | W | +| N | E | N | +| W | W | W | +``` + +这个模式表示:一个分支节点位于狭窄走廊中,分支身后只连着一个空格,而且这个空格本身没有进一步延伸,也没有资源、入口、关键路径价值。 + +这种结构通常既不形成真正的房间,也不形成有效的路线分化。玩家经过这个分支以后,收益近似于零,因此它很容易成为无意义的“占位怪”或“占位门”。 + +### 模式二:走廊尽头的单分支,身后直接是墙 + +```text +| W | W | W | +| N | N | E | +| W | W | W | +``` + +这个模式比模式一更极端:分支节点本身已经在走廊末端,身后直接就是墙。也就是说,这个分支既没有守护任何后侧空间,也没有作为区域边界存在的必要。 + +如果没有其他特殊语义,这类分支几乎可以直接视为纯噪声结构。 + +### 模式三:由局部死胡同串联形成的连续无用分支 + +```text +| W | W | W | W | W | +| N | N | E | N | W | +| W | W | W | E | W | +| W | E | N | N | W | +| W | W | W | W | W | +``` + +这里的问题不一定是某一个分支单独看完全无用,而是多个分支首尾相接,每个分支后面都只接很小的空区、墙或另一个同样无价值的分支,最终形成“连续好几个没有用的怪”或“连续好几个没有用的门”。 + +这种模式说明无用分支不一定是单点问题,而可能具有递归暴露的特征:当最末端的无用分支被识别出来以后,它前面的分支也会随之变成新的无用分支候选。 + +### 模式四:单出口的封闭怪环 + +若干个怪物形成一个环,并且这个环整体只有一个出口,环内部也没有任何有价值的资源、入口或关键路径推进点,那么这整个结构应视为重点清洗对象。 + +```text +| W | W | W | W | W | +| W | E | E | E | W | +| N | E | N | E | W | +| W | E | E | E | W | +| W | W | W | W | W | +``` + +这个模式的关键不在于某一只怪本身,而在于整个环状结构制造了一个看似复杂的局部障碍,但它既不提供收益,也不提供真正的路径选择,只是在形式上增加了包围感和阻挡感。 + +如果把这类结构保留在数据集中,模型很容易学到“围一圈怪也算一种合理局部模板”,从而在生成时复现出不必要的怪环。 + +### 初步识别思路 + +下面先给出一版偏保守、但更接近最终可实现形态的识别思路。 + +#### 建议主算法:基于入口连通性的单次穿越判定 + +经过进一步细化后,当前更合适的主算法不再使用“禁止回到进入侧区域”这种表述,而是改成更明确的“目标分支节点仅允许被穿过一次”。 + +这版算法对于模式一、模式二、模式三已经足够准确,而且能直接复用现有拓扑图中的入口、分支、资源和连通性信息。 + +形式化描述如下: + +1. 对每个目标分支节点 `B`,先检查它在格子层面的可通行相邻方向数。 +2. 如果 `B` 在格子层面只有一个可通行相邻方向,则可直接判为无用分支。因为只有死胡同结构才会出现这种情况,这正对应模式二以及模式一的一部分极端情形。 +3. 如果 `B` 在格子层面的可通行相邻方向数大于 1,再观察它周围所有直接相邻的节点。 +4. 在“禁止经过 `B`”的前提下,分别判断这些相邻节点是否还能连通到任意入口。 +5. 若某个相邻节点在不经过 `B` 时无法连通到任意入口,则认为它属于 `B` 的“后侧候选区域”。这里用入口连通性来判定方向,而不是用资源连通性,因为玩家的进入方向天然由入口定义。 +6. 对每个后侧候选区域,从该区域出发,在仍然禁止经过 `B` 的前提下做搜索;搜索过程中允许经过其他分支节点。 +7. 如果某个后侧候选区域无法到达任何资源节点,则该区域视为“无资源收益后侧区域”。 +8. 当且仅当 `B` 的所有后侧候选区域都属于“无资源收益后侧区域”时,`B` 才被判为无用分支候选。 +9. 一旦识别到任意一个无用分支节点,这张地图即可直接剔除,不再需要后续迭代剥离。 + +这种写法的好处是: + +1. “哪边是前面、哪边是后面”由入口连通性决定,定义比“不走回头路”更稳定。 +2. “目标分支只能穿过一次”可以自然等价为“评估后侧时,把目标分支从搜索图中移除”。 +3. 允许经过其他分支节点后,模式一到模式三都可以被统一覆盖,而且不会因为后方还串着其他分支而提前误判。 +4. 格子层面单方向可通行的分支节点可以直接作为快速命中规则,不必再做更复杂的后侧分析。 + +在当前版本中,第二阶段的目标可以收敛为“资源节点”,不再需要继续保留更宽泛的“有价值目标”定义,原因如下: + +1. 如果某个方向上仍然守护入口或楼层切换点,那么在第 4 步删除 `B` 后,该方向仍会与某个入口连通,不会进入“后侧候选区域”。 +2. 楼层切换点按入口处理,因此不需要单独再作为目标类型讨论。 +3. 如果某个后侧区域只是通向资源的中间主路,那么由于搜索允许穿过其他分支节点,它最终仍会连通到对应的资源节点。 + +因此,当前版本的核心判定可以收敛为一句话:删除目标分支以后,所有失去入口连通性的后侧区域是否都无法到达任意资源节点。 + +这里还要额外强调一个实现细节:上面的快捷判定必须使用格子层面的可通行方向数,不能直接写成拓扑图上的 `neighbors.size === 1`。因为当前拓扑图会把连成一片的空地或资源压缩成一个节点,同一个邻接节点可能从多个方向贴住该分支;这时虽然 `neighbors.size === 1`,该分支也未必真的是死胡同。 + +#### 当前已经明确的结论 + +这版算法中,下面几条已经可以视为定稿,不再作为开放问题继续讨论。 + +1. “后侧候选区域”可能不止一个,只有当所有后侧候选区域都没有资源收益时,目标分支才命中。 +2. 第二阶段只检查“是否能到达任意资源”已经足够,不需要再单独引入入口、楼层切换点、后续主路等目标类型。 +3. 如果删除目标分支以后,它周围所有相邻节点仍然都能连回入口,那么这个分支按当前定义不属于无用分支,也不应被这个规则剔除。 +4. 一旦识别到任意一个无用分支节点,整张地图直接剔除,因此不需要再做迭代剥离。 + +#### 当前版本暂不处理的情况 + +模式四暂时不纳入当前版本的过滤规则。它的关键特征是“整块环状子图只有一个对外出口”,而不是“删除某一个目标分支后出现明显的后侧孤立区”,因此不能继续沿用当前单节点规则硬套。 + +考虑到模式四本身出现频率很低,而且在模式一到模式三先完成过滤后很可能已经基本消失,当前版本先不对它做额外剔除。 + +#### 当前建议的整合方式 + +综合来看,这条方法已经可以正式作为“无用分支第一主规则”,并且可以直接承担模式一到模式三的检测任务。当前版本保留的实现约束只有三点: + +1. 用入口连通性识别后侧候选区域,而不是再使用“回头路”这类口语化定义。 +2. 对后侧价值的判断统一收敛为“是否还能到达任意资源节点”。 +3. 判定必须按所有后侧候选区域整体聚合,不能只命中其中一个死胡同区域就直接过滤。 + +在这个修正后,这条规则非常适合承担当前版本的主体判定: + +1. 对模式一、模式二,它通常能直接命中。 +2. 对模式三,不需要迭代剥离,只要命中其中任意一个无用分支,整张地图就会被直接剔除。 +3. 对模式四,当前版本先不处理,不额外设计过滤规则。 + +### 当前阶段的实现倾向 + +从工程上看,当前版本比较稳妥的方向是先只做一阶段判定: + +1. 以“基于入口连通性的单次穿越判定”作为主规则,并加上“格子层面单方向可通行分支直接命中”这一快捷规则,用来覆盖模式一、模式二、模式三。 +2. 模式四先不纳入本轮过滤范围,后续如果实际样本和生成结果仍能明显观察到,再单独补规则。 + +这样做的原因是:新的主规则已经能统一描述大部分“穿过一次后,后侧没有资源收益”的情况,而且可以直接复用现有拓扑图中的入口、分支、资源、区域连通信息;而模式四既少见,又明显属于另一类局部环状子图问题,当前版本不值得为了它额外增加复杂度。 + +--- + +## 闲置节点章节 + +无用分支和闲置节点不是同一类问题。无用分支更偏向“分支后侧没有收益”,而闲置节点则可能仍然位于连通区域内部,不会被无用分支规则命中,但删掉它以后,资源获取、路线推进和玩家选择都几乎不会发生变化。 + +这类问题比无用分支更难抽象成一条统一规则,因此当前版本先采用“典型模式整理”的方式收集样例,后续再决定哪些模式值得稳定实现为硬过滤规则。 + +### 模式一:多个同类分支同时守同一资源区域 + +这类模式的核心特征是:两个或多个**同类**分支节点同时守着同一个资源区域。这里的“同类”指“门和门”或“怪和怪”,而不是“门和怪”的混合组合。 + +典型例子如下: + +```text +| W | W | W | W | +| W | R | R | E | +| W | R | R | E | +| W | W | W | W | +``` + +或者以斜向方式分布: + +```text +| W | W | W | W | +| W | R | E | N | +| W | R | R | E | +| W | W | W | W | +``` + +这两种结构的共同点是:多个同类分支节点实际只是在重复守同一个资源房间。一旦玩家已经通过其中任意一个分支,剩余同类分支的存在意义就会显著下降,因此它们属于强闲置候选。 + +对于这一模式,当前已经有一版更具体的候选识别思路: + +1. 在辅助判定阶段,先把资源节点与空地节点临时整合为更大的连通节点,用来描述“同一个房间 / 同一个可进入区域”。 +2. 对每个分支节点,若它的邻居中恰好存在两个非分支的整合连通节点,则把这两个连通节点与该分支的类型一起视为它的结构签名;如果它还有其他邻居,这些额外邻居可以是分支节点,不影响该模式命中。 + +3. 将结构签名一致的同类分支节点收集起来,作为“重复守同一连通区域”的疑似候选集。 +4. 对候选集再做聚类筛选。当前版本不再使用含糊的 L1 距离表述,而是更倾向于在候选分支之间做八方向 BFS 聚类,前提是这些分支具有相同的结构签名。这样可以同时覆盖并排和斜向的重复守卫结构。 +5. 如果候选集大小达到 2 个及以上,则判为该模式命中;如果达到 3 个及以上,同样应命中。由于连续 3 个及以上的拓扑同类分支已经会被前面的“连续门 / 连续怪”规则优先剔除,所以这里额外需要关注的主要是斜向或松散贴边的多分支重复守卫结构。 +6. 当前版本保留“整合连通节点面积为 1 且包含资源则保留”的保守例外,用来避免把过小资源点附近的重复守卫结构过早一刀切过滤。 + +这套思路还有一个好处:它不仅能覆盖上面的两个典型样例,也可能顺带覆盖模式二中的一部分明显重复守卫结构。 + +这套识别思路经过补充后,当前已经明确了几条实现口径: + +1. 房间在这里按“整合后的连通区域”理解,不再额外区分所谓“同一房间内的不同局部功能区”。只要能进入这个房间中的任意一个格子,就可以认为能够到达整个房间。 +2. 距离或接近性的定义收敛为“相同结构签名下的八方向聚类”,不再继续保留含糊的 L1 距离说法。 +3. “面积为 1 且包含资源则保留”的保守例外继续保留,当前版本先按保守口径处理。 +4. 如果同一张图同时命中多个闲置节点模式,直接剔除即可,不需要为了统计目的再额外区分模式间的重叠计数。 + +在这个基础上,这一模式当前真正需要注意的风险主要是漏检而不是误杀:如果多个同类分支虽然也在重复守同一资源区域,但它们在格子层面既不并排,也不满足同结构签名下的八方向聚类,那么当前版本仍可能不会命中。这类样例后续需要通过数据集抽查再决定是否继续补规则。 + +这里有两个重要边界: + +1. 如果是一个门和一个怪同时守同一个资源区域,则**不应**按这个模式直接判为闲置节点。因为玩家仍然在“开门”与“打怪”之间拥有真实选择,这种结构在设计上是有意义的。 +2. 如果结构更松散,虽然也能看出多个同类分支与同一资源区域有关,但其中某个分支并不是明显的“重复守卫”,则应保守处理。 + +例如下面这个例子: + +```text +| W | W | W | W | +| W | E | N | N | +| W | R | E | N | +| W | W | W | W | +``` + +这个结构和前两个例子相近,但第二个分支与资源区域的关系已经没有那么直接。考虑到不少正常地图里也会出现这种布局,当前版本先把它记录为边界样例,不直接归入闲置节点的硬过滤模式。 + +### 模式二:不属于无用分支、但对地图没有影响的连通闲置节点 + +这类模式的特点是:节点本身不满足无用分支的死胡同定义,也可能与其他分支或路径连在一起,但它的存在并不会改变任何资源获取结果,也不会改变后续路径是否可走通。 + +典型例子如下: + +```text +| W | D | W | W | +| W | E | N | N | +| W | N | E | N | +| W | W | W | W | +``` + +在这个结构里,上面的怪物打掉以后还可以继续开门,因此它不属于无用节点;下面那个怪物会通过某条路径与上面的怪物连起来,也不会匹配“无用分支”的条件。但显然,下面这个怪物打不打都不会改变任何收益和路径,它只是被动挂在一条已经成立的局部结构上,因此应视为典型的闲置节点。 + +还有一些最基础的闲置节点,甚至连无用分支规则都不会命中: + +```text +| W | W | W | W | +| N | E | N | N | +| N | N | N | N | +| W | W | W | W | +``` + +在这个结构里,怪物只是在一大片空地区域边缘插入了一个阻挡点。它不守资源,不控路径,不制造选择,也不会导致任何后续区域被隔开,因此显然属于闲置怪;但由于它并不形成狭义的“分支后侧区域”,仅靠无用分支规则并不能稳定识别它。 + +这类模式说明:闲置节点不能只靠“是否死胡同”来判断。一个节点即使处于连通区域内部,甚至连接着大块空地,只要删掉它以后地图的资源收益和通路结构都没有变化,它依然可能是闲置节点。 + +对于这一模式,当前已经有一个可以直接落地的识别思路:如果某个分支节点在拓扑图上满足 `neighbors.size === 1`,则它可以直接视为闲置节点。理由是:玩家只能从同一个拓扑节点到达它,而击败怪物或开门以后也不会暴露出新的拓扑节点,因此它很可能只是挂在现有连通区域边缘的“无影响阻挡点”。 + +但这里需要特别区分两件事: + +1. 这条规则就是“闲置节点模式二”的直接规则。 +2. 它**不应该**反向替换无用分支章节里的“格子层面可通行方向数”快捷规则,因为两者针对的问题并不一样。 + +更具体地说: + +1. 无用分支章节里的快捷规则是在判断“是否死胡同 / 是否会暴露新后侧区域”,因此必须用格子层面的可通行方向数。 +2. 这里的 `neighbors.size === 1` 更适合描述“删除该分支以后不会引入新的拓扑节点”,也就是一种连通但无影响的闲置状态。 + +经过补充后,这一模式的当前口径可以进一步收紧为: + +1. `neighbors.size === 1` 可以直接上升为硬过滤规则,而不再只是候选信号。因为这种分支只连接到一个其他拓扑节点,玩家只能从这个节点到达它,击败怪物或开门以后也不会暴露出新的拓扑节点,因此它在当前目标下可以直接视为闲置节点。 +2. 这条规则仍然不替代无用分支章节中的格子层快捷规则,因为一个是“连通但无影响”的定义,一个是“死胡同 / 后侧暴露”的定义,两者语义不同。 +3. 当前版本不把“房间内部刻意摆放但 `neighbors.size === 1` 的分支”视为需要保留的特殊样例。对于通用性的战斗地图生成目标,这类节点即使是作者有意放置,也更应该被剔除。 +4. 这条规则与模式一默认视为两类不同模板,不需要额外为交集计数设计特殊处理。 + +不过这一模式仍然存在一个明确的漏检风险:如果闲置节点本身不止一个,而且几个分支彼此相连,那么单看 `neighbors.size === 1` 仍然无法识别。例如: + +```text +| W | W | W | W | W | +| N | N | E | E | N | +| N | N | N | N | N | +| W | W | W | W | W | +``` + +更复杂的例子如下: + +```text +| W | W | W | W | W | +| N | E | N | E | N | +| N | E | E | E | N | +| N | N | N | N | N | +| W | W | W | W | W | +``` + +这两类结构的共同点是:闲置节点已经从“单个挂边分支”变成了一个小的连通分支团,因此 `neighbors.size === 1` 的单点规则不再足够。当前版本对这种模式仍然无法稳定识别。 + +不过这类场景预计出现频率很低,当前版本可以先忽略;如果后续在数据集抽查或生成结果中仍能明显观察到,再单独补一个“连通闲置分支团”规则。 + +### 当前设计倾向 + +对于闲置节点,当前版本不追求像无用分支那样先抽象出一条统一主规则,而是更偏向于采用保守的“典型模式库”策略: + +1. 先把高置信度的重复守卫结构和明显无影响节点结构整理出来。 +2. 只对这些结构非常明确、误杀风险较低的模式考虑硬过滤。 +3. `neighbors.size === 1` 在当前版本里可以直接作为硬过滤规则,而不是只做候选信号。 +4. 当前主要需要警惕的不是这类规则的误杀,而是更复杂的连通闲置分支团可能漏检。 +5. 对边界样例先记录、不急于下结论,避免把正常地图中常见的局部模板误杀掉。 + +--- + +## 工程约束 + +后续算法设计和实现需要满足以下工程约束: + +1. 规则必须能够在数据集构建阶段自动批量运行,不能依赖人工逐图判断。 +2. 规则应尽量复用现有拓扑分析结果,而不是完全脱离现有 `MapTopology` 另起一套复杂流程。 +3. 过滤结果需要可解释,至少要能回答“这张地图为什么因为连续门怪被过滤”或“为什么因为闲置门怪被过滤”。 +4. 统计信息需要能接入现有过滤日志,便于后续观察各类规则分别清掉了多少样本。 +5. 新规则应优先追求高精度,宁可第一版少杀一些,也不要大量误杀正常地图。 + +--- + +## 验收方向 + +本轮需求后续落地时,至少应满足以下验收方向: + +1. 能单独统计“连续门过滤命中数”和“连续怪过滤命中数”。 +2. 能单独统计“闲置门怪过滤命中数”,并尽量区分命中的主要原因。 +3. 对命中的样本,能够抽样输出代表性案例,便于人工复核。 +4. 过滤后重新观察生成结果时,规模过大的门团、怪团以及无意义门怪占位等现象应明显减少。 + +这里的最后一条是最终目的,但前两条统计可观测性是必要前提,否则后续很难调规则。 + +--- + +## 本文档暂不回答的问题 + +为了把需求和算法分开,以下问题暂时不在本文中定稿,留到下一轮讨论: + +1. 连续闲置分支团这类更复杂的连通结构是否需要单独补模板规则,以及何时值得补。 +2. 闲置怪是否需要结合怪物强度、收益交换比等战斗语义。 +3. 是否要对大小恰好为 3 的门团 / 怪团做观测性统计或软标记,而不是直接删除。 + +本文只固定一件事:这两类问题已经足够影响模型生成质量,值得作为数据集清洗的独立目标处理。