Deploy Hallway 8 (multi-arc memory game)
Browse files- Dockerfile +25 -0
- LICENSE +201 -0
- README.md +245 -5
- anomalies.py +47 -0
- app.py +208 -0
- data/arcs/coach8.json +345 -0
- data/arcs/hallway-eight.json +315 -0
- data/arcs/stairway8.json +387 -0
- docs/ARCS.md +138 -0
- hallway.py +141 -0
- memory.py +62 -0
- requirements.txt +2 -0
- scripts/deploy_hf.py +63 -0
- static/game.js +282 -0
- static/style.css +254 -0
- templates/index.html +71 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Container image for hosting the game (works as-is on Hugging Face Spaces
|
| 2 |
+
# with the Docker SDK, which expects the app on port 7860).
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
# Hugging Face Spaces runs containers as a non-root user (uid 1000).
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV HOME=/home/user \
|
| 9 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 10 |
+
HOST=0.0.0.0 \
|
| 11 |
+
PORT=7860
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
|
| 15 |
+
COPY --chown=user requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 17 |
+
|
| 18 |
+
COPY --chown=user . .
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# IMPORTANT: a single worker. Game state (and each corridor's answer) lives in
|
| 23 |
+
# memory in one process; multiple workers would split that state and break runs.
|
| 24 |
+
# Threads give plenty of concurrency for this lightweight app.
|
| 25 |
+
CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:7860", "app:app"]
|
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,10 +1,250 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Hallway 8
|
| 3 |
+
emoji: 🚪
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Hallway 8
|
| 12 |
+
|
| 13 |
+
[](https://huggingface.co/new-space?sdk=docker)
|
| 14 |
+
|
| 15 |
+
A small browser game about memory and doubt.
|
| 16 |
+
|
| 17 |
+
You move through a place that should be identical every time. If anything
|
| 18 |
+
differs from what you remember, **turn back**. If nothing has changed, **go
|
| 19 |
+
on**. Reach the eighth loop without contradicting your own memory. Get it wrong
|
| 20 |
+
and you start over.
|
| 21 |
+
|
| 22 |
+
The trick is that the place is never described the same way twice. Only a
|
| 23 |
+
handful of its details are mentioned each loop, and each is phrased from a pool
|
| 24 |
+
of interchangeable sentences — so you can't compare two loops word-for-word.
|
| 25 |
+
You're forced to remember *meaning*, not wording. Somewhere in that fog, one
|
| 26 |
+
detail occasionally changes for real.
|
| 27 |
+
|
| 28 |
+
## Scenarios (arcs)
|
| 29 |
+
|
| 30 |
+
The game ships with several **arcs** — each its own backstory, vocabulary, and
|
| 31 |
+
visual skin, all sharing the one mechanic. You pick one from the opening screen.
|
| 32 |
+
|
| 33 |
+
- **Hallway 8** — locked in after hours, you follow a sign to the fire exit and
|
| 34 |
+
walk corridor after identical corridor toward the eighth fire door.
|
| 35 |
+
- **Stairway 8** — the elevator is out of order, so you descend eight flights on
|
| 36 |
+
foot, checking each landing before you keep going down to the ground floor.
|
| 37 |
+
- **Coach 8** — the conductor sent you forward to coach 8, but the night train
|
| 38 |
+
never seems to reach it; you walk carriage after identical carriage.
|
| 39 |
+
|
| 40 |
+
Every arc carries more than physical changes: each has an **NPC** who is usually
|
| 41 |
+
passive but can become the anomaly (turning to face you, moving, vanishing) —
|
| 42 |
+
including a loop-aware twist where they *acknowledge you* — and a psychological
|
| 43 |
+
`sense` layer where the wrongness is in perception, not objects (a corridor that
|
| 44 |
+
feels too long, a calm that shouldn't be there).
|
| 45 |
+
|
| 46 |
+
Arcs live as self-contained JSON files in `data/arcs/`. Adding a new one is just
|
| 47 |
+
dropping in another file (see *Adding an arc* below) — no code changes needed.
|
| 48 |
+
The design rules and the queue of future arcs are kept in
|
| 49 |
+
[`docs/ARCS.md`](docs/ARCS.md).
|
| 50 |
+
|
| 51 |
+
## What makes it unsettling
|
| 52 |
+
|
| 53 |
+
- **Partial description.** Each corridor has eight properties (lighting, sound,
|
| 54 |
+
the man down the hall, the exit sign, a door, a camera, the floor, a poster),
|
| 55 |
+
but only four to six are ever mentioned per loop.
|
| 56 |
+
- **Rotating prose.** Every detail is drawn from several equivalent sentences,
|
| 57 |
+
so the wording drifts even when nothing has actually changed.
|
| 58 |
+
- **A single quiet mutation.** When a corridor *is* different, exactly one
|
| 59 |
+
property has shifted to a plausible variant — nothing screams "look here."
|
| 60 |
+
- **Confidence as a mechanic.** Before you commit, you rate how sure you are.
|
| 61 |
+
- **A corridor that notices you.** Linger on one detail across loops and the
|
| 62 |
+
narration begins to react to your habits.
|
| 63 |
+
- **Presentation drift.** Heading, pacing, font weight, letter spacing, and the
|
| 64 |
+
background shift a fraction each loop — felt more than seen.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Run it locally
|
| 69 |
+
|
| 70 |
+
You need **Python 3.9+** (any recent Python 3 works).
|
| 71 |
+
|
| 72 |
+
**1. Get the code**
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
git clone https://github.com/alvations/hallway8.git
|
| 76 |
+
cd hallway8
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**2. (Optional but recommended) create a virtual environment**
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
python -m venv .venv
|
| 83 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
**3. Install the dependencies**
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
pip install -r requirements.txt
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
**4. Start the server**
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
python app.py
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
You'll see something like `Running on http://127.0.0.1:5000`.
|
| 99 |
+
|
| 100 |
+
**5. Open the game in your browser**
|
| 101 |
+
|
| 102 |
+
Go to **<http://127.0.0.1:5000>** and press **Begin**.
|
| 103 |
+
|
| 104 |
+
To stop the server, press `Ctrl+C` in the terminal.
|
| 105 |
+
|
| 106 |
+
### Options
|
| 107 |
+
|
| 108 |
+
| Variable | Default | What it does |
|
| 109 |
+
|--------------|-------------|---------------------------------------------------------|
|
| 110 |
+
| `PORT` | `5000` | Port the server listens on. |
|
| 111 |
+
| `HOST` | `127.0.0.1` | Interface to bind. Use `0.0.0.0` to expose on a network.|
|
| 112 |
+
| `SECRET_KEY` | random | Set a fixed value to keep sessions stable across restarts. |
|
| 113 |
+
|
| 114 |
+
Example: `PORT=8000 python app.py` then open <http://127.0.0.1:8000>.
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Host it on Hugging Face Spaces
|
| 119 |
+
|
| 120 |
+
This repo is ready to run as a **Docker Space**. The included `Dockerfile`
|
| 121 |
+
serves the app on port `7860`, and the metadata block at the very top of this
|
| 122 |
+
README is the Space card Hugging Face reads — so there's nothing extra to
|
| 123 |
+
configure.
|
| 124 |
+
|
| 125 |
+
**Prerequisites:** a free Hugging Face account (<https://huggingface.co/join>).
|
| 126 |
+
For the git and CLI methods you'll also need a **write access token** from
|
| 127 |
+
<https://huggingface.co/settings/tokens>.
|
| 128 |
+
|
| 129 |
+
### Step 1 — create the Space
|
| 130 |
+
|
| 131 |
+
Click the **Deploy to Spaces** badge at the top of this README, or go to
|
| 132 |
+
<https://huggingface.co/new-space> and:
|
| 133 |
+
|
| 134 |
+
1. **Owner / Space name** — e.g. `hallway8`.
|
| 135 |
+
2. **SDK** — choose **Docker**, then the **Blank** template.
|
| 136 |
+
3. **Hardware** — the free **CPU basic** tier is plenty.
|
| 137 |
+
4. **Visibility** — Public or Private, your call. Create the Space.
|
| 138 |
+
|
| 139 |
+
You now have an empty Space at
|
| 140 |
+
`https://huggingface.co/spaces/<your-username>/hallway8`.
|
| 141 |
+
|
| 142 |
+
### Step 2 — get the code into the Space
|
| 143 |
+
|
| 144 |
+
Pick whichever is easiest. All three upload the same files; the Space rebuilds
|
| 145 |
+
on every change.
|
| 146 |
+
|
| 147 |
+
**Option A — drag and drop (no tools needed)**
|
| 148 |
+
|
| 149 |
+
In the Space's **Files** tab, click **Add file → Upload files** and upload the
|
| 150 |
+
whole project. Make sure these are all present at the Space root: `Dockerfile`,
|
| 151 |
+
`app.py`, `hallway.py`, `anomalies.py`, `memory.py`, `requirements.txt`,
|
| 152 |
+
`README.md`, and the `data/`, `templates/`, and `static/` folders.
|
| 153 |
+
|
| 154 |
+
**Option B — push with git**
|
| 155 |
+
|
| 156 |
+
A Space is a git repo. Add it as a remote and push:
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
git remote add space https://huggingface.co/spaces/<your-username>/hallway8
|
| 160 |
+
git push space main
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
When prompted, enter your **Hugging Face username** and paste your **access
|
| 164 |
+
token** as the password. (To avoid the prompt, run `pip install -U huggingface_hub`
|
| 165 |
+
then `hf auth login` once — it installs a git credential helper.)
|
| 166 |
+
|
| 167 |
+
> If the Space was created with its own starter `README.md`, your first push may
|
| 168 |
+
> be rejected as out of date. Either `git pull space main --rebase` first, or
|
| 169 |
+
> `git push space main --force` to overwrite the starter files with this repo.
|
| 170 |
+
|
| 171 |
+
**Option C — upload with the Hugging Face CLI**
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
pip install -U huggingface_hub
|
| 175 |
+
hf auth login # paste your write token once
|
| 176 |
+
hf upload <your-username>/hallway8 . . --repo-type=space
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### Step 3 — watch it build and play
|
| 180 |
+
|
| 181 |
+
Open the Space's **Logs** (or **App**) tab. Hugging Face builds the Docker
|
| 182 |
+
image and starts the container; the first build takes a couple of minutes. Once
|
| 183 |
+
the status turns **Running**, the game plays right there in the browser at
|
| 184 |
+
`https://huggingface.co/spaces/<your-username>/hallway8`.
|
| 185 |
+
|
| 186 |
+
### Recommended settings
|
| 187 |
+
|
| 188 |
+
- In **Settings → Variables and secrets**, add a secret named `SECRET_KEY` with
|
| 189 |
+
any long random string. This keeps player sessions valid across Space
|
| 190 |
+
restarts. (Without it a fresh key is generated on each boot, which simply
|
| 191 |
+
resets any in-progress runs.)
|
| 192 |
+
- The free tier **sleeps after a period of inactivity** and wakes on the next
|
| 193 |
+
visit — normal, and fine for a game like this.
|
| 194 |
+
|
| 195 |
+
> **Why the Dockerfile pins a single worker:** the game keeps each corridor's
|
| 196 |
+
> answer in server memory so the page can't be inspected to cheat. That state
|
| 197 |
+
> lives in one process, so the app must run with a single worker (the Dockerfile
|
| 198 |
+
> already does this). Don't scale it to multiple workers/replicas.
|
| 199 |
+
|
| 200 |
+
### Add your own "Open in Spaces" badge
|
| 201 |
+
|
| 202 |
+
Once your Space is live, you can point people straight at it by adding this near
|
| 203 |
+
the top of the README (swap in your username):
|
| 204 |
+
|
| 205 |
+
```markdown
|
| 206 |
+
[](https://huggingface.co/spaces/<your-username>/hallway8)
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
### Other hosts
|
| 210 |
+
|
| 211 |
+
The same `Dockerfile` runs anywhere that takes a container — Fly.io, Render,
|
| 212 |
+
Railway, Google Cloud Run, etc. Point the platform at the image (or use
|
| 213 |
+
`gunicorn -w 1 --threads 8 -b 0.0.0.0:$PORT app:app`) and keep it to a single
|
| 214 |
+
worker for the reason above.
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## Layout
|
| 219 |
+
|
| 220 |
+
```
|
| 221 |
+
app.py Flask server; owns all game state, arc selection, and answers
|
| 222 |
+
hallway.py loads an arc, builds and describes a loop
|
| 223 |
+
anomalies.py anomaly selection + wording/heading drift
|
| 224 |
+
memory.py tracks the player's habits across loops
|
| 225 |
+
data/arcs/*.json one file per arc: meta/skin, properties, sentence pools, story
|
| 226 |
+
templates/ the page
|
| 227 |
+
static/ style (skins) + client logic (rendering, pacing, drift)
|
| 228 |
+
Dockerfile container image for Hugging Face Spaces and other hosts
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
The browser only ever receives a description — never whether a place has
|
| 232 |
+
changed — so you can't read the page source to cheat.
|
| 233 |
+
|
| 234 |
+
### Adding an arc
|
| 235 |
+
|
| 236 |
+
Copy an existing file in `data/arcs/` and edit it. Each arc is fully
|
| 237 |
+
self-contained:
|
| 238 |
+
|
| 239 |
+
- **`meta`** — `id`, `title`, `tagline`, a `skin` name, the two action labels
|
| 240 |
+
(`go_on` / `turn_back`), a `progress` HUD template (placeholders `{level}`,
|
| 241 |
+
`{goal}`, `{floor}`, `{remaining}`), and friendly `labels` per property.
|
| 242 |
+
- **`properties`** — each with a `baseline` value, a pool of interchangeable
|
| 243 |
+
`values` sentences, and `anomalies` (each an alternate value with its own
|
| 244 |
+
sentence pool). Only these ever change between loops.
|
| 245 |
+
- **`title_variants`**, **`anchor_order`**, **`adaptive`**, and **`framing`**
|
| 246 |
+
(the intro and win text).
|
| 247 |
+
|
| 248 |
+
To give the arc its own colours, add a `body[data-skin="<name>"]` block in
|
| 249 |
+
`static/style.css`. The new arc appears automatically on the opening screen ���
|
| 250 |
+
no code changes required.
|
anomalies.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Anomaly selection and the small "drift" tricks.
|
| 2 |
+
|
| 3 |
+
Nothing here screams. An anomaly is a single property quietly swapped for a
|
| 4 |
+
plausible variant. The drift helpers change wording, punctuation, and the
|
| 5 |
+
heading itself so the player can never compare two loops character-for-character
|
| 6 |
+
and must fall back on memory of *meaning*.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def pick_anomaly(properties: Dict[str, dict], rng: random.Random) -> Tuple[str, str]:
|
| 16 |
+
"""Choose one property and one of its anomalous values.
|
| 17 |
+
|
| 18 |
+
Returns (property_name, anomaly_value).
|
| 19 |
+
"""
|
| 20 |
+
candidates = [name for name, spec in properties.items() if spec.get("anomalies")]
|
| 21 |
+
prop = rng.choice(candidates)
|
| 22 |
+
value = rng.choice(list(properties[prop]["anomalies"].keys()))
|
| 23 |
+
return prop, value
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def drift_heading(title_variants: List[str], loops: int, rng: random.Random) -> str:
|
| 27 |
+
"""The heading is usually 'Hallway 8', but every so often it slips.
|
| 28 |
+
|
| 29 |
+
Early on it is stable. The longer the player survives, the more the
|
| 30 |
+
heading is allowed to wander -- a meta anomaly they can never act on.
|
| 31 |
+
"""
|
| 32 |
+
if loops < 4:
|
| 33 |
+
return title_variants[0]
|
| 34 |
+
# a small, growing chance of a slipped heading
|
| 35 |
+
if rng.random() < min(0.05 + loops * 0.01, 0.30):
|
| 36 |
+
return rng.choice(title_variants)
|
| 37 |
+
return title_variants[0]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def drift_sign(sign_variants: List[str], rng: random.Random) -> str:
|
| 41 |
+
"""The exit sign's spacing/arrow drifts even when it is *not* the anomaly."""
|
| 42 |
+
return rng.choice(sign_variants)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def render_sentence(template: str, sign_text: str) -> str:
|
| 46 |
+
"""Fill the shared placeholders in a sentence template."""
|
| 47 |
+
return template.replace("{SIGN}", sign_text)
|
app.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A tiny Flask server for the memory-loop game.
|
| 2 |
+
|
| 3 |
+
Run: python app.py then open http://127.0.0.1:5000
|
| 4 |
+
|
| 5 |
+
The game ships several *arcs* (skins/backstories) that share one mechanic.
|
| 6 |
+
Game state -- and, crucially, the answer to each loop -- lives on the server.
|
| 7 |
+
The browser only ever receives the description, never whether a place is
|
| 8 |
+
anomalous, so the page cannot be inspected to cheat.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import random
|
| 15 |
+
import secrets
|
| 16 |
+
from typing import Dict
|
| 17 |
+
|
| 18 |
+
from flask import (
|
| 19 |
+
Flask,
|
| 20 |
+
jsonify,
|
| 21 |
+
render_template,
|
| 22 |
+
request,
|
| 23 |
+
session,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
from hallway import Hallway, GOAL, DEFAULT_ARC, list_arcs
|
| 27 |
+
from memory import PlayerMemory
|
| 28 |
+
|
| 29 |
+
app = Flask(__name__)
|
| 30 |
+
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
| 31 |
+
|
| 32 |
+
# Available arcs, and a cache of one Hallway per arc (each just holds parsed
|
| 33 |
+
# JSON, so sharing them across requests is safe).
|
| 34 |
+
ARCS = {a["id"]: a for a in list_arcs()}
|
| 35 |
+
_HALLWAYS: Dict[str, Hallway] = {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_hallway(arc_id: str) -> Hallway:
|
| 39 |
+
if arc_id not in ARCS:
|
| 40 |
+
arc_id = DEFAULT_ARC
|
| 41 |
+
if arc_id not in _HALLWAYS:
|
| 42 |
+
_HALLWAYS[arc_id] = Hallway(arc_id)
|
| 43 |
+
return _HALLWAYS[arc_id]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Server-side session store, keyed by a token kept in the signed cookie.
|
| 47 |
+
# Holds the live PlayerMemory, the current (answer-bearing) room, the RNG,
|
| 48 |
+
# and which arc is being played. In-memory is fine for a single process.
|
| 49 |
+
STATE: Dict[str, dict] = {}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _slot() -> dict:
|
| 53 |
+
"""Return this browser's server-side state, creating it if needed."""
|
| 54 |
+
token = session.get("token")
|
| 55 |
+
if not token or token not in STATE:
|
| 56 |
+
token = secrets.token_hex(16)
|
| 57 |
+
session["token"] = token
|
| 58 |
+
STATE[token] = _fresh(DEFAULT_ARC)
|
| 59 |
+
return STATE[token]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _fresh(arc_id: str) -> dict:
|
| 63 |
+
return {
|
| 64 |
+
"mem": PlayerMemory(),
|
| 65 |
+
"room": None,
|
| 66 |
+
"rng": random.Random(secrets.randbits(64)),
|
| 67 |
+
"arc": arc_id if arc_id in ARCS else DEFAULT_ARC,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _hall(slot: dict) -> Hallway:
|
| 72 |
+
return get_hallway(slot["arc"])
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _next_room(slot: dict) -> dict:
|
| 76 |
+
mem: PlayerMemory = slot["mem"]
|
| 77 |
+
mem.loops += 1
|
| 78 |
+
room = _hall(slot).build(mem, slot["rng"])
|
| 79 |
+
slot["room"] = room
|
| 80 |
+
return room
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _payload(slot: dict, room: dict, extra: dict | None = None) -> dict:
|
| 84 |
+
mem: PlayerMemory = slot["mem"]
|
| 85 |
+
hall = _hall(slot)
|
| 86 |
+
data = {
|
| 87 |
+
"room": hall.public(room),
|
| 88 |
+
"meta": hall.meta,
|
| 89 |
+
"level": mem.level,
|
| 90 |
+
"goal": GOAL,
|
| 91 |
+
"best": mem.best_level,
|
| 92 |
+
"loops": mem.loops,
|
| 93 |
+
}
|
| 94 |
+
if extra:
|
| 95 |
+
data.update(extra)
|
| 96 |
+
return jsonify(data)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.route("/")
|
| 100 |
+
def index():
|
| 101 |
+
return render_template("index.html")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.route("/api/arcs", methods=["GET"])
|
| 105 |
+
def api_arcs():
|
| 106 |
+
return jsonify({"arcs": list(ARCS.values()), "default": DEFAULT_ARC})
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.route("/api/new", methods=["POST"])
|
| 110 |
+
def api_new():
|
| 111 |
+
body = request.get_json(silent=True) or {}
|
| 112 |
+
arc_id = body.get("arc", DEFAULT_ARC)
|
| 113 |
+
if arc_id not in ARCS:
|
| 114 |
+
arc_id = DEFAULT_ARC
|
| 115 |
+
|
| 116 |
+
token = session.get("token") or secrets.token_hex(16)
|
| 117 |
+
session["token"] = token
|
| 118 |
+
STATE[token] = _fresh(arc_id)
|
| 119 |
+
slot = STATE[token]
|
| 120 |
+
|
| 121 |
+
room = _next_room(slot)
|
| 122 |
+
return _payload(slot, room, {"intro": _hall(slot).intro()})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.route("/api/room", methods=["GET"])
|
| 126 |
+
def api_room():
|
| 127 |
+
slot = _slot()
|
| 128 |
+
room = slot["room"] or _next_room(slot)
|
| 129 |
+
return _payload(slot, room)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@app.route("/api/inspect", methods=["POST"])
|
| 133 |
+
def api_inspect():
|
| 134 |
+
"""The player lingers on one detail. The place may notice."""
|
| 135 |
+
slot = _slot()
|
| 136 |
+
mem: PlayerMemory = slot["mem"]
|
| 137 |
+
prop = (request.get_json(silent=True) or {}).get("prop", "")
|
| 138 |
+
line = None
|
| 139 |
+
if prop:
|
| 140 |
+
mem.record_inspect(prop)
|
| 141 |
+
# Occasionally, staring too long earns an unsettling aside.
|
| 142 |
+
if slot["rng"].random() < 0.35:
|
| 143 |
+
line = slot["rng"].choice(_hall(slot).data["adaptive"]["long_look"])
|
| 144 |
+
return jsonify({"line": line})
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.route("/api/act", methods=["POST"])
|
| 148 |
+
def api_act():
|
| 149 |
+
"""The player commits: 'continue' goes on, 'back' turns around."""
|
| 150 |
+
slot = _slot()
|
| 151 |
+
mem: PlayerMemory = slot["mem"]
|
| 152 |
+
room = slot["room"]
|
| 153 |
+
if room is None:
|
| 154 |
+
room = _next_room(slot)
|
| 155 |
+
|
| 156 |
+
body = request.get_json(silent=True) or {}
|
| 157 |
+
choice = body.get("choice")
|
| 158 |
+
confidence = body.get("confidence")
|
| 159 |
+
mem.record_confidence(confidence)
|
| 160 |
+
|
| 161 |
+
has_anomaly = room["_has_anomaly"]
|
| 162 |
+
# Correct play: turn back on a changed place, go on when nothing changed.
|
| 163 |
+
correct = (choice == "back") == has_anomaly
|
| 164 |
+
|
| 165 |
+
if choice == "back":
|
| 166 |
+
mem.turn_backs += 1
|
| 167 |
+
else:
|
| 168 |
+
mem.continues += 1
|
| 169 |
+
|
| 170 |
+
won = False
|
| 171 |
+
if correct:
|
| 172 |
+
mem.level += 1
|
| 173 |
+
mem.best_level = max(mem.best_level, mem.level)
|
| 174 |
+
if mem.level >= GOAL:
|
| 175 |
+
won = True
|
| 176 |
+
else:
|
| 177 |
+
if not has_anomaly and choice == "back":
|
| 178 |
+
mem.false_reports += 1
|
| 179 |
+
elif has_anomaly and choice == "continue":
|
| 180 |
+
mem.missed += 1
|
| 181 |
+
mem.level = 0
|
| 182 |
+
|
| 183 |
+
result = {
|
| 184 |
+
"correct": correct,
|
| 185 |
+
"had_anomaly": has_anomaly,
|
| 186 |
+
"won": won,
|
| 187 |
+
"confidence": confidence,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
if won:
|
| 191 |
+
result["win_text"] = _hall(slot).win()
|
| 192 |
+
result["stats"] = mem.to_dict()
|
| 193 |
+
# Reset progress so a fresh run starts clean, but keep the record.
|
| 194 |
+
best = mem.best_level
|
| 195 |
+
slot["mem"] = PlayerMemory(best_level=best)
|
| 196 |
+
room = _next_room(slot)
|
| 197 |
+
return _payload(slot, room, result)
|
| 198 |
+
|
| 199 |
+
room = _next_room(slot)
|
| 200 |
+
return _payload(slot, room, result)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
# HOST/PORT are env-driven so the same file runs locally (127.0.0.1:5000)
|
| 205 |
+
# and on a host like Hugging Face Spaces (0.0.0.0:7860).
|
| 206 |
+
host = os.environ.get("HOST", "127.0.0.1")
|
| 207 |
+
port = int(os.environ.get("PORT", "5000"))
|
| 208 |
+
app.run(host=host, port=port, debug=False)
|
data/arcs/coach8.json
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Coach arc. Same schema and mechanic as the others, different skin and story: a night train. The conductor sent you forward to coach 8, and you walk carriage after identical carriage. Turn back if a coach has changed; keep moving forward if it hasn't. Motion is part of the unease.",
|
| 3 |
+
|
| 4 |
+
"meta": {
|
| 5 |
+
"id": "coach8",
|
| 6 |
+
"title": "Coach 8",
|
| 7 |
+
"tagline": "The conductor said coach 8. The train never seems to reach it.",
|
| 8 |
+
"skin": "coach",
|
| 9 |
+
"go_on": "Next coach →",
|
| 10 |
+
"turn_back": "Back a coach ←",
|
| 11 |
+
"unit": "coach",
|
| 12 |
+
"progress": "Coach {level} / {goal}",
|
| 13 |
+
"labels": {
|
| 14 |
+
"lighting": "lights",
|
| 15 |
+
"sound": "sound",
|
| 16 |
+
"window": "window",
|
| 17 |
+
"passenger": "sleeping passenger",
|
| 18 |
+
"seatnum": "seat numbers",
|
| 19 |
+
"door": "connecting door",
|
| 20 |
+
"luggage": "luggage rack",
|
| 21 |
+
"table": "table",
|
| 22 |
+
"sense": "feeling"
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
"title_variants": ["Coach 8", "Coach 8", "Coach 8", "Coach 8", "Carriage 8", "Coach Eight", "COACH 8", "Coach 8"],
|
| 27 |
+
|
| 28 |
+
"sign_variants": ["COACH 8"],
|
| 29 |
+
|
| 30 |
+
"anchor_order": ["lighting", "sound", "window", "passenger", "seatnum", "door", "luggage", "table", "sense"],
|
| 31 |
+
|
| 32 |
+
"properties": {
|
| 33 |
+
"lighting": {
|
| 34 |
+
"baseline": "dim",
|
| 35 |
+
"values": {
|
| 36 |
+
"dim": [
|
| 37 |
+
"The cabin lights are dimmed for night, a soft amber wash.",
|
| 38 |
+
"Low night-lighting glows along the luggage racks, as always.",
|
| 39 |
+
"The overhead lights are down to their amber night setting.",
|
| 40 |
+
"The coach is lit the same soft gold as the last."
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
"anomalies": {
|
| 44 |
+
"flicker": [
|
| 45 |
+
"The cabin lights flutter with the motion of the train, then hold.",
|
| 46 |
+
"One strip of lighting stutters overhead.",
|
| 47 |
+
"The amber light trips off, then on again."
|
| 48 |
+
],
|
| 49 |
+
"bright": [
|
| 50 |
+
"The lights are up full, harsh and white — not the night setting.",
|
| 51 |
+
"This coach is lit far brighter than the last.",
|
| 52 |
+
"The soft amber is gone; everything glares white."
|
| 53 |
+
],
|
| 54 |
+
"off": [
|
| 55 |
+
"The cabin is dark but for the emergency floor strips.",
|
| 56 |
+
"The lights are out here; only the aisle markers glow.",
|
| 57 |
+
"Darkness, save a thin line of light along the floor."
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
|
| 62 |
+
"sound": {
|
| 63 |
+
"baseline": "rhythm",
|
| 64 |
+
"values": {
|
| 65 |
+
"rhythm": [
|
| 66 |
+
"The rails beat their steady rhythm beneath the floor.",
|
| 67 |
+
"The train hums along, wheels ticking over the joints.",
|
| 68 |
+
"That familiar sway and clatter of a train at speed."
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
"anomalies": {
|
| 72 |
+
"slowing": [
|
| 73 |
+
"The rhythm of the rails is slowing. Are you stopping?",
|
| 74 |
+
"The clatter drags, as if the train is losing speed.",
|
| 75 |
+
"The beat beneath the floor stretches longer between ticks."
|
| 76 |
+
],
|
| 77 |
+
"silence": [
|
| 78 |
+
"The wheels have gone silent. You feel no motion at all.",
|
| 79 |
+
"The train sound has stopped. Dead still.",
|
| 80 |
+
"No clatter, no sway. The train might not be moving."
|
| 81 |
+
],
|
| 82 |
+
"announce": [
|
| 83 |
+
"A chime sounds, then a muffled announcement you can't make out.",
|
| 84 |
+
"The PA crackles with a voice, then cuts off.",
|
| 85 |
+
"An announcement plays — backwards, almost."
|
| 86 |
+
],
|
| 87 |
+
"faster": [
|
| 88 |
+
"The rails beat faster than they should, a rising whine.",
|
| 89 |
+
"The train is accelerating, the clatter climbing in pitch.",
|
| 90 |
+
"Everything rattles harder; you're speeding up."
|
| 91 |
+
]
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
"window": {
|
| 96 |
+
"baseline": "night",
|
| 97 |
+
"values": {
|
| 98 |
+
"night": [
|
| 99 |
+
"Beyond the window, black countryside and your own faint reflection.",
|
| 100 |
+
"The window shows only night and the ghost of the cabin.",
|
| 101 |
+
"Dark fields streak past the window, far-off lights scattered."
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
"anomalies": {
|
| 105 |
+
"station": [
|
| 106 |
+
"The window shows a lit platform sliding by — a station, unannounced.",
|
| 107 |
+
"Outside, a station platform passes, empty and bright.",
|
| 108 |
+
"A station slides past the glass; you never felt the train slow."
|
| 109 |
+
],
|
| 110 |
+
"reflection": [
|
| 111 |
+
"Your reflection in the glass is a half-second behind you.",
|
| 112 |
+
"The reflection in the window doesn't quite match the cabin.",
|
| 113 |
+
"Someone stands in the window's reflection who isn't in the coach."
|
| 114 |
+
],
|
| 115 |
+
"daylight": [
|
| 116 |
+
"The window is bright with daylight. It was night a moment ago.",
|
| 117 |
+
"Outside the glass it is suddenly, impossibly, day.",
|
| 118 |
+
"Sunlight fills the window where there was only dark."
|
| 119 |
+
],
|
| 120 |
+
"tunnel": [
|
| 121 |
+
"The window is pure black — a tunnel, roaring, with no end to it.",
|
| 122 |
+
"You're in a tunnel now; the glass shows only rushing dark.",
|
| 123 |
+
"Nothing but black tunnel wall streams past the window."
|
| 124 |
+
]
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
"passenger": {
|
| 129 |
+
"baseline": "sleeping",
|
| 130 |
+
"values": {
|
| 131 |
+
"sleeping": [
|
| 132 |
+
"A lone passenger sleeps against the far window, coat over their face.",
|
| 133 |
+
"One passenger dozes midway down the coach, head tipped back.",
|
| 134 |
+
"The same sleeping figure slumps in a window seat, as before."
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
"anomalies": {
|
| 138 |
+
"gone": [
|
| 139 |
+
"The seat where the passenger slept is empty now.",
|
| 140 |
+
"No one is sleeping in the coach this time.",
|
| 141 |
+
"The dozing passenger is gone; only a folded coat remains."
|
| 142 |
+
],
|
| 143 |
+
"awake": [
|
| 144 |
+
"The passenger is awake now, sitting upright, very still.",
|
| 145 |
+
"The sleeper's eyes are open, fixed on the aisle.",
|
| 146 |
+
"The passenger no longer sleeps. They watch you pass."
|
| 147 |
+
],
|
| 148 |
+
"moved": [
|
| 149 |
+
"The sleeping passenger has moved to a seat nearer the aisle.",
|
| 150 |
+
"The dozing figure is closer than before, on your side now.",
|
| 151 |
+
"The passenger sleeps in a different seat than a moment ago."
|
| 152 |
+
],
|
| 153 |
+
"two": [
|
| 154 |
+
"Two passengers sleep here now, where there was one.",
|
| 155 |
+
"There are a pair of coats-over-faces this time, not one.",
|
| 156 |
+
"A second sleeping figure has joined the first."
|
| 157 |
+
],
|
| 158 |
+
"speaks": [
|
| 159 |
+
"The sleeping passenger murmurs, without waking: 'again?'",
|
| 160 |
+
"From under the coat, a muffled voice: 'you were just here.'",
|
| 161 |
+
"The dozing figure says the name of your stop, though you told no one."
|
| 162 |
+
]
|
| 163 |
+
}
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
"seatnum": {
|
| 167 |
+
"baseline": "eight",
|
| 168 |
+
"values": {
|
| 169 |
+
"eight": [
|
| 170 |
+
"The seat decals read 8A, 8B, 8C down the row.",
|
| 171 |
+
"Little brass numbers mark the seats: row 8, as always.",
|
| 172 |
+
"The coach-number panel by the door glows COACH 8."
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
"anomalies": {
|
| 176 |
+
"wrong": [
|
| 177 |
+
"The seat decals read row 7 now, not 8.",
|
| 178 |
+
"The coach-number panel reads COACH 9.",
|
| 179 |
+
"The brass numbers say row 6; you were sure it was 8."
|
| 180 |
+
],
|
| 181 |
+
"missing": [
|
| 182 |
+
"The seat numbers have been prised off; bare screw-holes remain.",
|
| 183 |
+
"The coach-number panel by the door is dark and blank.",
|
| 184 |
+
"There are no numbers on the seats at all this time."
|
| 185 |
+
],
|
| 186 |
+
"letters": [
|
| 187 |
+
"The seat decals read 8X, 8Y, 8Z — letters that don't belong.",
|
| 188 |
+
"The row is numbered in an alphabet you don't recognise.",
|
| 189 |
+
"The seat labels are just symbols now, not numbers."
|
| 190 |
+
]
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
|
| 194 |
+
"door": {
|
| 195 |
+
"baseline": "closed",
|
| 196 |
+
"values": {
|
| 197 |
+
"closed": [
|
| 198 |
+
"The connecting door at the coach's end is shut, its glass dark.",
|
| 199 |
+
"The powered door to the next carriage stands closed, as usual.",
|
| 200 |
+
"The end door is closed, the vestibule beyond it dim."
|
| 201 |
+
]
|
| 202 |
+
},
|
| 203 |
+
"anomalies": {
|
| 204 |
+
"open": [
|
| 205 |
+
"The connecting door is stuck open, cold air rushing through.",
|
| 206 |
+
"The powered door won't close; the vestibule roars beyond.",
|
| 207 |
+
"The end door gapes open onto the shaking vestibule."
|
| 208 |
+
],
|
| 209 |
+
"locked": [
|
| 210 |
+
"A red light burns on the connecting door: LOCKED.",
|
| 211 |
+
"The end door shows a locked indicator that was green before.",
|
| 212 |
+
"The door to the next coach reads DO NOT ENTER."
|
| 213 |
+
],
|
| 214 |
+
"glass": [
|
| 215 |
+
"The glass in the connecting door is fogged, a hand-print on it.",
|
| 216 |
+
"Something smears the window of the end door from the far side.",
|
| 217 |
+
"The connecting door's glass is cracked in a spider's web."
|
| 218 |
+
]
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
|
| 222 |
+
"luggage": {
|
| 223 |
+
"baseline": "stowed",
|
| 224 |
+
"values": {
|
| 225 |
+
"stowed": [
|
| 226 |
+
"A single suitcase sits in the luggage rack by the door.",
|
| 227 |
+
"The overhead racks hold the usual scattering of bags.",
|
| 228 |
+
"One hard-shell case rides the luggage rack, as before."
|
| 229 |
+
]
|
| 230 |
+
},
|
| 231 |
+
"anomalies": {
|
| 232 |
+
"gone": [
|
| 233 |
+
"The luggage rack is empty now.",
|
| 234 |
+
"The suitcase by the door is gone.",
|
| 235 |
+
"There are no bags in the racks this time."
|
| 236 |
+
],
|
| 237 |
+
"more": [
|
| 238 |
+
"The luggage rack is crammed full, far more bags than before.",
|
| 239 |
+
"Cases are stacked nearly to the ceiling now.",
|
| 240 |
+
"The racks overflow with luggage that wasn't there."
|
| 241 |
+
],
|
| 242 |
+
"open": [
|
| 243 |
+
"The suitcase in the rack lies open, empty.",
|
| 244 |
+
"A case has spilled open across the luggage rack.",
|
| 245 |
+
"The hard-shell case gapes open, its contents gone."
|
| 246 |
+
]
|
| 247 |
+
}
|
| 248 |
+
},
|
| 249 |
+
|
| 250 |
+
"table": {
|
| 251 |
+
"baseline": "coffee",
|
| 252 |
+
"values": {
|
| 253 |
+
"coffee": [
|
| 254 |
+
"A fold-down table by the aisle holds a half-finished coffee.",
|
| 255 |
+
"The tray table is down, a paper cup cooling on it, as always.",
|
| 256 |
+
"On the table, someone's coffee, still steaming faintly."
|
| 257 |
+
]
|
| 258 |
+
},
|
| 259 |
+
"anomalies": {
|
| 260 |
+
"empty": [
|
| 261 |
+
"The table is bare; the coffee cup is gone.",
|
| 262 |
+
"The tray table holds nothing now.",
|
| 263 |
+
"There's no cup on the table this time."
|
| 264 |
+
],
|
| 265 |
+
"cold": [
|
| 266 |
+
"The coffee on the table has gone cold, a skin across the top.",
|
| 267 |
+
"The cup is stone cold now, long abandoned.",
|
| 268 |
+
"The coffee sits cold and still, no steam at all."
|
| 269 |
+
],
|
| 270 |
+
"many": [
|
| 271 |
+
"The table is crowded with cups now, not one but many.",
|
| 272 |
+
"Several coffees crowd the tray table where there was one.",
|
| 273 |
+
"The table is cluttered with more cups than anyone could drink."
|
| 274 |
+
],
|
| 275 |
+
"spilled": [
|
| 276 |
+
"The coffee has spilled, a dark stain spreading over the table.",
|
| 277 |
+
"The cup is tipped, coffee running off the table's edge.",
|
| 278 |
+
"Coffee pools across the table, dripping to the floor."
|
| 279 |
+
]
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
|
| 283 |
+
"sense": {
|
| 284 |
+
"baseline": "steady",
|
| 285 |
+
"values": {
|
| 286 |
+
"steady": [
|
| 287 |
+
"The train feels exactly as long as it always has.",
|
| 288 |
+
"You carry the same tired patience up the aisle, coach after coach.",
|
| 289 |
+
"Everything feels as ordinary as a night train should."
|
| 290 |
+
]
|
| 291 |
+
},
|
| 292 |
+
"anomalies": {
|
| 293 |
+
"forget": [
|
| 294 |
+
"For a moment you can't remember how many coaches you've walked.",
|
| 295 |
+
"You lose count of the carriages behind you, and it frightens you.",
|
| 296 |
+
"You can't recall boarding this train at all."
|
| 297 |
+
],
|
| 298 |
+
"calm": [
|
| 299 |
+
"You feel suddenly, wrongly calm — as if you'd already arrived.",
|
| 300 |
+
"A strange contentment settles over you that has no business being here.",
|
| 301 |
+
"You feel no urgency at all, and that itself feels wrong."
|
| 302 |
+
],
|
| 303 |
+
"watched": [
|
| 304 |
+
"You feel watched, though every passenger is asleep.",
|
| 305 |
+
"The certainty that someone is behind you won't leave.",
|
| 306 |
+
"Your neck prickles; you are sure you are not alone in the way you were."
|
| 307 |
+
]
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
},
|
| 311 |
+
|
| 312 |
+
"adaptive": {
|
| 313 |
+
"repeat_inspect": [
|
| 314 |
+
"You look at the {THING} again. You aren't sure why.",
|
| 315 |
+
"Your eyes go straight to the {THING}, the way they always seem to now.",
|
| 316 |
+
"The {THING} draws your attention once more."
|
| 317 |
+
],
|
| 318 |
+
"long_look": [
|
| 319 |
+
"You spend longer looking than usual. You don't remember deciding to stop.",
|
| 320 |
+
"You realise you've been standing in the aisle. For how long?",
|
| 321 |
+
"The train sways on, and you don't know how long you've been staring."
|
| 322 |
+
]
|
| 323 |
+
},
|
| 324 |
+
|
| 325 |
+
"framing": {
|
| 326 |
+
"intro": [
|
| 327 |
+
"COACH 8",
|
| 328 |
+
"",
|
| 329 |
+
"The conductor clipped your ticket and nodded up the train: coach 8, that way.",
|
| 330 |
+
"You've been walking forward through the carriages ever since.",
|
| 331 |
+
"",
|
| 332 |
+
"Each coach looks like the last. If one differs from what you remember, TURN BACK.",
|
| 333 |
+
"If nothing has changed, keep moving to the NEXT COACH.",
|
| 334 |
+
"",
|
| 335 |
+
"Reach coach 8 and you can finally sit down.",
|
| 336 |
+
"Lose your place, and you're back where the conductor left you."
|
| 337 |
+
],
|
| 338 |
+
"win": [
|
| 339 |
+
"You slide open one more door — and the seat numbers read 8.",
|
| 340 |
+
"Coach 8. Your seat, empty and waiting, beside a black window.",
|
| 341 |
+
"You sink into it at last. The train rushes on through the night.",
|
| 342 |
+
"You found your seat."
|
| 343 |
+
]
|
| 344 |
+
}
|
| 345 |
+
}
|
data/arcs/hallway-eight.json
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "The canonical corridor. Each property has a baseline value with a pool of equivalent sentences (wording drifts while meaning stays fixed), plus anomalous variants with their own pools. Remember MEANING, not wording.",
|
| 3 |
+
|
| 4 |
+
"meta": {
|
| 5 |
+
"id": "hallway-eight",
|
| 6 |
+
"title": "Hallway 8",
|
| 7 |
+
"tagline": "Locked in after hours. Follow the corridor to the fire exit.",
|
| 8 |
+
"skin": "hallway",
|
| 9 |
+
"go_on": "Walk on ↑",
|
| 10 |
+
"turn_back": "Turn back ↓",
|
| 11 |
+
"unit": "corridor",
|
| 12 |
+
"progress": "Hallway {level} / {goal}",
|
| 13 |
+
"labels": {
|
| 14 |
+
"lighting": "lights",
|
| 15 |
+
"sound": "sound",
|
| 16 |
+
"poster": "poster",
|
| 17 |
+
"figure": "man down the hall",
|
| 18 |
+
"sign": "exit sign",
|
| 19 |
+
"door": "maintenance door",
|
| 20 |
+
"camera": "camera",
|
| 21 |
+
"floor": "floor",
|
| 22 |
+
"sense": "feeling"
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
"title_variants": ["Hallway 8", "Hallway 8", "Hallway 8", "Hallway 8", "Corridor 8", "Hallway Eight", "HALLWAY 8", "Hallway 8"],
|
| 27 |
+
|
| 28 |
+
"sign_variants": ["EXIT →", "EXIT →", "EXIT →", "EXIT→", "Exit →", "EXIT ⇢", "EXIT → "],
|
| 29 |
+
|
| 30 |
+
"anchor_order": ["lighting", "sound", "poster", "figure", "sign", "door", "camera", "floor", "sense"],
|
| 31 |
+
|
| 32 |
+
"properties": {
|
| 33 |
+
"lighting": {
|
| 34 |
+
"baseline": "steady",
|
| 35 |
+
"values": {
|
| 36 |
+
"steady": [
|
| 37 |
+
"The fluorescent lights hum softly overhead.",
|
| 38 |
+
"The ceiling lights give off a steady, even glow.",
|
| 39 |
+
"Overhead, the fluorescents buzz at their usual pitch.",
|
| 40 |
+
"The lighting is flat and unremarkable, the way it always is."
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
"anomalies": {
|
| 44 |
+
"flicker": [
|
| 45 |
+
"One of the fluorescent tubes stutters, then holds.",
|
| 46 |
+
"The lights flicker — once, twice — before settling.",
|
| 47 |
+
"There's a faint strobe to the ceiling lights that wasn't there before."
|
| 48 |
+
],
|
| 49 |
+
"dim": [
|
| 50 |
+
"The corridor seems a shade darker than you remember.",
|
| 51 |
+
"The lights are dimmer here, as if half of them gave up.",
|
| 52 |
+
"A grey dimness has settled over everything."
|
| 53 |
+
],
|
| 54 |
+
"warm": [
|
| 55 |
+
"The light has a warm, yellowish cast to it.",
|
| 56 |
+
"The fluorescents glow oddly amber, almost like sunset.",
|
| 57 |
+
"The usual cold white has turned faintly golden."
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
|
| 62 |
+
"sound": {
|
| 63 |
+
"baseline": "vent",
|
| 64 |
+
"values": {
|
| 65 |
+
"vent": [
|
| 66 |
+
"Somewhere, ventilation exhales a low, constant breath.",
|
| 67 |
+
"The air handling hums in the walls, steady as ever.",
|
| 68 |
+
"You hear the familiar drone of distant ventilation."
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
"anomalies": {
|
| 72 |
+
"drip": [
|
| 73 |
+
"A slow drip taps somewhere out of sight.",
|
| 74 |
+
"Water is dripping — you can't place where.",
|
| 75 |
+
"Tick. Tick. Something is dripping nearby."
|
| 76 |
+
],
|
| 77 |
+
"silence": [
|
| 78 |
+
"The ventilation has stopped. The silence presses on your ears.",
|
| 79 |
+
"It is completely quiet. Too quiet.",
|
| 80 |
+
"You notice the hum is gone. The stillness feels wrong."
|
| 81 |
+
],
|
| 82 |
+
"footsteps": [
|
| 83 |
+
"Footsteps echo somewhere behind you, then stop.",
|
| 84 |
+
"You hear footsteps that don't match your own.",
|
| 85 |
+
"For a moment there are two sets of footsteps."
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
|
| 90 |
+
"poster": {
|
| 91 |
+
"baseline": "travel",
|
| 92 |
+
"values": {
|
| 93 |
+
"travel": [
|
| 94 |
+
"A faded travel poster clings to the wall — a beach, a blue sky.",
|
| 95 |
+
"The old poster of a sunny coastline is still taped to the wall.",
|
| 96 |
+
"On the wall, that washed-out advertisement for somewhere warm.",
|
| 97 |
+
"The travel poster hangs at its usual slight tilt."
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
"anomalies": {
|
| 101 |
+
"gone": [
|
| 102 |
+
"The wall is bare. A pale rectangle marks where a poster used to be.",
|
| 103 |
+
"The travel poster is gone; only the tape remains.",
|
| 104 |
+
"Nothing hangs on the wall now."
|
| 105 |
+
],
|
| 106 |
+
"mountains": [
|
| 107 |
+
"The poster shows snow-capped mountains, not a beach.",
|
| 108 |
+
"The coastline poster is now a photograph of grey mountains.",
|
| 109 |
+
"The advertisement pictures a cold summit under heavy cloud."
|
| 110 |
+
],
|
| 111 |
+
"upside": [
|
| 112 |
+
"The travel poster hangs upside down.",
|
| 113 |
+
"The beach on the poster is inverted, sky at the bottom.",
|
| 114 |
+
"Someone has flipped the poster; it hangs the wrong way up."
|
| 115 |
+
]
|
| 116 |
+
}
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
"figure": {
|
| 120 |
+
"baseline": "distant",
|
| 121 |
+
"values": {
|
| 122 |
+
"distant": [
|
| 123 |
+
"Far down the corridor, a man in a grey suit walks away from you.",
|
| 124 |
+
"A suited figure moves ahead in the distance, back turned.",
|
| 125 |
+
"The man in grey is up ahead, as always, walking on.",
|
| 126 |
+
"Down the hall, a man in a suit recedes toward the vanishing point."
|
| 127 |
+
]
|
| 128 |
+
},
|
| 129 |
+
"anomalies": {
|
| 130 |
+
"closer": [
|
| 131 |
+
"The man in the grey suit is much closer than he should be.",
|
| 132 |
+
"The suited figure has stopped halfway, closer than before.",
|
| 133 |
+
"The man in grey is only a few paces ahead now, still turned away."
|
| 134 |
+
],
|
| 135 |
+
"facing": [
|
| 136 |
+
"The man in the grey suit has stopped. He is facing you.",
|
| 137 |
+
"Down the hall, the suited figure stands still, looking your way.",
|
| 138 |
+
"The man in grey is not walking. He is watching you."
|
| 139 |
+
],
|
| 140 |
+
"gone": [
|
| 141 |
+
"The corridor ahead is empty. No one is walking it.",
|
| 142 |
+
"There is no figure ahead this time.",
|
| 143 |
+
"The man in the grey suit is nowhere to be seen."
|
| 144 |
+
],
|
| 145 |
+
"knows": [
|
| 146 |
+
"The man in grey has turned and raised a hand, as if he knows you.",
|
| 147 |
+
"The suited figure faces you and beckons, slow and deliberate.",
|
| 148 |
+
"The man mouths something down the corridor. It might be 'again.'"
|
| 149 |
+
]
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
|
| 153 |
+
"sign": {
|
| 154 |
+
"baseline": "exit",
|
| 155 |
+
"values": {
|
| 156 |
+
"exit": [
|
| 157 |
+
"The illuminated {SIGN} sign glows at the far end.",
|
| 158 |
+
"Ahead, the {SIGN} sign hangs above the corridor.",
|
| 159 |
+
"The green {SIGN} sign points the way, as it should.",
|
| 160 |
+
"You can make out the {SIGN} sign in the distance."
|
| 161 |
+
]
|
| 162 |
+
},
|
| 163 |
+
"anomalies": {
|
| 164 |
+
"reversed": [
|
| 165 |
+
"The exit sign points the wrong way — EXIT ←.",
|
| 166 |
+
"The arrow on the exit sign now points back the way you came: ←.",
|
| 167 |
+
"EXIT ←. The arrow has reversed."
|
| 168 |
+
],
|
| 169 |
+
"dark": [
|
| 170 |
+
"The exit sign is dark. No light behind the letters.",
|
| 171 |
+
"The exit sign has gone out.",
|
| 172 |
+
"Where the exit sign should glow, there is only a dead panel."
|
| 173 |
+
],
|
| 174 |
+
"number": [
|
| 175 |
+
"The sign reads EXIT 3. There was never a number on it.",
|
| 176 |
+
"A number has appeared on the sign: EXIT 3.",
|
| 177 |
+
"The exit sign now carries a numeral you don't remember: 3."
|
| 178 |
+
]
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
|
| 182 |
+
"door": {
|
| 183 |
+
"baseline": "closed",
|
| 184 |
+
"values": {
|
| 185 |
+
"closed": [
|
| 186 |
+
"A grey maintenance door sits shut in the left wall.",
|
| 187 |
+
"The service door on your left is closed, as usual.",
|
| 188 |
+
"There's the maintenance door, shut tight."
|
| 189 |
+
]
|
| 190 |
+
},
|
| 191 |
+
"anomalies": {
|
| 192 |
+
"ajar": [
|
| 193 |
+
"The maintenance door stands slightly ajar. Darkness inside.",
|
| 194 |
+
"The service door is open a crack, though you never saw it open.",
|
| 195 |
+
"The grey door is ajar; a thin black gap where it should be flush."
|
| 196 |
+
],
|
| 197 |
+
"gone": [
|
| 198 |
+
"There is no maintenance door in the left wall. Just smooth wall.",
|
| 199 |
+
"The service door is missing entirely.",
|
| 200 |
+
"The left wall is unbroken — no door at all."
|
| 201 |
+
]
|
| 202 |
+
}
|
| 203 |
+
},
|
| 204 |
+
|
| 205 |
+
"camera": {
|
| 206 |
+
"baseline": "away",
|
| 207 |
+
"values": {
|
| 208 |
+
"away": [
|
| 209 |
+
"A small CCTV camera in the corner points down the corridor.",
|
| 210 |
+
"The security camera watches the far end, angled away from you.",
|
| 211 |
+
"Up in the corner, the camera faces ahead, as always."
|
| 212 |
+
]
|
| 213 |
+
},
|
| 214 |
+
"anomalies": {
|
| 215 |
+
"atyou": [
|
| 216 |
+
"The CCTV camera has turned. It points directly at you.",
|
| 217 |
+
"The security camera is angled down at you now.",
|
| 218 |
+
"The camera in the corner faces you, its lens level with your eyes."
|
| 219 |
+
],
|
| 220 |
+
"red": [
|
| 221 |
+
"A red light blinks on the camera. It never used to blink.",
|
| 222 |
+
"The camera's recording light glows red for the first time.",
|
| 223 |
+
"The little LED on the camera pulses red."
|
| 224 |
+
]
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
|
| 228 |
+
"floor": {
|
| 229 |
+
"baseline": "dry",
|
| 230 |
+
"values": {
|
| 231 |
+
"dry": [
|
| 232 |
+
"The grey floor tiles stretch away, dry and dull.",
|
| 233 |
+
"Underfoot, the usual scuffed grey tiles.",
|
| 234 |
+
"The tiled floor is dry, reflecting nothing."
|
| 235 |
+
]
|
| 236 |
+
},
|
| 237 |
+
"anomalies": {
|
| 238 |
+
"wet": [
|
| 239 |
+
"The tiles ahead shine with a wet, dark sheen.",
|
| 240 |
+
"The floor is wet, though you saw no one clean it.",
|
| 241 |
+
"A slick of water spreads across the tiles."
|
| 242 |
+
],
|
| 243 |
+
"cracked": [
|
| 244 |
+
"A crack runs across the floor tiles that wasn't there before.",
|
| 245 |
+
"One of the floor tiles is split down the middle.",
|
| 246 |
+
"The tiling is fractured in a jagged line ahead."
|
| 247 |
+
]
|
| 248 |
+
}
|
| 249 |
+
},
|
| 250 |
+
|
| 251 |
+
"sense": {
|
| 252 |
+
"baseline": "steady",
|
| 253 |
+
"values": {
|
| 254 |
+
"steady": [
|
| 255 |
+
"The corridor feels exactly as long as it always has.",
|
| 256 |
+
"You carry the same low dread you've had since the lobby.",
|
| 257 |
+
"Everything feels ordinary, in the way this place is ever ordinary."
|
| 258 |
+
]
|
| 259 |
+
},
|
| 260 |
+
"anomalies": {
|
| 261 |
+
"longer": [
|
| 262 |
+
"The corridor feels longer this time, though you can't say why.",
|
| 263 |
+
"The far end seems further off than it should be.",
|
| 264 |
+
"The walls feel as if they've stretched since the last stretch."
|
| 265 |
+
],
|
| 266 |
+
"forget": [
|
| 267 |
+
"For a moment you can't remember how many doors you've passed.",
|
| 268 |
+
"You lose your count of the corridors, and it unsettles you.",
|
| 269 |
+
"You can't recall why you're down here at all."
|
| 270 |
+
],
|
| 271 |
+
"calm": [
|
| 272 |
+
"You feel suddenly, wrongly calm — as if you'd already escaped.",
|
| 273 |
+
"A strange relief washes over you that has no business being here.",
|
| 274 |
+
"You feel no fear at all, and that itself feels wrong."
|
| 275 |
+
]
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
},
|
| 279 |
+
|
| 280 |
+
"adaptive": {
|
| 281 |
+
"repeat_inspect": [
|
| 282 |
+
"You glance at the {THING} again. You aren't sure why.",
|
| 283 |
+
"Your eyes go straight to the {THING}, the way they always seem to now.",
|
| 284 |
+
"The {THING} draws your attention once more."
|
| 285 |
+
],
|
| 286 |
+
"long_look": [
|
| 287 |
+
"You spend longer looking than usual. You don't remember deciding to stop.",
|
| 288 |
+
"You realise you've been standing still. For how long?",
|
| 289 |
+
"Time slips. You were staring, and you don't know for how long."
|
| 290 |
+
]
|
| 291 |
+
},
|
| 292 |
+
|
| 293 |
+
"framing": {
|
| 294 |
+
"intro": [
|
| 295 |
+
"AFTER HOURS",
|
| 296 |
+
"",
|
| 297 |
+
"You stayed too late. The lobby doors are locked, the lights half-out.",
|
| 298 |
+
"A handwritten sign hangs over them: FIRE EXIT — USE CORRIDOR.",
|
| 299 |
+
"",
|
| 300 |
+
"So you follow it. Each stretch of corridor looks like the last.",
|
| 301 |
+
"",
|
| 302 |
+
"If anything differs from what you remember, TURN BACK.",
|
| 303 |
+
"If nothing has changed, WALK ON.",
|
| 304 |
+
"",
|
| 305 |
+
"Reach the eighth fire door and you are out.",
|
| 306 |
+
"Lose track of what you've seen, and the corridor starts you over."
|
| 307 |
+
],
|
| 308 |
+
"win": [
|
| 309 |
+
"The eighth fire door gives under your hands.",
|
| 310 |
+
"Cold air. A stairwell down to the street. The city, still there.",
|
| 311 |
+
"You were beginning to think the corridor had no end.",
|
| 312 |
+
"You are out."
|
| 313 |
+
]
|
| 314 |
+
}
|
| 315 |
+
}
|
data/arcs/stairway8.json
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Stairway arc. Same schema and mechanic as hallway-eight, different skin and story: the elevator is out, so you descend eight flights on foot. Each landing should be identical -- go back up if a landing has changed, keep descending if it hasn't.",
|
| 3 |
+
|
| 4 |
+
"meta": {
|
| 5 |
+
"id": "stairway8",
|
| 6 |
+
"title": "Stairway 8",
|
| 7 |
+
"tagline": "The elevator is out. Eight floors down, one landing at a time.",
|
| 8 |
+
"skin": "stairway",
|
| 9 |
+
"go_on": "Descend ↓",
|
| 10 |
+
"turn_back": "Back up ↑",
|
| 11 |
+
"unit": "landing",
|
| 12 |
+
"progress": "Floor {floor} · {level}/{goal} down",
|
| 13 |
+
"labels": {
|
| 14 |
+
"lighting": "light",
|
| 15 |
+
"sound": "sound",
|
| 16 |
+
"number": "floor number",
|
| 17 |
+
"door": "fire door",
|
| 18 |
+
"notice": "elevator notice",
|
| 19 |
+
"railing": "handrail",
|
| 20 |
+
"extinguisher": "extinguisher",
|
| 21 |
+
"stairs": "stairs down",
|
| 22 |
+
"figure": "figure on the stairs",
|
| 23 |
+
"sense": "feeling"
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
"title_variants": ["Stairway 8", "Stairway 8", "Stairway 8", "Stairway 8", "Stairwell 8", "Stairway Eight", "STAIRWAY 8", "Stairway 8"],
|
| 28 |
+
|
| 29 |
+
"sign_variants": ["OUT OF ORDER"],
|
| 30 |
+
|
| 31 |
+
"anchor_order": ["lighting", "sound", "number", "door", "notice", "figure", "railing", "extinguisher", "stairs", "sense"],
|
| 32 |
+
|
| 33 |
+
"properties": {
|
| 34 |
+
"lighting": {
|
| 35 |
+
"baseline": "steady",
|
| 36 |
+
"values": {
|
| 37 |
+
"steady": [
|
| 38 |
+
"A caged bulb throws steady light across the landing.",
|
| 39 |
+
"The stairwell light burns evenly overhead, as always.",
|
| 40 |
+
"The fluorescent strip on the landing glows its usual flat white.",
|
| 41 |
+
"Light falls here the same as every landing above."
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
"anomalies": {
|
| 45 |
+
"flicker": [
|
| 46 |
+
"The caged bulb stutters, then steadies.",
|
| 47 |
+
"The stairwell light flickers in a way it hasn't before.",
|
| 48 |
+
"Overhead, the light trips once and comes back."
|
| 49 |
+
],
|
| 50 |
+
"dim": [
|
| 51 |
+
"This landing is dimmer than the ones above it.",
|
| 52 |
+
"The light here has half given up; the corners stay dark.",
|
| 53 |
+
"A grey murk hangs over the landing."
|
| 54 |
+
],
|
| 55 |
+
"red": [
|
| 56 |
+
"The landing is lit red — an emergency bulb you don't remember.",
|
| 57 |
+
"Everything wears a low red emergency glow.",
|
| 58 |
+
"The white light is gone; only a red bulb remains."
|
| 59 |
+
]
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
"sound": {
|
| 64 |
+
"baseline": "quiet",
|
| 65 |
+
"values": {
|
| 66 |
+
"quiet": [
|
| 67 |
+
"Your footsteps echo up the shaft and fade.",
|
| 68 |
+
"The stairwell is quiet but for your own steps.",
|
| 69 |
+
"Far below, the building hums to itself."
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
"anomalies": {
|
| 73 |
+
"drip": [
|
| 74 |
+
"Water drips somewhere down the shaft. Tick. Tick.",
|
| 75 |
+
"A slow drip echoes up from below.",
|
| 76 |
+
"Something is dripping, floors down."
|
| 77 |
+
],
|
| 78 |
+
"slam": [
|
| 79 |
+
"A door slams far below, then silence.",
|
| 80 |
+
"Somewhere beneath you a door bangs shut.",
|
| 81 |
+
"A heavy door closes several floors down."
|
| 82 |
+
],
|
| 83 |
+
"footsteps": [
|
| 84 |
+
"Footsteps climb toward you, then stop.",
|
| 85 |
+
"You hear steps that aren't yours on the flight below.",
|
| 86 |
+
"For a moment, someone else is on the stairs."
|
| 87 |
+
],
|
| 88 |
+
"silence": [
|
| 89 |
+
"The building's hum has stopped. Dead quiet.",
|
| 90 |
+
"It is completely silent. Too silent.",
|
| 91 |
+
"Even the echo of your steps seems to vanish."
|
| 92 |
+
]
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
|
| 96 |
+
"number": {
|
| 97 |
+
"baseline": "eight",
|
| 98 |
+
"values": {
|
| 99 |
+
"eight": [
|
| 100 |
+
"A large painted 8 marks the landing wall.",
|
| 101 |
+
"The floor number — 8 — is stencilled on the concrete.",
|
| 102 |
+
"'8' is painted on the wall, drips frozen beneath the strokes.",
|
| 103 |
+
"The wall reads 8, the same as the landing before."
|
| 104 |
+
]
|
| 105 |
+
},
|
| 106 |
+
"anomalies": {
|
| 107 |
+
"wrong": [
|
| 108 |
+
"The wall reads 7. It has always read 8.",
|
| 109 |
+
"The painted number is a 6 now, not an 8.",
|
| 110 |
+
"The floor number has changed: it says 9."
|
| 111 |
+
],
|
| 112 |
+
"basement": [
|
| 113 |
+
"The wall reads B. Basement. You are nowhere near it.",
|
| 114 |
+
"The number is gone; a painted 'B' stands in its place.",
|
| 115 |
+
"The stencil reads B, though you've barely started down."
|
| 116 |
+
],
|
| 117 |
+
"faded": [
|
| 118 |
+
"The painted number has faded to a grey ghost.",
|
| 119 |
+
"Someone has scrubbed the number half away.",
|
| 120 |
+
"The 8 on the wall is almost gone."
|
| 121 |
+
],
|
| 122 |
+
"reversed": [
|
| 123 |
+
"The 8 is painted backwards, as if seen in a mirror.",
|
| 124 |
+
"The floor number is mirror-reversed.",
|
| 125 |
+
"The stencilled number reads the wrong way round."
|
| 126 |
+
]
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
"door": {
|
| 131 |
+
"baseline": "closed",
|
| 132 |
+
"values": {
|
| 133 |
+
"closed": [
|
| 134 |
+
"The fire door to the floor is shut, its wired glass dark.",
|
| 135 |
+
"The heavy grey fire door stands closed on the landing.",
|
| 136 |
+
"The door onto the floor is closed, the way it should be."
|
| 137 |
+
]
|
| 138 |
+
},
|
| 139 |
+
"anomalies": {
|
| 140 |
+
"ajar": [
|
| 141 |
+
"The fire door hangs slightly ajar. A black gap, no light.",
|
| 142 |
+
"The heavy door is open a crack; it never is.",
|
| 143 |
+
"The fire door isn't quite shut this time."
|
| 144 |
+
],
|
| 145 |
+
"propped": [
|
| 146 |
+
"The fire door is propped wide open onto a dark floor.",
|
| 147 |
+
"Someone has wedged the fire door open.",
|
| 148 |
+
"The door stands fully open, held by nothing you can see."
|
| 149 |
+
],
|
| 150 |
+
"window": [
|
| 151 |
+
"The wired-glass window in the door has gone black, covered.",
|
| 152 |
+
"The little window in the fire door is blocked from the other side.",
|
| 153 |
+
"Something presses against the glass in the door."
|
| 154 |
+
],
|
| 155 |
+
"handle": [
|
| 156 |
+
"The fire door has no handle now.",
|
| 157 |
+
"Where the door handle was, only bare holes.",
|
| 158 |
+
"The handle is missing from the fire door."
|
| 159 |
+
]
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
|
| 163 |
+
"notice": {
|
| 164 |
+
"baseline": "out",
|
| 165 |
+
"values": {
|
| 166 |
+
"out": [
|
| 167 |
+
"A taped notice on the elevator reads OUT OF ORDER — USE STAIRS.",
|
| 168 |
+
"The repair notice is still there: elevator out of order.",
|
| 169 |
+
"OUT OF ORDER, says the sign taped across the elevator doors."
|
| 170 |
+
]
|
| 171 |
+
},
|
| 172 |
+
"anomalies": {
|
| 173 |
+
"gone": [
|
| 174 |
+
"The elevator notice is gone. Just bare metal doors.",
|
| 175 |
+
"No sign on the elevator this time.",
|
| 176 |
+
"The repair notice has been taken down."
|
| 177 |
+
],
|
| 178 |
+
"service": [
|
| 179 |
+
"The notice now reads ELEVATOR IN SERVICE.",
|
| 180 |
+
"The sign says the elevator is working again.",
|
| 181 |
+
"OUT OF ORDER has become BACK IN SERVICE."
|
| 182 |
+
],
|
| 183 |
+
"wording": [
|
| 184 |
+
"The notice reads DO NOT USE STAIRS.",
|
| 185 |
+
"The sign warns STAIRS CLOSED — it never did before.",
|
| 186 |
+
"The notice now tells you not to take the stairs at all."
|
| 187 |
+
],
|
| 188 |
+
"arrow": [
|
| 189 |
+
"The arrow on the notice points up, not down.",
|
| 190 |
+
"The sign's arrow has flipped to point back up the stairs.",
|
| 191 |
+
"The notice directs you upward now."
|
| 192 |
+
]
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
|
| 196 |
+
"railing": {
|
| 197 |
+
"baseline": "cold",
|
| 198 |
+
"values": {
|
| 199 |
+
"cold": [
|
| 200 |
+
"The steel handrail runs cold and unbroken under your hand.",
|
| 201 |
+
"The painted railing is right where it always is.",
|
| 202 |
+
"Your hand finds the handrail, continuous along the flight."
|
| 203 |
+
]
|
| 204 |
+
},
|
| 205 |
+
"anomalies": {
|
| 206 |
+
"gap": [
|
| 207 |
+
"A section of the handrail is missing over the drop.",
|
| 208 |
+
"The railing has a gap where a length has been removed.",
|
| 209 |
+
"Part of the handrail simply isn't there."
|
| 210 |
+
],
|
| 211 |
+
"wet": [
|
| 212 |
+
"The handrail is wet, though nothing drips on it.",
|
| 213 |
+
"Your hand comes away damp from the rail.",
|
| 214 |
+
"The railing is slick under your palm."
|
| 215 |
+
],
|
| 216 |
+
"paint": [
|
| 217 |
+
"The railing is a different colour than the flights above.",
|
| 218 |
+
"Someone has repainted this stretch of handrail.",
|
| 219 |
+
"The rail's paint is green here, not grey."
|
| 220 |
+
]
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
|
| 224 |
+
"extinguisher": {
|
| 225 |
+
"baseline": "present",
|
| 226 |
+
"values": {
|
| 227 |
+
"present": [
|
| 228 |
+
"A fire extinguisher hangs on its bracket beside the door.",
|
| 229 |
+
"The red extinguisher is in its place on the wall.",
|
| 230 |
+
"The landing's fire extinguisher sits on its hook."
|
| 231 |
+
]
|
| 232 |
+
},
|
| 233 |
+
"anomalies": {
|
| 234 |
+
"gone": [
|
| 235 |
+
"The extinguisher bracket is empty.",
|
| 236 |
+
"The fire extinguisher is gone; only the bracket remains.",
|
| 237 |
+
"No extinguisher on the wall this time."
|
| 238 |
+
],
|
| 239 |
+
"floor": [
|
| 240 |
+
"The extinguisher lies on the floor, on its side.",
|
| 241 |
+
"The fire extinguisher has been set down on the landing.",
|
| 242 |
+
"The extinguisher stands loose against the wall, off its hook."
|
| 243 |
+
],
|
| 244 |
+
"gauge": [
|
| 245 |
+
"The extinguisher's pressure gauge reads in the red.",
|
| 246 |
+
"The needle on the extinguisher sits deep in the red.",
|
| 247 |
+
"The extinguisher's gauge has dropped to empty."
|
| 248 |
+
]
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
|
| 252 |
+
"stairs": {
|
| 253 |
+
"baseline": "clear",
|
| 254 |
+
"values": {
|
| 255 |
+
"clear": [
|
| 256 |
+
"The flight down is clear, its steps even and dry.",
|
| 257 |
+
"The stairs descend as always, nothing on them.",
|
| 258 |
+
"The next flight drops away, clean and empty."
|
| 259 |
+
]
|
| 260 |
+
},
|
| 261 |
+
"anomalies": {
|
| 262 |
+
"wet": [
|
| 263 |
+
"The steps below shine wet in the light.",
|
| 264 |
+
"The next flight is slick with water.",
|
| 265 |
+
"The stairs down are wet, though you saw no spill."
|
| 266 |
+
],
|
| 267 |
+
"missing": [
|
| 268 |
+
"One of the steps in the flight below is missing.",
|
| 269 |
+
"There's a gap in the stairs where a step should be.",
|
| 270 |
+
"A tread is gone from the flight down."
|
| 271 |
+
],
|
| 272 |
+
"up": [
|
| 273 |
+
"The arrow stencilled on the steps points UP, not down.",
|
| 274 |
+
"The direction painted on the stairs has reversed.",
|
| 275 |
+
"The steps seem to lead up now, though you are going down."
|
| 276 |
+
],
|
| 277 |
+
"debris": [
|
| 278 |
+
"Something has been left on the steps below — a bag, maybe.",
|
| 279 |
+
"There's debris scattered across the next flight.",
|
| 280 |
+
"A dark shape sits partway down the flight."
|
| 281 |
+
]
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
|
| 285 |
+
"figure": {
|
| 286 |
+
"baseline": "below",
|
| 287 |
+
"values": {
|
| 288 |
+
"below": [
|
| 289 |
+
"Far below, someone else winds down the stairwell, always a flight or two ahead.",
|
| 290 |
+
"A figure descends the shaft ahead of you, keeping the same distance.",
|
| 291 |
+
"Someone is on the stairs below, going down at your pace, never closer."
|
| 292 |
+
]
|
| 293 |
+
},
|
| 294 |
+
"anomalies": {
|
| 295 |
+
"facing": [
|
| 296 |
+
"The figure below has stopped. It is looking up at you.",
|
| 297 |
+
"Down the shaft, the figure stands still, face turned up toward you.",
|
| 298 |
+
"The one on the stairs isn't descending now. It's watching you."
|
| 299 |
+
],
|
| 300 |
+
"closer": [
|
| 301 |
+
"The figure on the stairs is much closer than before, just a flight down.",
|
| 302 |
+
"The descending figure has closed the distance; it's nearly on your landing.",
|
| 303 |
+
"Whoever is below is only a few steps down now."
|
| 304 |
+
],
|
| 305 |
+
"gone": [
|
| 306 |
+
"The stairwell below is empty. Whoever was descending is gone.",
|
| 307 |
+
"There is no figure on the stairs this time.",
|
| 308 |
+
"The one ahead of you has vanished from the shaft."
|
| 309 |
+
],
|
| 310 |
+
"ascending": [
|
| 311 |
+
"The figure is climbing now — coming up the stairs toward you.",
|
| 312 |
+
"Footsteps rise: the figure below is ascending, not descending.",
|
| 313 |
+
"The one on the stairs has turned around and is coming up."
|
| 314 |
+
],
|
| 315 |
+
"knows": [
|
| 316 |
+
"The figure below looks up and lifts a hand, as if it has been waiting for you.",
|
| 317 |
+
"The figure mouths something up the shaft. It might be 'again.'",
|
| 318 |
+
"It beckons you down, slow and certain, as though it knows you."
|
| 319 |
+
]
|
| 320 |
+
}
|
| 321 |
+
},
|
| 322 |
+
|
| 323 |
+
"sense": {
|
| 324 |
+
"baseline": "steady",
|
| 325 |
+
"values": {
|
| 326 |
+
"steady": [
|
| 327 |
+
"The shaft feels exactly as deep as it always has.",
|
| 328 |
+
"You carry the same wariness down flight after flight.",
|
| 329 |
+
"Everything feels ordinary, as far as any of this is ordinary."
|
| 330 |
+
]
|
| 331 |
+
},
|
| 332 |
+
"anomalies": {
|
| 333 |
+
"deeper": [
|
| 334 |
+
"The shaft feels deeper than it should, the ground floor further off.",
|
| 335 |
+
"You feel you've descended far more than eight floors.",
|
| 336 |
+
"The bottom seems to have moved further away."
|
| 337 |
+
],
|
| 338 |
+
"forget": [
|
| 339 |
+
"For a moment you can't remember how many landings you've passed.",
|
| 340 |
+
"You lose count of the flights above you, and it frightens you.",
|
| 341 |
+
"You can't recall which floor you started on."
|
| 342 |
+
],
|
| 343 |
+
"up": [
|
| 344 |
+
"For an instant you're certain you've been climbing, not descending.",
|
| 345 |
+
"A wrong feeling grips you: that down has been up all along.",
|
| 346 |
+
"You feel, briefly and coldly, that you are going the wrong way."
|
| 347 |
+
]
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
},
|
| 351 |
+
|
| 352 |
+
"adaptive": {
|
| 353 |
+
"repeat_inspect": [
|
| 354 |
+
"You check the {THING} again. You aren't sure why.",
|
| 355 |
+
"Your eyes go straight to the {THING}, the way they always seem to now.",
|
| 356 |
+
"The {THING} draws your attention once more."
|
| 357 |
+
],
|
| 358 |
+
"long_look": [
|
| 359 |
+
"You spend longer looking than usual. You don't remember deciding to stop.",
|
| 360 |
+
"You realise you've been standing on the landing. For how long?",
|
| 361 |
+
"Time slips. You were staring down the shaft, and you don't know for how long."
|
| 362 |
+
]
|
| 363 |
+
},
|
| 364 |
+
|
| 365 |
+
"framing": {
|
| 366 |
+
"intro": [
|
| 367 |
+
"OUT OF ORDER",
|
| 368 |
+
"",
|
| 369 |
+
"You press the call button. Nothing. A notice is taped across the doors:",
|
| 370 |
+
"ELEVATOR OUT OF ORDER — USE STAIRS.",
|
| 371 |
+
"",
|
| 372 |
+
"Eight floors up, and only the stairwell down. Each landing looks the same.",
|
| 373 |
+
"",
|
| 374 |
+
"If a landing differs from the one in your memory, GO BACK UP.",
|
| 375 |
+
"If nothing has changed, keep DESCENDING.",
|
| 376 |
+
"",
|
| 377 |
+
"Reach the ground floor and step out.",
|
| 378 |
+
"Lose track of the landings, and you're back at the top."
|
| 379 |
+
],
|
| 380 |
+
"win": [
|
| 381 |
+
"The last flight opens onto the lobby.",
|
| 382 |
+
"Daylight through the doors. The street. The elevator behind you, still dead.",
|
| 383 |
+
"Eight floors down, and your memory held every landing.",
|
| 384 |
+
"Ground floor."
|
| 385 |
+
]
|
| 386 |
+
}
|
| 387 |
+
}
|
docs/ARCS.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Arc roadmap
|
| 2 |
+
|
| 3 |
+
This is the living plan for the game's arcs (skins/backstories). It records the
|
| 4 |
+
design rules every arc must follow, what's already built, and the queue of
|
| 5 |
+
future arcs so we can pick up any of them later without re-deriving the idea.
|
| 6 |
+
|
| 7 |
+
Each arc is one self-contained `data/arcs/<id>.json` file plus a
|
| 8 |
+
`body[data-skin="<name>"]` block in `static/style.css`. See the README section
|
| 9 |
+
*Adding an arc* for the file schema. New arcs appear on the opening screen
|
| 10 |
+
automatically.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Design rules (apply to every arc)
|
| 15 |
+
|
| 16 |
+
The mechanic is fixed: move through a place that should be identical each loop;
|
| 17 |
+
**turn back** if something changed, **go on** if nothing did; eight correct
|
| 18 |
+
calls in a row wins, one wrong call resets. What differs per arc is the story,
|
| 19 |
+
vocabulary, skin — and the *flavour* of the anomalies. Every arc must include:
|
| 20 |
+
|
| 21 |
+
1. **A strong motivation ("the hook").** A concrete in-world reason you're stuck
|
| 22 |
+
doing this, revealed in the intro — the equivalent of Stairway 8's
|
| 23 |
+
out-of-order elevator. Never "you are being tested" with no diegetic cause.
|
| 24 |
+
2. **An NPC.** A recurring character that is normally passive but can become the
|
| 25 |
+
anomaly — turning, watching, approaching, vanishing, duplicating. Every arc
|
| 26 |
+
needs its own recurring uncanny figure.
|
| 27 |
+
3. **A loop-aware NPC twist.** At least one NPC anomaly that breaks past physical
|
| 28 |
+
change into the uncanny: the character *acknowledges you* or the loop itself
|
| 29 |
+
(waves, beckons, mouths/says "again"). Property key convention: `knows` for
|
| 30 |
+
silent figures, `speaks` for ones that can talk.
|
| 31 |
+
4. **A psychological layer (`sense`).** A non-physical property whose baseline is
|
| 32 |
+
your ordinary state and whose anomalies are perceptual/emotional wrongness —
|
| 33 |
+
losing count, a calm that shouldn't be there, a conviction you're going the
|
| 34 |
+
wrong way. Anomalies beyond objects, per the "twists" requirement.
|
| 35 |
+
5. **Meta drift (free).** The engine already drifts the heading, wording, pacing,
|
| 36 |
+
weight, spacing and background every loop — no per-arc work needed, but keep
|
| 37 |
+
`title_variants` themed.
|
| 38 |
+
|
| 39 |
+
Keep anomalies *plausible*, never loud. One property mutates per anomalous loop,
|
| 40 |
+
and it is always among the details shown that loop (the engine guarantees this).
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Built
|
| 45 |
+
|
| 46 |
+
| Arc | Hook | Loop unit | NPC (+ twist) | `sense` anomalies | Skin |
|
| 47 |
+
|-----|------|-----------|---------------|-------------------|------|
|
| 48 |
+
| **Hallway 8** (`hallway-eight`) | Locked in after hours; a sign points to the fire exit | corridor | man in grey suit (`knows`: turns/beckons/"again") | longer, forget, calm | cold teal |
|
| 49 |
+
| **Stairway 8** (`stairway8`) | Elevator out of order; descend on foot | landing | figure on the stairs below (`knows`: looks up, waves, ascends) | deeper, forget, wrong-way | emergency green |
|
| 50 |
+
| **Coach 8** (`coach8`) | Conductor sent you to coach 8; the train never reaches it | carriage | sleeping passenger (`speaks`: murmurs "again", "you were just here") | forget, calm, watched | cabin amber |
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Planned (not yet built)
|
| 55 |
+
|
| 56 |
+
Each entry is enough to implement directly. Ordered by how strong/fresh the loop
|
| 57 |
+
feels; pick any.
|
| 58 |
+
|
| 59 |
+
### Platform 8 (`platform8`) — the underpass
|
| 60 |
+
- **Hook:** last train of the night; the turnstiles are shut and a sign routes
|
| 61 |
+
you through the pedestrian underpass — FOLLOW SIGNS TO PLATFORM 8.
|
| 62 |
+
- **Loop unit:** identical tiled underpass segments. **Go on** = walk on /
|
| 63 |
+
**Turn back** = double back.
|
| 64 |
+
- **NPC:** a busker sitting against the wall with an open case. Twist (`knows`):
|
| 65 |
+
stops playing to watch you; case is suddenly empty/overfull; plays the same
|
| 66 |
+
bar as you pass "again."
|
| 67 |
+
- **Physical anomalies:** tiled advert, the drip-puddle, map's "YOU ARE HERE"
|
| 68 |
+
dot, poster date, platform number on the sign, tile colour, a pigeon.
|
| 69 |
+
- **`sense`:** the tunnel feels longer; you forget which platform you wanted; a
|
| 70 |
+
certainty you already caught the train.
|
| 71 |
+
- **Skin:** cold blue-white station tile (`--hue: 205`).
|
| 72 |
+
|
| 73 |
+
### Aisle 8 (`aisle8`) — locked in the supermarket
|
| 74 |
+
- **Hook:** you looked up and the shop had closed around you; the tannoy loops
|
| 75 |
+
"please proceed to checkout 8 to exit."
|
| 76 |
+
- **Loop unit:** identical grocery aisles. **Go on** = next aisle / **Turn
|
| 77 |
+
back** = previous aisle.
|
| 78 |
+
- **NPC:** a night-shift stocker with a trolley, back to you. Twist (`knows`):
|
| 79 |
+
turns and stares; trolley abandoned mid-aisle; softly says "again" over the
|
| 80 |
+
muzak.
|
| 81 |
+
- **Physical anomalies:** a shelf gap, a product facing the wrong way, a price
|
| 82 |
+
label, the end-cap promo, the muzak track, the abandoned trolley, floor sheen.
|
| 83 |
+
- **`sense`:** the aisle feels longer; you forget your list; a calm certainty
|
| 84 |
+
you've already paid.
|
| 85 |
+
- **Skin:** flat retail fluorescent, a touch too bright (`--hue: 60`, high L).
|
| 86 |
+
|
| 87 |
+
### Ward 8 (`ward8`) — the hospital at night
|
| 88 |
+
- **Hook:** visiting hours are over; the night sign says the way out is via Ward
|
| 89 |
+
8. *(Handle tone with care — dread, not gore.)*
|
| 90 |
+
- **Loop unit:** identical hospital corridors. **Go on** / **Turn back**.
|
| 91 |
+
- **NPC:** a patient in a gown standing in a doorway, or a nurse at the far
|
| 92 |
+
station. Twist (`knows`): the figure has moved to the next doorway; is facing
|
| 93 |
+
you; mouths "again."
|
| 94 |
+
- **Physical anomalies:** a gurney's position, the room-number plate, a
|
| 95 |
+
flickering "NIL BY MOUTH" sign, the hand-gel dispenser, a chart on a door,
|
| 96 |
+
distant monitor beeps, a wheelchair.
|
| 97 |
+
- **`sense`:** the corridor feels longer; you forget why you came; a wrong calm.
|
| 98 |
+
- **Skin:** pale clinical green-grey (`--hue: 160`, low sat).
|
| 99 |
+
|
| 100 |
+
### Level 8 (`level8`) — the underground car park
|
| 101 |
+
- **Hook:** your ticket says Level 8; the lift to the street is dead, so follow
|
| 102 |
+
the ramp up. (Ascending mirror of Stairway 8.)
|
| 103 |
+
- **Loop unit:** identical parking rows. **Go on** = up a ramp / **Turn back** =
|
| 104 |
+
back down.
|
| 105 |
+
- **NPC:** a figure between the parked cars, or someone sitting in a driver's
|
| 106 |
+
seat. Twist (`knows`): the seated figure's headlights flick on as you pass; it
|
| 107 |
+
has moved cars; it waves.
|
| 108 |
+
- **Physical anomalies:** a parked car's colour, the level number on the pillar,
|
| 109 |
+
a flickering strip light, the arrow painted on the floor, an oil stain, a
|
| 110 |
+
distant engine, a car alarm.
|
| 111 |
+
- **`sense`:** the level feels deeper; you forget where you parked; certainty
|
| 112 |
+
you're descending, not climbing.
|
| 113 |
+
- **Skin:** concrete grey with sodium-orange pools (`--hue: 30`, low L).
|
| 114 |
+
|
| 115 |
+
### Gate 8 (`gate8`) — the airport after the last flight
|
| 116 |
+
- **Hook:** you dozed off; the terminal's near-empty and the board shows one
|
| 117 |
+
route left — EXIT VIA GATE 8.
|
| 118 |
+
- **Loop unit:** identical concourse stretches / travelators. **Go on** /
|
| 119 |
+
**Turn back**.
|
| 120 |
+
- **NPC:** a lone cleaner with a floor-buffer, or a single seated passenger.
|
| 121 |
+
Twist (`knows`): stops buffing to watch; the seated passenger is gone/closer;
|
| 122 |
+
a gate announcement says your name.
|
| 123 |
+
- **Physical anomalies:** the gate number, a departures-board row, the duty-free
|
| 124 |
+
window display, an abandoned bag (unsettling), the announcement chime, a clock,
|
| 125 |
+
a potted plant.
|
| 126 |
+
- **`sense`:** the concourse feels longer; you forget your destination; a calm
|
| 127 |
+
that you've already boarded.
|
| 128 |
+
- **Skin:** glassy blue night-terminal (`--hue: 215`).
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## Backlog ideas (thin — flesh out before building)
|
| 133 |
+
|
| 134 |
+
- **Museum after close** — galleries loop; NPC is a security guard or a statue
|
| 135 |
+
that isn't always in the same pose.
|
| 136 |
+
- **Motel corridor** — reach room 8 / the exit; NPC is someone at an ice machine.
|
| 137 |
+
- **Ferry decks** — climb decks to the exit; NPC a lone smoker at the rail;
|
| 138 |
+
motion + sea outside the windows.
|
hallway.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The corridor: building a loop and describing it.
|
| 2 |
+
|
| 3 |
+
A "room" is not a paragraph. It is a set of properties, each currently at its
|
| 4 |
+
baseline value or (at most one of them) at an anomalous value. The describer
|
| 5 |
+
turns that into a handful of sentences drawn from rotating pools, so the same
|
| 6 |
+
place reads differently every time while still being the same place.
|
| 7 |
+
|
| 8 |
+
Content is organised into *arcs* (skins/backstories) under data/arcs/. Each arc
|
| 9 |
+
file shares the same schema; only the theme, story, and vocabulary change.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import glob
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import random
|
| 18 |
+
from typing import Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
import anomalies
|
| 21 |
+
from memory import PlayerMemory
|
| 22 |
+
|
| 23 |
+
ARCS_DIR = os.path.join(os.path.dirname(__file__), "data", "arcs")
|
| 24 |
+
|
| 25 |
+
DEFAULT_ARC = "hallway-eight"
|
| 26 |
+
|
| 27 |
+
# How many hallways/landings you must clear. Reaching this level wins.
|
| 28 |
+
GOAL = 8
|
| 29 |
+
|
| 30 |
+
# How many details are described each loop. Enough to overload memory a little,
|
| 31 |
+
# few enough to keep it readable.
|
| 32 |
+
MIN_DETAILS = 4
|
| 33 |
+
MAX_DETAILS = 6
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _arc_path(arc_id: str) -> str:
|
| 37 |
+
return os.path.join(ARCS_DIR, f"{arc_id}.json")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def list_arcs() -> List[dict]:
|
| 41 |
+
"""Return the selectable arcs, described by their meta block.
|
| 42 |
+
|
| 43 |
+
The default arc is listed first; the rest follow alphabetically.
|
| 44 |
+
"""
|
| 45 |
+
arcs = []
|
| 46 |
+
for path in sorted(glob.glob(os.path.join(ARCS_DIR, "*.json"))):
|
| 47 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 48 |
+
meta = json.load(f).get("meta", {})
|
| 49 |
+
if meta.get("id"):
|
| 50 |
+
arcs.append({
|
| 51 |
+
"id": meta["id"],
|
| 52 |
+
"title": meta.get("title", meta["id"]),
|
| 53 |
+
"tagline": meta.get("tagline", ""),
|
| 54 |
+
"skin": meta.get("skin", "hallway"),
|
| 55 |
+
})
|
| 56 |
+
arcs.sort(key=lambda a: (a["id"] != DEFAULT_ARC, a["id"]))
|
| 57 |
+
return arcs
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Hallway:
|
| 61 |
+
def __init__(self, arc_id: str = DEFAULT_ARC):
|
| 62 |
+
path = _arc_path(arc_id)
|
| 63 |
+
if not os.path.exists(path):
|
| 64 |
+
path = _arc_path(DEFAULT_ARC)
|
| 65 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 66 |
+
self.data = json.load(f)
|
| 67 |
+
self.properties: Dict[str, dict] = self.data["properties"]
|
| 68 |
+
self.meta: dict = self.data.get("meta", {})
|
| 69 |
+
self.labels: Dict[str, str] = self.meta.get("labels", {})
|
| 70 |
+
|
| 71 |
+
def build(self, mem: PlayerMemory, rng: random.Random) -> dict:
|
| 72 |
+
"""Generate the next place for the player's current level.
|
| 73 |
+
|
| 74 |
+
The probability of an anomaly stays near a coin-flip so neither
|
| 75 |
+
"always continue" nor "always turn back" is a winning strategy.
|
| 76 |
+
"""
|
| 77 |
+
has_anomaly = rng.random() < 0.5
|
| 78 |
+
anomaly_prop: Optional[str] = None
|
| 79 |
+
anomaly_val: Optional[str] = None
|
| 80 |
+
if has_anomaly:
|
| 81 |
+
anomaly_prop, anomaly_val = anomalies.pick_anomaly(self.properties, rng)
|
| 82 |
+
|
| 83 |
+
# Decide which properties get described this loop.
|
| 84 |
+
order = list(self.data["anchor_order"])
|
| 85 |
+
rng.shuffle(order)
|
| 86 |
+
n = rng.randint(MIN_DETAILS, MAX_DETAILS)
|
| 87 |
+
shown = order[:n]
|
| 88 |
+
# If there is an anomaly, it must be visible this loop -- otherwise
|
| 89 |
+
# the player is asked to notice something they were never shown.
|
| 90 |
+
if anomaly_prop and anomaly_prop not in shown:
|
| 91 |
+
shown[rng.randrange(len(shown))] = anomaly_prop
|
| 92 |
+
# Re-sort shown into the canonical order so the "camera" of the prose
|
| 93 |
+
# feels consistent even as content rotates.
|
| 94 |
+
shown = [p for p in self.data["anchor_order"] if p in shown]
|
| 95 |
+
|
| 96 |
+
sign_text = anomalies.drift_sign(self.data["sign_variants"], rng)
|
| 97 |
+
|
| 98 |
+
sentences: List[str] = []
|
| 99 |
+
for prop in shown:
|
| 100 |
+
spec = self.properties[prop]
|
| 101 |
+
if prop == anomaly_prop:
|
| 102 |
+
pool = spec["anomalies"][anomaly_val]
|
| 103 |
+
else:
|
| 104 |
+
baseline = spec["baseline"]
|
| 105 |
+
pool = spec["values"][baseline]
|
| 106 |
+
line = anomalies.render_sentence(rng.choice(pool), sign_text)
|
| 107 |
+
sentences.append(line)
|
| 108 |
+
|
| 109 |
+
# Adaptive touch: if the player keeps inspecting one thing, and it is
|
| 110 |
+
# on show this loop, let the place notice.
|
| 111 |
+
fav = mem.favorite
|
| 112 |
+
adaptive_line = None
|
| 113 |
+
if fav and fav in shown and rng.random() < 0.5:
|
| 114 |
+
tmpl = rng.choice(self.data["adaptive"]["repeat_inspect"])
|
| 115 |
+
adaptive_line = tmpl.replace("{THING}", self.labels.get(fav, fav))
|
| 116 |
+
|
| 117 |
+
heading = anomalies.drift_heading(
|
| 118 |
+
self.data["title_variants"], mem.loops, rng
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"heading": heading,
|
| 123 |
+
"sentences": sentences,
|
| 124 |
+
"adaptive": adaptive_line,
|
| 125 |
+
"sign_text": sign_text,
|
| 126 |
+
"shown": shown,
|
| 127 |
+
# server-authoritative truth, never sent to the client:
|
| 128 |
+
"_has_anomaly": has_anomaly,
|
| 129 |
+
"_anomaly_prop": anomaly_prop,
|
| 130 |
+
"_anomaly_val": anomaly_val,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def public(self, room: dict) -> dict:
|
| 134 |
+
"""Strip the answer before sending a room to the browser."""
|
| 135 |
+
return {k: v for k, v in room.items() if not k.startswith("_")}
|
| 136 |
+
|
| 137 |
+
def intro(self) -> List[str]:
|
| 138 |
+
return self.data["framing"]["intro"]
|
| 139 |
+
|
| 140 |
+
def win(self) -> List[str]:
|
| 141 |
+
return self.data["framing"]["win"]
|
memory.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tracks how the player behaves across loops.
|
| 2 |
+
|
| 3 |
+
The point of this module is not scoring. It is to give the game a small
|
| 4 |
+
model of the player's habits so the narration can quietly react to them:
|
| 5 |
+
the corridor that "remembers you."
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field, asdict
|
| 11 |
+
from typing import Dict, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class PlayerMemory:
|
| 16 |
+
# progress
|
| 17 |
+
level: int = 0 # which hallway you are standing in (0 = the start)
|
| 18 |
+
best_level: int = 0 # furthest you have ever reached
|
| 19 |
+
loops: int = 0 # total corridors walked this run
|
| 20 |
+
|
| 21 |
+
# behaviour
|
| 22 |
+
turn_backs: int = 0
|
| 23 |
+
continues: int = 0
|
| 24 |
+
false_reports: int = 0 # turned back when nothing was wrong
|
| 25 |
+
missed: int = 0 # walked on past a real change
|
| 26 |
+
inspects: Dict[str, int] = field(default_factory=dict)
|
| 27 |
+
|
| 28 |
+
# confidence
|
| 29 |
+
confidence_sum: float = 0.0
|
| 30 |
+
confidence_n: int = 0
|
| 31 |
+
|
| 32 |
+
def record_inspect(self, thing: str) -> None:
|
| 33 |
+
self.inspects[thing] = self.inspects.get(thing, 0) + 1
|
| 34 |
+
|
| 35 |
+
def record_confidence(self, value: Optional[int]) -> None:
|
| 36 |
+
if value:
|
| 37 |
+
self.confidence_sum += value
|
| 38 |
+
self.confidence_n += 1
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def confidence(self) -> float:
|
| 42 |
+
if not self.confidence_n:
|
| 43 |
+
return 0.0
|
| 44 |
+
return round(self.confidence_sum / self.confidence_n, 2)
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def favorite(self) -> Optional[str]:
|
| 48 |
+
"""The property the player fixates on, once a habit has formed."""
|
| 49 |
+
if not self.inspects:
|
| 50 |
+
return None
|
| 51 |
+
thing, count = max(self.inspects.items(), key=lambda kv: kv[1])
|
| 52 |
+
return thing if count >= 3 else None
|
| 53 |
+
|
| 54 |
+
def to_dict(self) -> dict:
|
| 55 |
+
return asdict(self)
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
def from_dict(cls, data: dict) -> "PlayerMemory":
|
| 59 |
+
if not data:
|
| 60 |
+
return cls()
|
| 61 |
+
known = {f: data[f] for f in cls.__dataclass_fields__ if f in data}
|
| 62 |
+
return cls(**known)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask>=3.0
|
| 2 |
+
gunicorn>=21.0
|
scripts/deploy_hf.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Deploy this repo to a Hugging Face Docker Space.
|
| 3 |
+
|
| 4 |
+
Run from a machine with normal internet access (not the sandboxed web session,
|
| 5 |
+
whose network policy blocks huggingface.co):
|
| 6 |
+
|
| 7 |
+
pip install "huggingface_hub>=0.24"
|
| 8 |
+
export HF_TOKEN=hf_... # a *write* token from hf.co/settings/tokens
|
| 9 |
+
python scripts/deploy_hf.py # defaults to alvations/hallway8
|
| 10 |
+
|
| 11 |
+
Optionally pass a different target: python scripts/deploy_hf.py owner/space-name
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import secrets
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from huggingface_hub import HfApi, get_token
|
| 19 |
+
|
| 20 |
+
REPO_ID = sys.argv[1] if len(sys.argv) > 1 else "alvations/hallway8"
|
| 21 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
# Prefer an explicit HF_TOKEN, but fall back to a stored `hf auth login`
|
| 26 |
+
# credential so this works without exporting anything.
|
| 27 |
+
token = os.environ.get("HF_TOKEN") or get_token()
|
| 28 |
+
if not token:
|
| 29 |
+
sys.exit(
|
| 30 |
+
"No token found. Run `hf auth login` or set HF_TOKEN to a write token."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
api = HfApi(token=token)
|
| 34 |
+
who = api.whoami()
|
| 35 |
+
print(f"Authenticated as {who['name']}")
|
| 36 |
+
|
| 37 |
+
url = api.create_repo(
|
| 38 |
+
repo_id=REPO_ID, repo_type="space", space_sdk="docker", exist_ok=True
|
| 39 |
+
)
|
| 40 |
+
print(f"Space ready: {url}")
|
| 41 |
+
|
| 42 |
+
# Stable session key so in-progress runs survive Space restarts.
|
| 43 |
+
try:
|
| 44 |
+
api.add_space_secret(REPO_ID, "SECRET_KEY", secrets.token_hex(32))
|
| 45 |
+
print("SECRET_KEY secret set")
|
| 46 |
+
except Exception as exc: # non-fatal
|
| 47 |
+
print(f"Could not set SECRET_KEY (set it manually if you want): {exc}")
|
| 48 |
+
|
| 49 |
+
api.upload_folder(
|
| 50 |
+
folder_path=ROOT,
|
| 51 |
+
repo_id=REPO_ID,
|
| 52 |
+
repo_type="space",
|
| 53 |
+
commit_message="Deploy Hallway 8 (multi-arc memory game)",
|
| 54 |
+
ignore_patterns=[
|
| 55 |
+
".git*", "**/__pycache__/**", "*.pyc", ".venv/**", "venv/**", ".env",
|
| 56 |
+
],
|
| 57 |
+
)
|
| 58 |
+
print(f"\nDone → https://huggingface.co/spaces/{REPO_ID}")
|
| 59 |
+
print("The Space will build the Docker image and go live in a couple of minutes.")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
main()
|
static/game.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Eight -- client.
|
| 2 |
+
*
|
| 3 |
+
* The server owns the truth (whether a place changed) and ships several arcs
|
| 4 |
+
* (skins/backstories). This file lets the player pick an arc, applies its skin,
|
| 5 |
+
* then renders, paces, and quietly drifts the presentation so no two loops can
|
| 6 |
+
* be compared pixel-for-pixel. The unease is in the timing and typography as
|
| 7 |
+
* much as in the words.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
const $ = (sel) => document.querySelector(sel);
|
| 11 |
+
|
| 12 |
+
const el = {
|
| 13 |
+
select: $("#select"),
|
| 14 |
+
arcs: $("#arcs"),
|
| 15 |
+
intro: $("#intro"),
|
| 16 |
+
introTitle: $("#intro-title"),
|
| 17 |
+
introText: $("#intro-text"),
|
| 18 |
+
begin: $("#begin"),
|
| 19 |
+
introBack: $("#intro-back"),
|
| 20 |
+
corridor: $("#corridor"),
|
| 21 |
+
progress: $("#progress"),
|
| 22 |
+
best: $("#best"),
|
| 23 |
+
heading: $("#heading"),
|
| 24 |
+
prose: $("#prose"),
|
| 25 |
+
aside: $("#aside"),
|
| 26 |
+
controls: $("#controls"),
|
| 27 |
+
confidence: $("#confidence"),
|
| 28 |
+
win: $("#win"),
|
| 29 |
+
winText: $("#win-text"),
|
| 30 |
+
again: $("#again"),
|
| 31 |
+
change: $("#change"),
|
| 32 |
+
body: document.body,
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
let current = null; // latest room payload
|
| 36 |
+
let meta = null; // current arc's presentation meta
|
| 37 |
+
let selectedArc = null; // arc id in play
|
| 38 |
+
let pendingChoice = null; // choice awaiting a confidence rating
|
| 39 |
+
let drift = 0; // slowly accumulating presentation drift
|
| 40 |
+
|
| 41 |
+
// --- helpers ---------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
async function get(url) {
|
| 44 |
+
const res = await fetch(url);
|
| 45 |
+
return res.json();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function post(url, body) {
|
| 49 |
+
const res = await fetch(url, {
|
| 50 |
+
method: "POST",
|
| 51 |
+
headers: { "Content-Type": "application/json" },
|
| 52 |
+
body: JSON.stringify(body || {}),
|
| 53 |
+
});
|
| 54 |
+
return res.json();
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
| 58 |
+
|
| 59 |
+
// A tiny jitter so pacing feels alive but not like noise.
|
| 60 |
+
const jitter = (base, spread) => base + Math.floor(Math.random() * spread);
|
| 61 |
+
|
| 62 |
+
function show(section) {
|
| 63 |
+
[el.select, el.intro, el.corridor, el.win].forEach((s) =>
|
| 64 |
+
s.classList.add("hidden")
|
| 65 |
+
);
|
| 66 |
+
section.classList.remove("hidden");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Nudge the presentation a fraction every loop. Individually invisible;
|
| 70 |
+
// cumulatively, "something feels off."
|
| 71 |
+
function applyDrift() {
|
| 72 |
+
drift += 1;
|
| 73 |
+
el.body.style.setProperty("--bg-shift", Math.min(drift * 0.25, 6).toFixed(2));
|
| 74 |
+
const weight = 340 + Math.round(Math.sin(drift / 3) * 30);
|
| 75 |
+
document.documentElement.style.setProperty("--weight", String(weight));
|
| 76 |
+
const track = (0.01 + Math.sin(drift / 5) * 0.012).toFixed(4);
|
| 77 |
+
document.documentElement.style.setProperty("--tracking", `${track}em`);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Apply an arc's skin, title, and button labels.
|
| 81 |
+
function applyMeta(m) {
|
| 82 |
+
meta = m;
|
| 83 |
+
el.body.dataset.skin = m.skin || "hallway";
|
| 84 |
+
document.title = m.title || "8";
|
| 85 |
+
el.introTitle.textContent = m.title || "8";
|
| 86 |
+
el.controls.querySelector('[data-choice="continue"]').textContent =
|
| 87 |
+
m.go_on || "Go on";
|
| 88 |
+
el.controls.querySelector('[data-choice="back"]').textContent =
|
| 89 |
+
m.turn_back || "Turn back";
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// --- rendering -------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
function setHud(p) {
|
| 95 |
+
const floor = p.goal - p.level;
|
| 96 |
+
const remaining = p.goal - p.level;
|
| 97 |
+
const tmpl = (meta && meta.progress) || "{level} / {goal}";
|
| 98 |
+
const label = tmpl
|
| 99 |
+
.replace("{level}", p.level)
|
| 100 |
+
.replace("{goal}", p.goal)
|
| 101 |
+
.replace("{floor}", floor)
|
| 102 |
+
.replace("{remaining}", remaining);
|
| 103 |
+
const dots = Array.from({ length: p.goal }, (_, i) =>
|
| 104 |
+
i < p.level ? "•" : "·"
|
| 105 |
+
).join(" ");
|
| 106 |
+
el.progress.textContent = `${label} ${dots}`;
|
| 107 |
+
el.best.textContent = p.best ? `furthest ${p.best}` : "";
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function renderRoom(p) {
|
| 111 |
+
current = p;
|
| 112 |
+
if (p.meta) applyMeta(p.meta);
|
| 113 |
+
setHud(p);
|
| 114 |
+
applyDrift();
|
| 115 |
+
|
| 116 |
+
el.heading.textContent = p.room.heading;
|
| 117 |
+
el.prose.innerHTML = "";
|
| 118 |
+
el.aside.textContent = "";
|
| 119 |
+
el.aside.classList.remove("show");
|
| 120 |
+
el.controls.classList.add("hidden");
|
| 121 |
+
el.confidence.classList.add("hidden");
|
| 122 |
+
pendingChoice = null;
|
| 123 |
+
|
| 124 |
+
const lines = p.room.sentences;
|
| 125 |
+
const shown = p.room.shown || [];
|
| 126 |
+
|
| 127 |
+
lines.forEach((text, i) => {
|
| 128 |
+
const div = document.createElement("div");
|
| 129 |
+
div.className = "line";
|
| 130 |
+
div.textContent = text;
|
| 131 |
+
if (shown[i]) {
|
| 132 |
+
div.dataset.prop = shown[i];
|
| 133 |
+
attachInspect(div);
|
| 134 |
+
}
|
| 135 |
+
el.prose.appendChild(div);
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
// Reveal one line at a time with drifting pauses. One line, occasionally,
|
| 139 |
+
// hangs noticeably longer -- a pause the player reads as significance.
|
| 140 |
+
const nodes = [...el.prose.children];
|
| 141 |
+
for (let i = 0; i < nodes.length; i++) {
|
| 142 |
+
const long = Math.random() < 0.18;
|
| 143 |
+
await sleep(long ? jitter(1100, 500) : jitter(380, 340));
|
| 144 |
+
nodes[i].classList.add("show");
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (p.room.adaptive) {
|
| 148 |
+
await sleep(600);
|
| 149 |
+
el.aside.textContent = p.room.adaptive;
|
| 150 |
+
el.aside.classList.add("show");
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
await sleep(400);
|
| 154 |
+
el.controls.classList.remove("hidden");
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Lingering on a detail may earn an unsettling aside from the server.
|
| 158 |
+
function attachInspect(node) {
|
| 159 |
+
let timer = null;
|
| 160 |
+
node.addEventListener("mouseenter", () => {
|
| 161 |
+
timer = setTimeout(async () => {
|
| 162 |
+
const { line } = await post("/api/inspect", { prop: node.dataset.prop });
|
| 163 |
+
if (line && !el.aside.textContent) {
|
| 164 |
+
el.aside.textContent = line;
|
| 165 |
+
el.aside.classList.add("show");
|
| 166 |
+
}
|
| 167 |
+
}, 1300);
|
| 168 |
+
});
|
| 169 |
+
node.addEventListener("mouseleave", () => clearTimeout(timer));
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function flash(correct) {
|
| 173 |
+
const cls = correct ? "flash-good" : "flash-bad";
|
| 174 |
+
el.corridor.classList.add(cls);
|
| 175 |
+
setTimeout(() => el.corridor.classList.remove(cls), 1400);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// --- flow ------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
function renderArcCards(arcs) {
|
| 181 |
+
el.arcs.innerHTML = "";
|
| 182 |
+
arcs.forEach((a) => {
|
| 183 |
+
const card = document.createElement("button");
|
| 184 |
+
card.className = "arc-card";
|
| 185 |
+
card.dataset.arc = a.id;
|
| 186 |
+
card.dataset.skin = a.skin;
|
| 187 |
+
card.innerHTML =
|
| 188 |
+
`<div class="arc-title">${a.title}</div>` +
|
| 189 |
+
`<div class="arc-tag">${a.tagline}</div>`;
|
| 190 |
+
card.addEventListener("click", () => chooseArc(a));
|
| 191 |
+
el.arcs.appendChild(card);
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function renderIntro(lines) {
|
| 196 |
+
el.introText.innerHTML = "";
|
| 197 |
+
lines.forEach((ln, i) => {
|
| 198 |
+
const div = document.createElement("div");
|
| 199 |
+
div.textContent = ln;
|
| 200 |
+
if (i === 0) div.className = "lead";
|
| 201 |
+
el.introText.appendChild(div);
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
async function chooseArc(a) {
|
| 206 |
+
selectedArc = a.id;
|
| 207 |
+
applyMeta(a); // skin the intro screen immediately
|
| 208 |
+
const p = await post("/api/new", { arc: a.id });
|
| 209 |
+
applyMeta(p.meta);
|
| 210 |
+
renderIntro(p.intro);
|
| 211 |
+
current = p;
|
| 212 |
+
drift = 0;
|
| 213 |
+
show(el.intro);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function enterCorridor() {
|
| 217 |
+
show(el.corridor);
|
| 218 |
+
renderRoom(current);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function showWin(p) {
|
| 222 |
+
el.winText.innerHTML = "";
|
| 223 |
+
(p.win_text || []).forEach((ln, i) => {
|
| 224 |
+
const div = document.createElement("div");
|
| 225 |
+
div.textContent = ln;
|
| 226 |
+
if (i === p.win_text.length - 1) div.className = "lead";
|
| 227 |
+
el.winText.appendChild(div);
|
| 228 |
+
});
|
| 229 |
+
show(el.win);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
async function loadSelect() {
|
| 233 |
+
const { arcs } = await get("/api/arcs");
|
| 234 |
+
renderArcCards(arcs);
|
| 235 |
+
el.body.dataset.skin = "hallway";
|
| 236 |
+
show(el.select);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// choice -> ask for confidence
|
| 240 |
+
el.controls.addEventListener("click", (e) => {
|
| 241 |
+
const btn = e.target.closest(".choice");
|
| 242 |
+
if (!btn) return;
|
| 243 |
+
pendingChoice = btn.dataset.choice;
|
| 244 |
+
el.controls.classList.add("hidden");
|
| 245 |
+
el.confidence.classList.remove("hidden");
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
// confidence -> commit
|
| 249 |
+
el.confidence.addEventListener("click", async (e) => {
|
| 250 |
+
const btn = e.target.closest("[data-conf]");
|
| 251 |
+
if (!btn || !pendingChoice) return;
|
| 252 |
+
const confidence = parseInt(btn.dataset.conf, 10);
|
| 253 |
+
el.confidence.classList.add("hidden");
|
| 254 |
+
|
| 255 |
+
const p = await post("/api/act", { choice: pendingChoice, confidence });
|
| 256 |
+
flash(p.correct);
|
| 257 |
+
await sleep(1500);
|
| 258 |
+
|
| 259 |
+
if (p.won) {
|
| 260 |
+
showWin(p);
|
| 261 |
+
return;
|
| 262 |
+
}
|
| 263 |
+
renderRoom(p);
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
el.begin.addEventListener("click", enterCorridor);
|
| 267 |
+
el.introBack.addEventListener("click", loadSelect);
|
| 268 |
+
el.change.addEventListener("click", loadSelect);
|
| 269 |
+
el.again.addEventListener("click", async () => {
|
| 270 |
+
const a = ARCS_BY_ID[selectedArc];
|
| 271 |
+
if (a) await chooseArc(a);
|
| 272 |
+
enterCorridor();
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
// keep a lookup of arcs so "walk it again" can restart the same one
|
| 276 |
+
let ARCS_BY_ID = {};
|
| 277 |
+
(async function boot() {
|
| 278 |
+
const { arcs } = await get("/api/arcs");
|
| 279 |
+
ARCS_BY_ID = Object.fromEntries(arcs.map((a) => [a.id, a]));
|
| 280 |
+
renderArcCards(arcs);
|
| 281 |
+
show(el.select);
|
| 282 |
+
})();
|
static/style.css
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #0b0c0e;
|
| 3 |
+
--fg: #d7d7d2;
|
| 4 |
+
--dim: #6d7076;
|
| 5 |
+
--accent: #9fd0c0;
|
| 6 |
+
--danger: #c98b8b;
|
| 7 |
+
--hue: 210; /* base hue of the ambient background glow */
|
| 8 |
+
--panel-w: 40rem;
|
| 9 |
+
/* These are nudged by JS a fraction at a time -- a drift you feel
|
| 10 |
+
rather than see. */
|
| 11 |
+
--bg-shift: 0;
|
| 12 |
+
--weight: 340;
|
| 13 |
+
--tracking: 0.01em;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* --- skins ---------------------------------------------------------------
|
| 17 |
+
Each arc sets data-skin on <body>; these override the palette. */
|
| 18 |
+
body[data-skin="hallway"] {
|
| 19 |
+
--bg: #0b0c0e;
|
| 20 |
+
--fg: #d7d7d2;
|
| 21 |
+
--dim: #6d7076;
|
| 22 |
+
--accent: #9fd0c0; /* cold fluorescent teal */
|
| 23 |
+
--hue: 210;
|
| 24 |
+
}
|
| 25 |
+
body[data-skin="stairway"] {
|
| 26 |
+
--bg: #0a0d0b;
|
| 27 |
+
--fg: #d3d8d2;
|
| 28 |
+
--dim: #6a726b;
|
| 29 |
+
--accent: #8fc79a; /* emergency-exit green */
|
| 30 |
+
--hue: 150;
|
| 31 |
+
}
|
| 32 |
+
body[data-skin="coach"] {
|
| 33 |
+
--bg: #0c0a08;
|
| 34 |
+
--fg: #e0d7c8;
|
| 35 |
+
--dim: #7a7060;
|
| 36 |
+
--accent: #d8b06a; /* warm cabin amber */
|
| 37 |
+
--hue: 35;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
* { box-sizing: border-box; }
|
| 41 |
+
|
| 42 |
+
html, body {
|
| 43 |
+
margin: 0;
|
| 44 |
+
height: 100%;
|
| 45 |
+
background: var(--bg);
|
| 46 |
+
color: var(--fg);
|
| 47 |
+
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
| 48 |
+
-webkit-font-smoothing: antialiased;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
body {
|
| 52 |
+
display: flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
justify-content: center;
|
| 55 |
+
min-height: 100vh;
|
| 56 |
+
/* the 1%-at-a-time background shift lives here */
|
| 57 |
+
background:
|
| 58 |
+
radial-gradient(
|
| 59 |
+
120% 90% at 50% -10%,
|
| 60 |
+
hsl(var(--hue) 8% calc(9% + var(--bg-shift) * 1%)),
|
| 61 |
+
var(--bg) 70%
|
| 62 |
+
);
|
| 63 |
+
transition: background 2.4s ease;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
#screen {
|
| 67 |
+
width: var(--panel-w);
|
| 68 |
+
max-width: 92vw;
|
| 69 |
+
padding: 2rem;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.panel { animation: fade-in 1.1s ease both; }
|
| 73 |
+
.hidden { display: none !important; }
|
| 74 |
+
|
| 75 |
+
/* --- title --- */
|
| 76 |
+
.title {
|
| 77 |
+
font-size: 3rem;
|
| 78 |
+
font-weight: 300;
|
| 79 |
+
letter-spacing: 0.18em;
|
| 80 |
+
text-align: center;
|
| 81 |
+
margin: 0 0 1.5rem;
|
| 82 |
+
color: var(--fg);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.select-lead {
|
| 86 |
+
text-align: center;
|
| 87 |
+
color: var(--dim);
|
| 88 |
+
line-height: 1.9;
|
| 89 |
+
margin: 0 0 1rem;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.brief {
|
| 93 |
+
white-space: pre-wrap;
|
| 94 |
+
line-height: 1.9;
|
| 95 |
+
color: var(--dim);
|
| 96 |
+
font-size: 1.02rem;
|
| 97 |
+
min-height: 12rem;
|
| 98 |
+
margin-bottom: 2rem;
|
| 99 |
+
}
|
| 100 |
+
.brief .lead { color: var(--fg); letter-spacing: 0.14em; }
|
| 101 |
+
|
| 102 |
+
/* --- arc cards --- */
|
| 103 |
+
.arcs {
|
| 104 |
+
display: flex;
|
| 105 |
+
flex-direction: column;
|
| 106 |
+
gap: 1rem;
|
| 107 |
+
margin-top: 2rem;
|
| 108 |
+
}
|
| 109 |
+
.arc-card {
|
| 110 |
+
text-align: left;
|
| 111 |
+
background: transparent;
|
| 112 |
+
color: var(--fg);
|
| 113 |
+
border: 1px solid #2a2d31;
|
| 114 |
+
border-left: 3px solid #2a2d31;
|
| 115 |
+
padding: 1.1rem 1.3rem;
|
| 116 |
+
font-family: inherit;
|
| 117 |
+
cursor: pointer;
|
| 118 |
+
transition: border-color 0.3s ease, transform 0.15s ease;
|
| 119 |
+
}
|
| 120 |
+
.arc-card:hover { transform: translateX(3px); }
|
| 121 |
+
.arc-card[data-skin="hallway"] { border-left-color: #9fd0c0; }
|
| 122 |
+
.arc-card[data-skin="hallway"]:hover { border-color: #9fd0c0; }
|
| 123 |
+
.arc-card[data-skin="stairway"] { border-left-color: #8fc79a; }
|
| 124 |
+
.arc-card[data-skin="stairway"]:hover { border-color: #8fc79a; }
|
| 125 |
+
.arc-card[data-skin="coach"] { border-left-color: #d8b06a; }
|
| 126 |
+
.arc-card[data-skin="coach"]:hover { border-color: #d8b06a; }
|
| 127 |
+
.arc-title {
|
| 128 |
+
font-size: 1.3rem;
|
| 129 |
+
font-weight: 300;
|
| 130 |
+
letter-spacing: 0.12em;
|
| 131 |
+
}
|
| 132 |
+
.arc-tag {
|
| 133 |
+
color: var(--dim);
|
| 134 |
+
font-size: 0.95rem;
|
| 135 |
+
margin-top: 0.4rem;
|
| 136 |
+
line-height: 1.5;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* --- hud --- */
|
| 140 |
+
.hud {
|
| 141 |
+
display: flex;
|
| 142 |
+
justify-content: space-between;
|
| 143 |
+
font-size: 0.72rem;
|
| 144 |
+
letter-spacing: 0.24em;
|
| 145 |
+
text-transform: uppercase;
|
| 146 |
+
color: var(--dim);
|
| 147 |
+
margin-bottom: 2.5rem;
|
| 148 |
+
gap: 1rem;
|
| 149 |
+
}
|
| 150 |
+
.best { opacity: 0.6; white-space: nowrap; }
|
| 151 |
+
|
| 152 |
+
/* --- the loop --- */
|
| 153 |
+
.heading {
|
| 154 |
+
text-align: center;
|
| 155 |
+
font-weight: 300;
|
| 156 |
+
letter-spacing: 0.22em;
|
| 157 |
+
font-size: 1.5rem;
|
| 158 |
+
margin: 0 0 2.5rem;
|
| 159 |
+
color: var(--fg);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.prose {
|
| 163 |
+
line-height: 2.1;
|
| 164 |
+
font-size: 1.12rem;
|
| 165 |
+
font-weight: var(--weight);
|
| 166 |
+
letter-spacing: var(--tracking);
|
| 167 |
+
min-height: 11rem;
|
| 168 |
+
}
|
| 169 |
+
.prose .line {
|
| 170 |
+
opacity: 0;
|
| 171 |
+
cursor: default;
|
| 172 |
+
transition: opacity 0.9s ease, color 0.4s ease;
|
| 173 |
+
}
|
| 174 |
+
.prose .line.show { opacity: 1; }
|
| 175 |
+
.prose .line:hover { color: #fff; }
|
| 176 |
+
|
| 177 |
+
.aside {
|
| 178 |
+
color: var(--dim);
|
| 179 |
+
font-style: italic;
|
| 180 |
+
min-height: 1.6rem;
|
| 181 |
+
opacity: 0;
|
| 182 |
+
transition: opacity 1.2s ease;
|
| 183 |
+
margin-top: 0.5rem;
|
| 184 |
+
}
|
| 185 |
+
.aside.show { opacity: 0.85; }
|
| 186 |
+
|
| 187 |
+
/* --- controls --- */
|
| 188 |
+
.controls {
|
| 189 |
+
display: flex;
|
| 190 |
+
gap: 1rem;
|
| 191 |
+
margin-top: 2.5rem;
|
| 192 |
+
animation: fade-in 1s ease both;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.confidence {
|
| 196 |
+
margin-top: 1.6rem;
|
| 197 |
+
animation: fade-in 0.6s ease both;
|
| 198 |
+
}
|
| 199 |
+
.conf-q { color: var(--dim); font-size: 0.92rem; margin: 0 0 0.7rem; }
|
| 200 |
+
.conf-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
| 201 |
+
|
| 202 |
+
.row {
|
| 203 |
+
display: flex;
|
| 204 |
+
gap: 1rem;
|
| 205 |
+
align-items: center;
|
| 206 |
+
justify-content: center;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* --- buttons --- */
|
| 210 |
+
.btn {
|
| 211 |
+
background: transparent;
|
| 212 |
+
color: var(--fg);
|
| 213 |
+
border: 1px solid #2a2d31;
|
| 214 |
+
padding: 0.8rem 1.4rem;
|
| 215 |
+
font-family: inherit;
|
| 216 |
+
font-size: 1rem;
|
| 217 |
+
letter-spacing: 0.06em;
|
| 218 |
+
cursor: pointer;
|
| 219 |
+
transition: border-color 0.3s ease, background 0.3s ease, color 0.3s ease;
|
| 220 |
+
}
|
| 221 |
+
.btn:hover { border-color: var(--accent); color: #fff; }
|
| 222 |
+
.btn:active { transform: translateY(1px); }
|
| 223 |
+
.choice { flex: 1; }
|
| 224 |
+
|
| 225 |
+
.btn.ghost {
|
| 226 |
+
border-color: #23262a;
|
| 227 |
+
color: var(--dim);
|
| 228 |
+
padding: 0.55rem 1rem;
|
| 229 |
+
font-size: 0.86rem;
|
| 230 |
+
}
|
| 231 |
+
.btn.ghost:hover { color: var(--fg); border-color: var(--dim); }
|
| 232 |
+
|
| 233 |
+
#begin, #again { min-width: 12rem; }
|
| 234 |
+
|
| 235 |
+
/* --- verdict flashes --- */
|
| 236 |
+
.flash-good { animation: good 1.4s ease; }
|
| 237 |
+
.flash-bad { animation: bad 1.4s ease; }
|
| 238 |
+
|
| 239 |
+
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
| 240 |
+
@keyframes good {
|
| 241 |
+
0% { box-shadow: none; }
|
| 242 |
+
30% { box-shadow: inset 0 0 120px -40px var(--accent); }
|
| 243 |
+
100% { box-shadow: none; }
|
| 244 |
+
}
|
| 245 |
+
@keyframes bad {
|
| 246 |
+
0% { box-shadow: none; }
|
| 247 |
+
20% { box-shadow: inset 0 0 160px -30px var(--danger); }
|
| 248 |
+
100% { box-shadow: none; }
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
@media (max-width: 640px) {
|
| 252 |
+
.title { font-size: 2.2rem; }
|
| 253 |
+
#screen { padding: 1.2rem; }
|
| 254 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" />
|
| 6 |
+
<title>8</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
| 8 |
+
</head>
|
| 9 |
+
<body data-skin="hallway">
|
| 10 |
+
<main id="screen">
|
| 11 |
+
|
| 12 |
+
<!-- Scenario / arc selection -->
|
| 13 |
+
<section id="select" class="panel">
|
| 14 |
+
<h1 class="title">8</h1>
|
| 15 |
+
<p class="select-lead">Somewhere you can't quite leave.<br />Choose a way through.</p>
|
| 16 |
+
<div id="arcs" class="arcs"></div>
|
| 17 |
+
</section>
|
| 18 |
+
|
| 19 |
+
<!-- Title / intro (per arc) -->
|
| 20 |
+
<section id="intro" class="panel hidden">
|
| 21 |
+
<h1 id="intro-title" class="title">8</h1>
|
| 22 |
+
<div id="intro-text" class="brief"></div>
|
| 23 |
+
<div class="row">
|
| 24 |
+
<button id="begin" class="btn">Begin</button>
|
| 25 |
+
<button id="intro-back" class="btn ghost">Back</button>
|
| 26 |
+
</div>
|
| 27 |
+
</section>
|
| 28 |
+
|
| 29 |
+
<!-- The loop (corridor / landing) -->
|
| 30 |
+
<section id="corridor" class="panel hidden">
|
| 31 |
+
<header class="hud">
|
| 32 |
+
<span id="progress" class="progress"></span>
|
| 33 |
+
<span id="best" class="best"></span>
|
| 34 |
+
</header>
|
| 35 |
+
|
| 36 |
+
<h2 id="heading" class="heading"></h2>
|
| 37 |
+
|
| 38 |
+
<div id="prose" class="prose"></div>
|
| 39 |
+
|
| 40 |
+
<p id="aside" class="aside"></p>
|
| 41 |
+
|
| 42 |
+
<div id="controls" class="controls hidden">
|
| 43 |
+
<button class="btn choice" data-choice="continue">Go on</button>
|
| 44 |
+
<button class="btn choice" data-choice="back">Turn back</button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div id="confidence" class="confidence hidden">
|
| 48 |
+
<p class="conf-q">How certain are you?</p>
|
| 49 |
+
<div class="conf-row">
|
| 50 |
+
<button class="btn ghost" data-conf="1">Guessing</button>
|
| 51 |
+
<button class="btn ghost" data-conf="2">I think so</button>
|
| 52 |
+
<button class="btn ghost" data-conf="3">Almost sure</button>
|
| 53 |
+
<button class="btn ghost" data-conf="4">Certain</button>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
</section>
|
| 57 |
+
|
| 58 |
+
<!-- Win -->
|
| 59 |
+
<section id="win" class="panel hidden">
|
| 60 |
+
<div id="win-text" class="brief"></div>
|
| 61 |
+
<div class="row">
|
| 62 |
+
<button id="again" class="btn">Walk it again</button>
|
| 63 |
+
<button id="change" class="btn ghost">Change scenario</button>
|
| 64 |
+
</div>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
</main>
|
| 68 |
+
|
| 69 |
+
<script src="{{ url_for('static', filename='game.js') }}"></script>
|
| 70 |
+
</body>
|
| 71 |
+
</html>
|