skillplay 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. skillplay-1.3.0/.github/workflows/ci.yml +117 -0
  2. skillplay-1.3.0/ARCHITECTURE.md +357 -0
  3. skillplay-1.3.0/PKG-INFO +13 -0
  4. skillplay-1.3.0/README.md +117 -0
  5. skillplay-1.3.0/config.sample.yaml +30 -0
  6. skillplay-1.3.0/docs/authoring-packs.md +143 -0
  7. skillplay-1.3.0/docs/editor-integration.md +76 -0
  8. skillplay-1.3.0/pyproject.toml +41 -0
  9. skillplay-1.3.0/scripts/build_binary.py +76 -0
  10. skillplay-1.3.0/skillplay/__init__.py +3 -0
  11. skillplay-1.3.0/skillplay/__main__.py +24 -0
  12. skillplay-1.3.0/skillplay/core/__init__.py +39 -0
  13. skillplay-1.3.0/skillplay/core/achievements.py +73 -0
  14. skillplay-1.3.0/skillplay/core/adaptive.py +291 -0
  15. skillplay-1.3.0/skillplay/core/capstone.py +140 -0
  16. skillplay-1.3.0/skillplay/core/cli.py +823 -0
  17. skillplay-1.3.0/skillplay/core/config.py +61 -0
  18. skillplay-1.3.0/skillplay/core/demo.py +138 -0
  19. skillplay-1.3.0/skillplay/core/engine.py +625 -0
  20. skillplay-1.3.0/skillplay/core/exam.py +176 -0
  21. skillplay-1.3.0/skillplay/core/generate.py +642 -0
  22. skillplay-1.3.0/skillplay/core/goals.py +138 -0
  23. skillplay-1.3.0/skillplay/core/hlr.py +73 -0
  24. skillplay-1.3.0/skillplay/core/i18n.py +116 -0
  25. skillplay-1.3.0/skillplay/core/leaderboard_server.py +288 -0
  26. skillplay-1.3.0/skillplay/core/loader.py +214 -0
  27. skillplay-1.3.0/skillplay/core/mentor.py +214 -0
  28. skillplay-1.3.0/skillplay/core/progress.py +184 -0
  29. skillplay-1.3.0/skillplay/core/registry.py +314 -0
  30. skillplay-1.3.0/skillplay/core/registry_index.json +8 -0
  31. skillplay-1.3.0/skillplay/core/schema.py +177 -0
  32. skillplay-1.3.0/skillplay/core/share.py +61 -0
  33. skillplay-1.3.0/skillplay/core/skillgraph.py +161 -0
  34. skillplay-1.3.0/skillplay/core/sound.py +34 -0
  35. skillplay-1.3.0/skillplay/core/stats.py +88 -0
  36. skillplay-1.3.0/skillplay/core/streak.py +33 -0
  37. skillplay-1.3.0/skillplay/core/sync.py +182 -0
  38. skillplay-1.3.0/skillplay/core/telemetry.py +66 -0
  39. skillplay-1.3.0/skillplay/core/validators.py +568 -0
  40. skillplay-1.3.0/skillplay/packs/algorithms/challenges/01-fib.yaml +36 -0
  41. skillplay-1.3.0/skillplay/packs/algorithms/challenges/02-factorial.yaml +35 -0
  42. skillplay-1.3.0/skillplay/packs/algorithms/challenges/03-prime.yaml +39 -0
  43. skillplay-1.3.0/skillplay/packs/algorithms/challenges/04-max.yaml +32 -0
  44. skillplay-1.3.0/skillplay/packs/algorithms/challenges/05-sum.yaml +32 -0
  45. skillplay-1.3.0/skillplay/packs/algorithms/challenges/06-min.yaml +32 -0
  46. skillplay-1.3.0/skillplay/packs/algorithms/challenges/07-wordcount.yaml +38 -0
  47. skillplay-1.3.0/skillplay/packs/algorithms/challenges/08-anagram.yaml +38 -0
  48. skillplay-1.3.0/skillplay/packs/algorithms/challenges/09-bubble.yaml +46 -0
  49. skillplay-1.3.0/skillplay/packs/algorithms/challenges/10-bisect.yaml +47 -0
  50. skillplay-1.3.0/skillplay/packs/algorithms/challenges/11-twopointer.yaml +47 -0
  51. skillplay-1.3.0/skillplay/packs/algorithms/challenges/12-power.yaml +41 -0
  52. skillplay-1.3.0/skillplay/packs/algorithms/pack.yaml +10 -0
  53. skillplay-1.3.0/skillplay/packs/css-basics/challenges/01-p.yaml +16 -0
  54. skillplay-1.3.0/skillplay/packs/css-basics/challenges/02-id.yaml +16 -0
  55. skillplay-1.3.0/skillplay/packs/css-basics/challenges/03-class.yaml +16 -0
  56. skillplay-1.3.0/skillplay/packs/css-basics/challenges/04-desc.yaml +17 -0
  57. skillplay-1.3.0/skillplay/packs/css-basics/challenges/05-color.yaml +20 -0
  58. skillplay-1.3.0/skillplay/packs/css-basics/challenges/06-em.yaml +20 -0
  59. skillplay-1.3.0/skillplay/packs/css-basics/challenges/07-spec.yaml +23 -0
  60. skillplay-1.3.0/skillplay/packs/css-basics/challenges/08-nth.yaml +19 -0
  61. skillplay-1.3.0/skillplay/packs/css-basics/challenges/09-flex.yaml +20 -0
  62. skillplay-1.3.0/skillplay/packs/css-basics/pack.yaml +10 -0
  63. skillplay-1.3.0/skillplay/packs/data-structures/challenges/01-first.yaml +41 -0
  64. skillplay-1.3.0/skillplay/packs/data-structures/challenges/02-last.yaml +42 -0
  65. skillplay-1.3.0/skillplay/packs/data-structures/challenges/03-reverse.yaml +42 -0
  66. skillplay-1.3.0/skillplay/packs/data-structures/challenges/04-count.yaml +42 -0
  67. skillplay-1.3.0/skillplay/packs/data-structures/challenges/05-dict.yaml +44 -0
  68. skillplay-1.3.0/skillplay/packs/data-structures/challenges/06-set.yaml +48 -0
  69. skillplay-1.3.0/skillplay/packs/data-structures/challenges/07-freq.yaml +58 -0
  70. skillplay-1.3.0/skillplay/packs/data-structures/challenges/08-stack.yaml +60 -0
  71. skillplay-1.3.0/skillplay/packs/data-structures/challenges/09-queue.yaml +52 -0
  72. skillplay-1.3.0/skillplay/packs/data-structures/challenges/10-heap.yaml +53 -0
  73. skillplay-1.3.0/skillplay/packs/data-structures/pack.yaml +14 -0
  74. skillplay-1.3.0/skillplay/packs/fix-bug/challenges/01-sum.yaml +31 -0
  75. skillplay-1.3.0/skillplay/packs/fix-bug/challenges/02-greet.yaml +31 -0
  76. skillplay-1.3.0/skillplay/packs/fix-bug/challenges/03-max.yaml +31 -0
  77. skillplay-1.3.0/skillplay/packs/fix-bug/challenges/04-even.yaml +31 -0
  78. skillplay-1.3.0/skillplay/packs/fix-bug/pack.yaml +12 -0
  79. skillplay-1.3.0/skillplay/packs/fix-bug-js/challenges/01-double.yaml +33 -0
  80. skillplay-1.3.0/skillplay/packs/fix-bug-js/challenges/02-sum.yaml +36 -0
  81. skillplay-1.3.0/skillplay/packs/fix-bug-js/challenges/03-palindrome.yaml +34 -0
  82. skillplay-1.3.0/skillplay/packs/fix-bug-js/pack.yaml +12 -0
  83. skillplay-1.3.0/skillplay/packs/freeform-intro/challenges/01-greet.yaml +32 -0
  84. skillplay-1.3.0/skillplay/packs/freeform-intro/challenges/02-sum_even.yaml +34 -0
  85. skillplay-1.3.0/skillplay/packs/freeform-intro/challenges/03-palindrome-js.yaml +33 -0
  86. skillplay-1.3.0/skillplay/packs/freeform-intro/pack.yaml +12 -0
  87. skillplay-1.3.0/skillplay/packs/git-basics/challenges/01-init.yaml +19 -0
  88. skillplay-1.3.0/skillplay/packs/git-basics/challenges/02-status.yaml +20 -0
  89. skillplay-1.3.0/skillplay/packs/git-basics/challenges/03-add.yaml +20 -0
  90. skillplay-1.3.0/skillplay/packs/git-basics/challenges/04-commit.yaml +21 -0
  91. skillplay-1.3.0/skillplay/packs/git-basics/challenges/05-branch.yaml +20 -0
  92. skillplay-1.3.0/skillplay/packs/git-basics/challenges/06-mc-stage.yaml +28 -0
  93. skillplay-1.3.0/skillplay/packs/git-basics/challenges/07-mc-clone.yaml +28 -0
  94. skillplay-1.3.0/skillplay/packs/git-basics/challenges/08-mc-merge-rebase.yaml +28 -0
  95. skillplay-1.3.0/skillplay/packs/git-basics/challenges/09-mc-undo.yaml +29 -0
  96. skillplay-1.3.0/skillplay/packs/git-basics/challenges/10-mc-remote.yaml +28 -0
  97. skillplay-1.3.0/skillplay/packs/git-basics/pack.yaml +10 -0
  98. skillplay-1.3.0/skillplay/packs/http-rest/challenges/01-get.yaml +20 -0
  99. skillplay-1.3.0/skillplay/packs/http-rest/challenges/02-post.yaml +20 -0
  100. skillplay-1.3.0/skillplay/packs/http-rest/challenges/03-put.yaml +20 -0
  101. skillplay-1.3.0/skillplay/packs/http-rest/challenges/04-404.yaml +20 -0
  102. skillplay-1.3.0/skillplay/packs/http-rest/challenges/05-ctype.yaml +16 -0
  103. skillplay-1.3.0/skillplay/packs/http-rest/challenges/06-auth.yaml +21 -0
  104. skillplay-1.3.0/skillplay/packs/http-rest/challenges/07-flow-201.yaml +23 -0
  105. skillplay-1.3.0/skillplay/packs/http-rest/challenges/08-status-classes.yaml +23 -0
  106. skillplay-1.3.0/skillplay/packs/http-rest/challenges/09-auth-bearer.yaml +19 -0
  107. skillplay-1.3.0/skillplay/packs/http-rest/pack.yaml +10 -0
  108. skillplay-1.3.0/skillplay/packs/mini-cli/challenges/01-greet.yaml +33 -0
  109. skillplay-1.3.0/skillplay/packs/mini-cli/challenges/02-add.yaml +33 -0
  110. skillplay-1.3.0/skillplay/packs/mini-cli/challenges/03-main.yaml +32 -0
  111. skillplay-1.3.0/skillplay/packs/mini-cli/pack.yaml +17 -0
  112. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/01-path.yaml +44 -0
  113. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/02-json.yaml +37 -0
  114. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/03-today.yaml +41 -0
  115. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/04-counter.yaml +38 -0
  116. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/05-random.yaml +36 -0
  117. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/06-itertools.yaml +39 -0
  118. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/07-functools.yaml +47 -0
  119. skillplay-1.3.0/skillplay/packs/py-stdlib/challenges/08-heapq.yaml +42 -0
  120. skillplay-1.3.0/skillplay/packs/py-stdlib/pack.yaml +12 -0
  121. skillplay-1.3.0/skillplay/packs/regex-101/challenges/01-literal.yaml +20 -0
  122. skillplay-1.3.0/skillplay/packs/regex-101/challenges/02-digit.yaml +20 -0
  123. skillplay-1.3.0/skillplay/packs/regex-101/challenges/03-nondigit.yaml +20 -0
  124. skillplay-1.3.0/skillplay/packs/regex-101/challenges/04-start.yaml +20 -0
  125. skillplay-1.3.0/skillplay/packs/regex-101/challenges/05-end.yaml +20 -0
  126. skillplay-1.3.0/skillplay/packs/regex-101/challenges/06-plus.yaml +20 -0
  127. skillplay-1.3.0/skillplay/packs/regex-101/challenges/07-star.yaml +20 -0
  128. skillplay-1.3.0/skillplay/packs/regex-101/challenges/08-optional.yaml +20 -0
  129. skillplay-1.3.0/skillplay/packs/regex-101/challenges/09-class.yaml +20 -0
  130. skillplay-1.3.0/skillplay/packs/regex-101/challenges/10-negclass.yaml +20 -0
  131. skillplay-1.3.0/skillplay/packs/regex-101/challenges/11-boundary.yaml +20 -0
  132. skillplay-1.3.0/skillplay/packs/regex-101/challenges/12-alt.yaml +20 -0
  133. skillplay-1.3.0/skillplay/packs/regex-101/challenges/13-range.yaml +20 -0
  134. skillplay-1.3.0/skillplay/packs/regex-101/challenges/14-group.yaml +20 -0
  135. skillplay-1.3.0/skillplay/packs/regex-101/challenges/15-anchored.yaml +20 -0
  136. skillplay-1.3.0/skillplay/packs/regex-101/pack.yaml +10 -0
  137. skillplay-1.3.0/skillplay/packs/schemas/challenge.schema.json +94 -0
  138. skillplay-1.3.0/skillplay/packs/schemas/pack.schema.json +43 -0
  139. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/01-ls.yaml +16 -0
  140. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/02-pwd.yaml +16 -0
  141. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/03-cd.yaml +16 -0
  142. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/04-rm.yaml +16 -0
  143. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/05-grep.yaml +17 -0
  144. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/06-find.yaml +17 -0
  145. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/07-pipe.yaml +20 -0
  146. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/08-status.yaml +19 -0
  147. skillplay-1.3.0/skillplay/packs/shell-basics/challenges/09-env.yaml +20 -0
  148. skillplay-1.3.0/skillplay/packs/shell-basics/pack.yaml +10 -0
  149. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/01-select.yaml +34 -0
  150. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/02-where.yaml +34 -0
  151. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/03-order.yaml +34 -0
  152. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/04-limit.yaml +34 -0
  153. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/05-count.yaml +34 -0
  154. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/06-distinct.yaml +34 -0
  155. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/07-and.yaml +34 -0
  156. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/08-or.yaml +34 -0
  157. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/09-in.yaml +34 -0
  158. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/10-between.yaml +34 -0
  159. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/11-like.yaml +34 -0
  160. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/12-join.yaml +38 -0
  161. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/13-group.yaml +38 -0
  162. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/14-having.yaml +36 -0
  163. skillplay-1.3.0/skillplay/packs/sql-basics/challenges/15-subquery.yaml +34 -0
  164. skillplay-1.3.0/skillplay/packs/sql-basics/pack.yaml +14 -0
  165. skillplay-1.3.0/skillplay/tui/__init__.py +3 -0
  166. skillplay-1.3.0/skillplay/tui/app.py +1117 -0
  167. skillplay-1.3.0/tests/conftest.py +49 -0
  168. skillplay-1.3.0/tests/leaderboard_server_test.py +115 -0
  169. skillplay-1.3.0/tests/registry_test.py +48 -0
  170. skillplay-1.3.0/tests/smoke_core.py +90 -0
  171. skillplay-1.3.0/tests/smoke_tui.py +70 -0
  172. skillplay-1.3.0/tests/test_capstone.py +82 -0
  173. skillplay-1.3.0/tests/test_community.py +82 -0
  174. skillplay-1.3.0/tests/test_exam.py +177 -0
  175. skillplay-1.3.0/tests/test_features.py +166 -0
  176. skillplay-1.3.0/tests/test_hardening.py +165 -0
  177. skillplay-1.3.0/tests/test_hlr.py +98 -0
  178. skillplay-1.3.0/tests/test_intelligence.py +428 -0
  179. skillplay-1.3.0/tests/test_mentor.py +87 -0
  180. skillplay-1.3.0/tests/test_skillgraph.py +150 -0
  181. skillplay-1.3.0/tests/test_sync.py +152 -0
  182. skillplay-1.3.0/tests/test_v9.py +83 -0
  183. skillplay-1.3.0/tests/test_validators.py +211 -0
