From f7ca158fe4e8c270bbf60c3e5244961e58dd8df6 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Fri, 21 Aug 2026 19:41:50 +0500 Subject: [PATCH] fix(gameplay): start campaigns in playable state Co-authored-by: multica-agent --- README.md | 2 +- apps/web/pages/campaign/[id].vue | 6 +- apps/web/pages/worlds/new.vue | 20 +- .../v1/coauthor/sessions/[id]/respond.post.ts | 59 ++--- .../server/utils/coauthor-question.test.ts | 30 +++ apps/web/server/utils/coauthor-question.ts | 63 +++++ scripts/smoke-multiplayer.mjs | 17 +- supabase/bootstrap.sql | 222 +++++++++++++++++- .../0010_playable_starter_parties.sql | 215 +++++++++++++++++ 9 files changed, 590 insertions(+), 44 deletions(-) create mode 100644 apps/web/server/utils/coauthor-question.test.ts create mode 100644 apps/web/server/utils/coauthor-question.ts create mode 100644 supabase/migrations/0010_playable_starter_parties.sql diff --git a/README.md b/README.md index 652bb4d..de064db 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile 1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow. 2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload. 3. Review every starting-world field, including the owner-only hidden threat, then confirm it. -4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way. +4. A new campaign starts immediately with an owner-controlled hero and a persistent AI companion. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way. 5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`. 6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting. 7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus. diff --git a/apps/web/pages/campaign/[id].vue b/apps/web/pages/campaign/[id].vue index da97aca..3bb079c 100644 --- a/apps/web/pages/campaign/[id].vue +++ b/apps/web/pages/campaign/[id].vue @@ -51,7 +51,11 @@ const myIntent = computed(() => payload.value?.intents.find(intent => intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id, )) const isReady = computed(() => Boolean(myIntent.value?.ready)) -const activeHumanMembers = computed(() => payload.value?.members.filter(member => member.active !== false) ?? []) +const activeHumanMembers = computed(() => payload.value?.members.filter(member => + member.active !== false && payload.value?.characters.some(character => + character.user_id === member.user_id && character.controller === 'human', + ), +) ?? []) const readyCount = computed(() => activeHumanMembers.value.filter(member => payload.value?.intents.some(intent => intent.member_id === member.id && intent.ready)).length) const canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value)) const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status))) diff --git a/apps/web/pages/worlds/new.vue b/apps/web/pages/worlds/new.vue index e66a318..30be4e9 100644 --- a/apps/web/pages/worlds/new.vue +++ b/apps/web/pages/worlds/new.vue @@ -16,6 +16,8 @@ interface StoredSession { confirmed_world_id?: string | null } +const questionLimit = 4 + const { api } = useDngApi() const route = useRoute() const stage = ref('seed') @@ -33,7 +35,14 @@ const messages = ref([ ]) const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null) -const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55)) +const conversationMessages = computed(() => { + if (!currentQuestion.value) return messages.value + const currentIndex = messages.value.findLastIndex(message => + message.role === 'coauthor' && message.body === currentQuestion.value?.label, + ) + return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex) +}) +const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, questionLimit) / questionLimit) * 55)) function addMessage(role: Message['role'], body: string, label?: string) { messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label }) @@ -117,6 +126,7 @@ async function resume(id: string) { async function answerQuestion(key: string, value: string) { if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return + const answeredQuestion = currentQuestion.value busy.value = true pendingAnswer.value = { key, value } try { @@ -124,9 +134,10 @@ async function answerQuestion(key: string, value: string) { method: 'POST', body: { content: value }, }) answers[key] = value + addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`) addMessage('player', value) currentQuestion.value = null - answerCount.value = Math.min(answerCount.value + 1, 5) + answerCount.value = Math.min(answerCount.value + 1, questionLimit) pendingAnswer.value = null retryAction.value = 'respond' await requestNextQuestion() @@ -140,14 +151,13 @@ async function answerQuestion(key: string, value: string) { async function requestNextQuestion() { if (!sessionId.value) return const result = await api(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' }) - if (result.readyToGenerate || answerCount.value >= 5) { + if (result.readyToGenerate || answerCount.value >= questionLimit) { currentQuestion.value = null await generate() return } if (!result.question) throw new Error('The coauthor did not return its next question.') currentQuestion.value = result.question - addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`) } function errorText(error: unknown) { @@ -247,7 +257,7 @@ onMounted(() => {

COAUTHOR / SESSION 01

BUILD THE
IMPOSSIBLE.

- +