File size: 16,729 Bytes
3f45f47
32d6660
3f45f47
32d6660
ec501f5
3f45f47
d2076fc
3f45f47
 
8bef568
d2076fc
 
3f45f47
 
 
 
 
 
c75d321
3f45f47
8bef568
32d6660
3f45f47
32d6660
 
 
 
 
 
 
 
 
 
 
a10be2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460761
 
 
 
 
 
 
 
 
 
 
 
 
7b39fc8
 
 
 
 
 
 
 
 
 
 
9d28c02
 
 
 
 
 
 
 
 
32d6660
 
 
 
 
 
 
 
 
 
 
 
3f45f47
20bbecb
3f45f47
 
c75d321
 
 
 
 
c31cef0
a2eee21
3f45f47
 
 
 
 
 
c75d321
3f45f47
 
 
 
 
 
 
8bef568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2eee21
3f45f47
 
d2076fc
c31cef0
d2076fc
3f45f47
 
 
a2eee21
3f45f47
 
c31cef0
3f45f47
 
a2eee21
 
 
 
 
 
 
 
 
 
 
d2076fc
 
3f45f47
d2076fc
 
32d6660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460761
32d6660
 
 
 
 
 
a10be2f
7b39fc8
9d28c02
 
32d6660
 
7b39fc8
 
9d28c02
 
32d6660
 
 
 
 
 
 
 
 
 
 
 
a10be2f
 
 
7b39fc8
9d28c02
1460761
32d6660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a10be2f
 
7b39fc8
9d28c02
1460761
32d6660
 
 
 
 
 
a10be2f
 
7b39fc8
 
 
9d28c02
 
1460761
 
32d6660
 
 
 
 
 
1f92812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c5277e
 
1f92812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c5277e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c32cd1c
1f92812
 
c31cef0
 
 
 
 
 
 
32d6660
c31cef0
ec501f5
 
 
648398d
c31cef0
 
32d6660
8c5277e
32d6660
 
 
 
 
 
 
 
c31cef0
 
1f92812
 
 
c31cef0
 
 
 
 
 
 
ec501f5
 
778ac59
1f92812
f6abbc0
c31cef0
1f92812
f6abbc0
32d6660
 
c31cef0
f6abbc0
c31cef0
 
 
56e5f1b
c31cef0
f6abbc0
 
 
4264b6a
c31cef0
32d6660
 
 
1f92812
32d6660
6823371
 
32d6660
 
7f0712e
8c5277e
 
 
 
7f0712e
4264b6a
d2076fc
7f0712e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
import os
import random
from functools import lru_cache
from hashlib import blake2b
from typing import Any

import gradio as gr
from huggingface_hub import hf_hub_download
from llama_cpp import Llama
from llama_cpp.llama_chat_format import Jinja2ChatFormatter


MODEL_REPO = os.getenv("MODEL_REPO", "unsloth/NVIDIA-Nemotron-3-Nano-4B-GGUF")
MODEL_FILE = os.getenv("MODEL_FILE", "NVIDIA-Nemotron-3-Nano-4B-Q5_K_M.gguf")

N_CTX = int(os.getenv("N_CTX", "2048"))
N_BATCH = int(os.getenv("N_BATCH", "128"))
N_THREADS = int(os.getenv("N_THREADS", str(max(1, (os.cpu_count() or 2) - 1))))
N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "-1"))
MAX_HISTORY_TURNS = int(os.getenv("MAX_HISTORY_TURNS", "6"))
ENABLE_THINKING = os.getenv("ENABLE_THINKING", "false").lower() in {"1", "true", "yes"}
GAME_SEED = os.getenv("GAME_SEED", "dreadzone")

SESSIONS: dict[str, dict[str, Any]] = {}

