Skip to content

Commit 33ab5da

Browse files
committed
Avoid scrambling to the original string or to a banned word
1 parent f9b6ab8 commit 33ab5da

3 files changed

Lines changed: 89 additions & 30 deletions

File tree

src/hooks/useAppState.ts

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,28 @@ export type Round = Readonly<{
1010
didGuess: boolean;
1111
}>;
1212

13-
function getNewRound(wordPack: readonly string[]): Round {
14-
const word = getRandomElement(wordPack);
15-
16-
return {
17-
wordUnscrambled: word,
18-
wordScrambled: scrambleString(word),
19-
didGuess: false,
20-
};
13+
function getNewRound(
14+
wordPack: readonly string[],
15+
bannedWords: readonly string[],
16+
): Round {
17+
while (true) {
18+
const word = getRandomElement(wordPack);
19+
20+
try {
21+
return {
22+
wordUnscrambled: word,
23+
wordScrambled: scrambleString(word, bannedWords),
24+
didGuess: false,
25+
};
26+
} catch {
27+
console.warn("Struggled to scramble " + word);
28+
}
29+
}
2130
}
2231

2332
type PreGameState = Readonly<{
2433
phase: "pre-game";
34+
bannedWords: readonly string[] | null;
2535
wordPack: readonly string[] | null;
2636
}>;
2737

@@ -30,12 +40,14 @@ type InGameState = Readonly<{
3040
currentRound: Round;
3141
finishedRounds: readonly Round[];
3242
guess: string;
43+
bannedWords: readonly string[];
3344
wordPack: readonly string[];
3445
}>;
3546

3647
type PostGameState = {
3748
phase: "post-game";
3849
finishedRounds: readonly Round[];
50+
bannedWords: readonly string[];
3951
wordPack: readonly string[];
4052
};
4153

@@ -44,7 +56,7 @@ export type State = PreGameState | InGameState | PostGameState;
4456
function getNewRoundState(state: InGameState, didGuess: boolean): InGameState {
4557
return {
4658
...state,
47-
currentRound: getNewRound(state.wordPack),
59+
currentRound: getNewRound(state.wordPack, state.bannedWords),
4860
finishedRounds: [
4961
...state.finishedRounds,
5062
didGuess ? { ...state.currentRound, didGuess: true } : state.currentRound,
@@ -54,15 +66,16 @@ function getNewRoundState(state: InGameState, didGuess: boolean): InGameState {
5466
}
5567

5668
export function getInitialState(): State {
57-
return { phase: "pre-game", wordPack: null };
69+
return { phase: "pre-game", bannedWords: null, wordPack: null };
5870
}
5971

6072
export type Action =
61-
| { type: "load-data"; wordPack: readonly string[] }
62-
| { type: "start-game" }
63-
| { type: "update-guess"; newGuess: string }
73+
| { type: "end-game" }
74+
| { type: "load-banned-words"; bannedWords: readonly string[] }
75+
| { type: "load-word-pack"; wordPack: readonly string[] }
6476
| { type: "skip-word" }
65-
| { type: "end-game" };
77+
| { type: "start-game" }
78+
| { type: "update-guess"; newGuess: string };
6679

6780
export function reducer(state: State, action: Action): State {
6881
switch (action.type) {
@@ -75,13 +88,23 @@ export function reducer(state: State, action: Action): State {
7588
return {
7689
phase: "post-game",
7790
finishedRounds: [...state.finishedRounds, state.currentRound],
91+
bannedWords: state.bannedWords,
7892
wordPack: state.wordPack,
7993
};
8094
}
8195

82-
case "load-data": {
83-
// No-op if not in pre-game phase.
84-
if (state.phase !== "pre-game") {
96+
case "load-banned-words": {
97+
// No-op if not in pre-game phase, or if we already have banned words..
98+
if (state.phase !== "pre-game" || state.bannedWords) {
99+
return state;
100+
}
101+
102+
return { ...state, bannedWords: action.bannedWords };
103+
}
104+
105+
case "load-word-pack": {
106+
// No-op if not in pre-game phase, or if we already have a word pack.
107+
if (state.phase !== "pre-game" || state.wordPack) {
85108
return state;
86109
}
87110

@@ -104,16 +127,17 @@ export function reducer(state: State, action: Action): State {
104127
}
105128

106129
// No-op if data is not loaded.
107-
const { wordPack } = state;
108-
if (wordPack == null) {
130+
const { bannedWords, wordPack } = state;
131+
if (bannedWords == null || wordPack == null) {
109132
return state;
110133
}
111134

112135
return {
113136
phase: "in-game",
114-
currentRound: getNewRound(wordPack),
137+
currentRound: getNewRound(wordPack, bannedWords),
115138
finishedRounds: [],
116139
guess: "",
140+
bannedWords,
117141
wordPack,
118142
};
119143
}

src/hooks/useLoadData.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import { type Dispatch, useEffect } from "react";
22

33
import normalizeString from "../util/normalizeString";
4+
import type { Action } from "./useAppState";
45

5-
export default function useLoadData(
6-
dispatch: Dispatch<{ type: "load-data"; wordPack: readonly string[] }>,
7-
) {
6+
export default function useLoadData(dispatch: Dispatch<Action>) {
87
useEffect(() => {
98
fetch("fruits.txt")
109
.then((response) => response.text())
11-
.then((text) => {
10+
.then((text) =>
1211
dispatch({
13-
type: "load-data",
12+
type: "load-word-pack",
1413
wordPack: text.split("\n").map(normalizeString).filter(Boolean),
15-
});
16-
});
14+
}),
15+
);
16+
17+
fetch("https://unpkg.com/naughty-words@1.2.0/en.json")
18+
.then((response) => response.json())
19+
.then((bannedWords) =>
20+
dispatch({
21+
type: "load-banned-words",
22+
bannedWords: ((window as any).bannedWords = Array.from(
23+
bannedWords,
24+
(word) => normalizeString(String(word)),
25+
)),
26+
}),
27+
);
1728
}, [dispatch]);
1829
}

src/util/scrambleString.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
import shuffle from "./shuffle";
22

3-
export default function scrambleString(s: string): string {
3+
const MAX_ATTEMPTS = 10;
4+
5+
export default function scrambleString(
6+
s: string,
7+
bannedWords: readonly string[],
8+
): string {
49
const arr = [...s];
5-
shuffle(arr);
6-
return arr.join("");
10+
11+
for (let attempts = 0; attempts < MAX_ATTEMPTS; ++attempts) {
12+
shuffle(arr);
13+
const candidate = arr.join("");
14+
15+
// Actually scramble the string.
16+
if (candidate === s) {
17+
continue;
18+
}
19+
20+
// Avoid banned words.
21+
if (bannedWords.some((word) => candidate.includes(word))) {
22+
continue;
23+
}
24+
25+
return candidate;
26+
}
27+
28+
throw new Error("Exceeded the maximum attempts!");
729
}
30+
31+
(window as any).scrambleString = scrambleString;

0 commit comments

Comments
 (0)