Skip to content

Commit 40010bd

Browse files
committed
Implement unscrambling reaction tests
Part of #718
1 parent 8114d69 commit 40010bd

7 files changed

Lines changed: 227 additions & 2 deletions

File tree

data/irc_messages.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@
5252
"fightclub-winner": "<color:13>*** %s (Id:%d) has won the fight against %s (Id:%d) with a score of %d : %d.",
5353
"fightclub-draw": "<color:13>*** The fight between %s (Id:%d) and %s (Id:%d) ended in a draw.",
5454

55+
"reaction-calculate": "<color:6>*** First player to calculate %s wins %$!",
5556
"reaction-remember": "<color:6>*** Players have been asked to remember a number in order to win %$!",
5657
"reaction-remember-2": "<color:6>*** Players have been asked to repeat the number they were asked to remember to win %$!",
5758
"reaction-repeat": "<color:6>*** First player to type %s wins %$!",
58-
"reaction-calculate": "<color:6>*** First player to calculate %s wins %$!",
5959
"reaction-result": "<color:6>*** %s (Id:%d) has won the reaction test in %d seconds!",
60+
"reaction-unscramble": "<color:6>*** First player to unscramble \"%s\" wins %$!",
6061

6162
"merchant": "<color:3>*** The merchant wants to purchase a %{1}s for %{0}$."
6263
}

data/messages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@
500500
"REACTION_TEST_ANNOUNCE_REMEMBER": "{FFFF00}You've been asked to remember the number %d...",
501501
"REACTION_TEST_ANNOUNCE_REMEMBER_2": "{FFFF00}The first one to repeat the number you had to remember wins %$!",
502502
"REACTION_TEST_ANNOUNCE_REPEAT": "{FFFF00}The first one who repeats \"%s\" wins %$!",
503+
"REACTION_TEST_ANNOUNCE_UNSCRAMBLE": "{FFFF00}The first one who unscrambles \"%s\" wins %$!",
503504
"REACTION_TEST_ANNOUNCE_WINNER_FIRST": "{FFFF00}%s has won the reaction test in %.2f seconds. This is their first win!",
504505
"REACTION_TEST_ANNOUNCE_WINNER_SECOND": "{FFFF00}%s has won the reaction test in %.2f seconds. They've won once before.",
505506
"REACTION_TEST_ANNOUNCE_WINNER": "{FFFF00}%s has won the reaction test in %.2f seconds. They've won %d times before.",

javascript/features/reaction_tests/reaction_tests.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CalculationStrategy } from 'features/reaction_tests/strategies/calculat
66
import { Feature } from 'components/feature_manager/feature.js';
77
import { RandomStrategy } from 'features/reaction_tests/strategies/random_strategy.js';
88
import { RememberStrategy } from 'features/reaction_tests/strategies/remember_strategy.js';
9+
import { UnscrambleStrategy } from 'features/reaction_tests/strategies/unscramble_strategy.js';
910

1011
import * as achievements from 'features/collectables/achievements.js';
1112

@@ -65,6 +66,7 @@ export default class ReactionTests extends Feature {
6566
CalculationStrategy,
6667
RandomStrategy,
6768
RememberStrategy,
69+
UnscrambleStrategy,
6870
];
6971

7072
// Immediately schedule the first reaction test to start.