ZONE_PROFILES = [
    "stale yellow corridors, buzzing fluorescent panels, damp carpet",
    "concrete service halls with numbered doors and distant machinery",
    "abandoned office cubicles under a ceiling that sags like wet paper",
    "tile-lined maintenance rooms smelling of bleach and hot dust",
    "empty retail aisles where the shelves repeat without logic",
    "hotel corridors with patterned wallpaper and no visible stairs",
]

ENTITY_PRESSURES = [
    {
        "name": "acoustic mismatch",
        "hint": "the room tone drops out for half a second, then returns too loud",
        "instruction": "Use a subtle change in sound or silence; avoid saying anything is watching.",
    },
    {
        "name": "impossible maintenance",
        "hint": "a fresh wet-floor sign stands where the floor is bone dry",
        "instruction": "Use a mundane object that is newly wrong; do not make the threat visible.",
    },
    {
        "name": "light lag",
        "hint": "the fluorescent panels brighten one corridor behind the player",
        "instruction": "Use delayed light or shadow behavior; keep it ambiguous.",
    },
    {
        "name": "spatial edit",
        "hint": "a doorway seems to have been moved a few inches while no one looked",
        "instruction": "Use architecture changing by a small amount; avoid direct pursuit language.",
    },
    {
        "name": "borrowed voice",
        "hint": "a voice repeats the last word the player thought, not the last one spoken",
        "instruction": "Use a brief auditory intrusion; do not identify a speaker.",
    },
    {
        "name": "texture error",
        "hint": "the wallpaper pattern misaligns like a copied image pasted over itself",
        "instruction": "Use a visual flaw in the environment; make it quietly disturbing.",
    },
]

AMBIENT_NOISES = [
    "a duct clicks once, then again from somewhere much lower",
    "water taps behind a wall with no plumbing",
    "a fluorescent tube gives a soft electrical sigh",
    "carpet fibers whisper as if brushed by a passing sleeve",
    "something plastic crinkles two rooms away",
    "a door latch tests itself and goes still",
    "the ceiling emits a slow settling pop",
    "a distant intercom opens, carries no voice, and shuts",
    "one of the lights hums in a rhythm like breathing",
    "a rolling object crosses the floor where there is no object",
]

REVELATIONS = [
    "the player recognizes the hallway as a place they avoided thinking about for years",
    "the player realizes the exit signs have been using their own handwriting",
    "the player notices every room contains one object from a home they never returned to",
    "the player understands the entity is not chasing them, but correcting their path",
    "the player remembers choosing a door before the memory cuts out",
    "the player sees that the map in their head has always had this place at its center",
    "the player recognizes the buzzing as the sound that used to fill their childhood bedroom",
    "the player realizes the corridor is arranging itself around a thing they refuse to name",
]

ESCAPE_EVENTS = [
    "an exit door opens onto the player's own bedroom, but the wallpaper continues across the ceiling",
    "a fire escape stairwell appears, descending into warm daylight that smells like wet carpet",
    "a service elevator arrives with the player's name taped over every floor button",
    "a glass storefront shows an empty street outside, though the reflection does not match the player",
    "a loading dock door rises to reveal a night sky with no stars and a familiar parked car",
    "a stairwell landing contains a phone booth already ringing with the player's home number",
]

DIRECTIONS = {
    "north": (0, 1),
    "n": (0, 1),
    "south": (0, -1),
    "s": (0, -1),
    "east": (1, 0),
    "e": (1, 0),
    "west": (-1, 0),
    "w": (-1, 0),
}

DEFAULT_SYSTEM_PROMPT = "You are the Dreadzone narration engine."


@lru_cache(maxsize=1)
def get_llm() -> Llama:
    print(
        f"Loading model {MODEL_REPO}/{MODEL_FILE} "
        f"with n_gpu_layers={N_GPU_LAYERS}",
        flush=True,
    )
    model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
    print(f"Model file ready: {model_path}", flush=True)

    return Llama(
        model_path=model_path,
        n_ctx=N_CTX,
        n_batch=N_BATCH,
        n_threads=N_THREADS,
        n_gpu_layers=N_GPU_LAYERS,
        use_mmap=True,
        use_mlock=False,
        logits_all=False,
        verbose=False,
    )


