quizwitz-validator 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,3 +58,10 @@ export declare function validGroupId(value: unknown): number | undefined;
58
58
  * questions becomes one new group above the highest id.
59
59
  */
60
60
  export declare function normalizedGroupIds(questions: QuestionAggregate[]): number[];
61
+ /**
62
+ * A sound setting of an audio/video attachment as the game reads it
63
+ * (client SoundParameters.parse): `attributes.sound.{key}`, else a legacy
64
+ * `[key=value]` bracket in the name (key ignoring case). A volume above 1 is
65
+ * played at 1. Undefined when neither holds a number.
66
+ */
67
+ export declare function attachmentSoundNumber(attachment: Record<string, any>, key: string): number | undefined;
package/dist/aggregate.js CHANGED
@@ -145,3 +145,31 @@ export function normalizedGroupIds(questions) {
145
145
  return runId;
146
146
  });
147
147
  }
148
+ /**
149
+ * A sound setting of an audio/video attachment as the game reads it
150
+ * (client SoundParameters.parse): `attributes.sound.{key}`, else a legacy
151
+ * `[key=value]` bracket in the name (key ignoring case). A volume above 1 is
152
+ * played at 1. Undefined when neither holds a number.
153
+ */
154
+ export function attachmentSoundNumber(attachment, key) {
155
+ const read = (value) => {
156
+ const number = typeof value === 'number' ? value : parseFloat(String(value));
157
+ return isFinite(number) ? Math.min(1, number) : undefined;
158
+ };
159
+ const sound = attachment?.attributes?.sound;
160
+ if (sound && typeof sound === 'object' && sound[key] !== undefined && sound[key] !== null) {
161
+ const stored = read(sound[key]);
162
+ if (stored !== undefined) {
163
+ return stored;
164
+ }
165
+ }
166
+ let legacy;
167
+ const name = typeof attachment?.name === 'string' ? attachment.name : '';
168
+ for (const match of name.match(/\[(.*?)\]/g) ?? []) {
169
+ const parts = match.substring(1, match.length - 1).split('=');
170
+ if (parts.length === 2 && parts[0].toLowerCase() === key.toLowerCase()) {
171
+ legacy = read(parts[1]) ?? legacy;
172
+ }
173
+ }
174
+ return legacy;
175
+ }
package/dist/evaluate.js CHANGED
@@ -2,7 +2,7 @@ import { TypeCatalog } from './catalog.js';
2
2
  import { questionSetting, questionTypeToken, questionsOf, normalizedGroupIds, roundName, roundTypeToken, roundsOf } from './aggregate.js';
3
3
  import { ignoredBy } from './ignore.js';
4
4
  import { questionWarningId } from './ignore.js';
