← Files QuestforgeARCHIVED FILE
scripts/quick_start.py
21.8 KB · Sep 30, 2026 · 23:13 UTC
"""Create a playable Questforge campaign from one structured spec."""
from __future__ import annotations
import argparse
import json
from datetime import date
from pathlib import Path
from typing import Iterable
try:
from .campaign_conception import (
conception_record,
ensure_complete_text,
require_original_conception,
)
from .campaign_memory import create_campaign, save_visual_prompt, slugify
from .campaign_save import record_local_save
from .game_state import (
add_character,
add_item,
create_checkpoint,
default_character,
load_state,
save_state,
)
from .session_analytics import append_event
except ImportError: # pragma: no cover - direct script execution path
from campaign_conception import (
conception_record,
ensure_complete_text,
require_original_conception,
)
from campaign_memory import create_campaign, save_visual_prompt, slugify
from campaign_save import record_local_save
from game_state import (
add_character,
add_item,
create_checkpoint,
default_character,
load_state,
save_state,
)
from session_analytics import append_event
PLAYER_LABELS = {
"en": {
"campaign_promise": "Campaign Promise",
"active_hooks": "Active Hooks",
"location": "Location",
"pressure": "Pressure",
"npc_intent": "NPC intent",
"sensory_detail": "Sensory detail",
"visible_risks": "Visible Risks",
"approaches": "Possible Approaches",
"fallback_action": "Attempt any plausible action.",
"player_journal": "Player Journal",
"campaign": "Campaign",
"hero": "Hero",
"current_objective": "Current objective",
"immediate_risk": "Immediate risk",
"known_clues": "Known Clues And NPCs",
"open_threads": "Open Threads",
"none_yet": "None yet.",
"ancestry": "Ancestry",
"class": "Class",
"background": "Background",
"hp": "HP",
"ac": "AC",
"xp": "XP",
"features": "Features",
"notes": "Notes",
"session_log": "Session Log",
"session": "Session",
"date": "Date",
"character_present": "Character present",
"scenes": "Scenes",
"scene": "Scene",
"opening_question": "Opening question",
},
"es": {
"campaign_promise": "Promesa de campaña",
"active_hooks": "Ganchos activos",
"location": "Ubicación",
"pressure": "Presión",
"npc_intent": "Intención del PNJ",
"sensory_detail": "Detalle sensorial",
"visible_risks": "Riesgos visibles",
"approaches": "Enfoques posibles",
"fallback_action": "Intentar cualquier acción plausible.",
"player_journal": "Diario del jugador",
"campaign": "Campaña",
"hero": "Héroe",
"current_objective": "Objetivo actual",
"immediate_risk": "Riesgo inmediato",
"known_clues": "Pistas y PNJ conocidos",
"open_threads": "Hilos abiertos",
"none_yet": "Todavía ninguno.",
"ancestry": "Linaje",
"class": "Clase",
"background": "Trasfondo",
"hp": "PG",
"ac": "CA",
"xp": "PX",
"features": "Rasgos",
"notes": "Notas",
"session_log": "Registro de sesión",
"session": "Sesión",
"date": "Fecha",
"character_present": "Personaje presente",
"scenes": "Escenas",
"scene": "Escena",
"opening_question": "Pregunta inicial",
},
}
def create_quick_start(workspace_root: Path, spec: dict) -> Path:
"""Create and populate one campaign without repeated setup commands."""
if spec.get("spec_version") != 2:
raise ValueError(
"quick-start spec_version must be 2 and include a completed "
"creative conception."
)
conception_spec = required_mapping(spec, "conception")
campaign_spec = dict(required_mapping(spec, "campaign"))
hero_spec = required_mapping(spec, "hero")
opening_spec = required_mapping(spec, "opening")
visual_spec = required_mapping(spec, "visual")
validate_start_contract(
campaign_spec,
hero_spec,
opening_spec,
visual_spec,
)
campaign_spec["tone"] = required_text(conception_spec, "tone")
campaign_spec["promise"] = required_text(
conception_spec,
"campaign_promise",
)
campaign_name = required_text(campaign_spec, "name")
hero_name = required_text(hero_spec, "name")
campaigns_dir = workspace_root.expanduser().resolve() / "campaigns"
campaigns_dir.mkdir(parents=True, exist_ok=True)
expected_root = campaigns_dir / slugify(campaign_name)
if expected_root.exists():
raise FileExistsError(f"Campaign already exists: {expected_root}")
conception_audit = require_original_conception(
conception_spec,
campaigns_dir,
)
paths = create_campaign(
campaigns_dir=campaigns_dir,
name=campaign_name,
tone=str(campaign_spec.get("tone", "")),
boundaries=str(campaign_spec.get("boundaries", "")),
session_date=date.today(),
)
state = load_state(paths.root)
character = default_character(
name=hero_name,
class_name=required_text(hero_spec, "class_name"),
ancestry=required_text(hero_spec, "ancestry"),
level=positive_int(hero_spec.get("level", 1), "hero.level"),
xp=nonnegative_int(hero_spec.get("xp", 0), "hero.xp"),
max_hp=positive_int(hero_spec.get("max_hp"), "hero.max_hp"),
armor_class=positive_int(
hero_spec.get("armor_class"), "hero.armor_class"
),
hit_die=str(hero_spec.get("hit_die", "d8")),
)
apply_character_details(character, hero_spec)
add_character(state, character)
for item_spec in hero_spec.get("items", []):
if not isinstance(item_spec, dict):
raise ValueError("Every hero.items entry must be an object.")
add_item(
state,
hero_name,
required_text(item_spec, "name"),
quantity=positive_int(
item_spec.get("quantity", 1), "item.quantity"
),
location=str(item_spec.get("location", "backpack")),
slot=str(item_spec.get("slot", "")),
mechanical_effect=str(item_spec.get("mechanical_effect", "")),
story_significance=str(item_spec.get("story_significance", "")),
value=str(item_spec.get("value", "")),
)
save_state(paths.root, state)
create_checkpoint(
paths.root,
state,
label=str(spec.get("checkpoint_label", "Before session start")),
)
(paths.root / "campaign-conception.json").write_text(
json.dumps(
conception_record(conception_spec, conception_audit),
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
newline="\n",
)
language = str(campaign_spec.get("language", "en"))
paths.opening_brief.write_text(
render_opening(campaign_spec, opening_spec, language),
encoding="utf-8",
newline="\n",
)
paths.adventure_spine.write_text(
render_spine(campaign_spec, conception_spec),
encoding="utf-8",
newline="\n",
)
paths.player_journal.write_text(
render_player_journal(
campaign_name,
hero_name,
opening_spec,
language,
),
encoding="utf-8",
newline="\n",
)
(paths.characters / f"{slugify(hero_name)}.md").write_text(
render_character_sheet(character, language),
encoding="utf-8",
newline="\n",
)
seed_visual_continuity(paths, hero_spec, opening_spec)
session_path = paths.sessions / "session-001.md"
session_path.write_text(
render_session(campaign_name, hero_name, opening_spec, language),
encoding="utf-8",
newline="\n",
)
manifest = json.loads(paths.manifest.read_text(encoding="utf-8"))
manifest["language"] = language
manifest["creative_conception"] = {
"path": "campaign-conception.json",
"environment_signature": conception_audit.environment_signature,
}
paths.manifest.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
save_visual_prompt(
campaign_root=paths.root,
session_number=1,
scene_number=1,
kind=str(visual_spec.get("kind", "scene")),
label=required_text(visual_spec, "label"),
prompt=required_text(visual_spec, "prompt"),
)
append_event(
paths.root,
{
"event_type": "session_start",
"session": 1,
"scene": 1,
"summary": f"{hero_name} enters {opening_spec.get('location', '')}.",
"tags": ["quick-start", "creative-conception-v2", language],
},
)
record_local_save(
paths.root,
profile="canonical",
scene_id="session-001:scene-001",
scene_label=str(opening_spec.get("title", "Opening scene")),
)
return paths.root
def validate_start_contract(
campaign: dict,
hero: dict,
opening: dict,
visual: dict,
) -> None:
"""Validate the playable shell around the free-form conception."""
for field in ("name", "language", "boundaries"):
required_text(campaign, field)
for field in ("hooks", "core_truths", "faction_intents", "outcomes"):
required_string_list(campaign, field, minimum=1)
required_string_list(campaign, "clue_routes", exact=3)
for field in (
"name",
"class_name",
"ancestry",
"background",
"hit_die",
"visual_description",
):
required_text(hero, field)
required_string_list(hero, "visual_must_preserve", minimum=1)
for field in (
"title",
"location",
"pressure",
"npc_intent",
"sensory_detail",
"question",
):
required_text(opening, field)
required_string_list(opening, "visible_risks", minimum=1)
required_string_list(opening, "options", minimum=2)
required_text(visual, "kind")
required_text(visual, "label")
required_text(visual, "prompt")
def required_string_list(
payload: dict,
key: str,
minimum: int | None = None,
exact: int | None = None,
) -> list[str]:
value = payload.get(key)
if not isinstance(value, list):
raise ValueError(f"{key} must be a list.")
if exact is not None and len(value) != exact:
raise ValueError(f"{key} must contain exactly {exact} entries.")
if minimum is not None and len(value) < minimum:
raise ValueError(f"{key} must contain at least {minimum} entries.")
return [
ensure_complete_text(item, f"{key}[{index}]")
for index, item in enumerate(value, start=1)
]
def apply_character_details(character: dict, hero_spec: dict) -> None:
"""Apply validated optional hero fields to the default character."""
for key in ("abilities", "skill_modifiers", "currency"):
value = hero_spec.get(key)
if value is not None:
if not isinstance(value, dict):
raise ValueError(f"hero.{key} must be an object.")
character[key].update(value)
for key in ("resources", "spells"):
value = hero_spec.get(key)
if value is not None:
if not isinstance(value, dict):
raise ValueError(f"hero.{key} must be an object.")
deep_update(character[key], value)
for key in ("features", "notes"):
value = hero_spec.get(key)
if value is not None:
if not isinstance(value, list):
raise ValueError(f"hero.{key} must be a list.")
character[key] = [str(item) for item in value]
for key in ("initiative_modifier", "speed"):
if key in hero_spec:
character[key] = int(hero_spec[key])
if "background" in hero_spec:
character["background"] = str(hero_spec["background"])
def deep_update(target: dict, updates: dict) -> None:
for key, value in updates.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
deep_update(target[key], value)
else:
target[key] = value
def render_opening(campaign: dict, opening: dict, language: str) -> str:
labels = labels_for(language)
hooks = markdown_bullets(campaign.get("hooks", []), labels["none_yet"])
risks = markdown_bullets(
opening.get("visible_risks", []), labels["none_yet"]
)
options = markdown_numbered(
opening.get("options", []), labels["fallback_action"]
)
return (
f"# {campaign['name']}\n\n"
f"## {labels['campaign_promise']}\n\n"
f"{campaign.get('promise', '')}\n\n"
f"## {labels['active_hooks']}\n\n"
f"{hooks}\n\n"
f"## {opening.get('title', 'Opening Scene')}\n\n"
f"- {labels['location']}: {opening.get('location', '')}\n"
f"- {labels['pressure']}: {opening.get('pressure', '')}\n"
f"- {labels['npc_intent']}: {opening.get('npc_intent', '')}\n"
f"- {labels['sensory_detail']}: {opening.get('sensory_detail', '')}\n\n"
f"### {labels['visible_risks']}\n\n"
f"{risks}\n\n"
f"### {labels['approaches']}\n\n"
f"{options}\n\n"
f"**{opening.get('question', 'What do you do?')}**\n"
)
def render_spine(campaign: dict, conception: dict) -> str:
environment = conception["environment"]
environment_lines = "\n".join(
f"- {field.replace('_', ' ').title()}: {environment[field]}"
for field in (
"biome",
"climate",
"season",
"time_of_day",
"surface",
"social_scale",
"water_relevance",
)
)
relationship_lines = "\n".join(
"- {npc} ({role}) wants {wants}; relationship: {relationship}".format(
**relationship
)
for relationship in conception["npc_relationships"]
)
return (
"# Adventure Spine\n\n"
"## Creative Foundation\n\n"
f"- Community: {conception['community']}\n"
f"- Material conflict: {conception['material_conflict']}\n"
f"- Threat: {conception['threat']}\n"
f"- Tone: {conception['tone']}\n"
f"- Aesthetic: {conception['aesthetic']}\n"
f"- Campaign promise: {conception['campaign_promise']}\n\n"
"### Environment\n\n"
f"{environment_lines}\n\n"
"### NPC Relationship Web\n\n"
f"{relationship_lines}\n\n"
"## Core Truths\n\n"
f"{markdown_bullets(campaign.get('core_truths', []))}\n\n"
"## Active Hooks\n\n"
f"{markdown_bullets(campaign.get('hooks', []))}\n\n"
"## Clue Routes\n\n"
f"{markdown_bullets(campaign.get('clue_routes', []))}\n\n"
"## Faction Intent\n\n"
f"{markdown_bullets(campaign.get('faction_intents', []))}\n\n"
"## Plausible Outcomes\n\n"
f"{markdown_bullets(campaign.get('outcomes', []))}\n"
)
def render_player_journal(
campaign_name: str,
hero_name: str,
opening: dict,
language: str,
) -> str:
labels = labels_for(language)
return (
f"# {labels['player_journal']}\n\n"
f"- {labels['campaign']}: {campaign_name}\n"
f"- {labels['hero']}: {hero_name}\n"
f"- {labels['current_objective']}: {opening.get('question', '')}\n"
f"- {labels['immediate_risk']}: {opening.get('pressure', '')}\n"
f"- {labels['location']}: {opening.get('location', '')}\n\n"
f"## {labels['known_clues']}\n\n- {labels['none_yet']}\n\n"
f"## {labels['open_threads']}\n\n"
f"{markdown_bullets(opening.get('visible_risks', []), labels['none_yet'])}\n"
)
def render_character_sheet(character: dict, language: str) -> str:
labels = labels_for(language)
return (
f"# {character['name']}\n\n"
f"- {labels['ancestry']}: {character['ancestry']}\n"
f"- {labels['class']}: {character['class']} {character['level']}\n"
f"- {labels['background']}: {character['background']}\n"
f"- {labels['hp']}: {character['current_hp']}/{character['max_hp']}\n"
f"- {labels['ac']}: {character['armor_class']}\n"
f"- {labels['xp']}: {character['xp']}\n\n"
f"## {labels['features']}\n\n"
f"{markdown_bullets(character.get('features', []), labels['none_yet'])}\n\n"
f"## {labels['notes']}\n\n"
f"{markdown_bullets(character.get('notes', []), labels['none_yet'])}\n"
)
def render_session(
campaign_name: str,
hero_name: str,
opening: dict,
language: str,
) -> str:
labels = labels_for(language)
return (
f"# {labels['session_log']}\n\n"
f"## {labels['session']}\n\n"
f"- {labels['campaign']}: {campaign_name}\n"
f"- {labels['session']}: 1\n"
f"- {labels['date']}: {date.today().isoformat()}\n"
f"- {labels['character_present']}: {hero_name}\n\n"
f"## {labels['scenes']}\n\n"
f"### {labels['scene']} 1\n\n"
f"- {labels['location']}: {opening.get('location', '')}\n"
f"- {labels['pressure']}: {opening.get('pressure', '')}\n"
f"- {labels['opening_question']}: {opening.get('question', '')}\n"
)
def seed_visual_continuity(paths, hero: dict, opening: dict) -> None:
description = str(hero.get("visual_description", "")).strip()
if not description:
return
must_preserve = ", ".join(
str(value) for value in hero.get("visual_must_preserve", [])
)
may_drift = str(hero.get("visual_may_drift", "Minor pose and expression"))
ledger = paths.visual_ledger.read_text(encoding="utf-8")
entity_separator = "| --- | --- | --- | --- |"
entity_row = (
f"| {pipe_safe(hero['name'])} | {pipe_safe(description)} | "
f"{pipe_safe(must_preserve)} | {pipe_safe(may_drift)} |"
)
ledger = ledger.replace(
entity_separator,
f"{entity_separator}\n{entity_row}",
1,
)
paths.visual_ledger.write_text(
ledger,
encoding="utf-8",
newline="\n",
)
bible = paths.visual_bible.read_text(encoding="utf-8")
character_separator = "| --- | --- | --- | --- | --- |"
character_row = (
f"| {pipe_safe(hero['name'])} | {pipe_safe(description)} | "
f"{pipe_safe(str(hero.get('visual_clothing', '')))} | "
f"{pipe_safe(str(hero.get('visual_gear', '')))} | "
f"{pipe_safe(str(hero.get('visual_colors', '')))} |"
)
bible = bible.replace(
character_separator,
f"{character_separator}\n{character_row}",
1,
)
paths.visual_bible.write_text(
bible,
encoding="utf-8",
newline="\n",
)
location = str(opening.get("location", "")).strip()
if location:
ledger = paths.visual_ledger.read_text(encoding="utf-8")
location_separator = "| --- | --- | --- | --- |"
location_row = (
f"| {pipe_safe(location)} | To establish in the first image | "
"Opening scene | Preserve player-known spatial relationships |"
)
second_separator_index = ledger.find(
location_separator,
ledger.find(location_separator) + len(location_separator),
)
third_separator_index = ledger.find(
location_separator,
second_separator_index + len(location_separator),
)
if third_separator_index >= 0:
insert_at = third_separator_index + len(location_separator)
ledger = (
ledger[:insert_at] + "\n" + location_row + ledger[insert_at:]
)
paths.visual_ledger.write_text(
ledger,
encoding="utf-8",
newline="\n",
)
def pipe_safe(value: str) -> str:
return value.replace("|", "/").replace("\n", " ").strip()
def markdown_bullets(values: object, empty_value: str = "None yet.") -> str:
if not isinstance(values, list) or not values:
return f"- {empty_value}"
return "\n".join(f"- {value}" for value in values)
def markdown_numbered(
values: object,
fallback_action: str = "Attempt any plausible action.",
) -> str:
if not isinstance(values, list) or not values:
return f"1. {fallback_action}"
return "\n".join(
f"{index}. {value}" for index, value in enumerate(values, start=1)
)
def required_mapping(payload: dict, key: str) -> dict:
value = payload.get(key)
if not isinstance(value, dict):
raise ValueError(f"{key} must be an object.")
return value
def labels_for(language: str) -> dict[str, str]:
return PLAYER_LABELS.get(language.casefold(), PLAYER_LABELS["en"])
def required_text(payload: dict, key: str) -> str:
return ensure_complete_text(payload.get(key), key)
def positive_int(value: object, label: str) -> int:
parsed = int(value)
if parsed < 1:
raise ValueError(f"{label} must be positive.")
return parsed
def nonnegative_int(value: object, label: str) -> int:
parsed = int(value)
if parsed < 0:
raise ValueError(f"{label} cannot be negative.")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create a playable Questforge campaign from one spec."
)
parser.add_argument("--workspace-root", required=True, type=Path)
parser.add_argument("--spec", required=True, type=Path)
return parser
def main(arguments: Iterable[str] | None = None) -> int:
parsed_arguments = build_parser().parse_args(arguments)
spec = json.loads(parsed_arguments.spec.read_text(encoding="utf-8"))
campaign_root = create_quick_start(
parsed_arguments.workspace_root,
spec,
)
print(json.dumps({"campaign_root": str(campaign_root)}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 6da90e453e5175056b02b9c3e0a7b0edaa3ab4be14eb1fb35dda64775d788962