def token_text(llm: Llama, token_id: int) -> str:
    if token_id < 0:
        return ""
    text = llm._model.token_get_text(token_id)  # noqa: SLF001
    return text.decode("utf-8", errors="ignore") if isinstance(text, bytes) else text


def render_prompt(llm: Llama, messages: list[dict[str, str]]) -> tuple[str, list[str]]:
    template = llm.metadata.get("tokenizer.chat_template")
    if not template:
        raise ValueError("GGUF does not include tokenizer.chat_template metadata.")

    eos_token = token_text(llm, llm.token_eos())
    bos_token = token_text(llm, llm.token_bos())
    formatter = Jinja2ChatFormatter(
        template=template,
        eos_token=eos_token,
        bos_token=bos_token,
    )
    formatted = formatter(
        messages=messages,
        enable_thinking=ENABLE_THINKING,
    )
    stop = formatted.stop if isinstance(formatted.stop, list) else [formatted.stop]
    return formatted.prompt, [item for item in stop if item]


def trim_history(history: list[Any]) -> list[Any]:
    if not history:
        return []

    return history[-(MAX_HISTORY_TURNS * 2) :]


def build_messages(
    message: str,
    history: list[Any],
    system_message: str,
) -> list[dict[str, str]]:
    messages = [{"role": "system", "content": system_message}]

    for item in trim_history(history):
        if isinstance(item, dict):
            role = item.get("role")
            content = item.get("content")
            if role in {"user", "assistant"} and content:
                messages.append({"role": role, "content": content})
        elif isinstance(item, (list, tuple)) and len(item) >= 2:
            user_text, assistant_text = item[:2]
            if user_text:
                messages.append({"role": "user", "content": str(user_text)})
            if assistant_text:
                messages.append({"role": "assistant", "content": str(assistant_text)})

    messages.append({"role": "user", "content": message})
    return messages


def session_id(request: gr.Request | None) -> str:
    if request and request.session_hash:
        return request.session_hash
    return "local"


def initial_state() -> dict[str, Any]:
    return {
        "x": 0,
        "y": 0,
        "sanity": 84,
        "turn": 0,
        "last_event": "You came to yourself under a failing fluorescent light.",
    }


def get_state(request: gr.Request | None) -> dict[str, Any]:
    sid = session_id(request)
    if sid not in SESSIONS:
        SESSIONS[sid] = initial_state()
    return SESSIONS[sid]


def tile_seed(x: int, y: int, turn: int = 0) -> int:
    digest = blake2b(
        f"{GAME_SEED}:{x}:{y}:{turn}".encode("utf-8"),
        digest_size=8,
    ).digest()
    return int.from_bytes(digest, "big")


def zone_profile(x: int, y: int) -> str:
    return ZONE_PROFILES[tile_seed(x, y) % len(ZONE_PROFILES)]


def parse_action(user_input: str) -> tuple[str, tuple[int, int]]:
    lowered = user_input.strip().lower()
    words = lowered.replace(",", " ").replace(".", " ").split()
    for word in words:
        if word in DIRECTIONS:
            return f"move {word}", DIRECTIONS[word]
    if lowered in DIRECTIONS:
        return f"move {lowered}", DIRECTIONS[lowered]
    return "interact", (0, 0)


