From a730f33195fe403f90514472273622fcc9b0d892 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Tue, 8 Sep 2026 23:53:39 +0800 Subject: [PATCH] fix(01-06): reject malformed raw event structures - Validate map, layer, position, priority, and event id shapes before registration. - Add dedicated logger codes and no-partial-registration coverage. --- .../data-base/src/map/eventPath.test.ts | 57 +++++++ packages-user/data-base/src/map/mapState.ts | 151 ++++++++++++++++++ packages/common/src/logger.json | 13 +- 3 files changed, 217 insertions(+), 4 deletions(-) diff --git a/packages-user/data-base/src/map/eventPath.test.ts b/packages-user/data-base/src/map/eventPath.test.ts index ba7bf5b..c32cc54 100644 --- a/packages-user/data-base/src/map/eventPath.test.ts +++ b/packages-user/data-base/src/map/eventPath.test.ts @@ -62,3 +62,60 @@ describe('MapState raw event path', () => { expect(layer?.event(1, 0)?.dirty()).toBe(false); }); }); + +describe('MapState malformed raw event structures', () => { + const cases = [ + { + name: 'missing raw.map container', + mutate: raw => Reflect.set(raw, 'map', null), + code: 63 + }, + { + name: 'missing raw.events container', + mutate: raw => Reflect.set(raw, 'events', null), + code: 63 + }, + { + name: 'missing event layer container', + mutate: raw => Reflect.set(raw.events, '0', null), + code: 63 + }, + { + name: 'invalid event position container', + mutate: raw => Reflect.set(raw.events[0], '1', []), + code: 63 + }, + { + name: 'non-numeric map layer key', + mutate: raw => Reflect.set(raw.map, 'bad', [1, 1, 1, 1]), + code: 62 + }, + { + name: 'invalid map layer value', + mutate: raw => Reflect.set(raw.map, '0', null), + code: 64 + }, + { + name: 'out of range event position', + mutate: raw => Reflect.set(raw.events[0], '4', { 5: 'id' }), + code: 64 + }, + { + name: 'non-string event id', + mutate: raw => Reflect.set(raw.events[0][1], '5', 3), + code: 64 + } + ]; + + it.each(cases)('$name is rejected before map registration', testCase => { + const mapState = createMapState(); + const raw = createRaw(); + testCase.mutate(raw); + + const result = modules.logger.catch(() => mapState.fromRaw(raw)); + + expect(result.ret).toBeNull(); + expect(result.info.map(info => info.code)).toContain(testCase.code); + expect(mapState.getMap(raw.floorId)).toBeNull(); + }); +}); diff --git a/packages-user/data-base/src/map/mapState.ts b/packages-user/data-base/src/map/mapState.ts index b19a1e0..2ef4a97 100644 --- a/packages-user/data-base/src/map/mapState.ts +++ b/packages-user/data-base/src/map/mapState.ts @@ -35,9 +35,160 @@ export class MapState implements IMapState { public readonly state: IDataCommon ) {} + /** + * 判断原始数据中的值是否为可枚举的对象容器 + * @param value 待判断的运行时值 + */ + private isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + /** + * 判断字符串键是否能无歧义地转换为有限数字 + * @param key 原始对象键 + */ + private isNumericKey(key: string): boolean { + return key.trim() !== '' && Number.isFinite(Number(key)); + } + + /** + * 在创建地图前验证外部原始地图及事件结构 + * @param raw 待验证的楼层原始数据 + */ + private validateRaw(raw: IMapRawData): boolean { + const rawMap: unknown = raw.map; + const rawEvents: unknown = raw.events; + const rawAliases: unknown = raw.layerAlias; + if (!this.isRecord(rawMap)) { + logger.error(63, 'map', raw.floorId); + return false; + } + if (!this.isRecord(rawEvents)) { + logger.error(63, 'events', raw.floorId); + return false; + } + if (!this.isRecord(rawAliases)) { + logger.error(63, 'layerAlias', raw.floorId); + return false; + } + if (!Number.isInteger(raw.width) || raw.width <= 0) { + logger.error(64, 'width', raw.floorId); + return false; + } + + let length = 0; + for (const [zIndex, map] of Object.entries(rawMap)) { + if (!this.isNumericKey(zIndex)) { + logger.error( + 62, + 'layer', + raw.floorId, + 'IMapRawData.map', + zIndex + ); + return false; + } + if (!Array.isArray(map)) { + logger.error(64, 'map layer', raw.floorId); + return false; + } + if ( + !map.every( + value => + typeof value === 'number' && + Number.isFinite(value) && + Number.isInteger(value) && + value >= 0 + ) + ) { + logger.error(64, 'map value', raw.floorId); + return false; + } + if (length > 0 && map.length !== length) { + logger.error( + 60, + map.length.toString(), + length.toString(), + raw.floorId + ); + return false; + } + length = map.length; + + const alias = rawAliases[zIndex]; + if (typeof alias !== 'string') { + logger.error(64, 'layer alias', raw.floorId); + return false; + } + const events = rawEvents[zIndex]; + if (!this.isRecord(events)) { + logger.error(63, `events layer ${zIndex}`, raw.floorId); + return false; + } + for (const [index, tileEvents] of Object.entries(events)) { + if (!this.isNumericKey(index)) { + logger.error( + 62, + 'event', + raw.floorId, + 'IMapRawData.events', + index + ); + return false; + } + const indexNum = Number(index); + if ( + !Number.isInteger(indexNum) || + indexNum < 0 || + indexNum >= length + ) { + logger.error(64, 'event position', raw.floorId); + return false; + } + if (!this.isRecord(tileEvents)) { + logger.error(63, `events position ${index}`, raw.floorId); + return false; + } + for (const [priority, id] of Object.entries(tileEvents)) { + if (!this.isNumericKey(priority)) { + logger.error( + 62, + 'event', + raw.floorId, + 'IMapRawData.events', + priority + ); + return false; + } + if (typeof id !== 'string') { + logger.error(64, 'event id', raw.floorId); + return false; + } + } + } + } + if (length % raw.width !== 0) { + logger.error( + 61, + length.toString(), + raw.width.toString(), + raw.floorId + ); + return false; + } + for (const zIndex of Object.keys(rawEvents)) { + if (!this.isNumericKey(zIndex) || !Object.hasOwn(rawMap, zIndex)) { + logger.error(64, 'event layer', raw.floorId); + return false; + } + } + return true; + } + //#region 楼层管理 fromRaw(raw: IMapRawData): IGameMap | null { + if (!this.validateRaw(raw)) return null; let length = 0; const entries = Object.entries(raw.map); for (const [_, map] of Object.entries(raw.map)) { diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 39bea1a..bc9a694 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -59,9 +59,11 @@ "57": "Cannot convert legacy item data since no item legacy converter is attached to ItemStore.", "58": "Cannot find max equipment uid while loading equipment store.", "59": "Cannot find item raw data for tile num $1.", - "60": "Cannot create layer state from raw data since the area of every layer of the map does not equal: $1 vs $2.", - "61": "Cannot create layer state from raw data since map area cannot be divided by width evenly: area = $1 while divider = $2.", - "62": "Expected a number for the key of $1, but got $2." + "60": "Cannot create game map '$3' from raw data since the area of every layer of the map does not equal: $1 vs $2.", + "61": "Cannot create game map '$3' from raw data since map area cannot be divided by width evenly: area = $1 while divider = $2.", + "62": "Cannot add $1 from raw data for game map '$2' since Expected a number for the key of $3, but got $4.", + "63": "Cannot create game map '$2' from raw data since the '$1' container is missing, null, or not an object.", + "64": "Cannot create game map '$2' from raw data since the '$1' value has an invalid type or range." }, "warn": { "1": "Resource with type of 'none' is loaded.", @@ -232,6 +234,9 @@ "166": "Related floor id or related position needs to be specified while change floor type is 'ChangeFloorType.Related'.", "167": "Hero must at a specified floor before using related change floor method.", "168": "The current floor of hero does not in the specified map list.", - "169": "The current floor is the last or the first floor in the map list, so related floor change method will not work." + "169": "The current floor is the last or the first floor in the map list, so related floor change method will not work.", + "170": "Game event id '$1' has already been used, old event will be overridden.", + "171": "Event id '$1' not found in event store, event will be skipped.", + "172": "Event returned non-boolean value '$1' during reduction. JavaScript short-circuit semantics will be used." } }