Jingzhi Ye Claude Opus 4.8 commited on
Commit ·
9e67084
1
Parent(s): 9b33614
Port Inkwitch game (production files) to ink-witch Space
Browse filesBring 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 +20 -0
- .python-version +1 -0
- LICENSE +201 -0
- README.md +15 -5
- app.py +8 -0
- pyproject.toml +38 -0
- requirements.txt +343 -0
- witch_wood/__init__.py +0 -0
- witch_wood/core/__init__.py +0 -0
- witch_wood/core/constants.py +80 -0
- witch_wood/core/schema.py +55 -0
- witch_wood/core/state.py +62 -0
- witch_wood/engine/__init__.py +0 -0
- witch_wood/engine/content.py +205 -0
- witch_wood/engine/economy.py +77 -0
- witch_wood/engine/game.py +123 -0
- witch_wood/engine/progression.py +10 -0
- witch_wood/engine/scoring.py +36 -0
- witch_wood/gen/__init__.py +0 -0
- witch_wood/gen/pipeline.py +84 -0
- witch_wood/gen/scribble.py +173 -0
- witch_wood/ui/__init__.py +0 -0
- witch_wood/ui/app.py +221 -0
- witch_wood/ui/canvas.py +81 -0
- witch_wood/ui/format.py +42 -0
- witch_wood/ui/server.py +503 -0
- witch_wood/ui/static/app.js +895 -0
- witch_wood/ui/static/index.html +234 -0
- witch_wood/ui/static/style.css +1134 -0
- witch_wood/vision/__init__.py +3 -0
- witch_wood/vision/base.py +16 -0
- witch_wood/vision/minicpm.py +361 -0
- witch_wood/vision/mock.py +34 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">×</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
|