def apply_turn(state: dict[str, Any], user_input: str) -> dict[str, Any]:
    action, delta = parse_action(user_input)
    old_x, old_y = state["x"], state["y"]
    state["turn"] += 1
    state["x"] += delta[0]
    state["y"] += delta[1]

    rng = random.Random(tile_seed(state["x"], state["y"], state["turn"]))
    ambient_noise = rng.choice(AMBIENT_NOISES) if rng.randint(1, 6) == 1 else "none"
    base_loss = 1 if delta != (0, 0) else 0
    if rng.random() < 0.25:
        base_loss += 1

    encounter_chance = min(0.45, max(0.03, (100 - state["sanity"]) / 260))
    entity_present = rng.random() < encounter_chance
    pressure = rng.choice(ENTITY_PRESSURES) if entity_present else None
    revelation = rng.choice(REVELATIONS) if entity_present and rng.randint(1, 4) == 1 else "none"
    escape_chance = 0.015 if state["turn"] >= 8 else 0.0
    escape_event = rng.choice(ESCAPE_EVENTS) if rng.random() < escape_chance else "none"
    if entity_present:
        base_loss += rng.choice([2, 3, 4])
    if revelation != "none":
        base_loss += 1
    if escape_event != "none":
        base_loss += 2

    state["sanity"] = max(0, min(100, state["sanity"] - base_loss))
    state["last_event"] = (
        f"Moved from ({old_x}, {old_y}) to ({state['x']}, {state['y']})."
        if delta != (0, 0)
        else "Stayed in place and examined the immediate area."
    )

    return {
        "action": action,
        "moved": delta != (0, 0),
        "entity_present": entity_present,
        "encounter_hint": pressure["hint"] if pressure else "none",
        "encounter_mode": pressure["name"] if pressure else "none",
        "encounter_instruction": pressure["instruction"] if pressure else "none",
        "revelation": revelation,
        "escape_event": escape_event,
        "ambient_noise": ambient_noise,
        "sanity_loss": base_loss,
        "zone": zone_profile(state["x"], state["y"]),
    }


def game_prompt(
    state: dict[str, Any],
    turn: dict[str, Any],
    user_input: str,
    system_message: str,
) -> str:
    return f"""
{system_message}

[SYSTEM POLICY: DREADZONE SIMULATION ENGINE]
You narrate a Backrooms-inspired exploration game. Stay in-world.
Do not mention prompts, dice, hidden state, coordinates, percentages, or rules.

Hidden state:
- Coordinates: ({state["x"]}, {state["y"]})
- Sanity: {state["sanity"]}%
- Turn: {state["turn"]}
- Zone profile: {turn["zone"]}
- Player action: {user_input!r}
- Action classification: {turn["action"]}
- Entity encounter: {turn["encounter_hint"]}
- Encounter mode: {turn["encounter_mode"]}
- Encounter narration note: {turn["encounter_instruction"]}
- Psychological revelation: {turn["revelation"]}
- Possible escape: {turn["escape_event"]}
- Ambient unexplained noise: {turn["ambient_noise"]}
- Sanity erosion this turn: {turn["sanity_loss"]}
- Prior event: {state["last_event"]}

Narration task:
Write 2-5 sentences in second person.
Describe the immediate result of the action and the environment.
If an entity encounter is not "none", imply danger using the encounter narration
note. Do not repeat stock phrases like "it is watching" or "you are getting closer."
If psychological revelation is not "none", make it the emotional turn of the
scene: reveal it obliquely as a realization, memory, or pattern. Do not fully
explain it.
If possible escape is not "none", treat it as a rare climax: show the apparent
way out, but make it unsettling and ambiguous rather than triumphant.
If ambient unexplained noise is not "none", weave it into the scene as a small
unsettling detail without making it the whole turn.
If sanity is low, make perception less reliable without naming sanity.
Do not invent inventory, explicit exits, UI commands, or system facts.
End with a subtle opening for the next action.
""".strip()


