← Files QuestforgeARCHIVED FILE
scripts/puzzle_beats.py
5.56 KB · Sep 30, 2026 · 23:13 UTC
"""Draft non-blocking Questforge deduction beats and minigames."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from typing import Iterable
PUZZLE_KINDS = {
"clue_connection",
"symbolic_order",
"contradiction",
"route_logic",
"key_choice",
"ritual_sequence",
"social_read",
}
KIND_PATTERNS = {
"symbolic_order": ["order", "sequence", "first", "second", "last"],
"contradiction": ["lie", "contradiction", "inconsistent", "alibi"],
"route_logic": ["route", "map", "path", "door", "exit", "timing"],
"key_choice": ["key", "seal", "phrase", "tune", "lock"],
"ritual_sequence": ["ritual", "ingredient", "bell", "oath", "circle"],
"social_read": ["trust", "pressure", "witness", "confess"],
}
@dataclass(frozen=True)
class PuzzleBeat:
"""One small player-facing reasoning beat."""
title: str
kind: str
clues: list[str]
player_ask: str
solution: str
fallback: str
reward: str
symbolic_weight: str = ""
def classify_puzzle_kind(summary: str) -> str:
"""Choose a likely puzzle kind from a short description."""
text = summary.casefold()
for kind, patterns in KIND_PATTERNS.items():
if any(re.search(rf"\b{pattern}\b", text) for pattern in patterns):
return kind
return "clue_connection"
def validate_puzzle_beat(puzzle: PuzzleBeat) -> list[str]:
"""Return design warnings for puzzle beats that may feel weak."""
warnings = []
if puzzle.kind not in PUZZLE_KINDS:
warnings.append(f"Unknown kind `{puzzle.kind}`.")
if len([clue for clue in puzzle.clues if clue.strip()]) < 2:
warnings.append(
"Use at least two prior clues for satisfying deduction."
)
if len(puzzle.player_ask.split()) > 32:
warnings.append("Keep the player-facing ask short and concrete.")
if not puzzle.fallback.strip():
warnings.append("Add a failure-forward fallback so play cannot block.")
if not puzzle.reward.strip():
warnings.append("Add a meaningful reward or changed situation.")
if not puzzle.symbolic_weight.strip():
warnings.append(
"Add symbolic weight if this beat touches a core theme."
)
return warnings
def render_puzzle_beat(puzzle: PuzzleBeat) -> str:
"""Render a puzzle beat as a DM-ready Markdown note."""
clue_lines = "\n".join(f"- {clue.strip()}" for clue in puzzle.clues)
warnings = validate_puzzle_beat(puzzle)
warning_lines = "\n".join(f"- {warning}" for warning in warnings)
if not warning_lines:
warning_lines = "- None."
return f"""# Puzzle Beat: {puzzle.title.strip()}
- Kind: `{puzzle.kind}`
- Player-facing ask: {sentence(puzzle.player_ask)}
- Solution: {sentence(puzzle.solution)}
- Failure-forward fallback: {sentence(puzzle.fallback)}
- Reward: {sentence(puzzle.reward)}
- Symbolic weight: {sentence(puzzle.symbolic_weight or "optional")}
## Prior Clues
{clue_lines}
## Guardrails
- Do not require exact wording.
- Accept partial reasoning with progress plus complication.
- After two wrong attempts or a hint request, point back to one clue.
- If still blocked, apply the fallback and keep play moving.
## Design Warnings
{warning_lines}
"""
def sentence(value: str) -> str:
stripped_value = value.strip()
if stripped_value.endswith((".", "!", "?")):
return stripped_value
return stripped_value + "."
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Draft Questforge puzzle beats that cannot block play."
)
subparsers = parser.add_subparsers(dest="command", required=True)
classify_parser = subparsers.add_parser("classify")
classify_parser.add_argument("--summary", required=True)
draft_parser = subparsers.add_parser("draft")
draft_parser.add_argument("--title", required=True)
draft_parser.add_argument("--kind", default="")
draft_parser.add_argument("--summary", default="")
draft_parser.add_argument("--clue", action="append", required=True)
draft_parser.add_argument("--ask", required=True)
draft_parser.add_argument("--solution", required=True)
draft_parser.add_argument("--fallback", required=True)
draft_parser.add_argument("--reward", required=True)
draft_parser.add_argument("--symbolic-weight", default="")
draft_parser.add_argument(
"--format", choices=("markdown", "json"), default="markdown"
)
return parser
def main(arguments: Iterable[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parsed_arguments = build_parser().parse_args(arguments)
if parsed_arguments.command == "classify":
print(classify_puzzle_kind(parsed_arguments.summary))
return 0
kind = parsed_arguments.kind or classify_puzzle_kind(
parsed_arguments.summary or parsed_arguments.title
)
puzzle = PuzzleBeat(
title=parsed_arguments.title,
kind=kind,
clues=parsed_arguments.clue,
player_ask=parsed_arguments.ask,
solution=parsed_arguments.solution,
fallback=parsed_arguments.fallback,
reward=parsed_arguments.reward,
symbolic_weight=parsed_arguments.symbolic_weight,
)
if parsed_arguments.format == "json":
payload = asdict(puzzle)
payload["warnings"] = validate_puzzle_beat(puzzle)
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
print(render_puzzle_beat(puzzle))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 6d9e6e820607110b18f9abdab626f6b3d415ac7a6cef6c8c84d91c23e1edbaa5