aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarko Zajc <marko@zajc.eu.org>2023-07-30 03:12:44 +0200
committerMarko Zajc <marko@zajc.eu.org>2023-07-30 03:12:44 +0200
commit754f925dbf373314f065301cdeb7f4ea9db779db (patch)
treec13cb31d4b08835734ab83f1928f07950217c15d
parent34802e19e0993947e22b6d0c0e276cfcd6929b2d (diff)
parenta9e6f1b333b8c98f64f4d9bad76c04d2551db566 (diff)
Merge branch 'development'v1.6
-rw-r--r--README.md48
-rw-r--r--example/src/main/java/zajc/akiwrapper/AkinatorExample.java307
-rw-r--r--pom.xml24
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/Akiwrapper.java238
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/AkiwrapperBuilder.java175
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/Route.java230
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/Guess.java16
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/Identifiable.java3
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/Question.java36
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/Server.java27
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/ServerList.java72
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/Status.java74
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/GuessImpl.java (renamed from src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/GuessImpl.java)22
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/QuestionImpl.java61
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/ServerImpl.java (renamed from src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerImpl.java)11
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/StatusImpl.java97
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/QuestionImpl.java71
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImpl.java85
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImpl.java61
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/AkinatorException.java25
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/MissingQuestionException.java15
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/QuestionsExhaustedException.java13
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerNotFoundException.java12
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerStatusException.java29
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerUnavailableException.java35
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/StatusException.java32
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/exceptions/UndoOutOfBoundsException.java16
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/impl/AkiwrapperImpl.java223
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/ApiKey.java (renamed from src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ApiKey.java)35
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/Servers.java27
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/UnirestUtils.java19
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/WorkaroundUtils.java11
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Request.java106
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Response.java31
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Route.java137
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/route/RouteBuilder.java150
-rw-r--r--src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Routes.java154
-rw-r--r--src/test/java/com/github/markozajc/akiwrapper/IntegrationTest.java129
-rw-r--r--src/test/java/com/github/markozajc/akiwrapper/core/RouteTest.java43
-rw-r--r--src/test/java/com/github/markozajc/akiwrapper/core/entities/IdentifiableTest.java6
-rw-r--r--src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImplTest.java67
-rw-r--r--src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImplTest.java44
42 files changed, 1713 insertions, 1304 deletions
diff --git a/README.md b/README.md
index 84f7f27..bc210e2 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,13 @@ Add the following dependency to your pom.xml:
9<dependency> 9<dependency>
10 <groupId>com.github.markozajc</groupId> 10 <groupId>com.github.markozajc</groupId>
11 <artifactId>akiwrapper</artifactId> 11 <artifactId>akiwrapper</artifactId>
12 <version>1.5.2</version> 12 <version>1.6</version>
13</dependency> 13</dependency>
14``` 14```
15#### Gradle 15#### Gradle
16Add the following dependency to your build.gradle: 16Add the following dependency to your build.gradle:
17```gradle 17```gradle
18implementation group: 'com.github.markozajc', name: 'akiwrapper', version: '1.5.2' 18implementation group: 'com.github.markozajc', name: 'akiwrapper', version: '1.6'
19``` 19```
20 20
21## Usage 21## Usage
@@ -28,44 +28,52 @@ If you, for example, wish to use a different language that the default English,
28something other than characters, you may use the following setup: 28something other than characters, you may use the following setup:
29```java 29```java
30Akiwrapper aw = new AkiwrapperBuilder() 30Akiwrapper aw = new AkiwrapperBuilder()
31 .setLanguage(Language.GERMAN) 31 .setLanguage(Language.GERMAN)
32 .setGuessType(GuessType.PLACE) 32 .setGuessType(GuessType.PLACE)
33 .build(); 33 .build();
34``` 34```
35(keep in mind that not all language-guesstype combinations are supported, though all languages support `CHARACTER`) 35(keep in mind that not all language-guesstype combinations are supported, though all languages support `CHARACTER`)
36 36
37You'll likely want to set up a question-answer loop afterwards. Fetch questions with 37You'll typically want to set up a question-answer loop afterwards. Fetch questions with
38```java 38```java
39Question question = aw.getQuestion(); 39Question question = aw.getQuestion();
40``` 40```
41 41
42Display the question to the user, collect their answer, and feed it to Akinator with 42Display the question to the player, collect their answer, and feed it to Akinator with
43```java 43```java
44aw.answer(Answer.YES); 44aw.answer(Answer.YES);
45``` 45```
46 46
47If the player wishes to undo their previous answer, you can let Akinator know with 47If the player wishes to undo their previous answer, you can let do that with
48```java 48```java
49aw.undoAnswer(); 49aw.undoAnswer();
50``` 50```
51You can undo answers all the way to the first question.
51 52
52Akinator will propose a list of guesses after each answer, coupled with their determined probabilities. You can get all 53Akinator will occasionally try guessing what the player is thinking about.
53guesses above a certain probability with
54```java 54```java
55aw.getGuessesAboveProbability(0.85f); // 85% seems to be the sweet spot, though you're free to use anything you want 55var guess = aw.suggestGuess()
56if (guess != null) {
57 // ask the player to confirm or reject the guess
58 if (playerConfirmedGuess) {
59 aw.confirmGuess(guess); // let Akinator know that the guess is right
60 return; // finish the game
61
62 } else {
63 aw.rejectLastGuess(); // let Akinator know that the guess is not right - this also gives us a new question
64 }
65}
66;
56``` 67```
57Let the player review each guess, but keep track of the declined ones, as Akinator will send you the same guesses over 68When a guess is available, the player should be asked to confirm it. If the guess is confirmed, we finish the game and
58and over if he feels like it. 69optionally let Akinator know. If the guess is rejected, we let Akinator know and continue. Akiwrapper also keeps track
70of rejected guesses for you, so `suggestGuess()` never returns the same guess.
59 71
60At some point Akinator will run out of questions to ask. This is indicated by `aw.getCurrentQuestion()` equalling null. 72At some point (normally after question #80) Akinator will run out of questions to ask. This is indicated by
61If and when this happens, fetch and propose all remaining guesses (this time without a probability filter) with 73`aw.isExhausted()`. After there are no questions left, the last guess should be retrieved and shown to the player.
62```java
63aw.getGuesses()
64```
65and propose each one to the player. This also marks the absolute end of the game.
66 74
67Unless you provide your own UnirestInstance to AkiwrapperBuilder, you should make sure to shut down the singleton 75Unless you provide your own UnirestInstance to AkiwrapperBuilder, you should make sure to shut down the singleton
68instance that Akiwrapper uses by default after you're done with Akiwrapper: 76instance that Akiwrapper uses by default after you're done with Akiwrapper (calling `System.exit()` also works):
69```java 77```java
70UnirestUtils.shutdownInstance(); 78UnirestUtils.shutdownInstance();
71``` 79```
diff --git a/example/src/main/java/zajc/akiwrapper/AkinatorExample.java b/example/src/main/java/zajc/akiwrapper/AkinatorExample.java
index 148394f..0a903e6 100644
--- a/example/src/main/java/zajc/akiwrapper/AkinatorExample.java
+++ b/example/src/main/java/zajc/akiwrapper/AkinatorExample.java
@@ -3,7 +3,7 @@ package zajc.akiwrapper;
3import static com.github.markozajc.akiwrapper.Akiwrapper.Answer.*; 3import static com.github.markozajc.akiwrapper.Akiwrapper.Answer.*;
4import static com.github.markozajc.akiwrapper.core.entities.Server.GuessType.CHARACTER; 4import static com.github.markozajc.akiwrapper.core.entities.Server.GuessType.CHARACTER;
5import static com.github.markozajc.akiwrapper.core.entities.Server.Language.ENGLISH; 5import static com.github.markozajc.akiwrapper.core.entities.Server.Language.ENGLISH;
6import static java.lang.Integer.parseInt; 6import static java.lang.Character.toLowerCase;
7import static java.lang.System.*; 7import static java.lang.System.*;
8import static java.util.stream.Collectors.joining; 8import static java.util.stream.Collectors.joining;
9 9
@@ -19,121 +19,82 @@ import com.github.markozajc.akiwrapper.core.exceptions.ServerNotFoundException;
19@SuppressWarnings("javadoc") 19@SuppressWarnings("javadoc")
20public class AkinatorExample { 20public class AkinatorExample {
21 21
22 public static final double PROBABILITY_THRESHOLD = 0.85; 22 private static final String ANSWER_TIP =
23 // This is used to determine which guesses are probable (probability is determined by 23 "Y or yes, N or no, DK or don't know, P or probably, PN or probably not, or go back one step with B or back.";
24 // Akinator) enough to propose to the player. 24 private static final Scanner IN = new Scanner(System.in).useDelimiter("\n");
25 25
26 @SuppressWarnings("null") 26 public static void main(String[] args) {
27 public static void main(String[] args) throws Exception { 27 boolean filterProfanity = getProfanityFilter();
28 try (var in = new Scanner(System.in)) { 28 // Gets player's age. Like the Akinator's website, this will turn on the profanity
29 boolean filterProfanity = getProfanityFilter(in); 29 // filter if the age entered is below 16.
30 // Gets player's age. Like the Akinator's website, this will turn on the profanity 30
31 // filter if the age entered is below 16. 31 var language = getLanguage();
32 32 // Gets player's language. Akinator will give the user localized questions and
33 var language = getLanguage(in); 33 // guesses depending on user's language.
34 // Gets player's language. Akinator will give the user localized questions and 34
35 // guesses depending on user's language. 35 var guessType = getGuessType();
36 36 // Gets the guess type.
37 var guessType = getGuessType(in); 37
38 // Gets the guess type. 38 Akiwrapper aw;
39 39 try {
40 Akiwrapper aw; 40 aw = new AkiwrapperBuilder().setFilterProfanity(filterProfanity)
41 try { 41 .setLanguage(language)
42 aw = new AkiwrapperBuilder().setFilterProfanity(filterProfanity) 42 .setGuessType(guessType)
43 .setLanguage(language) 43 .build();
44 .setGuessType(guessType) 44 } catch (ServerNotFoundException e) {
45 .build(); 45 err.println("Unsupported combination of language and guess type");
46 } catch (ServerNotFoundException e) { 46 return;
47 err.println("Unsupported combination of language and guess type");
48 return;
49 }
50 // Builds the Akiwrapper instance, this is what we'll be using to perform
51 // operations such as answering questions, fetching guesses, etc
52
53 var rejected = new ArrayList<Long>();
54 // A list of rejected guesses, used to prevent them from repeating
55
56 while (aw.getQuestion() != null) {
57 // Runs while there are still questions left
58
59 Question question = aw.getQuestion();
60 if (question == null)
61 break;
62 // Breaks the loop if question is null; /should/ not occur, but safety is still
63 // first
64
65 out.printf("Question #%d%n", question.getStep() + 1);
66 out.printf("\t%s%n", question.getQuestion());
67 // Displays the question.
68
69 if (question.getStep() == 0)
70 out.printf("%nAnswer with " +
71 "Y (yes), N (no), DK (don't know), P (probably) or PN (probably not) " +
72 "or go back in time with B (back).%n");
73 // Displays the tip (only for the first time)
74
75 answerQuestion(in, aw);
76
77 reviewGuesses(in, aw, rejected);
78 // Iterates over any available guesses.
79 }
80
81 for (Guess guess : aw.getGuesses()) {
82 if (reviewGuess(guess, in)) {
83 // Reviews all final guesses.
84 finish(true);
85 exit(0);
86 }
87 }
88
89 finish(false);
90 // Loses if all guesses are rejected.
91 } 47 }
92 } 48 // Builds the Akiwrapper instance, this is what we'll be using to perform
49 // operations such as answering questions, fetching guesses, etc
93 50
94 private static void answerQuestion(@Nonnull Scanner sc, @Nonnull Akiwrapper aw) { 51 while (aw.getQuestion() != null) {
95 boolean answered = false; 52 // Runs while there are still questions left
96 while (!answered) {
97 // Iterates while the questions remains unanswered.
98 53
99 var answer = sc.nextLine().toLowerCase(); 54 Question question = aw.getQuestion();
55 if (question == null)
56 break;
57 // Breaks the loop if question is null; /should/ not occur, but safety is still
58 // first
100 59
101 if (answer.equals("y")) { 60 out.printf("Question #%d%n\t%s%n", question.getStep() + 1, question.getQuestion());
102 aw.answer(YES); 61 // Displays the question.
103 62
104 } else if (answer.equals("n")) { 63 if (question.getStep() == 0)
105 aw.answer(NO); 64 out.printf("%nAnswer with %s%n", ANSWER_TIP);
65 // Displays the tip (only for the first time)
106 66
107 } else if (answer.equals("dk")) { 67 out.print("> ");
108 aw.answer(DONT_KNOW);
109 68
110 } else if (answer.equals("p")) { 69 answerQuestion(aw);
111 aw.answer(PROBABLY); 70 // Displays the question and prompts the player for an answer
112 71
113 } else if (answer.equals("pn")) { 72 reviewSuggestedGuess(aw);
114 aw.answer(PROBABLY_NOT); 73 // Checks if any guess is available and prompts the player
74 }
115 75
116 } else if (answer.equals("b")) { 76 reviewSuggestedGuess(aw);
117 aw.undoAnswer(); 77 // Reviews the final guess
118 78
119 } else if (answer.equals("debug")) { 79 finish(false);
120 out.printf("Debug information:%n\tCurrent API server: %s%n\tCurrent guess count: %d%n", 80 // Loses if all guesses are rejected.
121 aw.getServer().getUrl(), aw.getGuesses().size()); 81 }
122 continue;
123 // Displays some debug information.
124 82
83 private static Guess reviewSuggestedGuess(@Nonnull Akiwrapper aw) {
84 var guess = aw.suggestGuess();
85 if (guess != null) {
86 if (reviewGuess(guess)) {
87 aw.confirmGuess(guess);
88 finish(true);
89 exit(0);
125 } else { 90 } else {
126 out.println("Please answer with either " + 91 aw.rejectLastGuess();
127 "[Y]ES, [N]O, [D|ONT |K]NOW, [P]ROBABLY or [P|ROBABLY |N]OT or go back one step with [B]ACK.");
128 continue;
129 } 92 }
130
131 answered = true;
132 // Answers the question.
133 } 93 }
94 return guess;
134 } 95 }
135 96
136 private static boolean reviewGuess(@Nonnull Guess guess, @Nonnull Scanner sc) { 97 private static boolean reviewGuess(@Nonnull Guess guess) {
137 out.println(guess.getName()); 98 out.println(guess.getName());
138 out.print("\t"); 99 out.print("\t");
139 if (guess.getDescription() == null) 100 if (guess.getDescription() == null)
@@ -143,49 +104,77 @@ public class AkinatorExample {
143 out.println(); 104 out.println();
144 // Displays the guess' information 105 // Displays the guess' information
145 106
146 boolean answered = false; 107 out.print("Is this what you're thinking of? [Y/n] ");
147 boolean isCharacter = false; 108 var input = IN.next().trim();
148 while (!answered) { 109 // Asks the player if the guess is correct
149 // Asks the player if the guess is correct 110
111 return input.isEmpty() || toLowerCase(input.charAt(0)) == 'y';
112 }
113
114 private static void answerQuestion(@Nonnull Akiwrapper aw) {
115 main: while (true) {
116 // Prompts the player for an answer
117
118 var input = IN.next().toLowerCase();
150 119
151 out.println("Is this your character? (y/n)"); 120 switch (input) {
152 String line = sc.nextLine();
153 switch (line) {
154 case "y": 121 case "y":
155 // If the player has responded positively. 122 case "yes":
156 answered = true; 123 aw.answer(YES);
157 isCharacter = true; 124 break main;
158 break;
159 125
160 case "n": 126 case "n":
161 // If the player has responded negatively. 127 case "no":
162 answered = true; 128 aw.answer(NO);
163 isCharacter = false; 129 break main;
130
131 case "dk":
132 case "don'tknow":
133 case "dontknow":
134 case "dont know":
135 case "don't know":
136 aw.answer(DONT_KNOW);
137 break main;
138
139 case "p":
140 case "probably":
141 aw.answer(PROBABLY);
142 break main;
143
144 case "pn":
145 case "probablynot":
146 case "probably not":
147 aw.answer(PROBABLY_NOT);
148 break main;
149
150 case "b":
151 case "back":
152 if (aw.getStep() == 0)
153 out.println("Can't undo on the first question.");
154 else
155 aw.undoAnswer();
156 break main;
157
158 case "debug":
159 displayDebug(aw);
164 break; 160 break;
161 // Displays some debug information.
165 162
166 default: 163 default:
164 out.println("Please answer with either " + ANSWER_TIP);
167 break; 165 break;
168 } 166 }
169 } 167 }
170
171 return isCharacter;
172 } 168 }
173 169
174 private static void reviewGuesses(@Nonnull Scanner sc, @Nonnull Akiwrapper aw, @Nonnull List<Long> declined) { 170 private static void displayDebug(Akiwrapper aw) {
175 for (var guess : aw.getGuessesAboveProbability(PROBABILITY_THRESHOLD)) { 171 var question = aw.getQuestion();
176 if (!declined.contains(Long.valueOf(guess.getIdLong()))) { 172 out.println("Debug information:");
177 // Checks if this guess complies with the conditions. 173 out.printf("\tCurrent API server: %s%n", aw.getServer().getUrl());
178 174 out.printf("\tProgression: %f%%%n", question == null ? -1 : question.getProgression());
179 if (reviewGuess(guess, sc)) { 175 out.println("\tGuesses:");
180 // If the player accepts this guess 176 aw.getGuesses().stream().forEach(g -> out.printf("\t\t%f - %s%n", g.getProbability(), g.getName()));
181 finish(true); 177 out.print("> ");
182 exit(0);
183 }
184
185 declined.add(guess.getIdLong());
186 // Registers this guess as rejected.
187 }
188 }
189 } 178 }
190 179
191 private static void finish(boolean win) { 180 private static void finish(boolean win) {
@@ -200,67 +189,53 @@ public class AkinatorExample {
200 } 189 }
201 } 190 }
202 191
203 private static boolean getProfanityFilter(@Nonnull Scanner sc) { 192 private static boolean getProfanityFilter() {
204 out.println("What's your age? (default: 18)"); 193 out.print("Enable profanity filtering? [y/N] ");
205 while (true) { 194 var input = IN.next().trim();
206 var age = sc.nextLine(); 195 return !input.isEmpty() && toLowerCase(input.charAt(0)) == 'y';
207
208 if (age.equals(""))
209 return false;
210
211 try {
212 return parseInt(age) < 16;
213 } catch (NumberFormatException e) {
214 out.println("That's not a number");
215 }
216 }
217 } 196 }
218 197
219 @Nonnull 198 @Nonnull
220 private static Language getLanguage(@Nonnull Scanner sc) { 199 @SuppressWarnings("null")
200 private static Language getLanguage() {
221 var languages = EnumSet.allOf(Language.class); 201 var languages = EnumSet.allOf(Language.class);
222 202
223 out.println("What's your language? (default: English)"); 203 out.print("What's your language? [English] ");
224 while (true) { 204 while (true) {
225 String selectedLanguage = sc.nextLine().toLowerCase().trim(); 205 var input = IN.next().trim().toUpperCase();
226 206
227 if (selectedLanguage.equals("")) 207 if (input.isEmpty())
228 return ENGLISH; 208 return ENGLISH;
229 209
230 var language = languages.stream() 210 var language = languages.stream().filter(l -> l.toString().equals(input)).findAny();
231 .filter(l -> l.toString().toLowerCase().equals(selectedLanguage))
232 .findAny()
233 .orElse(null);
234 211
235 if (language != null) { 212 if (language.isPresent()) {
236 return language; 213 return language.orElseThrow();
237 214
238 } else { 215 } else {
239 out.println(languages.stream() 216 out.println(languages.stream()
240 .map(Enum::toString) 217 .map(Enum::toString)
241 .collect(joining("\n-", "Sorry, that language isn't supported. Choose between\n-", ""))); 218 .collect(joining("\n-", "Sorry, that language isn't supported. Available options:\n-", "")));
242 } 219 }
243 } 220 }
244 } 221 }
245 222
246 @Nonnull 223 @Nonnull
247 private static GuessType getGuessType(@Nonnull Scanner sc) { 224 @SuppressWarnings("null")
225 private static GuessType getGuessType() {
248 var guessTypes = EnumSet.allOf(GuessType.class); 226 var guessTypes = EnumSet.allOf(GuessType.class);
249 227
250 out.println("What will you be guessing? (default: character)"); 228 out.print("What will you be guessing? [character] ");
251 while (true) { 229 while (true) {
252 String selectedGuessType = sc.nextLine().toLowerCase().trim(); 230 var input = IN.next().trim().toUpperCase();
253 231
254 if (selectedGuessType.equals("")) 232 if (input.isEmpty())
255 return CHARACTER; 233 return CHARACTER;
256 234
257 var guessType = guessTypes.stream() 235 var guessType = guessTypes.stream().filter(l -> l.toString().equals(input)).findAny();
258 .filter(l -> l.toString().toLowerCase().equals(selectedGuessType))
259 .findAny()
260 .orElse(null);
261 236
262 if (guessType != null) { 237 if (guessType.isPresent()) {
263 return guessType; 238 return guessType.orElseThrow();
264 239
265 } else { 240 } else {
266 out.println("" + guessTypes.stream() 241 out.println("" + guessTypes.stream()
diff --git a/pom.xml b/pom.xml
index 9b50ef3..7767226 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,9 +1,11 @@
1<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 1<project xmlns="http://maven.apache.org/POM/4.0.0"
2 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
2 <modelVersion>4.0.0</modelVersion> 4 <modelVersion>4.0.0</modelVersion>
3 5
4 <groupId>com.github.markozajc</groupId> 6 <groupId>com.github.markozajc</groupId>
5 <artifactId>akiwrapper</artifactId> 7 <artifactId>akiwrapper</artifactId>
6 <version>1.5.2-2</version> 8 <version>1.6</version>
7 9
8 <name>Akiwrapper</name> 10 <name>Akiwrapper</name>
9 <description>A Java API wrapper for Akinator</description> 11 <description>A Java API wrapper for Akinator</description>
@@ -29,13 +31,14 @@
29 <scm> 31 <scm>
30 <url>https://git.zajc.eu.org/akiwrapper.git/</url> 32 <url>https://git.zajc.eu.org/akiwrapper.git/</url>
31 <connection>scm:git:https:https://git.zajc.eu.org/akiwrapper.git/</connection> 33 <connection>scm:git:https:https://git.zajc.eu.org/akiwrapper.git/</connection>
32 <developerConnection>scm:git:ssh://git@zajc.eu.org/srv/git/akiwrapper.git</developerConnection> 34 <developerConnection>
35 scm:git:ssh://git@zajc.eu.org/srv/git/akiwrapper.git</developerConnection>
33 </scm> 36 </scm>
34 37
35 <properties> 38 <properties>
36 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 39 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
37 <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> 40 <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
38 <junit.version>5.9.3</junit.version> 41 <junit.version>5.10.0</junit.version>
39 <slf4j.version>2.0.7</slf4j.version> 42 <slf4j.version>2.0.7</slf4j.version>
40 <maven.compiler.source>11</maven.compiler.source> 43 <maven.compiler.source>11</maven.compiler.source>
41 <maven.compiler.target>11</maven.compiler.target> 44 <maven.compiler.target>11</maven.compiler.target>
@@ -47,21 +50,21 @@
47 <dependency> 50 <dependency>
48 <groupId>com.jcabi</groupId> 51 <groupId>com.jcabi</groupId>
49 <artifactId>jcabi-xml</artifactId> 52 <artifactId>jcabi-xml</artifactId>
50 <version>0.27.2</version> 53 <version>0.29.0</version>
51 </dependency> 54 </dependency>
52 55
53 <!-- JSON parsing --> 56 <!-- JSON parsing -->
54 <dependency> 57 <dependency>
55 <groupId>org.json</groupId> 58 <groupId>org.json</groupId>
56 <artifactId>json</artifactId> 59 <artifactId>json</artifactId>
57 <version>20230227</version> 60 <version>20230618</version>
58 </dependency> 61 </dependency>
59 62
60 <!-- HTTP requests --> 63 <!-- HTTP requests -->
61 <dependency> 64 <dependency>
62 <groupId>com.konghq</groupId> 65 <groupId>com.konghq</groupId>
63 <artifactId>unirest-java</artifactId> 66 <artifactId>unirest-java</artifactId>
64 <version>3.14.2</version> 67 <version>3.14.5</version>
65 </dependency> 68 </dependency>
66 69
67 <!-- Logging --> 70 <!-- Logging -->
@@ -97,6 +100,13 @@
97 <scope>test</scope> 100 <scope>test</scope>
98 </dependency> 101 </dependency>
99 102
103 <!-- Collections -->
104 <dependency>
105 <groupId>org.eclipse.collections</groupId>
106 <artifactId>eclipse-collections</artifactId>
107 <version>11.1.0</version>
108 </dependency>
109
100 <!-- Annotations --> 110 <!-- Annotations -->
101 <dependency> 111 <dependency>
102 <groupId>com.github.spotbugs</groupId> 112 <groupId>com.github.spotbugs</groupId>
diff --git a/src/main/java/com/github/markozajc/akiwrapper/Akiwrapper.java b/src/main/java/com/github/markozajc/akiwrapper/Akiwrapper.java
index 6480f12..2a979da 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/Akiwrapper.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/Akiwrapper.java
@@ -2,15 +2,44 @@ package com.github.markozajc.akiwrapper;
2 2
3import static java.util.stream.Collectors.toList; 3import static java.util.stream.Collectors.toList;
4 4
5import java.io.ObjectInputFilter.Status;
5import java.util.List; 6import java.util.List;
6 7
7import javax.annotation.*; 8import javax.annotation.*;
8 9
9import com.github.markozajc.akiwrapper.core.entities.*; 10import com.github.markozajc.akiwrapper.core.entities.*;
11import com.github.markozajc.akiwrapper.core.entities.Server.*;
12import com.github.markozajc.akiwrapper.core.exceptions.*;
10 13
11/** 14/**
12 * The "core" of interaction with the Akinator's API. Contains all methods required 15 * The "core" of interaction with the Akinator's API.<br>
13 * to fully utilize all (known) Akinator's API's endpoints. 16 * Akinator is a 20 questions-type game, which means that the computer
17 * algorithmically tries to figure out (using {@link Guess}es) what character,
18 * object, or movie the player is thinking about by asking {@link Question}s and
19 * getting {@link Answer}s. As mentioned before, this library interfaces with
20 * Akinator's online API and does not run the ranking algorithm locally.<br>
21 * <br>
22 * The library is typically used in the following loop:
23 * <ol>
24 * <li>The API sends a question, which is retrieved using {@link #getQuestion()} and
25 * shown to the player</li>
26 * <li>The player gives one of the five {@link Answer}s, which is submitted using
27 * {@link #answer(Answer)} and returns the next question</li>
28 * <li>Before displaying the next question, {@link #suggestGuess()} is called to
29 * fetch the most relevant {@link Guess} (or {@code null} if none is available)</li>
30 * </ol>
31 * If a {@link Guess} is available, the following should happen:
32 * <ol>
33 * <li>The guess metadata is shown to the player
34 * <ul>
35 * <li>If the player confirms the guess, {@link #confirmGuess(Guess)} is called and
36 * the game is finished
37 * <li>If the player rejects the guess, {@link #rejectLastGuess()} is called and the
38 * game continues
39 * </ul>
40 * </ol>
41 * An example of this library in action can be viewed in the {@code examples/}
42 * directory of the repository.
14 * 43 *
15 * @author Marko Zajc 44 * @author Marko Zajc
16 */ 45 */
@@ -62,93 +91,138 @@ public interface Akiwrapper {
62 } 91 }
63 92
64 /** 93 /**
65 * Answers current question and retrieves the next one. The next question is passed 94 * Returns the API {@link Server} currently in use. The {@link Server} is
66 * as return value and can be retrieved later on with {@link #getQuestion()}. If 95 * automatically determined and set by {@link AkiwrapperBuilder} based on the
67 * there are no more questions left, this will return {@code null}. 96 * {@link Language} and {@link GuessType} preferences, both of which can be retrieved
97 * from the {@link Server} object itself.
68 * 98 *
69 * @param answer 99 * @return the API server currently in use.
70 * the answer 100 */
71 * 101 @Nonnull
72 * @return the latest question or null if an answer was found 102 Server getServer();
103
104 /**
105 * Returns whether or not Akinator has been instructed to filter out explicit
106 * content.<br>
107 * This can be configured in
108 * {@link AkiwrapperBuilder#setFilterProfanity(boolean)}.<br>
109 * Keep in mind that explicit {@link Guess}es can still be returned by
110 * {@link #getGuesses()} or {@link #suggestGuess()}, see {@link Guess#isExplicit()}
111 * for more details on why that is.
73 * 112 *
74 * @deprecated Use {@link #answer(Answer)} instead 113 * @return whether the profanity filter is enabled.
75 */ 114 */
76 @Nullable 115 boolean doesFilterProfanity();
77 @Deprecated(since = "1.5.2", forRemoval = true)
78 default Question answerCurrentQuestion(Answer answer) {
79 return answer(answer);
80 }
81 116
82 /** 117 /**
83 * Answers current question and retrieves the next one. The next question is passed 118 * Sends an answer to the current {@link Question} and fetches the next one,
84 * as return value and can be retrieved later on with {@link #getQuestion()}. If 119 * incrementing the current step.<br>
85 * there are no more questions left, this will return {@code null}. 120 * If there are no more questions left, this will return {@code null}. Any subsequent
121 * calls to this method after the question list has been exhausted will throw a
122 * {@link QuestionsExhaustedException}. A call to this method can be undone with
123 * {@link #undoAnswer()}.
86 * 124 *
87 * @param answer 125 * @param answer
88 * the answer 126 * the {@link Answer} to send.
127 *
128 * @return the next {@link Question} or {@code null} if there are no questions left.
129 *
130 * @throws QuestionsExhaustedException
131 * if the session has exhausted all questions (when
132 * {@link #isExhausted()} returns {@code true}).
133 * @throws ServerStatusException
134 * if the API server returns an erroneous {@link Status}.
89 * 135 *
90 * @return the latest question or null if an answer was found 136 * @see #undoAnswer()
91 */ 137 */
92 @Nullable 138 @Nullable
93 Question answer(Answer answer); 139 Question answer(Answer answer);
94 140
95 /** 141 /**
96 * Goes one question backwards.<br> 142 * Goes one question backwards, undoing the previous {@link #answer(Answer)} call.
97 * For example, if {@link #getQuestion()} returns a question on step {@code 5}, 143 * For example, if {@link #getQuestion()} returns a question on step {@code 5},
98 * calling this command will make {@link #getQuestion()} return the question from 144 * calling this command will make {@link #getQuestion()} return the question from
99 * step {@code 4}. You can call this as many times as you want.<br> 145 * step {@code 4}. You can call this as many times as you want, until you reach step
100 * <strong> Beware that calling this when {@link #getQuestion()} returns a question 146 * {@code 0}<br>
101 * on step {@code 0}, calling this will return {@code null} and nothing will actually 147 * If this method is called on step {@code 0}, {@link UndoOutOfBoundsException} is
102 * be changed!<br> 148 * thrown. If this method is called after questions have been exhausted,
103 * This will also return {@code null} if {@link #getQuestion()} returns {@code null} 149 * {@link QuestionsExhaustedException} is thrown.
104 * as well.</strong> 150 *
105 * 151 * @return the previous {@link Question}.
106 * @return the past message 152 *
153 * @throws UndoOutOfBoundsException
154 * if the session has exhausted all questions (when
155 * {@link #getQuestion()} returns {@code null}.
156 * @throws QuestionsExhaustedException
157 * if the session has exhausted all questions (when
158 * {@link #isExhausted()} returns {@code true}).
159 * @throws ServerStatusException
160 * if the API server returns an erroneous {@link Status}.
161 *
162 * @see #answer(Answer)
107 */ 163 */
108 @Nullable 164 @Nonnull
109 Question undoAnswer(); 165 Question undoAnswer();
110 166
111 /** 167 /**
112 * Returns the current question. You can answer it with {@link #answer(Answer)}. If 168 * Returns a probability-sorted (the lower the index, the higher the probability) and
113 * there are no more questions left, this will return {@code null}. 169 * unmodifiable list of <b>all relevant</b> Akinator's guesses, or an empty list if
170 * there are no guesses.
171 *
172 * @return a sorted list of {@link Guess}es.
114 * 173 *
115 * @return current question 174 * @see Akiwrapper#getGuesses(int)
116 * 175 *
117 * @deprecated Use {@link #getQuestion()} instead 176 * @throws ServerStatusException
177 * if the API server returns an erroneous {@link Status}.
118 */ 178 */
119 @Nullable 179 @Nonnull
120 @Deprecated(since = "1.5.2", forRemoval = true) 180 default List<Guess> getGuesses() {
121 default Question getCurrentQuestion() { 181 return getGuesses(0);
122 return getQuestion();
123 } 182 }
124 183
125 /** 184 /**
126 * Returns the current question. You can answer it with {@link #answer(Answer)}. If 185 * Returns a probability-sorted (the lower the index, the higher the probability) and
127 * there are no more questions left, this will return {@code null}. 186 * unmodifiable list of <b>the first N</b> Akinator's guesses, or an empty list if
187 * there are no guesses.<br>
188 * Note that the API may return less guesses than requested, but not more.
189 *
190 * @param count
191 * the number of {@link Guess}es to get.
192 *
193 * @return a sorted list of {@link Guess}es.
194 *
195 * @see Akiwrapper#getGuesses()
196 */
197 @Nonnull
198 List<Guess> getGuesses(int count);
199
200 /**
201 * Returns the current {@link Question} or {@code null} if the question list has been
202 * exhausted.<br>
203 * You can answer it with {@link #answer(Answer)}.
128 * 204 *
129 * @return current question 205 * @return the current question.
130 */ 206 */
131 @Nullable 207 @Nullable
132 Question getQuestion(); 208 Question getQuestion();
133 209
134 /** 210 /**
135 * Returns a probability-sorted (the lower the index, the higher the probability) and 211 * Returns the current step / question number. Keep in mind that this value is
136 * unmodifiable list of Akinator's guesses, or an empty list if there are no guesses. 212 * zero-based, so the first question is on step {@code 0}.
137 * Note that this method caches the result, which means subsequent calls will not
138 * make API requests.
139 * 213 *
140 * @return a sorted list of guesses 214 * @return the current step
141 *
142 * @see Akiwrapper#getGuessesAboveProbability(double)
143 */ 215 */
144 @Nonnull 216 int getStep();
145 List<Guess> getGuesses();
146 217
147 /** 218 /**
148 * @return the API server this instance of Akiwrapper uses. 219 * Returns if the session has been exhausted, which occurs after answering 80
220 * {@link Question}s.<br>
221 * Sending or undoing answers can no longer be done after the session is exhausted.
222 *
223 * @return whether the session is exhausted.
149 */ 224 */
150 @Nonnull 225 boolean isExhausted();
151 Server getServer();
152 226
153 /** 227 /**
154 * @param probability 228 * @param probability
@@ -158,10 +232,66 @@ public interface Akiwrapper {
158 * probability threshold. 232 * probability threshold.
159 * 233 *
160 * @see Akiwrapper#getGuesses() 234 * @see Akiwrapper#getGuesses()
235 *
236 * @deprecated Use {@link #suggestGuess()} instead
161 */ 237 */
162 @SuppressWarnings("null")
163 @Nonnull 238 @Nonnull
239 @SuppressWarnings("null")
240 @Deprecated(since = "1.6", forRemoval = true)
164 default List<Guess> getGuessesAboveProbability(double probability) { 241 default List<Guess> getGuessesAboveProbability(double probability) {
165 return getGuesses().stream().filter(g -> g.getProbability() > probability).collect(toList()); 242 return getGuesses().stream().filter(g -> g.getProbability() > probability).collect(toList());
166 } 243 }
244
245 /**
246 * <b>Important:</b> this method mutates the session's state, which means that
247 * subsequent calls to it will not yield the same result.<br>
248 * <br>
249 * Provides a likely {@link Guess} for the current session or {@code null} if none
250 * are available. This method should be called after every call to
251 * {@link #answer(Answer)} to let the player review Akinator's guesses - the internal
252 * logic replicates how Akinator works, only returning a {@link Guess} when
253 * Akinator's confidence is high enough. It will also space suggestions out evenly,
254 * always returning {@code null} for a number of steps after the last suggestion.
255 *
256 * @return a suggested {@link Guess} or {@code null} if none are available
257 */
258 @Nullable
259 Guess suggestGuess();
260
261 /**
262 * Confirms a {@link Guess}. While this doesn't affect the current session, because
263 * it's called at the very end, it likely affects Akinator's algorithm and associates
264 * the taken answer route with the confirmed guess, thus improving the game for
265 * everyone.
266 *
267 * @param guess
268 * the {@link Guess} to confirm.
269 *
270 * @apiNote Do not use this method in automated tests, as it introduces faulty data
271 * into Akinator's database, dulling the ranking algorithm.
272 */
273 void confirmGuess(@Nonnull Guess guess);
274
275 /**
276 * <b>Note:</b> this method should only be called immediately after
277 * {@link #suggestGuess()} returns a {@link Guess} - don't send or undo answers
278 * before calling it.<br>
279 * <br>
280 * Rejects the previously suggested {@link Guess} and provides an updated
281 * {@link Question} (or {@code null} if the session is exhausted). The question is on
282 * the same step that the last call to {@link #answer(Answer)} was, but might have
283 * different text.
284 *
285 * @return the replacement {@link Question}.
286 *
287 * @throws ServerStatusException
288 * if the API server returns an erroneous {@link Status}. Throwing this
289 * is suppressed if the session is already exhausted.
290 *
291 * @apiNote Do not use this method in automated tests, as it introduces faulty data
292 * into Akinator's database, dulling the ranking algorithm.
293 */
294 @Nullable
295 Question rejectLastGuess();
296
167} 297}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/AkiwrapperBuilder.java b/src/main/java/com/github/markozajc/akiwrapper/AkiwrapperBuilder.java
index e13b31a..944668b 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/AkiwrapperBuilder.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/AkiwrapperBuilder.java
@@ -3,6 +3,7 @@ package com.github.markozajc.akiwrapper;
3import static com.github.markozajc.akiwrapper.core.entities.Server.GuessType.CHARACTER; 3import static com.github.markozajc.akiwrapper.core.entities.Server.GuessType.CHARACTER;
4import static com.github.markozajc.akiwrapper.core.entities.Server.Language.ENGLISH; 4import static com.github.markozajc.akiwrapper.core.entities.Server.Language.ENGLISH;
5import static com.github.markozajc.akiwrapper.core.utils.Servers.findServers; 5import static com.github.markozajc.akiwrapper.core.utils.Servers.findServers;
6import static java.lang.String.format;
6 7
7import javax.annotation.*; 8import javax.annotation.*;
8 9
@@ -12,19 +13,12 @@ import com.github.markozajc.akiwrapper.core.entities.*;
12import com.github.markozajc.akiwrapper.core.entities.Server.*; 13import com.github.markozajc.akiwrapper.core.entities.Server.*;
13import com.github.markozajc.akiwrapper.core.exceptions.*; 14import com.github.markozajc.akiwrapper.core.exceptions.*;
14import com.github.markozajc.akiwrapper.core.impl.AkiwrapperImpl; 15import com.github.markozajc.akiwrapper.core.impl.AkiwrapperImpl;
15import com.github.markozajc.akiwrapper.core.utils.*; 16import com.github.markozajc.akiwrapper.core.utils.UnirestUtils;
16 17
17import kong.unirest.UnirestInstance; 18import kong.unirest.UnirestInstance;
18 19
19/** 20/**
20 * A class used to build an {@link Akiwrapper} object. It allows you to set various 21 * A class used to build an {@link Akiwrapper} object.
21 * values before building it in a method chaining fashion. Note that
22 * {@link Language}, {@link GuessType}, and {@link Server} configuration are
23 * connected - {@link Language} and {@link GuessType} are used to find a suitable
24 * {@link Server}, but they will only be used if a {@link Server} is not manually
25 * set. It is not recommended to set the {@link Server} manually (unless for
26 * debugging purposes or as some kind of workaround where Akiwrapper's server finder
27 * fails) as Akiwrapper already does its best to find the most suitable one.
28 * 22 *
29 * @author Marko Zajc 23 * @author Marko Zajc
30 */ 24 */
@@ -33,7 +27,6 @@ public class AkiwrapperBuilder {
33 private static final Logger LOG = LoggerFactory.getLogger(AkiwrapperBuilder.class); 27 private static final Logger LOG = LoggerFactory.getLogger(AkiwrapperBuilder.class);
34 28
35 @Nullable private UnirestInstance unirest; 29 @Nullable private UnirestInstance unirest;
36 @Nullable private Server server;
37 private boolean filterProfanity; 30 private boolean filterProfanity;
38 @Nonnull private Language language; 31 @Nonnull private Language language;
39 @Nonnull private GuessType guessType; 32 @Nonnull private GuessType guessType;
@@ -46,27 +39,41 @@ public class AkiwrapperBuilder {
46 /** 39 /**
47 * The default {@link Language} for new {@link Akiwrapper} instances. 40 * The default {@link Language} for new {@link Akiwrapper} instances.
48 */ 41 */
49 @Nonnull public static final Language DEFAULT_LOCALIZATION = ENGLISH; 42 @Nonnull public static final Language DEFAULT_LANGUAGE = ENGLISH;
43
44 /**
45 * The default {@link Language} for new {@link Akiwrapper} instances.
46 *
47 * @deprecated Use {@link #DEFAULT_LANGUAGE} instead
48 */
49 @Deprecated(since = "1.6", forRemoval = true)
50 @Nonnull public static final Language DEFAULT_LOCALIZATION = DEFAULT_LANGUAGE;
50 51
51 /** 52 /**
52 * The default {@link GuessType} for new {@link Akiwrapper} instances. 53 * The default {@link GuessType} for new {@link Akiwrapper} instances.
53 */ 54 */
54 @Nonnull public static final GuessType DEFAULT_GUESS_TYPE = CHARACTER; 55 @Nonnull public static final GuessType DEFAULT_GUESS_TYPE = CHARACTER;
55 56
56 private AkiwrapperBuilder(@Nullable UnirestInstance unirest, @Nullable Server server, boolean filterProfanity, 57 private AkiwrapperBuilder(@Nullable UnirestInstance unirest, boolean filterProfanity, @Nonnull Language language,
57 @Nonnull Language language, @Nonnull GuessType guessType) { 58 @Nonnull GuessType guessType) {
58 this.unirest = unirest; 59 this.unirest = unirest;
59 this.server = server;
60 this.filterProfanity = filterProfanity; 60 this.filterProfanity = filterProfanity;
61 this.language = language; 61 this.language = language;
62 this.guessType = guessType; 62 this.guessType = guessType;
63 } 63 }
64 64
65 /** 65 /**
66 * Creates a new AkiwrapperBuilder object. 66 * Creates a new {@link AkiwrapperBuilder} with the following defaults:
67 * <ul>
68 * <li>profanity filtering is set to {@code false}
69 * ({@link #DEFAULT_FILTER_PROFANITY}),
70 * <li>language is set to {@link Language#ENGLISH} ({@link #DEFAULT_LANGUAGE}),
71 * <li>guess type is set to {@link GuessType#CHARACTER}
72 * ({@link #DEFAULT_GUESS_TYPE}),
73 * </ul>
67 */ 74 */
68 public AkiwrapperBuilder() { 75 public AkiwrapperBuilder() {
69 this(null, null, DEFAULT_FILTER_PROFANITY, DEFAULT_LOCALIZATION, DEFAULT_GUESS_TYPE); 76 this(null, DEFAULT_FILTER_PROFANITY, DEFAULT_LANGUAGE, DEFAULT_GUESS_TYPE);
70 } 77 }
71 78
72 /** 79 /**
@@ -76,7 +83,8 @@ public class AkiwrapperBuilder {
76 * {@link UnirestUtils#configureInstance(UnirestInstance)} before using it with 83 * {@link UnirestUtils#configureInstance(UnirestInstance)} before using it with
77 * Akiwrapper. You will also need to shut it down yourself, or, if you decide to set 84 * Akiwrapper. You will also need to shut it down yourself, or, if you decide to set
78 * or leave this on {$code null}, call {@link UnirestUtils#shutdownInstance()} to 85 * or leave this on {$code null}, call {@link UnirestUtils#shutdownInstance()} to
79 * shut down Akiwrapper's default singleton instance. 86 * shut down Akiwrapper's default singleton instance, otherwise its threads will stay
87 * alive.
80 * 88 *
81 * @param unirest 89 * @param unirest
82 * the {@link UnirestInstance} to be used by Akiwrapper or {$code null} to 90 * the {@link UnirestInstance} to be used by Akiwrapper or {$code null} to
@@ -96,7 +104,7 @@ public class AkiwrapperBuilder {
96 * Returns the {@link UnirestInstance} to be used by the built Akiwrapper instance. 104 * Returns the {@link UnirestInstance} to be used by the built Akiwrapper instance.
97 * If this is {$code null}, {@link UnirestUtils#getInstance()} will be used (which 105 * If this is {$code null}, {@link UnirestUtils#getInstance()} will be used (which
98 * means you will need to shut it down through 106 * means you will need to shut it down through
99 * {@link UnirestUtils#shutdownInstance()}). 107 * {@link UnirestUtils#shutdownInstance()}, otherwise its threads will stay alive).
100 * 108 *
101 * @return {@link UnirestInstance} to be used or {$code null} for 109 * @return {@link UnirestInstance} to be used or {$code null} for
102 * {@link UnirestUtils#getInstance()} 110 * {@link UnirestUtils#getInstance()}
@@ -107,47 +115,15 @@ public class AkiwrapperBuilder {
107 } 115 }
108 116
109 /** 117 /**
110 * Sets the {@link Server} or (recommended) a {@link ServerList}. It is not 118 * Sets the "filter profanity" mode. Keep in mind that explicit {@link Guess}es can
111 * recommended to set the {@link Server} manually (unless for debugging purposes or 119 * still be returned by {@link Akiwrapper#getGuesses()} or
112 * as some kind of workaround where Akiwrapper's server finder fails) as Akiwrapper 120 * {@link Akiwrapper#suggestGuess()}, see {@link Guess#isExplicit()} for more details
113 * already does its best to find the most suitable one. <br> 121 * on why that is.<br>
114 * <b>Caution!</b> Setting the server to a non-null value overwrites the 122 * This is set to {@code false} by default.
115 * {@link Language} and the {@link GuessType} with the given {@link Server}'s values.
116 *
117 * @param server
118 *
119 * @return current instance, used for chaining
120 *
121 * @see #getServer()
122 * @see Servers#findServers(UnirestInstance, Language, GuessType)
123 */
124 @Nonnull
125 public AkiwrapperBuilder setServer(@Nullable Server server) {
126 this.server = server;
127 if (server != null) {
128 this.language = server.getLanguage();
129 this.guessType = server.getGuessType();
130 }
131 return this;
132 }
133
134 /**
135 * Returns the {@link Server} that requests will be sent to. Might also return a
136 * {@link ServerList} (which extends {@link Server}).
137 *
138 * @return server.
139 */
140 @Nullable
141 public Server getServer() {
142 return this.server;
143 }
144
145 /**
146 * Sets the "filter profanity" mode.
147 * 123 *
148 * @param filterProfanity 124 * @param filterProfanity
149 * 125 *
150 * @return current instance, used for chaining 126 * @return current instance, used for chaining.
151 * 127 *
152 * @see #doesFilterProfanity() 128 * @see #doesFilterProfanity()
153 */ 129 */
@@ -159,7 +135,10 @@ public class AkiwrapperBuilder {
159 135
160 /** 136 /**
161 * Returns the profanity filter preference. Profanity filtering is done by Akinator 137 * Returns the profanity filter preference. Profanity filtering is done by Akinator
162 * and not by Akiwrapper. 138 * and not by Akiwrapper. Keep in mind that explicit {@link Guess}es can still be
139 * returned by {@link Akiwrapper#getGuesses()} or {@link Akiwrapper#suggestGuess()},
140 * see {@link Guess#isExplicit()} for more details on why that is.<br>
141 * This is set to {@code false} by default.
163 * 142 *
164 * @return profanity filter preference. 143 * @return profanity filter preference.
165 */ 144 */
@@ -168,28 +147,31 @@ public class AkiwrapperBuilder {
168 } 147 }
169 148
170 /** 149 /**
171 * Sets the {@link Language}.<br> 150 * <b>Note:</b> not all {@link Language}s support all {@link GuessType}s. The
172 * <b>Caution!</b> Setting the {@link Language} will set the {@link Server} to 151 * standard ones seem to be {@link GuessType#ANIMAL}, {@link GuessType#CHARACTER},
173 * {@code null} (meaning it will be automatically selected). 152 * and {@link GuessType#OBJECT}, but you might still face
153 * {@link ServerNotFoundException}s using them or other ones.<br>
154 * <br>
155 * Sets the {@link Language}. The server will return localized {@link Question}s and
156 * {@link Guess}es depending on this preference.<br>
157 * This is set to {@link Language#ENGLISH} by default.
174 * 158 *
175 * @param language 159 * @param language
176 * 160 *
177 * @return current instance, used for chaining 161 * @return current instance, used for chaining.
178 * 162 *
179 * @see #getLanguage() 163 * @see #getLanguage()
180 */ 164 */
181 @Nonnull 165 @Nonnull
182 public AkiwrapperBuilder setLanguage(@Nonnull Language language) { 166 public AkiwrapperBuilder setLanguage(@Nonnull Language language) {
183 this.language = language; 167 this.language = language;
184 this.server = null;
185 return this; 168 return this;
186 } 169 }
187 170
188 /** 171 /**
189 * Returns the {@link Language} preference. {@link Language} impacts what language 172 * Returns the {@link Language}. The server will return localized {@link Question}s
190 * {@link Question}s and {@link Guess}es are in.<br> 173 * and {@link Guess}es depending on this preference.<br>
191 * {@link #getGuessType()} and {@link #getLanguage()} decide what {@link Server} will 174 * This is set to {@link Language#ENGLISH} by default.
192 * be used if it's not set manually.
193 * 175 *
194 * @return language preference. 176 * @return language preference.
195 */ 177 */
@@ -199,20 +181,25 @@ public class AkiwrapperBuilder {
199 } 181 }
200 182
201 /** 183 /**
202 * Sets the {@link GuessType}.<br> 184 * <b>Note:</b> not all {@link Language}s support all {@link GuessType}s. The
203 * <b>Caution!</b> Setting the {@link Language} will set the {@link Server} to 185 * standard ones seem to be {@link GuessType#ANIMAL}, {@link GuessType#CHARACTER},
204 * {@code null} (meaning it will be automatically selected). 186 * and {@link GuessType#OBJECT}, but you might still face
187 * {@link ServerNotFoundException}s using them or other ones.<br>
188 * <br>
189 * Sets the {@link GuessType}. This decides what kind of things the {@link Server}'s
190 * {@link Guess}es will represent. While the name might imply that this affects only
191 * guess content, it also affects {@link Question}s.<br>
192 * This is set to {@link GuessType#CHARACTER} by default.
205 * 193 *
206 * @param guessType 194 * @param guessType
207 * 195 *
208 * @return current instance, used for chaining 196 * @return current instance, used for chaining.
209 * 197 *
210 * @see #getLanguage() 198 * @see #getLanguage()
211 */ 199 */
212 @Nonnull 200 @Nonnull
213 public AkiwrapperBuilder setGuessType(@Nonnull GuessType guessType) { 201 public AkiwrapperBuilder setGuessType(@Nonnull GuessType guessType) {
214 this.guessType = guessType; 202 this.guessType = guessType;
215 this.server = null;
216 return this; 203 return this;
217 } 204 }
218 205
@@ -220,7 +207,8 @@ public class AkiwrapperBuilder {
220 * Returns the {@link GuessType} preference. {@link GuessType} impacts what kind of 207 * Returns the {@link GuessType} preference. {@link GuessType} impacts what kind of
221 * subject {@link Question}s and {@link Guess}es are about.<br> 208 * subject {@link Question}s and {@link Guess}es are about.<br>
222 * {@link #getGuessType()} and {@link #getLanguage()} decide what {@link Server} will 209 * {@link #getGuessType()} and {@link #getLanguage()} decide what {@link Server} will
223 * be used if it's not set manually. 210 * be used if it's not set manually.<br>
211 * This is set to {@link GuessType#CHARACTER} by default.
224 * 212 *
225 * @return guess type preference. 213 * @return guess type preference.
226 */ 214 */
@@ -234,42 +222,37 @@ public class AkiwrapperBuilder {
234 * {@link UnirestInstance} was set (with 222 * {@link UnirestInstance} was set (with
235 * {@link #setUnirestInstance(UnirestInstance)}), a singleton instance will be 223 * {@link #setUnirestInstance(UnirestInstance)}), a singleton instance will be
236 * acquired from {@link UnirestUtils#getInstance()}. This instance must be shut down 224 * acquired from {@link UnirestUtils#getInstance()}. This instance must be shut down
237 * after you're done using Akiwrapper with {@link UnirestUtils#shutdownInstance()}. 225 * after you're done using Akiwrapper with {@link UnirestUtils#shutdownInstance()},
238 * If no server was set (with {@link #setServer(Server)}), Akiwrapper will find one 226 * otherwise its threads will stay alive.
239 * based on {@link #getLanguage()} and {@link #getGuessType()} for you.
240 * 227 *
241 * @return a new {@link Akiwrapper} instance that will use all set preferences 228 * @return a new {@link Akiwrapper} instance.
242 * 229 *
243 * @throws ServerNotFoundException 230 * @throws ServerNotFoundException
244 * if no server with that {@link Language} and {@link GuessType} is 231 * if no server with that {@link Language} and {@link GuessType} is
245 * available. 232 * available.
246 */ 233 */
247 @Nonnull 234 @Nonnull
248 @SuppressWarnings("resource") 235 @SuppressWarnings({ "resource", "null" })
249 public Akiwrapper build() throws ServerNotFoundException { 236 public Akiwrapper build() throws ServerNotFoundException {
250 UnirestInstance unirest = this.unirest != null ? this.unirest : UnirestUtils.getInstance(); 237 var unirest = this.unirest != null ? this.unirest : UnirestUtils.getInstance();
251 238
252 var server = this.server != null ? this.server : findServers(unirest, this.getLanguage(), this.getGuessType()); 239 var servers = findServers(unirest, this.getLanguage(), this.getGuessType());
253 if (server instanceof ServerList) { 240 if (servers.isEmpty())
254 ServerList serverList = (ServerList) server; 241 throw new ServerNotFoundException(format("No servers exist for %s - %s", this.language, this.guessType));
255 int count = serverList.getRemainingSize() + 1;
256 do {
257 LOG.debug("Using server {} out of {} from the list.", count - serverList.getRemainingSize(), count);
258 try {
259 return new AkiwrapperImpl(unirest, server, this.filterProfanity);
260 242
261 } catch (ServerUnavailableException e) { 243 for (var server : servers) {
262 LOG.debug("Server seems to be down."); 244 try {
245 var api = new AkiwrapperImpl(unirest, server, this.filterProfanity);
246 api.createSession();
247 return api;
263 248
264 } catch (RuntimeException e) { 249 } catch (ServerStatusException e) {
265 LOG.warn("Failed to construct an instance, trying the next available server", e); 250 LOG.debug("Failed to construct an instance, trying the next available server", e);
266 } 251 }
267 } while (serverList.next());
268 throw new ServerUnavailableException("KO - NO SERVER AVAILABLE");
269 } else {
270 LOG.debug("Given Server is not a ServerList, only attempting to build once.");
271 return new AkiwrapperImpl(unirest, server, this.filterProfanity);
272 } 252 }
253
254 throw new ServerNotFoundException(format("Servers exist for %s - %s, but none of them is usable", this.language,
255 this.guessType));
273 } 256 }
274 257
275} 258}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/Route.java b/src/main/java/com/github/markozajc/akiwrapper/core/Route.java
deleted file mode 100644
index 309e297..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/Route.java
+++ /dev/null
@@ -1,230 +0,0 @@
1package com.github.markozajc.akiwrapper.core;
2
3import static com.github.markozajc.akiwrapper.core.entities.Status.Level.ERROR;
4import static com.github.markozajc.akiwrapper.core.entities.impl.immutable.ApiKey.accquireApiKey;
5import static java.lang.String.format;
6import static java.lang.System.currentTimeMillis;
7import static java.nio.charset.StandardCharsets.UTF_8;
8import static java.util.regex.Pattern.compile;
9
10import java.net.URLEncoder;
11import java.util.regex.*;
12
13import javax.annotation.*;
14
15import org.json.*;
16import org.slf4j.*;
17
18import com.github.markozajc.akiwrapper.core.entities.Status;
19import com.github.markozajc.akiwrapper.core.entities.impl.immutable.StatusImpl;
20import com.github.markozajc.akiwrapper.core.exceptions.*;
21import com.github.markozajc.akiwrapper.core.impl.AkiwrapperImpl.Session;
22
23import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
24import kong.unirest.UnirestInstance;
25
26public final class Route {
27
28 public static final String BASE_AKINATOR_URL = "https://en.akinator.com";
29 private static final String SERVER_DOWN_STATUS_MESSAGE = "server down";
30 private static final Pattern FILTER_ARGUMENT_PATTERN = compile("\\{FILTER\\}");
31
32 /**
33 * Whether to run status checks on {@link Request#getJSON()} by default. Setting this
34 * to false may result in unpredicted exceptions! <b>You usually don't need to alter
35 * this value</b>
36 */
37 @SuppressFBWarnings("MS_SHOULD_BE_FINAL") public static boolean defaultRunChecks = true; // NOSONAR
38
39 /**
40 * Creates a new session for further gameplay.<br>
41 * <b>Caution!</b> Because this endpoint uses a static hostname, you <u>must</u> pass
42 * an empty string to {@code baseUrl} of
43 * {@link Route#createRequest(UnirestInstance, String, boolean, Object...)} <br>
44 * Parameters:
45 * <ol>
46 * <li>Current time in milliseconds</li>
47 * <li>API server's URL</li>
48 * </ol>
49 */
50 public static final Route NEW_SESSION =
51 new Route(1,
52 BASE_AKINATOR_URL +
53 "/new_session?partner=1&player=website-desktop&constraint=ETAT%%3C%%3E%%27AV%%27&{API_KEY}" +
54 "&soft_constraint={FILTER}&question_filter={FILTER}&_=%s&urlApiWs=%s",
55 "ETAT=%%27EN%%27", "cat=1");
56
57 /**
58 * Answers a question. Parameters:
59 * <ol>
60 * <li>Current step</li>
61 * <li>Answer's ID</li>
62 * </ol>
63 */
64 public static final Route ANSWER = new Route(2, "/answer?step=%s&answer=%s", "&question_filter=cat=1");
65
66 /**
67 * Cancels (undoes) an answer. Parameters:
68 * <ol>
69 * <li>Current step</li>
70 * </ol>
71 */
72 public static final Route CANCEL_ANSWER =
73 new Route(1, "/cancel_answer?step=%s&answer=-1", "&question_filter=cat=1");
74
75 /**
76 * Lists all available guesses. Parameters:
77 * <ol>
78 * <li>Current step</li>
79 * </ol>
80 */
81 public static final Route LIST = new Route(1, "/list?mode_question=0&step=%s");
82
83 @Nonnull private final String path;
84 @Nonnull private final String[] filterArguments;
85
86 private final int parametersQuantity;
87
88 private Route(int parameters, @Nonnull String path) {
89 this(parameters, path, new String[0]);
90 }
91
92 private Route(int parameters, @Nonnull String path, @Nonnull String... filterArguments) {
93 this.path = path;
94 this.filterArguments = filterArguments.clone();
95 this.parametersQuantity = parameters;
96 }
97
98 /**
99 * @deprecated Use
100 * Route#createRequest(UnirestInstance,String,boolean,Session,String...)
101 * instead
102 */
103 @Nonnull
104 @Deprecated(since = "1.5.2", forRemoval = true)
105 public Request getRequest(@Nonnull UnirestInstance unirest, @Nonnull String baseUrl, boolean filterProfanity,
106 @Nullable Session token, @Nonnull String... parameters) {
107 return createRequest(unirest, baseUrl, filterProfanity, token, (Object[]) parameters);
108 }
109
110 @Nonnull
111 public Request createRequest(@Nonnull UnirestInstance unirest, @Nonnull String baseUrl, boolean filterProfanity,
112 @Nullable Session token, @Nonnull Object... parameters) {
113 if (parameters.length < this.parametersQuantity)
114 throw new IllegalArgumentException("Insufficient parameters; Expected " + this.parametersQuantity +
115 ", got " +
116 parameters.length);
117
118 String[] encodedParams = new String[parameters.length];
119 for (int i = 0; i < parameters.length; i++) {
120 encodedParams[i] = URLEncoder.encode(parameters[i].toString(), UTF_8);
121 }
122
123 String formattedPath = this.path;
124
125 Matcher matcher = FILTER_ARGUMENT_PATTERN.matcher(formattedPath);
126 StringBuffer sb = new StringBuffer();
127 for (int i = 0; matcher.find(); i++) {
128 matcher.appendReplacement(sb, filterProfanity ? this.filterArguments[i] : "");
129 }
130 matcher.appendTail(sb);
131 formattedPath = sb.toString();
132 formattedPath = formattedPath.replace("{API_KEY}", accquireApiKey(unirest).querystring().replace("%", "%%"));
133 formattedPath = format(formattedPath, (Object[]) encodedParams);
134
135 String jQueryCallback = "jQuery331023608747682107778_" + currentTimeMillis();
136 formattedPath = formattedPath + "&callback=" + jQueryCallback;
137
138 if (token != null)
139 formattedPath = formattedPath + token.querystring();
140
141 return new Request(unirest, baseUrl + formattedPath, jQueryCallback.length());
142 }
143
144 /**
145 * @deprecated Use {@link #createRequest(UnirestInstance,String,boolean,Object...)}
146 * instead
147 */
148 @Nonnull
149 @Deprecated(since = "1.5.2", forRemoval = true)
150 public Request getRequest(@Nonnull UnirestInstance unirest, @Nonnull String baseUrl, boolean filterProfanity,
151 @Nonnull String... parameters) {
152 return createRequest(unirest, baseUrl, filterProfanity, (Object[]) parameters);
153 }
154
155 @Nonnull
156 public Request createRequest(@Nonnull UnirestInstance unirest, @Nonnull String baseUrl, boolean filterProfanity,
157 @Nonnull Object... parameters) {
158 return this.createRequest(unirest, baseUrl, filterProfanity, null, parameters);
159 }
160
161 @Nonnull
162 public String getPath() {
163 return this.path;
164 }
165
166 /**
167 * @deprecated Use {@link #getParameterCount()} instead
168 */
169 @Deprecated(since = "1.5.2", forRemoval = true)
170 public int getParametersQuantity() {
171 return getParameterCount();
172 }
173
174 public int getParameterCount() {
175 return this.parametersQuantity;
176 }
177
178 public static class Request {
179
180 private static final Logger LOG = LoggerFactory.getLogger(Route.Request.class);
181
182 @Nonnull private final UnirestInstance unirest;
183 @Nonnull private final String url;
184 private final int jQueryCallbackLength;
185
186 Request(@Nonnull UnirestInstance unirest, @Nonnull String url, int jQueryCallbackLength) {
187 this.unirest = unirest;
188 this.jQueryCallbackLength = jQueryCallbackLength;
189 this.url = url;
190 }
191
192 @Nonnull
193 public JSONObject getJSON() {
194 return getJSON(defaultRunChecks);
195 }
196
197 @Nonnull
198 public JSONObject getJSON(boolean runChecks) {
199 var response = this.unirest.get(this.url).asString().getBody();
200 response = response.substring(this.jQueryCallbackLength + 1, response.length() - 1);
201
202 LOG.trace("--> {}", this.url);
203 LOG.trace("<-- {}", response);
204
205 try {
206 var result = new JSONObject(response);
207
208 if (runChecks)
209 ensureSuccessful(result);
210
211 return result;
212
213 } catch (JSONException e) {
214 LOG.error("Failed to parse JSON from the API server", e);
215 throw new StatusException(new StatusImpl("AW-KO - COULDN'T PARSE JSON"));
216 }
217 }
218
219 static void ensureSuccessful(@Nonnull JSONObject response) {
220 Status completion = new StatusImpl(response);
221 if (completion.getLevel() == ERROR) {
222 if (SERVER_DOWN_STATUS_MESSAGE.equalsIgnoreCase(completion.getReason()))
223 throw new ServerUnavailableException(completion);
224
225 throw new StatusException(completion);
226 }
227 }
228 }
229
230}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Guess.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Guess.java
index bc3eb3f..f3533e1 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Guess.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Guess.java
@@ -4,7 +4,7 @@ import java.net.URL;
4 4
5import javax.annotation.*; 5import javax.annotation.*;
6 6
7import com.github.markozajc.akiwrapper.AkiwrapperBuilder; 7import com.github.markozajc.akiwrapper.*;
8import com.github.markozajc.akiwrapper.core.entities.Server.GuessType; 8import com.github.markozajc.akiwrapper.core.entities.Server.GuessType;
9 9
10/** 10/**
@@ -32,7 +32,8 @@ public interface Guess extends Identifiable, Comparable<Guess> {
32 32
33 /** 33 /**
34 * Returns the approximate probability that the answer is the one user has in mind 34 * Returns the approximate probability that the answer is the one user has in mind
35 * (as a double). 35 * (as a double).<br>
36 * The value ranges between 0 and 1.
36 * 37 *
37 * @return probability that this is the right answer. 38 * @return probability that this is the right answer.
38 */ 39 */
@@ -57,4 +58,15 @@ public interface Guess extends Identifiable, Comparable<Guess> {
57 @Nullable 58 @Nullable
58 URL getImage(); 59 URL getImage();
59 60
61 /**
62 * <b>Important:</b> Akinator for some reason flags certain perfectly SFW guesses as
63 * explicit. Akiwrapper's code for this reason doesn't rely on this parameter -
64 * {@link Akiwrapper#suggestGuess} will return guesses marked as explicit even when
65 * profanity filtering is on. Using this value to filter content is optional but not
66 * advised.
67 *
68 * @return whether or not the guess is explicit (often incorrect).
69 */
70 boolean isExplicit();
71
60} 72}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Identifiable.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Identifiable.java
index 8e9b9fd..fb0105f 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Identifiable.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Identifiable.java
@@ -1,6 +1,6 @@
1package com.github.markozajc.akiwrapper.core.entities; 1package com.github.markozajc.akiwrapper.core.entities;
2 2
3import javax.annotation.*; 3import javax.annotation.Nonnull;
4 4
5/** 5/**
6 * A representation of an object with a numeric identifier. Some objects in the API 6 * A representation of an object with a numeric identifier. Some objects in the API
@@ -21,7 +21,6 @@ public interface Identifiable {
21 * 21 *
22 * @see #getId() 22 * @see #getId()
23 */ 23 */
24 @Nonnegative
25 default long getIdLong() { 24 default long getIdLong() {
26 return Long.parseLong(getId()); 25 return Long.parseLong(getId());
27 } 26 }
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Question.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Question.java
index 2095d34..5ae4111 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Question.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Question.java
@@ -1,6 +1,6 @@
1package com.github.markozajc.akiwrapper.core.entities; 1package com.github.markozajc.akiwrapper.core.entities;
2 2
3import javax.annotation.*; 3import javax.annotation.Nonnull;
4 4
5import com.github.markozajc.akiwrapper.Akiwrapper.Answer; 5import com.github.markozajc.akiwrapper.Akiwrapper.Answer;
6import com.github.markozajc.akiwrapper.AkiwrapperBuilder; 6import com.github.markozajc.akiwrapper.AkiwrapperBuilder;
@@ -15,13 +15,12 @@ import com.github.markozajc.akiwrapper.AkiwrapperBuilder;
15public interface Question extends Identifiable { 15public interface Question extends Identifiable {
16 16
17 /** 17 /**
18 * Current completion percentage (as a double). Higher means that Akinator is closer 18 * Current completion percentage (as a double). Higher means that Akinator believes
19 * to the correct answer (or the game is close to the end?). Not sure if that's the 19 * to be closer to the correct answer.<br>
20 * case, but I believe this can go down as well. 20 * The value ranges between 0 and 100.
21 * 21 *
22 * @return completion percentage. 22 * @return completion percentage.
23 */ 23 */
24 @Nonnegative
25 double getProgression(); 24 double getProgression();
26 25
27 /** 26 /**
@@ -30,37 +29,20 @@ public interface Question extends Identifiable {
30 * 29 *
31 * @return current step. 30 * @return current step.
32 */ 31 */
33 @Nonnegative
34 int getStep(); 32 int getStep();
35 33
36 /** 34 /**
37 * Returns the gained accuracy from the last question (as a double). I'm not exactly 35 * Returns the gained accuracy from the last question (as a double). I'm not exactly
38 * sure what this does, but I'm pretty sure that it's meant to describe how well 36 * sure what this does.
39 * Akinator can pinpoint the answer after a question was with the answered question.
40 * 37 *
41 * @return accuracy gain. 38 * @return infogain.
42 *
43 * @deprecated Use {@link #getInfogain()} instead
44 */
45 @Nonnegative
46 @Deprecated(since = "1.5.2", forRemoval = true)
47 default double getGain() {
48 return getInfogain();
49 }
50
51 /**
52 * Returns the gained accuracy from the last question (as a double). I'm not exactly
53 * sure what this does, but I'm pretty sure that it's meant to describe how well
54 * Akinator can pinpoint the answer after a question was with the answered question.
55 *
56 * @return accuracy gain.
57 */ 39 */
58 @Nonnegative
59 double getInfogain(); 40 double getInfogain();
60 41
61 /** 42 /**
62 * Returns the actual question that the user must answer. This is provided in the 43 * Returns the question content that should be displayed to the user. This localized
63 * language that was specified using the {@link AkiwrapperBuilder}. 44 * to the language specified in
45 * {@link AkiwrapperBuilder#setLanguage(Server.Language)}.
64 * 46 *
65 * @return question. 47 * @return question.
66 */ 48 */
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Server.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Server.java
index 7c1f104..3005590 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Server.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Server.java
@@ -2,23 +2,21 @@ package com.github.markozajc.akiwrapper.core.entities;
2 2
3import javax.annotation.*; 3import javax.annotation.*;
4 4
5import com.github.markozajc.akiwrapper.core.Route;
6import com.github.markozajc.akiwrapper.core.exceptions.ServerNotFoundException;
7import com.github.markozajc.akiwrapper.core.utils.Servers; 5import com.github.markozajc.akiwrapper.core.utils.Servers;
8 6
9import kong.unirest.UnirestInstance; 7import kong.unirest.UnirestInstance;
10 8
11/** 9/**
12 * A representation of an API server. All requests (except for 10 * A representation of an API server. Each server has a predefined {@link Language}
13 * {@link Route#NEW_SESSION} are passed to an such server. Each server has a 11 * and {@link GuessType}.
14 * predefined {@link Language} and {@link GuessType}.
15 * 12 *
16 * @author Marko Zajc 13 * @author Marko Zajc
17 */ 14 */
18public interface Server { 15public interface Server {
19 16
20 /** 17 /**
21 * A language specific to a {@link Server}. 18 * A language specific to a {@link Server}. The server will return localized
19 * {@link Question}s and {@link Guess}es depending on its language.
22 * 20 *
23 * @author Marko Zajc 21 * @author Marko Zajc
24 */ 22 */
@@ -62,15 +60,10 @@ public interface Server {
62 } 60 }
63 61
64 /** 62 /**
65 * Server's guess type (referred to as the "subject" in the API). Decides what kind 63 * Represents the server's guess type (also referred to as the subject or theme).
66 * of things server's guesses will represent. While the name might suggest that this 64 * This decides what kind of things the {@link Server}'s {@link Guess}es will
67 * affects only {@link Guess}es, it will also inevitably also impact 65 * represent. While the name might imply that this affects only guess content, it
68 * {@link Question}s (it wouldn't make sense to ask the "Is it still alive" question 66 * also affects {@link Question}s.<br>
69 * for a place). <br>
70 * <b>Caution!</b> Not all {@link Language}s support all {@link GuessType}s. The
71 * standard ones seem to be {@link #ANIMAL}, {@link #CHARACTER}, and {@link #OBJECT},
72 * but you might still face {@link ServerNotFoundException}s using them or other
73 * ones.
74 * 67 *
75 * @author Marko Zajc 68 * @author Marko Zajc
76 */ 69 */
@@ -104,7 +97,7 @@ public interface Server {
104 } 97 }
105 98
106 /** 99 /**
107 * Server's host name. As the people behind Akinator tend to mix up their servers and 100 * Server's base URL. As the people behind Akinator tend to mix up their servers and
108 * the API in general, this should only fetch values from the server-listing endpoint 101 * the API in general, this should only fetch values from the server-listing endpoint
109 * (which is done in {@link Servers#getServers(UnirestInstance)}. The host is a valid 102 * (which is done in {@link Servers#getServers(UnirestInstance)}. The host is a valid
110 * URL, complete with the path to the endpoint.<br> 103 * URL, complete with the path to the endpoint.<br>
@@ -117,7 +110,7 @@ public interface Server {
117 110
118 /** 111 /**
119 * Returns this {@link Server}'s {@link Language}. The server will return localized 112 * Returns this {@link Server}'s {@link Language}. The server will return localized
120 * {@link Question}s and {@link Guess}es depending on its {@link Language}. 113 * {@link Question}s and {@link Guess}es depending on its language.
121 * 114 *
122 * @return server's language. 115 * @return server's language.
123 */ 116 */
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/ServerList.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/ServerList.java
deleted file mode 100644
index c3a4552..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/ServerList.java
+++ /dev/null
@@ -1,72 +0,0 @@
1package com.github.markozajc.akiwrapper.core.entities;
2
3import java.sql.ResultSet;
4import java.util.List;
5import java.util.regex.Matcher;
6
7import javax.annotation.Nonnull;
8
9import com.github.markozajc.akiwrapper.core.exceptions.ServerUnavailableException;
10
11/**
12 * A representation of multiple {@link Server}s at once. While a single
13 * {@link Server} instance is <i>usually</i> enough, it might go down. Akiwrapper
14 * will in that case automatically find the next available one in a given
15 * {@link ServerList}, until it hits into one that is available. If none are
16 * available, it will fail with a {@link ServerUnavailableException}.<br>
17 * Note: being available means succeeding to create a new session. Failure can mean
18 * that either:
19 * <ul>
20 * <li>the server is down, which is very unlikely, but is the best case scenario
21 * since no code needs to be fixed,
22 * <li>or that Akinator has changed something and Akiwrapper doesn't yet support the
23 * change, causing failure on a new session.
24 * </ul>
25 * The {@link ServerList} acts similarly to a {@link Matcher} or a {@link ResultSet}
26 * - you can use it the same way as you would a regular {@link Server}, and if it is
27 * down or does not work, you can call {@link #next()} to seamlessly switch to the
28 * next instance.
29 *
30 * @author Marko Zajc
31 */
32public interface ServerList extends Server {
33
34 /**
35 * Iterates to the next {@link Server} in the queue. Returns of all methods will be
36 * replaced with those of the next server. The return value of this method indicates
37 * success - {@code false} means that there are no more servers in the queue (the
38 * server is not changed), {@code true} means that the server has been changed.
39 *
40 * @return success indicator.
41 */
42 boolean next();
43
44 /**
45 * Returns the remaining {@link Server} in the queue plus the current {@link Server}.
46 * Note that calling {@link #next()} removes the cycles to the next server and
47 * removes the current one, meaning that it will not be included in this list.
48 *
49 * @return all servers.
50 */
51 @Nonnull
52 List<Server> getServers();
53
54 /**
55 * Returns the amount of remaining {@link Server}s in the queue.
56 *
57 * @return amount of remaining servers.
58 */
59 int getRemainingSize();
60
61 /**
62 * Checks whether or not this {@link ServerList} has another {@link Server} to
63 * iterate to. This performs the same check as {@link #next()}, but doesn't change
64 * the state and doesn't actually iterate to the next server.
65 *
66 * @return whether there is another server to iterate to.
67 */
68 default boolean hasNext() {
69 return getRemainingSize() > 0;
70 }
71
72}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Status.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Status.java
index 650fedc..c6403e3 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/Status.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/Status.java
@@ -4,6 +4,8 @@ import java.io.Serializable;
4 4
5import javax.annotation.*; 5import javax.annotation.*;
6 6
7import com.github.markozajc.akiwrapper.core.exceptions.ServerStatusException;
8
7/** 9/**
8 * An interface used to represent API call's completion status. 10 * An interface used to represent API call's completion status.
9 * 11 *
@@ -32,14 +34,6 @@ public interface Status extends Serializable {
32 ERROR("KO"), 34 ERROR("KO"),
33 35
34 /** 36 /**
35 * The action might have completed completed, but an error has occurred on
36 * Akiwrapper's side. This status is never actually returned by the API, but is made
37 * up by Akiwrapper internally to indicate some errors caused by invalid or
38 * unexpected API responses.
39 */
40 AKIWRAPPER_ERROR("AW-KO"),
41
42 /**
43 * Unknown status (should not ever occur under normal circumstances), indicates that 37 * Unknown status (should not ever occur under normal circumstances), indicates that
44 * the status level doesn't match any of the known ones. 38 * the status level doesn't match any of the known ones.
45 */ 39 */
@@ -60,6 +54,7 @@ public interface Status extends Serializable {
60 } 54 }
61 55
62 @Nonnull 56 @Nonnull
57 @SuppressWarnings("javadoc") // internal impl
63 public static Level fromString(@Nonnull String completion) { 58 public static Level fromString(@Nonnull String completion) {
64 for (Level iteratedLevel : Level.values()) 59 for (Level iteratedLevel : Level.values())
65 if (completion.toUpperCase().startsWith(iteratedLevel.toString())) 60 if (completion.toUpperCase().startsWith(iteratedLevel.toString()))
@@ -71,6 +66,45 @@ public interface Status extends Serializable {
71 } 66 }
72 67
73 /** 68 /**
69 * A less cryptic form of the status message, which helps you distinguish between a
70 * usage error (a problem with your code), a library error (a problem with Akiwrapper
71 * that should be reported), a server error (a problem with Akinator's servers that
72 * only they can fix), or an unproblematic status.
73 */
74 public enum Reason {
75
76 /**
77 * <b>Note:</b> This {@link Reason} should generally not find its way into a
78 * {@link ServerStatusException}, please open an issue if it ever does.<br>
79 * <br>
80 * The status is non-erroneous.
81 */
82 OK,
83 /**
84 * <b>Note:</b> This {@link Reason} should generally not find its way into a
85 * {@link ServerStatusException}, please open an issue if it ever does.<br>
86 * <br>
87 * The status is non-erroneous and the questions have been exhausted.
88 */
89 QUESTIONS_EXHAUSTED,
90 /**
91 * The status is erroneous and likely caused by a bug in the library. Please open an
92 * issue if this occurs.
93 */
94 LIBRARY_FAILURE,
95 /**
96 * The status is erroneous and likely caused by a problem with Akinator's servers.
97 * Please only open an issue if this occurs consistently for a period of time.
98 */
99 SERVER_FAILURE,
100 /**
101 * The reason is unknown. Refer to the status message and level for more details.
102 */
103 UNKNOWN
104
105 }
106
107 /**
74 * Returns the level of this status. Status level indicates severity of the status. 108 * Returns the level of this status. Status level indicates severity of the status.
75 * 109 *
76 * @return status level 110 * @return status level
@@ -78,13 +112,27 @@ public interface Status extends Serializable {
78 Level getLevel(); 112 Level getLevel();
79 113
80 /** 114 /**
81 * Returns the status reason or {@code null} if it was not specified. Note that the 115 * Returns the status message or {@code null} if it was not specified. Note that the
82 * status reason is usually pretty cryptic and won't mean much to regular users or 116 * status message is usually pretty cryptic and won't mean much to regular users or
83 * anyone not experienced with the Akinator API. 117 * anyone not experienced with the Akinator API. If you need something something more
118 * tangible, use {@link #getReason()}.
84 * 119 *
85 * @return status reason 120 * @return status message
86 */ 121 */
87 @Nullable 122 @Nullable
88 String getReason(); 123 String getMessage();
124
125 /**
126 * Returns the status reason, which is picked from a list of predefined values or
127 * {@link Reason#UNKNOWN} if it's meaning/significance are unknown. This generally
128 * helps distinguish between a usage error (a problem with your code), a library
129 * error (a problem with Akiwrapper that should be reported), a server error (a
130 * problem with Akinator's servers that only they can fix), or an unproblematic
131 * status.
132 *
133 * @return status {@link Reason}
134 */
135 @Nonnull
136 Reason getReason();
89 137
90} 138}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/GuessImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/GuessImpl.java
index f2afa77..5e2080c 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/GuessImpl.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/GuessImpl.java
@@ -1,6 +1,6 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable; 1package com.github.markozajc.akiwrapper.core.entities.impl;
2 2
3import static com.github.markozajc.akiwrapper.core.utils.JSONUtils.getDouble; 3import static com.github.markozajc.akiwrapper.core.utils.JSONUtils.*;
4import static java.lang.Double.compare; 4import static java.lang.Double.compare;
5 5
6import java.net.*; 6import java.net.*;
@@ -11,27 +11,30 @@ import org.json.JSONObject;
11 11
12import com.github.markozajc.akiwrapper.core.entities.Guess; 12import com.github.markozajc.akiwrapper.core.entities.Guess;
13 13
14@SuppressWarnings("javadoc") // internal impl
14public class GuessImpl implements Guess { 15public class GuessImpl implements Guess {
15 16
16 @Nonnull private final String id; 17 @Nonnull private final String id;
17 @Nonnull private final String name; 18 @Nonnull private final String name;
18 @Nullable private final String description; 19 @Nullable private final String description;
19 @Nullable private final URL image; 20 @Nullable private final URL image;
20 @Nonnegative private final double probability; 21 private final double probability;
22 private final boolean explicit;
21 23
22 public GuessImpl(@Nonnull String id, @Nonnull String name, @Nullable String description, @Nullable URL image, 24 GuessImpl(@Nonnull String id, @Nonnull String name, @Nullable String description, @Nullable URL image,
23 @Nonnegative double probability) { 25 @Nonnegative double probability, boolean explicit) {
24 this.id = id; 26 this.id = id;
25 this.name = name; 27 this.name = name;
26 this.description = description; 28 this.description = description;
27 this.image = image; 29 this.image = image;
28 this.probability = probability; 30 this.probability = probability;
31 this.explicit = explicit;
29 } 32 }
30 33
31 @SuppressWarnings("null") 34 @SuppressWarnings("null")
32 public static GuessImpl from(@Nonnull JSONObject json) { 35 public static GuessImpl fromJson(@Nonnull JSONObject json) {
33 return new GuessImpl(json.getString("id"), json.getString("name"), getDescription(json), getImage(json), 36 return new GuessImpl(json.getString("id"), json.getString("name"), getDescription(json), getImage(json),
34 getDouble(json, "proba").orElseThrow()); 37 getDouble(json, "proba").orElseThrow(), getInteger(json, "corrupt").orElseThrow() == 1);
35 } 38 }
36 39
37 @Nullable 40 @Nullable
@@ -81,4 +84,9 @@ public class GuessImpl implements Guess {
81 return compare(o.getProbability(), this.probability); 84 return compare(o.getProbability(), this.probability);
82 } 85 }
83 86
87 @Override
88 public boolean isExplicit() {
89 return this.explicit;
90 }
91
84} 92}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/QuestionImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/QuestionImpl.java
new file mode 100644
index 0000000..f96ed0c
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/QuestionImpl.java
@@ -0,0 +1,61 @@
1package com.github.markozajc.akiwrapper.core.entities.impl;
2
3import static com.github.markozajc.akiwrapper.core.utils.JSONUtils.*;
4
5import javax.annotation.*;
6
7import org.json.JSONObject;
8
9import com.github.markozajc.akiwrapper.core.entities.Question;
10
11@SuppressWarnings("javadoc") // internal impl
12public class QuestionImpl implements Question {
13
14 @Nonnull private final String id;
15 @Nonnull private final String question;
16 private final int step;
17 private final double gain;
18 private final double progression;
19
20 private QuestionImpl(@Nonnull String id, @Nonnull String question, @Nonnegative int step, @Nonnegative double gain,
21 @Nonnegative double progression) {
22 this.id = id;
23 this.question = question;
24 this.step = step;
25 this.gain = gain;
26 this.progression = progression;
27 }
28
29 @SuppressWarnings("null")
30 public static QuestionImpl fromJson(@Nonnull JSONObject json) {
31 return new QuestionImpl(json.getString("questionid"), json.getString("question"),
32 getInteger(json, "step").orElseThrow(), getDouble(json, "infogain").orElseThrow(),
33 getDouble(json, "progression").orElseThrow());
34 }
35
36 @Override
37 public double getProgression() {
38 return this.progression;
39 }
40
41 @Override
42 public int getStep() {
43 return this.step;
44 }
45
46 @Override
47 public double getInfogain() {
48 return this.gain;
49 }
50
51 @Override
52 public String getQuestion() {
53 return this.question;
54 }
55
56 @Override
57 public String getId() {
58 return this.id;
59 }
60
61}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/ServerImpl.java
index 7af6db8..8d388f0 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerImpl.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/ServerImpl.java
@@ -1,5 +1,8 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable; 1package com.github.markozajc.akiwrapper.core.entities.impl;
2 2
3import static java.nio.charset.StandardCharsets.UTF_8;
4
5import java.net.URLEncoder;
3import java.util.List; 6import java.util.List;
4import java.util.stream.Collectors; 7import java.util.stream.Collectors;
5 8
@@ -8,6 +11,7 @@ import javax.annotation.Nonnull;
8import com.github.markozajc.akiwrapper.core.entities.Server; 11import com.github.markozajc.akiwrapper.core.entities.Server;
9import com.jcabi.xml.XML; 12import com.jcabi.xml.XML;
10 13
14@SuppressWarnings("javadoc") // internal impl
11public class ServerImpl implements Server { 15public class ServerImpl implements Server {
12 16
13 private static final String LANGUAGE_ID_XPATH = "LANGUAGE/LANG_ID/text()"; // NOSONAR not a URL 17 private static final String LANGUAGE_ID_XPATH = "LANGUAGE/LANG_ID/text()"; // NOSONAR not a URL
@@ -57,4 +61,9 @@ public class ServerImpl implements Server {
57 return this.url; 61 return this.url;
58 } 62 }
59 63
64 @Nonnull
65 public String asUrlApiWs() {
66 return "urlApiWs=" + URLEncoder.encode(this.url, UTF_8);
67 }
68
60} 69}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/StatusImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/StatusImpl.java
new file mode 100644
index 0000000..15fe5a0
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/StatusImpl.java
@@ -0,0 +1,97 @@
1package com.github.markozajc.akiwrapper.core.entities.impl;
2
3import static com.github.markozajc.akiwrapper.core.entities.Status.Reason.UNKNOWN;
4
5import javax.annotation.*;
6
7import org.json.JSONObject;
8
9import com.github.markozajc.akiwrapper.core.entities.Status;
10
11@SuppressWarnings("javadoc") // internal impl
12public class StatusImpl implements Status {
13
14 private static final String DIVIDER = " - ";
15
16 @Nonnull private final Level level;
17 @Nullable private final String message;
18 @Nonnull private final Reason reason;
19
20 public static StatusImpl fromCompletion(@Nonnull String completion) {
21 var level = Level.fromString(completion);
22 var message = determineMessage(completion);
23 var reason = resolveReason(level, message);
24 return new StatusImpl(level, message, reason);
25 }
26
27 @SuppressWarnings("null")
28 public static StatusImpl fromJson(@Nonnull JSONObject json) {
29 return fromCompletion(json.getString("completion"));
30 }
31
32 private StatusImpl(@Nonnull Level level, @Nullable String message, @Nonnull Reason reason) {
33 this.level = level;
34 this.message = message;
35 this.reason = reason;
36 }
37
38 @Nullable
39 private static String determineMessage(@Nonnull String completion) {
40 int reasonSplitIndex = completion.indexOf(DIVIDER);
41 if (reasonSplitIndex != -1)
42 return completion.substring(reasonSplitIndex + DIVIDER.length());
43 return null;
44 }
45
46 @Override
47 public Level getLevel() {
48 return this.level;
49 }
50
51 @Override
52 public String getMessage() {
53 return this.message;
54 }
55
56 @Override
57 public Reason getReason() {
58 return this.reason;
59 }
60
61 @Nonnull
62 public static Reason resolveReason(@Nonnull Level level, @Nullable String message) {
63 if (level == Level.OK && message == null) {
64 return Reason.OK;
65 } else if (level == Level.WARNING && message != null && message.equals("NO QUESTION")) {
66 return Reason.QUESTIONS_EXHAUSTED;
67 } else if (level == Level.ERROR && message != null) {
68 if (message.equals("TECHNICAL ERROR"))
69 return Reason.SERVER_FAILURE;
70 else if (message.equals("MISSING KEY") || message.equals("ELEM LIST IS EMPTY")
71 || message.equals("MISSING PARAMETERS") || message.equals("UNAUTHORIZED"))
72 return Reason.LIBRARY_FAILURE;
73 }
74
75 return UNKNOWN;
76 }
77
78 @Override
79 public String toString() {
80 var sb = new StringBuilder();
81 sb.append(this.level);
82
83 if (this.message != null) {
84 sb.append(" - ");
85 sb.append(this.message);
86 }
87
88 if (this.reason != UNKNOWN) {
89 sb.append(" (");
90 sb.append(this.reason);
91 sb.append(')');
92 }
93
94 return sb.toString();
95 }
96
97}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/QuestionImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/QuestionImpl.java
deleted file mode 100644
index c5b77cf..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/QuestionImpl.java
+++ /dev/null
@@ -1,71 +0,0 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable;
2
3import static com.github.markozajc.akiwrapper.core.entities.Status.Level.WARNING;
4import static com.github.markozajc.akiwrapper.core.utils.JSONUtils.*;
5
6import javax.annotation.*;
7
8import org.json.JSONObject;
9
10import com.github.markozajc.akiwrapper.core.entities.*;
11import com.github.markozajc.akiwrapper.core.exceptions.MissingQuestionException;
12
13public class QuestionImpl implements Question {
14
15 private static final String REASON_OUT_OF_QUESTIONS = "no question";
16
17 @Nonnull private final String id;
18 @Nonnull private final String question;
19 @Nonnegative private final int step;
20 @Nonnegative private final double gain;
21 @Nonnegative private final double progression;
22
23 public QuestionImpl(@Nonnull String id, @Nonnull String question, @Nonnegative int step, @Nonnegative double gain,
24 @Nonnegative double progression, @Nonnull Status status) {
25 checkMissingQuestion(status);
26
27 this.id = id;
28 this.question = question;
29 this.step = step;
30 this.gain = gain;
31 this.progression = progression;
32 }
33
34 @SuppressWarnings("null")
35 public static QuestionImpl from(@Nonnull JSONObject json, @Nonnull Status status) {
36 return new QuestionImpl(json.getString("questionid"), json.getString("question"),
37 getInteger(json, "step").orElseThrow(), getDouble(json, "infogain").orElseThrow(),
38 getDouble(json, "progression").orElseThrow(), status);
39 }
40
41 private static void checkMissingQuestion(@Nonnull Status status) {
42 if (status.getLevel() == WARNING && REASON_OUT_OF_QUESTIONS.equalsIgnoreCase(status.getReason()))
43 throw new MissingQuestionException();
44 }
45
46 @Override
47 public double getProgression() {
48 return this.progression;
49 }
50
51 @Override
52 public int getStep() {
53 return this.step;
54 }
55
56 @Override
57 public double getInfogain() {
58 return this.gain;
59 }
60
61 @Override
62 public String getQuestion() {
63 return this.question;
64 }
65
66 @Override
67 public String getId() {
68 return this.id;
69 }
70
71}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImpl.java
deleted file mode 100644
index 2a3114c..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImpl.java
+++ /dev/null
@@ -1,85 +0,0 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable;
2
3import java.util.*;
4import java.util.concurrent.ConcurrentLinkedQueue;
5import java.util.stream.*;
6
7import javax.annotation.Nonnull;
8
9import com.github.markozajc.akiwrapper.core.entities.*;
10
11public class ServerListImpl implements ServerList {
12
13 @Nonnull private Server currentServer;
14 @Nonnull private final Queue<Server> candidateServers;
15
16 @SuppressWarnings("null")
17 public ServerListImpl(@Nonnull Server first, @Nonnull Server... candidates) {
18 this(first, Arrays.asList(candidates));
19 }
20
21 public ServerListImpl(@Nonnull Server first, @Nonnull Collection<Server> candidates) {
22 this.candidateServers = unwrapServersIntoQueue(candidates);
23 this.currentServer = first;
24 }
25
26 @SuppressWarnings("null")
27 public ServerListImpl(@Nonnull Collection<Server> servers) {
28 if (servers.isEmpty())
29 throw new IllegalArgumentException("The collection of servers may not be empty");
30
31 ConcurrentLinkedQueue<Server> queue = unwrapServersIntoQueue(servers);
32 this.candidateServers = queue;
33 this.currentServer = this.candidateServers.remove();
34 }
35
36 @SuppressWarnings("null")
37 @Nonnull
38 private static ConcurrentLinkedQueue<Server> unwrapServersIntoQueue(@Nonnull Collection<Server> servers) {
39 return servers.stream().flatMap(s -> {
40 if (s instanceof ServerList)
41 return ((ServerList) s).getServers().stream();
42 else
43 return Stream.of(s);
44 }).collect(Collectors.toCollection(ConcurrentLinkedQueue<Server>::new));
45 }
46
47 @Override
48 public String getUrl() {
49 return this.currentServer.getUrl();
50 }
51
52 @Override
53 public Language getLanguage() {
54 return this.currentServer.getLanguage();
55 }
56
57 @Override
58 public GuessType getGuessType() {
59 return this.currentServer.getGuessType();
60 }
61
62 @SuppressWarnings("null")
63 @Override
64 public boolean next() {
65 if (!hasNext())
66 return false;
67
68 this.currentServer = this.candidateServers.remove();
69 return true;
70 }
71
72 @Override
73 public List<Server> getServers() {
74 List<Server> result = new ArrayList<>(getRemainingSize() + 1);
75 result.add(this.currentServer);
76 result.addAll(this.candidateServers);
77 return result;
78 }
79
80 @Override
81 public int getRemainingSize() {
82 return this.candidateServers.size();
83 }
84
85}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImpl.java
deleted file mode 100644
index b3c2381..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImpl.java
+++ /dev/null
@@ -1,61 +0,0 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable;
2
3import static java.lang.String.format;
4
5import javax.annotation.*;
6
7import org.json.JSONObject;
8
9import com.github.markozajc.akiwrapper.core.entities.Status;
10
11public class StatusImpl implements Status {
12
13 private static final String DIVIDER = " - ";
14 private static final String STATUS_FORMAT = "%s" + DIVIDER + "%s";
15
16 public static final StatusImpl STATUS_OK = new StatusImpl(Level.OK, null);
17
18 @Nullable private final String reason;
19 @Nonnull private final Level level;
20
21 private StatusImpl(@Nonnull Level level, @Nullable String reason) {
22 this.level = level;
23 this.reason = reason;
24 }
25
26 public StatusImpl(@Nonnull String completion) {
27 this(Level.fromString(completion), determineReason(completion));
28 }
29
30 @SuppressWarnings("null")
31 public StatusImpl(@Nonnull JSONObject json) {
32 this(json.getString("completion"));
33 }
34
35 @Nullable
36 private static String determineReason(@Nonnull String completion) {
37 int reasonSplitIndex = completion.indexOf(DIVIDER);
38 if (reasonSplitIndex != -1)
39 return completion.substring(reasonSplitIndex + DIVIDER.length());
40 return null;
41 }
42
43 @Override
44 public String getReason() {
45 return this.reason;
46 }
47
48 @Override
49 public Level getLevel() {
50 return this.level;
51 }
52
53 @Override
54 public String toString() {
55 if (getReason() == null)
56 return getLevel().toString();
57 else
58 return format(STATUS_FORMAT, getLevel().toString(), getReason());
59 }
60
61}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/AkinatorException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/AkinatorException.java
new file mode 100644
index 0000000..152cb95
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/AkinatorException.java
@@ -0,0 +1,25 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3/**
4 * The root exception class for exceptions in Akiwrapper.
5 *
6 * @author Marko Zajc
7 */
8public class AkinatorException extends RuntimeException {
9
10 @SuppressWarnings("javadoc") // internal
11 public AkinatorException() {
12 super();
13 }
14
15 @SuppressWarnings("javadoc") // internal
16 public AkinatorException(String message) {
17 super(message);
18 }
19
20 @SuppressWarnings("javadoc") // internal
21 public AkinatorException(String message, Throwable cause) {
22 super(message, cause);
23 }
24
25}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/MissingQuestionException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/MissingQuestionException.java
deleted file mode 100644
index 89739c6..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/MissingQuestionException.java
+++ /dev/null
@@ -1,15 +0,0 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3/**
4 * An exception indicating that there is no question left to answer or fetch.
5 *
6 * @author Marko Zajc
7 */
8public class MissingQuestionException extends RuntimeException {
9
10 /**
11 * Constructs a new {@link MissingQuestionException} instance.
12 */
13 public MissingQuestionException() {}
14
15}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/QuestionsExhaustedException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/QuestionsExhaustedException.java
new file mode 100644
index 0000000..25e834a
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/QuestionsExhaustedException.java
@@ -0,0 +1,13 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3/**
4 * An exception indicating that there is no question left to answer or fetch.
5 *
6 * @author Marko Zajc
7 */
8public class QuestionsExhaustedException extends AkinatorException {
9
10 @SuppressWarnings("javadoc") // internal
11 public QuestionsExhaustedException() {}
12
13}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerNotFoundException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerNotFoundException.java
index dbec147..f19bd93 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerNotFoundException.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerNotFoundException.java
@@ -1,5 +1,7 @@
1package com.github.markozajc.akiwrapper.core.exceptions; 1package com.github.markozajc.akiwrapper.core.exceptions;
2 2
3import javax.annotation.Nonnull;
4
3import com.github.markozajc.akiwrapper.core.entities.Server; 5import com.github.markozajc.akiwrapper.core.entities.Server;
4import com.github.markozajc.akiwrapper.core.entities.Server.*; 6import com.github.markozajc.akiwrapper.core.entities.Server.*;
5 7
@@ -9,11 +11,11 @@ import com.github.markozajc.akiwrapper.core.entities.Server.*;
9 * 11 *
10 * @author Marko Zajc 12 * @author Marko Zajc
11 */ 13 */
12public class ServerNotFoundException extends Exception { 14public class ServerNotFoundException extends AkinatorException {
13 15
14 /** 16 @SuppressWarnings("javadoc") // internal
15 * Constructs a new {@link ServerNotFoundException}. 17 public ServerNotFoundException(@Nonnull String message) {
16 */ 18 super(message);
17 public ServerNotFoundException() {} 19 }
18 20
19} 21}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerStatusException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerStatusException.java
new file mode 100644
index 0000000..b21692e
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerStatusException.java
@@ -0,0 +1,29 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3import com.github.markozajc.akiwrapper.core.entities.Status;
4import com.github.markozajc.akiwrapper.core.entities.Status.Level;
5
6/**
7 * An exception indicating that the server returned an error code
8 * ({@link Level#ERROR}).
9 *
10 * @author Marko Zajc
11 */
12public class ServerStatusException extends AkinatorException {
13
14 private final Status status;
15
16 @SuppressWarnings("javadoc") // internal
17 public ServerStatusException(Status status) {
18 super(status.toString());
19 this.status = status;
20 }
21
22 /**
23 * @return the erroneous status returned by the server
24 */
25 public Status getStatus() {
26 return this.status;
27 }
28
29}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerUnavailableException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerUnavailableException.java
deleted file mode 100644
index 61b503f..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/ServerUnavailableException.java
+++ /dev/null
@@ -1,35 +0,0 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3import javax.annotation.Nonnull;
4
5import com.github.markozajc.akiwrapper.core.entities.*;
6import com.github.markozajc.akiwrapper.core.entities.impl.immutable.StatusImpl;
7
8/**
9 * An exception indicating that the currently used {@link Server} has gone offline.
10 *
11 * @author Marko Zajc
12 */
13public class ServerUnavailableException extends StatusException {
14
15 /**
16 * Constructs a new {@link ServerUnavailableException} from a {@link Status}.
17 *
18 * @param status
19 * erroneous status.
20 */
21 public ServerUnavailableException(@Nonnull Status status) {
22 super(status);
23 }
24
25 /**
26 * Constructs a new {@link ServerUnavailableException} from a {@link Status} string.
27 *
28 * @param status
29 * erroneous status string.
30 */
31 public ServerUnavailableException(@Nonnull String status) {
32 super(new StatusImpl(status));
33 }
34
35}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/StatusException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/StatusException.java
deleted file mode 100644
index 49b3062..0000000
--- a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/StatusException.java
+++ /dev/null
@@ -1,32 +0,0 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3import com.github.markozajc.akiwrapper.core.entities.Status;
4
5/**
6 * An exception indicating that the server returned an error code ("KO").
7 *
8 * @author Marko Zajc
9 */
10public class StatusException extends RuntimeException {
11
12 private final Status status;
13
14 /**
15 * Constructs a new {@link StatusException}.
16 *
17 * @param status
18 * status to append
19 */
20 public StatusException(Status status) {
21 super(status.getLevel().toString().toUpperCase() + " - " + status.getReason());
22 this.status = status;
23 }
24
25 /**
26 * @return the problematic status that has been returned
27 */
28 public Status getStatus() {
29 return this.status;
30 }
31
32}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/UndoOutOfBoundsException.java b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/UndoOutOfBoundsException.java
new file mode 100644
index 0000000..d605fd3
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/exceptions/UndoOutOfBoundsException.java
@@ -0,0 +1,16 @@
1package com.github.markozajc.akiwrapper.core.exceptions;
2
3import com.github.markozajc.akiwrapper.Akiwrapper;
4
5/**
6 * An exception indicating that {@link Akiwrapper#undoAnswer()} has been called on
7 * the first question - when {@link Akiwrapper#getStep()} is {@code 0}.
8 *
9 * @author Marko Zajc
10 */
11public class UndoOutOfBoundsException extends AkinatorException {
12
13 @SuppressWarnings("javadoc") // internal
14 public UndoOutOfBoundsException() {}
15
16}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/impl/AkiwrapperImpl.java b/src/main/java/com/github/markozajc/akiwrapper/core/impl/AkiwrapperImpl.java
index 7609cdb..00cad2c 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/impl/AkiwrapperImpl.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/impl/AkiwrapperImpl.java
@@ -1,46 +1,51 @@
1package com.github.markozajc.akiwrapper.core.impl; 1package com.github.markozajc.akiwrapper.core.impl;
2 2
3import static com.github.markozajc.akiwrapper.core.Route.*; 3import static com.github.markozajc.akiwrapper.core.entities.Status.Reason.QUESTIONS_EXHAUSTED;
4import static com.github.markozajc.akiwrapper.core.entities.Status.Level.ERROR; 4import static com.github.markozajc.akiwrapper.core.utils.route.Routes.*;
5import static com.github.markozajc.akiwrapper.core.entities.impl.immutable.StatusImpl.STATUS_OK;
6import static java.lang.Integer.parseInt; 5import static java.lang.Integer.parseInt;
7import static java.lang.Long.parseLong; 6import static java.lang.Long.parseLong;
8import static java.lang.String.format; 7import static java.lang.String.format;
9import static java.lang.System.currentTimeMillis;
10import static java.util.Collections.emptyList;
11import static java.util.stream.Collectors.toUnmodifiableList; 8import static java.util.stream.Collectors.toUnmodifiableList;
12import static java.util.stream.StreamSupport.stream; 9import static java.util.stream.StreamSupport.stream;
13 10
14import java.util.List; 11import java.util.List;
15 12
16import javax.annotation.*; 13import javax.annotation.Nonnull;
17 14
15import org.eclipse.collections.api.factory.primitive.LongSets;
16import org.eclipse.collections.api.set.primitive.MutableLongSet;
18import org.json.JSONObject; 17import org.json.JSONObject;
18import org.slf4j.*;
19 19
20import com.github.markozajc.akiwrapper.Akiwrapper; 20import com.github.markozajc.akiwrapper.Akiwrapper;
21import com.github.markozajc.akiwrapper.core.entities.*; 21import com.github.markozajc.akiwrapper.core.entities.*;
22import com.github.markozajc.akiwrapper.core.entities.impl.immutable.*; 22import com.github.markozajc.akiwrapper.core.entities.impl.*;
23import com.github.markozajc.akiwrapper.core.exceptions.*; 23import com.github.markozajc.akiwrapper.core.exceptions.*;
24import com.github.markozajc.akiwrapper.core.utils.ApiKey;
24 25
25import kong.unirest.UnirestInstance; 26import kong.unirest.UnirestInstance;
26 27
28@SuppressWarnings("javadoc") // internal impl
27public class AkiwrapperImpl implements Akiwrapper { 29public class AkiwrapperImpl implements Akiwrapper {
28 30
29 private static final String NO_MORE_QUESTIONS_STATUS = "elem list is empty";
30 private static final String PARAMETERS_KEY = "parameters";
31
32 public static class Session { 31 public static class Session {
33 32
34 private static final String FORMAT_QUERYSTRING = "&session=%s&signature=%s"; 33 private static final String FORMAT_QUERYSTRING = "session=%s&signature=%s";
35 34
36 private final long signature; 35 private final long signature;
37 private final int session; 36 private final int session;
38 37
39 public Session(long signature, int session) { 38 private Session(long signature, int session) {
40 this.signature = signature; 39 this.signature = signature;
41 this.session = session; 40 this.session = session;
42 } 41 }
43 42
43 @Nonnull
44 public static Session fromJson(@Nonnull JSONObject parameters) {
45 var session = parameters.getJSONObject("identification");
46 return new Session(parseLong(session.getString("signature")), parseInt(session.getString("session")));
47 }
48
44 public long getSignature() { 49 public long getSignature() {
45 return this.signature; 50 return this.signature;
46 } 51 }
@@ -49,79 +54,134 @@ public class AkiwrapperImpl implements Akiwrapper {
49 return this.session; 54 return this.session;
50 } 55 }
51 56
52 public String querystring() { 57 public String asQuerystring() {
53 return format(FORMAT_QUERYSTRING, this.getSession(), this.getSignature()); 58 return format(FORMAT_QUERYSTRING, this.getSession(), this.getSignature());
54 } 59 }
55 } 60 }
56 61
57 @Nonnull private final Server server; 62 private static final Logger LOG = LoggerFactory.getLogger(AkiwrapperImpl.class);
63
64 private static final int LAST_STEP = 80;
65
66 @Nonnull private final ServerImpl server;
58 @Nonnull private final UnirestInstance unirest; 67 @Nonnull private final UnirestInstance unirest;
59 private final boolean filterProfanity; 68 private final boolean filterProfanity;
60 @Nonnull private final Session session;
61 @Nonnegative private int currentStep;
62 @Nullable private Question question;
63 private List<Guess> guessCache;
64
65 @SuppressWarnings("null")
66 public AkiwrapperImpl(@Nonnull UnirestInstance unirest, @Nonnull Server server, boolean filterProfanity) {
67 var questionJson =
68 NEW_SESSION.createRequest(unirest, "", filterProfanity, currentTimeMillis(), server.getUrl()).getJSON();
69 69
70 var parameters = questionJson.getJSONObject(PARAMETERS_KEY); 70 private ApiKey apiKey;
71 private Session session;
72 private Question question;
73 private int lastGuessStep;
74 private MutableLongSet rejectedGuesses = LongSets.mutable.empty();
71 75
72 this.session = getSession(parameters); 76 public AkiwrapperImpl(@Nonnull UnirestInstance unirest, @Nonnull ServerImpl server, boolean filterProfanity) {
73 this.question = QuestionImpl.from(parameters.getJSONObject("step_information"), STATUS_OK);
74 this.filterProfanity = filterProfanity; 77 this.filterProfanity = filterProfanity;
75 this.server = server; 78 this.server = server;
76 this.unirest = unirest; 79 this.unirest = unirest;
77 this.currentStep = 0;
78 } 80 }
79 81
80 @SuppressWarnings("null") 82 @SuppressWarnings("null")
83 public void createSession() {
84 this.apiKey = ApiKey.accquireApiKey(this.unirest);
85 var sessionParameters = NEW_SESSION.createRequest(this).execute().getBody();
86 this.session = Session.fromJson(sessionParameters);
87 this.question = QuestionImpl.fromJson(sessionParameters.getJSONObject("step_information"));
88 }
89
81 @Override 90 @Override
82 public Question answer(Answer answer) { 91 public Question answer(Answer answer) {
83 this.guessCache = null; 92 if (isExhausted())
84 var oldQuestion = this.question; 93 throw new QuestionsExhaustedException();
85 if (oldQuestion != null) { 94
86 var newQuestionJson = ANSWER 95 var response = ANSWER.createRequest(this)
87 .createRequest(this.unirest, this.server.getUrl(), this.filterProfanity, this.session, 96 .parameter(PARAMETER_STEP, getStep())
88 oldQuestion.getStep(), answer.getId()) 97 .parameter(PARAMETER_ANSWER, answer.getId())
89 .getJSON(); 98 .execute();
90
91 try {
92 this.question =
93 QuestionImpl.from(newQuestionJson.getJSONObject(PARAMETERS_KEY), new StatusImpl(newQuestionJson));
94
95 } catch (MissingQuestionException e) { // NOSONAR It does not need to be logged
96 this.question = null;
97 return null;
98 }
99 99
100 this.currentStep += 1; 100 if (response.getStatus().getReason() == QUESTIONS_EXHAUSTED) {
101 return this.question; 101 return this.question = null;
102 } 102 }
103 103
104 return null; 104 return this.question = QuestionImpl.fromJson(response.getBody());
105 } 105 }
106 106
107 @Override 107 @Override
108 @SuppressWarnings("null") 108 @SuppressWarnings("null")
109 public Question undoAnswer() { 109 public Question undoAnswer() {
110 this.guessCache = null; 110 if (isExhausted())
111 throw new QuestionsExhaustedException(); // the api won't let us
111 112
112 Question current = getQuestion(); 113 if (getStep() == 0)
113 if (current == null || current.getStep() < 1) 114 throw new UndoOutOfBoundsException();
115
116 var response = CANCEL_ANSWER.createRequest(this).parameter(PARAMETER_STEP, getStep()).execute();
117 return this.question = QuestionImpl.fromJson(response.getBody());
118 }
119
120 @Override
121 @SuppressWarnings("null")
122 public List<Guess> getGuesses(int count) {
123 var request = LIST.createRequest(this).parameter(PARAMETER_STEP, getStep());
124 if (count > 0)
125 request.parameter(PARAMETER_SIZE, count);
126 var response = request.execute();
127
128 return stream(response.getBody().getJSONArray("elements").spliterator(), false).map(JSONObject.class::cast)
129 .map(j -> j.getJSONObject("element"))
130 .map(GuessImpl::fromJson)
131 .sorted()
132 .collect(toUnmodifiableList());
133 }
134
135 @Override
136 public Guess suggestGuess() {
137 boolean shouldSuggest = isExhausted() || getStep() - this.lastGuessStep >= 5 // NOSONAR I'm just copying
138 && (this.question != null && this.question.getProgression() > 97 || getStep() - this.lastGuessStep == 25)
139 && getStep() != 75;
140 // I have no clue what that last part is, but that's what akinator does
141
142 if (!shouldSuggest)
114 return null; 143 return null;
115 144
116 var questionJson = CANCEL_ANSWER 145 for (var guess : getGuesses(2)) {
117 .createRequest(this.unirest, this.server.getUrl(), this.filterProfanity, this.session, current.getStep()) 146 if (!this.rejectedGuesses.contains(guess.getIdLong())) {
118 .getJSON(); 147 this.rejectedGuesses.add(guess.getIdLong());
148 this.lastGuessStep = getStep();
149 return guess;
150 }
151 }
152 return null;
153 }
119 154
120 this.question = QuestionImpl.from(questionJson.getJSONObject(PARAMETERS_KEY), new StatusImpl(questionJson)); 155 @Override
156 public Question rejectLastGuess() {
157 try {
158 var response = EXCLUSION.createRequest(this).parameter(PARAMETER_STEP, getStep()).execute();
159 return this.question = QuestionImpl.fromJson(response.getBody());
121 160
122 this.currentStep -= 1; 161 } catch (AkinatorException e) {
162 if (isExhausted()) {
163 // we don't care about out session anymore anyways, throwing would be silly
164 LOG.warn("Caught an exception when rejecting a guess", e);
165 return null;
166
167 } else {
168 throw e;
169 }
170 }
123 171
124 return this.question; 172 }
173
174 @Override
175 public void confirmGuess(Guess guess) {
176 try {
177 CHOICE.createRequest(this)
178 .parameter(PARAMETER_STEP, getStep())
179 .parameter(PARAMETER_ELEMENT, guess.getId())
180 .execute();
181 } catch (AkinatorException e) {
182 // we don't care about out session anymore anyways, throwing would be silly
183 LOG.warn("Caught an exception when confirming a guess", e);
184 }
125 } 185 }
126 186
127 @Override 187 @Override
@@ -129,44 +189,39 @@ public class AkiwrapperImpl implements Akiwrapper {
129 return this.question; 189 return this.question;
130 } 190 }
131 191
132 @SuppressWarnings("null")
133 @Override 192 @Override
134 public List<Guess> getGuesses() { 193 public int getStep() {
135 try { 194 var question = this.getQuestion();
136 if (this.guessCache == null) 195 return question == null ? LAST_STEP : question.getStep();
137 this.guessCache = stream(LIST 196 }
138 .createRequest(this.unirest, this.server.getUrl(), this.filterProfanity, this.session,
139 this.currentStep)
140 .getJSON()
141 .getJSONObject(PARAMETERS_KEY)
142 .getJSONArray("elements")
143 .spliterator(), false).map(JSONObject.class::cast)
144 .map(j -> j.getJSONObject("element"))
145 .map(GuessImpl::from)
146 .sorted()
147 .collect(toUnmodifiableList());
148
149 return this.guessCache;
150
151 } catch (StatusException e) {
152 if (e.getStatus().getLevel() == ERROR
153 && NO_MORE_QUESTIONS_STATUS.equalsIgnoreCase(e.getStatus().getReason())) {
154 return emptyList();
155 }
156 197
157 throw e; 198 @Override
158 } 199 public boolean isExhausted() {
200 // question is only null after we've exhausted them (that is post step 80)
201 return this.question == null;
159 } 202 }
160 203
161 @Override 204 @Override
162 public Server getServer() { 205 public ServerImpl getServer() {
163 return this.server; 206 return this.server;
164 } 207 }
165 208
209 @Override
210 public boolean doesFilterProfanity() {
211 return this.filterProfanity;
212 }
213
214 public Session getSession() {
215 return this.session;
216 }
217
218 public ApiKey getApiKey() {
219 return this.apiKey;
220 }
221
166 @Nonnull 222 @Nonnull
167 private static Session getSession(@Nonnull JSONObject parameters) { 223 public UnirestInstance getUnirest() {
168 var session = parameters.getJSONObject("identification"); 224 return this.unirest;
169 return new Session(parseLong(session.getString("signature")), parseInt(session.getString("session")));
170 } 225 }
171 226
172} 227}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ApiKey.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/ApiKey.java
index 5447ab4..bfe2829 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ApiKey.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/ApiKey.java
@@ -1,53 +1,54 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable; 1package com.github.markozajc.akiwrapper.core.utils;
2 2
3import static com.github.markozajc.akiwrapper.core.Route.BASE_AKINATOR_URL;
4import static java.lang.String.format; 3import static java.lang.String.format;
5import static java.nio.charset.StandardCharsets.UTF_8; 4import static java.nio.charset.StandardCharsets.UTF_8;
6import static java.util.regex.Pattern.compile; 5import static java.util.regex.Pattern.compile;
7 6
8import java.net.URLEncoder; 7import java.net.URLEncoder;
9import java.util.Base64;
10import java.util.regex.Pattern; 8import java.util.regex.Pattern;
11 9
12import javax.annotation.Nonnull; 10import javax.annotation.Nonnull;
13 11
12import com.github.markozajc.akiwrapper.core.utils.route.Route;
13
14import kong.unirest.UnirestInstance; 14import kong.unirest.UnirestInstance;
15 15
16@SuppressWarnings("javadoc") // internal impl
16public class ApiKey { 17public class ApiKey {
17 18
18 private static final String EXCEPTION_NO_KEY = "Couldn't find the API key!" + 19 private static final String EXCEPTION_NO_KEY = "Couldn't find the API key!" +
19 "Please consider opening a new ticket at https://github.com/markozajc/Akiwrapper/issues." + 20 "Please consider opening a new ticket at https://github.com/markozajc/Akiwrapper/issues.";
20 "Base64 encoded page: %s";
21 21
22 private static final Pattern API_KEY_PATTERN = 22 private static final Pattern API_KEY_PATTERN =
23 compile("var uid_ext_session = '(.*)'\\;\\n.*var frontaddr = '(.*)'\\;"); 23 compile("var uid_ext_session = '(.*)'\\;\\n.*var frontaddr = '(.*)'\\;");
24 24
25 private static final String FORMAT = "frontaddr=%s&uid_ext_session=%s"; 25 @Nonnull private final String uidExtSession;
26 26 @Nonnull private final String frontaddr;
27 @Nonnull private final String sessionUid;
28 @Nonnull private final String frontAddress;
29 27
30 ApiKey(@Nonnull String sessionUid, @Nonnull String frontAddress) { 28 ApiKey(@Nonnull String sessionUid, @Nonnull String frontAddress) {
31 this.sessionUid = sessionUid; 29 this.uidExtSession = sessionUid;
32 this.frontAddress = frontAddress; 30 this.frontaddr = frontAddress;
33 } 31 }
34 32
35 @Nonnull 33 @Nonnull
36 @SuppressWarnings("null") 34 public String asQuerystringUidExtSession() {
37 public String querystring() { 35 return "uid_ext_session=" + this.uidExtSession;
38 return format(FORMAT, URLEncoder.encode(this.frontAddress, UTF_8), this.sessionUid); 36 }
37
38 @Nonnull
39 public String asQuerystringFrontaddr() {
40 return "frontaddr=" + URLEncoder.encode(this.frontaddr, UTF_8);
39 } 41 }
40 42
41 @SuppressWarnings("null") 43 @SuppressWarnings("null")
42 public static ApiKey accquireApiKey(UnirestInstance unirest) { 44 public static ApiKey accquireApiKey(UnirestInstance unirest) {
43 var page = unirest.get(BASE_AKINATOR_URL + "/game").asString().getBody(); 45 var page = unirest.get(Route.WEBSITE_URL + "/game").asString().getBody();
44 var matcher = API_KEY_PATTERN.matcher(page); 46 var matcher = API_KEY_PATTERN.matcher(page);
45 if (matcher.find()) { 47 if (matcher.find()) {
46 return new ApiKey(matcher.group(1), matcher.group(2)); 48 return new ApiKey(matcher.group(1), matcher.group(2));
47 49
48 } else { 50 } else {
49 throw new IllegalStateException(format(EXCEPTION_NO_KEY, 51 throw new IllegalStateException(format(EXCEPTION_NO_KEY));
50 Base64.getEncoder().encodeToString(page.getBytes(UTF_8))));
51 } 52 }
52 } 53 }
53 54
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/Servers.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/Servers.java
index 6f74a9b..528b671 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/utils/Servers.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/Servers.java
@@ -7,10 +7,9 @@ import java.util.stream.Stream;
7 7
8import javax.annotation.Nonnull; 8import javax.annotation.Nonnull;
9 9
10import com.github.markozajc.akiwrapper.core.entities.*; 10import com.github.markozajc.akiwrapper.core.entities.Server;
11import com.github.markozajc.akiwrapper.core.entities.Server.*; 11import com.github.markozajc.akiwrapper.core.entities.Server.*;
12import com.github.markozajc.akiwrapper.core.entities.impl.immutable.*; 12import com.github.markozajc.akiwrapper.core.entities.impl.ServerImpl;
13import com.github.markozajc.akiwrapper.core.exceptions.ServerNotFoundException;
14import com.jcabi.xml.XMLDocument; 13import com.jcabi.xml.XMLDocument;
15 14
16import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 15import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -30,8 +29,7 @@ public final class Servers {
30 private Servers() {} 29 private Servers() {}
31 30
32 /** 31 /**
33 * Finds correct {@link Server}s using given parameters and compiles a 32 * Finds correct {@link Server}s using given parameters
34 * {@link ServerList} out of them.
35 * 33 *
36 * @param unirest 34 * @param unirest
37 * the {@link UnirestInstance} to use for the request 35 * the {@link UnirestInstance} to use for the request
@@ -40,20 +38,15 @@ public final class Servers {
40 * @param guessType 38 * @param guessType
41 * guessType of the server to search for 39 * guessType of the server to search for
42 * 40 *
43 * @return a {@link ServerList} with {@link Server}s that suit the given parameters. 41 * @return a list of {@link Server}s that suit the given parameters.
44 *
45 * @throws ServerNotFoundException
46 * if there is no server that matches the query.
47 */ 42 */
48 @Nonnull 43 @Nonnull
49 public static ServerList findServers(@Nonnull UnirestInstance unirest, @Nonnull Language localization, 44 @SuppressWarnings("null")
50 @Nonnull GuessType guessType) throws ServerNotFoundException { 45 public static List<ServerImpl> findServers(@Nonnull UnirestInstance unirest, @Nonnull Language localization,
51 List<Server> servers = getServers(unirest).filter(s -> s.getGuessType() == guessType) 46 @Nonnull GuessType guessType) {
47 return getServers(unirest).filter(s -> s.getGuessType() == guessType)
52 .filter(s -> s.getLanguage() == localization) 48 .filter(s -> s.getLanguage() == localization)
53 .collect(toList()); 49 .collect(toList());
54 if (servers.isEmpty())
55 throw new ServerNotFoundException();
56 return new ServerListImpl(servers);
57 } 50 }
58 51
59 /** 52 /**
@@ -63,10 +56,10 @@ public final class Servers {
63 * @param unirest 56 * @param unirest
64 * the {@link UnirestInstance} to use for the request 57 * the {@link UnirestInstance} to use for the request
65 * 58 *
66 * @return a {@link Stream} of all {@link Server}s. 59 * @return a {@link Stream} of all available {@link Server}s.
67 */ 60 */
68 @SuppressWarnings("null") 61 @SuppressWarnings("null")
69 public static Stream<Server> getServers(@Nonnull UnirestInstance unirest) { 62 public static Stream<ServerImpl> getServers(@Nonnull UnirestInstance unirest) {
70 return new XMLDocument(fetchListXml(unirest)).nodes("//RESULT/PARAMETERS/*") 63 return new XMLDocument(fetchListXml(unirest)).nodes("//RESULT/PARAMETERS/*")
71 .stream() 64 .stream()
72 .flatMap(xml -> ServerImpl.fromXml(xml).stream()); 65 .flatMap(xml -> ServerImpl.fromXml(xml).stream());
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/UnirestUtils.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/UnirestUtils.java
index f418cb9..7dc38e3 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/utils/UnirestUtils.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/UnirestUtils.java
@@ -48,15 +48,16 @@ public class UnirestUtils {
48 } 48 }
49 49
50 /** 50 /**
51 * Configures a new {@link UnirestInstance} for use by Akiwrapper. Akinator's API 51 * <b>Note:</b> even though this method returns a {@link UnirestInstance}, the
52 * servers are quite picky about the headers you send to them so if you supply 52 * instance you pass to it is itself mutated and returned. The return value is only
53 * {@link AkiwrapperBuilder} with your own {@link UnirestInstance} you should either 53 * there for ease of chaining. Configures a new {@link UnirestInstance} for use by
54 * pass it through this or configure it accordingly yourself. This also applies the 54 * Akiwrapper.<br>
55 * workaround to Akinator's incomplete SSL chain from 55 * <br>
56 * {@link WorkaroundUtils#workaroundIncompleteChain(kong.unirest.Config)} .<br> 56 * Akinator's API servers are quite picky about the headers you send to them so if
57 * Note: even though this method returns a {@link UnirestInstance}, the instance you 57 * you supply {@link AkiwrapperBuilder} with your own {@link UnirestInstance} you
58 * pass to it is itself mutated and returned. The return value is only there for ease 58 * should either pass it through this or configure it accordingly yourself. This also
59 * of chaining. 59 * applies the workaround to Akinator's incomplete SSL chain from
60 * {@link WorkaroundUtils#workaroundIncompleteChain(kong.unirest.Config)}.
60 * 61 *
61 * @param unirest 62 * @param unirest
62 * the {@link UnirestInstance} to configure 63 * the {@link UnirestInstance} to configure
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/WorkaroundUtils.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/WorkaroundUtils.java
index 0cc1427..71620dd 100644
--- a/src/main/java/com/github/markozajc/akiwrapper/core/utils/WorkaroundUtils.java
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/WorkaroundUtils.java
@@ -9,6 +9,7 @@ import java.security.cert.*;
9import javax.annotation.Nonnull; 9import javax.annotation.Nonnull;
10import javax.net.ssl.*; 10import javax.net.ssl.*;
11 11
12import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
12import kong.unirest.Config; 13import kong.unirest.Config;
13 14
14/** 15/**
@@ -19,11 +20,12 @@ import kong.unirest.Config;
19public class WorkaroundUtils { 20public class WorkaroundUtils {
20 21
21 /** 22 /**
23 * <b>Note:</b> even though this method returns a {@link Config}, the instance you
24 * pass to it is itself mutated and returned. The return value is only there for ease
25 * of chaining.<br>
26 * <br>
22 * Applies a workaround for the {@code PKIX path building failed} exception to a 27 * Applies a workaround for the {@code PKIX path building failed} exception to a
23 * Unirest {@link Config}.<br> 28 * Unirest {@link Config}.
24 * Note: even though this method returns a {@link Config}, the instance you pass to
25 * it is itself mutated and returned. The return value is only there for ease of
26 * chaining.
27 * 29 *
28 * @param config 30 * @param config
29 * the {@link Config} to apply the workaround to 31 * the {@link Config} to apply the workaround to
@@ -110,6 +112,7 @@ public class WorkaroundUtils {
110 */ 112 */
111 @Nonnull 113 @Nonnull
112 @SuppressWarnings("null") 114 @SuppressWarnings("null")
115 @SuppressFBWarnings("HARD_CODE_PASSWORD")
113 public static X509TrustManager getIncompleteChainWorkaroundCustomTrustManager() throws KeyStoreException, 116 public static X509TrustManager getIncompleteChainWorkaroundCustomTrustManager() throws KeyStoreException,
114 IOException, 117 IOException,
115 NoSuchAlgorithmException, 118 NoSuchAlgorithmException,
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Request.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Request.java
new file mode 100644
index 0000000..78980d9
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Request.java
@@ -0,0 +1,106 @@
1package com.github.markozajc.akiwrapper.core.utils.route;
2
3import static com.github.markozajc.akiwrapper.core.entities.Status.Level.ERROR;
4import static com.github.markozajc.akiwrapper.core.utils.route.Route.*;
5import static java.util.stream.Collectors.joining;
6import static org.slf4j.LoggerFactory.getLogger;
7
8import java.util.*;
9
10import javax.annotation.*;
11
12import org.json.*;
13import org.slf4j.Logger;
14
15import com.github.markozajc.akiwrapper.core.entities.impl.StatusImpl;
16import com.github.markozajc.akiwrapper.core.exceptions.*;
17
18import kong.unirest.UnirestInstance;
19
20@SuppressWarnings("javadoc") // internal util
21public class Request {
22
23 private static final Logger LOG = getLogger(Request.class);
24
25 @Nonnull private final String url;
26 @Nonnull private final UnirestInstance unirest;
27 private Set<String> mandatoryParameters;
28 private Map<String, String> parameters;
29 private final boolean urlHasQuerystring;
30
31 Request(@Nonnull String url, @Nonnull UnirestInstance unirest, @Nullable Set<String> mandatoryParameters,
32 @Nullable Map<String, String> parameters, boolean pathHasQuerystring) {
33 this.url = url;
34 this.unirest = unirest;
35 this.urlHasQuerystring = pathHasQuerystring;
36
37 if (mandatoryParameters != null)
38 this.mandatoryParameters = new HashSet<>(mandatoryParameters);
39 else
40 this.mandatoryParameters = null;
41
42 if (parameters != null)
43 this.parameters = new HashMap<>(parameters);
44 else
45 this.parameters = null;
46 }
47
48 @Nonnull
49 @SuppressWarnings("null")
50 public Request parameter(@Nonnull String name, int value) {
51 parameter(name, Integer.toString(value));
52 return this;
53 }
54
55 @Nonnull
56 public Request parameter(@Nonnull String name, @Nonnull String value) {
57 if (this.parameters != null && this.parameters.containsKey(name)) {
58 this.parameters.put(name, value);
59
60 if (this.mandatoryParameters != null)
61 this.mandatoryParameters.remove(name);
62
63 } else {
64 throw new IllegalArgumentException("Parameter \"" + name + "\" is not defined");
65 }
66 return this;
67 }
68
69 @Nonnull
70 @SuppressWarnings("null")
71 public Response execute() {
72 checkState();
73
74 String processedUrl = this.url;
75 boolean hasQuerystring = this.urlHasQuerystring;
76
77 if (this.parameters != null && !this.parameters.isEmpty())
78 processedUrl += formatQuerystring(formatParameters(this.parameters), hasQuerystring);
79
80 LOG.trace("--> {}", processedUrl);
81 var response = this.unirest.get(processedUrl).asString().getBody();
82
83 LOG.trace("<-- {}", response);
84 response = response.substring(7 /* "jQuery(" */, response.length() - 1 /* ")" */); // cut the callback
85
86 try {
87 var body = new JSONObject(response);
88 var status = StatusImpl.fromJson(body);
89 if (status.getLevel() == ERROR)
90 throw new ServerStatusException(status);
91
92 return new Response(status, body);
93
94 } catch (JSONException e) {
95 throw new AkinatorException("Couldn't parse a server response", e);
96 }
97 }
98
99 private void checkState() {
100 if (this.mandatoryParameters != null && !this.mandatoryParameters.isEmpty()) {
101 var unset = this.mandatoryParameters.stream().collect(joining(", "));
102 throw new IllegalStateException("Some mandatory parameters aren't set: " + unset);
103 }
104 }
105
106}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Response.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Response.java
new file mode 100644
index 0000000..08d4714
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Response.java
@@ -0,0 +1,31 @@
1package com.github.markozajc.akiwrapper.core.utils.route;
2
3import javax.annotation.Nonnull;
4
5import org.json.JSONObject;
6
7import com.github.markozajc.akiwrapper.core.entities.Status;
8
9@SuppressWarnings("javadoc") // internal util
10public class Response {
11
12 @Nonnull private final Status status;
13 @Nonnull private final JSONObject body;
14
15 public Response(@Nonnull Status status, @Nonnull JSONObject body) {
16 this.status = status;
17 this.body = body;
18 }
19
20 @Nonnull
21 public Status getStatus() {
22 return this.status;
23 }
24
25 @Nonnull
26 @SuppressWarnings("null")
27 public JSONObject getBody() {
28 return this.body.getJSONObject("parameters");
29 }
30
31}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Route.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Route.java
new file mode 100644
index 0000000..33e25b9
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Route.java
@@ -0,0 +1,137 @@
1package com.github.markozajc.akiwrapper.core.utils.route;
2
3import static com.github.markozajc.akiwrapper.core.utils.route.Route.Endpoint.GAME_SERVER;
4import static java.lang.String.format;
5import static java.nio.charset.StandardCharsets.UTF_8;
6import static java.util.stream.Collectors.joining;
7
8import java.net.URLEncoder;
9import java.util.*;
10import java.util.function.Supplier;
11
12import javax.annotation.*;
13
14import com.github.markozajc.akiwrapper.core.impl.AkiwrapperImpl;
15
16@SuppressWarnings("javadoc") // internal util
17public final class Route {
18
19 public static final String WEBSITE_URL = "https://en.akinator.com";
20
21 @Nonnull private final String path;
22 @Nonnull private final Endpoint endpoint;
23 private final boolean requiresSession;
24 private final boolean requiresFrontaddr;
25 private final boolean requiresUidExtSession;
26 private final boolean requiresUrlApiWs;
27 private final Map<String, Supplier<String>> automaticParameters;
28 @Nullable private final String profanityDisabledQuerystring;
29 @Nullable private final String profanityEnabledQuerystring;
30 @Nullable private Set<String> mandatoryParameters;
31 @Nullable private Map<String, String> parameters;
32 private boolean pathHasQuerystring;
33
34 Route(@Nonnull String path, @Nonnull Endpoint endpoint, boolean requiresSession, boolean requiresFrontaddr,
35 boolean requiresUidExtSession, boolean requiresUrlApiWs,
36 @Nullable Map<String, Supplier<String>> automaticParameters, @Nullable String profanityDisabledQuerystring,
37 @Nullable String profanityEnabledQuerystring, @Nullable Set<String> mandatoryParameters,
38 @Nullable Map<String, String> parameters, boolean pathHasQuerystring) {
39 this.path = path;
40 this.endpoint = endpoint;
41 this.requiresSession = requiresSession;
42 this.requiresFrontaddr = requiresFrontaddr;
43 this.requiresUidExtSession = requiresUidExtSession;
44 this.requiresUrlApiWs = requiresUrlApiWs;
45 this.automaticParameters = automaticParameters;
46 this.profanityDisabledQuerystring = profanityDisabledQuerystring;
47 this.profanityEnabledQuerystring = profanityEnabledQuerystring;
48 this.mandatoryParameters = mandatoryParameters;
49 this.parameters = parameters;
50 this.pathHasQuerystring = pathHasQuerystring;
51 }
52
53 @Nonnull
54 @SuppressWarnings({ "resource", "null" })
55 public Request createRequest(@Nonnull AkiwrapperImpl api) {
56 boolean hasQuerystring = this.pathHasQuerystring;
57
58 var url = new StringBuilder();
59 if (this.endpoint == GAME_SERVER) {
60 url.append(api.getServer().getUrl());
61 } else {
62 url.append(WEBSITE_URL);
63 }
64
65 url.append(this.path);
66
67 // generate and append automatic parameters
68 if (this.automaticParameters != null && !this.automaticParameters.isEmpty()) {
69 url.append(formatQuerystring(formatParameters(this.automaticParameters), hasQuerystring));
70 hasQuerystring = true;
71 }
72
73 // append urlApiWs
74 if (this.requiresUrlApiWs) {
75 url.append(formatQuerystring(api.getServer().asUrlApiWs(), hasQuerystring));
76 hasQuerystring = true;
77 }
78
79 // append session
80 if (this.requiresSession) {
81 if (api.getSession() == null)
82 throw new IllegalStateException("Session is required but not set in the Akiwrapper object");
83
84 url.append(formatQuerystring(api.getSession().asQuerystring(), hasQuerystring));
85 hasQuerystring = true;
86 }
87
88 // append frontaddr (of api key)
89 if (this.requiresFrontaddr) {
90 url.append(formatQuerystring(api.getApiKey().asQuerystringFrontaddr(), hasQuerystring));
91 hasQuerystring = true;
92 }
93
94 // append uid_ext_session (of api key)
95 if (this.requiresUidExtSession) {
96 url.append(formatQuerystring(api.getApiKey().asQuerystringUidExtSession(), hasQuerystring));
97 hasQuerystring = true;
98 }
99
100 // append profanity parameters
101 if (api.doesFilterProfanity()) {
102 if (this.profanityDisabledQuerystring != null) {
103 url.append(formatQuerystring(this.profanityDisabledQuerystring, hasQuerystring));
104 hasQuerystring = true;
105 }
106
107 } else if (this.profanityEnabledQuerystring != null) {
108 url.append(formatQuerystring(this.profanityEnabledQuerystring, hasQuerystring));
109 hasQuerystring = true;
110 }
111
112 return new Request(url.toString(), api.getUnirest(), this.mandatoryParameters, this.parameters, hasQuerystring);
113 }
114
115 @Nonnull
116 static String formatQuerystring(@Nonnull String querystring, boolean pathHasQuerystring) {
117 return (pathHasQuerystring ? '&' : '?') + querystring;
118 }
119
120 @Nonnull
121 @SuppressWarnings("null")
122 static String formatParameters(@Nonnull Map<String, ?> parameters) {
123 return parameters.entrySet().stream().filter(e -> e.getValue() != null).map(e -> {
124 Object value = e.getValue();
125 if (value instanceof Supplier)
126 value = ((Supplier<?>) value).get();
127
128 return format("%s=%s", e.getKey(), URLEncoder.encode(value.toString(), UTF_8));
129 }).collect(joining("&"));
130 }
131
132 enum Endpoint {
133 WEBSITE, // en.akinator.com
134 GAME_SERVER // srvX.akinator.com
135 }
136
137}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/RouteBuilder.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/RouteBuilder.java
new file mode 100644
index 0000000..225cc1a
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/RouteBuilder.java
@@ -0,0 +1,150 @@
1package com.github.markozajc.akiwrapper.core.utils.route;
2
3import static com.github.markozajc.akiwrapper.core.utils.route.Route.formatParameters;
4import static com.github.markozajc.akiwrapper.core.utils.route.Route.Endpoint.GAME_SERVER;
5import static java.lang.System.currentTimeMillis;
6
7import java.util.*;
8import java.util.function.Supplier;
9
10import javax.annotation.*;
11
12import com.github.markozajc.akiwrapper.core.utils.route.Route.Endpoint;
13
14@SuppressWarnings("javadoc") // internal util
15public class RouteBuilder {
16
17 @Nonnull private final String path;
18 @Nonnull private Endpoint endpoint = GAME_SERVER;
19 private boolean requiresSession = false;
20 private boolean requiresFrontaddr = false;
21 private boolean requiresUidExtSession = false;
22 private boolean requiresUrlApiWs = false;
23 private Map<String, String> constantParameters;
24 private Map<String, Supplier<String>> automaticParameters;
25 private Map<String, String> profanityDisabledParameters;
26 private Map<String, String> profanityEnabledParameters;
27 private Set<String> mandatoryParameters;
28 private Map<String, String> parameters; // using Map instead of Set to avoid conversion later
29
30 public RouteBuilder(@Nonnull String path) {
31 this.path = path;
32 }
33
34 @Nonnull
35 public RouteBuilder endpoint(@Nonnull Endpoint endpoint) {
36 this.endpoint = endpoint;
37 return this;
38 }
39
40 @Nonnull
41 public RouteBuilder requiresSession() {
42 this.requiresSession = true;
43 return this;
44 }
45
46 @Nonnull
47 public RouteBuilder requiresFrontaddr() {
48 this.requiresFrontaddr = true;
49 return this;
50 }
51
52 @Nonnull
53 public RouteBuilder requiresUidExtSession() {
54 this.requiresUidExtSession = true;
55 return this;
56 }
57
58 @Nonnull
59 public RouteBuilder requiresUrlApiWs() {
60 this.requiresUrlApiWs = true;
61 return this;
62 }
63
64 @Nonnull
65 public RouteBuilder constantParameter(@Nonnull String key, @Nonnull String value) {
66 if (this.constantParameters == null)
67 this.constantParameters = new HashMap<>();
68 this.constantParameters.put(key, value);
69 return this;
70 }
71
72 @Nonnull
73 public RouteBuilder automaticParameter(@Nonnull String key, @Nonnull Supplier<String> valueSupplier) {
74 if (this.automaticParameters == null)
75 this.automaticParameters = new HashMap<>();
76 this.automaticParameters.put(key, valueSupplier);
77 return this;
78 }
79
80 @Nonnull
81 public RouteBuilder profanityDisabledParameter(@Nonnull String key, @Nonnull String value) {
82 if (this.profanityDisabledParameters == null)
83 this.profanityDisabledParameters = new HashMap<>();
84 this.profanityDisabledParameters.put(key, value);
85 return this;
86 }
87
88 @Nonnull
89 public RouteBuilder profanityEnabledParameter(@Nonnull String key, @Nonnull String value) {
90 if (this.profanityEnabledParameters == null)
91 this.profanityEnabledParameters = new HashMap<>();
92 this.profanityEnabledParameters.put(key, value);
93 return this;
94 }
95
96 @Nonnull
97 public RouteBuilder mandatoryParameter(@Nonnull String key) {
98 putParameter(key, null);
99
100 if (this.mandatoryParameters == null)
101 this.mandatoryParameters = new HashSet<>();
102 this.mandatoryParameters.add(key);
103
104 return this;
105 }
106
107 @Nonnull
108 public RouteBuilder optionalParameter(@Nonnull String key) {
109 putParameter(key, null);
110 return this;
111 }
112
113 @Nonnull
114 public RouteBuilder defaultParameter(@Nonnull String key, @Nonnull String value) {
115 putParameter(key, value);
116 return this;
117 }
118
119 private void putParameter(@Nonnull String key, @Nullable String value) {
120 if (this.parameters == null)
121 this.parameters = new HashMap<>();
122
123 this.parameters.put(key, value);
124 }
125
126 @SuppressWarnings("null")
127 public Route build() {
128 // these two are required by all routes (the callback one might only be required for
129 // NEW_SESSION, but it changes response parsing logic so I'm opting to just put it
130 // into all requests for future proofing if not anything else)
131 automaticParameter("_", () -> Long.toString(currentTimeMillis()));
132 constantParameter("callback", "jQuery");
133
134 String processedPath = this.path;
135 if (this.constantParameters != null)
136 processedPath += "?" + formatParameters(this.constantParameters);
137
138 String profanityDisabledQuerystring =
139 this.profanityDisabledParameters == null ? null : formatParameters(this.profanityDisabledParameters);
140
141 String profanityEnabledQuerystring =
142 this.profanityDisabledParameters == null ? null : formatParameters(this.profanityEnabledParameters);
143
144 return new Route(processedPath, this.endpoint, this.requiresSession, this.requiresFrontaddr,
145 this.requiresUidExtSession, this.requiresUrlApiWs, this.automaticParameters,
146 profanityDisabledQuerystring, profanityEnabledQuerystring, this.mandatoryParameters,
147 this.parameters, this.constantParameters != null);
148 }
149
150}
diff --git a/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Routes.java b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Routes.java
new file mode 100644
index 0000000..33ed7b7
--- /dev/null
+++ b/src/main/java/com/github/markozajc/akiwrapper/core/utils/route/Routes.java
@@ -0,0 +1,154 @@
1package com.github.markozajc.akiwrapper.core.utils.route;
2
3import static com.github.markozajc.akiwrapper.core.utils.route.Route.Endpoint.*;
4
5@SuppressWarnings("javadoc") // internal util
6public final class Routes {
7
8 /*
9 * Certain constant parameters are marked with "?" - this means that the parameter is
10 * set by the website (though not necessarily required), but I'm not sure of its
11 * function or significance. Feel free to open an issue if you know what any of them
12 * do so that I can note it down and possibly use them in the wrapper.
13 */
14
15 private static final String PARAMETER_QUESTION_FILTER = "question_filter";
16
17 private static final String VALUE_QUESTION_FILTER_PROFANITY_ENABLED = "";
18 private static final String VALUE_QUESTION_FILTER_PROFANITY_DISABLED = "cat=1";
19
20 public static final String PARAMETER_STEP = "step";
21 public static final String PARAMETER_ANSWER = "answer";
22 public static final String PARAMETER_SIZE = "size";
23 public static final String PARAMETER_MAX_PIC_WIDTH = "max_pic_width";
24 public static final String PARAMETER_MAX_PIC_HEIGHT = "max_pic_height";
25 public static final String PARAMETER_ELEMENT = "element";
26
27 /**
28 * Creates a new game session that all further state is associated with.<br>
29 * <i>This route takes no parameters.</i>
30 */
31 public static final Route NEW_SESSION = new RouteBuilder("/new_session").endpoint(WEBSITE)
32 .requiresUrlApiWs()
33 .requiresFrontaddr()
34 .requiresUidExtSession()
35 .constantParameter("player", "website-desktop") // ?
36 .constantParameter("partner", "1") // ?
37 .constantParameter("constraint", "ETAT<>'AV'") // ?
38 .profanityEnabledParameter("childMod", "")
39 .profanityEnabledParameter("soft_constraint", "")
40 .profanityEnabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_ENABLED)
41 .profanityDisabledParameter("childMod", "true")
42 .profanityDisabledParameter("soft_constraint", "ETAT='EN'")
43 .profanityDisabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_DISABLED)
44 .build();
45
46 /**
47 * Answers the current question and fetches the next one. <br>
48 * Parameters:
49 * <ul>
50 * <li>{@link Routes#PARAMETER_STEP}: must always point to the current zero-index
51 * step</li>
52 * <li>{@link Routes#PARAMETER_ANSWER}: the answer to the current question</li>
53 * </ul>
54 * <b>This route requires a session</b>
55 */
56 public static final Route ANSWER = new RouteBuilder("/answer_api").endpoint(WEBSITE)
57 .requiresUrlApiWs()
58 .requiresFrontaddr()
59 .requiresSession()
60 .profanityEnabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_ENABLED)
61 .profanityDisabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_DISABLED)
62 .mandatoryParameter(PARAMETER_STEP)
63 .mandatoryParameter(PARAMETER_ANSWER)
64 .build();
65
66 /**
67 * Cancels (undoes) an answer and fetches the previous question. Parameters:
68 * <ul>
69 * <li>{@link Routes#PARAMETER_STEP}: must always point to the current zero-index
70 * step</li>
71 * </ul>
72 * <b>This route requires a session</b>
73 */
74 public static final Route CANCEL_ANSWER = new RouteBuilder("/cancel_answer").endpoint(GAME_SERVER)
75 .requiresSession()
76 .constantParameter(PARAMETER_ANSWER, "-1")
77 .profanityEnabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_ENABLED)
78 .profanityDisabledParameter(PARAMETER_QUESTION_FILTER, VALUE_QUESTION_FILTER_PROFANITY_DISABLED)
79 .mandatoryParameter(PARAMETER_STEP)
80 .build();
81
82 /**
83 * Lists available guesses. Parameters:
84 * <ul>
85 * <li>{@link Routes#PARAMETER_STEP}: must always point to the current zero-index
86 * step</li>
87 * <li><i>(optional)</i> {@link Routes#PARAMETER_SIZE}: the number of guesses to
88 * fetch (or all if not set)</li>
89 * <li><i>(defaults to 246)</i> {@link Routes#PARAMETER_MAX_PIC_WIDTH}: presumably
90 * the max width of the returned image, but I haven't checked if that's actually
91 * true</li>
92 * <li><i>(defaults to 294)</i> {@link Routes#PARAMETER_MAX_PIC_HEIGHT}: ditto for
93 * height</li>
94 * </ul>
95 * <b>This route requires a session</b>
96 */
97 public static final Route LIST = new RouteBuilder("/list").endpoint(GAME_SERVER)
98 .requiresSession()
99 .constantParameter("pref_photos", "VO-OK") // ?
100 .constantParameter("duel_allowed", "1") // ?
101 .constantParameter("mode_question", "0") // ?
102 .defaultParameter(PARAMETER_MAX_PIC_HEIGHT, "294")
103 .defaultParameter(PARAMETER_MAX_PIC_WIDTH, "246")
104 .mandatoryParameter(PARAMETER_STEP)
105 .optionalParameter(PARAMETER_SIZE)
106 .build();
107
108 /**
109 * <b>IMPORTANT: This route is EXCLUDED from tests!</b> Because automated tests don't
110 * tend to behave like players, calling this during testing might introduce faulty
111 * data into Akinator's algorithm, so please avoid doing that.<br>
112 * Exclude a guess. Apparently this won't prevent it from showing up in {@link #LIST}
113 * for whatever reason, but it might improve the further questions. Parameters:
114 * <ul>
115 * <li>{@link Routes#PARAMETER_STEP}: must always point to the current zero-index
116 * step</li>
117 * </ul>
118 * <b>This route requires a session</b>
119 *
120 * @apiNote Considering that it doesn't take a question ID parameter and that it's
121 * called right after a guess is rejected on the website, I can only assume
122 * that this excludes the top guess.
123 */
124 public static final Route EXCLUSION = new RouteBuilder("/exclusion").endpoint(GAME_SERVER)
125 .requiresSession()
126 .constantParameter("forward_answer", "1") // ?
127 .mandatoryParameter(PARAMETER_STEP)
128 .build();
129
130 /**
131 * <b>IMPORTANT: This route is EXCLUDED from tests!</b> Because automated tests don't
132 * tend to behave like players, calling this during testing might introduce faulty
133 * data into Akinator's algorithm, so please avoid doing that.<br>
134 * Confirm a guess. While this doesn't affect the current session, because it's
135 * called at the very end, it likely affects Akinator's algorithm and associates the
136 * taken answer route with the confirmed guess, thus improving the game for everyone.
137 * Parameters:
138 * <ul>
139 * <li>{@link Routes#PARAMETER_STEP}: must always point to the current zero-index
140 * step</li>
141 * <li>{@link Routes#PARAMETER_ELEMENT}: the guess ID to confirm</li>
142 * </ul>
143 * <b>This route requires a session</b>
144 */
145 public static final Route CHOICE = new RouteBuilder("/choice").endpoint(GAME_SERVER)
146 .requiresSession()
147 .constantParameter("duel_allowed", "1") // ?
148 .mandatoryParameter(PARAMETER_STEP)
149 .mandatoryParameter(PARAMETER_ELEMENT)
150 .build();
151
152 private Routes() {}
153
154}
diff --git a/src/test/java/com/github/markozajc/akiwrapper/IntegrationTest.java b/src/test/java/com/github/markozajc/akiwrapper/IntegrationTest.java
index 0bd9ec9..476a20d 100644
--- a/src/test/java/com/github/markozajc/akiwrapper/IntegrationTest.java
+++ b/src/test/java/com/github/markozajc/akiwrapper/IntegrationTest.java
@@ -1,5 +1,6 @@
1package com.github.markozajc.akiwrapper; 1package com.github.markozajc.akiwrapper;
2 2
3import static com.github.markozajc.akiwrapper.Akiwrapper.Answer.YES;
3import static java.lang.String.format; 4import static java.lang.String.format;
4import static org.junit.jupiter.api.Assumptions.abort; 5import static org.junit.jupiter.api.Assumptions.abort;
5import static org.slf4j.LoggerFactory.getLogger; 6import static org.slf4j.LoggerFactory.getLogger;
@@ -11,17 +12,19 @@ import javax.annotation.*;
11 12
12import org.junit.jupiter.params.ParameterizedTest; 13import org.junit.jupiter.params.ParameterizedTest;
13import org.junit.jupiter.params.provider.*; 14import org.junit.jupiter.params.provider.*;
15import org.opentest4j.TestAbortedException;
14import org.slf4j.Logger; 16import org.slf4j.Logger;
15 17
16import com.github.markozajc.akiwrapper.Akiwrapper.Answer; 18import com.github.markozajc.akiwrapper.Akiwrapper.Answer;
17import com.github.markozajc.akiwrapper.core.entities.*; 19import com.github.markozajc.akiwrapper.core.entities.*;
18import com.github.markozajc.akiwrapper.core.entities.Server.*; 20import com.github.markozajc.akiwrapper.core.entities.Server.*;
19import com.github.markozajc.akiwrapper.core.exceptions.ServerNotFoundException; 21import com.github.markozajc.akiwrapper.core.exceptions.*;
20 22
21import static org.junit.jupiter.api.Assertions.*; 23import static org.junit.jupiter.api.Assertions.*;
22 24
23class IntegrationTest { 25class IntegrationTest {
24 26
27 private static final String FETCHING_GUESSES_THROWS = "Fetching guesses throws";
25 private static final String SERVER_GUESSTYPE_NO_MATCH = 28 private static final String SERVER_GUESSTYPE_NO_MATCH =
26 "The wanted and actual guess type of the server don't match."; 29 "The wanted and actual guess type of the server don't match.";
27 private static final String SERVER_LANGUAGE_NO_MATCH = "The wanted and actual language of the server don't match."; 30 private static final String SERVER_LANGUAGE_NO_MATCH = "The wanted and actual language of the server don't match.";
@@ -36,65 +39,139 @@ class IntegrationTest {
36 @ParameterizedTest 39 @ParameterizedTest
37 @MethodSource("generateTestAkiwrapper") 40 @MethodSource("generateTestAkiwrapper")
38 void testAkiwrapper(@Nonnull Language language, @Nonnull GuessType guessType) { 41 void testAkiwrapper(@Nonnull Language language, @Nonnull GuessType guessType) {
39 Logger log = getLogger(format("%s-%s", language, guessType));
40 log.info("Establishing connection");
41 Akiwrapper api;
42 try { 42 try {
43 api = new AkiwrapperBuilder().setLanguage(language).setGuessType(guessType).build(); 43 Logger log = getLogger(format("%s-%s", language, guessType));
44 } catch (ServerNotFoundException e) { 44 log.info("Establishing connection");
45 abort("Current combination not supported, server wasn't found."); 45 Akiwrapper api;
46 return; 46 try {
47 api = new AkiwrapperBuilder().setLanguage(language).setGuessType(guessType).build();
48 } catch (ServerNotFoundException e) {
49 abort("Current combination not supported, server wasn't found.");
50 return;
51 }
52
53 Question initialQuestion = api.getQuestion();
54 testInitialState(log, api, initialQuestion, language, guessType);
55 int expectedState = testAnswering(log, api);
56 testUndo(log, api, initialQuestion, expectedState);
57 testExhaustion(log, api);
58 } catch (TestAbortedException e) {
59 throw e;
60
61 } catch (Exception e) {
62 e.printStackTrace();
63 fail("Got an exception running the test");
47 } 64 }
65 }
48 66
67 private static void testInitialState(@Nonnull Logger log, @Nonnull Akiwrapper api,
68 @Nullable Question initialQuestion, @Nonnull Language language,
69 @Nonnull GuessType guessType) {
49 log.info("Asserting the current state."); 70 log.info("Asserting the current state.");
50 Question initialQuestion = api.getQuestion(); 71 checkQuestion(0, initialQuestion);
51 int expectedState = 0; 72 assertDoesNotThrow(() -> api.getGuesses(), FETCHING_GUESSES_THROWS);
52 checkQuestion(initialQuestion, expectedState);
53 assertEquals(language, api.getServer().getLanguage(), SERVER_LANGUAGE_NO_MATCH); 73 assertEquals(language, api.getServer().getLanguage(), SERVER_LANGUAGE_NO_MATCH);
54 assertEquals(guessType, api.getServer().getGuessType(), SERVER_GUESSTYPE_NO_MATCH); 74 assertEquals(guessType, api.getServer().getGuessType(), SERVER_GUESSTYPE_NO_MATCH);
55 log.trace("API server URL: {}", api.getServer().getUrl()); 75 log.trace("API server URL: {}", api.getServer().getUrl());
76 }
56 77
78 private static int testAnswering(@Nonnull Logger log, @Nonnull Akiwrapper api) {
57 log.info("Advancing {} steps (one time for each possible answer).", Answer.values().length); 79 log.info("Advancing {} steps (one time for each possible answer).", Answer.values().length);
80 int expectedState = 0;
58 for (Answer answer : Answer.values()) { 81 for (Answer answer : Answer.values()) {
59 log.debug("Answering with {} and checking the question.", answer.name()); 82 log.info("Answering with {} and checking the state (step={}).", answer.name(), api.getStep());
60 Question newQuestion = api.answer(answer); 83 Question newQuestion = api.answer(answer);
61 assertEquals(newQuestion, api.getQuestion(), QUESTION_CURRENT_NO_MATCH); 84 assertEquals(newQuestion, api.getQuestion(), QUESTION_CURRENT_NO_MATCH);
62 expectedState++; 85 expectedState++;
63 checkQuestion(api.getQuestion(), expectedState); 86 assertEquals(expectedState, api.getStep());
87 checkQuestion(expectedState, api.getQuestion());
88 checkGuessCount(log, api);
64 } 89 }
65 90
66 log.info("Fetching guesses.", Answer.values().length); 91 log.info("Asserting the current state.");
67 List<Guess> guesses = api.getGuesses(); 92 fetchAndDebugGuesses(log, api);
68 debugGuesses(log, guesses); 93 return expectedState;
94 }
69 95
96 private static void testUndo(@Nonnull Logger log, @Nonnull Akiwrapper api, @Nullable Question initialQuestion,
97 int initialExpectedState) {
70 log.info("Advancing -{} steps (using undo).", Answer.values().length); 98 log.info("Advancing -{} steps (using undo).", Answer.values().length);
99 int expectedState = initialExpectedState;
71 for (int i = 0; i < Answer.values().length; i++) { 100 for (int i = 0; i < Answer.values().length; i++) {
101 log.info("Undoing a step and checking the state (step={}).", api.getStep());
72 Question undoneQuestion = api.undoAnswer(); 102 Question undoneQuestion = api.undoAnswer();
73 assertEquals(undoneQuestion, api.getQuestion(), QUESTION_CURRENT_NO_MATCH); 103 assertEquals(undoneQuestion, api.getQuestion(), QUESTION_CURRENT_NO_MATCH);
74 expectedState--; 104 expectedState--;
75 checkQuestion(api.getQuestion(), expectedState); 105 assertEquals(expectedState, api.getStep());
106 checkQuestion(expectedState, api.getQuestion());
107 checkGuessCount(log, api);
76 } 108 }
77 109
78 log.info("Asserting the final state."); 110 log.info("Asserting the current state.");
79 assertNull(api.undoAnswer()); 111 assertThrows(UndoOutOfBoundsException.class, () -> api.undoAnswer());
80 checkQuestion(api.getQuestion(), 0); 112 checkQuestion(0, api.getQuestion());
113 assertDoesNotThrow(() -> api.getGuesses(), FETCHING_GUESSES_THROWS);
81 Question currentQuestion = api.getQuestion(); 114 Question currentQuestion = api.getQuestion();
82 if (initialQuestion != null && currentQuestion != null) { 115 if (initialQuestion != null && currentQuestion != null)
83 assertEquals(initialQuestion.getQuestion(), currentQuestion.getQuestion(), QUESTION_INITIAL_NO_MATCH); 116 assertEquals(initialQuestion.getQuestion(), currentQuestion.getQuestion(), QUESTION_INITIAL_NO_MATCH);
117 else
118 fail("initialQuestion or currentQuestion were somehow null");
119 // using this syntax instead of assertNotNull to please null analysis of @Nullable
120 }
121
122 private static void testExhaustion(@Nonnull Logger log, @Nonnull Akiwrapper api) {
123 log.info("Exhausting questions by answering YES to all.");
124 var lastQuestion = api.getQuestion();
125 assertNotNull(lastQuestion, "Current question is already null");
126
127 int i = api.getStep();
128 while (true) {
129 checkQuestion(i, api.getQuestion());
130 assertEquals(i, api.getStep());
131
132 var question = api.answer(YES);
133 if (question == null) {
134 log.info("Ran out at step {}.", api.getStep());
135 break;
136
137 } else {
138 log.info("Exhausting questions (step={})", api.getStep());
139 checkQuestion(++i, question);
140 }
141
142 if (i > 80)
143 fail("Got over step 80, API must have changed. Ensure there are no side effects and find the new limit.");
84 } 144 }
85 // Neither of those can be null due to checkQuestion checking (and failing on)
86 // nullability.
87 145
146 log.info("Asserting the current state.");
147 assertThrows(QuestionsExhaustedException.class, () -> api.answer(YES));
148 assertThrows(QuestionsExhaustedException.class, () -> api.undoAnswer());
149 assertDoesNotThrow(() -> api.getGuesses());
150
151 fetchAndDebugGuesses(log, api);
152 }
153
154 private static void checkGuessCount(Logger log, Akiwrapper api) {
155 for (int i = 1; i < 5; i++) {
156 log.info("Fetching {} guesses.", i);
157 assertTrue(api.getGuesses(i).size() <= i, "Got more guesses than requested from the API");
158 }
159 }
160
161 private static void fetchAndDebugGuesses(Logger log, Akiwrapper api) {
162 log.info("Fetching all guesses.", Answer.values().length);
163 List<Guess> guesses = api.getGuesses();
164 debugGuesses(log, guesses);
88 } 165 }
89 166
90 private static void debugGuesses(Logger log, List<Guess> guesses) { 167 private static void debugGuesses(Logger log, List<Guess> guesses) {
91 log.debug("There are {} guesses.", guesses.size()); 168 log.info("There are {} guesses.", guesses.size());
92 for (Guess guess : guesses) { 169 for (Guess guess : guesses) {
93 log.trace("{} - {}", guess.getProbability(), guess.getName()); 170 log.info("{} - {}", guess.getProbability(), guess.getName());
94 } 171 }
95 } 172 }
96 173
97 private static void checkQuestion(@Nullable Question question, int expectedState) { 174 private static void checkQuestion(int expectedState, @Nullable Question question) {
98 if (question == null) { 175 if (question == null) {
99 fail(QUESTION_NULL); 176 fail(QUESTION_NULL);
100 } else { 177 } else {
diff --git a/src/test/java/com/github/markozajc/akiwrapper/core/RouteTest.java b/src/test/java/com/github/markozajc/akiwrapper/core/RouteTest.java
deleted file mode 100644
index 79b214c..0000000
--- a/src/test/java/com/github/markozajc/akiwrapper/core/RouteTest.java
+++ /dev/null
@@ -1,43 +0,0 @@
1package com.github.markozajc.akiwrapper.core;
2
3import static kong.unirest.Unirest.spawnInstance;
4
5import org.json.JSONObject;
6import org.junit.jupiter.api.Test;
7
8import com.github.markozajc.akiwrapper.core.exceptions.*;
9
10import static org.junit.jupiter.api.Assertions.*;
11
12class RouteTest {
13
14 @Test
15 @SuppressWarnings("null")
16 void testMissingParameters() {
17 try (var unirest = spawnInstance()) {
18 assertThrows(IllegalArgumentException.class,
19 () -> Route.NEW_SESSION.createRequest(unirest, "", false /* and no parameters */));
20 }
21 }
22
23 @Test
24 void testTestResponse() {
25 JSONObject base = new JSONObject();
26
27 base.put("completion", "KO - SERVER DOWN");
28 assertThrows(ServerUnavailableException.class, () -> Route.Request.ensureSuccessful(base));
29
30 base.put("completion", "KO - TEST REASON");
31 assertThrows(StatusException.class, () -> Route.Request.ensureSuccessful(base));
32
33 base.put("completion", "WARN - TEST REASON");
34 assertDoesNotThrow(() -> Route.Request.ensureSuccessful(base));
35
36 base.put("completion", "OK - TEST REASON");
37 assertDoesNotThrow(() -> Route.Request.ensureSuccessful(base));
38
39 base.put("completion", "OK");
40 assertDoesNotThrow(() -> Route.Request.ensureSuccessful(base));
41 }
42
43}
diff --git a/src/test/java/com/github/markozajc/akiwrapper/core/entities/IdentifiableTest.java b/src/test/java/com/github/markozajc/akiwrapper/core/entities/IdentifiableTest.java
index 821f7a2..6996a58 100644
--- a/src/test/java/com/github/markozajc/akiwrapper/core/entities/IdentifiableTest.java
+++ b/src/test/java/com/github/markozajc/akiwrapper/core/entities/IdentifiableTest.java
@@ -13,13 +13,9 @@ class IdentifiableTest {
13 } 13 }
14 14
15 @Test 15 @Test
16 @SuppressWarnings("null")
16 void testGetIdLongLong() { 17 void testGetIdLongLong() {
17 String maxLongString = Long.toString(Long.MAX_VALUE); 18 String maxLongString = Long.toString(Long.MAX_VALUE);
18 if (maxLongString == null) {
19 fail(); // Sorry suppress warnings broke and would let me ignore null warnings
20 return; // Also because eclipse doesn't realize that fail throws
21 }
22
23 Identifiable identifiable = () -> maxLongString; 19 Identifiable identifiable = () -> maxLongString;
24 assertEquals(Long.MAX_VALUE, identifiable.getIdLong()); 20 assertEquals(Long.MAX_VALUE, identifiable.getIdLong());
25 } 21 }
diff --git a/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImplTest.java b/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImplTest.java
deleted file mode 100644
index f67c586..0000000
--- a/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/ServerListImplTest.java
+++ /dev/null
@@ -1,67 +0,0 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable;
2
3import static com.github.markozajc.akiwrapper.core.entities.Server.GuessType.ANIMAL;
4import static com.github.markozajc.akiwrapper.core.entities.Server.Language.*;
5import static java.util.Arrays.asList;
6
7import java.util.*;
8
9import org.junit.jupiter.api.Test;
10
11import com.github.markozajc.akiwrapper.core.entities.*;
12
13import static org.junit.jupiter.api.Assertions.*;
14
15class ServerListImplTest {
16
17 @SuppressWarnings("null")
18 @Test
19 void testEmptyCollection() {
20 List<Server> emptyList = Collections.emptyList();
21 assertThrows(IllegalArgumentException.class, () -> new ServerListImpl(emptyList));
22 }
23
24 @SuppressWarnings("null")
25 @Test
26 void testServersCollection() {
27 List<Server> serversList = asList(new ServerImpl("x", ARABIC, ANIMAL), new ServerImpl("x", FRENCH, ANIMAL));
28 ServerList serverList = new ServerListImpl(serversList);
29 assertEquals(serversList.size() - 1, serverList.getRemainingSize());
30 assertEquals(serversList, serverList.getServers());
31 assertEquals(ARABIC, serverList.getLanguage());
32 assertTrue(serverList.hasNext());
33 assertTrue(serverList.next());
34 assertEquals(FRENCH, serverList.getLanguage());
35 assertFalse(serverList.hasNext());
36 assertFalse(serverList.next());
37 }
38
39 @SuppressWarnings("null")
40 @Test
41 void testNestedServersCollection() {
42 List<Server> serversList = asList(new ServerImpl("x", ARABIC, ANIMAL), new ServerImpl("x", FRENCH, ANIMAL));
43 ServerList serverList = new ServerListImpl(Arrays.asList(new ServerListImpl(serversList)));
44 assertEquals(serversList.size() - 1, serverList.getRemainingSize());
45 assertEquals(serversList, serverList.getServers());
46 assertEquals(ARABIC, serverList.getLanguage());
47 assertTrue(serverList.hasNext());
48 assertTrue(serverList.next());
49 assertEquals(FRENCH, serverList.getLanguage());
50 assertFalse(serverList.hasNext());
51 assertFalse(serverList.next());
52 }
53
54 @Test
55 void testMixedServersCollection() {
56 ServerList serverList = new ServerListImpl(new ServerImpl("x", ARABIC, ANIMAL),
57 new ServerListImpl(new ServerImpl("x", FRENCH, ANIMAL)));
58 assertEquals(2 /* amount of servers */ - 1, serverList.getRemainingSize());
59 assertEquals(ARABIC, serverList.getLanguage());
60 assertTrue(serverList.hasNext());
61 assertTrue(serverList.next());
62 assertEquals(FRENCH, serverList.getLanguage());
63 assertFalse(serverList.hasNext());
64 assertFalse(serverList.next());
65 }
66
67}
diff --git a/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImplTest.java b/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImplTest.java
index 9889ec3..ef90421 100644
--- a/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImplTest.java
+++ b/src/test/java/com/github/markozajc/akiwrapper/core/entities/impl/immutable/StatusImplTest.java
@@ -1,46 +1,62 @@
1package com.github.markozajc.akiwrapper.core.entities.impl.immutable; 1package com.github.markozajc.akiwrapper.core.entities.impl.immutable;
2 2
3import static com.github.markozajc.akiwrapper.core.entities.Status.Reason.*;
3import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE; 4import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE;
4 5
5import java.util.stream.Stream; 6import java.util.stream.Stream;
6 7
7import javax.annotation.Nonnull; 8import javax.annotation.Nonnull;
8 9
10import org.junit.jupiter.api.Test;
9import org.junit.jupiter.params.ParameterizedTest; 11import org.junit.jupiter.params.ParameterizedTest;
10import org.junit.jupiter.params.provider.*; 12import org.junit.jupiter.params.provider.*;
11 13
12import com.github.markozajc.akiwrapper.core.entities.Status;
13import com.github.markozajc.akiwrapper.core.entities.Status.Level; 14import com.github.markozajc.akiwrapper.core.entities.Status.Level;
15import com.github.markozajc.akiwrapper.core.entities.impl.StatusImpl;
14 16
15import static org.junit.jupiter.api.Assertions.*; 17import static org.junit.jupiter.api.Assertions.*;
16 18
17class StatusImplTest { 19class StatusImplTest {
18 20
21 @Test
22 void testReason() {
23 assertEquals(OK, StatusImpl.fromCompletion("OK").getReason());
24 assertEquals(QUESTIONS_EXHAUSTED, StatusImpl.fromCompletion("WARN - NO QUESTION").getReason());
25 assertEquals(SERVER_FAILURE, StatusImpl.fromCompletion("KO - TECHNICAL ERROR").getReason());
26 assertEquals(LIBRARY_FAILURE, StatusImpl.fromCompletion("KO - UNAUTHORIZED").getReason());
27 assertEquals(LIBRARY_FAILURE, StatusImpl.fromCompletion("KO - ELEM LIST IS EMPTY").getReason());
28 assertEquals(LIBRARY_FAILURE, StatusImpl.fromCompletion("KO - MISSING KEY").getReason());
29 assertEquals(LIBRARY_FAILURE, StatusImpl.fromCompletion("KO - MISSING PARAMETERS").getReason());
30 assertEquals(UNKNOWN, StatusImpl.fromCompletion("OK - MESSAGE").getReason());
31 assertEquals(UNKNOWN, StatusImpl.fromCompletion("WARN - MESSAGE").getReason());
32 assertEquals(UNKNOWN, StatusImpl.fromCompletion("KO - MESSAGE").getReason());
33 }
34
19 @ParameterizedTest 35 @ParameterizedTest
20 @EnumSource(value = Level.class, mode = EXCLUDE) 36 @EnumSource(value = Level.class, mode = EXCLUDE)
21 void testStringConstructorNoReason(@Nonnull Level level) { 37 void testStringConstructorNoMessage(@Nonnull Level level) {
22 @SuppressWarnings("null") 38 @SuppressWarnings("null")
23 Status status = new StatusImpl(level.toString()); 39 var status = StatusImpl.fromCompletion(level.toString());
24 assertEquals(level, status.getLevel()); 40 assertEquals(level, status.getLevel());
25 assertNull(status.getReason()); 41 assertNull(status.getMessage());
26 } 42 }
27 43
28 @ParameterizedTest 44 @ParameterizedTest
29 @MethodSource("generateTestStringConstructorWithReason") 45 @MethodSource("generateTestStringConstructorWithMessage")
30 void testStringConstructorWithReason(@Nonnull Level level, @Nonnull String reason) { 46 void testStringConstructorWithMessage(@Nonnull Level level, @Nonnull String message) {
31 String completion = level.toString() + " - " + reason; 47 String completion = level.toString() + " - " + message;
32 Status status = new StatusImpl(completion); 48 var status = StatusImpl.fromCompletion(completion);
33 assertEquals(level, status.getLevel()); 49 assertEquals(level, status.getLevel());
34 assertEquals(reason, status.getReason()); 50 assertEquals(message, status.getMessage());
35 } 51 }
36 52
37 private static Stream<Arguments> generateTestStringConstructorWithReason() { 53 private static Stream<Arguments> generateTestStringConstructorWithMessage() {
38 String[] reasons = { "", "reason", "reason with spaces", "UPPERCASE", "UPPERCASE WITH SPACES" }; 54 String[] messages = { "", "message", "message with spaces", "UPPERCASE", "UPPERCASE WITH SPACES" };
39 Arguments[] arguments = new Arguments[Level.values().length * reasons.length]; 55 Arguments[] arguments = new Arguments[Level.values().length * messages.length];
40 int i = 0; 56 int i = 0;
41 for (Level level : Level.values()) 57 for (Level level : Level.values())
42 for (String reason : reasons) { 58 for (String message : messages) {
43 arguments[i] = Arguments.of(level, reason); 59 arguments[i] = Arguments.of(level, message);
44 i++; 60 i++;
45 } 61 }
46 return Stream.of(arguments); 62 return Stream.of(arguments);