Skip to content

Commit 445c5ea

Browse files
authored
Merge pull request #750 from lootlog/main
Main
2 parents 972bbf8 + 1e5ecf4 commit 445c5ea

2 files changed

Lines changed: 294 additions & 22 deletions

File tree

apps/game-client/src/api/characters.api.ts

Lines changed: 217 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,206 @@ export type MargonemCharacter = {
2020
world?: string;
2121
};
2222

23+
const toNumberOrNull = (value: unknown) => {
24+
if (typeof value === "number" && Number.isFinite(value)) {
25+
return value;
26+
}
27+
28+
if (typeof value === "string" && value.trim() !== "") {
29+
const parsedValue = Number(value);
30+
31+
if (Number.isFinite(parsedValue)) {
32+
return parsedValue;
33+
}
34+
}
35+
36+
return null;
37+
};
38+
39+
const toStringOrNull = (value: unknown) => {
40+
if (typeof value === "string") {
41+
return value;
42+
}
43+
44+
if (typeof value === "number" && Number.isFinite(value)) {
45+
return String(value);
46+
}
47+
48+
return null;
49+
};
50+
51+
const findValueByAliases = (
52+
characterData: Record<string, unknown>,
53+
aliases: string[],
54+
) => {
55+
for (const alias of aliases) {
56+
const directValue = characterData[alias];
57+
58+
if (directValue !== undefined) {
59+
return directValue;
60+
}
61+
}
62+
63+
const loweredAliases = new Set(aliases.map((alias) => alias.toLowerCase()));
64+
65+
for (const [key, value] of Object.entries(characterData)) {
66+
if (loweredAliases.has(key.toLowerCase())) {
67+
return value;
68+
}
69+
}
70+
71+
return undefined;
72+
};
73+
74+
const unwrapCharacterData = (characterData: Record<string, unknown>) => {
75+
const nestedCharacterCandidates = [
76+
characterData.character,
77+
characterData.char,
78+
characterData.hero,
79+
characterData.data,
80+
characterData.d,
81+
characterData.value,
82+
];
83+
84+
for (const nestedCharacterCandidate of nestedCharacterCandidates) {
85+
if (
86+
typeof nestedCharacterCandidate === "object" &&
87+
nestedCharacterCandidate !== null &&
88+
!Array.isArray(nestedCharacterCandidate)
89+
) {
90+
return nestedCharacterCandidate as Record<string, unknown>;
91+
}
92+
}
93+
94+
return characterData;
95+
};
96+
97+
const normalizeCharacter = (character: unknown): MargonemCharacter | null => {
98+
if (Array.isArray(character)) {
99+
const [id, nick, world, lvl, prof, gender, icon, last, clan, clan_rank] =
100+
character;
101+
102+
return normalizeCharacter({
103+
clan,
104+
clan_rank,
105+
gender,
106+
icon,
107+
id,
108+
last,
109+
lvl,
110+
nick,
111+
prof,
112+
world,
113+
});
114+
}
115+
116+
if (typeof character !== "object" || character === null) {
117+
return null;
118+
}
119+
120+
const characterData = unwrapCharacterData(
121+
character as Record<string, unknown>,
122+
);
123+
const normalizedId = toNumberOrNull(
124+
findValueByAliases(characterData, ["id", "charId", "characterId"]),
125+
);
126+
const normalizedIcon = toStringOrNull(
127+
findValueByAliases(characterData, [
128+
"icon",
129+
"image",
130+
"imageUrl",
131+
"iconUrl",
132+
"avatar",
133+
]),
134+
);
135+
const normalizedLevel = toNumberOrNull(
136+
findValueByAliases(characterData, ["lvl", "level"]),
137+
);
138+
const normalizedNick = toStringOrNull(
139+
findValueByAliases(characterData, ["nick", "nickname", "name"]),
140+
);
141+
const normalizedProfession = toStringOrNull(
142+
findValueByAliases(characterData, ["prof", "profession", "class"]),
143+
);
144+
const normalizedWorld = toStringOrNull(
145+
findValueByAliases(characterData, [
146+
"world",
147+
"server",
148+
"worldName",
149+
"worldname",
150+
"serverName",
151+
]),
152+
);
153+
154+
if (
155+
normalizedId === null ||
156+
normalizedIcon === null ||
157+
normalizedLevel === null ||
158+
normalizedNick === null ||
159+
normalizedProfession === null ||
160+
normalizedWorld === null
161+
) {
162+
return null;
163+
}
164+
165+
const normalizedClan = toNumberOrNull(
166+
findValueByAliases(characterData, ["clan", "clanId"]),
167+
);
168+
const normalizedClanRank = toNumberOrNull(
169+
findValueByAliases(characterData, ["clan_rank", "clanRank"]),
170+
);
171+
const normalizedLast = toNumberOrNull(
172+
findValueByAliases(characterData, ["last", "lastSeen", "lastLogin"]),
173+
);
174+
const rawGender = findValueByAliases(characterData, ["gender", "sex"]);
175+
const normalizedGender =
176+
rawGender === "m" || rawGender === "f" ? rawGender : undefined;
177+
178+
return {
179+
clan: normalizedClan ?? undefined,
180+
clan_rank: normalizedClanRank ?? undefined,
181+
gender: normalizedGender,
182+
icon: normalizedIcon,
183+
id: normalizedId,
184+
last: normalizedLast ?? undefined,
185+
lvl: normalizedLevel,
186+
nick: normalizedNick,
187+
prof: normalizedProfession,
188+
world: normalizedWorld,
189+
};
190+
};
191+
23192
export const normalizeCharacterList = (
24193
characters: unknown,
25194
): MargonemCharacter[] => {
26195
if (!Array.isArray(characters)) {
27196
return [];
28197
}
29198

30-
return characters.filter((character): character is MargonemCharacter => {
31-
return (
32-
typeof character === "object" &&
33-
character !== null &&
34-
typeof character.id === "number" &&
35-
typeof character.icon === "string" &&
36-
typeof character.lvl === "number" &&
37-
typeof character.nick === "string" &&
38-
typeof character.prof === "string" &&
39-
typeof character.world === "string"
199+
return characters.reduce<MargonemCharacter[]>(
200+
(normalizedCharacters, character) => {
201+
const normalizedCharacter = normalizeCharacter(character);
202+
203+
if (normalizedCharacter) {
204+
normalizedCharacters.push(normalizedCharacter);
205+
}
206+
207+
return normalizedCharacters;
208+
},
209+
[],
210+
);
211+
};
212+
213+
const filterCharactersByWorld = (
214+
characters: MargonemCharacter[],
215+
world: string | undefined,
216+
) => {
217+
return characters
218+
.filter((character) => character.world === world)
219+
.sort(
220+
(firstCharacter, secondCharacter) =>
221+
secondCharacter.lvl - firstCharacter.lvl,
40222
);
41-
});
42223
};
43224

44225
type FetchCharacterListOptions = {
@@ -53,21 +234,32 @@ export async function fetchCharacterList({
53234
languageVersion,
54235
}: FetchCharacterListOptions): Promise<MargonemCharacter[]> {
55236
const margonemEntry = window.localStorage?.getItem("Margonem");
56-
const parsed = margonemEntry ? JSON.parse(margonemEntry) : null;
237+
const accountIdKey = String(accountId);
238+
239+
let parsed: unknown = null;
57240

241+
if (margonemEntry) {
242+
try {
243+
parsed = JSON.parse(margonemEntry);
244+
} catch (error) {
245+
throw error;
246+
}
247+
}
248+
249+
// @ts-expect-error `get` accepts runtime data here; `parsed` intentionally stays `unknown`.
58250
const charlist = get(parsed, "charlist", null) as Record<
59251
string,
60252
MargonemCharacter[]
61253
> | null;
254+
const rawCachedCharacters = accountId
255+
? (charlist?.[accountIdKey] ?? null)
256+
: null;
62257

63-
const cached = accountId
64-
? normalizeCharacterList(charlist?.[accountId] ?? null)
65-
: [];
258+
const cached = accountId ? normalizeCharacterList(rawCachedCharacters) : [];
259+
const filteredCached = filterCharactersByWorld(cached, world);
66260

67-
if (cached.length > 0) {
68-
return cached
69-
.filter((character) => character.world === world)
70-
.sort((a, b) => b.lvl - a.lvl);
261+
if (filteredCached.length > 0) {
262+
return filteredCached;
71263
}
72264

73265
const hs3 = window.getCookie?.("hs3");
@@ -84,12 +276,15 @@ export async function fetchCharacterList({
84276
const response = await client.get<MargonemCharacter[]>(`${url}?hs3=${hs3}`, {
85277
withCredentials: true,
86278
});
279+
const normalizedCharacters = normalizeCharacterList(response.data);
280+
const filteredCharacters = filterCharactersByWorld(
281+
normalizedCharacters,
282+
world,
283+
);
87284

88285
if (!response.data || response.data.length === 0) {
89286
throw new Error("Empty character list received from API");
90287
}
91288

92-
return normalizeCharacterList(response.data)
93-
.filter((character) => character.world === world)
94-
.sort((a, b) => b.lvl - a.lvl);
289+
return filteredCharacters;
95290
}

apps/game-client/src/hooks/api/use-character-list.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,83 @@ describe("use-character-list helpers", () => {
3535
]);
3636
});
3737

38+
it("coerces string numeric fields from charlist payloads", () => {
39+
expect(
40+
normalizeCharacterList([
41+
{
42+
id: "1",
43+
icon: "/icon.gif",
44+
lvl: "300",
45+
nick: "Hero",
46+
prof: "w",
47+
world: "fobos",
48+
clan: "12",
49+
clan_rank: "3",
50+
last: "123456",
51+
},
52+
]),
53+
).toEqual([
54+
{
55+
id: 1,
56+
icon: "/icon.gif",
57+
lvl: 300,
58+
nick: "Hero",
59+
prof: "w",
60+
world: "fobos",
61+
clan: 12,
62+
clan_rank: 3,
63+
last: 123456,
64+
},
65+
]);
66+
});
67+
68+
it("normalizes tuple-based character payloads", () => {
69+
expect(
70+
normalizeCharacterList([
71+
[1, "Hero", "fobos", "300", "w", "m", "/icon.gif", "123456", "7", "2"],
72+
]),
73+
).toEqual([
74+
{
75+
id: 1,
76+
nick: "Hero",
77+
world: "fobos",
78+
lvl: 300,
79+
prof: "w",
80+
gender: "m",
81+
icon: "/icon.gif",
82+
last: 123456,
83+
clan: 7,
84+
clan_rank: 2,
85+
},
86+
]);
87+
});
88+
89+
it("normalizes nested payloads with alias field names", () => {
90+
expect(
91+
normalizeCharacterList([
92+
{
93+
character: {
94+
characterId: "1",
95+
nickname: "Hero",
96+
serverName: "fobos",
97+
level: "300",
98+
profession: "w",
99+
imageUrl: "/icon.gif",
100+
},
101+
},
102+
]),
103+
).toEqual([
104+
{
105+
id: 1,
106+
nick: "Hero",
107+
world: "fobos",
108+
lvl: 300,
109+
prof: "w",
110+
icon: "/icon.gif",
111+
},
112+
]);
113+
});
114+
38115
it("drops legacy cached response objects instead of throwing in consumers", () => {
39116
expect(
40117
normalizeCharacterList({

0 commit comments

Comments
 (0)