Jingzhi Ye Claude Opus 4.8 commited on
Commit
9e67084
·
1 Parent(s): 9b33614

Port Inkwitch game (production files) to ink-witch Space

Browse files

Bring over the production app from gizzleon/hf-playground: app entry,
Space config, pinned requirements, and the witch_wood package (core,
engine, vision, gen, ui + custom HTML/JS frontend). Dev-only files
(tests, docs, uv.lock) are intentionally omitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+
10
+ # Local tooling / OS
11
+ .claude/
12
+ .vscode/
13
+ .DS_Store
14
+
15
+ # HF cache
16
+ .cache/
17
+
18
+ # Test artifacts
19
+ .playwright-mcp/
20
+ *.png
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,13 +1,23 @@
1
  ---
2
- title: Ink Witch
3
- emoji: 🐢
4
  colorFrom: indigo
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Inkwitch
3
+ emoji: 🧙‍♀️
4
  colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.16.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
+ # Inkwitch
14
+
15
+ Draw objects-on-demand for visitors under a stroke-length mana budget; a
16
+ vision model (MiniCPM-V-4.6) judges each doodle against the requested object
17
+ and its line-drawable properties. Earn stars to open harder, higher-paying
18
+ slots and pay off your debt before the deadline.
19
+
20
+ Runs on Hugging Face Spaces **ZeroGPU**: the model is loaded at startup and each
21
+ appraisal runs inside a `@spaces.GPU` call.
22
+
23
+ Local dev (no GPU): `WITCH_WOOD_MOCK=1 python app.py`
app.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from witch_wood.ui.server import app
2
+
3
+ if __name__ == "__main__":
4
+ # ssr_mode=False is required: this Space serves a custom HTML/JS frontend
5
+ # from `@app.get("/")` + the /static mount. With SSR on (the default when
6
+ # Node is present, as it is on HF Spaces) Gradio's Node server takes over
7
+ # "/" and the custom Inkwitch page never renders.
8
+ app.launch(ssr_mode=False)
pyproject.toml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "ink-witch"
3
+ version = "0.1.0"
4
+ description = "Inkwitch — a doodle-judging game on Hugging Face ZeroGPU"
5
+ readme = "README.md"
6
+ # ZeroGPU runtimes only ship Python 3.12.12 and 3.10.13; pin to 3.12 so the
7
+ # local venv matches the Space (README python_version: '3.12').
8
+ requires-python = ">=3.12,<3.13"
9
+ dependencies = [
10
+ # gradio matches the Space's sdk_version in README.md frontmatter. The
11
+ # [oauth,mcp] extras mirror what the HF Spaces builder injects
12
+ # (`gradio[oauth,mcp]==<sdk_version>`); declaring them here makes uv resolve
13
+ # transitive pins (notably pydantic <=2.12.5, required by the mcp extra)
14
+ # that are compatible with the Space's own install command.
15
+ "gradio[oauth,mcp]==6.16.0",
16
+ # ZeroGPU supports torch up to 2.11.0 (not 2.12+); torchvision 0.26.0 is the
17
+ # matching sidecar for torch 2.11.x.
18
+ "torch==2.11.0",
19
+ "torchvision==0.26.0",
20
+ "transformers>=5.10.1",
21
+ "accelerate>=1.13.0",
22
+ # SDXL-Flash + scribble ControlNet image-gen second stage (witch_wood/gen).
23
+ "diffusers>=0.32.0",
24
+ "pillow>=12.0.0",
25
+ # Declared so uv co-resolves its transitive constraints (e.g. psutil). It is
26
+ # provided by the ZeroGPU base image and excluded from the exported
27
+ # requirements.txt — never pin `spaces` for the Space.
28
+ "spaces>=0.42.1",
29
+ ]
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=9.0.3",
34
+ ]
35
+
36
+ [tool.pytest.ini_options]
37
+ pythonpath = ["."]
38
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv export --no-hashes --no-dev --no-emit-package spaces -o requirements.txt
3
+ accelerate==1.13.0
4
+ # via hf-playground
5
+ annotated-doc==0.0.4
6
+ # via
7
+ # fastapi
8
+ # typer
9
+ annotated-types==0.7.0
10
+ # via pydantic
11
+ anyio==4.13.0
12
+ # via
13
+ # gradio
14
+ # httpx
15
+ # mcp
16
+ # sse-starlette
17
+ # starlette
18
+ attrs==26.1.0
19
+ # via
20
+ # jsonschema
21
+ # referencing
22
+ authlib==1.7.2
23
+ # via gradio
24
+ brotli==1.2.0
25
+ # via gradio
26
+ certifi==2026.5.20
27
+ # via
28
+ # httpcore
29
+ # httpx
30
+ # requests
31
+ cffi==2.0.0 ; platform_python_implementation != 'PyPy'
32
+ # via cryptography
33
+ charset-normalizer==3.4.7
34
+ # via requests
35
+ click==8.4.1
36
+ # via
37
+ # huggingface-hub
38
+ # typer
39
+ # uvicorn
40
+ colorama==0.4.6 ; sys_platform == 'win32'
41
+ # via
42
+ # click
43
+ # tqdm
44
+ cryptography==48.0.1
45
+ # via
46
+ # authlib
47
+ # joserfc
48
+ # pyjwt
49
+ cuda-bindings==13.3.1 ; sys_platform == 'linux'
50
+ # via torch
51
+ cuda-pathfinder==1.5.5 ; sys_platform == 'linux'
52
+ # via cuda-bindings
53
+ cuda-toolkit==13.0.2 ; sys_platform == 'linux'
54
+ # via torch
55
+ diffusers==0.38.0
56
+ # via hf-playground
57
+ fastapi==0.136.3
58
+ # via gradio
59
+ filelock==3.29.2
60
+ # via
61
+ # diffusers
62
+ # huggingface-hub
63
+ # torch
64
+ fsspec==2026.4.0
65
+ # via
66
+ # gradio-client
67
+ # huggingface-hub
68
+ # torch
69
+ gradio==6.16.0
70
+ # via
71
+ # hf-playground
72
+ # spaces
73
+ gradio-client==2.5.0
74
+ # via
75
+ # gradio
76
+ # hf-gradio
77
+ groovy==0.1.2
78
+ # via gradio
79
+ h11==0.16.0
80
+ # via
81
+ # httpcore
82
+ # uvicorn
83
+ hf-gradio==0.4.1
84
+ # via gradio
85
+ hf-xet==1.5.1 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
86
+ # via huggingface-hub
87
+ httpcore==1.0.9
88
+ # via httpx
89
+ httpx==0.28.1
90
+ # via
91
+ # diffusers
92
+ # gradio
93
+ # gradio-client
94
+ # huggingface-hub
95
+ # mcp
96
+ # safehttpx
97
+ # spaces
98
+ httpx-sse==0.4.3
99
+ # via mcp
100
+ huggingface-hub==1.18.0
101
+ # via
102
+ # accelerate
103
+ # diffusers
104
+ # gradio
105
+ # gradio-client
106
+ # tokenizers
107
+ # transformers
108
+ idna==3.18
109
+ # via
110
+ # anyio
111
+ # httpx
112
+ # requests
113
+ importlib-metadata==9.0.0
114
+ # via diffusers
115
+ itsdangerous==2.2.0
116
+ # via gradio
117
+ jinja2==3.1.6
118
+ # via
119
+ # gradio
120
+ # torch
121
+ joserfc==1.7.1
122
+ # via authlib
123
+ jsonschema==4.26.0
124
+ # via mcp
125
+ jsonschema-specifications==2025.9.1
126
+ # via jsonschema
127
+ markdown-it-py==4.2.0
128
+ # via rich
129
+ markupsafe==3.0.3
130
+ # via
131
+ # gradio
132
+ # jinja2
133
+ mcp==1.27.2
134
+ # via gradio
135
+ mdurl==0.1.2
136
+ # via markdown-it-py
137
+ mpmath==1.3.0
138
+ # via sympy
139
+ networkx==3.6.1
140
+ # via torch
141
+ numpy==2.4.6
142
+ # via
143
+ # accelerate
144
+ # diffusers
145
+ # gradio
146
+ # pandas
147
+ # torchvision
148
+ # transformers
149
+ nvidia-cublas==13.1.0.3 ; sys_platform == 'linux'
150
+ # via
151
+ # cuda-toolkit
152
+ # nvidia-cudnn-cu13
153
+ # nvidia-cusolver
154
+ nvidia-cuda-cupti==13.0.85 ; sys_platform == 'linux'
155
+ # via cuda-toolkit
156
+ nvidia-cuda-nvrtc==13.0.88 ; sys_platform == 'linux'
157
+ # via cuda-toolkit
158
+ nvidia-cuda-runtime==13.0.96 ; sys_platform == 'linux'
159
+ # via cuda-toolkit
160
+ nvidia-cudnn-cu13==9.19.0.56 ; sys_platform == 'linux'
161
+ # via torch
162
+ nvidia-cufft==12.0.0.61 ; sys_platform == 'linux'
163
+ # via cuda-toolkit
164
+ nvidia-cufile==1.15.1.6 ; sys_platform == 'linux'
165
+ # via cuda-toolkit
166
+ nvidia-curand==10.4.0.35 ; sys_platform == 'linux'
167
+ # via cuda-toolkit
168
+ nvidia-cusolver==12.0.4.66 ; sys_platform == 'linux'
169
+ # via cuda-toolkit
170
+ nvidia-cusparse==12.6.3.3 ; sys_platform == 'linux'
171
+ # via
172
+ # cuda-toolkit
173
+ # nvidia-cusolver
174
+ nvidia-cusparselt-cu13==0.8.0 ; sys_platform == 'linux'
175
+ # via torch
176
+ nvidia-nccl-cu13==2.28.9 ; sys_platform == 'linux'
177
+ # via torch
178
+ nvidia-nvjitlink==13.0.88 ; sys_platform == 'linux'
179
+ # via
180
+ # cuda-toolkit
181
+ # nvidia-cufft
182
+ # nvidia-cusolver
183
+ # nvidia-cusparse
184
+ nvidia-nvshmem-cu13==3.4.5 ; sys_platform == 'linux'
185
+ # via torch
186
+ nvidia-nvtx==13.0.85 ; sys_platform == 'linux'
187
+ # via cuda-toolkit
188
+ orjson==3.11.9
189
+ # via gradio
190
+ packaging==26.2
191
+ # via
192
+ # accelerate
193
+ # gradio
194
+ # gradio-client
195
+ # huggingface-hub
196
+ # spaces
197
+ # transformers
198
+ pandas==3.0.3
199
+ # via gradio
200
+ pillow==12.2.0
201
+ # via
202
+ # diffusers
203
+ # gradio
204
+ # hf-playground
205
+ # torchvision
206
+ psutil==7.2.2
207
+ # via accelerate
208
+ pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
209
+ # via cffi
210
+ pydantic==2.12.5
211
+ # via
212
+ # fastapi
213
+ # gradio
214
+ # mcp
215
+ # pydantic-settings
216
+ # spaces
217
+ pydantic-core==2.41.5
218
+ # via pydantic
219
+ pydantic-settings==2.14.1
220
+ # via mcp
221
+ pydub==0.25.1
222
+ # via gradio
223
+ pygments==2.20.0
224
+ # via rich
225
+ pyjwt==2.13.0
226
+ # via mcp
227
+ python-dateutil==2.9.0.post0
228
+ # via pandas
229
+ python-dotenv==1.2.2
230
+ # via pydantic-settings
231
+ python-multipart==0.0.32
232
+ # via
233
+ # gradio
234
+ # mcp
235
+ pytz==2026.2
236
+ # via gradio
237
+ pywin32==312 ; sys_platform == 'win32'
238
+ # via mcp
239
+ pyyaml==6.0.3
240
+ # via
241
+ # accelerate
242
+ # gradio
243
+ # huggingface-hub
244
+ # transformers
245
+ referencing==0.37.0
246
+ # via
247
+ # jsonschema
248
+ # jsonschema-specifications
249
+ regex==2026.5.9
250
+ # via
251
+ # diffusers
252
+ # transformers
253
+ requests==2.34.2
254
+ # via
255
+ # diffusers
256
+ # spaces
257
+ rich==15.0.0
258
+ # via typer
259
+ rpds-py==2026.5.1
260
+ # via
261
+ # jsonschema
262
+ # referencing
263
+ safehttpx==0.1.7
264
+ # via gradio
265
+ safetensors==0.8.0
266
+ # via
267
+ # accelerate
268
+ # diffusers
269
+ # transformers
270
+ semantic-version==2.10.0
271
+ # via gradio
272
+ setuptools==81.0.0
273
+ # via torch
274
+ shellingham==1.5.4
275
+ # via typer
276
+ six==1.17.0
277
+ # via python-dateutil
278
+ sse-starlette==3.4.4
279
+ # via mcp
280
+ starlette==1.2.1
281
+ # via
282
+ # fastapi
283
+ # gradio
284
+ # mcp
285
+ # sse-starlette
286
+ sympy==1.14.0
287
+ # via torch
288
+ tokenizers==0.22.2
289
+ # via transformers
290
+ tomlkit==0.14.0
291
+ # via gradio
292
+ torch==2.11.0
293
+ # via
294
+ # accelerate
295
+ # hf-playground
296
+ # torchvision
297
+ torchvision==0.26.0
298
+ # via hf-playground
299
+ tqdm==4.68.2
300
+ # via
301
+ # huggingface-hub
302
+ # transformers
303
+ transformers==5.11.0
304
+ # via hf-playground
305
+ triton==3.6.0 ; sys_platform == 'linux'
306
+ # via torch
307
+ typer==0.25.1
308
+ # via
309
+ # gradio
310
+ # hf-gradio
311
+ # huggingface-hub
312
+ # transformers
313
+ typing-extensions==4.15.0
314
+ # via
315
+ # anyio
316
+ # fastapi
317
+ # gradio
318
+ # gradio-client
319
+ # huggingface-hub
320
+ # mcp
321
+ # pydantic
322
+ # pydantic-core
323
+ # referencing
324
+ # spaces
325
+ # starlette
326
+ # torch
327
+ # typing-inspection
328
+ typing-inspection==0.4.2
329
+ # via
330
+ # fastapi
331
+ # mcp
332
+ # pydantic
333
+ # pydantic-settings
334
+ tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
335
+ # via pandas
336
+ urllib3==2.7.0
337
+ # via requests
338
+ uvicorn==0.49.0
339
+ # via
340
+ # gradio
341
+ # mcp
342
+ zipp==4.1.0
343
+ # via importlib-metadata
witch_wood/__init__.py ADDED
File without changes
witch_wood/core/__init__.py ADDED
File without changes
witch_wood/core/constants.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """All tunable game knobs in one place."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ # --- TEST MODE -------------------------------------------------------------
7
+ # Manual-testing harness for the image-gen pipeline. Enable by setting the Space
8
+ # Variable WITCH_WOOD_TESTMODE=1 (or the env var locally). When on, every new
9
+ # game starts with effectively unlimited gold + mana, the visitor queue is the
10
+ # FULL request pool (any object selectable, regardless of slot/tier/prereqs),
11
+ # and a request may be delivered repeatedly. OFF by default — the real game is
12
+ # unaffected. NOTHING here should ship enabled.
13
+ TEST_MODE: bool = os.environ.get("WITCH_WOOD_TESTMODE") == "1"
14
+ TEST_GOLD: int = 1_000_000
15
+ TEST_MANA_MAX: int = 1_000_000
16
+
17
+ # --- IMAGE GEN -------------------------------------------------------------
18
+ # Single source of truth for the doodle->image stage (witch_wood/gen/scribble.py).
19
+ # The server renders these into the tuning panel's HTML at serve time
20
+ # (ui/server.py _render_index), so the panel defaults are never duplicated in the
21
+ # static file; the panel can override any of them per-delivery. STYLE_SUFFIX /
22
+ # BASE_NEGATIVE shape build_prompts(); the
23
+ # numeric params drive generate_image(). Tuned for the 384px render: guidance 5.0
24
+ # (7.5 over-cooks at low res, 2.5 is muddy), cnet 1.1 keeps doodle fidelity,
25
+ # ~18 steps refines without oversaturating. ~5-9s/render.
26
+ GEN_ENABLED: bool = True # image-gen stage on by default
27
+ GEN_STEPS: int = 30
28
+ GEN_GUIDANCE: float = 5.0
29
+ GEN_CNET_SCALE: float = 0.5
30
+ GEN_SEED: int = -1 # -1 = random seed each generation
31
+ STYLE_SUFFIX: str = (
32
+ ", full shot, realistic, photograph, soft even lighting, single subject, centered, plain simple background, minimal"
33
+ )
34
+ BASE_NEGATIVE: str = (
35
+ "lowres, closeup, blurry, graphic, painting, text, busy background, border, frame, lens flare, neon, rim light, cropped, partial object, zoom"
36
+ )
37
+
38
+ # Scoring thresholds (set from Gate B observations). With binary yes/no
39
+ # confidences (yes->1.0/no->0.0) a 0.5 threshold is effectively a pass-through.
40
+ T_RECOGNIZE: float = 0.5
41
+ T_PROPERTY: float = 0.5
42
+
43
+ # Payment model — fractions of a request's base_payment. Applied in
44
+ # scoring.resolve(). Recognition is the make-or-break; properties are creative
45
+ # upside. NEVER hard-code these in logic — they are balance knobs.
46
+ RECOGNITION_FAIL_PAYOUT: float = 0.5 # flat payout when recognition fails (stars ignored)
47
+ RECOGNITION_PASS_BASE: float = 1.0 # base payout when recognition passes
48
+ STAR_BONUS_PER_STAR: float = 0.2 # added per property star earned, on a pass
49
+
50
+ # Canvas reset: fraction of an abandoned drawing's spent mana refunded on Reset.
51
+ CANVAS_RESET_REFUND: float = 0.7
52
+
53
+ # Mana
54
+ STARTING_MANA_MAX: int = 1500
55
+
56
+ # Economy
57
+ DEBT: int = 800
58
+ DEADLINE_DAY: int = 5
59
+ RENT: int = 30
60
+ STARTING_GOLD: int = 30
61
+
62
+ # Shop prices (slots are NOT sold — they unlock by stars, below)
63
+ POTION_COST: int = 20
64
+ POTION_COST_STEP: int = 10 # added to potion price per purchase that day; resets each morning
65
+ POTION_MANA: int = 500 # temporary, today only
66
+ MANA_UPGRADE_COST: int = 50
67
+ MANA_UPGRADE_AMOUNT: int = 500 # permanent
68
+
69
+ # Progression: slot index (1-based) -> lifetime total_stars required to open it.
70
+ # Slot k offers a tier-(k-1) job (slot 1 = 0-star recognition-only ... slot 4 = 3-star).
71
+ # Max tier is 3, so there are at most 4 slots. Slots 1 & 2 are free so the
72
+ # 1-star slot can bootstrap star income.
73
+ SLOT_UNLOCK_STARS: dict[int, int] = {1: 0, 2: 0, 3: 2, 4: 5}
74
+
75
+ # Property appraisal mode. True -> one VLM generation per property (more
76
+ # reliable when a request has several properties). False -> the legacy combined
77
+ # numbered yes/no block in a single generation. The KV-cache on/off switch is
78
+ # NOT here: it is a vision-layer concern (MiniCPMBackend(use_kv_cache=...)) so
79
+ # caching can be removed without touching game knobs.
80
+ SEPARATE_PROPERTY_CHECKS: bool = True
witch_wood/core/schema.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core immutable game records."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Property:
9
+ label: str # player-visible star label, e.g. "pointy"
10
+ predicate: str # hidden VLM yes/no prompt, e.g. "the blade has a sharp pointed tip"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Prereq:
15
+ request_id: str
16
+ min_stars: int
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Request:
21
+ id: str
22
+ visitor: str
23
+ flavor: str
24
+ object: str # recognition target
25
+ properties: tuple[Property, ...]
26
+ base_payment: int
27
+ requires: tuple["Prereq", ...] = () # storyline gates: each must be met
28
+ priority: int = 0 # higher = surfaces first within its tier
29
+
30
+ @property
31
+ def tier(self) -> int:
32
+ return len(self.properties)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Verdict:
37
+ """Raw, un-thresholded confidences in [0,1] from a VisionBackend.
38
+
39
+ Confidences are currently binary (yes->1.0 / no->0.0) from categorical
40
+ yes/no model queries. `description` is the model's free-form read of the
41
+ doodle (shown to the player for transparency/flavor; not scored).
42
+ """
43
+ recognition: float
44
+ properties: tuple[float, ...] # aligned with Request.properties order
45
+ description: str = ""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DeliverResult:
50
+ """Player-visible outcome of a delivery after thresholding."""
51
+ recognized: bool
52
+ stars: int
53
+ passes: tuple[bool, ...]
54
+ payment: int
55
+ description: str = "" # the model's free-form read, shown to the player
witch_wood/core/state.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/core/state.py
2
+ """Mutable game state container + initial setup."""
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass, field
7
+
8
+ from witch_wood.core import constants as C
9
+ from witch_wood.core.schema import Request
10
+ from witch_wood.engine.content import REQUEST_POOL, sample_day
11
+ from witch_wood.engine.progression import unlocked_slots
12
+
13
+
14
+ @dataclass
15
+ class GameState:
16
+ day: int
17
+ gold: int
18
+ mana: int
19
+ mana_max: int
20
+ slots: int # open slots (recomputed from total_stars each morning)
21
+ total_stars: int
22
+ debt: int
23
+ deadline_day: int
24
+ rent: int
25
+ status: str # "playing" | "won" | "lost" (terminal at deadline)
26
+ today: list[Request]
27
+ completed: tuple[str, ...] # ids delivered TODAY (reset nightly)
28
+ stars_by_request: dict[str, int] # lifetime best stars per request id
29
+ recognized: set[str] # ids that passed recognition at least once (gates storyline prereqs)
30
+ seen: set[str] # ids that have appeared on a guest list this session (retired whether or not served)
31
+ potions_bought_today: int # potions bought today (drives escalating price; reset nightly)
32
+ rng: random.Random = field(repr=False)
33
+ logs: list[str] = field(default_factory=list)
34
+ test_mode: bool = False # per-session harness flag (unlimited gold/mana, full pool, repeat delivery)
35
+
36
+
37
+ def new_game(seed: int = 0, test_mode: bool | None = None) -> GameState:
38
+ rng = random.Random(seed)
39
+ # Per-session override (hidden 5-reset gesture) falls back to the env constant.
40
+ tm = C.TEST_MODE if test_mode is None else test_mode
41
+ gold = C.TEST_GOLD if tm else C.STARTING_GOLD
42
+ mana_max = C.TEST_MANA_MAX if tm else C.STARTING_MANA_MAX
43
+ g = GameState(
44
+ day=1, gold=gold,
45
+ mana=mana_max, mana_max=mana_max,
46
+ slots=unlocked_slots(0), total_stars=0,
47
+ debt=C.DEBT, deadline_day=C.DEADLINE_DAY, rent=C.RENT,
48
+ status="playing",
49
+ today=[], completed=(), stars_by_request={}, recognized=set(), seen=set(),
50
+ potions_bought_today=0, rng=rng, test_mode=tm,
51
+ )
52
+ if tm:
53
+ # Test harness: expose the whole pool so any object can be drawn/tested,
54
+ # ignoring slot/tier/prereq gating. Repeat delivery is allowed in deliver().
55
+ g.today = list(REQUEST_POOL)
56
+ g.logs.append(
57
+ f"[TEST MODE] {len(g.today)} requests available; gold & mana unlimited."
58
+ )
59
+ else:
60
+ g.today = sample_day(g.total_stars, g.rng, g.stars_by_request, g.recognized, g.seen)
61
+ g.logs.append(f"Day 1: {len(g.today)} customers are waiting.")
62
+ return g
witch_wood/engine/__init__.py ADDED
File without changes
witch_wood/engine/content.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/engine/content.py
2
+ """Preset request pool, daily sampling, and the line-drawing content lint."""
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ from witch_wood.core.schema import Prereq, Property, Request
8
+ from witch_wood.engine.progression import unlocked_slots
9
+
10
+ # Attributes a monochrome line drawing CANNOT convey. Properties/predicates
11
+ # must not depend on these — every star must be earnable by drawing alone.
12
+ # (Decorative shape-based add-ons — a cat face, wings, a flower — are fine:
13
+ # they are parts/shapes, not unseeable attributes, so they pass.)
14
+ DISALLOWED_ATTRS: frozenset[str] = frozenset({
15
+ # colour
16
+ "red", "orange", "yellow", "green", "blue", "purple", "pink", "brown",
17
+ "black", "white", "grey", "gray", "colour", "color", "colored", "coloured",
18
+ # material / texture
19
+ "metallic", "metal", "wooden", "wood", "golden", "silver", "glass",
20
+ "furry", "fur", "fluffy", "smooth", "rough", "shiny", "glossy", "matte",
21
+ "transparent", "glowing",
22
+ # mass / temperature / other unseeable
23
+ "heavy", "light", "lightweight", "warm", "cold", "hot", "wet", "soft", "hard",
24
+ })
25
+
26
+
27
+ def lint_request(request: Request) -> None:
28
+ """Raise ValueError if any label/predicate references a disallowed attribute."""
29
+ for prop in request.properties:
30
+ text = f"{prop.label} {prop.predicate}".lower()
31
+ for word in DISALLOWED_ATTRS:
32
+ if word in text.split() or word in prop.label.lower().split("_"):
33
+ raise ValueError(
34
+ f"Request {request.id!r} property {prop.label!r} uses "
35
+ f"non-line-drawable attribute {word!r}: {prop.predicate!r}"
36
+ )
37
+
38
+
39
+ def _p(label: str, predicate: str) -> Property:
40
+ return Property(label=label, predicate=predicate)
41
+
42
+
43
+ # Preset pool. base_payment scales with tier. All predicates are line-inferable.
44
+ # tier = number of properties (= max stars); the maximum tier is 3. Slot k draws
45
+ # a tier-(k-1) request, so there are at most 4 slots (slot 1 = tier 0 ...
46
+ # slot 4 = tier 3).
47
+ #
48
+ # Requests are grouped by STORYLINE first (a chain linked by `requires`), then the
49
+ # standalone requests are grouped by tier. A storyline prerequisite must be
50
+ # DELIVERED AND RECOGNISED; its `min_stars` then caps how many property stars that
51
+ # recognised delivery needed (0 = recognition alone is enough).
52
+ REQUEST_POOL: tuple[Request, ...] = (
53
+ # ══ Storyline: Pete & the war ══
54
+ # sword → shield → (the kingdom builds a) castle → Pete returns wounded (crutch)
55
+ Request("sword", "Pete", "A blade, witch! I will gladly bleed for the glory of our kingdom!",
56
+ "sword", (), 30, priority=2),
57
+ Request("shield", "Pete", "Help me defend the crown's honour!",
58
+ "shield", (_p("has_sigil", "there is a crab on the shield"),), 50,
59
+ priority=1, requires=(Prereq("sword", 0),)),
60
+ Request("castle", "Lucian", "A monument grand enough to celebrate my glorious triumph!",
61
+ "castle", (_p("has_towers", "there are towers"),
62
+ _p("has_flag", "there is a flag on top"),
63
+ _p("has_gate", "the castle has a gate/entrance")), 200,
64
+ priority=1, requires=(Prereq("sword", 0), Prereq("shield", 1))),
65
+ Request("crutch", "Pete", "Just something to lean on...",
66
+ "crutch", (), 10, priority=1, requires=(Prereq("castle", 0),)),
67
+
68
+ # ══ Storyline: James & Della's wedding ══
69
+ # comb (James' gift) + wristwatch (Della's gift) → wedding_cake (their big day)
70
+ Request("wristwatch", "Della", "A gift for my boyfriend so he won't keep me waiting next time!",
71
+ "wristwatch", (_p("has_strap", "there is a watch strap"),
72
+ _p("has_clock_hands", "the watch has clock hands")), 40,
73
+ priority=1),
74
+ Request("comb", "James", "A secret gift for my girl. I want her to look beautiful when I ask the big question.",
75
+ "comb", (), 15, priority=1, requires=(Prereq("wristwatch", 1),)),
76
+ Request("wedding_cake", "James & Della", "Our big day is finally here!",
77
+ "wedding cake", (_p("has_tiers", "there are stacked tiers"),
78
+ _p("has_topper", "there is a heart on top"),
79
+ _p("has_decorations", "there are decorative swirls or dots")), 170,
80
+ requires=(Prereq("comb", 0), Prereq("wristwatch", 0)), priority=1),
81
+
82
+ # ══ Standalone — tier 0: recognition-only (no properties) ══
83
+ # Request("bread", "Gary", "Please, my kids haven't eaten a hot meal in two days.",
84
+ # "bread", (), 15),
85
+ Request("carrot", "Olaf", "The forest rabbits stole my nose while I was sleeping!",
86
+ "carrot", (), 15),
87
+ Request("tent", "Johan", "Can't wait for my next camping!",
88
+ "tent", (), 20),
89
+ Request("garlic", "Oliver", "I need something to hang by my front door. I saw glowing eyes outside last night!",
90
+ "garlic", (), 20),
91
+ Request("lollipop", "Rado", "Could you make something sweet? My little sister won't stop crying...",
92
+ "lollipop", (), 15),
93
+
94
+ # ══ Standalone — tier 1 ══
95
+ Request("pants", "Rex", "I can't go to town looking like this! I need a new pair!",
96
+ "pants", (_p("has_pocket", "the pants have pockets"),), 30),
97
+ Request("candle", "Kota", "The night is so long, and the darkness feels heavy without it.",
98
+ "candle", (_p("has_flame", "there is a flame on top"),), 24),
99
+ Request("fish", "Kitty", "Meow~",
100
+ "fish", (_p("has_fin", "there is at least one fin"),), 20),
101
+ Request("briefcase", "Anna", "It's my first day of work tomorrow!",
102
+ "briefcase", (_p("has_handle", "it has a handle"),), 30),
103
+ Request("dress", "Ella", "My sisters left me behind, but I must attend the ball!",
104
+ "dress", (_p("has_flower", "there are flower patterns on the dress"),), 30),
105
+
106
+ # ══ Standalone — tier 2 ══
107
+ Request("apple", "Melanion", "I must win her, no matter the cost!",
108
+ "apple", (_p("has_stem", "there is a small stem on top"),
109
+ _p("has_leaf", "there is a leaf by the stem")), 100),
110
+ Request("boat", "Robinson", "Time to sail back home.",
111
+ "boat", (_p("has_mast", "there is a vertical mast"),
112
+ _p("has_sail", "there is a triangular sail")), 80),
113
+ Request("cat_mug", "Michelle", "A cute mug to cheer my mornings!",
114
+ "mug", (_p("has_handle", "there is a handle on the side"),
115
+ _p("cat_face", "there is a cat face drawn on it")), 50),
116
+ Request("key", "Ivan", "Hopefully this one follows me well and I won't lose it again!",
117
+ "key", (_p("has_bow", "there is a round loop at one end"),
118
+ _p("has_wings", "the key has wings around the bow")), 80),
119
+ Request("teddy_bear", "Dave", "I want a new friend!",
120
+ "teddy bear", (_p("has_body", "the teddy bear has a body"),
121
+ _p("has_bow_tie", "there is a bow tie around the neck")), 60),
122
+
123
+ # ══ Standalone — tier 3 ══
124
+ Request("christmas_tree", "Ethan", "I want the most fabulous Christmas tree in town!",
125
+ "Christmas tree", (_p("has_boxes", "there are gift boxes beneath the tree."),
126
+ _p("has_decorations", "there are decorative balls on the tree"),
127
+ _p("has_star", "there is a star on top")), 120),
128
+ Request("house", "Ben", "We've been wandering the wild roads for so long.",
129
+ "house", (_p("has_roof", "there is a triangular roof"),
130
+ _p("has_window", "there is a window"),
131
+ _p("has_door", "there is a door")), 120),
132
+ Request("teapot", "Delilah", "The high duchess is coming for my tea reception, and everything must be perfect!",
133
+ "teapot", (_p("has_handle", "there is a handle on the side"),
134
+ _p("has_lid", "there is a lid on top"),
135
+ _p("has_leaf", "there is a leaf drawn on it")), 100),
136
+ )
137
+
138
+
139
+ def validate_pool() -> None:
140
+ """Lint every request; verify every `requires` references an existing id."""
141
+ ids = {r.id for r in REQUEST_POOL}
142
+ for request in REQUEST_POOL:
143
+ lint_request(request)
144
+ for prereq in request.requires:
145
+ if prereq.request_id not in ids:
146
+ raise ValueError(
147
+ f"Request {request.id!r} requires unknown id {prereq.request_id!r}"
148
+ )
149
+
150
+
151
+ def available_requests(
152
+ stars_by_request: dict[str, int],
153
+ recognized: set[str] | None = None,
154
+ seen: set[str] | None = None,
155
+ ) -> list[Request]:
156
+ """Requests eligible by STORYLINE rules (tier-agnostic): prerequisites met
157
+ AND not yet delivered AND not yet shown this session (once-per-session).
158
+
159
+ `stars_by_request` is the lifetime best stars per delivered request id (key
160
+ present == delivered at least once, pass or fail). `recognized` is the set of
161
+ ids whose delivery PASSED recognition. A prerequisite is met only when it was
162
+ delivered AND recognised; `min_stars` then caps how many property stars that
163
+ recognised delivery needed (0 = recognition alone). `seen` is the set of ids
164
+ that have already appeared on a guest list this session: a guest who was shown
165
+ but never served is retired too, so they never re-appear. Slot/tier gating is
166
+ applied separately in `sample_day`."""
167
+ recognized = recognized or set()
168
+ seen = seen or set()
169
+
170
+ def prereq_met(p: Prereq) -> bool:
171
+ return (
172
+ p.request_id in recognized
173
+ and stars_by_request.get(p.request_id, 0) >= p.min_stars
174
+ )
175
+
176
+ def ok(r: Request) -> bool:
177
+ if r.id in stars_by_request or r.id in seen: # retired: delivered OR already shown
178
+ return False
179
+ return all(prereq_met(p) for p in r.requires)
180
+
181
+ return [r for r in REQUEST_POOL if ok(r)]
182
+
183
+
184
+ def sample_day(
185
+ total_stars: int,
186
+ rng: random.Random,
187
+ stars_by_request: dict[str, int] | None = None,
188
+ recognized: set[str] | None = None,
189
+ seen: set[str] | None = None,
190
+ ) -> list[Request]:
191
+ """Fill the open slots. Slot k (1..unlocked_slots) draws one eligible
192
+ tier-(k-1) request; a slot with no eligible request is left empty."""
193
+ avail = available_requests(stars_by_request or {}, recognized or set(), seen or set())
194
+ day: list[Request] = []
195
+ chosen: set[str] = set()
196
+ for slot in range(1, unlocked_slots(total_stars) + 1):
197
+ tier = slot - 1
198
+ pool = [r for r in avail if r.tier == tier and r.id not in chosen]
199
+ if pool:
200
+ top = max(r.priority for r in pool) # highest non-empty priority group
201
+ group = [r for r in pool if r.priority == top]
202
+ pick = rng.choice(group)
203
+ day.append(pick)
204
+ chosen.add(pick.id)
205
+ return day
witch_wood/engine/economy.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/engine/economy.py
2
+ """Pure-ish mutations: shop purchases, nightfall, win/lose. Operate on GameState."""
3
+ from __future__ import annotations
4
+
5
+ from witch_wood.core import constants as C
6
+ from witch_wood.core.state import GameState
7
+
8
+
9
+ def _spend(g: GameState, cost: int) -> None:
10
+ if g.gold < cost:
11
+ raise ValueError(f"not enough gold: need {cost}, have {g.gold}")
12
+ g.gold -= cost
13
+
14
+
15
+ def current_potion_price(g: GameState) -> int:
16
+ """Today's next-potion price: base plus a step per potion already bought."""
17
+ return C.POTION_COST + C.POTION_COST_STEP * g.potions_bought_today
18
+
19
+
20
+ def buy_potion(g: GameState) -> None:
21
+ price = current_potion_price(g)
22
+ _spend(g, price)
23
+ g.mana += C.POTION_MANA # temporary; cleared at next morning refill
24
+ g.potions_bought_today += 1 # next potion today costs one step more
25
+ g.logs.append(f"Bought a Mana Potion for {price}g. Mana increased by {C.POTION_MANA}.")
26
+
27
+
28
+ def buy_mana_upgrade(g: GameState) -> None:
29
+ _spend(g, C.MANA_UPGRADE_COST)
30
+ g.mana_max += C.MANA_UPGRADE_AMOUNT
31
+ g.logs.append(f"Crystal upgraded for {C.MANA_UPGRADE_COST}g. Max Mana increased by {C.MANA_UPGRADE_AMOUNT}.")
32
+
33
+
34
+ from witch_wood.engine.content import sample_day
35
+ from witch_wood.engine.progression import unlocked_slots
36
+
37
+
38
+ def _advance_morning(g: GameState) -> None:
39
+ g.day += 1
40
+ g.mana = g.mana_max # refill; discards potion overflow
41
+ g.slots = unlocked_slots(g.total_stars) # newly-earned slots open this morning
42
+ if not g.test_mode:
43
+ # Retire everyone who appeared today: an unserved guest does NOT re-appear
44
+ # later this session (served ones are already retired via stars_by_request).
45
+ g.seen.update(r.id for r in g.today)
46
+ g.today = sample_day(g.total_stars, g.rng, g.stars_by_request, g.recognized, g.seen)
47
+ g.completed = ()
48
+ g.potions_bought_today = 0 # potion price resets each morning
49
+ g.logs.append(f"Day {g.day}: {len(g.today)} customers are waiting.")
50
+
51
+
52
+ def end_day(g: GameState) -> None:
53
+ """Pay rent, then resolve the deadline. The run ENDS at the deadline."""
54
+ # 1) Rent first — failing rent is an immediate loss.
55
+ if g.gold < g.rent:
56
+ g.status = "lost"
57
+ g.logs.append(f"Failed to pay rent of {g.rent}g.")
58
+ return
59
+
60
+ rent_paid = g.rent
61
+ g.gold -= g.rent
62
+ g.logs.append(f"Paid {rent_paid}g in rent.")
63
+
64
+ # 2) Deadline: collect the debt in full -> win or lose, then STOP (no new day).
65
+ if g.day >= g.deadline_day:
66
+ if g.gold >= g.debt:
67
+ g.gold -= g.debt
68
+ g.logs.append(f"Paid the remaining debt of {g.debt}g.")
69
+ g.debt = 0
70
+ g.status = "won"
71
+ else:
72
+ g.status = "lost"
73
+ g.logs.append(f"Failed to pay the debt of {g.debt}g.")
74
+ return
75
+
76
+ # 3) Otherwise advance to the next morning.
77
+ _advance_morning(g)
witch_wood/engine/game.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/engine/game.py
2
+ """Orchestrates a delivery against GameState using a VisionBackend."""
3
+ from __future__ import annotations
4
+
5
+ from witch_wood.core import constants as C
6
+ from witch_wood.core.schema import DeliverResult, Request, Verdict
7
+ from witch_wood.core.state import GameState
8
+ from witch_wood.engine import scoring
9
+ from witch_wood.engine.content import available_requests
10
+ from witch_wood.engine.progression import unlocked_slots
11
+ from witch_wood.vision.base import VisionBackend
12
+
13
+
14
+ def _find_request(g: GameState, request_id: str) -> Request:
15
+ for r in g.today:
16
+ if r.id == request_id:
17
+ return r
18
+ raise ValueError(f"no such request today: {request_id!r}")
19
+
20
+
21
+ def deliver(
22
+ g: GameState,
23
+ request_id: str,
24
+ image,
25
+ spent_length: int,
26
+ backend: VisionBackend | None = None,
27
+ gen=None,
28
+ gpu_pipeline=None,
29
+ ) -> tuple[DeliverResult, object | None]:
30
+ """Resolve a delivery. Returns (result, render).
31
+
32
+ Pipeline (conceptually):
33
+ 1. Appraise the DOODLE — recognition (gate) + per-property checks.
34
+ 2. If recognition passes, generate a polished image (the prompt injects the
35
+ object AND the doodle's passed/failed properties, so the generator is
36
+ told what to draw).
37
+ 3. Re-check the properties on that RENDER and score from those — stars
38
+ reflect what's actually shown, catching properties the generator dropped.
39
+ Doodle-PASSED properties are trusted (scored as-is) so the player isn't
40
+ punished when the generator quietly drops a feature they drew; only
41
+ doodle-FAILED ones are re-asked on the render.
42
+
43
+ Two execution paths produce the (verdict, render) pair:
44
+ * `gpu_pipeline` (real Space): all three steps run in ONE @spaces.GPU
45
+ acquisition; `gpu_pipeline(image, request) -> (verdict, render)` and the
46
+ returned verdict already reflects the render re-check.
47
+ * split path (MOCK / tests / no-GPU): `backend.appraise` then, if `gen` is
48
+ given, `gen(image, request, doodle_passes) -> render` and
49
+ `backend.check_properties` re-checks the render.
50
+ If neither produces a render (gen disabled, recognition fails, or generation
51
+ fails), scoring falls back to the doodle's own property checks.
52
+ """
53
+ if request_id in g.completed and not g.test_mode:
54
+ raise ValueError(f"request {request_id!r} already delivered")
55
+ if spent_length > g.mana:
56
+ raise ValueError(f"not enough mana: spent {spent_length}, have {g.mana}")
57
+ request = _find_request(g, request_id)
58
+
59
+ g.mana -= spent_length # ink is spent regardless of verdict
60
+
61
+ if gpu_pipeline is not None:
62
+ # Fused fast path: one GPU acquisition for appraise + image-gen + render
63
+ # re-check. The verdict already incorporates the render re-check.
64
+ verdict, render = gpu_pipeline(image, request)
65
+ else:
66
+ verdict = backend.appraise(image, request) # recognition + doodle properties
67
+ render = None
68
+ if gen is not None and verdict.recognition >= C.T_RECOGNIZE:
69
+ doodle_passes = tuple(c >= C.T_PROPERTY for c in verdict.properties)
70
+ render = gen(image, request, doodle_passes)
71
+ if render is not None and request.properties:
72
+ # Score the RENDER, not the doodle: stars reflect the shown image.
73
+ render_props = backend.check_properties(render, request)
74
+ verdict = Verdict(verdict.recognition, render_props, verdict.description)
75
+
76
+ result = scoring.resolve(verdict, request)
77
+
78
+ g.gold += result.payment
79
+ g.total_stars += result.stars
80
+ g.completed = (*g.completed, request_id)
81
+ # Lifetime best stars per request (key always created — even at 0 stars —
82
+ # so once-per-session retirement works; see content.available_requests).
83
+ g.stars_by_request[request_id] = max(
84
+ g.stars_by_request.get(request_id, 0), result.stars
85
+ )
86
+ # Recognition is what gates storyline prerequisites (a delivery that failed
87
+ # recognition banks 0 stars but must NOT satisfy a min_stars=0 prereq).
88
+ if result.recognized:
89
+ g.recognized.add(request_id)
90
+
91
+ if result.recognized:
92
+ g.logs.append(f"Successfully transmuted the {request.object}! Received {result.payment}g.")
93
+ else:
94
+ g.logs.append(f"Failed to transmute the {request.object}. Received {result.payment}g.")
95
+
96
+ # Recompute slots and sample a request for each newly opened slot immediately
97
+ old_slots = g.slots
98
+ new_slots = unlocked_slots(g.total_stars)
99
+ if new_slots > old_slots:
100
+ g.slots = new_slots
101
+ avail = available_requests(g.stars_by_request, g.recognized, g.seen)
102
+ chosen = {r.id for r in g.today}
103
+ for slot in range(old_slots + 1, new_slots + 1):
104
+ tier = slot - 1
105
+ pool = [r for r in avail if r.tier == tier and r.id not in chosen]
106
+ if pool:
107
+ top = max(r.priority for r in pool)
108
+ group = [r for r in pool if r.priority == top]
109
+ pick = g.rng.choice(group)
110
+ g.today.append(pick)
111
+ chosen.add(pick.id)
112
+ g.logs.append(f"New customer {pick.visitor} is coming.")
113
+
114
+ return result, render
115
+
116
+
117
+ def reset_drawing(g: GameState, spent_length: int) -> int:
118
+ """Abandon the in-progress (undelivered) drawing. Refund
119
+ `CANVAS_RESET_REFUND` of its spent ink; charge only the remainder against
120
+ the day's mana. Returns the new remaining mana (re-arms the canvas budget)."""
121
+ penalty = round((1 - C.CANVAS_RESET_REFUND) * spent_length)
122
+ g.mana = max(0, g.mana - penalty)
123
+ return g.mana
witch_wood/engine/progression.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/engine/progression.py
2
+ """Lifetime total_stars -> number of open slots (slot k offers tier k-1)."""
3
+ from __future__ import annotations
4
+
5
+ from witch_wood.core import constants as C
6
+
7
+
8
+ def unlocked_slots(total_stars: int) -> int:
9
+ """How many slots are open. Slot k (1-based) presents a tier-(k-1) job."""
10
+ return sum(1 for need in C.SLOT_UNLOCK_STARS.values() if total_stars >= need)
witch_wood/engine/scoring.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/engine/scoring.py
2
+ """Turn a raw Verdict into a player-visible DeliverResult.
3
+
4
+ Payment model (all fractions are tunable constants — never hard-coded here):
5
+ - recognition FAILS -> flat `RECOGNITION_FAIL_PAYOUT` of base, stars ignored.
6
+ - recognition PASSES -> `RECOGNITION_PASS_BASE + STAR_BONUS_PER_STAR * stars` of base.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from witch_wood.core import constants as C
11
+ from witch_wood.core.schema import DeliverResult, Request, Verdict
12
+
13
+
14
+ def resolve(
15
+ verdict: Verdict,
16
+ request: Request,
17
+ t_recognize: float = C.T_RECOGNIZE,
18
+ t_property: float = C.T_PROPERTY,
19
+ ) -> DeliverResult:
20
+ total = len(request.properties)
21
+ recognized = verdict.recognition >= t_recognize
22
+ if not recognized:
23
+ payment = round(request.base_payment * C.RECOGNITION_FAIL_PAYOUT)
24
+ return DeliverResult(
25
+ recognized=False, stars=0,
26
+ passes=tuple(False for _ in range(total)), payment=payment,
27
+ description=verdict.description,
28
+ )
29
+ passes = tuple(conf >= t_property for conf in verdict.properties)
30
+ stars = sum(passes)
31
+ frac = C.RECOGNITION_PASS_BASE + C.STAR_BONUS_PER_STAR * stars
32
+ payment = round(request.base_payment * frac)
33
+ return DeliverResult(
34
+ recognized=True, stars=stars, passes=passes, payment=payment,
35
+ description=verdict.description,
36
+ )
witch_wood/gen/__init__.py ADDED
File without changes
witch_wood/gen/pipeline.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fused delivery pipeline: appraise + image-gen + render re-check in ONE
2
+ `@spaces.GPU` acquisition.
3
+
4
+ Each delivery used to make three separate ZeroGPU entries (doodle appraisal,
5
+ diffusion, render re-check), each paying its own queue wait + worker attach +
6
+ weight streaming. Both heavy models (MiniCPM-V and the SDXL-Flash pipe) are
7
+ module-global and eagerly loaded at Space startup, so a single decorated
8
+ function can drive both and return a plain `(Verdict, PIL.Image | None)` — both
9
+ picklable, no CUDA tensors crossing the worker boundary.
10
+
11
+ The split path (separate calls) still exists for MOCK / tests / CUDA-less dev;
12
+ this module is imported only on the real GPU Space (see ui/server.py).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import spaces
17
+
18
+ from witch_wood.core import constants as C
19
+ from witch_wood.core.schema import Request, Verdict
20
+ from witch_wood.gen import scribble
21
+ from witch_wood.vision import minicpm
22
+
23
+
24
+ def _duration(image, request: Request, gen_opts: dict) -> int:
25
+ """GPU seconds reserved for the quota pre-check, CAPPED at 30s.
26
+
27
+ The cap is the point: the anonymous/free ZeroGPU tier has a per-call duration
28
+ limit, and declaring more fails with 'ZeroGPU illegal duration' — locking
29
+ logged-out visitors out entirely. 30s keeps every delivery within that tier
30
+ so anyone can play. It's also comfortable on compute: the Phase-1 benchmark
31
+ measured ~4.4s warm for a 3-property delivery (appraise ~2.6 + gen ~1.8 +
32
+ recheck, which the skip-passed opt often elides); both models are eager-loaded
33
+ at Space startup so workers are usually warm, leaving headroom under 30s for
34
+ cold weight-streaming. Lighter deliveries reserve less (shorter durations rank
35
+ higher in the queue)."""
36
+ n = len(request.properties)
37
+ if not gen_opts.get("enabled"):
38
+ return min(30, 18 + 2 * n)
39
+ return 30
40
+
41
+
42
+ @spaces.GPU(duration=_duration)
43
+ def run_delivery_gpu(image, request: Request, gen_opts: dict):
44
+ """Appraise the doodle, optionally render it, re-check the render — all in
45
+ one GPU call. Returns `(verdict, render)` where `verdict` already reflects
46
+ the render re-check when a render was produced.
47
+
48
+ `gen_opts` is the tuning-panel payload (`_gen_options`): {enabled, steps,
49
+ guidance, controlnet_scale, seed, style_suffix, base_negative}. When
50
+ `enabled` is false (or recognition fails) only the appraisal runs and the
51
+ render is `None` — scoring falls back to the doodle, exactly as a no-gen
52
+ delivery does today."""
53
+ model, processor = minicpm._ensure_loaded()
54
+ verdict = minicpm._appraise(model, processor, image, request, use_kv_cache=True)
55
+
56
+ render = None
57
+ if gen_opts.get("enabled") and verdict.recognition >= C.T_RECOGNIZE:
58
+ passes = tuple(c >= C.T_PROPERTY for c in verdict.properties)
59
+ try:
60
+ prompt, negative = scribble.build_prompts(
61
+ request.object, request.properties, passes,
62
+ gen_opts["style_suffix"], gen_opts["base_negative"],
63
+ )
64
+ print(f"[image-gen] {request.id}: doodle_passes={passes}\n"
65
+ f" prompt = {prompt!r}\n"
66
+ f" negative= {negative!r}", flush=True)
67
+ render = scribble._generate(
68
+ scribble._ensure_loaded(), image, prompt, negative,
69
+ gen_opts["steps"], gen_opts["guidance"],
70
+ gen_opts["controlnet_scale"], gen_opts["seed"],
71
+ )
72
+ except Exception as gen_err: # noqa: BLE001 — fall back to the doodle verdict
73
+ print(f"[image-gen] generation failed: {gen_err}", flush=True)
74
+ render = None
75
+
76
+ if render is not None and request.properties:
77
+ # Re-check only the doodle-failed props on the render; passed props
78
+ # are trusted to carry through (scored 1.0). See _check_properties.
79
+ render_props = minicpm._check_properties(
80
+ model, processor, render, request, passes
81
+ )
82
+ verdict = Verdict(verdict.recognition, render_props, verdict.description)
83
+
84
+ return verdict, render
witch_wood/gen/scribble.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scribble -> polished image generator (ZeroGPU-ready).
2
+
3
+ Second GPU stage of a delivery: once MiniCPM-V passes recognition, the player's
4
+ doodle is turned into a polished image by a ControlNet (xinsir scribble-sdxl)
5
+ on top of the few-step SDXL-Flash backbone. The doodle drives the ControlNet;
6
+ the *text* prompt is built from the per-property pass/fail result (NOT from
7
+ MiniCPM's free-form description) — passed predicates steer the positive prompt,
8
+ failed ones the negative prompt (see `build_prompts`).
9
+
10
+ Ported from the `experimental/image-gen-spike` branch
11
+ (`scripts/scribble_gen_spike.py`), adapted for ZeroGPU:
12
+
13
+ * The pipe is loaded ONCE at module scope (registered for ZeroGPU device
14
+ migration); the real GPU is only attached inside `@spaces.GPU generate_image`.
15
+ Heavy torch/diffusers imports stay inside the load/gen functions so
16
+ `build_prompts` (pure) imports cleanly on a CUDA-less box (the pipe itself
17
+ requires CUDA and does not run on MPS).
18
+ * `generate_image` returns a PIL Image (picklable; never a CUDA tensor) so it
19
+ crosses the worker-process boundary cleanly.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ import spaces
26
+
27
+ from witch_wood.core import constants as C
28
+ from witch_wood.core.schema import Property
29
+
30
+ CONTROLNET_ID = "xinsir/controlnet-scribble-sdxl-1.0"
31
+ SDXL_BASE_ID = "sd-community/sdxl-flash"
32
+ VAE_ID = "madebyollin/sdxl-vae-fp16-fix"
33
+
34
+ # Prompt scaffolding. Final form:
35
+ # cinematic photo of <object>: <passed_1>; <passed_2>. <STYLE_SUFFIX>
36
+ # The style/negative strings and the numeric defaults are the single source of
37
+ # truth in witch_wood/core/constants.py (the server exposes them to the tuning
38
+ # panel, which can override any of them per-delivery). Re-exported here so the
39
+ # existing importers (tests, server _gen_options) keep their names.
40
+ STYLE_SUFFIX = C.STYLE_SUFFIX
41
+ BASE_NEGATIVE = C.BASE_NEGATIVE
42
+ DEFAULT_STEPS = C.GEN_STEPS
43
+ DEFAULT_GUIDANCE = C.GEN_GUIDANCE
44
+ DEFAULT_CNET_SCALE = C.GEN_CNET_SCALE
45
+ DEFAULT_SEED = C.GEN_SEED
46
+
47
+ # Control image fed to the ControlNet. Our canvas is black ink on white; the
48
+ # xinsir scribble model is trained on thin white lines on black, so we invert.
49
+ # The control size also sets the OUTPUT size (SDXL ControlNet renders at the
50
+ # control resolution). Kept below 448x448 so the generated image is a single
51
+ # MiniCPM-V vision tile and the property re-check on the render runs fast.
52
+ # (SDXL-Flash fine-detail at this size is modest; raise if features render poorly.)
53
+ _CONTROL_SIZE = 384
54
+
55
+
56
+ def build_prompts(
57
+ object_name: str,
58
+ properties: tuple[Property, ...],
59
+ passes: tuple[bool, ...],
60
+ style_suffix: str | None = None,
61
+ base_negative: str | None = None,
62
+ ) -> tuple[str, str]:
63
+ """Construct (prompt, negative_prompt) from the property check result.
64
+
65
+ Passed property predicates steer the positive prompt; failed ones are
66
+ appended to the negative prompt. A request with no passed properties yields
67
+ a clean `cinematic photo of <object>. <style>` with no dangling `:`/`;`.
68
+
69
+ `style_suffix` / `base_negative` override the constants (the tuning panel
70
+ passes live-edited strings); `None` falls back to C.STYLE_SUFFIX/BASE_NEGATIVE.
71
+ """
72
+ style = C.STYLE_SUFFIX if style_suffix is None else style_suffix
73
+ base_neg = C.BASE_NEGATIVE if base_negative is None else base_negative
74
+ passed = [p.predicate for p, ok in zip(properties, passes) if ok]
75
+ failed = [p.predicate for p, ok in zip(properties, passes) if not ok]
76
+ head = f"cinematic photo of {object_name}"
77
+ body = (": " + "; ".join(passed)) if passed else ""
78
+ prompt = f"{head}{body}{style}"
79
+ negative_properties = ((("; ".join(failed)) + ". ") if failed else "")
80
+ negative = negative_properties + base_neg
81
+ return prompt, negative
82
+
83
+
84
+ # --- Model loading (module scope, ZeroGPU-registered) -----------------------
85
+
86
+ _PIPE = None
87
+
88
+
89
+ def _ensure_loaded():
90
+ """Load SDXL-Flash + scribble ControlNet once into a module global.
91
+
92
+ On ZeroGPU `import spaces` monkey-patches torch so `.to("cuda")` at module
93
+ scope registers the weights for device migration; the real GPU is only
94
+ attached inside the `@spaces.GPU` call that runs generation.
95
+ """
96
+ global _PIPE
97
+ if _PIPE is None:
98
+ import torch
99
+ from diffusers import (
100
+ AutoencoderKL,
101
+ ControlNetModel,
102
+ EulerAncestralDiscreteScheduler,
103
+ StableDiffusionXLControlNetPipeline,
104
+ )
105
+
106
+ controlnet = ControlNetModel.from_pretrained(
107
+ CONTROLNET_ID, torch_dtype=torch.float16
108
+ )
109
+ vae = AutoencoderKL.from_pretrained(VAE_ID, torch_dtype=torch.float16)
110
+ pipe = StableDiffusionXLControlNetPipeline.from_pretrained(
111
+ SDXL_BASE_ID, controlnet=controlnet, vae=vae, torch_dtype=torch.float16
112
+ ).to("cuda")
113
+ pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(
114
+ pipe.scheduler.config
115
+ )
116
+ _PIPE = pipe
117
+ return _PIPE
118
+
119
+
120
+ def _control_image(image):
121
+ """Resize the doodle to the control size and invert to white-on-black."""
122
+ from PIL import ImageOps
123
+
124
+ ctrl = image.convert("RGB").resize((_CONTROL_SIZE, _CONTROL_SIZE))
125
+ return ImageOps.invert(ctrl)
126
+
127
+
128
+ def _gen_duration(image, prompt, negative, steps, guidance, cnet_scale, seed) -> int:
129
+ """Estimate GPU seconds for the quota pre-check. SDXL-Flash is few-step;
130
+ keep this small (ZeroGPU reserves the full declared duration up front and
131
+ ranks shorter durations higher) with headroom for cold weight streaming."""
132
+ return int(steps) + 15
133
+
134
+
135
+ def _generate(pipe, image, prompt, negative, steps, guidance, cnet_scale, seed):
136
+ """Run the diffusion on an already-loaded pipe. Plain helper (no GPU
137
+ decorator) so it can run inside another module's `@spaces.GPU` entry (the
138
+ fused delivery pipeline) without nesting GPU acquisitions."""
139
+ import random as _random
140
+ import torch
141
+
142
+ resolved_seed = _random.randint(0, 2**32 - 1) if int(seed) == -1 else int(seed)
143
+ control = _control_image(image)
144
+ result = pipe(
145
+ prompt=prompt,
146
+ negative_prompt=negative,
147
+ image=control,
148
+ num_inference_steps=int(steps),
149
+ guidance_scale=float(guidance),
150
+ controlnet_conditioning_scale=float(cnet_scale),
151
+ generator=torch.Generator(device="cuda").manual_seed(resolved_seed),
152
+ )
153
+ return result.images[0]
154
+
155
+
156
+ @spaces.GPU(duration=_gen_duration)
157
+ def generate_image(image, prompt, negative, steps, guidance, cnet_scale, seed):
158
+ """ZeroGPU entry point: turn a doodle into a polished image.
159
+
160
+ The pipe lives in a module global (never pickled); only the PIL doodle and
161
+ the prompt strings cross into the worker, and a PIL Image crosses back out.
162
+ """
163
+ return _generate(_ensure_loaded(), image, prompt, negative,
164
+ steps, guidance, cnet_scale, seed)
165
+
166
+
167
+ # Trigger the (slow) load eagerly at import so the cold-start cost is paid once
168
+ # at Space startup rather than on the first player's delivery. Gated on the
169
+ # ZeroGPU env so a local CUDA-less import (mac dev / pure prompt tests) does NOT
170
+ # download multi-GB weights or touch CUDA — generate_image lazily loads on its
171
+ # first call there (and the pipe only actually runs on the GPU Space anyway).
172
+ if os.environ.get("SPACES_ZERO_GPU"):
173
+ _ensure_loaded()
witch_wood/ui/__init__.py ADDED
File without changes
witch_wood/ui/app.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inkwitch — Gradio game UI."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import io
6
+ import os
7
+
8
+ import gradio as gr
9
+ from PIL import Image
10
+
11
+ from witch_wood.core.state import new_game
12
+ from witch_wood.engine import economy
13
+ from witch_wood.engine.game import deliver, reset_drawing
14
+ from witch_wood.ui.canvas import CANVAS_HTML, CANVAS_JS
15
+ from witch_wood.ui.format import request_card_md, result_md, status_md
16
+ from witch_wood.vision import MockBackend
17
+
18
+ MOCK = os.environ.get("WITCH_WOOD_MOCK") == "1"
19
+
20
+ _BACKEND = None
21
+
22
+
23
+ def _make_backend():
24
+ if MOCK:
25
+ return MockBackend(seed=0)
26
+ from witch_wood.vision.minicpm import MiniCPMBackend
27
+ # KV-cache reuse saves ~25% per appraise in separate mode (measured) by not
28
+ # re-encoding the image on each property call. See scripts/kv_cache_spike.py.
29
+ return MiniCPMBackend(use_kv_cache=True)
30
+
31
+
32
+ def _get_backend():
33
+ global _BACKEND
34
+ if _BACKEND is None:
35
+ _BACKEND = _make_backend()
36
+ return _BACKEND
37
+
38
+
39
+ def _decode_png(data_url: str) -> Image.Image:
40
+ _, _, b64 = data_url.partition(",")
41
+ rgba = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGBA")
42
+ flat = Image.new("RGB", rgba.size, "white")
43
+ flat.paste(rgba, mask=rgba.split()[3])
44
+ return flat
45
+
46
+
47
+ def _visitor_choices(g):
48
+ return [(f"{r.visitor}: {r.object} ({r.base_payment}g)", r.id)
49
+ for r in g.today if r.id not in g.completed]
50
+
51
+
52
+ def start_game():
53
+ _get_backend() # load model now; no-op on subsequent calls
54
+ g = new_game(seed=int.from_bytes(os.urandom(2), "big"))
55
+ first = g.today[0]
56
+ choices = _visitor_choices(g)
57
+ return (
58
+ g, g.mana, g.mana_max, status_md(g),
59
+ gr.update(choices=choices, value=first.id),
60
+ request_card_md(first), "Draw the object, then Deliver.",
61
+ )
62
+
63
+
64
+ def select_visitor(g, request_id):
65
+ if g is None or request_id is None:
66
+ return "", gr.update()
67
+ req = next((r for r in g.today if r.id == request_id), None)
68
+ if req is None:
69
+ return "", gr.update()
70
+ return request_card_md(req), gr.update()
71
+
72
+
73
+ def do_deliver(png_data_url, spent_str, g, request_id):
74
+ if not png_data_url or g is None:
75
+ return g, g.mana if g else 0, g.mana_max if g else 0, status_md(g) if g else "", "Draw something first.", gr.update()
76
+ image = _decode_png(png_data_url)
77
+ spent = int(spent_str or "0")
78
+ req = next((r for r in g.today if r.id == request_id), None)
79
+ if req is None:
80
+ return g, g.mana, g.mana_max, status_md(g), "No request selected.", gr.update()
81
+ try:
82
+ result, _ = deliver(g, request_id, image=image, spent_length=spent, backend=_get_backend())
83
+ except ValueError as e:
84
+ return g, g.mana, g.mana_max, status_md(g), f"⚠️ {e}", gr.update()
85
+ remaining = _visitor_choices(g)
86
+ return (
87
+ g, g.mana, g.mana_max, status_md(g), result_md(req, result),
88
+ gr.update(choices=remaining, value=(remaining[0][1] if remaining else None)),
89
+ )
90
+
91
+
92
+ def do_reset(spent_str, g):
93
+ if g is None:
94
+ return g, 0, 0, "", "Start a game first."
95
+ spent = int(spent_str or "0")
96
+ try:
97
+ reset_drawing(g, spent)
98
+ except ValueError as e:
99
+ return g, g.mana, g.mana_max, status_md(g), f"⚠️ {e}"
100
+ return g, g.mana, g.mana_max, status_md(g), "🧽 Canvas reset — most of that ink refunded."
101
+
102
+
103
+ def do_end_day(g):
104
+ if g is None:
105
+ return g, 0, 0, "", "Start a game first.", gr.update()
106
+ economy.end_day(g)
107
+ if g.status == "won":
108
+ return (g, g.mana, g.mana_max, status_md(g), "🎉 **Debt paid — you win! The run is over.**",
109
+ gr.update(choices=[]))
110
+ if g.status == "lost":
111
+ reason = ("⏰ **Deadline reached short of the debt — game over.**"
112
+ if g.day >= g.deadline_day else "💀 **Bankrupt — game over.**")
113
+ return g, g.mana, g.mana_max, status_md(g), reason, gr.update(choices=[])
114
+ nxt = _visitor_choices(g)
115
+ return (g, g.mana, g.mana_max, status_md(g), "A new day dawns.",
116
+ gr.update(choices=nxt, value=(nxt[0][1] if nxt else None)))
117
+
118
+
119
+ def buy(g, kind):
120
+ if g is None:
121
+ return g, 0, 0, "", "Start a game first."
122
+ try:
123
+ {"potion": economy.buy_potion, "mana": economy.buy_mana_upgrade}[kind](g)
124
+ msg = f"Bought {kind}."
125
+ except ValueError as e:
126
+ msg = str(e)
127
+ return g, g.mana, g.mana_max, status_md(g), msg
128
+
129
+
130
+ def build_demo() -> gr.Blocks:
131
+ # Reset lazy backend when building demo so MOCK env var is re-evaluated
132
+ global _BACKEND, MOCK
133
+ MOCK = os.environ.get("WITCH_WOOD_MOCK") == "1"
134
+ _BACKEND = None
135
+
136
+ with gr.Blocks(title="Inkwitch") as demo:
137
+ state = gr.State(None)
138
+ # gr.State never sends its value to the JS frontend (always null on the
139
+ # client). Use a hidden gr.Number so the updated mana value is actually
140
+ # serialised and delivered to the .then() JS callbacks.
141
+ mana_state = gr.Number(value=0, visible=False)
142
+ mana_max_state = gr.Number(value=0, visible=False)
143
+ gr.Markdown("# 🧙‍♀️ Inkwitch\nDraw to pay your debt before the deadline.")
144
+ status = gr.Markdown("")
145
+ with gr.Row():
146
+ with gr.Column():
147
+ visitor = gr.Radio(label="Visitors", choices=[], interactive=True)
148
+ card = gr.Markdown("")
149
+ gr.HTML(CANVAS_HTML)
150
+ png_field = gr.Textbox(visible=False, elem_id="iw-png")
151
+ spent_field = gr.Textbox(visible=False, elem_id="iw-spent")
152
+ with gr.Row():
153
+ deliver_btn = gr.Button("Deliver ✒️", variant="primary")
154
+ reset_btn = gr.Button("Reset canvas 🧽")
155
+ with gr.Column():
156
+ outcome = gr.Markdown("")
157
+ with gr.Row():
158
+ potion_btn = gr.Button("Potion 🧪")
159
+ mana_btn = gr.Button("+Max mana 🔮")
160
+ end_btn = gr.Button("End day 🌙", variant="secondary")
161
+ start_btn = gr.Button("New game ▶️")
162
+
163
+ demo.load(None, None, None, js=CANVAS_JS)
164
+
165
+ # Combined JS: re-runs the canvas init (idempotent — _ready guard prevents
166
+ # double-setup) then sets the budget. Needed because demo.load may fire
167
+ # before gr.HTML renders the canvas into the DOM, leaving window.inkwitch
168
+ # undefined. By "New game" click time, the DOM is settled.
169
+ _init_and_arm = f"(m, mx) => {{ ({CANVAS_JS})(); window.inkwitch.setMana(Number(m), Number(mx)); }}"
170
+
171
+ start_btn.click(
172
+ start_game, None,
173
+ [state, mana_state, mana_max_state, status, visitor, card, outcome],
174
+ ).then(None, [mana_state, mana_max_state], None, js=_init_and_arm)
175
+
176
+ visitor.change(select_visitor, [state, visitor], [card, outcome])
177
+
178
+ # Deliver: Gradio 6 passes ALL inputs (including gr.State) as JS args,
179
+ # so there are 4 args: (png_val, spent_val, state_val, visitor_val).
180
+ # We capture all 4 explicitly, replace png/spent from the canvas, pass
181
+ # state (st) back unchanged (Gradio uses its server-side copy anyway),
182
+ # and forward the visitor value in position [3].
183
+ deliver_btn.click(
184
+ do_deliver, [png_field, spent_field, state, visitor],
185
+ [state, mana_state, mana_max_state, status, outcome, visitor],
186
+ js="(p, s, st, v) => { const [png, spent] = window.inkwitch.getResult(); return [png, spent, st, v]; }",
187
+ ).then(None, [mana_state, mana_max_state], None,
188
+ js="(m, mx) => { window.inkwitch && window.inkwitch.setMana(Number(m), Number(mx)); }")
189
+
190
+ # Reset: inputs are [spent_field, state]. Gradio passes both as JS args.
191
+ # Capture state (st) at position [1] and pass it back unchanged.
192
+ reset_btn.click(
193
+ do_reset, [spent_field, state],
194
+ [state, mana_state, mana_max_state, status, outcome],
195
+ js="(s, st) => [window.inkwitch.getResult()[1], st]",
196
+ ).then(None, [mana_state, mana_max_state], None,
197
+ js="(m, mx) => { window.inkwitch && window.inkwitch.setMana(Number(m), Number(mx)); }")
198
+
199
+ end_btn.click(
200
+ do_end_day, [state],
201
+ [state, mana_state, mana_max_state, status, outcome, visitor],
202
+ ).then(None, [mana_state, mana_max_state], None,
203
+ js="(m, mx) => { window.inkwitch && window.inkwitch.setMana(Number(m), Number(mx)); }")
204
+
205
+ potion_btn.click(
206
+ lambda g: buy(g, "potion"), [state],
207
+ [state, mana_state, mana_max_state, status, outcome],
208
+ ).then(None, [mana_state, mana_max_state], None,
209
+ js="(m, mx) => { window.inkwitch && window.inkwitch.updateMana(Number(m), Number(mx)); }")
210
+
211
+ mana_btn.click(
212
+ lambda g: buy(g, "mana"), [state],
213
+ [state, mana_state, mana_max_state, status, outcome],
214
+ ).then(None, [mana_state, mana_max_state], None,
215
+ js="(m, mx) => { window.inkwitch && window.inkwitch.updateMana(Number(m), Number(mx)); }")
216
+
217
+ return demo
218
+
219
+
220
+ if __name__ == "__main__":
221
+ build_demo().launch()
witch_wood/ui/canvas.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client-side mana-metered drawing canvas (Approach A).
2
+
3
+ The browser owns drawing, stroke-length metering, the mana bar, and the hard
4
+ stop. On Deliver, JS writes {png, spent} into two hidden Gradio textboxes so a
5
+ Python handler can read them. Python never sees intermediate strokes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ CANVAS_WIDTH = 384
10
+ CANVAS_HEIGHT = 384
11
+
12
+ CANVAS_HTML = f"""
13
+ <div id="iw-wrap" style="display:flex;flex-direction:column;gap:8px;max-width:{CANVAS_WIDTH}px">
14
+ <div style="background:#eee;border-radius:6px;height:14px;overflow:hidden">
15
+ <div id="iw-mana" style="background:#6c63ff;height:100%;width:100%"></div>
16
+ </div>
17
+ <div id="iw-mana-label" style="font:12px monospace;color:#555">mana: -- / --</div>
18
+ <canvas id="iw-canvas" width="{CANVAS_WIDTH}" height="{CANVAS_HEIGHT}"
19
+ style="border:1px solid #999;border-radius:6px;background:#fff;touch-action:none;cursor:crosshair">
20
+ </canvas>
21
+ </div>
22
+ """
23
+
24
+ # Defines window.inkwitch with setBudget/reset/getResult and wires pointer events.
25
+ CANVAS_JS = """
26
+ () => {
27
+ if (window.inkwitch && window.inkwitch._ready) return;
28
+ const canvas = document.getElementById('iw-canvas');
29
+ const bar = document.getElementById('iw-mana');
30
+ const label = document.getElementById('iw-mana-label');
31
+ if (!canvas) return;
32
+ const ctx = canvas.getContext('2d');
33
+ ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#111';
34
+
35
+ let budget = 0, previous_spent = 0, current_spent = 0, drawing = false, last = null;
36
+
37
+ function refresh() {
38
+ const total_spent = previous_spent + current_spent;
39
+ const remaining = Math.max(0, budget - total_spent);
40
+ const pct = budget > 0 ? (remaining / budget) * 100 : 0;
41
+ bar.style.width = Math.min(100, Math.max(0, pct)) + '%';
42
+ bar.style.background = pct < 20 ? '#e0526a' : '#6c63ff';
43
+ label.textContent = 'mana: ' + Math.round(remaining) + ' / ' + Math.round(budget);
44
+ }
45
+ function pos(e) {
46
+ const r = canvas.getBoundingClientRect();
47
+ return {
48
+ x: (e.clientX - r.left) * (canvas.width / r.width),
49
+ y: (e.clientY - r.top) * (canvas.height / r.height)
50
+ };
51
+ }
52
+ canvas.addEventListener('pointerdown', (e) => {
53
+ if (previous_spent + current_spent >= budget) return;
54
+ drawing = true; last = pos(e); canvas.setPointerCapture(e.pointerId);
55
+ });
56
+ canvas.addEventListener('pointermove', (e) => {
57
+ if (!drawing) return;
58
+ const p = pos(e);
59
+ const seg = Math.hypot(p.x - last.x, p.y - last.y);
60
+ if (previous_spent + current_spent + seg > budget) { // hard stop: clip to remaining and end
61
+ drawing = false; refresh(); return;
62
+ }
63
+ ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
64
+ current_spent += seg; last = p; refresh();
65
+ });
66
+ function endStroke() { drawing = false; }
67
+ canvas.addEventListener('pointerup', endStroke);
68
+ canvas.addEventListener('pointercancel', endStroke);
69
+ canvas.addEventListener('pointerleave', endStroke);
70
+
71
+ window.inkwitch = {
72
+ _ready: true,
73
+ setBudget(n) { budget = n; previous_spent = 0; current_spent = 0; ctx.clearRect(0,0,canvas.width,canvas.height); refresh(); },
74
+ setMana(remaining, max) { budget = Math.max(max, remaining); previous_spent = Math.max(0, budget - remaining); current_spent = 0; ctx.clearRect(0,0,canvas.width,canvas.height); refresh(); },
75
+ updateMana(remaining, max) { budget = Math.max(max, remaining); previous_spent = Math.max(0, budget - remaining); refresh(); },
76
+ reset() { current_spent = 0; ctx.clearRect(0,0,canvas.width,canvas.height); refresh(); },
77
+ getResult() { return [canvas.toDataURL('image/png'), String(Math.round(current_spent))]; }
78
+ };
79
+ refresh();
80
+ }
81
+ """
witch_wood/ui/format.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/ui/format.py
2
+ """Pure Markdown formatters for the Gradio UI."""
3
+ from __future__ import annotations
4
+
5
+ from witch_wood.core.schema import DeliverResult, Request
6
+ from witch_wood.core.state import GameState
7
+ from witch_wood.engine.economy import current_potion_price
8
+
9
+
10
+ def request_card_md(req: Request) -> str:
11
+ props = "\n".join(f"- ☆ {p.predicate}" for p in req.properties)
12
+ body = f"\n\n{props}" if props else ""
13
+ return (
14
+ f"**{req.visitor}** wants a **{req.object}** — *{req.flavor}*{body}\n\n"
15
+ f"Offers **{req.base_payment}** gold"
16
+ )
17
+
18
+
19
+ def result_md(req: Request, res: DeliverResult) -> str:
20
+ seen = f"\n\n*The witch sees: {res.description}*" if res.description else ""
21
+ rec = "✅ Recognized" if res.recognized else "❌ Not recognized"
22
+ if not res.recognized:
23
+ return (
24
+ f"{rec} as a **{req.object}** — "
25
+ f"base payment (50%) **{res.payment} gold**.{seen}"
26
+ )
27
+ marks = "\n".join(
28
+ f"- {'★' if ok else '☆'} {p.label}"
29
+ for p, ok in zip(req.properties, res.passes)
30
+ )
31
+ body = f"\n\n{marks}" if marks else ""
32
+ return f"{rec} as a **{req.object}**{body}\n\n**{res.stars}/{req.tier} stars → {res.payment} gold**{seen}"
33
+
34
+
35
+ def status_md(g: GameState) -> str:
36
+ return (
37
+ f"**Day {g.day}/{g.deadline_day}** · "
38
+ f"💰 {g.gold} · 🔮 mana {g.mana}/{g.mana_max} · "
39
+ f"🪑 slots {g.slots} · ⭐ {g.total_stars} · "
40
+ f"🧪 {current_potion_price(g)} · "
41
+ f"debt {g.debt}"
42
+ )
witch_wood/ui/server.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # witch_wood/ui/server.py
2
+ """Gradio Server backend for the hand-drawn Inkwitch UI."""
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import json
8
+ import os
9
+ import threading
10
+ from collections import OrderedDict
11
+ from PIL import Image
12
+
13
+ import gradio as gr
14
+ from fastapi import FastAPI
15
+ from fastapi.staticfiles import StaticFiles
16
+ from fastapi.responses import HTMLResponse, JSONResponse
17
+
18
+ from witch_wood.core import constants as C
19
+ from witch_wood.core.state import new_game, GameState
20
+ from witch_wood.engine import economy
21
+ from witch_wood.engine.game import deliver, reset_drawing
22
+ from witch_wood.ui.format import request_card_md, result_md, status_md
23
+ from witch_wood.vision.mock import MockBackend
24
+
25
+ MOCK = os.environ.get("WITCH_WOOD_MOCK") == "1"
26
+
27
+
28
+ def _make_backend():
29
+ if MOCK:
30
+ return MockBackend(seed=0)
31
+ # Importing minicpm loads MiniCPM-V at module scope (registered for ZeroGPU
32
+ # device migration). Done eagerly at startup so the cold-start cost is paid
33
+ # once here rather than on the first player's delivery.
34
+ from witch_wood.vision.minicpm import MiniCPMBackend
35
+ return MiniCPMBackend(use_kv_cache=True)
36
+
37
+
38
+ # Eager singleton: the model is loaded when this module is imported (Space
39
+ # startup), before any GPU request arrives. The model is read-only during
40
+ # inference, so sharing this one instance across concurrent requests is safe.
41
+ _BACKEND = _make_backend()
42
+
43
+
44
+ def _get_backend():
45
+ return _BACKEND
46
+
47
+
48
+ # Image-gen second stage. Importing `scribble` eagerly loads the SDXL-Flash +
49
+ # ControlNet pipe at module scope (Space startup) — same eager-load rationale as
50
+ # the vision backend. Skipped under MOCK so local CUDA-less dev never pulls in
51
+ # the GPU pipe (which requires CUDA and does not run on MPS).
52
+ if not MOCK:
53
+ # Importing `pipeline` pulls in both scribble (SDXL pipe) and minicpm
54
+ # (MiniCPM-V) and eager-loads them at module scope — the fused delivery runs
55
+ # all three model calls (appraise + gen + render re-check) in one GPU entry.
56
+ from witch_wood.gen import pipeline
57
+ else:
58
+ pipeline = None
59
+
60
+
61
+ # --- Per-session game state -------------------------------------------------
62
+ #
63
+ # @app.api endpoints serialize calls through Gradio's queue (concurrency_limit),
64
+ # but they do NOT isolate state per user — so each visitor needs their own
65
+ # GameState. We key it on `gr.Request.session_hash`, which is unique per
66
+ # @gradio/client connection (one per browser tab) and stable across that
67
+ # connection's calls. An LRU cap bounds memory from abandoned sessions.
68
+ MAX_SESSIONS = 500
69
+ _SESSIONS_LOCK = threading.Lock()
70
+
71
+ # session_hash -> GameState
72
+ GAMES: "OrderedDict[str, GameState]" = OrderedDict()
73
+ # session_hash -> {visitor_id -> result breakdown} (today's completed deliveries)
74
+ RESULTS: "OrderedDict[str, dict[str, dict]]" = OrderedDict()
75
+
76
+
77
+ def _session_id(request: gr.Request | None) -> str:
78
+ """Stable per-connection key. Direct API callers without a Gradio session
79
+ (e.g. raw curl) all share one fallback bucket."""
80
+ sid = getattr(request, "session_hash", None) if request is not None else None
81
+ return sid or "_anonymous"
82
+
83
+
84
+ def _start_session(request: gr.Request | None, test_mode: bool | None = None) -> GameState:
85
+ """Create a fresh game (and empty results) for this session, evicting the
86
+ least-recently-used sessions past the cap. `test_mode=True` forces the harness
87
+ on for this session (hidden 5-reset gesture); None falls back to the env const."""
88
+ sid = _session_id(request)
89
+ game = new_game(seed=int.from_bytes(os.urandom(2), "big"), test_mode=test_mode)
90
+ with _SESSIONS_LOCK:
91
+ GAMES[sid] = game
92
+ GAMES.move_to_end(sid)
93
+ RESULTS[sid] = {}
94
+ RESULTS.move_to_end(sid)
95
+ while len(GAMES) > MAX_SESSIONS:
96
+ old_sid, _ = GAMES.popitem(last=False)
97
+ RESULTS.pop(old_sid, None)
98
+ return game
99
+
100
+
101
+ def _session(request: gr.Request | None) -> tuple[GameState | None, dict[str, dict]]:
102
+ """Return (game, results) for this session; game is None if not started."""
103
+ sid = _session_id(request)
104
+ with _SESSIONS_LOCK:
105
+ game = GAMES.get(sid)
106
+ if game is not None:
107
+ GAMES.move_to_end(sid)
108
+ results = RESULTS.setdefault(sid, {})
109
+ return game, results
110
+
111
+
112
+ app = gr.Server()
113
+
114
+ # Mount static folder for assets, CSS, JS
115
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
116
+ os.makedirs(STATIC_DIR, exist_ok=True)
117
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
118
+
119
+
120
+ def _decode_png(data_url: str) -> Image.Image:
121
+ _, _, b64 = data_url.partition(",")
122
+ rgba = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGBA")
123
+ flat = Image.new("RGB", rgba.size, "white")
124
+ flat.paste(rgba, mask=rgba.split()[3])
125
+ return flat
126
+
127
+
128
+ def _encode_png(image: Image.Image) -> str:
129
+ """Encode a PIL image as a base64 PNG data URL for the frontend."""
130
+ buf = io.BytesIO()
131
+ image.save(buf, format="PNG")
132
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
133
+ return f"data:image/png;base64,{b64}"
134
+
135
+
136
+ # --- image-gen tuning panel -------------------------------------------------
137
+ # The gen flag/params/prompt-suffixes live in constants.py (single source). The
138
+ # panel's default values are rendered into the HTML from those constants by
139
+ # _render_index; here we parse the panel's `gen_opts` JSON back, overriding any
140
+ # field (blank text falls back to the constant).
141
+ def _gen_options(gen_opts: str) -> dict:
142
+ try:
143
+ opts = json.loads(gen_opts) if gen_opts else {}
144
+ except (ValueError, TypeError):
145
+ opts = {}
146
+ # Blank textareas -> fall back to the constants (treat ""/whitespace as unset).
147
+ style = str(opts.get("style_suffix") or "").strip() or C.STYLE_SUFFIX
148
+ negative = str(opts.get("base_negative") or "").strip() or C.BASE_NEGATIVE
149
+ return {
150
+ "enabled": bool(opts.get("enabled", C.GEN_ENABLED)),
151
+ "steps": int(opts.get("steps", C.GEN_STEPS)),
152
+ "guidance": float(opts.get("guidance", C.GEN_GUIDANCE)),
153
+ "controlnet_scale": float(opts.get("controlnet_scale", C.GEN_CNET_SCALE)),
154
+ "seed": int(opts.get("seed", C.GEN_SEED)),
155
+ "style_suffix": style,
156
+ "base_negative": negative,
157
+ }
158
+
159
+
160
+ def _get_visitor_list(g: GameState, results: dict[str, dict]) -> list[dict]:
161
+ """Helper to serialize today's visitor queue (including completed ones)."""
162
+ visitors_data = []
163
+ for r in g.today:
164
+ is_completed = r.id in g.completed
165
+ result_breakdown = results.get(r.id)
166
+
167
+ visitors_data.append({
168
+ "id": r.id,
169
+ "visitor": r.visitor,
170
+ "object": r.object,
171
+ "base_payment": r.base_payment,
172
+ "actual_payment": result_breakdown["total_payment"] if is_completed and result_breakdown else None,
173
+ "flavor": r.flavor,
174
+ "tier": r.tier,
175
+ "properties": [{"label": p.label, "predicate": p.predicate} for p in r.properties],
176
+ "completed": is_completed,
177
+ "breakdown": result_breakdown,
178
+ "doodle": result_breakdown.get("doodle") if is_completed and result_breakdown else None,
179
+ "generated": result_breakdown.get("generated") if is_completed and result_breakdown else None
180
+ })
181
+ return visitors_data
182
+
183
+
184
+ def _get_game_payload(g: GameState) -> dict:
185
+ """Helper to serialize the general game state."""
186
+ return {
187
+ "day": g.day,
188
+ "deadline_day": g.deadline_day,
189
+ "gold": g.gold,
190
+ "debt": g.debt,
191
+ "rent": g.rent,
192
+ "mana": g.mana,
193
+ "mana_max": g.mana_max,
194
+ "slots": g.slots,
195
+ "total_stars": g.total_stars,
196
+ "potion_price": economy.current_potion_price(g),
197
+ "max_mana_price": C.MANA_UPGRADE_COST,
198
+ "status": g.status,
199
+ "potions_bought_today": g.potions_bought_today,
200
+ "slot_unlock_stars": {str(k): v for k, v in C.SLOT_UNLOCK_STARS.items()},
201
+ "logs": g.logs,
202
+ }
203
+
204
+
205
+ def _render_index(html: str) -> str:
206
+ """Fill the image-gen panel's `{{WW_*}}` tokens from the Python constants so
207
+ the panel defaults are always in sync with constants.py (single source of
208
+ truth) — never hardcoded in the static HTML."""
209
+ import html as _html
210
+
211
+ repl = {
212
+ "{{WW_GEN_ENABLED_CHECKED}}": "checked" if C.GEN_ENABLED else "",
213
+ "{{WW_GEN_STEPS}}": str(C.GEN_STEPS),
214
+ "{{WW_GEN_GUIDANCE}}": str(C.GEN_GUIDANCE),
215
+ "{{WW_GEN_CNET_SCALE}}": str(C.GEN_CNET_SCALE),
216
+ "{{WW_GEN_SEED}}": str(C.GEN_SEED),
217
+ # textarea body: escape so any markup-special chars stay literal text
218
+ "{{WW_STYLE_SUFFIX}}": _html.escape(C.STYLE_SUFFIX),
219
+ "{{WW_BASE_NEGATIVE}}": _html.escape(C.BASE_NEGATIVE),
220
+ # Game economy constants
221
+ "{{WW_RENT}}": str(C.RENT),
222
+ "{{WW_DEBT}}": str(C.DEBT),
223
+ "{{WW_DEADLINE_DAY}}": str(C.DEADLINE_DAY),
224
+ "{{WW_POTION_MANA}}": str(C.POTION_MANA),
225
+ "{{WW_MANA_UPGRADE_AMOUNT}}": str(C.MANA_UPGRADE_AMOUNT),
226
+ "{{WW_CANVAS_RESET_REFUND_PCT}}": str(int(C.CANVAS_RESET_REFUND * 100)),
227
+ }
228
+ for token, value in repl.items():
229
+ html = html.replace(token, value)
230
+ return html
231
+
232
+
233
+ @app.get("/", response_class=HTMLResponse)
234
+ def get_index():
235
+ index_path = os.path.join(STATIC_DIR, "index.html")
236
+ if not os.path.exists(index_path):
237
+ return HTMLResponse("<h1>Static files not created yet!</h1><p>Please wait for the static assets to build.</p>")
238
+ with open(index_path, "r", encoding="utf-8") as f:
239
+ return _render_index(f.read())
240
+
241
+
242
+ @app.api(name="start_game")
243
+ def api_start_game(request: gr.Request) -> dict:
244
+ """Initializes a new game for this visitor's session.
245
+
246
+ Always starts in NORMAL mode — test mode is opt-in via the hidden 5-reset
247
+ gesture (`enable_testmode`), never the default, even if the Space still has
248
+ the legacy `WITCH_WOOD_TESTMODE` variable set."""
249
+ g = _start_session(request, test_mode=False)
250
+
251
+ visitors = _get_visitor_list(g, {})
252
+ payload = _get_game_payload(g)
253
+
254
+ return {
255
+ "success": True,
256
+ "game": payload,
257
+ "visitors": visitors,
258
+ "selected_visitor_id": visitors[0]["id"] if visitors else None,
259
+ }
260
+
261
+
262
+ @app.api(name="enable_testmode")
263
+ def api_enable_testmode(request: gr.Request) -> dict:
264
+ """Hidden harness gesture (frontend: 5 consecutive Reset clicks): start a fresh
265
+ game with test mode forced on for this session (unlimited gold/mana, full
266
+ visitor pool, repeat delivery). Refresh the page to return to a normal run."""
267
+ g = _start_session(request, test_mode=True)
268
+
269
+ visitors = _get_visitor_list(g, {})
270
+ payload = _get_game_payload(g)
271
+
272
+ return {
273
+ "success": True,
274
+ "game": payload,
275
+ "visitors": visitors,
276
+ "selected_visitor_id": visitors[0]["id"] if visitors else None,
277
+ "message": "🧪 Test mode enabled — unlimited gold & mana, full visitor pool.",
278
+ }
279
+
280
+
281
+ @app.api(name="select_visitor")
282
+ def api_select_visitor(visitor_id: str, request: gr.Request) -> dict:
283
+ """Returns the details of a specific visitor request."""
284
+ g, results = _session(request)
285
+ if g is None:
286
+ return {"success": False, "error": "Game not started"}
287
+
288
+ req = next((r for r in g.today if r.id == visitor_id), None)
289
+ if req is None:
290
+ return {"success": False, "error": "Visitor not found"}
291
+
292
+ is_completed = req.id in g.completed
293
+ result_breakdown = results.get(req.id)
294
+
295
+ return {
296
+ "success": True,
297
+ "visitor": {
298
+ "id": req.id,
299
+ "visitor": req.visitor,
300
+ "object": req.object,
301
+ "base_payment": req.base_payment,
302
+ "actual_payment": result_breakdown["total_payment"] if is_completed and result_breakdown else None,
303
+ "flavor": req.flavor,
304
+ "tier": req.tier,
305
+ "properties": [{"label": p.label, "predicate": p.predicate} for p in req.properties],
306
+ "completed": is_completed,
307
+ "breakdown": result_breakdown,
308
+ "doodle": result_breakdown.get("doodle") if is_completed and result_breakdown else None,
309
+ "generated": result_breakdown.get("generated") if is_completed and result_breakdown else None
310
+ }
311
+ }
312
+
313
+
314
+ @app.api(name="deliver")
315
+ def api_deliver(png_data_url: str, spent_mana: int, visitor_id: str, gen_opts: str = "", request: gr.Request = None) -> dict:
316
+ """Processes canvas drawing delivery and grades it.
317
+
318
+ `gen_opts` is a TEMPORARY JSON string from the image-gen tuning panel
319
+ ({enabled, steps, guidance, controlnet_scale, seed}); remove it along with
320
+ the panel once the params are tuned.
321
+ """
322
+ g, results = _session(request)
323
+ if g is None:
324
+ return {"success": False, "error": "Game not started"}
325
+
326
+ if not png_data_url:
327
+ return {"success": False, "error": "Draw something first!"}
328
+
329
+ req = next((r for r in g.today if r.id == visitor_id), None)
330
+ if req is None:
331
+ return {"success": False, "error": "Visitor not found"}
332
+
333
+ try:
334
+ # Decode base64 canvas image
335
+ image = _decode_png(png_data_url)
336
+
337
+ # Build the fused GPU pipeline. It runs INSIDE deliver() and does the
338
+ # whole delivery — doodle appraisal, image-gen (object + doodle
339
+ # passed/failed properties injected into the prompt), and the render
340
+ # re-check — in ONE @spaces.GPU acquisition, returning (verdict, render).
341
+ # `gen_opts["enabled"]` toggles the image-gen stage; when off (or under
342
+ # MOCK, where pipeline is None) scoring falls back to the doodle.
343
+ gpu_pipeline = None
344
+ if pipeline is not None:
345
+ opts = _gen_options(gen_opts)
346
+
347
+ def gpu_pipeline(img, request):
348
+ return pipeline.run_delivery_gpu(img, request, opts)
349
+
350
+ # Execute deliver logic
351
+ res, gen_img = deliver(
352
+ g,
353
+ visitor_id,
354
+ image=image,
355
+ spent_length=int(spent_mana),
356
+ backend=_get_backend(),
357
+ gpu_pipeline=gpu_pipeline,
358
+ )
359
+
360
+ # Calculate structured payment breakdown details
361
+ is_recognized = res.recognized
362
+ base_payment = req.base_payment
363
+ total_payment = res.payment
364
+
365
+ if is_recognized:
366
+ base_payment_earned = base_payment # 100% of base
367
+ bonus_percent = C.STAR_BONUS_PER_STAR * res.stars
368
+ bonus_earned = round(base_payment * bonus_percent)
369
+ penalty_subtracted = 0
370
+ else:
371
+ base_payment_earned = round(base_payment * C.RECOGNITION_FAIL_PAYOUT) # 50% flat consolation
372
+ bonus_earned = 0
373
+ penalty_subtracted = base_payment - base_payment_earned
374
+
375
+ breakdown = {
376
+ "recognized": is_recognized,
377
+ "base_payment": base_payment,
378
+ "recognition_passed": is_recognized,
379
+ "stars_earned": res.stars,
380
+ "star_multiplier": C.STAR_BONUS_PER_STAR,
381
+ "base_payment_earned": base_payment_earned,
382
+ "bonus_earned": bonus_earned,
383
+ "penalty_subtracted": penalty_subtracted,
384
+ "total_payment": total_payment,
385
+ "description": res.description,
386
+ "passes": list(res.passes),
387
+ "doodle": png_data_url,
388
+ "generated": _encode_png(gen_img) if gen_img is not None else None,
389
+ }
390
+
391
+ # Store in this session's completed results map
392
+ results[visitor_id] = breakdown
393
+
394
+ visitors = _get_visitor_list(g, results)
395
+ payload = _get_game_payload(g)
396
+
397
+ return {
398
+ "success": True,
399
+ "game": payload,
400
+ "visitors": visitors,
401
+ "selected_visitor_id": visitor_id, # Keep the delivered guest selected to view outcomes
402
+ "breakdown": breakdown
403
+ }
404
+ except Exception as e:
405
+ return {"success": False, "error": str(e)}
406
+
407
+
408
+ @app.api(name="reset_canvas")
409
+ def api_reset_canvas(spent_mana: int, request: gr.Request) -> dict:
410
+ """Refunds part of the spent mana and clears canvas budget."""
411
+ g, _ = _session(request)
412
+ if g is None:
413
+ return {"success": False, "error": "Game not started"}
414
+
415
+ try:
416
+ new_mana = reset_drawing(g, int(spent_mana))
417
+ payload = _get_game_payload(g)
418
+ return {
419
+ "success": True,
420
+ "game": payload,
421
+ "message": "🧽 Canvas reset — most of that ink refunded!"
422
+ }
423
+ except Exception as e:
424
+ return {"success": False, "error": str(e)}
425
+
426
+
427
+ @app.api(name="buy_potion")
428
+ def api_buy_potion(request: gr.Request) -> dict:
429
+ """Purchases a mana-refill potion from the shop."""
430
+ g, _ = _session(request)
431
+ if g is None:
432
+ return {"success": False, "error": "Game not started"}
433
+
434
+ try:
435
+ economy.buy_potion(g)
436
+ payload = _get_game_payload(g)
437
+ return {
438
+ "success": True,
439
+ "game": payload,
440
+ "message": "🧪 Bought mana potion!"
441
+ }
442
+ except Exception as e:
443
+ return {"success": False, "error": str(e)}
444
+
445
+
446
+ @app.api(name="buy_max_mana")
447
+ def api_buy_max_mana(request: gr.Request) -> dict:
448
+ """Upgrades the player's max mana capacity."""
449
+ g, _ = _session(request)
450
+ if g is None:
451
+ return {"success": False, "error": "Game not started"}
452
+
453
+ try:
454
+ economy.buy_mana_upgrade(g)
455
+ payload = _get_game_payload(g)
456
+ return {
457
+ "success": True,
458
+ "game": payload,
459
+ "message": "🔮 Max mana capacity upgraded!"
460
+ }
461
+ except Exception as e:
462
+ return {"success": False, "error": str(e)}
463
+
464
+
465
+ @app.api(name="end_day")
466
+ def api_end_day(request: gr.Request) -> dict:
467
+ """Triggers nightfall, checks win/loss debt conditions, refills mana, advances day."""
468
+ g, results = _session(request)
469
+ if g is None:
470
+ return {"success": False, "error": "Game not started"}
471
+
472
+ try:
473
+ old_day = g.day
474
+ old_gold = g.gold
475
+ rent = g.rent
476
+ debt = g.debt
477
+
478
+ economy.end_day(g)
479
+ results.clear() # reset completed guest records for the new day
480
+
481
+ payload = _get_game_payload(g)
482
+ visitors = _get_visitor_list(g, results)
483
+
484
+ # Determine night message
485
+ if g.status == "won":
486
+ msg = "🎉 **Debt paid — you win! The run is over.**"
487
+ elif g.status == "lost":
488
+ if old_day >= g.deadline_day:
489
+ msg = "⏰ **Deadline reached short of the debt — game over.**"
490
+ else:
491
+ msg = "💀 **Bankrupt — game over.**"
492
+ else:
493
+ msg = f"🌙 Paid rent ({rent}g). A new day dawns!"
494
+
495
+ return {
496
+ "success": True,
497
+ "game": payload,
498
+ "visitors": visitors,
499
+ "selected_visitor_id": visitors[0]["id"] if visitors else None,
500
+ "message": msg
501
+ }
502
+ except Exception as e:
503
+ return {"success": False, "error": str(e)}
witch_wood/ui/static/app.js ADDED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // app.js - Inkwitch Redesign Client Controller
2
+
3
+ // State variables
4
+ let gameState = null;
5
+ let visitors = [];
6
+ let selectedVisitorId = null;
7
+ let spentMana = 0;
8
+ let drawing = false;
9
+ let lastPos = null;
10
+ let isDelivering = false;
11
+ let resetStreak = 0; // consecutive Reset clicks; 5 in a row flips on test mode
12
+
13
+ // DOM Elements
14
+ const canvas = document.getElementById('paint-canvas');
15
+ const ctx = canvas.getContext('2d');
16
+ const manaText = document.getElementById('mana-text');
17
+ const manaBarInner = document.getElementById('mana-bar-inner');
18
+ const visitorsList = document.getElementById('visitors-list');
19
+ const visitorRequestCard = document.getElementById('visitor-request-card');
20
+ const outcomeContent = document.getElementById('outcome-content');
21
+
22
+ // Status board elements
23
+ const statDay = document.getElementById('stat-day');
24
+ const statGold = document.getElementById('stat-gold');
25
+ const statDebt = document.getElementById('stat-debt');
26
+ const statStars = document.getElementById('stat-stars');
27
+
28
+ // Shop elements
29
+ const costPotion = document.getElementById('cost-potion');
30
+ const costMaxMana = document.getElementById('cost-max-mana');
31
+
32
+ // Buttons
33
+ const btnReset = document.getElementById('btn-reset');
34
+ const btnDeliver = document.getElementById('btn-deliver');
35
+ const btnBuyPotion = document.getElementById('btn-buy-potion');
36
+ const btnBuyMax = document.getElementById('btn-buy-max');
37
+ const btnEndDay = document.getElementById('btn-end-day');
38
+ const btnNewGame = document.getElementById('btn-new-game');
39
+ const btnInstructions = document.getElementById('btn-instructions');
40
+ const btnCloseInstructions = document.getElementById('btn-close-instructions');
41
+ const instructionsModal = document.getElementById('instructions-modal');
42
+
43
+
44
+ // Configure Canvas Stroke
45
+ ctx.lineWidth = 4;
46
+ ctx.lineCap = 'round';
47
+ ctx.lineJoin = 'round';
48
+ ctx.strokeStyle = '#2c2825'; // Matches charcoal ink
49
+
50
+ // Canvas drawing coordinate helper
51
+ function getPos(e) {
52
+ const rect = canvas.getBoundingClientRect();
53
+ // Support both mouse and touch events
54
+ const clientX = e.clientX || (e.touches && e.touches[0].clientX);
55
+ const clientY = e.clientY || (e.touches && e.touches[0].clientY);
56
+
57
+ // Scale coordinates to match internal canvas drawing space when visually resized
58
+ return {
59
+ x: (clientX - rect.left) * (canvas.width / rect.width),
60
+ y: (clientY - rect.top) * (canvas.height / rect.height)
61
+ };
62
+ }
63
+
64
+ // Update the mana visual indicators
65
+ function updateManaBar() {
66
+ if (!gameState) return;
67
+ const currentAvailable = Math.max(0, gameState.mana - spentMana);
68
+ const percent = gameState.mana_max > 0 ? (currentAvailable / gameState.mana_max) * 100 : 0;
69
+
70
+ manaText.textContent = `${Math.round(currentAvailable)} / ${gameState.mana_max}`;
71
+ manaBarInner.style.width = `${Math.min(100, Math.max(0, percent))}%`;
72
+
73
+ // Set alert color when mana is low (under 20%)
74
+ if (percent < 20) {
75
+ manaBarInner.style.backgroundColor = 'var(--color-error)';
76
+ } else {
77
+ manaBarInner.style.backgroundColor = 'var(--color-primary)';
78
+ }
79
+ }
80
+
81
+ // Canvas Event Listeners
82
+ canvas.addEventListener('pointerdown', (e) => {
83
+ if (!gameState || gameState.status !== 'playing' || isDelivering) return;
84
+ if (gameState.mana - spentMana <= 0) return;
85
+
86
+ drawing = true;
87
+ lastPos = getPos(e);
88
+ canvas.setPointerCapture(e.pointerId);
89
+ });
90
+
91
+ canvas.addEventListener('pointermove', (e) => {
92
+ if (!drawing || isDelivering) return;
93
+
94
+ const currentPos = getPos(e);
95
+ const segmentLength = Math.hypot(currentPos.x - lastPos.x, currentPos.y - lastPos.y);
96
+
97
+ // Hard stop check: limit drawing by remaining mana
98
+ if (spentMana + segmentLength > gameState.mana) {
99
+ drawing = false;
100
+ updateManaBar();
101
+ return;
102
+ }
103
+
104
+ ctx.beginPath();
105
+ ctx.moveTo(lastPos.x, lastPos.y);
106
+ ctx.lineTo(currentPos.x, currentPos.y);
107
+ ctx.stroke();
108
+
109
+ spentMana += segmentLength;
110
+ lastPos = currentPos;
111
+ updateManaBar();
112
+ });
113
+
114
+ function endDrawing() {
115
+ drawing = false;
116
+ }
117
+
118
+ canvas.addEventListener('pointerup', endDrawing);
119
+ canvas.addEventListener('pointercancel', endDrawing);
120
+ canvas.addEventListener('pointerleave', endDrawing);
121
+
122
+ // Clear canvas visually
123
+ function clearCanvas() {
124
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
125
+ }
126
+
127
+ // Bleeding effect animation loop and helper variables
128
+ let bleedInterval = null;
129
+ let originalCanvas = null;
130
+ let tempCanvas = null;
131
+
132
+ function startBleedingEffect() {
133
+ if (!canvas || !ctx) return;
134
+
135
+ // Save the original drawing to a background canvas
136
+ originalCanvas = document.createElement('canvas');
137
+ originalCanvas.width = canvas.width;
138
+ originalCanvas.height = canvas.height;
139
+ const origCtx = originalCanvas.getContext('2d');
140
+ origCtx.drawImage(canvas, 0, 0);
141
+
142
+ // Create a temp canvas for double-buffered diffusion rendering
143
+ tempCanvas = document.createElement('canvas');
144
+ tempCanvas.width = canvas.width;
145
+ tempCanvas.height = canvas.height;
146
+ const tempCtx = tempCanvas.getContext('2d');
147
+
148
+ let frameCount = 0;
149
+
150
+ function bleedStep() {
151
+ if (!isDelivering) {
152
+ cleanupBleed();
153
+ return;
154
+ }
155
+
156
+ frameCount++;
157
+
158
+ // Heartbeat rhythm (60 frames = ~1s): short strong pulse then slow diffusion
159
+ const cycleFrames = 60;
160
+ const phase = frameCount % cycleFrames;
161
+ let jitterStrength = 0.15;
162
+ let pulseScale = 1.0001;
163
+ let blurRadius = 0.0;
164
+
165
+ if (phase < 12) { // strong phase (first 0.2s of each cycle)
166
+ const progress = phase / 12;
167
+ const pulseFactor = Math.sin(progress * Math.PI);
168
+
169
+ jitterStrength = 0.15 + pulseFactor * 5;
170
+ pulseScale = 1.0001 + pulseFactor * 0.0025;
171
+ blurRadius = 0.25 + pulseFactor * 0.65;
172
+ }
173
+
174
+ // Copy current canvas contents to temp canvas
175
+ tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
176
+ tempCtx.drawImage(canvas, 0, 0);
177
+
178
+ // Clear main canvas
179
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
180
+
181
+ // Diffusion step: blur + fade + scale outward + jitter
182
+ ctx.save();
183
+ if (typeof ctx.filter !== 'undefined') {
184
+ ctx.filter = `blur(${blurRadius}px)`;
185
+ }
186
+ ctx.globalAlpha = 0.975;
187
+ ctx.translate(canvas.width / 2, canvas.height / 2);
188
+ ctx.scale(pulseScale, pulseScale);
189
+ ctx.translate(-canvas.width / 2, -canvas.height / 2);
190
+ const dx = (Math.random() - 0.5) * jitterStrength;
191
+ const dy = (Math.random() - 0.5) * jitterStrength;
192
+ ctx.drawImage(tempCanvas, dx, dy);
193
+ ctx.restore();
194
+
195
+ // Feed a faint copy of the original to keep the core ink lines alive
196
+ ctx.save();
197
+ ctx.globalAlpha = 0.035;
198
+ ctx.drawImage(originalCanvas, 0, 0);
199
+ ctx.restore();
200
+
201
+ bleedInterval = requestAnimationFrame(bleedStep);
202
+ }
203
+
204
+ bleedInterval = requestAnimationFrame(bleedStep);
205
+ }
206
+
207
+ function stopBleedingEffect(deliverySuccessful) {
208
+ if (bleedInterval) {
209
+ cancelAnimationFrame(bleedInterval);
210
+ bleedInterval = null;
211
+ }
212
+ // Restore original drawing if delivery failed or was cancelled
213
+ if (!deliverySuccessful && originalCanvas) {
214
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
215
+ ctx.drawImage(originalCanvas, 0, 0);
216
+ }
217
+ originalCanvas = null;
218
+ tempCanvas = null;
219
+ }
220
+
221
+ function cleanupBleed() {
222
+ if (bleedInterval) {
223
+ cancelAnimationFrame(bleedInterval);
224
+ bleedInterval = null;
225
+ }
226
+ originalCanvas = null;
227
+ tempCanvas = null;
228
+ }
229
+
230
+ // Connect to the backend through the official Gradio browser client.
231
+ //
232
+ // Why not a raw fetch to /gradio_api/call/<name>? On a ZeroGPU Space the Hub
233
+ // hands the embedded app a per-visitor `X-IP-Token` JWT that identifies the
234
+ // logged-in user (and their quota tier, e.g. PRO). The Gradio client forwards
235
+ // that token on every queued call; a hand-rolled fetch does not, so the GPU
236
+ // work gets billed to the anonymous IP pool and PRO users hit the 2-min/day
237
+ // limit. Routing through Client.connect() restores correct quota attribution.
238
+ // Pin the client version: an unpinned `@gradio/client` lets esm.sh serve a
239
+ // moving "latest" (and its multi-file node-polyfill graph), so a fresh/broken
240
+ // build or a single transient CDN fetch can fail the whole dynamic import with
241
+ // "Failed to fetch dynamically imported module". 2.2.1 is the build that has
242
+ // been working against this server's gradio 6.16.0 queue protocol.
243
+ const GRADIO_CLIENT_URL = 'https://esm.sh/@gradio/client@2.2.1';
244
+ let _gradioAppPromise = null;
245
+ function getGradioApp() {
246
+ if (_gradioAppPromise === null) {
247
+ _gradioAppPromise = (async () => {
248
+ let lastErr;
249
+ for (let attempt = 1; attempt <= 3; attempt++) {
250
+ try {
251
+ const { Client } = await import(GRADIO_CLIENT_URL);
252
+ return await Client.connect(window.location.origin);
253
+ } catch (err) {
254
+ lastErr = err;
255
+ console.warn(`gradio client load attempt ${attempt}/3 failed:`, err);
256
+ await new Promise((r) => setTimeout(r, 500 * attempt));
257
+ }
258
+ }
259
+ // Don't cache the failure: a transient CDN/network blip would otherwise
260
+ // wedge the whole session (every later call returns this rejected promise)
261
+ // until a full page reload. Reset so the next user action retries.
262
+ _gradioAppPromise = null;
263
+ throw lastErr;
264
+ })();
265
+ }
266
+ return _gradioAppPromise;
267
+ }
268
+
269
+ async function callAPI(endpoint, payloadArray = []) {
270
+ try {
271
+ const app = await getGradioApp();
272
+ // predict() goes through Gradio's queue (with the X-IP-Token attached) and
273
+ // resolves once the call completes — including the GPU-bound `deliver`.
274
+ const result = await app.predict(`/${endpoint}`, payloadArray);
275
+ const data = result && result.data;
276
+ // Named endpoints return their outputs as an array: [returnValue].
277
+ if (Array.isArray(data) && data.length > 0) {
278
+ return data[0];
279
+ }
280
+ return data;
281
+ } catch (error) {
282
+ console.error(`API Error calling ${endpoint}:`, error);
283
+ return { success: false, error: error.message };
284
+ }
285
+ }
286
+
287
+ // Render the Guest Panel (Left Column)
288
+ function renderVisitorsList(visitorsData) {
289
+ visitors = visitorsData || [];
290
+ visitorsList.innerHTML = '';
291
+
292
+ if (visitors.length === 0 && (!gameState || gameState.total_stars >= 3)) {
293
+ visitorsList.innerHTML = '<p class="empty-state">No visitors left today.</p>';
294
+ return;
295
+ }
296
+
297
+ visitors.forEach(visitor => {
298
+ const item = document.createElement('div');
299
+ const completedClass = visitor.completed ? 'completed' : '';
300
+ item.className = `visitor-item wobbly-border-sm shadow-offset ${completedClass} ${visitor.id === selectedVisitorId ? 'active' : ''}`;
301
+
302
+ // Pick avatar character based on visitor name
303
+ let avatar = visitor.completed ? '✅' : '👤';
304
+ if (!visitor.completed) {
305
+ if (visitor.visitor.includes('Willow')) avatar = '🧓';
306
+ else if (visitor.visitor.includes('Witch')) avatar = '🧙‍♀️';
307
+ else if (visitor.visitor.includes('Goblin') || visitor.visitor.includes('Gremlin')) avatar = '👺';
308
+ else if (visitor.visitor.includes('Knight') || visitor.visitor.includes('Paladin')) avatar = '🛡️';
309
+ else if (visitor.visitor.includes('Elf') || visitor.visitor.includes('Fae')) avatar = '🧝';
310
+ else if (visitor.visitor.includes('Cat') || visitor.visitor.includes('Familiar')) avatar = '🐱';
311
+ }
312
+
313
+ const displayPayment = (visitor.completed && visitor.actual_payment !== undefined && visitor.actual_payment !== null)
314
+ ? visitor.actual_payment
315
+ : visitor.base_payment;
316
+
317
+ item.innerHTML = `
318
+ <div class="visitor-avatar wobbly-border-sm">${avatar}</div>
319
+ <div class="visitor-info">
320
+ <div class="visitor-name">${visitor.visitor}</div>
321
+ <div class="visitor-req-object">${visitor.completed ? 'delivered' : 'wants'}: <strong>${visitor.object}</strong></div>
322
+ </div>
323
+ <div class="visitor-reward">${displayPayment}g</div>
324
+ `;
325
+
326
+ item.addEventListener('click', () => {
327
+ if (isDelivering) return;
328
+ selectVisitor(visitor.id);
329
+ });
330
+
331
+ visitorsList.appendChild(item);
332
+ });
333
+
334
+ // Render Locked Slots based on current stars
335
+ if (gameState && gameState.slot_unlock_stars) {
336
+ const totalStars = gameState.total_stars;
337
+ const unlockStars = gameState.slot_unlock_stars;
338
+ // Slot 3
339
+ if (unlockStars["3"] !== undefined && totalStars < unlockStars["3"]) {
340
+ renderLockedSlot(3, unlockStars["3"]);
341
+ }
342
+ // Slot 4
343
+ if (unlockStars["4"] !== undefined && totalStars < unlockStars["4"]) {
344
+ renderLockedSlot(4, unlockStars["4"]);
345
+ }
346
+ }
347
+ }
348
+
349
+ function renderLockedSlot(slotNum, reqStars) {
350
+ const item = document.createElement('div');
351
+ item.className = 'visitor-item locked wobbly-border-sm';
352
+ item.innerHTML = `
353
+ <div class="visitor-avatar wobbly-border-sm">🔒</div>
354
+ <div class="visitor-info">
355
+ <div class="visitor-name">Locked Slot ${slotNum}</div>
356
+ <div class="visitor-req-object">Requires ${reqStars} ⭐ to unlock</div>
357
+ </div>
358
+ <div class="visitor-reward">--g</div>
359
+ `;
360
+ visitorsList.appendChild(item);
361
+ }
362
+
363
+
364
+ // Select a visitor and render details (Center Column)
365
+ function selectVisitor(visitorId) {
366
+ selectedVisitorId = visitorId;
367
+
368
+ // Update active state in ledger
369
+ document.querySelectorAll('.visitor-item').forEach(el => el.classList.remove('active'));
370
+ const activeItem = visitors.find(v => v.id === visitorId);
371
+
372
+ // Find visitor element in DOM
373
+ const visitorItems = visitorsList.children;
374
+ for (let i = 0; i < visitors.length; i++) {
375
+ if (visitors[i].id === visitorId) {
376
+ if (visitorItems[i]) visitorItems[i].classList.add('active');
377
+ break;
378
+ }
379
+ }
380
+
381
+ if (!activeItem) {
382
+ visitorRequestCard.innerHTML = '<div class="card-loader">Select a guest from the ledger to begin crafting.</div>';
383
+ return;
384
+ }
385
+
386
+ renderVisitorCard(activeItem);
387
+ }
388
+
389
+ // Render Detail Request Card (Message and Properties)
390
+ function renderVisitorCard(visitor) {
391
+ // Determine if there are bonus properties (original length > 0)
392
+ const hasBonus = visitor.properties.length > 0;
393
+ let bonusSectionHtml = '';
394
+
395
+ if (hasBonus) {
396
+ const bonusPropertiesHtml = visitor.properties.map((prop, idx) => {
397
+ let starSymbol = '☆';
398
+
399
+ if (visitor.completed && visitor.breakdown && visitor.breakdown.passes) {
400
+ if (visitor.breakdown.passes[idx]) {
401
+ starSymbol = '★';
402
+ }
403
+ }
404
+
405
+ return `
406
+ <div class="property-bullet">
407
+ <span class="bullet-star">${starSymbol}</span>
408
+ <div>
409
+ <strong>${prop.predicate}</strong>
410
+ </div>
411
+ </div>
412
+ `;
413
+ }).join('');
414
+
415
+ bonusSectionHtml = `
416
+ <div class="request-properties-title">Bonus</div>
417
+ <div class="request-properties-list">
418
+ ${bonusPropertiesHtml}
419
+ </div>
420
+ `;
421
+ }
422
+
423
+ // Outcome/Breakdown box if completed
424
+ let outcomeBoxHtml = '';
425
+ if (visitor.completed && visitor.breakdown) {
426
+ const b = visitor.breakdown;
427
+ const statusClass = b.recognized ? 'success' : 'fail';
428
+ const headerText = b.recognized ? '✅ Transmutation Success!' : '❌ Transmutation Failed!';
429
+
430
+ let breakdownRows = '';
431
+ if (b.recognized) {
432
+ if (visitor.properties.length > 0) {
433
+ const pct = Math.round(b.stars_earned * (b.star_multiplier || 0.2) * 100);
434
+ breakdownRows = `
435
+ <li><span>Base Payment:</span><strong>+${b.base_payment}g</strong></li>
436
+ <li><span>Bonus (+${pct}%):</span><strong>+${b.bonus_earned}g</strong></li>
437
+ <li class="total"><span>Total Gold Received:</span><strong>${b.total_payment}g</strong></li>
438
+ `;
439
+ } else {
440
+ breakdownRows = `
441
+ <li><span>Base Payment:</span><strong>+${b.base_payment}g</strong></li>
442
+ <li class="total"><span>Total Gold Received:</span><strong>${b.total_payment}g</strong></li>
443
+ `;
444
+ }
445
+ } else {
446
+ breakdownRows = `
447
+ <li><span>Base Payment (50%):</span><strong style="color:var(--color-error)">+${b.base_payment_earned}g</strong></li>
448
+ <li class="total"><span>Total Gold Received:</span><strong>${b.total_payment}g</strong></li>
449
+ `;
450
+ }
451
+
452
+ outcomeBoxHtml = `
453
+ <div class="delivery-outcome-box ${statusClass}">
454
+ <h4>${headerText}</h4>
455
+ <ul class="breakdown-rows">
456
+ ${breakdownRows}
457
+ </ul>
458
+ </div>
459
+ `;
460
+ }
461
+
462
+ // Cross out details visually if completed
463
+ const completedStyle = visitor.completed ? 'style="opacity: 0.7;"' : '';
464
+ const strikethroughStyle = visitor.completed ? 'style="text-decoration: line-through;"' : '';
465
+
466
+ let doodleHtml = '';
467
+ // Prefer the generated image (image-gen second stage) over the raw doodle.
468
+ const deliveredImg = visitor.generated || visitor.doodle;
469
+ if (visitor.completed && deliveredImg) {
470
+ doodleHtml = `
471
+ <div class="delivered-doodle-container">
472
+ <img class="delivered-doodle-preview" src="${deliveredImg}" alt="Delivered Spell Image" />
473
+ </div>
474
+ `;
475
+ }
476
+
477
+ visitorRequestCard.innerHTML = `
478
+ <div ${completedStyle}>
479
+ <div class="request-meta">
480
+ <span class="request-visitor" ${strikethroughStyle}>${visitor.visitor}</span>
481
+ <span class="request-gold">${visitor.completed ? '💰 Received' : '💰 ' + visitor.base_payment + 'g'}</span>
482
+ </div>
483
+ <p class="request-flavor">"${visitor.flavor}"</p>
484
+ <div class="request-target">
485
+ Target: <strong style="color: var(--color-primary);" ${strikethroughStyle}>${visitor.object}</strong>
486
+ </div>
487
+ ${bonusSectionHtml}
488
+ </div>
489
+ ${doodleHtml}
490
+ ${outcomeBoxHtml}
491
+ `;
492
+
493
+ // Disable canvas action buttons if visitor is completed
494
+ if (visitor.completed) {
495
+ btnDeliver.disabled = true;
496
+ btnDeliver.innerHTML = 'Completed <span class="material-symbols-outlined">history_edu</span>';
497
+ btnReset.disabled = true;
498
+ } else if (gameState && gameState.status === 'playing') {
499
+ btnDeliver.disabled = false;
500
+ btnDeliver.innerHTML = 'Transmute <span class="material-symbols-outlined">history_edu</span>';
501
+ btnReset.disabled = false;
502
+ }
503
+ }
504
+
505
+
506
+ // Render structured payment breakdown on completion
507
+ // Outcomes are now rendered inline inside the visitor card details.
508
+
509
+ function appendTempLog(icon, text, className) {
510
+ const listDiv = outcomeContent.querySelector('.logs-list');
511
+ if (listDiv) {
512
+ const tempEntry = document.createElement('div');
513
+ tempEntry.className = `log-entry ${className}`;
514
+ tempEntry.innerHTML = `
515
+ <span class="log-icon">${icon}</span>
516
+ <span class="log-text">${text}</span>
517
+ `;
518
+ listDiv.appendChild(tempEntry);
519
+ const panel = document.getElementById('outcome-panel');
520
+ if (panel) {
521
+ panel.scrollTop = panel.scrollHeight;
522
+ }
523
+ } else {
524
+ outcomeContent.innerHTML = `<p class="empty-state">${text}</p>`;
525
+ }
526
+ }
527
+
528
+ function renderLogs(logs, status) {
529
+ if (!logs || logs.length === 0) {
530
+ outcomeContent.innerHTML = '<p class="empty-state">No actions logged yet.</p>';
531
+ return;
532
+ }
533
+
534
+ let html = '<div class="logs-list">';
535
+ logs.forEach(log => {
536
+ let icon = '📝';
537
+ let className = 'log-generic';
538
+
539
+ if (log.includes('Day ')) {
540
+ icon = '📅';
541
+ className = 'log-day-start';
542
+ } else if (log.includes('New customer ')) {
543
+ icon = '👤';
544
+ className = 'log-new-customer';
545
+ } else if (log.includes('Bought a ') && log.includes('Potion')) {
546
+ icon = '🧪';
547
+ className = 'log-shop';
548
+ } else if (log.includes('Crystal upgraded')) {
549
+ icon = '🔮';
550
+ className = 'log-shop';
551
+ } else if (log.includes('Successfully transmuted ')) {
552
+ icon = '✨';
553
+ className = 'log-delivery-success';
554
+ } else if (log.includes('Failed to transmute ')) {
555
+ icon = '⚠️';
556
+ className = 'log-delivery-fail';
557
+ } else if (log.includes('Paid ') && log.includes(' rent')) {
558
+ icon = '🌙';
559
+ className = 'log-rent-paid';
560
+ } else if (log.includes('Failed to pay rent')) {
561
+ icon = '💀';
562
+ className = 'log-rent-failed';
563
+ } else if (log.includes('Paid the remaining debt')) {
564
+ icon = '💸';
565
+ className = 'log-debt-paid';
566
+ } else if (log.includes('Failed to pay the debt')) {
567
+ icon = '💀';
568
+ className = 'log-debt-failed';
569
+ }
570
+
571
+ html += `
572
+ <div class="log-entry ${className}">
573
+ <span class="log-icon">${icon}</span>
574
+ <span class="log-text">${log}</span>
575
+ </div>
576
+ `;
577
+ });
578
+
579
+ if (status !== 'playing') {
580
+ let isWon = status === 'won';
581
+ let endMsg = isWon ? '🤗 YOU WON!' : '😭 GAME OVER';
582
+ html += `
583
+ <div class="${isWon ? 'outcome-success' : 'outcome-fail'}" style="text-align:center; padding: 12px; margin-top: 10px;">
584
+ <h3 style="font-size: 16px; margin: 0;">${endMsg}</h3>
585
+ <p style="margin: 6px 0 0 0; font-style:italic; font-size: 11px;">Click 'New Run' to try again.</p>
586
+ </div>
587
+ `;
588
+ }
589
+
590
+ html += '</div>';
591
+ outcomeContent.innerHTML = html;
592
+
593
+ const panel = document.getElementById('outcome-panel');
594
+ if (panel) {
595
+ panel.scrollTop = panel.scrollHeight;
596
+ }
597
+ }
598
+
599
+ // Refresh General Game State HUD & Shop
600
+ function refreshGameState(game) {
601
+ if (!game) return;
602
+ gameState = game;
603
+
604
+ // Status panel
605
+ statDay.textContent = `${game.day} / ${game.deadline_day}`;
606
+ statGold.textContent = `${game.gold}g`;
607
+ statDebt.textContent = `${game.debt}g`;
608
+ statStars.textContent = `${game.total_stars}`;
609
+
610
+ // Shop prices
611
+ costPotion.textContent = `${game.potion_price}g`;
612
+ costMaxMana.textContent = `${game.max_mana_price}g`;
613
+
614
+ // Refresh mana bar
615
+ updateManaBar();
616
+
617
+ // Render logs
618
+ renderLogs(game.logs, game.status);
619
+
620
+ // Lock controls if game is won/lost (terminal)
621
+ if (game.status !== 'playing') {
622
+ btnDeliver.disabled = true;
623
+ btnReset.disabled = true;
624
+ btnBuyPotion.disabled = true;
625
+ btnBuyMax.disabled = true;
626
+ btnEndDay.disabled = true;
627
+ } else {
628
+ btnDeliver.disabled = false;
629
+ btnReset.disabled = false;
630
+ btnBuyPotion.disabled = false;
631
+ btnBuyMax.disabled = false;
632
+ btnEndDay.disabled = false;
633
+ }
634
+ }
635
+
636
+ // Button Click Interactions
637
+ btnNewGame.addEventListener('click', async () => {
638
+ outcomeContent.innerHTML = '<p class="empty-state">Starting a new run...</p>';
639
+ clearCanvas();
640
+ spentMana = 0;
641
+
642
+ const res = await callAPI('start_game');
643
+ if (res && res.success) {
644
+ refreshGameState(res.game);
645
+ renderVisitorsList(res.visitors);
646
+ if (res.selected_visitor_id) {
647
+ selectVisitor(res.selected_visitor_id);
648
+ } else {
649
+ selectedVisitorId = null;
650
+ visitorRequestCard.innerHTML = '<div class="card-loader">Select a guest from the ledger to begin crafting.</div>';
651
+ }
652
+ } else {
653
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Failed to start game: ${res.error || 'Unknown error'}</p>`;
654
+ }
655
+ });
656
+
657
+ btnReset.addEventListener('click', async () => {
658
+ if (!gameState || gameState.status !== 'playing') return;
659
+
660
+ const res = await callAPI('reset_canvas', [spentMana]);
661
+ if (res && res.success) {
662
+ clearCanvas();
663
+ spentMana = 0;
664
+
665
+ // Hidden harness gesture: 5 consecutive Resets flip test mode on for this
666
+ // session (no off switch — refresh the page for a normal run). Count first so
667
+ // a UI-refresh hiccup can't swallow the click.
668
+ resetStreak += 1;
669
+ if (resetStreak >= 5) {
670
+ resetStreak = 0;
671
+ await enableTestMode();
672
+ return;
673
+ }
674
+ refreshGameState(res.game);
675
+ } else {
676
+ alert(res.error || 'Failed to reset canvas');
677
+ }
678
+ });
679
+
680
+ // Any press anywhere EXCEPT the Reset button breaks the consecutive-Reset gesture,
681
+ // so the test-mode combo can't be triggered accidentally across other actions.
682
+ // pointerdown (not click) so it also catches canvas draw-start and other drags;
683
+ // closest('#btn-reset') because the press target is usually the button's icon span.
684
+ document.addEventListener('pointerdown', (e) => {
685
+ if (!e.target.closest('#btn-reset')) resetStreak = 0;
686
+ });
687
+
688
+ // Start a fresh test-mode run (hidden 5-reset gesture). Mirrors btnNewGame, and
689
+ // reveals the image-gen tuning panel (hidden during normal play).
690
+ async function enableTestMode() {
691
+ const res = await callAPI('enable_testmode');
692
+ if (res && res.success) {
693
+ clearCanvas();
694
+ spentMana = 0;
695
+ const panel = document.getElementById('gen-tuning-panel');
696
+ if (panel) panel.style.display = '';
697
+ refreshGameState(res.game);
698
+ renderVisitorsList(res.visitors);
699
+ if (res.selected_visitor_id) {
700
+ selectVisitor(res.selected_visitor_id);
701
+ } else {
702
+ selectedVisitorId = null;
703
+ }
704
+ appendTempLog('🧪', res.message || 'Test mode enabled.', 'log-appraising');
705
+ }
706
+ }
707
+
708
+ // --- image-gen tuning panel -----------------------------------------------
709
+ // The panel's default values are rendered into the HTML from the Python
710
+ // constants by the server (get_index/_render_index) — the single source of
711
+ // truth — so there is nothing to hydrate client-side. readGenOpts just sends
712
+ // the current field values (incl. the editable style/negative text) on deliver.
713
+ function readGenOpts() {
714
+ const $ = (id) => document.getElementById(id);
715
+ const num = (id, fallback) => {
716
+ const el = $(id);
717
+ const v = el ? parseFloat(el.value) : NaN;
718
+ return Number.isFinite(v) ? v : fallback;
719
+ };
720
+ const str = (id) => {
721
+ const el = $(id);
722
+ return el ? el.value : '';
723
+ };
724
+ const enableEl = $('gen-enable');
725
+ return {
726
+ enabled: enableEl ? enableEl.checked : false,
727
+ steps: num('gen-steps', 18),
728
+ guidance: num('gen-guidance', 5.0),
729
+ controlnet_scale: num('gen-cnet', 1.1),
730
+ seed: num('gen-seed', 0),
731
+ style_suffix: str('gen-style'),
732
+ base_negative: str('gen-negative'),
733
+ };
734
+ }
735
+
736
+ btnDeliver.addEventListener('click', async () => {
737
+ if (!gameState || gameState.status !== 'playing') return;
738
+ if (!selectedVisitorId) {
739
+ alert('Select a visitor from the ledger first!');
740
+ return;
741
+ }
742
+
743
+ // Extract canvas PNG data URL
744
+ const pngDataUrl = canvas.toDataURL('image/png');
745
+
746
+ appendTempLog('⏳', 'Transmuting the glyph...', 'log-appraising');
747
+
748
+ // Save state & elements
749
+ isDelivering = true;
750
+ drawing = false;
751
+ startBleedingEffect();
752
+ const originalHTML = btnDeliver.innerHTML;
753
+ btnDeliver.disabled = true;
754
+ btnDeliver.innerHTML = 'Transmuting <span class="material-symbols-outlined">history_edu</span>';
755
+
756
+ // Disable guest items
757
+ document.querySelectorAll('.visitor-item').forEach(el => {
758
+ el.style.pointerEvents = 'none';
759
+ el.style.opacity = '0.6';
760
+ });
761
+
762
+ // Disable End Day
763
+ btnEndDay.disabled = true;
764
+
765
+ // Start mana bouncing
766
+ manaBarInner.classList.add('mana-bouncing');
767
+
768
+ let deliverySuccessful = false;
769
+ try {
770
+ const res = await callAPI('deliver', [pngDataUrl, spentMana, selectedVisitorId, JSON.stringify(readGenOpts())]);
771
+ if (res && res.success) {
772
+ deliverySuccessful = true;
773
+ stopBleedingEffect(true);
774
+ if (res.breakdown && res.breakdown.description) {
775
+ console.log(res.breakdown.description);
776
+ }
777
+ clearCanvas();
778
+ spentMana = 0;
779
+ refreshGameState(res.game);
780
+ renderVisitorsList(res.visitors);
781
+ // Outcomes are now displayed inline inside the selected completed guest card details
782
+
783
+ // Select next visitor automatically if available
784
+ if (res.selected_visitor_id) {
785
+ selectVisitor(res.selected_visitor_id);
786
+ } else {
787
+ selectedVisitorId = null;
788
+ visitorRequestCard.innerHTML = '<div class="card-loader">No visitors remaining today. Click "End Day" to progress.</div>';
789
+ }
790
+ } else {
791
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Appraisal error: ${res.error || 'Unknown error'}</p>`;
792
+ }
793
+ } catch (err) {
794
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Appraisal error: ${err.message || err}</p>`;
795
+ } finally {
796
+ isDelivering = false;
797
+ stopBleedingEffect(deliverySuccessful);
798
+
799
+ // Enable End Day (unless game state dictates otherwise, e.g. status !== 'playing')
800
+ if (gameState && gameState.status === 'playing') {
801
+ btnEndDay.disabled = false;
802
+ }
803
+
804
+ // Re-enable and restore visitor items
805
+ document.querySelectorAll('.visitor-item').forEach(el => {
806
+ el.style.pointerEvents = '';
807
+ el.style.opacity = '';
808
+ });
809
+
810
+ // Stop mana bouncing
811
+ manaBarInner.classList.remove('mana-bouncing');
812
+ updateManaBar();
813
+
814
+ // Restore deliver button text and disabled state based on the current selected visitor
815
+ const activeItem = selectedVisitorId ? visitors.find(v => v.id === selectedVisitorId) : null;
816
+ if (activeItem && activeItem.completed) {
817
+ btnDeliver.disabled = true;
818
+ btnDeliver.innerHTML = 'Completed <span class="material-symbols-outlined">history_edu</span>';
819
+ } else {
820
+ btnDeliver.innerHTML = originalHTML;
821
+ if (gameState && gameState.status === 'playing' && selectedVisitorId) {
822
+ btnDeliver.disabled = false;
823
+ } else {
824
+ btnDeliver.disabled = true;
825
+ }
826
+ }
827
+ }
828
+ });
829
+
830
+ btnBuyPotion.addEventListener('click', async () => {
831
+ if (!gameState || gameState.status !== 'playing') return;
832
+
833
+ const res = await callAPI('buy_potion');
834
+ if (res && res.success) {
835
+ refreshGameState(res.game);
836
+ } else {
837
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Cannot buy potion: ${res.error || 'Not enough gold'}</p>`;
838
+ }
839
+ });
840
+
841
+ btnBuyMax.addEventListener('click', async () => {
842
+ if (!gameState || gameState.status !== 'playing') return;
843
+
844
+ const res = await callAPI('buy_max_mana');
845
+ if (res && res.success) {
846
+ refreshGameState(res.game);
847
+ } else {
848
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Cannot upgrade max mana: ${res.error || 'Not enough gold'}</p>`;
849
+ }
850
+ });
851
+
852
+ btnEndDay.addEventListener('click', async () => {
853
+ if (!gameState || gameState.status !== 'playing') return;
854
+
855
+ appendTempLog('⏳', 'Paying rent and starting next day...', 'log-advancing');
856
+ clearCanvas();
857
+ spentMana = 0;
858
+
859
+ const res = await callAPI('end_day');
860
+ if (res && res.success) {
861
+ refreshGameState(res.game);
862
+ renderVisitorsList(res.visitors);
863
+
864
+ if (res.selected_visitor_id) {
865
+ selectVisitor(res.selected_visitor_id);
866
+ } else {
867
+ selectedVisitorId = null;
868
+ visitorRequestCard.innerHTML = '<div class="card-loader">No visitors today.</div>';
869
+ }
870
+ } else {
871
+ outcomeContent.innerHTML = `<p class="empty-state" style="color:var(--color-error)">Failed to end day: ${res.error || 'Unknown error'}</p>`;
872
+ }
873
+ });
874
+
875
+ // Instructions Modal Listeners
876
+ btnInstructions.addEventListener('click', () => {
877
+ instructionsModal.classList.remove('hidden');
878
+ });
879
+
880
+ btnCloseInstructions.addEventListener('click', () => {
881
+ instructionsModal.classList.add('hidden');
882
+ });
883
+
884
+ instructionsModal.addEventListener('click', (e) => {
885
+ if (e.target === instructionsModal) {
886
+ instructionsModal.classList.add('hidden');
887
+ }
888
+ });
889
+
890
+ // Initialize game on load
891
+ window.addEventListener('DOMContentLoaded', () => {
892
+ // Trigger New Game automatically on start
893
+ btnNewGame.click();
894
+ });
895
+
witch_wood/ui/static/index.html ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>🧙‍♀️ Inkwitch — Redesign</title>
7
+
8
+ <!-- Font and Icon Imports -->
9
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700;800&family=Quicksand:wght@400;500;600&display=swap" rel="stylesheet"/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
11
+
12
+ <!-- Custom Stylesheet with cache buster -->
13
+ <link rel="stylesheet" href="/static/style.css?v=22"/>
14
+ </head>
15
+ <body class="paper-texture">
16
+
17
+ <!-- Top Status Bar (Horizontal HUD) -->
18
+ <header class="status-bar wobbly-border shadow-offset">
19
+ <div class="status-bar-brand">
20
+ <h1 class="handwritten-brand">InkWitch</h1>
21
+
22
+ <!-- Top Action Controls (Moved New Run here, added Instructions) -->
23
+ <div class="top-bar-actions">
24
+ <button id="btn-new-game" class="btn-sketch btn-warning btn-sm">
25
+ New Run <span class="material-symbols-outlined">restart_alt</span>
26
+ </button>
27
+ <button id="btn-instructions" class="btn-sketch btn-secondary btn-sm">
28
+ Instruction <span class="material-symbols-outlined">assignment</span>
29
+ </button>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="status-bar-items">
34
+ <div class="status-item-horizontal">
35
+ <span class="icon">📅</span>
36
+ <span class="stat-label">Day:</span>
37
+ <span id="stat-day" class="value">-/-</span>
38
+ </div>
39
+ <div class="status-item-horizontal">
40
+ <span class="icon">💰</span>
41
+ <span class="stat-label">Gold:</span>
42
+ <span id="stat-gold" class="value">--g</span>
43
+ </div>
44
+ <div class="status-item-horizontal">
45
+ <span class="icon">🔑</span>
46
+ <span class="stat-label">Rent:</span>
47
+ <span class="value">{{WW_RENT}}g</span>
48
+ </div>
49
+ <div class="status-item-horizontal">
50
+ <span class="icon">⚖️</span>
51
+ <span class="stat-label">Debt:</span>
52
+ <span id="stat-debt" class="value">--g</span>
53
+ </div>
54
+ <div class="status-item-horizontal">
55
+ <span class="icon">⭐</span>
56
+ <span class="stat-label">Reputation:</span>
57
+ <span id="stat-stars" class="value">--</span>
58
+ </div>
59
+ </div>
60
+ </header>
61
+
62
+ <!-- Main Panels Container (Distributed Horizontally) -->
63
+ <div class="workbench-panels">
64
+
65
+ <!-- Left Column: Visitors & Request Ledger -->
66
+ <aside class="ledger-panel wobbly-border shadow-offset transform-left">
67
+ <div class="panel-header">
68
+ <h2 class="handwritten-title">Guest List</h2>
69
+ <p class="tagline">Today's active guests...</p>
70
+ </div>
71
+
72
+ <!-- List of Today's Visitors -->
73
+ <div id="visitors-list" class="visitors-list">
74
+ <!-- Rendered dynamically in app.js -->
75
+ <p class="empty-state">No visitors left today.</p>
76
+ </div>
77
+
78
+ <hr class="ledger-divider"/>
79
+
80
+ <!-- Selected Visitor Card -->
81
+ <section id="visitor-request-card" class="visitor-request-card wobbly-border-sm paper-sheet">
82
+ <div class="card-loader">Select a guest from the ledger to begin crafting.</div>
83
+ </section>
84
+
85
+ </aside>
86
+
87
+ <!-- Center Column: The Workbench & Canvas (Spacious drawing area) -->
88
+ <main class="canvas-panel wobbly-border shadow-offset">
89
+
90
+ <!-- Central Drawing Area -->
91
+ <section class="drawing-area pencil-bg wobbly-border-sm">
92
+
93
+ <!-- Mana Bar (Metered Progress) -->
94
+ <div class="mana-container">
95
+ <div class="mana-meta">
96
+ <span class="label">Mana</span>
97
+ <span id="mana-text" class="value">-- / --</span>
98
+ </div>
99
+ <div class="mana-bar-outer wobbly-border-sm">
100
+ <div id="mana-bar-inner" class="mana-bar-inner scribble-fill" style="width: 100%;"></div>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- HTML5 Canvas -->
105
+ <div class="canvas-wrapper wobbly-border-sm">
106
+ <canvas id="paint-canvas" width="384" height="384"></canvas>
107
+ </div>
108
+
109
+ <!-- Workbench Action Buttons -->
110
+ <div class="workbench-actions">
111
+ <button id="btn-reset" class="btn-sketch btn-secondary">
112
+ <span class="material-symbols-outlined">mop</span> Reset
113
+ </button>
114
+ <button id="btn-deliver" class="btn-sketch btn-primary">
115
+ Transmute <span class="material-symbols-outlined">history_edu</span>
116
+ </button>
117
+ </div>
118
+
119
+ </section>
120
+
121
+ </main>
122
+
123
+ <!-- Right Column: Shop & Controls -->
124
+ <div class="right-column transform-right">
125
+
126
+ <!-- End Day (Moved here, above shop) -->
127
+ <button id="btn-end-day" class="btn-sketch btn-danger">
128
+ End Day & Pay Rent <span class="material-symbols-outlined">bedtime</span>
129
+ </button>
130
+
131
+ <!-- image-gen tuning panel — hidden in normal play; revealed only after the
132
+ hidden test-mode gesture (app.js enableTestMode). Image-gen itself still
133
+ runs in normal play using the default params below. Default values are
134
+ injected from the Python constants by the server (get_index); do NOT
135
+ hardcode them here. -->
136
+ <section id="gen-tuning-panel" class="shop-panel wobbly-border shadow-offset" style="display:none;">
137
+ <h3 class="handwritten-title">🧪 Image-gen (temp)</h3>
138
+ <label style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
139
+ <input type="checkbox" id="gen-enable" {{WW_GEN_ENABLED_CHECKED}} /> Enable image generation
140
+ </label>
141
+ <label style="display:block; margin-bottom:6px;">
142
+ Steps <input type="number" id="gen-steps" value="{{WW_GEN_STEPS}}" min="1" max="50" step="1" style="width:70px;" />
143
+ </label>
144
+ <label style="display:block; margin-bottom:6px;">
145
+ Guidance <input type="number" id="gen-guidance" value="{{WW_GEN_GUIDANCE}}" min="1" max="15" step="0.5" style="width:70px;" />
146
+ </label>
147
+ <label style="display:block; margin-bottom:6px;">
148
+ ControlNet scale <input type="number" id="gen-cnet" value="{{WW_GEN_CNET_SCALE}}" min="0.1" max="1.5" step="0.05" style="width:70px;" />
149
+ </label>
150
+ <label style="display:block; margin-bottom:6px;">
151
+ Seed <input type="number" id="gen-seed" value="{{WW_GEN_SEED}}" min="0" step="1" style="width:90px;" />
152
+ </label>
153
+ <label style="display:block; margin-bottom:6px;">
154
+ Style suffix
155
+ <textarea id="gen-style" rows="3" style="width:100%; box-sizing:border-box; resize:vertical;">{{WW_STYLE_SUFFIX}}</textarea>
156
+ </label>
157
+ <label style="display:block;">
158
+ Base negative
159
+ <textarea id="gen-negative" rows="3" style="width:100%; box-sizing:border-box; resize:vertical;">{{WW_BASE_NEGATIVE}}</textarea>
160
+ </label>
161
+ </section>
162
+
163
+ <!-- Shop Panel (Only Goods) -->
164
+ <section class="shop-panel wobbly-border shadow-offset">
165
+ <h3 class="handwritten-title">Shop</h3>
166
+ <div class="upgrade-row">
167
+ <button id="btn-buy-potion" class="btn-sketch btn-shop">
168
+ <div class="shop-item-desc">
169
+ <span class="icon">🧪</span>
170
+ <div class="label-group">
171
+ <span class="item-name">Mana Potion</span>
172
+ <span class="item-benefit">+{{WW_POTION_MANA}} mana</span>
173
+ </div>
174
+ </div>
175
+ <span id="cost-potion" class="price">--g</span>
176
+ </button>
177
+
178
+ <button id="btn-buy-max" class="btn-sketch btn-shop">
179
+ <div class="shop-item-desc">
180
+ <span class="icon">🔮</span>
181
+ <div class="label-group">
182
+ <span class="item-name">Crystal Upgrade</span>
183
+ <span class="item-benefit">+{{WW_MANA_UPGRADE_AMOUNT}} max mana</span>
184
+ </div>
185
+ </div>
186
+ <span id="cost-max-mana" class="price">--g</span>
187
+ </button>
188
+ </div>
189
+ </section>
190
+
191
+ <!-- Workbench Logs Card -->
192
+ <section id="outcome-panel" class="outcome-panel wobbly-border shadow-offset">
193
+ <h3 class="handwritten-title">Logbook</h3>
194
+ <div id="outcome-content" class="outcome-content">
195
+ <p class="empty-state">No actions logged yet.</p>
196
+ </div>
197
+ </section>
198
+
199
+ </div>
200
+
201
+ </div>
202
+
203
+ <!-- Instruction Modal (Hand-drawn instruction booklet) -->
204
+ <div id="instructions-modal" class="modal-overlay hidden">
205
+ <div class="modal-content wobbly-border shadow-offset paper-sheet">
206
+ <div class="modal-header">
207
+ <h3 class="handwritten-title">You Better Work, Witch</h3>
208
+ <button id="btn-close-instructions" class="btn-close">&times;</button>
209
+ </div>
210
+ <div class="modal-body">
211
+ <p style="margin-top: 6px; font-weight: 500;">
212
+ Dear Witch, your research funds are completely dried up, and your loan is due. To save my investment, I've converted your lab into a retail store. If you want to keep your roof and pay off your debt, you better get to work.
213
+ </p>
214
+ <p style="margin-top: 6px; font-weight: 500;">
215
+ Here is how we are going to run this store to get my money back:
216
+ </p>
217
+ <ul style="margin-top: 8px;">
218
+ <li><strong>Drawing</strong>: Draw a glyph of what the guests want on the canvas. Drawing drains your mana. No mana, no magic.</li>
219
+ <li><strong>Clean Slate</strong>: Resetting refunds <strong>{{WW_CANVAS_RESET_REFUND_PCT}}%</strong> of the mana spent on the current drawing.</li>
220
+ <li><strong>Transmutation</strong>: Transmute your glyph and sell your creation. Pass basic check to get base pay. Earn bonus gold for each matching quality.</li>
221
+ <li><strong>Shop</strong>: Buy potions to temporarily restore mana, or upgrade your crystal to permanently expand your maximum mana capacity.</li>
222
+ <li><strong>Daily Overhead</strong>: I will deduct <strong>{{WW_RENT}} gold rent</strong> each night. Pay the full <strong>{{WW_DEBT}} gold debt</strong> by Day {{WW_DEADLINE_DAY}} or you are bankrupt!</li>
223
+ </ul>
224
+ <p style="margin-top: 10px; font-style: italic; text-align: right; color: var(--color-secondary);">
225
+ — Golan, Zero Bank
226
+ </p>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- JavaScript Entry Point with cache buster -->
232
+ <script src="/static/app.js?v=26"></script>
233
+ </body>
234
+ </html>
witch_wood/ui/static/style.css ADDED
@@ -0,0 +1,1134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* style.css - Inkwitch Redesign Style Sheet */
2
+
3
+ /* Global Colors & Design Tokens */
4
+ :root {
5
+ --color-ink: #2c2825; /* Charcoal Ink instead of harsh black */
6
+ --color-paper: #fffcf8; /* Off-white paper background */
7
+ --color-desk: #ede6d9; /* Muted desk color under the notebook */
8
+ --color-primary: #3d608e; /* Primary Ink Blue */
9
+ --color-primary-light: #d8e6f5;/* Light Ink Blue background */
10
+ --color-secondary: #894d59; /* Dusty Rose for accents / shop items */
11
+ --color-secondary-light: #ffd9df;
12
+ --color-success: #3a6836; /* Forest Green for checks/wins */
13
+ --color-success-light: #e2f5df;
14
+ --color-warning: #b78a2a; /* Antique Gold */
15
+ --color-warning-light: #fef4db;
16
+ --color-error: #ba1a1a; /* Crimson */
17
+ --color-error-light: #ffdad6;
18
+ --color-card-bg: #ffffff;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ margin: 0;
24
+ padding: 0;
25
+ }
26
+
27
+ body {
28
+ font-family: 'Quicksand', sans-serif;
29
+ color: var(--color-ink);
30
+ background-color: var(--color-desk);
31
+ height: 100vh;
32
+ padding: 16px;
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 16px;
36
+ overflow: hidden; /* Force everything to stay within viewport */
37
+ }
38
+
39
+ .paper-texture {
40
+ background-image:
41
+ radial-gradient(#cabfad 1px, transparent 1px),
42
+ url("https://www.transparenttextures.com/patterns/natural-paper.png");
43
+ background-size: 30px 30px, auto;
44
+ }
45
+
46
+ /* Wobbly Border Utilities */
47
+ .wobbly-border {
48
+ border: 2px solid var(--color-ink);
49
+ border-radius: 255px 25px 225px 25px/25px 225px 25px 255px;
50
+ position: relative;
51
+ background-color: var(--color-paper);
52
+ }
53
+
54
+ .wobbly-border-sm {
55
+ border: 2px solid var(--color-ink);
56
+ border-radius: 120px 12px 100px 12px/12px 100px 12px 120px;
57
+ }
58
+
59
+ /* Card Drop Offset Shadows (Tactile Stationery Style) */
60
+ .shadow-offset {
61
+ box-shadow: 5px 5px 0px 0px var(--color-ink);
62
+ transition: transform 0.2s, box-shadow 0.2s;
63
+ }
64
+
65
+ /* Top Horizontal Status Bar */
66
+ .status-bar {
67
+ display: flex;
68
+ justify-content: space-between;
69
+ align-items: center;
70
+ padding: 10px 24px;
71
+ background-color: var(--color-paper);
72
+ border-radius: 200px 15px 200px 15px/15px 200px 15px 200px;
73
+ height: 60px;
74
+ flex-shrink: 0;
75
+ }
76
+
77
+ .status-bar-brand {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 10px;
81
+ }
82
+
83
+ .brand-icon {
84
+ font-size: 28px;
85
+ color: var(--color-primary);
86
+ }
87
+
88
+ .handwritten-brand {
89
+ font-family: 'Bricolage Grotesque', sans-serif;
90
+ font-weight: 800;
91
+ font-size: 20px;
92
+ letter-spacing: -0.01em;
93
+ }
94
+
95
+ .status-bar-items {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 24px;
99
+ }
100
+
101
+ .status-item-horizontal {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 8px;
105
+ font-family: 'Bricolage Grotesque', sans-serif;
106
+ font-weight: 700;
107
+ font-size: 16px;
108
+ padding: 4px 12px;
109
+ background-color: #ffffff;
110
+ border: 1px dashed rgba(44, 40, 37, 0.3);
111
+ border-radius: 6px;
112
+ }
113
+
114
+ .status-item-horizontal .icon {
115
+ font-size: 18px;
116
+ }
117
+
118
+ /* Layout Grid styling - distributed horizontally and viewport constrained */
119
+ .workbench-panels {
120
+ display: grid;
121
+ grid-template-columns: 280px 1fr 300px;
122
+ gap: 20px;
123
+ flex-grow: 1;
124
+ height: calc(100vh - 108px); /* Remaining viewport height */
125
+ min-height: 0; /* Let grid items shrink properly */
126
+ }
127
+
128
+ /* Right Column Wrapper */
129
+ .right-column {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 12px;
133
+ height: 100%;
134
+ overflow: hidden;
135
+ }
136
+
137
+ /* Panel Containers */
138
+ .ledger-panel, .canvas-panel {
139
+ padding: 16px;
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 12px;
143
+ height: 100%;
144
+ overflow: hidden; /* Prevent panels from growing beyond the layout height */
145
+ }
146
+
147
+ .shop-panel {
148
+ padding: 16px;
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 12px;
152
+ flex-shrink: 0;
153
+ }
154
+
155
+ /* Rotating panels for organic sketch look */
156
+ .transform-left {
157
+ transform: rotate(-0.5deg);
158
+ }
159
+
160
+ .transform-right {
161
+ transform: rotate(0.5deg);
162
+ }
163
+
164
+ /* Typography styles */
165
+ .handwritten-title {
166
+ font-family: 'Bricolage Grotesque', sans-serif;
167
+ font-weight: 800;
168
+ font-size: 20px;
169
+ line-height: 1.2;
170
+ border-bottom: 2px dashed rgba(44, 40, 37, 0.2);
171
+ padding-bottom: 4px;
172
+ margin-bottom: 2px;
173
+ }
174
+
175
+ .panel-header {
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 2px;
179
+ flex-shrink: 0;
180
+ }
181
+
182
+ .panel-header .header-icon {
183
+ font-size: 28px;
184
+ color: var(--color-primary);
185
+ }
186
+
187
+ .tagline {
188
+ font-size: 12px;
189
+ color: #666;
190
+ font-style: italic;
191
+ }
192
+
193
+ /* List of visitors (Left Panel) */
194
+ .visitors-list {
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 10px;
198
+ overflow-y: auto;
199
+ flex-grow: 1;
200
+ padding-right: 4px;
201
+ max-height: 48%; /* Leave room for card details below */
202
+ }
203
+
204
+ .visitor-item {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 10px;
208
+ padding: 10px;
209
+ background-color: #ffffff;
210
+ cursor: pointer;
211
+ transition: transform 0.2s, background-color 0.2s;
212
+ flex-shrink: 0;
213
+ }
214
+
215
+ .visitor-item:hover {
216
+ transform: scale(1.02) rotate(1deg);
217
+ background-color: var(--color-primary-light);
218
+ }
219
+
220
+ .visitor-item.active {
221
+ background-color: var(--color-primary-light);
222
+ border-color: var(--color-primary);
223
+ box-shadow: 3px 3px 0px 0px var(--color-ink);
224
+ }
225
+
226
+ .visitor-item.completed {
227
+ background-color: #eae6db;
228
+ border-color: rgba(44, 40, 37, 0.2);
229
+ opacity: 0.8;
230
+ }
231
+
232
+ .visitor-item.completed:hover {
233
+ background-color: #dfdacd;
234
+ }
235
+
236
+ .visitor-item.completed.active {
237
+ background-color: var(--color-primary-light);
238
+ opacity: 1;
239
+ }
240
+
241
+ .visitor-item.completed .visitor-name {
242
+ text-decoration: line-through;
243
+ color: #777;
244
+ }
245
+
246
+ .visitor-item.completed .visitor-req-object {
247
+ color: #888;
248
+ }
249
+
250
+ .visitor-item.completed .visitor-reward {
251
+ color: var(--color-success);
252
+ font-weight: 800;
253
+ }
254
+
255
+ .visitor-item.locked {
256
+ background-color: #ede6d9;
257
+ border: 2px dashed rgba(44, 40, 37, 0.4);
258
+ cursor: not-allowed;
259
+ opacity: 0.6;
260
+ }
261
+
262
+ .visitor-item.locked:hover {
263
+ transform: none !important;
264
+ background-color: #ede6d9;
265
+ box-shadow: none !important;
266
+ }
267
+
268
+ .visitor-item.locked .visitor-name {
269
+ color: #888;
270
+ font-style: italic;
271
+ font-weight: 500;
272
+ }
273
+
274
+ .visitor-item.locked .visitor-req-object {
275
+ color: #999;
276
+ }
277
+
278
+
279
+ .visitor-avatar {
280
+ width: 38px;
281
+ height: 38px;
282
+ background-color: #ede6d9;
283
+ display: flex;
284
+ justify-content: center;
285
+ align-items: center;
286
+ font-size: 18px;
287
+ flex-shrink: 0;
288
+ }
289
+
290
+ .visitor-info {
291
+ flex-grow: 1;
292
+ min-width: 0; /* Allows ellipsis/truncation of child text */
293
+ }
294
+
295
+ .visitor-name {
296
+ font-family: 'Bricolage Grotesque', sans-serif;
297
+ font-weight: 700;
298
+ font-size: 14px;
299
+ overflow: hidden;
300
+ text-overflow: ellipsis;
301
+ white-space: nowrap;
302
+ }
303
+
304
+ .visitor-req-object {
305
+ font-size: 12px;
306
+ color: #555;
307
+ overflow: hidden;
308
+ text-overflow: ellipsis;
309
+ white-space: nowrap;
310
+ }
311
+
312
+ .visitor-reward {
313
+ font-family: 'Bricolage Grotesque', sans-serif;
314
+ font-weight: 700;
315
+ font-size: 13px;
316
+ color: var(--color-warning);
317
+ flex-shrink: 0;
318
+ }
319
+
320
+ /* Selected Visitor Card (Center Panel) */
321
+ .visitor-request-card {
322
+ padding: 12px 16px;
323
+ background-color: #fffde6; /* Sticky note yellow */
324
+ transform: rotate(-0.5deg);
325
+ display: flex;
326
+ flex-direction: column;
327
+ gap: 6px;
328
+ flex-shrink: 0;
329
+ max-height: 48%;
330
+ overflow-y: auto; /* Internal scrolling if properties lists get long */
331
+ }
332
+
333
+ .card-loader {
334
+ font-style: italic;
335
+ color: #666;
336
+ text-align: center;
337
+ padding: 6px;
338
+ font-size: 13px;
339
+ }
340
+
341
+ .request-meta {
342
+ display: flex;
343
+ justify-content: space-between;
344
+ align-items: center;
345
+ border-bottom: 1px dotted rgba(44, 40, 37, 0.3);
346
+ padding-bottom: 4px;
347
+ }
348
+
349
+ .request-visitor {
350
+ font-family: 'Bricolage Grotesque', sans-serif;
351
+ font-weight: 800;
352
+ font-size: 16px;
353
+ color: var(--color-secondary);
354
+ }
355
+
356
+ .request-gold {
357
+ font-family: 'Bricolage Grotesque', sans-serif;
358
+ font-size: 14px;
359
+ font-weight: 700;
360
+ color: var(--color-warning);
361
+ }
362
+
363
+ .request-flavor {
364
+ font-size: 13px;
365
+ font-style: italic;
366
+ color: #555;
367
+ line-height: 1.3;
368
+ }
369
+
370
+ .request-target {
371
+ font-size: 14px;
372
+ font-weight: 600;
373
+ }
374
+
375
+ .request-properties-title {
376
+ font-size: 11px;
377
+ font-weight: 700;
378
+ text-transform: uppercase;
379
+ color: #666;
380
+ }
381
+
382
+ .request-properties-list {
383
+ display: flex;
384
+ flex-direction: row;
385
+ flex-wrap: wrap;
386
+ gap: 12px;
387
+ }
388
+
389
+ .property-bullet {
390
+ display: flex;
391
+ align-items: flex-start;
392
+ gap: 6px;
393
+ font-size: 13px;
394
+ }
395
+
396
+ .bullet-star {
397
+ color: var(--color-primary);
398
+ font-size: 14px;
399
+ margin-top: 1px;
400
+ }
401
+
402
+ .property-predicate {
403
+ font-size: 11px;
404
+ color: #666;
405
+ font-style: italic;
406
+ }
407
+
408
+ /* Delivered Doodle styling */
409
+ .delivered-doodle-container {
410
+ margin-top: 10px;
411
+ padding: 0;
412
+ border: 2px dashed var(--color-ink);
413
+ border-radius: 8px;
414
+ background-color: #ffffff;
415
+ box-shadow: 2px 2px 0px 0px rgba(0,0,0,0.15);
416
+ display: flex;
417
+ justify-content: center;
418
+ align-items: center;
419
+ box-sizing: border-box;
420
+ }
421
+
422
+ .delivered-doodle-preview {
423
+ width: 100%;
424
+ height: auto;
425
+ display: block;
426
+ border-radius: 6px;
427
+ }
428
+
429
+ /* Drawing Workbench Section */
430
+ .drawing-area {
431
+ padding: 12px;
432
+ display: flex;
433
+ flex-direction: column;
434
+ align-items: center;
435
+ gap: 12px;
436
+ flex-grow: 1;
437
+ justify-content: center;
438
+ min-height: 0;
439
+ }
440
+
441
+ /* Mana Indicator */
442
+ .mana-container {
443
+ width: 100%;
444
+ max-width: 384px;
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: 4px;
448
+ flex-shrink: 0;
449
+ }
450
+
451
+ .mana-meta {
452
+ display: flex;
453
+ justify-content: space-between;
454
+ font-size: 12px;
455
+ font-weight: 600;
456
+ }
457
+
458
+ .mana-meta .label {
459
+ text-transform: uppercase;
460
+ letter-spacing: 0.05em;
461
+ color: #666;
462
+ }
463
+
464
+ .mana-meta .value {
465
+ font-family: monospace;
466
+ font-weight: bold;
467
+ }
468
+
469
+ .mana-bar-outer {
470
+ height: 12px;
471
+ background-color: #ffffff;
472
+ overflow: hidden;
473
+ padding: 1px;
474
+ }
475
+
476
+ .mana-bar-inner {
477
+ height: 100%;
478
+ background-color: var(--color-primary);
479
+ border-radius: 6px;
480
+ transition: width 0.1s ease-out;
481
+ }
482
+
483
+ /* Scribble Texture for Mana Fill */
484
+ @keyframes scribble {
485
+ 0% { transform: skewX(-3deg) translateY(0); }
486
+ 50% { transform: skewX(3deg) translateY(0.5px); }
487
+ 100% { transform: skewX(-3deg) translateY(0); }
488
+ }
489
+
490
+ .scribble-fill {
491
+ background-image: repeating-linear-gradient(45deg, rgba(255,255,255,0.15), rgba(255,255,255,0.15) 2px, transparent 2px, transparent 4px);
492
+ animation: scribble 0.3s infinite ease-in-out;
493
+ }
494
+
495
+ .mana-bouncing {
496
+ width: 30% !important;
497
+ animation: scribble 0.3s infinite ease-in-out, mana-ping-pong 1.5s infinite ease-in-out !important;
498
+ }
499
+
500
+ @keyframes mana-ping-pong {
501
+ 0% { margin-left: 0%; }
502
+ 50% { margin-left: 70%; }
503
+ 100% { margin-left: 0%; }
504
+ }
505
+
506
+ /* HTML5 Canvas styles */
507
+ .canvas-wrapper {
508
+ background-color: #ffffff;
509
+ display: inline-block;
510
+ padding: 2px;
511
+ flex-shrink: 1;
512
+ min-height: 0;
513
+ }
514
+
515
+ #paint-canvas {
516
+ display: block;
517
+ background-color: #ffffff;
518
+ touch-action: none;
519
+ cursor: crosshair;
520
+ max-height: 100%;
521
+ max-width: 100%;
522
+ }
523
+
524
+ .canvas-wrapper.wobbly-border-sm {
525
+ border-radius: 8px;
526
+ }
527
+
528
+ /* Action Buttons */
529
+ .workbench-actions {
530
+ display: flex;
531
+ gap: 12px;
532
+ width: 100%;
533
+ max-width: 384px;
534
+ flex-shrink: 0;
535
+ }
536
+
537
+ .btn-sketch {
538
+ flex-grow: 1;
539
+ font-family: 'Bricolage Grotesque', sans-serif;
540
+ font-weight: 700;
541
+ font-size: 14px;
542
+ padding: 10px 14px;
543
+ border: 2px solid var(--color-ink);
544
+ background-color: #ffffff;
545
+ cursor: pointer;
546
+ display: inline-flex;
547
+ align-items: center;
548
+ justify-content: center;
549
+ gap: 6px;
550
+ box-shadow: 3px 3px 0px 0px var(--color-ink);
551
+ transition: transform 0.1s, box-shadow 0.1s;
552
+ }
553
+
554
+ /* Wobbly shape definitions for buttons */
555
+ .btn-sketch {
556
+ border-radius: 80px 10px 90px 10px/10px 90px 10px 80px;
557
+ }
558
+
559
+ .btn-sketch:hover {
560
+ transform: translate(-1px, -1px) rotate(-0.5deg);
561
+ box-shadow: 4px 4px 0px 0px var(--color-ink);
562
+ }
563
+
564
+ .btn-sketch:active {
565
+ transform: translate(2px, 2px);
566
+ box-shadow: 1px 1px 0px 0px var(--color-ink);
567
+ }
568
+
569
+ .btn-primary {
570
+ background-color: var(--color-primary-light);
571
+ color: var(--color-primary);
572
+ font-size: 15px;
573
+ font-weight: 800;
574
+ }
575
+
576
+ .btn-primary:hover {
577
+ background-color: #c5dcfa;
578
+ }
579
+
580
+ .btn-secondary {
581
+ color: #555;
582
+ }
583
+
584
+ .btn-danger {
585
+ background-color: var(--color-error-light);
586
+ color: var(--color-error);
587
+ }
588
+
589
+ .btn-danger:hover {
590
+ background-color: #ffc2bb;
591
+ }
592
+
593
+ .btn-warning {
594
+ background-color: var(--color-warning-light);
595
+ color: var(--color-warning);
596
+ }
597
+
598
+ .btn-warning:hover {
599
+ background-color: #fcebbb;
600
+ }
601
+
602
+ /* Right Panel: Shop, Controls & Outcomes */
603
+
604
+ /* Shop Upgrades Shelf */
605
+ .shop-upgrades {
606
+ display: flex;
607
+ flex-direction: column;
608
+ gap: 8px;
609
+ flex-shrink: 0;
610
+ }
611
+
612
+ .upgrade-row {
613
+ display: flex;
614
+ flex-direction: column;
615
+ gap: 8px;
616
+ }
617
+
618
+ .btn-shop {
619
+ width: 100%;
620
+ display: flex;
621
+ justify-content: space-between;
622
+ align-items: center;
623
+ text-align: left;
624
+ border-radius: 120px 10px 100px 10px/10px 100px 10px 120px;
625
+ }
626
+
627
+ .shop-item-desc {
628
+ display: flex;
629
+ align-items: center;
630
+ gap: 8px;
631
+ }
632
+
633
+ .shop-item-desc .icon {
634
+ font-size: 20px;
635
+ }
636
+
637
+ .label-group {
638
+ display: flex;
639
+ flex-direction: column;
640
+ }
641
+
642
+ .item-name {
643
+ font-weight: bold;
644
+ font-size: 13px;
645
+ }
646
+
647
+ .item-benefit {
648
+ font-size: 10px;
649
+ color: #666;
650
+ font-style: italic;
651
+ }
652
+
653
+ .btn-shop .price {
654
+ font-family: 'Bricolage Grotesque', sans-serif;
655
+ font-weight: 800;
656
+ font-size: 14px;
657
+ color: var(--color-warning);
658
+ background-color: #ffffff;
659
+ padding: 2px 6px;
660
+ border: 1px solid var(--color-ink);
661
+ border-radius: 4px;
662
+ }
663
+
664
+ /* Control actions bottom */
665
+ .control-actions {
666
+ display: flex;
667
+ flex-direction: column;
668
+ gap: 8px;
669
+ flex-shrink: 0;
670
+ }
671
+
672
+ /* Outcome Panel (Right Panel bottom) */
673
+ .outcome-panel {
674
+ padding: 12px;
675
+ background-color: var(--color-paper);
676
+ transform: rotate(-0.5deg);
677
+ flex-grow: 1;
678
+ overflow-y: auto; /* Internal scrolling */
679
+ min-height: 100px;
680
+ }
681
+
682
+ .outcome-content {
683
+ font-size: 13px;
684
+ line-height: 1.3;
685
+ }
686
+
687
+ .outcome-success {
688
+ background-color: var(--color-success-light);
689
+ border: 1.5px solid var(--color-success);
690
+ border-radius: 6px;
691
+ padding: 8px;
692
+ }
693
+
694
+ .outcome-success h3 {
695
+ color: var(--color-success);
696
+ font-family: 'Bricolage Grotesque', sans-serif;
697
+ font-size: 14px;
698
+ margin-bottom: 2px;
699
+ }
700
+
701
+ .outcome-fail {
702
+ background-color: var(--color-error-light);
703
+ border: 1.5px solid var(--color-error);
704
+ border-radius: 6px;
705
+ padding: 8px;
706
+ }
707
+
708
+ .outcome-fail h3 {
709
+ color: var(--color-error);
710
+ font-family: 'Bricolage Grotesque', sans-serif;
711
+ font-size: 14px;
712
+ margin-bottom: 2px;
713
+ }
714
+
715
+ .outcome-message {
716
+ font-style: italic;
717
+ font-size: 12px;
718
+ color: #555;
719
+ margin-bottom: 6px;
720
+ }
721
+
722
+ .breakdown-list {
723
+ list-style: none;
724
+ padding-left: 2px;
725
+ margin-top: 4px;
726
+ border-top: 1px dashed rgba(44, 40, 37, 0.2);
727
+ padding-top: 4px;
728
+ font-size: 11px;
729
+ }
730
+
731
+ .breakdown-list li {
732
+ display: flex;
733
+ justify-content: space-between;
734
+ margin-bottom: 2px;
735
+ }
736
+
737
+ .breakdown-list li.total {
738
+ font-weight: bold;
739
+ font-size: 12px;
740
+ border-top: 1px dotted rgba(44, 40, 37, 0.4);
741
+ padding-top: 2px;
742
+ margin-top: 2px;
743
+ color: var(--color-ink);
744
+ }
745
+
746
+ .empty-state {
747
+ text-align: center;
748
+ color: #777;
749
+ font-style: italic;
750
+ font-size: 12px;
751
+ margin-top: 12px;
752
+ }
753
+
754
+ /* Decorative Doodles */
755
+ .decorative-doodle {
756
+ position: absolute;
757
+ pointer-events: none;
758
+ opacity: 0.12;
759
+ color: var(--color-ink);
760
+ }
761
+
762
+ .doodle-left {
763
+ bottom: 8px;
764
+ right: 8px;
765
+ transform: rotate(15deg);
766
+ }
767
+
768
+ .doodle-right {
769
+ bottom: 8px;
770
+ left: 8px;
771
+ transform: rotate(-15deg);
772
+ }
773
+
774
+ .doodle-left .material-symbols-outlined,
775
+ .doodle-right .material-symbols-outlined {
776
+ font-size: 48px;
777
+ }
778
+
779
+ /* Custom ruled sheets */
780
+ .paper-sheet {
781
+ background-image: linear-gradient(rgba(44, 40, 37, 0.08) 1px, transparent 1px);
782
+ background-size: 100% 20px;
783
+ line-height: 20px;
784
+ }
785
+
786
+ /* Responsive Scaling for smaller heights / widths */
787
+ @media (max-width: 1000px) {
788
+ body {
789
+ height: auto;
790
+ overflow: auto;
791
+ }
792
+ .workbench-panels {
793
+ grid-template-columns: 1fr;
794
+ height: auto;
795
+ }
796
+ .status-bar {
797
+ border-radius: 12px;
798
+ height: auto;
799
+ flex-direction: column;
800
+ gap: 12px;
801
+ padding: 12px;
802
+ }
803
+ .status-bar-items {
804
+ flex-wrap: wrap;
805
+ justify-content: center;
806
+ gap: 12px;
807
+ }
808
+ .ledger-panel, .canvas-panel {
809
+ transform: none !important;
810
+ height: auto;
811
+ min-height: auto;
812
+ }
813
+ .right-column {
814
+ height: auto;
815
+ overflow: auto;
816
+ }
817
+ }
818
+
819
+ /* Delivery Outcome Box inside Request Card details */
820
+ .delivery-outcome-box {
821
+ margin-top: 10px;
822
+ padding: 12px;
823
+ border: 2px dashed var(--color-ink);
824
+ border-radius: 8px;
825
+ font-size: 13px;
826
+ line-height: 1.4;
827
+ background-color: #ffffff;
828
+ box-shadow: 2px 2px 0px 0px rgba(0,0,0,0.15);
829
+ }
830
+
831
+ .delivery-outcome-box.success {
832
+ border-color: var(--color-success);
833
+ background-color: var(--color-success-light);
834
+ }
835
+
836
+ .delivery-outcome-box.success h4 {
837
+ color: var(--color-success);
838
+ font-family: 'Bricolage Grotesque', sans-serif;
839
+ font-weight: 800;
840
+ font-size: 14px;
841
+ margin-bottom: 4px;
842
+ }
843
+
844
+ .delivery-outcome-box.fail {
845
+ border-color: var(--color-error);
846
+ background-color: var(--color-error-light);
847
+ }
848
+
849
+ .delivery-outcome-box.fail h4 {
850
+ color: var(--color-error);
851
+ font-family: 'Bricolage Grotesque', sans-serif;
852
+ font-weight: 800;
853
+ font-size: 14px;
854
+ margin-bottom: 4px;
855
+ }
856
+
857
+ .delivery-outcome-box .outcome-desc {
858
+ font-style: italic;
859
+ color: #555;
860
+ margin-bottom: 8px;
861
+ }
862
+
863
+ .delivery-outcome-box .breakdown-rows {
864
+ list-style: none;
865
+ border-top: 1px dotted rgba(44, 40, 37, 0.2);
866
+ padding-top: 6px;
867
+ margin-top: 6px;
868
+ font-size: 12px;
869
+ }
870
+
871
+ .delivery-outcome-box .breakdown-rows li {
872
+ display: flex;
873
+ justify-content: space-between;
874
+ margin-bottom: 3px;
875
+ }
876
+
877
+ .delivery-outcome-box .breakdown-rows li.total {
878
+ font-weight: bold;
879
+ font-size: 13px;
880
+ border-top: 1px dotted var(--color-ink);
881
+ padding-top: 4px;
882
+ margin-top: 4px;
883
+ color: var(--color-ink);
884
+ }
885
+
886
+ /* Button & Top Bar layout helpers */
887
+ .btn-block {
888
+ width: 100%;
889
+ flex-shrink: 0;
890
+ flex-grow: 0 !important;
891
+ }
892
+
893
+ .btn-sm {
894
+ padding: 6px 10px !important;
895
+ font-size: 11px !important;
896
+ box-shadow: 2px 2px 0px 0px var(--color-ink) !important;
897
+ }
898
+
899
+ .btn-sm:active {
900
+ transform: translate(1px, 1px) !important;
901
+ box-shadow: 1px 1px 0px 0px var(--color-ink) !important;
902
+ }
903
+
904
+ .top-bar-actions {
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 8px;
908
+ margin-left: 14px;
909
+ }
910
+
911
+ /* Instruction Modal overlay/card styling */
912
+ .modal-overlay {
913
+ position: fixed;
914
+ top: 0;
915
+ left: 0;
916
+ width: 100vw;
917
+ height: 100vh;
918
+ background-color: rgba(44, 40, 37, 0.45);
919
+ display: flex;
920
+ justify-content: center;
921
+ align-items: center;
922
+ z-index: 2000;
923
+ backdrop-filter: blur(2px);
924
+ transition: opacity 0.2s ease;
925
+ }
926
+
927
+ .modal-overlay.hidden {
928
+ display: none !important;
929
+ pointer-events: none;
930
+ }
931
+
932
+ .modal-content {
933
+ background-color: var(--color-paper);
934
+ padding: 24px;
935
+ max-width: 520px;
936
+ width: 90%;
937
+ position: relative;
938
+ border-radius: 15px 15px 15px 15px/15px 15px 15px 15px;
939
+ animation: modal-enter 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
940
+ }
941
+
942
+ @keyframes modal-enter {
943
+ from { transform: scale(0.9) translateY(20px); opacity: 0; }
944
+ to { transform: scale(1) translateY(0); opacity: 1; }
945
+ }
946
+
947
+ .modal-header {
948
+ display: flex;
949
+ justify-content: space-between;
950
+ align-items: center;
951
+ border-bottom: 2px dashed rgba(44, 40, 37, 0.2);
952
+ padding-bottom: 8px;
953
+ margin-bottom: 12px;
954
+ }
955
+
956
+ .modal-header .handwritten-title {
957
+ border-bottom: none;
958
+ margin-bottom: 0;
959
+ padding-bottom: 0;
960
+ }
961
+
962
+ .btn-close {
963
+ background: none;
964
+ border: none;
965
+ font-size: 28px;
966
+ cursor: pointer;
967
+ color: var(--color-ink);
968
+ line-height: 1;
969
+ padding: 0 4px;
970
+ }
971
+
972
+ .btn-close:hover {
973
+ color: var(--color-secondary);
974
+ }
975
+
976
+ .modal-body {
977
+ font-size: 14px;
978
+ line-height: 1.5;
979
+ }
980
+
981
+ .modal-body p {
982
+ margin-bottom: 8px;
983
+ }
984
+
985
+ .modal-body ul {
986
+ list-style: none;
987
+ padding-left: 0;
988
+ }
989
+
990
+ .modal-body li {
991
+ margin-bottom: 8px;
992
+ padding-left: 18px;
993
+ position: relative;
994
+ }
995
+
996
+ .modal-body li::before {
997
+ content: "✦";
998
+ position: absolute;
999
+ left: 2px;
1000
+ color: var(--color-primary);
1001
+ font-size: 12px;
1002
+ }
1003
+
1004
+ /* End Day Button Refinement */
1005
+ #btn-end-day {
1006
+ width: calc(100% - 32px);
1007
+ margin: 8px 16px 4px 16px;
1008
+ padding: 10px 20px;
1009
+ flex-grow: 0 !important;
1010
+ flex-shrink: 0;
1011
+ }
1012
+
1013
+ /* Stat Label & New Run Button Styling refinements */
1014
+ .stat-label {
1015
+ font-size: 11px;
1016
+ color: #666;
1017
+ text-transform: uppercase;
1018
+ font-family: 'Quicksand', sans-serif;
1019
+ font-weight: 700;
1020
+ letter-spacing: 0.05em;
1021
+ margin-right: 4px;
1022
+ }
1023
+
1024
+ #btn-new-game {
1025
+ font-size: 13px !important;
1026
+ padding: 6px 12px !important;
1027
+ }
1028
+
1029
+ /* Ledger Panel Divider */
1030
+ .ledger-divider {
1031
+ border: none;
1032
+ border-top: 2px dashed rgba(44, 40, 37, 0.25);
1033
+ margin: 4px 0;
1034
+ flex-shrink: 0;
1035
+ }
1036
+
1037
+ /* Logs styling */
1038
+ .logs-list {
1039
+ display: flex;
1040
+ flex-direction: column;
1041
+ gap: 8px;
1042
+ padding: 4px;
1043
+ }
1044
+
1045
+ .log-entry {
1046
+ display: flex;
1047
+ align-items: flex-start;
1048
+ gap: 8px;
1049
+ padding: 6px 8px;
1050
+ border-bottom: 1px dotted rgba(44, 40, 37, 0.15);
1051
+ font-size: 13px;
1052
+ line-height: 1.4;
1053
+ color: var(--color-ink);
1054
+ background: rgba(255, 255, 255, 0.4);
1055
+ border-radius: 4px;
1056
+ transition: background-color 0.2s ease;
1057
+ }
1058
+
1059
+ .log-entry:hover {
1060
+ background: rgba(255, 255, 255, 0.85);
1061
+ }
1062
+
1063
+ .log-icon {
1064
+ font-size: 16px;
1065
+ flex-shrink: 0;
1066
+ margin-top: 1px;
1067
+ }
1068
+
1069
+ .log-text {
1070
+ flex-grow: 1;
1071
+ }
1072
+
1073
+ /* Custom log type highlight styles */
1074
+ .log-day-start {
1075
+ font-weight: 700;
1076
+ border-bottom: 1.5px solid rgba(44, 40, 37, 0.3);
1077
+ background: var(--color-warning-light);
1078
+ }
1079
+
1080
+ .log-new-customer {
1081
+ border-left: 3px solid var(--color-secondary);
1082
+ }
1083
+
1084
+ .log-shop {
1085
+ border-left: 3px solid #6b5b95;
1086
+ }
1087
+
1088
+ .log-delivery-success {
1089
+ border-left: 3px solid var(--color-success);
1090
+ background: var(--color-success-light);
1091
+ }
1092
+
1093
+ .log-delivery-fail {
1094
+ border-left: 3px solid var(--color-error);
1095
+ background: var(--color-error-light);
1096
+ }
1097
+
1098
+ .log-rent-paid {
1099
+ border-left: 3px solid #feb236;
1100
+ }
1101
+
1102
+ .log-rent-failed {
1103
+ border-left: 3px solid var(--color-error);
1104
+ font-weight: bold;
1105
+ }
1106
+
1107
+ .log-debt-paid {
1108
+ border-left: 3px solid var(--color-success);
1109
+ font-weight: bold;
1110
+ }
1111
+
1112
+ .log-game-won {
1113
+ background: var(--color-success-light);
1114
+ border: 2px dashed var(--color-success);
1115
+ font-weight: bold;
1116
+ font-size: 14px;
1117
+ padding: 10px;
1118
+ }
1119
+
1120
+ .log-game-lost {
1121
+ background: var(--color-error-light);
1122
+ border: 2px dashed var(--color-error);
1123
+ font-weight: bold;
1124
+ font-size: 14px;
1125
+ padding: 10px;
1126
+ }
1127
+
1128
+ .log-appraising, .log-advancing {
1129
+ font-style: italic;
1130
+ color: #777;
1131
+ border-left: 3px dashed var(--color-ink);
1132
+ background: rgba(255, 255, 255, 0.25);
1133
+ }
1134
+
witch_wood/vision/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from witch_wood.vision.mock import MockBackend
2
+
3
+ __all__ = ["MockBackend"]
witch_wood/vision/base.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VisionBackend protocol: appraise a doodle against a request."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from witch_wood.core.schema import Request, Verdict
7
+
8
+
9
+ class VisionBackend(Protocol):
10
+ def appraise(self, image, request: Request) -> Verdict:
11
+ """Return raw (un-thresholded) recognition + per-property confidences."""
12
+ ...
13
+
14
+ def check_properties(self, image, request: Request) -> tuple[float, ...]:
15
+ """Per-property confidences for `image` (e.g. a generated render)."""
16
+ ...
witch_wood/vision/minicpm.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniCPM-V backend (ZeroGPU-ready).
2
+
3
+ Scoring uses TWO categorical (yes/no) turns in ONE conversation, because
4
+ MiniCPM-V cannot emit calibrated float confidences and degrades in combined
5
+ float prompts:
6
+
7
+ Turn 1 — recognition + description: "Does it depict a {object}?" (yes/no, the
8
+ gate) plus a one-sentence free-form description of the doodle.
9
+ Turn 2 — properties: one combined numbered yes/no block, ALWAYS run (even if
10
+ recognition is "no"). It reuses the same conversation, so the image
11
+ and the turn-1 description stay in context to ground the answers.
12
+
13
+ yes -> 1.0, no -> 0.0. The prompt builders and parsers are pure/testable.
14
+
15
+ ZeroGPU notes: the model/processor are loaded once at module scope (registered
16
+ for ZeroGPU device migration) and the whole appraisal — every `generate` call
17
+ for one doodle — runs inside a single `@spaces.GPU` entry. The model lives in a
18
+ module global, so it is never pickled across the worker-process boundary; only
19
+ the PIL image, the `Request` dataclass, and the returned `Verdict` cross it.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ import spaces
26
+ import torch
27
+
28
+ from witch_wood.core.schema import Property, Request, Verdict
29
+
30
+ MODEL_ID = "openbmb/MiniCPM-V-4.6"
31
+
32
+ # Diagnostic free-form prompt used by scripts/model_spike.py --describe.
33
+ DESCRIBE_PROMPT = (
34
+ "This is a rough black-and-white line-drawing doodle. "
35
+ "First name the single object you think it most depicts in 1-3 words. "
36
+ "Then briefly describe its overall shape and the distinct parts you can see. "
37
+ "Do not refuse; give your best guess even if the drawing is crude."
38
+ )
39
+
40
+
41
+ def recognition_prompt(request: Request) -> str:
42
+ """Call-1 prompt: recognition gate (yes/no) + a one-sentence description."""
43
+ return (
44
+ "Look at this rough black-and-white line drawing.\n"
45
+ f"1) Does it depict a {request.object}? Answer yes or no.\n"
46
+ "2) In one short sentence, describe the object and its visible parts.\n"
47
+ "Reply in exactly this format:\n"
48
+ "MATCH: <yes or no>\n"
49
+ "DESCRIPTION: <one sentence>"
50
+ )
51
+
52
+
53
+ def properties_prompt(request: Request) -> str:
54
+ """Call-2 prompt: one combined numbered yes/no block over all properties."""
55
+ lines = "\n".join(
56
+ f"{i + 1}. {p.predicate}" for i, p in enumerate(request.properties)
57
+ )
58
+ return "For the same drawing, answer yes or no for each:\n" + lines
59
+
60
+
61
+ def property_prompt_single(prop: Property) -> str:
62
+ """Separate-mode prompt: ask yes/no about ONE property of the same drawing."""
63
+ return (
64
+ "For the same drawing, answer with a single word, yes or no:\n"
65
+ f"{prop.predicate}"
66
+ )
67
+
68
+
69
+ def property_prompt_render(prop: Property) -> str:
70
+ """Yes/no prompt for checking ONE property on a GENERATED photo (not a doodle).
71
+
72
+ Used by the score-the-render stage: each property is a standalone turn on the
73
+ polished image, with no doodle/'drawing' framing."""
74
+ return (
75
+ "Look at this image and answer with a single word, yes or no:\n"
76
+ f"{prop.predicate}"
77
+ )
78
+
79
+
80
+ def _yes(text: str) -> float:
81
+ """1.0 if an affirmative 'yes' token appears, else 0.0."""
82
+ return 1.0 if re.search(r"\byes\b", text, re.IGNORECASE) else 0.0
83
+
84
+
85
+ def parse_recognition(text: str) -> tuple[float, str]:
86
+ """Parse a MATCH/DESCRIPTION reply into (recognition_conf, description).
87
+
88
+ Falls back to scanning the first line for yes if the MATCH label is absent.
89
+ """
90
+ m = re.search(r"MATCH:\s*(.+)", text, re.IGNORECASE)
91
+ d = re.search(r"DESCRIPTION:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
92
+ description = d.group(1).strip() if d else ""
93
+ if m:
94
+ recognition = _yes(m.group(1))
95
+ else:
96
+ first_line = text.splitlines()[0] if text.strip() else ""
97
+ recognition = _yes(first_line)
98
+ return recognition, description
99
+
100
+
101
+ def parse_properties(text: str, n_props: int) -> tuple[float, ...]:
102
+ """Parse numbered yes/no lines into n_props confidences (missing -> 0.0)."""
103
+ answers: dict[int, float] = {}
104
+ for line in text.splitlines():
105
+ m = re.match(r"\s*(\d+)[.):]\s*(.*)", line)
106
+ if m:
107
+ answers[int(m.group(1))] = _yes(m.group(2))
108
+ if answers:
109
+ return tuple(answers.get(i + 1, 0.0) for i in range(n_props))
110
+ # Fallback: take bare yes/no tokens in order.
111
+ tokens = re.findall(r"\b(yes|no)\b", text, re.IGNORECASE)
112
+ vals = [1.0 if t.lower() == "yes" else 0.0 for t in tokens]
113
+ return tuple(vals[i] if i < len(vals) else 0.0 for i in range(n_props))
114
+
115
+
116
+ # --- Model loading (module scope, ZeroGPU-registered) -----------------------
117
+
118
+ _MODEL = None
119
+ _PROCESSOR = None
120
+
121
+
122
+ def _ensure_loaded():
123
+ """Load MiniCPM-V once into module globals.
124
+
125
+ On ZeroGPU `import spaces` monkey-patches torch so `device_map="auto"` at
126
+ module scope registers the weights for device migration; the real GPU is
127
+ only attached inside the `@spaces.GPU` call that runs generation.
128
+ """
129
+ global _MODEL, _PROCESSOR
130
+ if _MODEL is None:
131
+ from transformers import AutoModelForImageTextToText, AutoProcessor
132
+
133
+ _PROCESSOR = AutoProcessor.from_pretrained(MODEL_ID)
134
+ _MODEL = AutoModelForImageTextToText.from_pretrained(
135
+ MODEL_ID, torch_dtype="auto", device_map="auto"
136
+ )
137
+ return _MODEL, _PROCESSOR
138
+
139
+
140
+ # Trigger the (slow) load eagerly at import so the cold-start cost is paid once
141
+ # at Space startup rather than on the first player's delivery.
142
+ _ensure_loaded()
143
+
144
+
145
+ # --- Generation helpers (run inside the @spaces.GPU worker) ------------------
146
+
147
+ def _text(content: str) -> dict:
148
+ return {"role": "user", "content": [{"type": "text", "text": content}]}
149
+
150
+
151
+ def _image_text(image, content: str) -> dict:
152
+ return {"role": "user", "content": [
153
+ {"type": "image", "image": image},
154
+ {"type": "text", "text": content},
155
+ ]}
156
+
157
+
158
+ def _run_fresh(model, processor, messages: list[dict], max_new_tokens: int):
159
+ """Template + generate one turn; return (text, cache, full_token_seq)."""
160
+ inputs = processor.apply_chat_template(
161
+ messages, tokenize=True, add_generation_prompt=True,
162
+ return_dict=True, return_tensors="pt",
163
+ ).to(model.device)
164
+ out = model.generate(
165
+ **inputs, max_new_tokens=max_new_tokens, do_sample=False,
166
+ return_dict_in_generate=True, use_cache=True,
167
+ )
168
+ seq = out.sequences
169
+ text = processor.batch_decode(
170
+ seq[:, inputs.input_ids.shape[1]:],
171
+ skip_special_tokens=True, clean_up_tokenization_spaces=False,
172
+ )[0]
173
+ return text, out.past_key_values, seq
174
+
175
+
176
+ def _run(model, processor, messages: list[dict], max_new_tokens: int) -> str:
177
+ """Text-only convenience wrapper over _run_fresh (no cache reuse)."""
178
+ return _run_fresh(model, processor, messages, max_new_tokens)[0]
179
+
180
+
181
+ def _run_cached(model, processor, full_messages, cache, prefix_seq, max_new_tokens) -> str:
182
+ """Generate one property turn reusing the recognition KV cache.
183
+
184
+ Finds the longest common prefix between the recognition token sequence
185
+ (prefix_seq) and the re-templated full conversation, crops the cache to
186
+ that boundary (the image tokens live inside it, so they are reused), and
187
+ feeds the remaining tokens. Crops back to the same boundary afterward so
188
+ the next property branches off a clean recognition-only prefix.
189
+ """
190
+ full = processor.apply_chat_template(
191
+ full_messages, tokenize=True, add_generation_prompt=True,
192
+ return_dict=True, return_tensors="pt",
193
+ ).to(model.device)
194
+ full_ids = full.input_ids
195
+ min_len = min(prefix_seq.shape[1], full_ids.shape[1])
196
+ diverge = (prefix_seq[0, :min_len] != full_ids[0, :min_len]).nonzero()
197
+ common = int(diverge[0].item()) if diverge.numel() else min_len
198
+ keep = min(common, cache.get_seq_length())
199
+ cache.crop(keep)
200
+ new_ids = full_ids[:, keep:]
201
+ attn = torch.ones((1, full_ids.shape[1]), dtype=torch.long,
202
+ device=full_ids.device)
203
+ gen = model.generate(
204
+ input_ids=new_ids, attention_mask=attn, past_key_values=cache,
205
+ max_new_tokens=max_new_tokens, do_sample=False, use_cache=True,
206
+ )
207
+ text = processor.batch_decode(
208
+ gen[:, new_ids.shape[1]:],
209
+ skip_special_tokens=True, clean_up_tokenization_spaces=False,
210
+ )[0]
211
+ cache.crop(keep) # restore shared prefix so the next property is isolated
212
+ return text
213
+
214
+
215
+ def _appraise(model, processor, image, request: Request, use_kv_cache: bool) -> Verdict:
216
+ """Full two-call yes/no appraisal. Runs entirely on the GPU worker."""
217
+ from witch_wood.core import constants as C
218
+
219
+ rec_messages = [_image_text(image, recognition_prompt(request))]
220
+ rec_text, cache, rec_seq = _run_fresh(model, processor, rec_messages, max_new_tokens=80)
221
+ recognition, description = parse_recognition(rec_text)
222
+
223
+ assistant_turn = {"role": "assistant",
224
+ "content": [{"type": "text", "text": rec_text}]}
225
+
226
+ def _property_turn(extra_user_msg, max_new_tokens, use_cache):
227
+ convo = [_image_text(image, recognition_prompt(request)),
228
+ assistant_turn, extra_user_msg]
229
+ if use_cache:
230
+ return _run_cached(model, processor, convo, cache, rec_seq, max_new_tokens)
231
+ return _run(model, processor, convo, max_new_tokens)
232
+
233
+ if not request.properties:
234
+ properties = ()
235
+ elif C.SEPARATE_PROPERTY_CHECKS:
236
+ confs = []
237
+ for prop in request.properties:
238
+ # Cache reuse is safe + worthwhile here: the recognition prefix is
239
+ # reused across N short yes/no answers, which are robust to the tiny
240
+ # KV FP noise the cache introduces.
241
+ text = _property_turn(_text(property_prompt_single(prop)),
242
+ 16, use_cache=use_kv_cache)
243
+ confs.append(_yes(text))
244
+ properties = tuple(confs)
245
+ else:
246
+ # Batch: ALWAYS re-template, never cache. A single combined call gets no
247
+ # amortization, and greedy decoding over its long answer diverges from
248
+ # the fresh-prefill output under cache FP noise (measured: verbose drift
249
+ # on ~half of tier-3 batch cases -> wrong stars). Caching is an
250
+ # optimization for the multi-call separate path only.
251
+ prop_text = _property_turn(_text(properties_prompt(request)),
252
+ 64, use_cache=False)
253
+ properties = parse_properties(prop_text, len(request.properties))
254
+
255
+ return Verdict(
256
+ recognition=recognition, properties=properties, description=description
257
+ )
258
+
259
+
260
+ def _appraise_duration(image, request: Request, use_kv_cache: bool = True) -> int:
261
+ """Estimate GPU seconds for the quota pre-check.
262
+
263
+ The appraisal is one ~80-token recognition generate plus one short yes/no
264
+ generate per property; a warm tier-0 call measured ~3s. Keep this small so
265
+ the quota pre-check doesn't block low-quota visitors (ZeroGPU reserves the
266
+ full declared duration up front and ranks shorter durations higher in the
267
+ queue), while leaving headroom for cold-start weight streaming."""
268
+ return 20 + 10 * len(request.properties)
269
+
270
+
271
+ @spaces.GPU(duration=_appraise_duration)
272
+ def appraise_gpu(image, request: Request, use_kv_cache: bool) -> Verdict:
273
+ """ZeroGPU entry point: one GPU acquisition per doodle appraisal.
274
+
275
+ The model lives in a module global (never pickled); only `image`/`request`
276
+ cross into the worker and a plain `Verdict` dataclass crosses back out.
277
+ """
278
+ model, processor = _ensure_loaded()
279
+ return _appraise(model, processor, image, request, use_kv_cache)
280
+
281
+
282
+ # --- Property re-check on the generated render ------------------------------
283
+ # The score-the-render stage: after the image-gen step, re-ask each property as
284
+ # a standalone yes/no on the polished image (separate GPU acquisition — the
285
+ # render doesn't exist until generation has run).
286
+
287
+ def _check_properties(
288
+ model, processor, image, request: Request, doodle_passes=None
289
+ ) -> tuple[float, ...]:
290
+ """Per-property yes/no on a generated render.
291
+
292
+ `doodle_passes` (one bool per property, from the doodle appraisal) lets us
293
+ skip work: a property that PASSED on the doodle is trusted to carry through
294
+ the render and is scored 1.0 without a model call; only doodle-FAILED
295
+ properties are re-asked on the render (the generator may have added one
296
+ back). `doodle_passes=None` re-checks every property (legacy contract).
297
+
298
+ The re-checked properties share one render encoding: the first is a fresh
299
+ prefill, the rest reuse its KV cache (same trick as the doodle appraisal's
300
+ separate path) — the image + the fixed render instruction are the shared
301
+ prefix, only the predicate diverges, so the render is encoded once."""
302
+ props = request.properties
303
+ if not props:
304
+ return ()
305
+ if doodle_passes is None:
306
+ recheck_idx = list(range(len(props)))
307
+ else:
308
+ recheck_idx = [i for i in range(len(props)) if not doodle_passes[i]]
309
+
310
+ confs = [1.0] * len(props) # passed props default to 1.0 (no call)
311
+ cache = seq = None
312
+ for n, i in enumerate(recheck_idx):
313
+ convo = [_image_text(image, property_prompt_render(props[i]))]
314
+ if n == 0:
315
+ text, cache, seq = _run_fresh(model, processor, convo, 16)
316
+ else:
317
+ text = _run_cached(model, processor, convo, cache, seq, 16)
318
+ confs[i] = _yes(text)
319
+ print(f"[recheck] rechecked {len(recheck_idx)} of {len(props)} props",
320
+ flush=True)
321
+ return tuple(confs)
322
+
323
+
324
+ def _check_props_duration(image, request: Request) -> int:
325
+ return 15 + 8 * len(request.properties)
326
+
327
+
328
+ @spaces.GPU(duration=_check_props_duration)
329
+ def check_properties_gpu(image, request: Request) -> tuple[float, ...]:
330
+ model, processor = _ensure_loaded()
331
+ return _check_properties(model, processor, image, request)
332
+
333
+
334
+ class MiniCPMBackend:
335
+ """Loads MiniCPM-V-4.6 and appraises doodles with the two-call yes/no flow.
336
+
337
+ The heavy model is a module global; this object only carries the cheap
338
+ `use_kv_cache` flag so it stays trivially picklable across the ZeroGPU
339
+ worker boundary.
340
+ """
341
+
342
+ def __init__(self, use_kv_cache: bool = False):
343
+ self._use_kv_cache = use_kv_cache
344
+ _ensure_loaded()
345
+
346
+ def appraise(self, image, request: Request) -> Verdict:
347
+ return appraise_gpu(image, request, self._use_kv_cache)
348
+
349
+ def check_properties(self, image, request: Request) -> tuple[float, ...]:
350
+ """Per-property yes/no on a generated render (the score-the-render step)."""
351
+ return check_properties_gpu(image, request)
352
+
353
+ def describe(self, image, question: str | None = None) -> str:
354
+ """Free-form: ask the model what it sees (diagnostic, not scored)."""
355
+ return _describe_gpu(image, question or DESCRIBE_PROMPT)
356
+
357
+
358
+ @spaces.GPU(duration=60)
359
+ def _describe_gpu(image, prompt_text: str) -> str:
360
+ model, processor = _ensure_loaded()
361
+ return _run(model, processor, [_image_text(image, prompt_text)], 200)
witch_wood/vision/mock.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MockBackend: scripted or seeded verdicts — no model, for dev and tests."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+
6
+ from witch_wood.core.schema import Request, Verdict
7
+
8
+
9
+ class MockBackend:
10
+ def __init__(self, seed: int = 0, scripted: list[Verdict] | None = None):
11
+ self._rng = random.Random(seed)
12
+ self._scripted = list(scripted) if scripted else None
13
+ self._i = 0
14
+
15
+ def appraise(self, image, request: Request) -> Verdict:
16
+ if self._scripted:
17
+ v = self._scripted[self._i % len(self._scripted)]
18
+ self._i += 1
19
+ return v
20
+ import os
21
+ if os.environ.get("WITCH_WOOD_MOCK_ALWAYS_PASS") == "1":
22
+ return Verdict(
23
+ recognition=1.0,
24
+ properties=tuple(1.0 for _ in request.properties),
25
+ description=f"a perfect mock doodle of a {request.object}",
26
+ )
27
+ return Verdict(
28
+ recognition=self._rng.random(),
29
+ properties=tuple(self._rng.random() for _ in request.properties),
30
+ description=f"a mock doodle of a {request.object}",
31
+ )
32
+
33
+ def check_properties(self, image, request: Request) -> tuple[float, ...]:
34
+ return self.appraise(image, request).properties