Keep existing campaigns reachable from Session Zero

This commit is contained in:
Meta Vens 2026-08-25 13:04:58 +02:00
commit f1aa14618e

View file

@ -3425,12 +3425,13 @@ async fn surface(
.await
{
Ok(Some(id)) => {
return match state
.session_zeros
.snapshot(id)
.await
.and_then(|snapshot| session_zero_surface(&snapshot, &session))
{
let projected = match state.session_zeros.snapshot(id).await {
Ok(snapshot) => {
session_zero_surface_with_campaign_choices(&state, &snapshot, &session).await
}
Err(error) => Err(error),
};
return match projected {
Ok(surface) => Json(surface).into_response(),
Err(error) => {
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
@ -3446,17 +3447,21 @@ async fn surface(
Ok(Some(value)) => value,
Ok(None) => {
return match state.session_zeros.session_for_account(&session).await {
Ok(Some(id)) => match state
.session_zeros
.snapshot(id)
.await
.and_then(|snapshot| session_zero_surface(&snapshot, &session))
{
Ok(surface) => Json(surface).into_response(),
Err(error) => {
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
Ok(Some(id)) => {
let projected = match state.session_zeros.snapshot(id).await {
Ok(snapshot) => {
session_zero_surface_with_campaign_choices(&state, &snapshot, &session)
.await
}
Err(error) => Err(error),
};
match projected {
Ok(surface) => Json(surface).into_response(),
Err(error) => {
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
}
}
},
}
Ok(None) => match session_zero_entry_surface(&state, &session, invite).await {
Ok(surface) => Json(surface).into_response(),
Err(error) => {
@ -3579,17 +3584,7 @@ async fn session_zero_entry_surface(
account_hash: &str,
invite: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let mut campaign_choices = Vec::new();
for id in state.registry.list().await {
let runtime = state.registry.runtime(id).await?;
let campaign = load_campaign(&runtime.store)?;
if campaign_member_for_account(&runtime.store, &campaign, account_hash).is_ok() {
campaign_choices.push(serde_json::json!({
"id":format!("campaign-choice:{id}"),"kind":"control.button",
"props":{"label":format!("Continue {}",campaign.name),"command":"campaign.select","action":{"command":"campaign.select","campaign_id":id}},"children":[]
}));
}
}
let campaign_choices = campaign_choice_components_for_account(state, account_hash).await?;
let mut children = vec![
serde_json::json!({"id":"entry.intro","kind":"card","props":{"title":"Begin Session Zero"},"children":[
{"id":"entry.intro.text","kind":"text","props":{"value":"Build the campaign with the DM before the world becomes canonical. Tone, boundaries, characters, evidence gaps, and opening pressure remain negotiable until everyone approves."},"children":[]}
@ -3638,6 +3633,87 @@ async fn session_zero_entry_surface(
}))
}
async fn session_zero_surface_with_campaign_choices(
state: &AppState,
session_zero: &SessionZeroState,
account_hash: &str,
) -> anyhow::Result<serde_json::Value> {
let mut surface = session_zero_surface(session_zero, account_hash)?;
let choices = campaign_choice_components_for_account(state, account_hash).await?;
append_campaign_choices(&mut surface, choices)?;
Ok(surface)
}
async fn campaign_choice_components_for_account(
state: &AppState,
account_hash: &str,
) -> anyhow::Result<Vec<serde_json::Value>> {
let mut campaigns = Vec::new();
for id in state.registry.list().await {
let runtime = state.registry.runtime(id).await?;
let campaign = load_campaign(&runtime.store)?;
if campaign_member_for_account(&runtime.store, &campaign, account_hash).is_ok() {
campaigns.push((id, campaign.name));
}
}
campaigns.sort_by(|left, right| left.1.cmp(&right.1).then(left.0.cmp(&right.0)));
Ok(campaign_choice_components(campaigns))
}
fn campaign_choice_components(
campaigns: impl IntoIterator<Item = (uuid::Uuid, String)>,
) -> Vec<serde_json::Value> {
campaigns
.into_iter()
.map(|(id, name)| {
serde_json::json!({
"id":format!("campaign-choice:{id}"),
"kind":"control.button",
"props":{
"label":format!("Continue {name}"),
"command":"campaign.select",
"action":{"command":"campaign.select","campaign_id":id}
},
"children":[]
})
})
.collect()
}
fn append_campaign_choices(
surface: &mut serde_json::Value,
campaign_choices: Vec<serde_json::Value>,
) -> anyhow::Result<()> {
if campaign_choices.is_empty() {
return Ok(());
}
surface
.pointer_mut("/surface/root/children")
.and_then(serde_json::Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("Session Zero surface has no root children"))?
.push(serde_json::json!({
"id":"session-zero.campaigns",
"kind":"card",
"props":{"title":"Your existing campaigns"},
"children":campaign_choices
}));
let commands = surface
.get_mut("commands")
.and_then(serde_json::Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("Session Zero surface has no command catalog"))?;
if !commands.iter().any(|command| {
command.get("command").and_then(serde_json::Value::as_str) == Some("campaign.select")
}) {
commands.push(eve_command_descriptor(
"campaign.select",
"ghostlight.campaign_select.v1",
&[],
"campaign_membership.v1",
));
}
Ok(())
}
fn eve_local_draft(name: &str, value_kind: &str) -> serde_json::Value {
serde_json::json!({"targetProp":"value","pointerId":format!("draft:{name}"),"sourceId":"renderer","schemaId":"gamecult.eve.local_draft.v1","routeKind":"local","bindingName":name,"documentId":"ghostlight.play.drafts","fieldPath":name,"valueKind":value_kind,"accessMode":"local-draft","authority":"renderer-ephemeral"})
}
@ -5278,10 +5354,19 @@ async fn refresh_mesh(state: &AppState) -> anyhow::Result<serde_json::Value> {
for id in state.session_zeros.list().await {
let session_zero = state.session_zeros.snapshot(id).await?;
for member in session_zero.members.values().filter(|member| member.active) {
let choices = campaign_choice_components(snapshots.iter().filter_map(|snapshot| {
snapshot
.membership
.as_ref()?
.member_for_account(&member.account_hash)?;
Some((snapshot.campaign.id, snapshot.campaign.name.clone()))
}));
let mut surface = session_zero_surface(&session_zero, &member.account_hash)?;
append_campaign_choices(&mut surface, choices)?;
session_zero_snapshots.push(SessionZeroMeshSnapshot {
session_zero_id: id,
member_id: member.id.clone(),
surface: session_zero_surface(&session_zero, &member.account_hash)?,
surface,
});
}
}
@ -6005,6 +6090,19 @@ mod tests {
use ghostlight_dungeon::domain::{ActorState, BranchOrigin, Location};
use tower::ServiceExt;
#[test]
fn empty_campaign_choice_projection_does_not_change_session_zero_surface() {
let mut surface = serde_json::json!({
"surface":{"root":{"children":[]}},
"commands":[]
});
let before = surface.clone();
append_campaign_choices(&mut surface, vec![]).unwrap();
assert_eq!(surface, before);
}
#[test]
fn strategic_failure_projection_never_contains_private_model_diagnostics() {
let private = anyhow::anyhow!(
@ -6695,6 +6793,22 @@ mod tests {
},
)
.unwrap();
let session_zero = SessionZeroState::new(
"Still negotiating".into(),
"fixture".into(),
account_hash.clone(),
"Owner".into(),
)
.unwrap();
let projected =
session_zero_surface_with_campaign_choices(&state, &session_zero, &account_hash)
.await
.unwrap();
let encoded = serde_json::to_string(&projected).unwrap();
assert!(encoded.contains("Your existing campaigns"));
assert!(encoded.contains("Continue Membership-bound"));
assert!(encoded.contains(&campaign.id.to_string()));
assert!(encoded.contains("campaign.select"));
select_campaign(&state, &account_hash, campaign.id)
.await
.unwrap();