Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ environment activated:
- **React player (default):** `python run_web.py`
- **Legacy player (explicit opt-in):** `python run_web.py --ui legacy`

For Windows installer users, run `launch_game.bat` for React, or
`launch_game.bat --ui legacy` for legacy.
**Want to play without a local installation?** [Explore NeverEndingQuest online](https://eternaltavern.com/neverendingquest/).
Hosted alpha access is limited; the website describes current availability.

On Windows, `launch_game.bat` now ships with the repository. Double-click it to
choose online play or local play. The Windows installer offers the same choice
before checking or installing dependencies. For an installed local game, explicit
arguments skip the choice: `launch_game.bat --ui react` or `launch_game.bat --ui legacy`.
Automated launches can set `NEQ_LOCAL_ONLY=1` or keep using `python run_web.py`.

Local/custom models are experimental. Settings requires acknowledgment that
capability and safeguards vary, outputs may be inappropriate or unreliable, and
game rules may be misunderstood. Existing local configurations remain selected,
but local calls wait for acknowledgment. For headless setup, review the warning
with `python acknowledge_local_model.py`; automation can explicitly accept the
displayed version with `python acknowledge_local_model.py --accept local-model-alpha-1`.
Only the disclaimer version and acceptance time are added to local settings.

Both interfaces open in your browser. React is selected automatically; there is
no interface-selection prompt and no automatic fallback to legacy. If React needs
Expand Down
30 changes: 30 additions & 0 deletions acknowledge_local_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Review/record local-model consent without starting a game or making AI calls."""
import argparse
import sys


def main():
import model_config
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--accept", metavar="VERSION", help="Explicit acknowledgment for automated setup")
args = parser.parse_args()
print(model_config.LOCAL_MODEL_DISCLAIMER)
print("Disclaimer version:", model_config.LOCAL_MODEL_CONSENT_VERSION)
if args.accept is None:
if not sys.stdin.isatty():
print("No acknowledgment recorded. Review the disclaimer, then use --accept VERSION.")
return 1
if input("Type ACCEPT to acknowledge, or press Enter to cancel: ").strip() != "ACCEPT":
print("No acknowledgment recorded.")
return 1
try:
model_config.acknowledge_local_model(args.accept or model_config.LOCAL_MODEL_CONSENT_VERSION)
except ValueError as exc:
print(str(exc))
return 1
print("Acknowledgment saved locally. Your selected provider has not been changed.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
53 changes: 53 additions & 0 deletions docs/local-launch-and-consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Online choice and experimental local models

`launch_game.bat` ships with the repository. With no arguments it offers online
play, local play, or exit. The online choice opens the public product page and
does not check Python/Git/Node, install dependencies, or create settings. The
standalone Windows installer presents the same choice before installation.
Hosted alpha access is limited; this release does not advertise open BYOK access.

Selecting local play uses the installation's `venv\Scripts\python.exe`. If that
environment is absent, the launcher directs the user to the installer/manual
setup. An installer inside a checkout uses that checkout rather than nesting
another clone, and keeps the tracked launcher instead of generating an older one.
React remains the local default; explicit arguments, including `--ui legacy`,
are forwarded. Any arguments or `NEQ_LOCAL_ONLY=1` skip the online/local menu.
`python run_web.py` and headless commands retain their noninteractive behavior.

## Acknowledgment

React and legacy settings show the experimental warning and request explicit
confirmation before selecting a local provider or first saving/testing its
endpoint. The backend checks the current disclaimer version before persistence
or probes. The local client factory also checks it before constructing a client,
so a saved pre-upgrade local configuration cannot bypass acknowledgment.

Existing local selection stays visible and does not fall back to paid OpenAI.
An unacknowledged local call fails with instructions to use Settings or
`python acknowledge_local_model.py`. This CLI displays the full warning and
requires `ACCEPT` interactively. It never reads consent from redirected input.
For deliberate automation, after reviewing the warning use:

```
python acknowledge_local_model.py --accept local-model-alpha-1
```

This records only `local_model_consent.version` and an integer UTC acceptance
timestamp in the installation's ignored `user_settings.json`. It does not change
the selected provider, collect prompts, or make an AI call. An older version does
not count after a disclaimer update. Consent is installation-wide, like the
existing local provider configuration, not a separate per-player account system.
Someone who owns and edits this open-source installation can alter its local
settings; this is an acknowledgment mechanism, not tamper-proof attestation.

The hosted edition still excludes local/custom endpoints. Consent never grants
permission to use an arbitrary URL from a hosted world.

## Checks

Python contract tests exercise actual handler bodies, Socket.IO event decorators,
temporary settings and synthetic/local provider stubs. Windows tests execute
online/exit branches without a browser/install and verify argument forwarding
and exit codes through a temporary venv. Frontend tests exercise decline/accept,
save/probe gating and prior settings behavior; browser checks verify selection
and reload behavior. No paid provider requests are used in this validation.
49 changes: 35 additions & 14 deletions install_neverendingquest_windows.bat
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@ REM NeverEndingQuest - Windows Installation Script with Virtual Environment
REM Automated installer for non-technical users
REM ============================================================================

SETLOCAL EnableDelayedExpansion

SETLOCAL EnableDelayedExpansion

REM This choice must precede Python/Git/Node checks and any installation.
if "%NEQ_LOCAL_ONLY%"=="1" goto LOCAL_SETUP
echo.
echo Play online without installing NeverEndingQuest on this computer.
echo Hosted alpha access is limited; see the website for current availability.
echo O: Explore online play L: Continue with local installation X: Exit
choice /C OLX /N /M "Choose O, L, or X: "
if errorlevel 3 exit /b 0
if errorlevel 2 goto LOCAL_SETUP
if errorlevel 1 (
start "" "https://eternaltavern.com/neverendingquest/"
exit /b 0
)
exit /b 1

:LOCAL_SETUP
echo.
echo ========================================
echo NeverEndingQuest Installation
Expand Down Expand Up @@ -61,7 +77,12 @@ git --version
echo [OK] Git found!
echo.

REM Step 3: Clone repository
REM Step 3: Clone repository
REM A downloaded checkout already contains the game; do not clone inside it.
if exist "%~dp0run_web.py" (
cd /d "%~dp0"
goto REPOSITORY_READY
)
echo Step 3: Cloning repository...
echo Installing to: %CD%
echo.
Expand All @@ -86,9 +107,10 @@ if exist "NeverEndingQuest" (
echo [OK] Repository cloned successfully!
)

cd NeverEndingQuest

REM Step 4: Create virtual environment
cd NeverEndingQuest

:REPOSITORY_READY
REM Step 4: Create virtual environment
echo.
echo Step 4: Creating Python virtual environment...
if not exist "venv" (
Expand Down Expand Up @@ -201,14 +223,13 @@ REM Step 7: Create desktop shortcut and launch script
echo.
echo Step 7: Creating launch scripts...

REM Create launch_game.bat in the repo folder
echo @echo off > launch_game.bat
echo cd /d "%%~dp0" >> launch_game.bat
echo call venv\Scripts\activate.bat >> launch_game.bat
echo python run_web.py %%* >> launch_game.bat
echo pause >> launch_game.bat

echo [OK] Created launch_game.bat
REM launch_game.bat ships with the checkout and includes the online/local choice.
REM Do not replace it with a generated launcher that loses that choice.
if not exist launch_game.bat (
echo [ERROR] This checkout is missing launch_game.bat. Download the current release.
exit /b 1
)
echo [OK] Using the repository's launch_game.bat

REM Create desktop shortcut
set SCRIPT_DIR=%CD%
Expand Down
32 changes: 32 additions & 0 deletions launch_game.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@echo off
setlocal
cd /d "%~dp0"
REM Arguments or NEQ_LOCAL_ONLY=1 preserve scripted local startup without a menu.
if not "%~1"=="" goto LOCAL
if "%NEQ_LOCAL_ONLY%"=="1" goto LOCAL
echo.
echo NeverEndingQuest - choose how to play
echo O: Explore online play - no local installation required
echo Hosted alpha access is limited. See the website for current availability.
echo L: Continue with local play on this computer
echo X: Exit
choice /C OLX /N /M "Choose O, L, or X: "
if errorlevel 3 exit /b 0
if errorlevel 2 goto LOCAL
if errorlevel 1 (
start "" "https://eternaltavern.com/neverendingquest/"
exit /b 0
)
exit /b 1

:LOCAL
if not exist "venv\Scripts\python.exe" goto SETUP_REQUIRED
"venv\Scripts\python.exe" run_web.py %*
exit /b %errorlevel%

:SETUP_REQUIRED
echo Local setup has not been completed in this folder.
echo Run install_neverendingquest_windows.bat and choose local setup.
echo For manual or automated installations, use your Python environment:
echo python run_web.py
exit /b 1
53 changes: 52 additions & 1 deletion model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,12 @@ def _convert_prop(prop):


def set_provider(provider_name):
if provider_name == "lmstudio":
require_local_model_consent()
_apply_provider(provider_name)


def _apply_provider(provider_name):
"""Switch all model variables to the specified provider's models.

Updates both model_config globals AND config module globals (since
Expand Down Expand Up @@ -1306,6 +1312,8 @@ def _forget_credential(name):

def persist_provider(provider_name):
"""Save provider choice to disk so it survives restarts."""
if provider_name == "lmstudio":
require_local_model_consent()
settings = _load_user_settings()
settings["model_provider"] = provider_name
_save_user_settings(settings)
Expand All @@ -1322,12 +1330,54 @@ def load_persisted_provider():
settings = _load_user_settings()
provider = settings.get("model_provider", "openai")
if provider in PROVIDER_MODELS:
set_provider(provider)
# Keep an existing local selection visible for acknowledgment. Never
# silently fall back to a paid cloud provider. The client factory blocks
# local calls until consent is current, including headless startup.
_apply_provider(provider)


DEFAULT_LOCAL_BASE_URL = "http://localhost:1234/v1"
DEFAULT_LOCAL_API_KEY = "not-needed"

LOCAL_MODEL_CONSENT_VERSION = "local-model-alpha-1"
LOCAL_MODEL_DISCLAIMER = (
"Local models vary widely in capability and safeguards. They may produce "
"inappropriate or unreliable content, misunderstand game rules, or behave "
"unpredictably. This integration is experimental and still in development. "
"I understand these limitations and want to enable a local model."
)


def local_model_consent_current():
consent = _load_user_settings().get("local_model_consent")
return (isinstance(consent, dict)
and consent.get("version") == LOCAL_MODEL_CONSENT_VERSION
and type(consent.get("accepted_at")) is int
and consent["accepted_at"] > 0)


def require_local_model_consent():
if not local_model_consent_current():
raise ValueError("Local models are experimental. Accept the disclaimer in Settings "
"before using them, or run python acknowledge_local_model.py for headless setup.")


def acknowledge_local_model(version):
if version != LOCAL_MODEL_CONSENT_VERSION:
raise ValueError("Please review and accept the current local-model disclaimer.")
import time
settings = _load_user_settings()
settings["local_model_consent"] = {"version": version, "accepted_at": int(time.time())}
_save_user_settings(settings)


def accept_local_model_request(data):
"""Explicit versioned acknowledgment, or an already accepted installation."""
version = data.get("local_model_consent_version")
if version is not None:
acknowledge_local_model(version)
require_local_model_consent()


def get_local_endpoint():
"""Return the Local/Custom endpoint config without exposing stored secrets.
Expand All @@ -1352,6 +1402,7 @@ def persist_local_endpoint(base_url="", api_key=None, model=""):
it. base_url/model are always written (blank base_url falls back to the
default; blank model means keep each callsite's own model).
"""
require_local_model_consent()
s = _migrate_plaintext_secrets(_load_user_settings())
s["local_base_url"] = (base_url or "").strip()
s["local_model"] = (model or "").strip()
Expand Down
5 changes: 3 additions & 2 deletions utils/openai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ def get_openai_client(provider=None):
# remote host). Endpoint is read live from user_settings.json so a web-UI
# change applies on the next request with no restart. Defaults preserve
# the original LM Studio localhost:1234 behavior. (Issue #120)
import model_config
ep = model_config.get_local_endpoint()
import model_config
model_config.require_local_model_consent()
ep = model_config.get_local_endpoint()
return OpenAI(
base_url=ep["base_url"],
api_key=ep["api_key"] or "not-needed"
Expand Down
14 changes: 14 additions & 0 deletions web/frontend/e2e/ember-providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { test, expect } from '@playwright/test'
test.describe.configure({ mode: 'serial' })
let lateResultObserved: Promise<void>
test.beforeEach(async ({ request, page }) => {
page.on('dialog', dialog => dialog.accept())
lateResultObserved = new Promise(resolve => page.on('websocket', socket => socket.on('framereceived', frame => {
if (String(frame.payload).includes('Delayed closed-panel result.')) resolve()
})))
Expand All @@ -14,6 +15,19 @@ test.beforeEach(async ({ request, page }) => {
await expect(page.getByLabel('Provider', { exact: true })).toBeEnabled()
})

test('declining the local disclaimer leaves the confirmed provider unchanged', async ({ page }) => {
page.removeAllListeners('dialog')
page.once('dialog', async dialog => {
expect(dialog.message()).toContain('inappropriate or unreliable')
await dialog.dismiss()
})
await page.getByLabel('Provider', { exact: true }).selectOption('lmstudio')
await expect(page.getByLabel('Provider', { exact: true })).toHaveValue('legacy')
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await expect(page.getByLabel('Provider', { exact: true })).toHaveValue('legacy')
})

test('each provider is confirmed and survives browser reload', async ({ page }) => {
for (const provider of ['openai', 'gemini', 'lmstudio', 'legacy']) {
await page.getByLabel('Provider', { exact: true }).selectOption(provider)
Expand Down
49 changes: 49 additions & 0 deletions web/frontend/e2e/launch_contract_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Run Windows launch branches in a disposable folder, with no installs/browser."""
import os
from pathlib import Path
import subprocess
import sys

import pytest

REPO = Path(__file__).resolve().parents[3]
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows entry points")


@pytest.mark.parametrize("script", ["launch_game.bat", "install_neverendingquest_windows.bat"])
@pytest.mark.parametrize("selection, expected", [("O", "ONLINE_SELECTED"), ("X", None)])
def test_online_or_exit_never_reaches_local_setup(tmp_path, script, selection, expected):
text = (REPO / script).read_text()
# Replace only the external browser launch with an observable sentinel.
# Everything after the local label is a tripwire: not even Python is needed.
text = text.replace('start "" "https://eternaltavern.com/neverendingquest/"', 'echo ONLINE_SELECTED')
label = ':LOCAL_SETUP\n' if script.startswith('install') else ':LOCAL\n'
text = text.split(label)[0] + label + 'echo LOCAL_TRIPWIRE\nexit /b 99\n'
target = tmp_path / script
target.write_text(text)
env = dict(os.environ)
env.pop('NEQ_LOCAL_ONLY', None)
result = subprocess.run(['cmd', '/d', '/c', str(target)], input=selection, text=True,
capture_output=True, env=env, timeout=10)
assert result.returncode == 0, result.stdout + result.stderr
assert 'LOCAL_TRIPWIRE' not in result.stdout
assert ('ONLINE_SELECTED' in result.stdout) == bool(expected)


def test_explicit_local_arguments_are_forwarded_and_exit_status_preserved(tmp_path):
# Use a genuine Python venv executable; the only program it runs is a stub.
subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(tmp_path / 'venv')], check=True)
(tmp_path / 'launch_game.bat').write_text((REPO / 'launch_game.bat').read_text())
(tmp_path / 'run_web.py').write_text('import sys\nprint(repr(sys.argv[1:]))\nraise SystemExit(7)\n')
result = subprocess.run(['cmd', '/d', '/c', str(tmp_path / 'launch_game.bat'), '--ui', 'legacy'],
text=True, capture_output=True, timeout=10)
assert result.returncode == 7
assert "['--ui', 'legacy']" in result.stdout
assert 'choose how to play' not in result.stdout


def test_installer_uses_checkout_launcher_and_local_checkout_label():
text = (REPO / 'install_neverendingquest_windows.bat').read_text()
assert ':REPOSITORY_READY\n' in text
assert 'echo @echo off > launch_game.bat' not in text
assert text.index('choice /C OLX') < text.index('python --version')
Loading