-
Notifications
You must be signed in to change notification settings - Fork 39
Expand file tree
/
Copy pathplayers-context.tsx
More file actions
283 lines (258 loc) · 8.09 KB
/
players-context.tsx
File metadata and controls
283 lines (258 loc) · 8.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import useSWR from "swr";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import type { BundleWithStatus } from "@/types/bundles";
import type {
CookingRet,
CraftingRet,
FishRet,
GeneralRet,
MonstersRet,
MuseumRet,
NotesRet,
PerfectionRet,
PowersRet,
ScrapsRet,
ShippingRet,
SocialRet,
WalnutRet,
} from "@communitycenter/stardew-save-parser";
import type { DeepPartial } from "react-hook-form";
export interface PlayerType {
_id: string;
general?: GeneralRet;
bundles?: BundleWithStatus[];
fishing?: FishRet;
cooking?: CookingRet;
crafting?: CraftingRet;
shipping?: ShippingRet;
museum?: MuseumRet;
social?: SocialRet;
monsters?: MonstersRet;
walnuts?: WalnutRet;
notes?: NotesRet;
scraps?: ScrapsRet;
perfection?: PerfectionRet;
powers?: PowersRet;
}
interface PlayersContextProps {
players?: PlayerType[];
uploadPlayers: (players: PlayerType[]) => Promise<Response>;
patchPlayer: (patch: DeepPartial<PlayerType>) => Promise<void>;
activePlayer?: PlayerType;
setActivePlayer: (player?: PlayerType) => void;
}
export const PlayersContext = createContext<PlayersContextProps>({
// @ts-expect-error - default values replaced in PlayersProvider
uploadPlayers: () => {},
patchPlayer: () => Promise.resolve(),
setActivePlayer: () => {},
});
/**
* Normalizes a patch object against a target object to ensure all nested objects and arrays will persist to the DB correctly.
* This function ensures anything in or under an array is included in the patch because json_merge_patch does not recurse into arrays.
* @param patch The changes to apply to the target.
* @param target The original object that the patch will modify.
* @param inArray A flag indicating if the current process is within an array.
* @returns A new object representing the merged state of patch and target.
*/
function normalizePatch(
patch: any,
target: any,
inArray: boolean = false,
): any {
// Return the patch immediately if there's no target to merge with.
if (!target) {
return patch;
}
// Return the patch directly if it's not an object or array.
if (typeof patch !== "object" || patch === null) {
return patch;
}
// Initialize a new patch that copies the original to avoid mutations.
let newPatch: any;
if (inArray) {
newPatch = Array.isArray(target) ? [...target] : { ...target };
} else {
newPatch = Array.isArray(patch) ? [...patch] : { ...patch };
}
// Iterate over all properties in the patch object.
for (const key in patch) {
if (Array.isArray(target[key])) {
// Handle array merging by first copying the existing target array.
newPatch[key] = [...target[key]];
// Recursively normalize each element of the array.
if (
patch[key] &&
typeof patch[key] === "object" &&
!Array.isArray(patch[key]) &&
Object.keys(patch[key]).every((input: any) => {
if (typeof input === "number") {
return Number.isInteger(input);
} else if (typeof input === "string") {
const num = Number(input);
return Number.isInteger(num) && input.trim() === num.toString();
}
return false;
})
) {
for (const arrIndex in patch[key]) {
newPatch[key][arrIndex] = normalizePatch(
patch[key][arrIndex],
// @ts-ignore
target[key][arrIndex],
true,
);
}
} else {
// If the patch is a non-object, replace the target array with the patch.
newPatch[key] = patch[key];
}
} else {
// Recursively normalize nested objects.
newPatch[key] = normalizePatch(patch[key], target[key], inArray);
}
}
// If we are in an array, ensure that missing fields in the patch are filled from the target.
if (inArray) {
Object.keys(target).forEach((field) => {
if (!(field in newPatch)) {
newPatch[field] = target[field];
}
});
}
return newPatch;
}
/**
* Recursively merges properties from source objects into a target object, creating a new object.
* This function does not mutate the original target but returns a new object.
* It only updates references within the new object when there are actual changes to content or children,
* regardless of the depth of those changes. Arrays are copied rather than merged, and nested objects
* are recursively populated. This function can handle an arbitrary number of source objects.
* @param target The initial object to merge properties into.
* @param sources One or more objects from which properties will be sourced.
* @returns The target object merged with properties from all source objects.
*/
export function mergeDeep(target: any, ...sources: any[]): any {
const isObject = (item: any) => item && typeof item === "object";
if (!sources.length) return target;
const source = sources.shift();
const newTarget = Array.isArray(target) ? [...target] : { ...target };
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (Array.isArray(source[key])) {
newTarget[key] = source[key];
} else if (isObject(source[key])) {
if (!target[key]) {
newTarget[key] = Array.isArray(source[key]) ? [] : {};
}
newTarget[key] = mergeDeep(newTarget[key], source[key]);
} else {
newTarget[key] = source[key];
}
}
}
return mergeDeep(newTarget, ...sources);
}
export const PlayersProvider = ({ children }: { children: ReactNode }) => {
const api = useSWR<PlayerType[]>("/api/saves", (...args: any[]) =>
// @ts-expect-error
fetch(...args).then((res) => res.json()),
);
const [activePlayerId, setActivePlayerId] = useState<string>();
const players = useMemo(() => api.data ?? [], [api.data]);
const activePlayer = useMemo(
() => players.find((p) => p._id === activePlayerId),
[players, activePlayerId],
);
useEffect(() => {
if (!activePlayerId && players.length > 0) {
// first lets check if local storage contains the last set player
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("player_id");
// also check if the player_id is still in the players array
if (stored && players.some((player) => player._id === stored)) {
setActivePlayerId(stored);
} else setActivePlayerId(players[0]._id);
}
}
}, [activePlayerId, players]);
// TODO: switch patchplayer use immutability-helper instead of custom merge logic
const patchPlayer = useCallback(
async (patch: DeepPartial<PlayerType>) => {
if (!activePlayer) return;
const patchPlayers = (players: PlayerType[] | undefined) =>
(players ?? []).map((p) => {
if (p._id === activePlayer._id) {
return mergeDeep(p, patch);
}
return p;
});
await api.mutate(
async (currentPlayers: PlayerType[] | undefined) => {
const normalizedPatch = normalizePatch(patch, activePlayer);
if (!normalizedPatch.bundles) {
// By default if bundles are not in the patch, the server will use an empty array,
// which will clobber the existing bundle data since mysql doesn't support arrays properly.
normalizedPatch.bundles = activePlayer.bundles;
}
// console.log("Normalizing patch:");
// console.dir(normalizedPatch);
await fetch(`/api/saves/${activePlayer._id}`, {
method: "PATCH",
body: JSON.stringify(normalizedPatch),
});
return patchPlayers(currentPlayers);
},
{ optimisticData: patchPlayers },
);
},
[api],
);
const uploadPlayers = useCallback(
async (players: PlayerType[]) => {
let res = await fetch("/api/saves", {
method: "POST",
body: JSON.stringify(players),
});
await api.mutate(players);
setActivePlayerId(players[0]._id);
return res;
},
[api, setActivePlayerId],
);
const setActivePlayer = useCallback((player?: PlayerType) => {
if (!player) {
setActivePlayerId(undefined);
return;
}
setActivePlayerId(player._id);
if (typeof window !== "undefined") {
// console.log(`Setting player_id to '${player._id}'`);
window.localStorage.setItem("player_id", player._id);
}
}, []);
return (
<PlayersContext.Provider
value={{
players,
uploadPlayers,
patchPlayer,
activePlayer,
setActivePlayer,
}}
>
{children}
</PlayersContext.Provider>
);
};
export const usePlayers = () => {
return useContext(PlayersContext);
};