@@ -10,10 +10,11 @@
@onSubmit="onInput"
/>
{{ ending }}
@@ -33,7 +34,10 @@ import VueMenu from '@/components/VueMenu.vue';
import VueOutput from '@/components/VueOutput.vue';
import VueLoader from '@/components/VueLoader.vue';
import { useRoute } from 'vue-router';
-import { interpolate, sleep } from '@/utils';
+import { type Effect, getClock, GLITCH_PREFIX, interpolate, parseEffect, sleep } from '@/utils';
+import { playKnock, playStatic } from '@/sounds';
+
+const DELAY_PER_CHARACTER = 80;
export default defineComponent({
name: 'GamePage',
@@ -45,6 +49,7 @@ export default defineComponent({
const output = ref
>([]);
const isTyping = ref(false);
const isInitializing = ref(true);
+ const isFlickering = ref(false);
let Q = createQuaire(questions);
const state = shallowRef(Q.getState());
let unsubscribe = Q.subscribe((newState) => (state.value = newState));
@@ -74,6 +79,33 @@ export default defineComponent({
Q.saveAnswer(answer);
window.localStorage.setItem('result', JSON.stringify(Q.getResult()));
};
+ const flicker = () => {
+ isFlickering.value = true;
+ setTimeout(() => (isFlickering.value = false), 800);
+ };
+ // plays an effect and returns how long the dialog waits after it
+ const playEffect = (effect: Effect): number => {
+ switch (effect.name) {
+ case 'knock':
+ playKnock();
+ addToOutput(`${GLITCH_PREFIX}*knock* *knock* *knock*`);
+ return 1800;
+ case 'static':
+ playStatic();
+ flicker();
+ return 1200;
+ case 'flicker':
+ flicker();
+ return 800;
+ case 'pause':
+ // "Aia is typing" without a message
+ isTyping.value = true;
+ return effect.duration ?? 2000;
+ default:
+ // silence
+ return effect.duration ?? 2000;
+ }
+ };
const playDialog = (lines: Array, run: number, index = 0) => {
if (run !== dialogRun) {
return;
@@ -87,9 +119,17 @@ export default defineComponent({
return;
}
- addToOutput(interpolate(line, result.value));
+ const effect = parseEffect(line);
+
+ if (effect) {
+ setTimeout(() => playDialog(lines, run, index + 1), playEffect(effect));
+ return;
+ }
+
+ const text = interpolate(line, { ...getClock(new Date()), ...result.value });
+ addToOutput(text);
isTyping.value = true;
- setTimeout(() => playDialog(lines, run, index + 1), line.length * 80);
+ setTimeout(() => playDialog(lines, run, index + 1), text.length * DELAY_PER_CHARACTER);
};
const playActiveDialog = () => {
const question = activeQuestion.value;
@@ -113,6 +153,11 @@ export default defineComponent({
addToOutput(`>> ${option.label}`);
saveAnswer(option.value);
};
+ const onTimeout = (option: QuaireSelectOption) => {
+ addToOutput(`${activeQuestion.value?.title}`);
+ addToOutput(`[${option.label}]`);
+ saveAnswer(option.value);
+ };
const restoreGame = () => {
const outputItem = localStorage.getItem('output');
const resultItem = localStorage.getItem('result');
@@ -121,8 +166,9 @@ export default defineComponent({
localStorage.removeItem('activeQuestionId');
if (outputItem && resultItem) {
- output.value = outputItem.split('|||');
startQuaire(JSON.parse(resultItem));
+ // without answers on the path the game starts again, e.g. after the story changed
+ output.value = Q.getProgress().answered > 0 ? outputItem.split('|||') : [];
} else {
startQuaire();
}
@@ -175,10 +221,12 @@ export default defineComponent({
output,
isTyping,
isInitializing,
+ isFlickering,
activeQuestion,
ending,
onInput,
onSingleSelect,
+ onTimeout,
onResetGame,
onClearGame,
};
@@ -198,6 +246,36 @@ export default defineComponent({
line-height: 28px;
}
+@keyframes flicker {
+ 0%,
+ 100% {
+ filter: none;
+ transform: none;
+ }
+ 20% {
+ filter: invert(1);
+ transform: translateX(-4px);
+ }
+ 40% {
+ filter: brightness(0.2);
+ }
+ 60% {
+ filter: invert(1) hue-rotate(90deg);
+ transform: translateX(4px);
+ }
+ 80% {
+ filter: brightness(2);
+ }
+}
+
+.flicker {
+ animation: flicker 0.8s steps(5);
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+}
+
.ending {
margin-top: 28px;
}
diff --git a/tests/unit/components/VueInput.spec.ts b/tests/unit/components/VueInput.spec.ts
index 2c110a2..21ef52a 100644
--- a/tests/unit/components/VueInput.spec.ts
+++ b/tests/unit/components/VueInput.spec.ts
@@ -37,4 +37,14 @@ describe('VueInput.vue', () => {
expect(wrapper.find('[role="alert"]').text()).toBe('Please enter an answer.');
});
+
+ test('should show the pattern error and trim the input', async () => {
+ const wrapper = mount(VueInput, { props: { activeQuestion: getQuestion({ error: 'PATTERN', isValid: false }) } });
+
+ await wrapper.find('input').setValue(' 2026 ');
+ await wrapper.find('form').trigger('submit');
+
+ expect(wrapper.emitted('onSubmit')).toEqual([['2026']]);
+ expect(wrapper.find('[role="alert"]').text()).toBe("That doesn't look right. Please try again.");
+ });
});
diff --git a/tests/unit/components/VueMenu.spec.ts b/tests/unit/components/VueMenu.spec.ts
new file mode 100644
index 0000000..a84f553
--- /dev/null
+++ b/tests/unit/components/VueMenu.spec.ts
@@ -0,0 +1,51 @@
+import { mount } from '@vue/test-utils';
+import VueMenu from '@/components/VueMenu.vue';
+import { soundEnabled, timersEnabled } from '@/settings';
+
+describe('VueMenu.vue', () => {
+ const push = vi.fn();
+ const mountMenu = () => mount(VueMenu, { global: { mocks: { $router: { push } } } });
+
+ test('should open the menu and emit the actions', async () => {
+ const wrapper = mountMenu();
+
+ await wrapper.find('[aria-label="Open"]').trigger('click');
+ expect(wrapper.classes()).toContain('open');
+
+ await wrapper.find('.clearGame:nth-child(2)').trigger('click');
+ await wrapper.find('.resetGame').trigger('click');
+ await wrapper.findAll('.resetGame')[1].trigger('click');
+
+ expect(wrapper.emitted('clear')).toHaveLength(1);
+ expect(wrapper.emitted('reset')).toHaveLength(1);
+ expect(push).toHaveBeenCalledWith('/');
+ });
+
+ test('should switch the sound on and off', async () => {
+ soundEnabled.value = true;
+ const wrapper = mountMenu();
+ const button = wrapper.find('.toggleSound');
+
+ expect(button.text()).toBe('Sound: On');
+
+ await button.trigger('click');
+
+ expect(button.text()).toBe('Sound: Off');
+ expect(button.attributes('aria-pressed')).toBe('false');
+ expect(soundEnabled.value).toBe(false);
+ });
+
+ test('should switch the timers on and off', async () => {
+ timersEnabled.value = true;
+ const wrapper = mountMenu();
+ const button = wrapper.find('.toggleTimers');
+
+ expect(button.text()).toBe('Timer: On');
+
+ await button.trigger('click');
+
+ expect(button.text()).toBe('Timer: Off');
+ expect(timersEnabled.value).toBe(false);
+ expect(localStorage.getItem('timers')).toBe('off');
+ });
+});
diff --git a/tests/unit/components/VueSingleSelect.spec.ts b/tests/unit/components/VueSingleSelect.spec.ts
index 057055b..7903817 100644
--- a/tests/unit/components/VueSingleSelect.spec.ts
+++ b/tests/unit/components/VueSingleSelect.spec.ts
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import VueSingleSelect from '@/components/VueSingleSelect.vue';
+import { timersEnabled } from '@/settings';
describe('VueSingleSelect.vue', () => {
test('should submit the selected option', async () => {
@@ -29,4 +30,98 @@ describe('VueSingleSelect.vue', () => {
expect(wrapper.emitted('onSubmit')).toEqual([[options[1]]]);
});
+
+ test('should count down and submit the timeout option', async () => {
+ vi.useFakeTimers();
+ const timeout = { label: "You didn't answer.", value: 'noAnswer', timeout: true };
+ const wrapper = mount(VueSingleSelect, {
+ props: {
+ activeQuestion: {
+ id: 'knockChoice',
+ type: 'TIMED_SELECT',
+ key: 'knockChoice',
+ title: 'Aia: What do I do??',
+ seconds: 2,
+ options: [{ label: 'Hide!', value: 'hide' }, timeout],
+ value: undefined,
+ error: null,
+ isValid: true,
+ hasValue: false,
+ },
+ },
+ });
+
+ expect(wrapper.findAll('button').map((button) => button.text())).toEqual(['Hide!']);
+ expect(wrapper.find('[role="progressbar"]').text()).toBe('2s');
+
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(wrapper.find('[role="progressbar"]').text()).toBe('1s');
+ expect(wrapper.emitted('onTimeout')).toBeUndefined();
+
+ await vi.advanceTimersByTimeAsync(500);
+ expect(wrapper.emitted('onTimeout')).toEqual([[timeout]]);
+ vi.useRealTimers();
+ });
+
+ test('should stop the countdown when the player answers', async () => {
+ vi.useFakeTimers();
+ const wrapper = mount(VueSingleSelect, {
+ props: {
+ activeQuestion: {
+ id: 'knockChoice',
+ type: 'TIMED_SELECT',
+ key: 'knockChoice',
+ title: 'Aia: What do I do??',
+ seconds: 2,
+ options: [
+ { label: 'Hide!', value: 'hide' },
+ { label: "You didn't answer.", value: 'noAnswer', timeout: true },
+ ],
+ value: undefined,
+ error: null,
+ isValid: true,
+ hasValue: false,
+ },
+ },
+ });
+
+ await wrapper.find('button').trigger('click');
+ await vi.advanceTimersByTimeAsync(3000);
+
+ expect(wrapper.emitted('onSubmit')).toHaveLength(1);
+ expect(wrapper.emitted('onTimeout')).toBeUndefined();
+ vi.useRealTimers();
+ });
+
+ test('should wait for the player when the timers are off', async () => {
+ vi.useFakeTimers();
+ timersEnabled.value = false;
+ const wrapper = mount(VueSingleSelect, {
+ props: {
+ activeQuestion: {
+ id: 'knockChoice',
+ type: 'TIMED_SELECT',
+ key: 'knockChoice',
+ title: 'Aia: What do I do??',
+ seconds: 2,
+ options: [
+ { label: 'Hide!', value: 'hide' },
+ { label: "You didn't answer.", value: 'noAnswer', timeout: true },
+ ],
+ value: undefined,
+ error: null,
+ isValid: true,
+ hasValue: false,
+ },
+ },
+ });
+
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(wrapper.find('[role="progressbar"]').exists()).toBe(false);
+ expect(wrapper.emitted('onTimeout')).toBeUndefined();
+ expect(wrapper.findAll('button').map((button) => button.text())).toEqual(['Hide!']);
+ timersEnabled.value = true;
+ vi.useRealTimers();
+ });
});
diff --git a/tests/unit/games/message-from-the-future.spec.ts b/tests/unit/games/message-from-the-future.spec.ts
index 80c82dc..c9abc1b 100644
--- a/tests/unit/games/message-from-the-future.spec.ts
+++ b/tests/unit/games/message-from-the-future.spec.ts
@@ -1,116 +1,220 @@
import { validateDefinition } from 'quaire';
-import { components, createQuaire, type GameQuaire } from '@/quaire';
+import { components, createQuaire, type GameQuaire, isTimeoutOption } from '@/quaire';
import { questions } from '@/games/message-from-the-future';
+import { hasSolvedPuzzle, isPuzzleSolution } from '@/games/message-from-the-future/puzzle';
+import { getWarmth } from '@/games/message-from-the-future/warmth';
+import { parseEffect } from '@/utils';
+import { coldPath, play, playDialogs, playNew, untilKnock, warmPath } from './story';
+
+const EFFECTS = ['knock', 'static', 'flicker', 'pause', 'silence'];
+
+const allLines = questions.flatMap((question) =>
+ question.type === 'DIALOG' ? [question.lines, ...(question.variants || []).map((v) => v.lines || [])].flat() : [],
+);
+
+const getLines = (Q: GameQuaire) => {
+ const question = Q.getActiveQuestion();
+
+ return question?.type === 'DIALOG' ? question.lines : [];
+};
+
+const getOptionValues = (Q: GameQuaire) => {
+ const question = Q.getActiveQuestion();
+
+ return question?.type === 'SINGLE_SELECT' || question?.type === 'TIMED_SELECT'
+ ? question.options.map((option) => option.value)
+ : [];
+};
describe('message-from-the-future', () => {
- let Q: GameQuaire;
-
- const startConversation = () => {
- Q.saveAnswer(true); // intro dialog
- Q.saveAnswer('Yes');
- Q.saveAnswer(true); // received messages dialog
- };
-
- const introduceYourself = (howAreYou: string, playerName: string) => {
- startConversation();
- Q.saveAnswer(howAreYou);
- Q.saveAnswer(true); // how are you dialog
- Q.saveAnswer(playerName);
- Q.saveAnswer(true); // nice to meet you dialog
- };
-
- beforeEach(() => {
- Q = createQuaire(questions);
- });
+ describe('definition', () => {
+ test('should have a valid definition', () => {
+ expect(validateDefinition({ questions, components })).toEqual([]);
+ });
- test('should have a valid definition', () => {
- expect(validateDefinition({ questions, components })).toEqual([]);
- });
+ test('should only use known effects', () => {
+ const effects = allLines.map(parseEffect).filter((effect) => effect !== null);
+
+ expect(effects.length).toBeGreaterThan(0);
+ effects.forEach((effect) => expect(EFFECTS).toContain(effect.name));
+ });
+
+ test('should have exactly one timeout option for every countdown', () => {
+ questions.forEach((question) => {
+ if (question.type === 'TIMED_SELECT') {
+ expect(question.options.filter(isTimeoutOption)).toHaveLength(1);
+ (question.variants || []).forEach((variant) =>
+ expect((variant.options || []).filter(isTimeoutOption)).toHaveLength(1),
+ );
+ }
+ });
+ });
- test('should start with the intro dialog', () => {
- expect(Q.getActiveQuestion()).toMatchObject({ id: 1, type: 'DIALOG', hasValue: false });
+ test('should never let the visitors say a name', () => {
+ const visitorLines = allLines.filter((line) => /^!!(Mila|Mrs\. Okafor):/.test(line));
+
+ expect(visitorLines.length).toBeGreaterThan(0);
+ visitorLines.forEach((line) => expect(line.split(':').slice(1).join(':')).not.toMatch(/aia|<%=/i));
+ });
});
- test('should end the game when the player does not answer', () => {
- Q.saveAnswer(true);
- Q.saveAnswer('No');
+ describe('act one', () => {
+ test('should start like a wrong number', () => {
+ const Q = createQuaire(questions);
+
+ expect(Q.getActiveQuestion()).toMatchObject({ id: 'intro', type: 'DIALOG' });
+ expect(getLines(Q)).toContain('Mila? Is that you?');
+ });
- expect(Q.getActiveQuestion()).toMatchObject({ id: 3, type: 'DIALOG', end: 'THE END' });
- expect(Q.isComplete()).toBe(false);
+ test('should end the game when the player blocks the number', () => {
+ const Q = playNew(['ignore', 'block']);
- Q.saveAnswer(true);
+ expect(Q.isComplete()).toBe(true);
+ expect(Q.getActiveQuestion()).toMatchObject({ id: 'blocked', end: 'THE END' });
+ });
- expect(Q.isComplete()).toBe(true);
- expect(Q.getResult()).toEqual({ introDialog: true, receivedMessages: 'No', notReceivedMessagesDialog: true });
+ test('should continue when the player answers after all', () => {
+ expect(playNew(['ignore', 'hi']).getActiveQuestion()?.id).toBe('askAnyway');
+ });
});
- test.each([
- ['Good', 'Awesome!'],
- ['Bad', "I didn't hear this in a while"],
- ])('should answer "%s" with the matching dialog', (howAreYou, firstLine) => {
- startConversation();
- Q.saveAnswer(howAreYou);
+ describe('act three', () => {
+ const beforeYear = [...warmPath.actOne, ...warmPath.actTwo];
- const question = Q.getActiveQuestion();
- expect(question?.type === 'DIALOG' && question.lines[0]).toBe(firstLine);
- });
+ test('should ask for a valid year', () => {
+ const Q = playNew(beforeYear);
+ Q.saveAnswer('next year');
+
+ expect(Q.getActiveQuestion()).toMatchObject({ id: 'playerYear', error: 'PATTERN' });
+ });
+
+ test.each([
+ ['2026', 'Aia: <%= playerYear %>.'],
+ ['2150', 'Aia: <%= playerYear %>? Very funny.'],
+ ])('should react to the year %s', (year, firstLine) => {
+ const Q = playNew(beforeYear);
+ Q.saveAnswer(year);
+
+ expect(getLines(Q)[0]).toBe(firstLine);
+ });
- test('should ask for the name again when it is empty', () => {
- startConversation();
- Q.saveAnswer('Good');
- Q.saveAnswer(true);
- Q.saveAnswer('');
+ test('should end the game when the player does not believe Aia', () => {
+ const Q = playNew([...beforeYear, '2026', 'no']);
- expect(Q.getActiveQuestion()).toMatchObject({ id: 7, error: 'REQUIRED' });
+ expect(Q.isComplete()).toBe(true);
+ expect(Q.getActiveQuestion()?.id).toBe('disbelief');
+ });
+
+ test.each([
+ ['name', true],
+ [' Name! ', true],
+ ['your name', true],
+ ['game', false],
+ [undefined, false],
+ ])('should check the puzzle answer "%s"', (answer, solved) => {
+ expect(isPuzzleSolution(answer)).toBe(solved);
+ });
+
+ test('should give hints after wrong answers', () => {
+ const Q = playNew([...beforeYear, '2026', 'yes', 'now', 'game']);
+
+ expect(Q.getActiveQuestion()?.id).toBe('puzzle2');
+ expect(Q.getResult().puzzleHint1).toBe(true);
+
+ play(Q, ['NAME']);
+
+ expect(Q.getResult().puzzleSolved).toBe(true);
+ expect(hasSolvedPuzzle(Q.getResult())).toBe(true);
+ });
+
+ test('should burn the note after three wrong answers', () => {
+ const Q = playNew(untilKnock(coldPath));
+
+ expect(Q.getResult().puzzleFailed).toBe(true);
+ expect(Q.getResult()).not.toHaveProperty('puzzleSolved');
+ expect(Q.getActiveQuestion()?.id).toBe('knockChoice');
+ });
});
- test.each([
- ['Location', 100, 'locationIntroDialog'],
- ['Brand', 200, 'brandIntroDialog'],
- ['Age', 300, 'ageIntroDialog'],
- ])('should follow the "%s" story line until it is continued', (topic, id, key) => {
- introduceYourself('Good', 'Neo');
- Q.saveAnswer(topic);
+ describe('act four', () => {
+ test('should only offer the name test when the player solved the puzzle', () => {
+ expect(getOptionValues(playNew([...untilKnock(warmPath), 'ask']))).toContain('sayName');
+ expect(getOptionValues(playNew([...untilKnock(coldPath), 'ask']))).not.toContain('sayName');
+ });
- expect(Q.getActiveQuestion()).toMatchObject({ id, end: 'TO BE CONTINUED...' });
+ test.each([
+ ['hide', 'hiding'],
+ ['ask', 'askWho'],
+ ['silent', 'staySilent'],
+ ['noAnswer', 'noAnswer'],
+ ])('should react to "%s" when someone knocks', (answer, id) => {
+ const Q = playNew(untilKnock(warmPath));
+ Q.saveAnswer(answer);
- Q.saveAnswer(true);
+ expect(Q.getActiveQuestion()?.id).toBe(id);
+ });
- expect(Q.isComplete()).toBe(true);
- expect(Q.getResult()).toMatchObject({ playerName: 'Neo', whereDidYouFindTheMobile: topic, [key]: true });
+ test('should let the visitor in when the player does not stop Aia', () => {
+ const Q = playNew([...untilKnock(coldPath), 'ask', 'secret', 'tooLate']);
+
+ expect(Q.isComplete()).toBe(true);
+ expect(Q.getActiveQuestion()?.id).toBe('doorOpens');
+ expect(getLines(Q)[0]).toBe("Aia: You're not answering.");
+ });
+
+ test('should let the visitor in when the player opens the door', () => {
+ const Q = playNew([...untilKnock(warmPath), 'hide', 'open']);
+
+ expect(Q.isComplete()).toBe(true);
+ expect(getLines(Q)[0]).toBe('Aia: Mila!');
+ });
});
- test('should remove the answers of an abandoned story line', () => {
- introduceYourself('Good', 'Neo');
- Q.saveAnswer('Location');
- Q.saveAnswer(true);
- Q.back();
- Q.saveAnswer('Age');
+ describe('act five', () => {
+ const escapeWarm = [...untilKnock(warmPath), 'ask', 'sayName'];
+ const escapeCold = [...untilKnock(coldPath), 'silent', 'dontOpen'];
+
+ test('should count the kind answers', () => {
+ expect(getWarmth(playNew(escapeWarm).getResult())).toBe(6);
+ expect(getWarmth(playNew(escapeCold).getResult())).toBe(0);
+ });
+
+ test.each([
+ ['should let Aia run when she trusts the player', [...escapeWarm, 'run'], 'outside'],
+ ['should keep Aia inside when she does not trust the player', [...escapeCold, 'run'], 'tooAfraid'],
+ ['should catch Aia when the player does not answer', [...escapeWarm, 'freeze'], 'frozen'],
+ ['should let Aia survive in the attic', [...escapeCold, 'attic', 'Stay strong.', ' AIA '], 'survived'],
+ ['should not trust a wrong name', [...escapeWarm, 'attic', 'Stay strong.', 'Mila'], 'notHer'],
+ ['should let Aia go into the Quiet', [...escapeWarm, 'stay', 'try'], 'goodbyeEnd'],
+ ])('%s', (_name, answers, id) => {
+ const Q = playNew(answers);
+
+ expect(Q.isComplete()).toBe(true);
+ expect(Q.getActiveQuestion()?.id).toBe(id);
+ });
- expect(Q.getResult()).not.toHaveProperty('locationIntroDialog');
- expect(Q.getActiveQuestion()?.id).toBe(300);
+ test('should keep the last message of the player', () => {
+ const Q = playNew([...escapeWarm, 'attic', 'See you in a hundred years.']);
+
+ expect(Q.getResult().lastMessage).toBe('See you in a hundred years.');
+ expect(Q.getActiveQuestion()?.id).toBe('sayHerName');
+ });
});
- test('should reset the dialog when the player changes the answer it depends on', () => {
- startConversation();
- Q.saveAnswer('Good');
- Q.saveAnswer(true);
- Q.goTo(5);
- Q.saveAnswer('Bad');
+ test('should remove the answers of an abandoned ending', () => {
+ const Q = playNew([...untilKnock(warmPath), 'ask', 'sayName', 'stay']);
+ Q.goTo('escapeChoice');
+ Q.saveAnswer('attic');
+ playDialogs(Q);
- expect(Q.getResult().howAreYouDialog).toBeNull();
- expect(Q.getActiveQuestion()).toMatchObject({ id: 6, hasValue: false });
+ expect(Q.getResult()).not.toHaveProperty('stayWithMe');
+ expect(Q.getActiveQuestion()?.id).toBe('lastMessage');
});
test('should continue a saved game with the first open question', () => {
- Q = createQuaire(questions, {
- introDialog: true,
- receivedMessages: 'Yes',
- receivedMessagesDialog: true,
- howAreYou: 'Bad',
- });
+ const Q = createQuaire(questions, { intro: true, whoIsThis: 'who', apology: true, askAnyway: 'yes' });
- expect(Q.getActiveQuestion()).toMatchObject({ id: 6, hasValue: false });
+ expect(Q.getActiveQuestion()).toMatchObject({ id: 'weatherIntro', hasValue: false });
expect(Q.canGoBack()).toBe(true);
});
});
diff --git a/tests/unit/games/story.ts b/tests/unit/games/story.ts
new file mode 100644
index 0000000..44ef223
--- /dev/null
+++ b/tests/unit/games/story.ts
@@ -0,0 +1,40 @@
+import { createQuaire, type GameQuaire } from '@/quaire';
+import { questions } from '@/games/message-from-the-future';
+
+// a kind player who solves the puzzle at once: Aia trusts them
+export const warmPath = {
+ actOne: ['who', 'yes', 'rain', 'Neo'],
+ actTwo: ['okay', 'bakery', 'yesterday', 'keepTalking', 'why', 'scared'],
+ actThree: ['2026', 'yes', 'now', 'name'],
+};
+
+// a cold player who fails the puzzle: Aia does not trust them
+export const coldPath = {
+ actOne: ['wrongNumber', 'busy', 'sun', 'Neo'],
+ actTwo: ['mila', 'phone', 'whyMatter', 'prank', 'start', 'rational'],
+ actThree: ['2026', 'prove', 'worldCup', 'coincidence', 'house', 'door', 'help'],
+};
+
+export const untilKnock = (path: typeof warmPath) => [...path.actOne, ...path.actTwo, ...path.actThree];
+
+// answers all dialogs in between, like the game does after printing the lines
+export const playDialogs = (Q: GameQuaire) => {
+ let question = Q.getActiveQuestion();
+
+ while (question?.type === 'DIALOG' && !question.hasValue) {
+ Q.saveAnswer(true);
+ question = Q.getActiveQuestion();
+ }
+};
+
+export const play = (Q: GameQuaire, answers: Array) => {
+ answers.forEach((answer) => {
+ playDialogs(Q);
+ Q.saveAnswer(answer);
+ });
+ playDialogs(Q);
+
+ return Q;
+};
+
+export const playNew = (answers: Array) => play(createQuaire(questions), answers);
diff --git a/tests/unit/sounds.spec.ts b/tests/unit/sounds.spec.ts
new file mode 100644
index 0000000..5c54461
--- /dev/null
+++ b/tests/unit/sounds.spec.ts
@@ -0,0 +1,110 @@
+import { nextTick } from 'vue';
+
+// records which audio nodes the sounds create
+const createFakeAudioContext = () => {
+ const created: Array = [];
+ const param = () => ({ value: 0, setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() });
+ const node = (name: string, extra: object = {}) => {
+ created.push(name);
+ const audioNode = { connect: () => audioNode, start: vi.fn(), stop: vi.fn(), ...extra };
+
+ return audioNode;
+ };
+
+ class FakeAudioContext {
+ static instances: Array = [];
+ state = 'suspended';
+ currentTime = 0;
+ sampleRate = 100;
+ destination = {};
+ resume = vi.fn(() => {
+ this.state = 'running';
+ return Promise.resolve();
+ });
+
+ constructor() {
+ FakeAudioContext.instances.push(this);
+ }
+
+ createOscillator() {
+ return node('oscillator', { frequency: param() });
+ }
+
+ createGain() {
+ return node('gain', { gain: param() });
+ }
+
+ createBiquadFilter() {
+ return node('filter', { type: '', frequency: param() });
+ }
+
+ createBuffer(_channels: number, length: number) {
+ const data = new Float32Array(length);
+
+ return { getChannelData: () => data };
+ }
+
+ createBufferSource() {
+ return node('noise', { buffer: null });
+ }
+ }
+
+ return { FakeAudioContext, created };
+};
+
+describe('sounds', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test('should play a knock and static', async () => {
+ const { FakeAudioContext, created } = createFakeAudioContext();
+ vi.stubGlobal('AudioContext', FakeAudioContext);
+ const { playKnock, playStatic } = await import('@/sounds');
+
+ playKnock();
+ playStatic();
+
+ expect(created.filter((name) => name === 'oscillator')).toHaveLength(3);
+ expect(created).toContain('noise');
+ expect(FakeAudioContext.instances).toHaveLength(1);
+ expect(FakeAudioContext.instances[0].resume).toHaveBeenCalledTimes(1);
+ });
+
+ test('should be silent when the sound is off', async () => {
+ const { FakeAudioContext, created } = createFakeAudioContext();
+ vi.stubGlobal('AudioContext', FakeAudioContext);
+ const { playKnock, playStatic } = await import('@/sounds');
+ const { soundEnabled } = await import('@/settings');
+
+ soundEnabled.value = false;
+ await nextTick();
+ playKnock();
+ playStatic();
+
+ expect(created).toEqual([]);
+ expect(localStorage.getItem('sound')).toBe('off');
+ });
+
+ test('should remember that the sound is off', async () => {
+ localStorage.setItem('sound', 'off');
+ const { soundEnabled } = await import('@/settings');
+
+ expect(soundEnabled.value).toBe(false);
+ });
+
+ test('should be silent without audio support', async () => {
+ vi.stubGlobal('AudioContext', undefined);
+ const { playKnock, playStatic } = await import('@/sounds');
+
+ expect(() => {
+ playKnock();
+ playStatic();
+ }).not.toThrow();
+ });
+});
diff --git a/tests/unit/utils.spec.ts b/tests/unit/utils.spec.ts
index 2d2f36a..e38b7d6 100644
--- a/tests/unit/utils.spec.ts
+++ b/tests/unit/utils.spec.ts
@@ -1,4 +1,4 @@
-import { interpolate, sleep } from '@/utils';
+import { getClock, interpolate, isGlitch, parseEffect, sleep, stripGlitch } from '@/utils';
describe('utils', () => {
test('should replace placeholders with answers', () => {
@@ -18,3 +18,24 @@ describe('utils', () => {
vi.useRealTimers();
});
});
+
+describe('clock and effects', () => {
+ test('should format the real time of the player', () => {
+ expect(getClock(new Date(2026, 9, 8, 3, 7))).toEqual({ time: '03:07', weekday: 'Thursday' });
+ });
+
+ test.each([
+ ['[[knock]]', { name: 'knock' }],
+ ['[[pause:3000]]', { name: 'pause', duration: 3000 }],
+ ['[You blocked the number.]', null],
+ ['Aia: [[knock]]', null],
+ ])('should parse "%s"', (line, effect) => {
+ expect(parseEffect(line)).toEqual(effect);
+ });
+
+ test('should detect and strip glitch lines', () => {
+ expect(isGlitch('!!Mila: let me in')).toBe(true);
+ expect(stripGlitch('!!Mila: let me in')).toBe('Mila: let me in');
+ expect(stripGlitch('Aia: hi')).toBe('Aia: hi');
+ });
+});
diff --git a/tests/unit/views/Game.spec.ts b/tests/unit/views/Game.spec.ts
index 0f32ca2..a8fcdef 100644
--- a/tests/unit/views/Game.spec.ts
+++ b/tests/unit/views/Game.spec.ts
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import { questions } from '@/games/message-from-the-future';
+import { playNew, untilKnock, warmPath } from '../games/story';
import Game from '@/views/Game.vue';
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { id: 'message-from-the-future' } }) }));
@@ -26,19 +27,22 @@ describe('Game.vue', () => {
test('should play the intro and ask the first question', async () => {
const wrapper = await mountGame();
- expect(wrapper.text()).toContain('I wonder what this old thing does');
- expect(wrapper.find('.single-select').text()).toContain('Can someone read this, heeeeelllloooooooo?');
- expect(JSON.parse(localStorage.getItem('result') || '{}')).toEqual({ introDialog: true });
+ expect(wrapper.text()).toContain("I've been trying to reach you for so long.");
+ expect(wrapper.find('.single-select').text()).toContain('Mila?? Please say something.');
+ expect(JSON.parse(localStorage.getItem('result') || '{}')).toEqual({ intro: true });
});
- test('should end the game when the player does not answer', async () => {
+ test('should end the game when the player blocks the number', async () => {
const wrapper = await mountGame();
+ await wrapper.findAll('.single-select button')[2].trigger('click');
+ await vi.runAllTimersAsync();
await wrapper.findAll('.single-select button')[1].trigger('click');
await vi.runAllTimersAsync();
- expect(wrapper.text()).toContain(">> Nope, that's too creepy!");
- expect(wrapper.text()).toContain('Anyway, bye!');
+ expect(wrapper.text()).toContain(">> Don't answer.");
+ expect(wrapper.text()).toContain('>> Block the number.');
+ expect(wrapper.find('.glitch').text()).toBe('Mila: she was so lonely.');
expect(wrapper.find('.ending').text()).toBe('THE END');
expect(wrapper.find('.single-select').exists()).toBe(false);
});
@@ -48,11 +52,13 @@ describe('Game.vue', () => {
localStorage.setItem(
'result',
JSON.stringify({
- introDialog: true,
- receivedMessages: 'Yes',
- receivedMessagesDialog: true,
- howAreYou: 'Good',
- howAreYouDialog: true,
+ intro: true,
+ whoIsThis: 'who',
+ apology: true,
+ askAnyway: 'yes',
+ weatherIntro: true,
+ weather: 'rain',
+ weatherReply: true,
}),
);
const wrapper = await mountGame();
@@ -60,29 +66,38 @@ describe('Game.vue', () => {
await wrapper.find('form').trigger('submit');
expect(wrapper.find('[role="alert"]').exists()).toBe(true);
- await wrapper.find('input').setValue('Neo');
+ await wrapper.find('input').setValue(' Neo ');
await wrapper.find('form').trigger('submit');
await vi.runAllTimersAsync();
expect(wrapper.text()).toContain('>> Neo');
- expect(wrapper.text()).toContain('Aia: Nice to meet you, Neo!');
- expect(wrapper.find('.single-select').text()).toContain('I need to ask you something?');
+ expect(wrapper.text()).toContain('Aia: Neo.');
+ expect(wrapper.find('.single-select').text()).toContain('Aia: You can ask me something too, if you want.');
});
test('should restore a finished game without playing it again', async () => {
- localStorage.setItem('output', 'Anyway, bye!');
+ localStorage.setItem('output', '[You put the phone away.]');
localStorage.setItem('activeQuestionId', '99999');
localStorage.setItem(
'result',
- JSON.stringify({ introDialog: true, receivedMessages: 'No', notReceivedMessagesDialog: true }),
+ JSON.stringify({ intro: true, whoIsThis: 'ignore', stillThere: true, secondChance: 'block', blocked: true }),
);
const wrapper = await mountGame();
- expect(wrapper.findAll('.output li').map((item) => item.text())).toEqual(['Anyway, bye!']);
+ expect(wrapper.findAll('.output li').map((item) => item.text())).toEqual(['[You put the phone away.]']);
expect(wrapper.find('.ending').text()).toBe('THE END');
expect(localStorage.getItem('activeQuestionId')).toBeNull();
});
+ test('should start again when the saved game does not match the story', async () => {
+ localStorage.setItem('output', 'Hello.......');
+ localStorage.setItem('result', JSON.stringify({ introDialog: true, receivedMessages: 'Yes' }));
+ const wrapper = await mountGame();
+
+ expect(wrapper.text()).not.toContain('Hello.......');
+ expect(wrapper.find('.single-select').text()).toContain('Mila?? Please say something.');
+ });
+
test('should reset and clear the game', async () => {
const wrapper = await mountGame();
@@ -93,7 +108,39 @@ describe('Game.vue', () => {
await wrapper.find('.resetGame').trigger('click');
await vi.runAllTimersAsync();
- expect(wrapper.text()).toContain('I wonder what this old thing does');
- expect(JSON.parse(localStorage.getItem('result') || '{}')).toEqual({ introDialog: true });
+ expect(wrapper.text()).toContain("I've been trying to reach you for so long.");
+ expect(JSON.parse(localStorage.getItem('result') || '{}')).toEqual({ intro: true });
+ });
+
+ test('should use the real time of the player', async () => {
+ vi.setSystemTime(new Date(2026, 9, 8, 23, 47));
+ localStorage.setItem('output', 'Hi');
+ localStorage.setItem('result', JSON.stringify(playNew(warmPath.actOne.concat(warmPath.actTwo)).getResult()));
+ // restart the act two dialog with the clocks
+ const result = JSON.parse(localStorage.getItem('result') || '{}');
+ delete result.clocks;
+ delete result.deviceClue;
+ localStorage.setItem('result', JSON.stringify(result));
+ const wrapper = await mountGame();
+
+ expect(wrapper.text()).toContain('Aia: Is that right? Is it 23:47 for you?');
+ });
+
+ test('should knock and decide for the player when the time is up', async () => {
+ localStorage.setItem('output', 'Hi');
+ localStorage.setItem('result', JSON.stringify(playNew(untilKnock(warmPath)).getResult()));
+ const wrapper = mount(Game, { global: { mocks: { $router: { push: vi.fn() } } } });
+ // the loading screen takes 2 seconds
+ await vi.advanceTimersByTimeAsync(2500);
+
+ expect(wrapper.find('[role="progressbar"]').exists()).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(10000);
+ await vi.runAllTimersAsync();
+
+ expect(wrapper.text()).toContain("[You didn't answer.]");
+ expect(wrapper.text()).toContain("Mila: it's me. it's mila.");
+ expect(wrapper.findAll('.glitch').map((line) => line.text())).toContain('*knock* *knock* *knock*');
+ expect(wrapper.find('.single-select').text()).toContain('Ask her to say your name.');
});
});