/* Starting a campaign, in one screen. * * `BROWSER-UX-SPEC.md` §41-42 and §7 of the M8 brief. What it replaced was a * scenario gallery followed by a placeholder modal: to begin a story you first * picked a *world*, and to make a world you opened an editor with a JSON stat * schema, a story-card table and an art picker. That is a fine way to build a * D&D module and a poor way to start a mystery. * * Two rules shaped this form. * * **Only the name is required.** Everything else has a sensible blank. A reader * who types "Westhaven" and presses Start gets a working campaign and can write * the first line themselves; the rest of the fields are there for someone who * already knows what they want. §7 is explicit that the data model must not be * a gate on beginning. * * **No genre is privileged.** There is no fantasy vocabulary in the labels, the * placeholders or the composed instructions — the same form has to fit a * western and a horror story. Genre and tone are free text with suggestions * rather than a closed list, because a closed list is a judgement about which * stories this product is for. * * The style answers are composed into the campaign's narrator instructions * (`ai_instructions`), which is the field the prompt builder already reads. No * new backend concept was invented to hold "tone" — it is a sentence in the * instructions, which is what it would have to become anyway. */ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../api' import { useToast } from '../components' import { classifyError } from '../errors' import { blocksPlay, useModelStatus } from '../modelStatus' import { ModelSetupNotice } from '../ModelSetupNotice' const GENRES = [ 'Fantasy', 'Science fiction', 'Mystery', 'Historical', 'Western', 'Horror', 'Thriller', 'Literary', ] const TONES = [ 'Grounded and restrained', 'Warm and hopeful', 'Bleak', 'Wry', 'Tense', 'Whimsical', 'Epic', ] const POV = [ { value: 'second-present', label: 'Second person, present tense ("You walk…")' }, { value: 'first-past', label: 'First person, past tense ("I walked…")' }, { value: 'third-past', label: 'Third person, past tense ("She walked…")' }, { value: 'third-present', label: 'Third person, present tense ("She walks…")' }, ] const LENGTHS = [ { value: 'brief', label: 'Brief — a paragraph or two' }, { value: 'medium', label: 'Medium — two to four paragraphs' }, { value: 'long', label: 'Long — four or more paragraphs' }, ] const POV_SENTENCE = { 'second-present': 'Write in second person, present tense.', 'first-past': 'Write in first person, past tense.', 'third-past': 'Write in third person, past tense.', 'third-present': 'Write in third person, present tense.', } const LENGTH_SENTENCE = { brief: 'Keep responses brief — one or two paragraphs.', medium: 'Keep responses to roughly two to four paragraphs.', long: 'Write at length — four or more paragraphs.', } /** * Turns the style answers into narrator instructions. * * Exported because the composition is the one piece of real logic on this * screen and a component test can check it without rendering anything. */ export function composeInstructions({ genre, tone, pov, length, style }) { const lines = [] if (genre.trim()) lines.push(`This is a ${genre.trim().toLowerCase()} story.`) if (tone.trim()) lines.push(`The tone is ${tone.trim().toLowerCase()}.`) if (POV_SENTENCE[pov]) lines.push(POV_SENTENCE[pov]) if (LENGTH_SENTENCE[length]) lines.push(LENGTH_SENTENCE[length]) // Always last, so a reader's own words can override anything above them. if (style.trim()) lines.push(style.trim()) return lines.join(' ') } /** A text field with a datalist of suggestions — open, not a closed set. */ function SuggestField({ id, label, value, onChange, options, placeholder, hint }) { return (
onChange(e.target.value)} /> {options.map((o) => {hint &&

{hint}

}
) } export default function NewCampaign() { const navigate = useNavigate() const toast = useToast() const { status } = useModelStatus() const [title, setTitle] = useState('') const [genre, setGenre] = useState('') const [tone, setTone] = useState('') const [pov, setPov] = useState('second-present') const [length, setLength] = useState('medium') const [style, setStyle] = useState('') const [protagonist, setProtagonist] = useState('') const [pronouns, setPronouns] = useState('') const [protagonistDesc, setProtagonistDesc] = useState('') const [opening, setOpening] = useState('') const [canon, setCanon] = useState('') const [creating, setCreating] = useState(false) const start = async (event) => { event.preventDefault() if (!title.trim() || creating) return setCreating(true) try { const campaign = await api.createAdventure({ title: title.trim(), opening: opening.trim(), // One rule per line is the lightest editor that produces a list, and it // is what a person writing rules actually does. canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean), persona_name: protagonist.trim(), persona_pronouns: pronouns.trim(), persona_desc: protagonistDesc.trim(), }) const instructions = composeInstructions({ genre, tone, pov, length, style }) if (instructions) { await api.updateAdventure(campaign.id, { ai_instructions: instructions }) } navigate(`/play/${campaign.id}`) } catch (err) { toast(classifyError(err.message).detail, 'error') setCreating(false) } } return (

New campaign

Only a name is needed. Everything else can be changed later, or left to the story to decide.

{blocksPlay(status) && }

Name

setTitle(e.target.value)} />

Story profile