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.
This commit is contained in:
unanmed 2026-09-08 23:53:39 +08:00
parent 564ef25360
commit a730f33195
3 changed files with 217 additions and 4 deletions

View File

@ -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();
});
});

View File

@ -35,9 +35,160 @@ export class MapState implements IMapState {
public readonly state: IDataCommon
) {}
/**
*
* @param value
*/
private isRecord(value: unknown): value is Record<string, unknown> {
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)) {

View File

@ -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."
}
}