5
- import { COMPLETENESS_MESSAGES, evaluateConsistency, evaluateExplanationPair, evaluateGroupPosition, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
5
+ import { COMPLETENESS_MESSAGES, evaluateAttachmentVolume, evaluateConsistency, evaluateExplanationPair, evaluateGroupPosition, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
6
6
  import { localizedText } from './text.js';
7
7
  import { COMPARATIVE_KINDS } from './types.js';
8
8
  export const ROUND_INCOMPLETE_MESSAGE = '{round}: scoring settings incomplete ({fields})';
@@ -223,6 +223,9 @@ function evaluateRound(catalog, scope, round, roundIndex) {
223
223
  case 'groupPosition':
224
224
  evaluateGroupPosition(rule, ctx).forEach(add);
225
225
  break;
226
+ case 'attachmentVolume':
227
+ evaluateAttachmentVolume(rule, ctx).forEach(add);
228
+ break;
226
229
  case 'consistency':
227
230
  evaluateConsistency(rule, ctx, quiet).forEach(add);
228
231
  break;
package/dist/rules.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupPositionRule, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
1
+ import type { AttachmentVolumeRule, CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupPositionRule, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
2
2
  export interface QuestionContext {
3
3
  question: QuestionAggregate;
4
4
  uuid: string;
@@ -32,6 +32,21 @@ export declare function evaluateGroupSize(rule: GroupSizeRule, ctx: RoundContext
32
32
  * are left out of the count.
33
33
  */
34
34
  export declare function evaluateGroupPosition(rule: GroupPositionRule, ctx: RoundContext): Finding[];
35
+ /**
36
+ * A volume gain as the editor's slider shows it: `√gain` as a whole percent
37
+ * (the sliders are perceptual, docs/design.md 3.4). Also the comparison key,
38
+ * so gains the author cannot tell apart on the slider count as the same.
39
+ */
40
+ export declare function volumePercent(gain: number): number;
41
+ /**
42
+ * One sound setting (`mutedBackgroundVolume`) on the audio/video attachments
43
+ * of a round's questions: where at least `majority` of the questions with
44
+ * such attachments (but not all) play every one of them at the same value,
45
+ * and that value is not the default, the questions with an attachment that
46
+ * differs or leaves it unset. A round whose majority leaves the setting at
47
+ * the default is left alone: the few that set it did so on purpose.
48
+ */
49
+ export declare function evaluateAttachmentVolume(rule: AttachmentVolumeRule, ctx: RoundContext): Finding[];
35
50
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
36
51
  export declare function evaluateExplanationPair(rule: ExplanationPairRule, ctx: RoundContext, quiet: Set<string>): Finding[];
37
52
  export declare function evaluateConsistency(rule: ConsistencyRule, ctx: RoundContext, quiet: Set<string>): Finding[];
package/dist/rules.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * question-anomaly.ts and question-type-descriptor.ts so behaviour and ids
5
5
  * are identical.
6
6
  */
7
- import { filledOptionCount, fillsSlot, imageMapOf, optionsOf, questionSetting, resourceText, typeSettingValue } from './aggregate.js';
7
+ import { attachmentSoundNumber, attachmentsOf, filledOptionCount, fillsSlot, imageMapOf, optionsOf, questionSetting, resourceText, typeSettingValue } from './aggregate.js';
8
8
  import { groupSizeWarningId, questionWarningId } from './ignore.js';
9
9
  import { interpolate, localizedText } from './text.js';
10
10
  export function render(text, params, language) {
@@ -207,6 +207,78 @@ export function evaluateGroupPosition(rule, ctx) {
207
207
  }
208
208
  return out;
209
209
  }
210
+ /**
211
+ * A volume gain as the editor's slider shows it: `√gain` as a whole percent
212
+ * (the sliders are perceptual, docs/design.md 3.4). Also the comparison key,
213
+ * so gains the author cannot tell apart on the slider count as the same.
214
+ */
215
+ export function volumePercent(gain) {
216
+ return gain > 0 ? Math.round(Math.sqrt(Math.min(1, gain)) * 100) : 0;
217
+ }
218
+ /**
219
+ * One sound setting (`mutedBackgroundVolume`) on the audio/video attachments
220
+ * of a round's questions: where at least `majority` of the questions with
221
+ * such attachments (but not all) play every one of them at the same value,
222
+ * and that value is not the default, the questions with an attachment that
223
+ * differs or leaves it unset. A round whose majority leaves the setting at
224
+ * the default is left alone: the few that set it did so on purpose.
225
+ */
226
+ export function evaluateAttachmentVolume(rule, ctx) {
227
+ const types = rule.types ?? ['audio', 'video'];
228
+ const excluded = rule.excludeSlots ?? [];
229
+ const defaultPercent = volumePercent(rule.default);
230
+ const carriers = [];
231
+ for (const q of ctx.questions) {
232
+ const percents = [];
233
+ const slots = attachmentsOf(q.question);
234
+ for (const slot of Object.keys(slots)) {
235
+ const items = slots[slot]?.items;
236
+ if (excluded.includes(slot) || !Array.isArray(items)) {
237
+ continue;
238
+ }
239
+ for (const attachment of items) {
240
+ if (attachment && types.includes(attachment.type)) {
241
+ const gain = attachmentSoundNumber(attachment, rule.setting);
242
+ percents.push(volumePercent(gain ?? rule.default));
243
+ }
244
+ }
245
+ }
246
+ if (percents.length > 0) {
247
+ carriers.push({ q, percents });
248
+ }
249
+ }
250
+ if (carriers.length < (rule.minPopulation ?? 4)) {
251
+ return [];
252
+ }
253
+ const tally = new Map();
254
+ for (const c of carriers) {
255
+ if (c.percents.every(p => p === c.percents[0])) {
256
+ tally.set(c.percents[0], (tally.get(c.percents[0]) ?? 0) + 1);
257
+ }
258
+ }
259
+ let expected = defaultPercent;
260
+ let agreeing = 0;
261
+ tally.forEach((count, percent) => {
262
+ if (count > agreeing) {
263
+ expected = percent;
264
+ agreeing = count;
265
+ }
266
+ });
267
+ if (expected === defaultPercent || agreeing === carriers.length || agreeing < carriers.length * (rule.majority ?? 0.75)) {
268
+ return [];
269
+ }
270
+ const out = [];
271
+ for (const c of carriers) {
272
+ const odd = c.percents.find(p => p !== expected);
273
+ if (odd === undefined) {
274
+ continue;
275
+ }
276
+ out.push(odd === defaultPercent
277
+ ? finding(ctx, c.q, rule, rule.messageUnset, { expected: expected + '%' })
278
+ : finding(ctx, c.q, rule, rule.message, { value: odd + '%', expected: expected + '%' }));
279
+ }
280
+ return out;
281
+ }
210
282
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
211
283
  export function evaluateExplanationPair(rule, ctx, quiet) {
212
284
  const fields = rule.fields;
package/dist/types.d.ts CHANGED
@@ -22,7 +22,7 @@ export interface SettingsField {
22
22
  };
23
23
  [key: string]: unknown;
24
24
  }
25
- export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'groupPosition' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
25
+ export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'groupPosition' | 'attachmentVolume' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
26
26
  export declare const COMPARATIVE_KINDS: readonly RuleKind[];
27
27
  export declare const PER_QUESTION_KINDS: readonly RuleKind[];
28
28
  export type Severity = 'error' | 'warning';
@@ -77,6 +77,19 @@ export interface GroupPositionRule extends RuleBase {
77
77
  /** a text on the primary resource (`label`), compared trimmed and ignoring case. */
78
78
  field?: string;
79
79
  }
80
+ export interface AttachmentVolumeRule extends RuleBase {
81
+ rule: 'attachmentVolume';
82
+ /** The sound setting compared: a linear gain under `attributes.sound` (`mutedBackgroundVolume`, `volume`). */
83
+ setting: string;
84
+ /** The gain an attachment that does not set it plays at (`mutedBackgroundVolume` 0, `volume` 1). */
85
+ default: number;
86
+ /** Attachment types that carry the setting (default `["audio", "video"]`). */
87
+ types?: string[];
88
+ /** Question attachment slots left out (`background`). */
89
+ excludeSlots?: string[];
90
+ /** For an attachment that leaves the setting at its default. */
91
+ messageUnset: LocalizedText;
92
+ }
80
93
  export type RequireCheck = 'correctOption' | 'allOptionsFilled' | 'answer' | 'imageMap';
81
94
  export interface RequireRule extends RuleBase {
82
95
  rule: 'require';
@@ -105,7 +118,7 @@ export interface TypeSettingRule extends RuleBase {
105
118
  min: number;
106
119
  };
107
120
  }
108
- export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | GroupPositionRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
121
+ export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | GroupPositionRule | AttachmentVolumeRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
109
122
  /** The existing editor-facing fields the evaluator reads as well (docs/design.md, 3.2). */
110
123
  export type CompletenessRule = 'requireText' | 'requireCorrectOption' | 'requireAllOptionsFilled' | 'requireAnswer' | 'requireImageMap';
111
124
  export interface OptionCountWarning {
package/dist/types.js CHANGED
@@ -2,5 +2,5 @@
2
2
  * The served manifests (the part this package reads), the rule vocabulary
3
3
  * and the evaluator's input and output (docs/design.md, 3 and 4).
4
4
  */
5
- export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize', 'groupPosition'];
5
+ export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize', 'groupPosition', 'attachmentVolume'];
6
6
  export const PER_QUESTION_KINDS = ['require', 'settingEquals', 'optionCountOver', 'typeSetting'];
package/docs/design.md CHANGED
@@ -80,6 +80,7 @@ Messages are **English keys**: the editor translates them with its own catalogue
80
80
  | `explanationPair` | `fields` (`["longExplanation", "explanation"]`), `labels` (`{field: label}`), `minPopulation` (4), `majority` (0.75) | Among questions that fill either field, the ones using the other half than the majority. Params `{expected, actual}` = labels. |
81
81
  | `optionCountMajority` | `types` (question type tokens), `minPopulation` (4), `majority` (0.5) | Same-type siblings (types listed) with a different number of filled options than the strict majority. Params `{count, expected}`. |
82
82
  | `groupPosition` | exactly one of `setting` (a numeric question setting: `points`) or `field` (a text on the primary resource: `label`, compared trimmed and ignoring case), `minPopulation` (3, groups), `majority` (0.5) | In a grouped round, the nth question of every group is compared with the nth question of the other groups: where a strict majority of the groups (but not all) agree, the nth questions that differ. Questions without a value (setting not > 0, empty text) are left out. Params `{value, expected, position}` (position from 1). |
83
+ | `attachmentVolume` | `setting` (a sound setting under `attributes.sound`, a linear gain: `mutedBackgroundVolume`), `default` (the gain when unset: 0), `types` (`["audio", "video"]`), `excludeSlots` (`["background"]`), `messageUnset`, `minPopulation` (4, questions), `majority` (0.75) | The audio/video attachments of every question's slots (legacy `[key=value]` name brackets read too). Gains are compared as the editor's perceptual slider shows them (√gain as a whole percent). Where at least `majority` of the questions with such attachments (but not all) play all of them at one value that is not the default, the questions with an attachment that differs: `message` with `{value, expected}`, or `messageUnset` with `{expected}` when it leaves the setting at the default. A round whose majority leaves it at the default gets nothing. |
83
84
  | `groupSize` | `minPopulation` (3, groups), `message` (several), `messageSingle` (one question) | In a grouped round, the one group whose size differs when every other group agrees. Id `<roundUuid>:group:<groupId>:group-size`, anchored on the group's first question. Params `{count, expected}`. |
84
85
 
85
86
  **Per-question kinds** — checked on every question in scope:
@@ -55,6 +55,7 @@
55
55
  {
56
56
  "token": "commonthread",
57
57
  "name": "Common Thread",
58
+ "roundExplanation": "Find the common thread that links all answers to the following questions.",
58
59
  "guidance": "Normal questions whose answers share a hidden link. The last question asks for that link (\"What is the common thread?\") and always stays last, even with random order. All results are postponed; the last question's result is shown first, then the others.",
59
60
  "description": "All answers should share a common thread. Feedback of the last question is shown first, before all other feedback.",
60
61
  "category": "Regular",
@@ -950,6 +951,9 @@
950
951
  "limiters": [
951
952
  "all"
952
953
  ],
954
+ "unavailableSettings": [
955
+ "timeBasedScoring"
956
+ ],
953
957
  "answerModel": {
954
958
  "cardinality": "single",
955
959
  "correctness": "all",
@@ -1062,6 +1066,7 @@
1062
1066
  {
1063
1067
  "token": "strike",
1064
1068
  "name": "Strike",
1069
+ "roundExplanation": "Answer consecutive questions correctly to reach higher levels and get bonus points.",
1065
1070
  "guidance": "Each correct answer in a row climbs a level and adds a bonus per level; a wrong answer drops levels (see the strike round type). The level ladder moves with every result, so a round scored this way shows each result right after its question and cannot play as a lightning round. In a question groups round the levels can start over with every group.",
1066
1071
  "immediateFeedback": true,
1067
1072
  "settingsFields": [
@@ -1104,6 +1109,7 @@
1104
1109
  {
1105
1110
  "token": "piece-of-pie",
1106
1111
  "name": "Piece of pie",
1112
+ "roundExplanation": "All available points are distributed among all players who answered the question correctly.",
1107
1113
  "guidance": "The question's points times the number of active players, shared equally among the players who answer correctly (see the pieceofpie round type).",
1108
1114
  "description": "All available points are distributed among all players who answered the question correctly."
1109
1115
  },
@@ -1156,6 +1162,7 @@
1156
1162
  {
1157
1163
  "token": "first-come-first-served",
1158
1164
  "name": "First come first served",
1165
+ "roundExplanation": "First players to answer correctly get more points.",
1159
1166
  "guidance": "Faster correct answers earn more: the fastest correct player gets the question's points, each next correct player 1/N less (N = number of players). Wrong answers earn nothing. Questions start without time-based scoring, which would count speed twice.",
1160
1167
  "description": "Faster correct answers earn more points.",
1161
1168
  "settingsFields": [
@@ -1472,6 +1479,25 @@
1472
1479
  "questionList": "list",
1473
1480
  "message": "Most questions in this round have an attachment in \"{slot}\", this one does not",
1474
1481
  "description": "Missing an attachment in \"{slot}\", which most of the round has"
1482
+ },
1483
+ {
1484
+ "id": "background-volume",
1485
+ "rule": "attachmentVolume",
1486
+ "setting": "mutedBackgroundVolume",
1487
+ "default": 0,
1488
+ "types": [
1489
+ "audio",
1490
+ "video"
1491
+ ],
1492
+ "excludeSlots": [
1493
+ "background"
1494
+ ],
1495
+ "minPopulation": 4,
1496
+ "majority": 0.75,
1497
+ "questionList": "list",
1498
+ "message": "Background volume {value} while most questions in this round use {expected}",
1499
+ "messageUnset": "No background volume set, so other sounds are muted, while most questions in this round use {expected}",
1500
+ "description": "Attachment with a different background volume than the rest of the round"
1475
1501
  }
1476
1502
  ]
1477
1503
  },
@@ -1550,6 +1576,29 @@
1550
1576
  {
1551
1577
  "token": "mod-betting",
1552
1578
  "name": "Betting",
1579
+ "roundExplanation": {
1580
+ "en": "Before each question you bet points on your answer. Answer right and you win your bet, answer wrong and you lose it!",
1581
+ "bg": "Преди всеки въпрос залагате точки на отговора си. Отговорите ли правилно, печелите залога си, отговорите ли грешно, го губите!",
1582
+ "cs": "Před každou otázkou vsadíte body na svou odpověď. Odpovíte-li správně, sázku vyhrajete, odpovíte-li špatně, přijdete o ni!",
1583
+ "da": "Før hvert spørgsmål satser du point på dit svar. Svar rigtigt, og du vinder din indsats - svar forkert, og du mister den!",
1584
+ "de": "Vor jeder Frage setzt du Punkte auf deine Antwort. Antwortest du richtig, gewinnst du deinen Einsatz, antwortest du falsch, verlierst du ihn!",
1585
+ "el": "Πριν από κάθε ερώτηση ποντάρετε πόντους στην απάντησή σας. Αν απαντήσετε σωστά κερδίζετε όσα ποντάρατε, αν απαντήσετε λάθος τα χάνετε!",
1586
+ "es": "Antes de cada pregunta apuestas puntos por tu respuesta. ¡Si aciertas, ganas tu apuesta; si fallas, la pierdes!",
1587
+ "fi": "Ennen jokaista kysymystä panostat pisteitä vastauksesi puolesta. Jos vastaat oikein, voitat panoksesi, ja jos vastaat väärin, menetät sen!",
1588
+ "fr": "Avant chaque question, vous misez des points sur votre réponse. Répondez correctement et vous gagnez votre mise, trompez-vous et vous la perdez !",
1589
+ "hi": "हर सवाल से पहले आप अपने जवाब पर अंकों का दांव लगाते हैं। सही जवाब दें तो दांव जीतें, गलत जवाब दें तो उसे हार जाएँ!",
1590
+ "hu": "Minden kérdés előtt pontokat teszel fel a válaszodra. Ha jól válaszolsz, megnyered a téted, ha rosszul, elveszíted!",
1591
+ "it": "Prima di ogni domanda punti dei punti sulla tua risposta. Rispondi bene e vinci la tua puntata, sbaglia e la perdi!",
1592
+ "ko": "문제마다 시작 전에 자신의 답에 점수를 베팅합니다. 맞히면 베팅한 점수를 얻고, 틀리면 잃습니다!",
1593
+ "nl": "Voor elke vraag zet je punten in op je antwoord. Antwoord juist en je wint je inzet, antwoord fout en je verliest je inzet!",
1594
+ "no": "Før hvert spørsmål satser du poeng på svaret ditt. Svarer du riktig, vinner du innsatsen din. Svarer du feil, taper du den!",
1595
+ "pl": "Przed każdym pytaniem stawiasz punkty na swoją odpowiedź. Odpowiesz poprawnie - wygrywasz stawkę, odpowiesz błędnie - tracisz ją!",
1596
+ "pt": "Antes de cada pergunta, apostas pontos na tua resposta. Se acertares, ganhas a aposta; se errares, perdes a aposta!",
1597
+ "sk": "Pred každou otázkou vsadíte body na svoju odpoveď. Ak odpoviete správne, stávku vyhráte, ak nesprávne, prídete o ňu!",
1598
+ "sv": "Före varje fråga satsar du poäng på ditt svar. Svarar du rätt vinner du din insats, svarar du fel förlorar du den!",
1599
+ "tr": "Her sorudan önce cevabın üzerine puan bahsi yaparsın. Doğru cevaplarsan bahsini kazanırsın, yanlış cevaplarsan kaybedersin!",
1600
+ "uk": "Перед кожним запитанням ти ставиш бали на свою відповідь. Відповідаєш правильно - виграєш ставку, неправильно - втрачаєш її!"
1601
+ },
1553
1602
  "description": "Before each question, players bet part of the question's points. A right answer wins the bet, a wrong answer loses it.",
1554
1603
  "strategy": "BettingStrategy.js",
1555
1604
  "supportedRoundTypes": [
@@ -1791,10 +1840,172 @@
1791
1840
  {
1792
1841
  "id": "qw-themehacker",
1793
1842
  "name": "Theme Hacker",
1794
- "version": "1.0.0",
1843
+ "version": "1.1.0",
1795
1844
  "contexts": {
1796
1845
  "main": "themehacker.js"
1797
- }
1846
+ },
1847
+ "themeTools": [
1848
+ {
1849
+ "id": "customize",
1850
+ "label": {
1851
+ "en": "Customize theme",
1852
+ "nl": "Thema aanpassen"
1853
+ },
1854
+ "hint": {
1855
+ "en": "Change the colors of the theme in the theme tester, then apply them to this quiz.",
1856
+ "nl": "Pas de kleuren van het thema aan in de thematester en pas ze daarna toe op deze quiz."
1857
+ },
1858
+ "mode": "test",
1859
+ "premium": true
1860
+ }
1861
+ ],
1862
+ "themeSettings": [
1863
+ {
1864
+ "name": "mainColor",
1865
+ "label": "Main color",
1866
+ "type": "color",
1867
+ "marker": "_main_"
1868
+ },
1869
+ {
1870
+ "name": "accentColor",
1871
+ "label": "Accent color",
1872
+ "type": "color",
1873
+ "marker": "_accent_"
1874
+ },
1875
+ {
1876
+ "name": "backgroundColor",
1877
+ "label": "Background",
1878
+ "type": "color",
1879
+ "allowGradient": true,
1880
+ "marker": "_background_"
1881
+ },
1882
+ {
1883
+ "name": "containerColor",
1884
+ "label": "Question container",
1885
+ "type": "color",
1886
+ "allowGradient": true,
1887
+ "allowAlpha": true,
1888
+ "marker": "_container_"
1889
+ },
1890
+ {
1891
+ "name": "containerTextColor",
1892
+ "label": "Player names on the container",
1893
+ "type": "color"
1894
+ },
1895
+ {
1896
+ "name": "timerBackgroundColor",
1897
+ "label": "Timer background",
1898
+ "type": "color",
1899
+ "marker": "_timerBackground_"
1900
+ },
1901
+ {
1902
+ "name": "optionColors",
1903
+ "label": "Answer options",
1904
+ "type": "color",
1905
+ "amount": 4,
1906
+ "marker": "_optionColors_"
1907
+ },
1908
+ {
1909
+ "name": "optionBorderColors",
1910
+ "label": "Answer option borders",
1911
+ "type": "color",
1912
+ "amount": 4,
1913
+ "marker": "_optionColors_"
1914
+ },
1915
+ {
1916
+ "name": "dialogTextColor",
1917
+ "label": "Dialog text",
1918
+ "type": "color"
1919
+ },
1920
+ {
1921
+ "name": "headerTextColor",
1922
+ "label": "Header text",
1923
+ "type": "color"
1924
+ },
1925
+ {
1926
+ "name": "optionTextColor",
1927
+ "label": "Answer option text",
1928
+ "type": "color"
1929
+ },
1930
+ {
1931
+ "name": "pieChartLabelColor",
1932
+ "label": "Pie chart labels",
1933
+ "type": "color"
1934
+ },
1935
+ {
1936
+ "name": "ladderColor",
1937
+ "label": "Score ladder",
1938
+ "type": "color"
1939
+ },
1940
+ {
1941
+ "name": "remoteBackgroundColor",
1942
+ "label": "Devices: background",
1943
+ "type": "color"
1944
+ },
1945
+ {
1946
+ "name": "remoteTextColor",
1947
+ "label": "Devices: text",
1948
+ "type": "color"
1949
+ },
1950
+ {
1951
+ "name": "remoteQuestionColor",
1952
+ "label": "Devices: question",
1953
+ "type": "color"
1954
+ },
1955
+ {
1956
+ "name": "remoteQuestionTextColor",
1957
+ "label": "Devices: question text",
1958
+ "type": "color"
1959
+ },
1960
+ {
1961
+ "name": "remoteOptionColors",
1962
+ "label": "Devices: answer options",
1963
+ "type": "color",
1964
+ "amount": 4
1965
+ },
1966
+ {
1967
+ "name": "remoteOptionOutlineColor",
1968
+ "label": "Devices: answer option outline",
1969
+ "type": "color"
1970
+ },
1971
+ {
1972
+ "name": "remoteOutlineColor",
1973
+ "label": "Devices: outline",
1974
+ "type": "color"
1975
+ },
1976
+ {
1977
+ "name": "remoteContainerBackground",
1978
+ "label": "Devices: container",
1979
+ "type": "color"
1980
+ },
1981
+ {
1982
+ "name": "remoteContainerText",
1983
+ "label": "Devices: container text",
1984
+ "type": "color"
1985
+ },
1986
+ {
1987
+ "name": "defaultFont",
1988
+ "label": "Font",
1989
+ "type": "font",
1990
+ "fontSlot": "default"
1991
+ },
1992
+ {
1993
+ "name": "headerFont",
1994
+ "label": "Header font",
1995
+ "type": "font",
1996
+ "fontSlot": "header"
1997
+ },
1998
+ {
1999
+ "name": "textDecorators",
2000
+ "label": "Upper case text",
2001
+ "type": "decorators"
2002
+ },
2003
+ {
2004
+ "name": "hideLogo",
2005
+ "label": "Hide the QuizWitz logo",
2006
+ "type": "flag"
2007
+ }
2008
+ ]
1798
2009
  },
1799
2010
  {
1800
2011
  "id": "qw-translationoverride",
@@ -2273,6 +2484,718 @@
2273
2484
  ]
2274
2485
  }
2275
2486
  },
2487
+ {
2488
+ "name": "background-volume-default-majority",
2489
+ "input": {
2490
+ "round": {
2491
+ "uuid": "r1",
2492
+ "original_language": "en",
2493
+ "mode": {
2494
+ "token": "trivia"
2495
+ },
2496
+ "scoringStrategy": {
2497
+ "token": "default"
2498
+ },
2499
+ "resources": [
2500
+ {
2501
+ "language": "en",
2502
+ "name": "Round one"
2503
+ }
2504
+ ],
2505
+ "questions": {
2506
+ "items": [
2507
+ {
2508
+ "uuid": "q1",
2509
+ "original_language": "en",
2510
+ "mode": {
2511
+ "token": "openquestion"
2512
+ },
2513
+ "points": 1000,
2514
+ "duration": 30,
2515
+ "resources": [
2516
+ {
2517
+ "language": "en",
2518
+ "name": "Question q1",
2519
+ "options": {
2520
+ "items": [
2521
+ {
2522
+ "option": 1,
2523
+ "text": "Answer",
2524
+ "correct": true
2525
+ }
2526
+ ]
2527
+ },
2528
+ "attachments": {
2529
+ "during": {
2530
+ "items": [
2531
+ {
2532
+ "id": 1,
2533
+ "type": "video",
2534
+ "url": "https://example.test/clip-1.mp4"
2535
+ }
2536
+ ]
2537
+ }
2538
+ }
2539
+ }
2540
+ ]
2541
+ },
2542
+ {
2543
+ "uuid": "q2",
2544
+ "original_language": "en",
2545
+ "mode": {
2546
+ "token": "openquestion"
2547
+ },
2548
+ "points": 1000,
2549
+ "duration": 30,
2550
+ "resources": [
2551
+ {
2552
+ "language": "en",
2553
+ "name": "Question q2",
2554
+ "options": {
2555
+ "items": [
2556
+ {
2557
+ "option": 1,
2558
+ "text": "Answer",
2559
+ "correct": true
2560
+ }
2561
+ ]
2562
+ },
2563
+ "attachments": {
2564
+ "during": {
2565
+ "items": [
2566
+ {
2567
+ "id": 2,
2568
+ "type": "video",
2569
+ "url": "https://example.test/clip-2.mp4"
2570
+ }
2571
+ ]
2572
+ }
2573
+ }
2574
+ }
2575
+ ]
2576
+ },
2577
+ {
2578
+ "uuid": "q3",
2579
+ "original_language": "en",
2580
+ "mode": {
2581
+ "token": "openquestion"
2582
+ },
2583
+ "points": 1000,
2584
+ "duration": 30,
2585
+ "resources": [
2586
+ {
2587
+ "language": "en",
2588
+ "name": "Question q3",
2589
+ "options": {
2590
+ "items": [
2591
+ {
2592
+ "option": 1,
2593
+ "text": "Answer",
2594
+ "correct": true
2595
+ }
2596
+ ]
2597
+ },
2598
+ "attachments": {
2599
+ "during": {
2600
+ "items": [
2601
+ {
2602
+ "id": 3,
2603
+ "type": "video",
2604
+ "url": "https://example.test/clip-3.mp4"
2605
+ }
2606
+ ]
2607
+ }
2608
+ }
2609
+ }
2610
+ ]
2611
+ },
2612
+ {
2613
+ "uuid": "q4",
2614
+ "original_language": "en",
2615
+ "mode": {
2616
+ "token": "openquestion"
2617
+ },
2618
+ "points": 1000,
2619
+ "duration": 30,
2620
+ "resources": [
2621
+ {
2622
+ "language": "en",
2623
+ "name": "Question q4",
2624
+ "options": {
2625
+ "items": [
2626
+ {
2627
+ "option": 1,
2628
+ "text": "Answer",
2629
+ "correct": true
2630
+ }
2631
+ ]
2632
+ },
2633
+ "attachments": {
2634
+ "during": {
2635
+ "items": [
2636
+ {
2637
+ "id": 4,
2638
+ "type": "video",
2639
+ "url": "https://example.test/clip-4.mp4"
2640
+ }
2641
+ ]
2642
+ }
2643
+ }
2644
+ }
2645
+ ]
2646
+ },
2647
+ {
2648
+ "uuid": "q5",
2649
+ "original_language": "en",
2650
+ "mode": {
2651
+ "token": "openquestion"
2652
+ },
2653
+ "points": 1000,
2654
+ "duration": 30,
2655
+ "resources": [
2656
+ {
2657
+ "language": "en",
2658
+ "name": "Question q5",
2659
+ "options": {
2660
+ "items": [
2661
+ {
2662
+ "option": 1,
2663
+ "text": "Answer",
2664
+ "correct": true
2665
+ }
2666
+ ]
2667
+ },
2668
+ "attachments": {
2669
+ "during": {
2670
+ "items": [
2671
+ {
2672
+ "id": 5,
2673
+ "type": "video",
2674
+ "url": "https://example.test/clip-5.mp4"
2675
+ }
2676
+ ]
2677
+ }
2678
+ }
2679
+ }
2680
+ ]
2681
+ },
2682
+ {
2683
+ "uuid": "q6",
2684
+ "original_language": "en",
2685
+ "mode": {
2686
+ "token": "openquestion"
2687
+ },
2688
+ "points": 1000,
2689
+ "duration": 30,
2690
+ "resources": [
2691
+ {
2692
+ "language": "en",
2693
+ "name": "Question q6",
2694
+ "options": {
2695
+ "items": [
2696
+ {
2697
+ "option": 1,
2698
+ "text": "Answer",
2699
+ "correct": true
2700
+ }
2701
+ ]
2702
+ },
2703
+ "attachments": {
2704
+ "during": {
2705
+ "items": [
2706
+ {
2707
+ "id": 6,
2708
+ "type": "video",
2709
+ "url": "https://example.test/clip-6.mp4",
2710
+ "attributes": {
2711
+ "sound": {
2712
+ "mutedBackgroundVolume": 0.25
2713
+ }
2714
+ }
2715
+ }
2716
+ ]
2717
+ }
2718
+ }
2719
+ }
2720
+ ]
2721
+ }
2722
+ ]
2723
+ }
2724
+ }
2725
+ },
2726
+ "expected": {
2727
+ "errors": [],
2728
+ "warnings": []
2729
+ }
2730
+ },
2731
+ {
2732
+ "name": "background-volume",
2733
+ "input": {
2734
+ "round": {
2735
+ "uuid": "r1",
2736
+ "original_language": "en",
2737
+ "mode": {
2738
+ "token": "trivia"
2739
+ },
2740
+ "scoringStrategy": {
2741
+ "token": "default"
2742
+ },
2743
+ "resources": [
2744
+ {
2745
+ "language": "en",
2746
+ "name": "Round one"
2747
+ }
2748
+ ],
2749
+ "questions": {
2750
+ "items": [
2751
+ {
2752
+ "uuid": "q1",
2753
+ "original_language": "en",
2754
+ "mode": {
2755
+ "token": "openquestion"
2756
+ },
2757
+ "points": 1000,
2758
+ "duration": 30,
2759
+ "resources": [
2760
+ {
2761
+ "language": "en",
2762
+ "name": "Question q1",
2763
+ "options": {
2764
+ "items": [
2765
+ {
2766
+ "option": 1,
2767
+ "text": "Answer",
2768
+ "correct": true
2769
+ }
2770
+ ]
2771
+ },
2772
+ "attachments": {
2773
+ "during": {
2774
+ "items": [
2775
+ {
2776
+ "id": 1,
2777
+ "type": "video",
2778
+ "url": "https://example.test/clip-1.mp4",
2779
+ "attributes": {
2780
+ "sound": {
2781
+ "mutedBackgroundVolume": 0.25
2782
+ }
2783
+ }
2784
+ }
2785
+ ]
2786
+ },
2787
+ "background": {
2788
+ "items": [
2789
+ {
2790
+ "id": 91,
2791
+ "type": "audio",
2792
+ "url": "https://example.test/clip-91.mp4"
2793
+ }
2794
+ ]
2795
+ }
2796
+ }
2797
+ }
2798
+ ]
2799
+ },
2800
+ {
2801
+ "uuid": "q2",
2802
+ "original_language": "en",
2803
+ "mode": {
2804
+ "token": "openquestion"
2805
+ },
2806
+ "points": 1000,
2807
+ "duration": 30,
2808
+ "resources": [
2809
+ {
2810
+ "language": "en",
2811
+ "name": "Question q2",
2812
+ "options": {
2813
+ "items": [
2814
+ {
2815
+ "option": 1,
2816
+ "text": "Answer",
2817
+ "correct": true
2818
+ }
2819
+ ]
2820
+ },
2821
+ "attachments": {
2822
+ "during": {
2823
+ "items": [
2824
+ {
2825
+ "id": 2,
2826
+ "type": "video",
2827
+ "url": "https://example.test/clip-2.mp4",
2828
+ "attributes": {
2829
+ "sound": {
2830
+ "mutedBackgroundVolume": 0.25
2831
+ }
2832
+ }
2833
+ }
2834
+ ]
2835
+ }
2836
+ }
2837
+ }
2838
+ ]
2839
+ },
2840
+ {
2841
+ "uuid": "q3",
2842
+ "original_language": "en",
2843
+ "mode": {
2844
+ "token": "openquestion"
2845
+ },
2846
+ "points": 1000,
2847
+ "duration": 30,
2848
+ "resources": [
2849
+ {
2850
+ "language": "en",
2851
+ "name": "Question q3",
2852
+ "options": {
2853
+ "items": [
2854
+ {
2855
+ "option": 1,
2856
+ "text": "Answer",
2857
+ "correct": true
2858
+ }
2859
+ ]
2860
+ },
2861
+ "attachments": {
2862
+ "during": {
2863
+ "items": [
2864
+ {
2865
+ "id": 3,
2866
+ "type": "video",
2867
+ "url": "https://example.test/clip-3.mp4",
2868
+ "attributes": {
2869
+ "sound": {
2870
+ "mutedBackgroundVolume": 0.2525
2871
+ }
2872
+ }
2873
+ }
2874
+ ]
2875
+ },
2876
+ "before": {
2877
+ "items": [
2878
+ {
2879
+ "id": 93,
2880
+ "type": "image",
2881
+ "url": "https://example.test/p.jpg"
2882
+ }
2883
+ ]
2884
+ }
2885
+ }
2886
+ }
2887
+ ]
2888
+ },
2889
+ {
2890
+ "uuid": "q4",
2891
+ "original_language": "en",
2892
+ "mode": {
2893
+ "token": "openquestion"
2894
+ },
2895
+ "points": 1000,
2896
+ "duration": 30,
2897
+ "resources": [
2898
+ {
2899
+ "language": "en",
2900
+ "name": "Question q4",
2901
+ "options": {
2902
+ "items": [
2903
+ {
2904
+ "option": 1,
2905
+ "text": "Answer",
2906
+ "correct": true
2907
+ }
2908
+ ]
2909
+ },
2910
+ "attachments": {
2911
+ "after": {
2912
+ "items": [
2913
+ {
2914
+ "id": 4,
2915
+ "type": "audio",
2916
+ "url": "https://example.test/clip-4.mp4",
2917
+ "name": "Applause [mutedBackgroundVolume=0.25]"
2918
+ }
2919
+ ]
2920
+ },
2921
+ "during": {
2922
+ "items": [
2923
+ {
2924
+ "id": 84,
2925
+ "type": "image",
2926
+ "url": "https://example.test/d.jpg"
2927
+ }
2928
+ ]
2929
+ }
2930
+ }
2931
+ }
2932
+ ]
2933
+ },
2934
+ {
2935
+ "uuid": "q5",
2936
+ "original_language": "en",
2937
+ "mode": {
2938
+ "token": "openquestion"
2939
+ },
2940
+ "points": 1000,
2941
+ "duration": 30,
2942
+ "resources": [
2943
+ {
2944
+ "language": "en",
2945
+ "name": "Question q5",
2946
+ "options": {
2947
+ "items": [
2948
+ {
2949
+ "option": 1,
2950
+ "text": "Answer",
2951
+ "correct": true
2952
+ }
2953
+ ]
2954
+ },
2955
+ "attachments": {
2956
+ "during": {
2957
+ "items": [
2958
+ {
2959
+ "id": 5,
2960
+ "type": "video",
2961
+ "url": "https://example.test/clip-5.mp4",
2962
+ "attributes": {
2963
+ "sound": {
2964
+ "mutedBackgroundVolume": 0.25
2965
+ }
2966
+ }
2967
+ }
2968
+ ]
2969
+ }
2970
+ }
2971
+ }
2972
+ ]
2973
+ },
2974
+ {
2975
+ "uuid": "q6",
2976
+ "original_language": "en",
2977
+ "mode": {
2978
+ "token": "openquestion"
2979
+ },
2980
+ "points": 1000,
2981
+ "duration": 30,
2982
+ "resources": [
2983
+ {
2984
+ "language": "en",
2985
+ "name": "Question q6",
2986
+ "options": {
2987
+ "items": [
2988
+ {
2989
+ "option": 1,
2990
+ "text": "Answer",
2991
+ "correct": true
2992
+ }
2993
+ ]
2994
+ },
2995
+ "attachments": {
2996
+ "during": {
2997
+ "items": [
2998
+ {
2999
+ "id": 6,
3000
+ "type": "video",
3001
+ "url": "https://example.test/clip-6.mp4",
3002
+ "attributes": {
3003
+ "sound": {
3004
+ "mutedBackgroundVolume": 0.25
3005
+ }
3006
+ }
3007
+ }
3008
+ ]
3009
+ },
3010
+ "feedback:after": {
3011
+ "items": [
3012
+ {
3013
+ "id": 96,
3014
+ "type": "audio",
3015
+ "url": "https://example.test/clip-96.mp4",
3016
+ "attributes": {
3017
+ "sound": {
3018
+ "mutedBackgroundVolume": 0.25
3019
+ }
3020
+ }
3021
+ }
3022
+ ]
3023
+ }
3024
+ }
3025
+ }
3026
+ ]
3027
+ },
3028
+ {
3029
+ "uuid": "q7",
3030
+ "original_language": "en",
3031
+ "mode": {
3032
+ "token": "openquestion"
3033
+ },
3034
+ "points": 1000,
3035
+ "duration": 30,
3036
+ "resources": [
3037
+ {
3038
+ "language": "en",
3039
+ "name": "Question q7",
3040
+ "options": {
3041
+ "items": [
3042
+ {
3043
+ "option": 1,
3044
+ "text": "Answer",
3045
+ "correct": true
3046
+ }
3047
+ ]
3048
+ },
3049
+ "attachments": {
3050
+ "during": {
3051
+ "items": [
3052
+ {
3053
+ "id": 7,
3054
+ "type": "video",
3055
+ "url": "https://example.test/clip-7.mp4",
3056
+ "attributes": {
3057
+ "sound": {
3058
+ "volume": 0.8
3059
+ }
3060
+ }
3061
+ }
3062
+ ]
3063
+ }
3064
+ }
3065
+ }
3066
+ ]
3067
+ },
3068
+ {
3069
+ "uuid": "q8",
3070
+ "original_language": "en",
3071
+ "mode": {
3072
+ "token": "openquestion"
3073
+ },
3074
+ "points": 1000,
3075
+ "duration": 30,
3076
+ "resources": [
3077
+ {
3078
+ "language": "en",
3079
+ "name": "Question q8",
3080
+ "options": {
3081
+ "items": [
3082
+ {
3083
+ "option": 1,
3084
+ "text": "Answer",
3085
+ "correct": true
3086
+ }
3087
+ ]
3088
+ },
3089
+ "attachments": {
3090
+ "during": {
3091
+ "items": [
3092
+ {
3093
+ "id": 8,
3094
+ "type": "video",
3095
+ "url": "https://example.test/clip-8.mp4",
3096
+ "attributes": {
3097
+ "sound": {
3098
+ "mutedBackgroundVolume": 0.25
3099
+ }
3100
+ }
3101
+ }
3102
+ ]
3103
+ },
3104
+ "after": {
3105
+ "items": [
3106
+ {
3107
+ "id": 98,
3108
+ "type": "video",
3109
+ "url": "https://example.test/clip-98.mp4",
3110
+ "attributes": {
3111
+ "sound": {
3112
+ "mutedBackgroundVolume": 0.5
3113
+ }
3114
+ }
3115
+ }
3116
+ ]
3117
+ }
3118
+ }
3119
+ }
3120
+ ]
3121
+ },
3122
+ {
3123
+ "uuid": "q9",
3124
+ "original_language": "en",
3125
+ "mode": {
3126
+ "token": "openquestion"
3127
+ },
3128
+ "points": 1000,
3129
+ "duration": 30,
3130
+ "resources": [
3131
+ {
3132
+ "language": "en",
3133
+ "name": "Question q9",
3134
+ "options": {
3135
+ "items": [
3136
+ {
3137
+ "option": 1,
3138
+ "text": "Answer",
3139
+ "correct": true
3140
+ }
3141
+ ]
3142
+ },
3143
+ "attachments": {
3144
+ "before": {
3145
+ "items": [
3146
+ {
3147
+ "id": 99,
3148
+ "type": "image",
3149
+ "url": "https://example.test/q.jpg"
3150
+ }
3151
+ ]
3152
+ },
3153
+ "during": {
3154
+ "items": [
3155
+ {
3156
+ "id": 89,
3157
+ "type": "image",
3158
+ "url": "https://example.test/d.jpg"
3159
+ }
3160
+ ]
3161
+ }
3162
+ }
3163
+ }
3164
+ ]
3165
+ }
3166
+ ]
3167
+ }
3168
+ }
3169
+ },
3170
+ "expected": {
3171
+ "errors": [],
3172
+ "warnings": [
3173
+ {
3174
+ "id": "q7:background-volume",
3175
+ "ruleId": "background-volume",
3176
+ "roundId": "r1",
3177
+ "questionId": "q7",
3178
+ "message": "No background volume set, so other sounds are muted, while most questions in this round use 50%",
3179
+ "messageKey": "No background volume set, so other sounds are muted, while most questions in this round use {expected}",
3180
+ "messageParams": {
3181
+ "expected": "50%"
3182
+ }
3183
+ },
3184
+ {
3185
+ "id": "q8:background-volume",
3186
+ "ruleId": "background-volume",
3187
+ "roundId": "r1",
3188
+ "questionId": "q8",
3189
+ "message": "Background volume 71% while most questions in this round use 50%",
3190
+ "messageKey": "Background volume {value} while most questions in this round use {expected}",
3191
+ "messageParams": {
3192
+ "value": "71%",
3193
+ "expected": "50%"
3194
+ }
3195
+ }
3196
+ ]
3197
+ }
3198
+ },
2276
3199
  {
2277
3200
  "name": "completeness-errors",
2278
3201
  "input": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quizwitz-validator",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Evaluates the findings (validation issues and surprising-settings warnings) the QuizWitz game client's type manifests declare, for the quiz editor and the MCP server.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",