fix(gameplay): start campaigns in playable state
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
30
apps/web/server/utils/coauthor-question.test.ts
Normal file
30
apps/web/server/utils/coauthor-question.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from './coauthor-question'
|
||||
|
||||
describe('coauthor question validation', () => {
|
||||
it('accepts three useful, distinct answers', () => {
|
||||
expect(CoauthorQuestionSchema.parse({
|
||||
question: 'How will the party enter the sealed archive?',
|
||||
options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'],
|
||||
})).toMatchObject({ options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'] })
|
||||
expect(CoauthorQuestionSchema.toJSONSchema()).toMatchObject({
|
||||
type: 'object',
|
||||
properties: { options: { type: 'array', minItems: 3, maxItems: 3 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed JSON fragments and a repeated question', () => {
|
||||
expect(() => CoauthorQuestionSchema.parse({
|
||||
question: 'Как персонажи собираются решить проблему отсутствия наличных денег?',
|
||||
options: [':', 'question', 'Как персонажи собираются решить проблему отсутствия наличных денег?'],
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('applies the same quality checks to a stored question', () => {
|
||||
expect(() => StoredCoauthorQuestionSchema.parse({
|
||||
id: 'question-2',
|
||||
label: 'What makes the signal dangerous?',
|
||||
options: ['The signal is alive', 'The signal is alive', 'Nobody knows yet'],
|
||||
})).toThrow()
|
||||
})
|
||||
})
|
||||
63
apps/web/server/utils/coauthor-question.ts
Normal file
63
apps/web/server/utils/coauthor-question.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const reservedOptionValues = new Set([
|
||||
'answer',
|
||||
'answers',
|
||||
'label',
|
||||
'option',
|
||||
'options',
|
||||
'question',
|
||||
])
|
||||
|
||||
function normalized(value: string) {
|
||||
return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export const CoauthorQuestionSchema = z.object({
|
||||
question: z.string().trim().min(5).max(300),
|
||||
options: z.array(z.string().trim().min(2).max(100)).length(3),
|
||||
}).superRefine((value, context) => {
|
||||
const question = normalized(value.question)
|
||||
const seen = new Set<string>()
|
||||
|
||||
value.options.forEach((option, index) => {
|
||||
const candidate = normalized(option)
|
||||
if (!/[\p{L}\p{N}]/u.test(candidate) || reservedOptionValues.has(candidate)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Each option must be a meaningful answer.',
|
||||
path: ['options', index],
|
||||
})
|
||||
}
|
||||
if (seen.has(candidate)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Answer options must be distinct.',
|
||||
path: ['options', index],
|
||||
})
|
||||
}
|
||||
if (candidate === question || (candidate.length >= 20 && question.includes(candidate))) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'An answer option must not repeat the question.',
|
||||
path: ['options', index],
|
||||
})
|
||||
}
|
||||
seen.add(candidate)
|
||||
})
|
||||
})
|
||||
|
||||
export const StoredCoauthorQuestionSchema = z.object({
|
||||
id: z.string().trim().min(1).max(80),
|
||||
label: z.string().trim().min(5).max(300),
|
||||
options: CoauthorQuestionSchema.shape.options,
|
||||
}).superRefine((value, context) => {
|
||||
const result = CoauthorQuestionSchema.safeParse({ question: value.label, options: value.options })
|
||||
for (const issue of result.error?.issues ?? []) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: issue.message,
|
||||
path: issue.path[0] === 'question' ? ['label', ...issue.path.slice(1)] : issue.path,
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user