@@ -0,0 +1,117 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: actions/setup-python@v7
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install -e ".[dev]"
24
+ - name: Validate packs
25
+ run: python -m skillplay validate-packs
26
+ - name: Run tests
27
+ run: python -m pytest tests/ -q
28
+
29
+ lint:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v7
33
+ - uses: actions/setup-python@v7
34
+ with:
35
+ python-version: "3.12"
36
+ - name: Install ruff
37
+ run: python -m pip install --upgrade pip ruff
38
+ - name: Ruff lint
39
+ run: python -m ruff check .
40
+ - name: Ruff format check
41
+ run: python -m ruff format --check .
42
+
43
+ publish:
44
+ needs: test
45
+ if: startsWith(github.ref, 'refs/tags/v')
46
+ runs-on: ubuntu-latest
47
+ # id-token: write is required for PyPI trusted publishing (OIDC) and for
48
+ # generating build attestations. No PYPI_API_TOKEN secret is needed when
49
+ # the project is configured as a trusted publisher on pypi.org.
50
+ permissions:
51
+ contents: read
52
+ id-token: write
53
+ steps:
54
+ - uses: actions/checkout@v7
55
+ - uses: actions/setup-python@v7
56
+ with:
57
+ python-version: "3.12"
58
+ - name: Build distribution
59
+ run: |
60
+ python -m pip install --upgrade pip build
61
+ python -m build
62
+ - name: Publish to PyPI
63
+ uses: pypa/gh-action-pypi-publish@release/v1
64
+ with:
65
+ password: ${{ secrets.PYPI_API_TOKEN }}
66
+ # Homebrew: publishing to a tap is repo-specific; the wheel on PyPI is the
67
+ # source of truth. Add a homebrew tap formula in a separate repo pointing
68
+ # at the PyPI release when you want `brew install skillplay`.
69
+
70
+ # D: ship a single self-contained binary per tag, for each major OS.
71
+ build-binary:
72
+ needs: test
73
+ if: startsWith(github.ref, 'refs/tags/v')
74
+ strategy:
75
+ matrix:
76
+ include:
77
+ - os: ubuntu-latest
78
+ artifact: skillplay-linux
79
+ - os: macos-latest
80
+ artifact: skillplay-macos
81
+ - os: windows-latest
82
+ artifact: skillplay-windows.exe
83
+ runs-on: ${{ matrix.os }}
84
+ permissions:
85
+ contents: write
86
+ steps:
87
+ - uses: actions/checkout@v7
88
+ - uses: actions/setup-python@v7
89
+ with:
90
+ python-version: "3.12"
91
+ - name: Install PyInstaller
92
+ run: python -m pip install --upgrade pip pyinstaller
93
+ - name: Build single binary
94
+ run: python scripts/build_binary.py --method pyinstaller
95
+ - name: Rename binary per OS
96
+ shell: bash
97
+ run: |
98
+ # PyInstaller always names the output `skillplay` (plus .exe on
99
+ # Windows); disambiguate per-OS so release assets don't collide.
100
+ if [ "$RUNNER_OS" = "Windows" ]; then
101
+ mv dist/skillplay.exe dist/skillplay-windows.exe
102
+ elif [ "$RUNNER_OS" = "macOS" ]; then
103
+ mv dist/skillplay dist/skillplay-macos
104
+ else
105
+ mv dist/skillplay dist/skillplay-linux
106
+ fi
107
+ - name: Upload artifact (workflow archive)
108
+ uses: actions/upload-artifact@v7
109
+ with:
110
+ name: ${{ matrix.artifact }}
111
+ path: dist/skillplay*
112
+ if-no-files-found: error
113
+ - name: Attach binary to GitHub release
114
+ uses: softprops/action-gh-release@v2
115
+ with:
116
+ files: dist/skillplay*
117
+ fail_on_unmatched_files: true
@@ -0,0 +1,357 @@
1
+ # skillplay — Architecture
2
+
3
+ > System design: project structure, data schemas, and runtime flows.
4
+ > Companion docs: `roadmap.md` (phases/status). This file describes **how the system works as built**.
5
+
6
+ ## 1. Tech choices
7
+
8
+ - Python 3.12, Textual (TUI) + Rich (rendering), PyYAML (packs), platformdirs (data dir), `sqlite3` stdlib (SQL validation).
9
+ - Offline-first, keyboard-only. Sound off by default (`settings.sound: false`, no audio backend wired).
10
+ - Entry point: `skillplay = "skillplay.__main__:main"` (`pyproject.toml`), `SkillPlayApp().run()`.
11
+
12
+ ## 2. Project structure
13
+
14
+ ```
15
+ skillplay/ # project root
16
+ pyproject.toml # hatchling build, deps, console script
17
+ roadmap.md # phases + verified state
18
+ ARCHITECTURE.md # this file
19
+ README.md # overview + pipx install + usage
20
+ config.sample.yaml # documented user config template
21
+ docs/
22
+ authoring-packs.md # community pack authoring guide
23
+ skillplay/ # package
24
+ __init__.py
25
+ __main__.py # main() -> TUI, or dispatches CLI subcommands
26
+ core/
27
+ __init__.py
28
+ loader.py # pack discovery + YAML parsing (loader.py:49-127)
29
+ validators.py # answer checking, behavior not strings (validators.py:16-150)
30
+ engine.py # session loop, scoring, SRS, weighting (engine.py:23-150)
31
+ progress.py # progress.json load/save, XP math (progress.py:18-58)
32
+ streak.py # daily streak rules (streak.py:9-33)
33
+ schema.py # lightweight pack/challenge schema checks
34
+ config.py # optional config.yaml merge over settings
35
+ cli.py # validate-packs / new-pack / export-stats / leaderboard
36
+ tui/
37
+ __init__.py
38
+ app.py # HomeScreen / PlayScreen / SummaryScreen / StatsScreen / SkillPlayApp
39
+ packs/
40
+ sql-basics/ (15 challenges)
41
+ regex-101/ (15 challenges, regex_tester)
42
+ git-basics/ (10 challenges, exact + multiple_choice)
43
+ fix-bug/ (4 challenges, test_cases / python)
44
+ fix-bug-js/ (3 challenges, test_cases / js via node)
45
+ css-basics/ (6 challenges, exact + multiple_choice)
46
+ shell-basics/ (6 challenges, exact)
47
+ http-rest/ (6 challenges, exact + multiple_choice)
48
+ data-structures/ (4 challenges, test_cases / python)
49
+ algorithms/ (4 challenges, test_cases / python)
50
+ schemas/
51
+ pack.schema.json
52
+ challenge.schema.json
53
+ tests/
54
+ smoke_core.py # loader/validators/engine/streak checks (isolated tmp data dir)
55
+ smoke_tui.py # headless Textual E2E: Home→Play→retry→Summary→Home
56
+ conftest.py # tmp-data-dir fixture + pack fixtures
57
+ test_validators.py # pytest: per-validator + engine unit tests
58
+ ```
59
+
60
+ Module responsibilities:
61
+
62
+ | Module | Owns | Never touches |
63
+ |---|---|---|
64
+ | `core/loader` | finding + parsing packs into `Pack`/`Challenge` dataclasses | UI, progress file |
65
+ | `core/validators` | `validate(challenge, input) -> Result(correct, detail)` | session state, disk |
66
+ | `core/engine` | challenge selection, scoring, SRS box updates, finalize, daily pick | widgets, YAML |
67
+ | `core/progress` | `progress.json` load/save, `level_for_xp` / `xp_for_level` | challenges |
68
+ | `core/streak` | pure date math for streaks | disk, UI |
69
+ | `core/schema` | `validate_pack(pack, strict) -> (errors, warnings)` — required keys, known modes, mode-specific fields, duplicate ids/prompts, over-broad regex, reference self-validation | disk, UI |
70
+ | `core/config` | optional `config.yaml` merge over `settings` | disk |
71
+ | `core/cli` | `validate-packs` / `export-stats` / `leaderboard` subcommands | UI |
72
+ | `tui/app` | screens, input, rendering, wiring engine calls | YAML parsing, SQL |
73
+
74
+ Dependency direction: `tui → core.{engine,loader,progress,streak}`; `engine → validators, progress, streak`. Validators and streak are leaf modules (no internal imports).
75
+
76
+ ## 3. Schemas
77
+
78
+ ### 3.1 Pack manifest (`pack.yaml`)
79
+
80
+ ```yaml
81
+ id: sql-basics
82
+ name: SQL Basics
83
+ version: 0.1.0
84
+ author: core
85
+ skill: sql
86
+ description: SELECT, WHERE, ORDER BY — learn SQL by doing.
87
+ difficulty: beginner
88
+ schema_version: 1 # bump on manifest-format changes
89
+ tags: [sql, beginner] # shown on Home; aids discovery
90
+ license: MIT # optional
91
+ min_version: "0.1.0" # optional: requires skillplay >= this
92
+ contributor: core # defaults to author
93
+ entry: challenges/*.yaml # glob, default challenges/*.yaml
94
+ ```
95
+
96
+ Discovery order (`loader.py:49-56`): builtin `skillplay/packs/` → `%LOCALAPPDATA%/skillplay/packs` (user) → `./packs` (local). First pack id wins (dedupe in `load_all_packs`); dirs without `pack.yaml` or with zero challenges are skipped, never fatal.
97
+
98
+ ### 3.2 Challenge file
99
+
100
+ Required: `id`, `type`. Defaults: `difficulty: 1`, `xp: 10`, `topic/title/hints/explanation` empty. `skill` is inherited from the pack manifest (`loader.py:86`). Real example (`02-where.yaml`):
101
+
102
+ ```yaml
103
+ id: sql-where-01
104
+ title: Filter with WHERE
105
+ topic: where
106
+ difficulty: 1
107
+ xp: 15
108
+ type: sql_query
109
+ prompt: |
110
+ Return the names of users who are older than 30.
111
+ context:
112
+ db_seed_sql: |
113
+ CREATE TABLE users(name TEXT, age INT);
114
+ INSERT INTO users VALUES ('ana', 25), ('bob', 35), ('cid', 40);
115
+ answer:
116
+ reference_sql: "SELECT name FROM users WHERE age > 30"
117
+ validation:
118
+ mode: sql_result
119
+ order_matters: false
120
+ normalize: true
121
+ hints: [...]
122
+ explanation: |
123
+ `WHERE` filters rows before they are projected.
124
+ ```
125
+
126
+ `type` values in use / planned: `sql_query`, `regex_build`, `git_command`, `multiple_choice` (used) → `fill_blank`, `fix_bug`, `ordering` (planned).
127
+
128
+ #### Challenge field reference (plugin API contract)
129
+
130
+ | Key | Required | Default | Notes |
131
+ |---|---|---|---|
132
+ | `id` | yes | — | unique across all packs; used for SRS + `completed_ids` |
133
+ | `type` | yes | — | selects validator family (`sql_query` today) |
134
+ | `prompt` | no | `""` | Markdown-ish text shown to the player |
135
+ | `title` / `topic` | no | `id` / `""` | `topic` groups stats later (e.g. `where`) |
136
+ | `difficulty` | no | `1` | 1–5 scale; currently informational only |
137
+ | `xp` | no | `10` | base XP before combo multiplier / retry halving |
138
+ | `answer` | no | `{}` | `reference_sql` for `sql_result`; `value`/`answers` for `exact` |
139
+ | `validation` | no | `{}` | must contain a known `mode` or validation always fails (§3.3) |
140
+ | `starter_code` | no (freeform) | `""` | V1: template pre-filled in the editor for `freeform` challenges (writing from scratch) |
141
+ | `validation.mode` | effectively | — | `exact` \| `regex_tester` \| `sql_result` \| `multiple_choice` \| `test_cases` \| `freeform` |
142
+ | `validation.normalize` | no | `true` | sort both row sets (order-insensitive compare) |
143
+ | `validation.order_matters` | no | `false` | skip sorting; use with `ORDER BY` challenges |
144
+ | `validation.case_insensitive` / `strip` | no | `false` / `true` | `exact` mode only |
145
+ | `validation.answers` | no | `[answer.value]` | accepted strings for `exact` mode |
146
+ | `validation.answer_id` | yes (MC) | — | correct option id for `multiple_choice`; must appear in `options[]` |
147
+ | `validation.must_match` / `must_not_match` | no | `[]` | test strings for `regex_tester` (empty = always fail) |
148
+ | `context.db_seed_sql` | no | `""` | `CREATE`/`INSERT` script run before both user + reference queries |
149
+ | `options[]` | yes (MC) | `[]` | `[{id, text}, …]` — rendered as buttons; max 8 (fixed button pool) |
150
+ | `hints` | no | `[]` | `hints[0]` shown on `Ctrl+H` |
151
+ | `explanation` | no | `""` | shown after a correct answer and on demand via `Ctrl+E` |
152
+ | `skill` | no (forbidden) | inherited | always overwritten from pack manifest (`loader.py:86`) — do not set per-challenge |
153
+ | `srs_reason` | no (runtime) | `""` | set by the engine on selection (`reason_for_challenge`); shown as `why: …` in PlayScreen — never authored in YAML |
154
+
155
+ ### 3.3 Validators (`validators.py:90-150`)
156
+
157
+ Dispatch on `validation.mode`:
158
+
159
+ | Mode | Rule | Used by |
160
+ |---|---|---|
161
+ | `exact` | string match after optional `strip` / `case_insensitive`; `answers[]` or `answer.value` | git-basics commands |
162
+ | `regex_tester` | compile user input; must match all `must_match`, none of `must_not_match` | regex-101 |
163
+ | `sql_result` | run user SQL + `reference_sql` on seeded in-memory sqlite, compare result sets | sql-basics |
164
+ | `multiple_choice` | compare chosen option id (case-insensitive) to `validation.answer_id` | git-basics concepts |
165
+ | `test_cases` | run user code in sandboxed subprocess; check named `function` against `test_cases` | fix-bug, data-structures, algorithms |
166
+ | `freeform` (V1) | identical runtime to `test_cases` but the TUI hides the test list and pre-fills `starter_code` so the player writes the function from scratch | freeform-intro |
167
+ | unknown | always `Result(False, "Unknown validation mode: …")` | — |
168
+
169
+ SQL safety (`validators.py:_run_sql` / `_reject_risky_sql`): seed via `executescript` first, **then** `PRAGMA query_only = ON`, then run the user query. Reversed order would block the seed itself. `normalize: true` sorts both row sets (order-insensitive); ordered challenges keep `normalize: false` (see `03-order.yaml`). Invalid SQL returns `Result(False, "SQL error: …")` — never raises.
170
+
171
+ Hardening (`validators.py`):
172
+ - `_reject_risky_sql` strips comments/string literals, then rejects multi-statement input (`;` followed by content) and write keywords (`INSERT/UPDATE/DELETE/DROP/…`) before the query runs — defense in depth on top of `query_only`.
173
+ - `_run_sql` installs `set_progress_handler` and aborts any query exceeding `_MAX_SQL_STEPS` (runaway `WITH RECURSIVE` / cartesian joins); results beyond `_MAX_SQL_ROWS` are rejected.
174
+
175
+ Never compare SQL or regex answers by string equality — only by behavior (rows returned / strings matched).
176
+
177
+ ### 3.4 Progress (`progress.json`)
178
+
179
+ Location via `platformdirs.user_data_dir("skillplay")`: `%LOCALAPPDATA%\skillplay\progress.json` (Windows), `~/.local/share/skillplay/` (Linux), `~/Library/Application Support/skillplay/` (macOS).
180
+
181
+ ```json
182
+ {
183
+ "version": 1,
184
+ "total_xp": 40,
185
+ "skills": {
186
+ "sql": { "xp": 40, "level": 1, "completed_ids": ["sql-select-01"], "attempts": 9, "correct": 5 }
187
+ },
188
+ "challenges": {
189
+ "sql-where-01": { "seen": 1, "correct": 1, "box": 2, "next_due": "2026-09-06" }
190
+ },
191
+ "streak": { "current": 1, "longest": 1, "last_played_date": "2026-09-05" },
192
+ "settings": { "session_size": 8, "sound": false }
193
+ }
194
+ ```
195
+
196
+ - XP/level (`progress.py:18-23`): `level = floor(sqrt(xp/100)) + 1`; `xp_for_level(L) = (L-1)² × 100`.
197
+ - SRS box (`engine.py:106-121`): correct → `box = min(5, box+1)`; wrong → `box = 1`. `next_due` offsets by box: `{1:0, 2:1, 3:3, 4:7, 5:16}` days.
198
+ - V3 Half-life regression (HLR): `next_due` is now driven by a per-card learned
199
+ half-life (`core/hlr.py`), not the fixed box map. Each review takes one gradient
200
+ step using the delay since `last_review`, correctness, and response `latency`
201
+ (slow correct answers shrink stability — the W4 hesitant-recall rule). The card
202
+ is rescheduled when predicted recall is expected to fall to `TARGET_RECALL`
203
+ (0.90). `box` is still updated for backward-compatible weighting/display; new
204
+ cards stay due today on first encounter. `Stats` shows per-skill avg `memory`
205
+ half-life.
206
+ - Save is atomic (`progress.py:49-54`): write `progress.tmp` + `os.replace`. Corrupt JSON falls back to defaults (`progress.py:37-46`).
207
+
208
+ ### 3.5 Streak rules (`streak.py:9-33`)
209
+
210
+ - Same day → no-op. Consecutive day (`diff == 1`) → `current += 1`. Gap (`diff > 1`) or first run → `current = 1`. `longest = max(longest, current)`. Dates stored as local ISO `YYYY-MM-DD`.
211
+
212
+ ## 4. Flows
213
+
214
+ ### 4.1 Boot (`SkillPlayApp`)
215
+
216
+ 1. `load_all_packs()` → `load()` progress (or defaults) → `apply_config` merges optional `config.yaml`.
217
+ 2. `compose` renders placeholder → `on_mount` updates streak, saves, pushes `HomeScreen`.
218
+ 3. Home shows streak / total XP (refreshed on `on_show`), plus buttons: **Stats**, **Daily Challenge**, **Mixed**, **Due today (N)**, **Goals**, **Achievements**, **Community packs**, **Settings**, and one `Button` per pack (`id="pack-{id}"`, colon-free — Textual ids forbid `:`). `Due today (N)` shows `engine.count_due_today` and launches the due-today review session (B). **Community packs** opens `CommunityScreen` (D) listing discoverable packs from the registry index with in-app Install buttons.
219
+
220
+ ### 4.2 Session loop (`PlayScreen`, `engine.py`)
221
+
222
+ ```
223
+ pick pack / daily → on_mount: Session(skill, select_challenges(pack, progress, session_size))
224
+ select: skip challenges with next_due > today; if none due, use all;
225
+ weighted sample without replacement, session_size items
226
+ weight = (6 - box) + 2×mistakes + 1 (low box & mistake-heavy first)
227
+ # A3 mistake-type targeting: add 2×dominant-type mistakes, +3 when the
228
+ # challenge's dominant type equals the player's globally weakest type
229
+ # (_weakest_mistake_type). Each chosen card gets `srs_reason`
230
+ # (reason_for_challenge) rendered as "why: …" in the TUI.
231
+ per challenge:
232
+ render HUD (Q i/n, skill XP, combo multiplier) + prompt
233
+ multiple_choice → render option buttons (fixed pool of 8, reused — ids opt0..opt7,
234
+ option ids tracked in opt_map); Input hidden.
235
+ test_cases → multi-line `TextArea` (#code, language per `validation.lang`) +
236
+ `▶ Run tests (Ctrl+Enter)` button; Input hidden. (A1 real code UX.)
237
+ otherwise → Input shown (up/down recalls input history)
238
+ grade via Input.Submitted or option button → engine.submit(input):
239
+ correct → combo+=1, best_combo=max, xp = int(base × mult)
240
+ mult: combo≥5 → 1.5x, ≥3 → 1.2x, else 1.0x
241
+ retried-before → xp //= 2; advance SRS box; index+=1
242
+ wrong, first time on this challenge → retried_ids.add(id), stay (retry, half XP available)
243
+ wrong, already retried → combo=0, SRS box→1, index+=1
244
+ correct: green "+N XP" + explanation; retry offered: yellow detail; final wrong: red detail
245
+ Ctrl+H → first hint; Ctrl+E → explanation of current challenge;
246
+ Ctrl+S → skip_current (advance, no grade, no SRS/XP);
247
+ Esc → _finish(): finalize (once, guarded by _finalized) → Summary. No progress lost.
248
+ all answered → _finish(): finalize() if any results → pop Play → push Summary
249
+ ```
250
+
251
+ `Session` state (`engine.py`): `index`, `combo`/`best_combo`, `correct_count`, `xp_gained`, `results[]`, `retried_ids{}`. `done` ⟺ `index >= len(challenges)`.
252
+
253
+ Daily challenge: `engine.daily_challenge(packs)` seeds `random.Random(today)` over all challenges across packs; Home wraps the single pick in a synthetic one-challenge `Pack` and pushes `PlayScreen`.
254
+
255
+ Due-today review (B): `engine.select_due_today(packs, progress, size)` returns only SRS-due (or never-seen) cards across *all* skills, sorted weakest-first; Home's `Due today` button launches it as the daily habit loop. Goals screen (B): each goal exposes a `▶ Practice` button that builds a `Session` from `engine.adaptive_order(pack, progress)` — challenges reordered weakest-first so a curated "path" self-adjusts to the player's performance rather than being a fixed list.
256
+
257
+ ### 4.3 Finalize + summary (`engine.py`, `app.py`)
258
+
259
+ `finalize`: `total_xp += session.xp_gained`; per-skill `xp`, recompute `level`, `attempts += total`, `correct += correct_count`, append correct ids to `completed_ids`; atomic save. Summary screen then updates streak + saves again, shows XP gained / accuracy / best combo; "Back to home" pops once, revealing Home.
260
+
261
+ ### 4.4 Screen stack
262
+
263
+ `[base "Loading…" | Home] → push Play → [base | Home | Play] → finish: pop Play, push Summary → [base | Home | Summary] → back: pop once → [base | Home]`. Stats likewise: push StatsScreen → pop back. The base screen is never visible after mount. StatsScreen reads progress live (total XP, streak current/longest, per-skill level/XP/accuracy/completed).
264
+
265
+ ### 4.7 Mastery exams (V6 / W6)
266
+
267
+ A real learning metric (not engagement). `core/exam.py` builds a randomized, mixed
268
+ session of ~`EXAM_SIZE` (20) challenges across all of a skill's packs, weighted
269
+ toward the *target difficulty tier* (current certification level + 1, capped at the
270
+ skill's hardest challenge). The session is graded normally; on finish,
271
+ `exam.certify` records `progress["certifications"][skill]` if accuracy ≥ 90%
272
+ (`PASS_RATIO`), setting `level` to the target tier and incrementing `exams_passed`.
273
+ Re-taking and passing raises the certified level. The TUI `ExamScreen` lists each
274
+ skill with its certified level + "Take exam" button; the result is shown on the
275
+ summary screen and persisted (with `progress.save`).
276
+
277
+ ### 4.6 Encrypted sync (V5 / W5)
278
+
279
+ Account-free, client-side-encrypted `progress.json` sync. No passwords, no
280
+ accounts — pairing is a shared **device key** (a Fernet key, `cryptography`):
281
+
282
+ 1. First device: `skillplay sync --pair` → prints the key (stored locally in
283
+ `DATA_DIR/sync.key`, *never* inside the synced blob).
284
+ 2. Other devices: `skillplay sync --set-key <KEY>` (same key).
285
+ 3. `sync --push` encrypts the on-disk `progress.json` (Fernet/AES-128-CBC+HMAC)
286
+ and `POST /sync/put` to the leaderboard server. `sync --pull` `GET /sync/get`s
287
+ the blob for an unguessable **slot** = `HMAC(key, handle)`, decrypts, and
288
+ overwrites local progress. `progress.save` snapshots the old file first, so a
289
+ pull is always undoable via `restore-progress`. The server stores only opaque
290
+ ciphertext (`leaderboard_sync.json`); it never sees the key or the plaintext.
291
+
292
+ ### 4.5 CLI (`core/cli.py`, dispatched from `__main__`)
293
+
294
+ Bare `skillplay` → TUI; with subcommands → CLI:
295
+
296
+ | Command | Behavior |
297
+ |---|---|
298
+ | `validate-packs [--json] [--strict] [--fix]` | `schema.validate_pack` on every discovered pack: required keys, known modes, mode-specific fields, duplicate ids **and prompts**, over-broad regex, and reference-answer self-validation. `--json` emits a CI-friendly report; `--strict` promotes warnings (missing explanation/hints) to errors; `--fix` prints concrete fix suggestions per problem. Exit 1 on any error. |
299
+ | `new-pack <id> [--skill S] [--dir D]` | scaffolds a new pack at `D/<id>/` with `pack.yaml` + a sample `challenges/01-example.yaml` (A2 authoring). |
300
+ | `play --pack PATH` | loads a single local pack (no install) and launches the TUI with just that pack — for authoring previews. |
301
+ | `export-stats [--format json|md] [--output PATH]` | dumps `progress.json` or a Markdown table (per-skill XP/level/accuracy/completed). |
302
+ | `leaderboard [--name] [--url] [--view] [--skill S] [--publish]` | opt-in, account-free. Default POSTs `{"name", "total_xp", "skills":{...}}` (per-skill XP; URL from flag → `SKILLPLAY_LEADERBOARD_URL` env → `leaderboard.url` config, treated as a **base** URL). `--view [--skill S]` fetches and prints the all-time + weekly boards (per skill when given). `--publish` uploads the player's progress + SVG card to the server's public `/u/<handle>` page. No URL configured → friendly exit 1. |
303
+ | `share-stats [--format md\|svg\|json] [--output PATH] [--url URL] [--publish]` | generates a Markdown snippet / SVG card / JSON dump; `--publish` posts the SVG card as a public handle page (see `leaderboard --publish`). |
304
+ | `serve-leaderboard [--host H] [--port P] [--db PATH]` | runs the opt-in, account-free leaderboard server. Stores scores in `leaderboard.json` + profiles in `leaderboard_profiles.json`. Routes: `POST /submit` (per-skill scores), `POST /profile` (public page), `GET /api/board[?skill=]`, `GET /api/profile?name=`, `GET /u/<handle>` (HTML card). Weekly board = current ISO week (rolls over); all-time is cumulative. |
305
+ | `registry [--index URL]` | D: lists discoverable community packs from the index (`core/registry.available_packs`). |
306
+ | `install <name> [--index URL] [--force]` | installs a pack by URL, local path, or index name (`install_from_index`). Updates `.registry.json` so `update-packs` can re-pull. |
307
+ | `context --file PATH [--open]` | D: editor integration — maps a file's extension to the best matching pack and (with `--open`) launches its TUI. |
308
+ | `play --pack PATH` / `play --pack-id ID` | launches the TUI with one pack (local dir, or a built-in pack by id). |
309
+
310
+ Config (`core/config.py`): optional `config.yaml` next to `progress.json`; honors `session_size`, `sound`, `leaderboard.{name,url}` — see `config.sample.yaml`.
311
+
312
+ ## 5. Invariants for contributors
313
+
314
+ 1. Validators are pure: `(challenge, str) -> Result`. No I/O, no exceptions outward.
315
+ 2. Engine mutates `progress` dict in memory; only `finalize`/`progress.save` touch disk.
316
+ 3. Never trust string equality for SQL/regex — add a `validation.mode`, not a special case.
317
+ 4. New packs = new folder under `skillplay/packs/` (or user/local dir) with `pack.yaml` + `challenges/*.yaml`. No code changes needed.
318
+ 5. Textual constraints learned the hard way: no screen ops inside `compose()`; no `self.app` access in `Screen.__init__` (use `on_mount`); widget ids `[A-Za-z0-9_-]` only; `align`/`content-align` need both axes in current Textual.
319
+
320
+ ## 6. Decision log
321
+
322
+ | Decision | Chosen | Rejected | Why |
323
+ |---|---|---|---|
324
+ | TUI framework | Textual | BubbleTea (Go), Ratatui (Rust) | content iteration speed matters more than binary size at MVP; Python lets pack authors read validator code; `run_test()` pilot gives free headless E2E |
325
+ | Pack format | YAML | TOML, JSON | multi-line `prompt`/`db_seed_sql` blocks are readable in YAML; authors are humans, not machines |
326
+ | Progress store | single JSON file, atomic replace | SQLite | progress is one small document, always read/written whole; JSON is inspectable/debuggable by users; atomic replace is enough crash safety |
327
+ | SQL validation | in-memory sqlite, result-set compare | string match, hosted DB | zero deps (stdlib), true semantic checking, per-query DB so challenges can't interfere; `query_only` after seed blocks writes (verified) |
328
+ | Challenge identity | global string `id` | per-pack numbering | SRS records, `completed_ids`, and `retried_ids` all key on it across sessions |
329
+ | Level curve | `floor(sqrt(xp/100))+1` | linear | diminishing returns keep early game rewarding without runaway numbers |
330
+
331
+ ## 7. Failure modes & limits (verified)
332
+
333
+ | # | Exposure | Behavior today | Status |
334
+ |---|---|---|---|
335
+ | 1 | Runaway user SQL (`WITH RECURSIVE` bomb, cartesian join) | `set_progress_handler` aborts after `_MAX_SQL_STEPS` (2M) instructions → `Result(False, "SQL error: interrupted")` | HANDLED (P4 guard) |
336
+ | 2 | Regex catastrophic backtracking (`(a+)+$` on long non-match) | hangs inside `_regex_tester` `.search()`; test strings are short so impact is a brief freeze | OPEN — acceptable until user-authored packs grow; then add `regex` timeout or length cap |
337
+ | 3 | `Esc` mid-session | `_finish()` → finalize (guarded once) → Summary. Answered XP + SRS updates are saved, nothing lost | HANDLED (was: silent loss — fixed in P4) |
338
+ | 4 | Multi-statement input (`SELECT …; DELETE …`) | `_reject_risky_sql` strips comments/literals, then rejects `;` followed by content before sqlite sees it → `Result(False, "SQL error: Only a single SQL statement is allowed")` | HANDLED (explicit pre-flight reject) |
339
+ | 5 | Write attempt (`DROP TABLE`, `INSERT`) | double-guarded: `_reject_risky_sql` rejects write keywords; `query_only` pragma → `OperationalError` if anything slips through | HANDLED |
340
+ | 6 | Corrupt `progress.json` | falls back to defaults (`progress.py`); corrupt file is overwritten on next save (data loss of old progress, app survives) | HANDLED (survival over recovery — by design) |
341
+ | 7 | Unknown `validation.mode` / bad pack YAML | challenge always grades wrong (`_unknown`); `validate-packs` CLI now surfaces schema errors + failed self-validation explicitly | HANDLED |
342
+ | 8 | Unbounded `fetchall` | queries returning more than `_MAX_SQL_ROWS` (5000) rows are rejected with a clean message | HANDLED (P4 row cap) |
343
+ | 9 | Option-button remount id collisions | option buttons are a fixed pool (`opt0..opt7`) reused via `opt_map`, never re-mounted per challenge | HANDLED (Textual `remove_children` is async — dynamic remount races) |
344
+
345
+ ## 8. Testing
346
+
347
+ ```
348
+ cd skillplay
349
+ python tests/smoke_core.py # 7 checks: loader, validators, retry/half-XP, finalize, streak, SRS
350
+ python tests/smoke_tui.py # headless pilot: Home→Play→wrong→retry→correct→Summary→Home
351
+ python -m pytest tests/ # unit suite: validators, engine, pack validation (needs `pytest`)
352
+ ```
353
+
354
+ - All scripts resolve the repo root from `__file__`, so they run from any cwd (packs load via builtin path).
355
+ - **Isolation:** all monkeypatch `progress.DATA_DIR`/`PROGRESS_PATH` to a fresh `tempfile.mkdtemp()` before running — real user progress is never touched. (Earlier versions wrote to the live data dir; fixed when moving into the repo.)
356
+ - `smoke_tui.py` and the pytest suite need `textual`; `smoke_core.py` needs only `pyyaml` + `platformdirs`. Dev deps: `pip install -e ".[dev]"`.
357
+ - pytest suite (`tests/test_validators.py` + `tests/conftest.py` fixtures) covers per-validator behavior (multi-statement/write rejection, runaway-query abort, multiple-choice, regex), engine retry/half-XP, session weighting, and `validate_pack` linting. Smoke scripts are retained for a zero-dep sanity check.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: skillplay
3
+ Version: 1.3.0
4
+ Summary: Learn-by-Play TUI — learn dev skills by playing bite-sized challenges
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: cryptography>=42.0
7
+ Requires-Dist: platformdirs>=4.2
8
+ Requires-Dist: pyyaml>=6.0
9
+ Requires-Dist: rich>=13.7
10
+ Requires-Dist: textual>=0.83
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: ruff>=0.6; extra == 'dev'
@@ -0,0 +1,117 @@
1
+ # skillplay
2
+
3
+ > Learn-by-Play TUI: learn dev skills (SQL, regex, git, Python, algorithms,
4
+ > CSS, shell, HTTP, JS) through bite-sized terminal challenges. Offline-first,
5
+ > keyboard-only, zero accounts.
6
+
7
+ ## Install
8
+
9
+ With [pipx](https://pipx.pypa.io/) (recommended — isolates the app in its own env):
10
+
11
+ ```bash
12
+ pipx install skillplay # from PyPI
13
+ # or, from a clone of this repo:
14
+ pipx install . # editable install from source
15
+ ```
16
+
17
+ Or with plain pip:
18
+
19
+ ```bash
20
+ pip install . # from the repo root
21
+ python -m skillplay # launch
22
+ ```
23
+
24
+ Requires Python 3.11+.
25
+
26
+ ## Run
27
+
28
+ ```bash
29
+ skillplay # launches the TUI
30
+ skillplay play --pack-id sql-basics # jump straight into one built-in pack
31
+ skillplay demo # random challenge served in your browser (zero install)
32
+ skillplay exam --skill sql # take a randomized mastery exam (TUI)
33
+ skillplay generate --play # endless fresh generated challenges (Python/JS/regex)
34
+ skillplay validate-packs # check all installed packs against the schema
35
+ skillplay export-stats # print progress as Markdown
36
+ skillplay export-stats --format json --output stats.json
37
+ skillplay share-stats --format md # a copy-pasteable stats snippet
38
+ skillplay share-stats --format svg --output card.svg
39
+ skillplay leaderboard --name you # opt-in POST of (per-skill) XP (needs a URL)
40
+ skillplay leaderboard --view --skill sql # fetch + print the board, by skill
41
+ skillplay leaderboard --publish # publish your public profile/handle page
42
+ skillplay serve-leaderboard # run the opt-in leaderboard server
43
+ skillplay sync --pair # generate + print the device pairing key
44
+ skillplay sync --set-key <KEY> # install a key copied from another device
45
+ skillplay sync --push # encrypt + upload progress to the server
46
+ skillplay sync --pull # download + decrypt progress onto this device
47
+ skillplay restore-progress # list/restore automatic daily progress backups
48
+ skillplay registry # list discoverable community packs
49
+ skillplay install <url|path|name> # install a community pack
50
+ skillplay update-packs # re-install community packs
51
+ skillplay list-packs # list installed community packs
52
+ skillplay new-pack my-pack # scaffold a new pack (authoring)
53
+ skillplay pack ./my-pack --publish # bundle/publish a community pack
54
+ skillplay context --file app.py # suggest a pack for the file you're editing
55
+ ```
56
+
57
+ ## What's included
58
+
59
+ 13 built-in packs · **110 challenges** across 9 skills:
60
+
61
+ | Pack | Skill | Challenges | Validator |
62
+ |---|---|---|---|
63
+ | `sql-basics` | sql | 15 | `sql_result` (in-memory sqlite) · prerequisite DAG · es |
64
+ | `regex-101` | regex | 15 | `regex_tester` |
65
+ | `git-basics` | git | 10 | `exact` + `multiple_choice` |
66
+ | `algorithms` | algorithms | 12 | `test_cases`/`freeform` (sandboxed subprocess) · prerequisite DAG |
67
+ | `css-basics` | css | 9 | `exact` + `multiple_choice` |
68
+ | `shell-basics` | shell | 9 | `exact` |
69
+ | `http-rest` | http | 9 | `exact` + `multiple_choice` |
70
+ | `data-structures` | python | 10 | `test_cases` · es |
71
+ | `py-stdlib` | python | 8 | `test_cases`/`freeform` (sandboxed subprocess) |
72
+ | `fix-bug` | python | 4 | `test_cases` (sandboxed subprocess) |
73
+ | `fix-bug-js` | javascript | 3 | `test_cases` (node, sandboxed) |
74
+ | `freeform-intro` | python | 3 | `freeform` (hidden-test construction) |
75
+ | `mini-cli` | python | 3 | `freeform` (capstone → portfolio artifact) |
76
+
77
+ ## How to play
78
+
79
+ - Pick a pack (or **Mixed** for an interleaved session across all skills).
80
+ - `Ctrl+H` cycles through hints, `Ctrl+S` skips, `Ctrl+E` shows the explanation,
81
+ `Ctrl+U`/`Ctrl+D` rate the explanation, `Esc` finishes and saves the session.
82
+ - Correct answers earn XP with a combo multiplier; the first wrong answer
83
+ offers a retry for half XP.
84
+ - Reviews are scheduled by a **half-life regression memory model**: each card
85
+ learns its own retention half-life from your delay, correctness, and response
86
+ latency, and comes back just before you'd forget it. Mistakes are tagged
87
+ (syntax / logic / off-by-one) and bias future weighting.
88
+ - A local **adaptive model** tilts selection toward what you need next, and the
89
+ **skill graph** unlocks challenge frontiers as prerequisites are passed.
90
+ - **Mastery exams** (V6): take a randomized, mixed exam per skill — score ≥ 90%
91
+ to certify the next difficulty level. A real learning metric, not just XP.
92
+ Thin skills top their exam up from related skills' packs, so every paper is
93
+ a full ~20 questions (own challenges always come first).
94
+ - **Capstone packs** (e.g. `mini-cli`) chain challenges that build one real
95
+ artifact; your solutions are assembled into a portfolio file on disk.
96
+ - Daily challenge, achievements, curated goals, and a crash-safe session
97
+ resume are all built in. Settings (sound, theme, language, telemetry, mentor
98
+ backend) live in the in-app **Settings** screen. `Ctrl+M` asks the **Mentor**
99
+ to explain a mistake like a senior dev. UI and challenge content ship in
100
+ English + Spanish.
101
+
102
+ ## Authoring your own packs
103
+
104
+ See [docs/authoring-packs.md](docs/authoring-packs.md). Packs are just a
105
+ folder of YAML — no code changes needed. `skillplay new-pack my-pack` scaffolds
106
+ one, and `skillplay validate-packs` self-validates every reference answer.
107
+
108
+ ## Distribution
109
+
110
+ Prefer zero install? `skillplay demo` serves a random challenge in your browser
111
+ (stdlib HTTP server, no account). A single-binary build is supported via
112
+ `scripts/build_binary.py` (PyInstaller, falling back to `shiv`). On each tagged
113
+ release (`v*`), CI builds a one-file binary for Linux, macOS, and Windows and
114
+ **attaches them to the GitHub release** (`.github/workflows/ci.yml` →
115
+ `build-binary` job), and publishes the wheel to PyPI
116
+ (`.github/workflows/ci.yml` → `publish` job).
117
+
@@ -0,0 +1,30 @@
1
+ # Sample user config
2
+ #
3
+ # Copy to the skillplay data dir (next to progress.json) and rename to
4
+ # `config.yaml`. All keys are optional; defaults are shown below.
5
+ #
6
+ # Linux/macOS: ~/.local/share/skillplay/config.yaml
7
+ # Windows: %LOCALAPPDATA%\skillplay\config.yaml
8
+
9
+ # How many challenges per session (default 8).
10
+ session_size: 8
11
+
12
+ # Sound effects (default off; dependency-free beep via terminal bell / winsound).
13
+ sound: false
14
+
15
+ # Theme: dark | light | high_contrast (respects NO_COLOR if set in the env).
16
+ theme: dark
17
+
18
+ # Language: en | es (community packs may add more).
19
+ language: en
20
+
21
+ # Anonymous telemetry of low first-try rates. Local-only by default; set true to
22
+ # also POST an anonymized aggregate (no names) to the leaderboard URL.
23
+ telemetry: false
24
+
25
+ # Opt-in online leaderboard (disabled until `url` is set — see `skillplay leaderboard --help`).
26
+ # `url` is the BASE url of a `serve-leaderboard` instance (e.g. http://localhost:8000);
27
+ # endpoints (/submit, /profile, /api/board, /u/<handle>) are appended automatically.
28
+ leaderboard:
29
+ name: anon
30
+ # url: http://localhost:8000