def opening_prompt(state: dict[str, Any]) -> str:
    return f"""
[SYSTEM POLICY: DREADZONE OPENING SCENE]
You narrate a Backrooms-inspired exploration game. Stay in-world.
Do not mention prompts, hidden state, coordinates, percentages, or rules.

Hidden state:
- Coordinates: ({state["x"]}, {state["y"]})
- Sanity: {state["sanity"]}%
- Zone profile: {zone_profile(state["x"], state["y"])}

Narration task:
Write 2-4 sentences in second person.
Begin in medias res: the player has just come to in the Dreadzone with no clear
memory of arrival.
Use concrete sensory detail and leave the player with an immediate opening for
their first action.
""".strip()


def run_completion(
    llm: Llama,
    messages: list[dict[str, str]],
    max_tokens: int,
    temperature: float,
    top_p: float,
):
    prompt, stop = render_prompt(llm, messages)
    for chunk in llm.create_completion(
        prompt=prompt,
        max_tokens=max_tokens,
        temperature=temperature,
        top_p=top_p,
        stop=stop,
        stream=True,
    ):
        yield chunk["choices"][0].get("text") or ""


def generate_opening(request: gr.Request):
    state = get_state(request)
    if state.get("opening"):
        history = [{"role": "assistant", "content": state["opening"]}]
        return history, history, history

    print("Generating opening scene", flush=True)
    llm = get_llm()
    messages = [
        {"role": "system", "content": DEFAULT_SYSTEM_PROMPT},
        {"role": "user", "content": opening_prompt(state)},
    ]
    opening = ""
    for token in run_completion(
        llm=llm,
        messages=messages,
        max_tokens=180,
        temperature=0.75,
        top_p=0.9,
    ):
        opening += token

    opening = opening.strip() or "You come to under a failing fluorescent light."
    state["opening"] = opening
    state["last_event"] = opening
    history = [{"role": "assistant", "content": opening}]
    return history, history, history


def history_with_opening(
    history: list[Any],
    state: dict[str, Any],
) -> list[Any]:
    if history:
        return history

    opening = state.get("opening")
    if not opening:
        return history

    return [{"role": "assistant", "content": opening}]


def respond(
    message,
    history: list[dict[str, str]],
    system_message,
    max_tokens,
    temperature,
    top_p,
    request: gr.Request,
):
    if not message.strip():
        return

    print(f"Received chat request: {message[:120]!r}", flush=True)

    llm = get_llm()
    state = get_state(request)
    history = history_with_opening(history, state)
    turn = apply_turn(state, message)
    narration_prompt = game_prompt(
        state=state,
        turn=turn,
        user_input=message,
        system_message=system_message or DEFAULT_SYSTEM_PROMPT,
    )
    messages = build_messages(narration_prompt, history, system_message or DEFAULT_SYSTEM_PROMPT)
    response = ""

    for token in run_completion(
        llm=llm,
        messages=messages,
        max_tokens=max_tokens,
        temperature=temperature,
        top_p=top_p,
    ):
        if token:
            response += token
            yield response


chat_window = gr.Chatbot()

chatbot = gr.ChatInterface(
    respond,
    chatbot=chat_window,
    additional_inputs=[
        gr.Textbox(value=DEFAULT_SYSTEM_PROMPT, label="Narration policy"),
        gr.Slider(minimum=32, maximum=768, value=220, step=16, label="Max new tokens"),
        gr.Slider(minimum=0.0, maximum=1.5, value=0.7, step=0.05, label="Temperature"),
        gr.Slider(
            minimum=0.1,
            maximum=1.0,
            value=0.9,
            step=0.05,
            label="Top-p (nucleus sampling)",
        ),
    ],
)

with gr.Blocks() as demo:
    gr.Markdown(
        """
        # Dreadzone
        Respond to the opening scene with an action like `look around`, `listen`, or `go north`.
        The space tracks your location and fraying perception behind the scenes.

        The first scene can take a moment while the local model wakes up.
        """
    )
    chatbot.render()
    demo.load(
        generate_opening,
        outputs=[chat_window, chatbot.chatbot_state, chatbot.chatbot_value],
    )


if __name__ == "__main__":
    demo.launch()