javascript/features/reaction_tests/strategies/random_strategy.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,6 @@ export class RandomStrategy extends Strategy {
4848

4949
// Verifies whether the |message| is, or contains, the answer to this reaction test.
5050
verify(message) {
51-
return message.toUpperCase() === this.answer_;
51+
return message.toUpperCase().startsWith(this.answer_);
5252
}
5353
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
import { Strategy } from 'features/reaction_tests/strategy.js';
6+
7+
import { random } from 'base/random.js';
8+
9+
// File that contains the word definitions for scrambled word games.
10+
const kWordDefinitionFile = 'data/scrambled_words.json';
11+
12+
// Utility function to determine whether the given |charCode| should be scrambled.
13+
function shouldScramble(charCode) {
14+
return (charCode >= 48 && charCode <= 57) || /* numbers */
15+
(charCode >= 65 && charCode <= 90) || /* upper case */
16+
(charCode >= 97 && charCode <= 122); /* lower case */
17+
}
18+
19+
// This strategy works by scrambling the characters in a list of predefined phrases, and requiring
20+
// players to guess the right word. The actual list is defined in |kWordDefinitionFile|.
21+
export class UnscrambleStrategy extends Strategy {
22+
// How many players should be on the server in order to consider this strategy?
23+
static kMinimumPlayerCount = 1;
24+
25+
answer_ = null;
26+
scrambled_ = null;
27+
settings_ = null;
28+
words_ = new Set();
29+
30+
constructor(settings) {
31+
super();
32+
33+
this.settings_ = settings;
34+
}
35+
36+
// Gets the answer to the current reaction test. May be NULL.
37+
get answer() { return this.answer_; }
38+
39+
// Gets the scrambled answer, primarily for testing purposes.
40+
get scrambled() { return this.scrambled_; }
41+
42+
// Initializes the list of words that players should unscramble. Will be called when a test is
43+
// starting, to avoid doing this too often while running tests.
44+
ensureWordListInitialized() {
45+
if (this.words_.size)
46+
return;
47+
48+
const words = JSON.parse(readFile(kWordDefinitionFile));
49+
for (const word of words) {
50+
if (word.startsWith('__'))
51+
continue; // considered as a note/comment, ignore it
52+
53+
this.words_.add(word);
54+
}
55+
}
56+
57+
// Starts a new test provided by this strategy. The question must be determined, and it should
58+
// be announced to people in-game and available through Nuwani accordingly.
59+
start(announceFn, nuwani, prize) {
60+
this.ensureWordListInitialized();
61+
62+
// Pick a random phrase from the loaded word list.
63+
this.answer_ = ([ ...this.words_ ][ random(this.words_.size) ]).toUpperCase();
64+
this.scrambled_ = this.scramble(this.answer_);
65+
66+
// Announce the test to all in-game participants.
67+
announceFn(Message.REACTION_TEST_ANNOUNCE_UNSCRAMBLE, this.scrambled_, prize);
68+
69+
// Announce the test to everyone reading along through Nuwani.
70+
nuwani().echo('reaction-unscramble', this.scrambled_, prize);
71+
}
72+
73+
// Scrambles the given |phrase| and returns the result. We decide which characters are valid to
74+
// be scrambled, then scramble them, re-compose the word, and return it as a string.
75+
scramble(phrase) {
76+
if (phrase.trim() !== phrase)
77+
console.log(`[reaction test] warning: "${phrase}" has excess spacing.`);
78+
79+
const kStaticPercentage =
80+
this.settings_().getValue('playground/reaction_test_unscramble_fixed');
81+
82+
const words = phrase.split(' ');
83+
const result = [];
84+
85+
for (const word of words) {
86+
const characters = [];
87+
const fixed = new Set();
88+
89+
// (1) Decide which characters are supposed to be fixed.
90+
for (let index = 0; index < word.length; ++index) {
91+
if (random(100) < kStaticPercentage)
92+
fixed.add(index);
93+
}
94+
95+
// (1) Collect all the characters in the |word|.
96+
for (let index = 0; index < word.length; ++index) {
97+
if (!shouldScramble(word.charCodeAt(index)) || fixed.has(index))
98+
continue;
99+
100+
characters.push(word.charAt(index));
101+
}
102+
103+
const shuffled = this.shuffle(characters);
104+
const composed = [];
105+
106+
// (2) Re-compose the scrambled word.
107+
for (let index = 0; index < word.length; ++index) {
108+
if (shouldScramble(word.charCodeAt(index)) && !fixed.has(index))
109+
composed.push(shuffled.shift());
110+
else
111+
composed.push(word.charAt(index));
112+
}
113+
114+
result.push(composed.join(''));
115+
}
116+
117+
// (3) Join the composed word together, then translate to upper case.
118+
return result.join(' ');
119+
}
120+
121+
// Shuffles the given |array| with the Fisher-Yates algorithm:
122+
// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
123+
shuffle(array) {
124+
let counter = array.length;
125+
126+
while (counter > 0) {
127+
const index = random(counter--);
128+
const temp = array[counter];
129+
130+
array[counter] = array[index];
131+
array[index] = temp;
132+
}
133+
134+
return array;
135+
}
136+
137+
// Verifies whether the |message| is, or contains, the answer to this reaction test.
138+
verify(message) {
139+
return message.toUpperCase().startsWith(this.answer_);
140+
}
141+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
import { UnscrambleStrategy } from 'features/reaction_tests/strategies/unscramble_strategy.js';
6+
7+
import { range } from 'base/range.js';
8+
import { symmetricDifference } from 'base/set_extensions.js';
9+
10+
describe('UnscrambleStrategy', (it, beforeEach) => {
11+
let announceFn = null;
12+
let nuwani = null;
13+
let settings = null;
14+
15+
beforeEach(() => {
16+
const driver = server.featureManager.loadFeature('reaction_tests');
17+
18+
announceFn = driver.__proto__.announceToPlayers.bind(driver);
19+
nuwani = server.featureManager.loadFeature('nuwani');
20+
settings = server.featureManager.loadFeature('settings');
21+
});
22+
23+
it('is able to scramble words while maintaining the correct answer', assert => {
24+
const strategy = new UnscrambleStrategy(() => settings);
25+
26+
// (1) Test the shuffling function.
27+
const values = range(100);
28+
const shuffled = strategy.shuffle(values);
29+
30+
assert.equal(values.length, shuffled.length);
31+
assert.equal(symmetricDifference(new Set(values), new Set(shuffled)).size, 0);
32+
33+
// (2) Test the scrambling function.
34+
const input = 'This abcdef is xyz123 a test';
35+
const scrambled = strategy.scramble(input);
36+
37+
assert.equal(input.length, scrambled.length);
38+
assert.equal(symmetricDifference(new Set(values), new Set(shuffled)).size, 0);
39+
});
40+
41+
it('is able to change difficulty level through static letters', assert => {
42+
const strategy = new UnscrambleStrategy(() => settings);
43+
44+
// (1) Don't scramble the words at all.
45+
settings.setValue('playground/reaction_test_unscramble_fixed', 100);
46+
47+
strategy.start(announceFn, () => nuwani, 1234);
48+
assert.equal(strategy.answer, strategy.scrambled);
49+
50+
// (2) Heavily scramble the words, with on fixed letters.
51+
settings.setValue('playground/reaction_test_unscramble_fixed', 0);
52+
53+
strategy.start(announceFn, () => nuwani, 1234);
54+
assert.notEqual(strategy.answer, strategy.scrambled);
55+
});
56+
57+
it('announces new tests to in-game players and Nuwani users', assert => {
58+
const gunther = server.playerManager.getById(/* Gunther= */ 0);
59+
const strategy = new UnscrambleStrategy(() => settings);
60+
61+
assert.equal(gunther.messages.length, 0);
62+
assert.equal(nuwani.messagesForTesting.length, 0);
63+
64+
strategy.start(announceFn, () => nuwani, 1234);
65+
66+
assert.equal(gunther.messages.length, 1);
67+
assert.equal(
68+
gunther.messages[0],
69+
Message.format(Message.REACTION_TEST_ANNOUNCE_UNSCRAMBLE, strategy.scrambled, 1234));
70+
71+
assert.equal(nuwani.messagesForTesting.length, 1);
72+
assert.deepEqual(nuwani.messagesForTesting[0], {
73+
tag: 'reaction-unscramble',
74+
params: [ strategy.scrambled, 1234 ]
75+
});
76+
77+
strategy.stop();
78+
});
79+
});

javascript/features/settings/setting_list.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)