From 6b967ea537778ef08bd266cb7774dc7e7472c4d5 Mon Sep 17 00:00:00 2001 From: Shonitbar Date: Sun, 26 Apr 2026 17:11:08 +0300 Subject: [PATCH 1/2] added pytest + tests --- .github/workflows/ci.yml | 26 +++++++++ pyproject.toml | 7 +++ tests/__init__.py | 0 tests/conftest.py | 80 ++++++++++++++++++++++++++ tests/test_compress.py | 25 +++++++++ tests/test_history.py | 44 +++++++++++++++ tests/test_mine.py | 66 ++++++++++++++++++++++ tests/test_mission_engine.py | 105 +++++++++++++++++++++++++++++++++++ tests/test_missions_route.py | 22 ++++++++ tests/test_status.py | 32 +++++++++++ 10 files changed, 407 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_compress.py create mode 100644 tests/test_history.py create mode 100644 tests/test_mine.py create mode 100644 tests/test_mission_engine.py create mode 100644 tests/test_missions_route.py create mode 100644 tests/test_status.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f658590 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: pip install -e ".[test]" + + - name: Run tests + run: pytest --cov=scriptrunner --cov-report=term-missing diff --git a/pyproject.toml b/pyproject.toml index 027ed80..e0136d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,10 @@ dependencies = [ [project.scripts] scriptrunner = "scriptrunner.cli:app" + +[project.optional-dependencies] +test = ["pytest>=8.0", "httpx>=0.27", "pytest-cov>=5.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..76e2844 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +""" +Shared fixtures for all tests. + +Uses an in-memory SQLite database (StaticPool) so every test starts from a +clean slate without touching the real ~/.scriptrunner/save.db file. +The FastAPI lifespan (init_db + start_decay_loop) is patched out so tests +don't spin up background tasks or write to disk. +""" +import pytest +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine, select +from sqlmodel.pool import StaticPool + +import scriptrunner.server.routes.core as _core_module +from scriptrunner.server.db import get_session, _MISSIONS +from scriptrunner.server.models import GameState, Mission +from scriptrunner.server.main import app + + +@pytest.fixture(name="engine") +def engine_fixture(): + """Fresh in-memory SQLite engine per test.""" + test_engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(test_engine) + yield test_engine + SQLModel.metadata.drop_all(test_engine) + + +@pytest.fixture(name="client") +def client_fixture(engine): + """ + TestClient wired to the in-memory DB. + - Dependency override replaces get_session with the test engine. + - init_db and start_decay_loop are patched so no background work runs. + - Mine cooldown (_last_mine_time) is reset to 0 before each test. + """ + _core_module._last_mine_time = 0.0 + + def _get_session_override(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = _get_session_override + + with Session(engine) as session: + session.add(GameState()) + for m in _MISSIONS: + session.add(Mission(**m)) + session.commit() + + with ( + patch("scriptrunner.server.main.init_db"), + patch("scriptrunner.server.main.start_decay_loop", new=AsyncMock(return_value=None)), + ): + with TestClient(app) as client: + yield client + + app.dependency_overrides.clear() + + +def set_state(engine, **kwargs): + """Helper: update GameState columns directly in tests.""" + with Session(engine) as session: + state = session.exec(select(GameState)).first() + for key, value in kwargs.items(): + setattr(state, key, value) + session.add(state) + session.commit() + + +def get_state(engine) -> GameState: + """Helper: read current GameState from the test DB.""" + with Session(engine) as session: + return session.exec(select(GameState)).first() diff --git a/tests/test_compress.py b/tests/test_compress.py new file mode 100644 index 0000000..74e9415 --- /dev/null +++ b/tests/test_compress.py @@ -0,0 +1,25 @@ +"""Tests for POST /compress.""" +from tests.conftest import set_state, get_state + + +def test_compress_locked_below_tier_2(client): + resp = client.post("/compress") + assert resp.status_code == 403 + assert "Tier 2" in resp.json()["detail"] + + +def test_compress_requires_100_cycles(client, engine): + set_state(engine, tier=2, cycles=50.0, entropy=50.0) + resp = client.post("/compress") + assert resp.status_code == 400 + assert "insufficient cycles" in resp.json()["detail"] + + +def test_compress_reduces_entropy_and_costs_cycles(client, engine): + set_state(engine, tier=2, cycles=200.0, entropy=60.0) + resp = client.post("/compress") + assert resp.status_code == 200 + data = resp.json() + assert data["cycles"] == 100.0 # 200 - 100 cost + assert data["entropy"] == 40.0 # 60 - 20 reduction + assert "entropy reduced" in data["message"] diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..10f7282 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,44 @@ +"""Tests for GET /status/history.""" +from sqlmodel import Session, select + +from scriptrunner.server.models import Mission +from tests.conftest import set_state + + +def _complete_first_contact(engine): + """Mark the first_contact mission as done in the test DB.""" + with Session(engine) as session: + m = session.exec(select(Mission).where(Mission.slug == "first_contact")).first() + m.completed = True + session.add(m) + session.commit() + + +def test_history_locked_without_first_contact(client): + resp = client.get("/status/history") + assert resp.status_code == 403 + assert "First Contact" in resp.json()["detail"] + + +def test_history_accessible_after_first_contact(client, engine): + _complete_first_contact(engine) + # Generate some log entries first + client.get("/status") + client.post("/mine") + + resp = client.get("/status/history") + assert resp.status_code == 200 + logs = resp.json() + assert isinstance(logs, list) + assert len(logs) > 0 + + +def test_history_entries_have_required_fields(client, engine): + _complete_first_contact(engine) + client.get("/status") + + resp = client.get("/status/history") + assert resp.status_code == 200 + for entry in resp.json(): + for field in ("endpoint", "method", "status_code", "result", "timestamp"): + assert field in entry, f"missing field: {field}" diff --git a/tests/test_mine.py b/tests/test_mine.py new file mode 100644 index 0000000..b8982da --- /dev/null +++ b/tests/test_mine.py @@ -0,0 +1,66 @@ +"""Tests for POST /mine.""" +from unittest.mock import patch + +import scriptrunner.server.routes.core as _core_module +from tests.conftest import set_state + + +def test_mine_safe_zone_gains_one_cycle(client): + """entropy < 30 → gain = 1 × multiplier, zone = safe.""" + resp = client.post("/mine") + assert resp.status_code == 200 + data = resp.json() + assert data["zone"] == "safe" + assert data["gained"] == 1.0 + # cycles may be higher because first_contact mission rewards 10 bonus cycles + assert data["cycles"] >= 1.0 + + +def test_mine_caution_zone_gains_two_cycles(client, engine): + """30 ≤ entropy < 70 → gain = 2, zone = caution.""" + set_state(engine, entropy=40.0) + resp = client.post("/mine") + assert resp.status_code == 200 + data = resp.json() + assert data["zone"] == "caution" + assert data["gained"] == 2.0 + + +def test_mine_danger_zone_returns_200(client, engine): + """70 ≤ entropy < 90 → zone = danger (gain may vary due to loss event).""" + set_state(engine, entropy=75.0) + # Patch random so no loss event fires (random() = 0.5 > 0.10 threshold) + with patch("scriptrunner.server.routes.core.random.random", return_value=0.5): + resp = client.post("/mine") + assert resp.status_code == 200 + data = resp.json() + assert data["zone"] == "danger" + assert data["gained"] == 5.0 + + +def test_mine_critical_entropy_blocked(client, engine): + """entropy ≥ 90 → 403, no cycles gained.""" + set_state(engine, entropy=95.0) + resp = client.post("/mine") + assert resp.status_code == 403 + assert "entropy critical" in resp.json()["detail"] + + +def test_mine_cooldown_returns_429(client): + """Two rapid POST /mine calls — second must be rate-limited.""" + _core_module._last_mine_time = 0.0 + first = client.post("/mine") + assert first.status_code == 200 + + # Second call within the 1-second cooldown window + second = client.post("/mine") + assert second.status_code == 429 + assert "cooldown" in second.json()["detail"] + + +def test_mine_completes_first_contact_mission(client): + """First mine ever completes the First Contact mission and promotes to Tier 1.""" + resp = client.post("/mine") + assert resp.status_code == 200 + data = resp.json() + assert "first_contact" in data.get("missions_completed", []) diff --git a/tests/test_mission_engine.py b/tests/test_mission_engine.py new file mode 100644 index 0000000..f2ad30e --- /dev/null +++ b/tests/test_mission_engine.py @@ -0,0 +1,105 @@ +"""Unit tests for the mission_engine — no HTTP, pure logic.""" +from datetime import datetime + +import pytest +from sqlmodel import Session, select + +from scriptrunner.server.db import _MISSIONS +from scriptrunner.server.mission_engine import check_missions +from scriptrunner.server.models import GameState, Mission + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _seed(engine): + """Seed a fresh GameState and all missions, return (state, session).""" + from sqlmodel import create_engine + from sqlmodel.pool import StaticPool + from sqlmodel import SQLModel + + e = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(e) + with Session(e) as session: + state = GameState() + session.add(state) + for m in _MISSIONS: + session.add(Mission(**m)) + session.commit() + session.refresh(state) + return e + + +@pytest.fixture +def db(): + return _seed(None) + + +def _run(db, **state_kwargs): + """Apply kwargs to GameState, run check_missions, return (completed, state).""" + with Session(db) as session: + state = session.exec(select(GameState)).first() + for key, value in state_kwargs.items(): + setattr(state, key, value) + session.add(state) + session.commit() + session.refresh(state) + + completed = check_missions(state, session) + session.commit() + session.refresh(state) + return completed, state + + +# ── tests ───────────────────────────────────────────────────────────────────── + +def test_first_contact_completes_on_first_mine(db): + completed, state = _run(db, mines_total=1) + assert "first_contact" in completed + + +def test_first_contact_promotes_tier_to_1(db): + _, state = _run(db, mines_total=1) + assert state.tier == 1 + + +def test_ten_in_a_row_requires_tier_1_and_10_mines(db): + _run(db, mines_total=1) # complete first_contact → tier 1 + completed, _ = _run(db, mines_total=10) + assert "ten_in_a_row" in completed + + +def test_grinder_completes_at_500_cycles_and_promotes_tier_2(db): + _run(db, mines_total=1) # tier 1 + completed, state = _run(db, cycles=500.0) + assert "grinder" in completed + assert state.tier == 2 + + +def test_patience_mission_completes_when_entropy_drops(db): + _run(db, mines_total=1) # tier 1, sets patience_first_mine_at + + completed, state = _run( + db, + mines_total=2, + entropy=1.0, + patience_first_mine_at=datetime.utcnow(), + ) + assert "patience" in completed + + +def test_danger_zone_mission_needs_5_danger_mines(db): + _run(db, mines_total=1) # tier 1 + _run(db, cycles=500.0) # tier 2 + completed, _ = _run(db, danger_mines=5) + assert "danger_zone" in completed + + +def test_loop_artist_needs_50_mines_at_tier_2(db): + _run(db, mines_total=1) # tier 1 + _run(db, cycles=500.0) # tier 2 + completed, _ = _run(db, mines_total=50) + assert "loop_artist" in completed diff --git a/tests/test_missions_route.py b/tests/test_missions_route.py new file mode 100644 index 0000000..cf64bee --- /dev/null +++ b/tests/test_missions_route.py @@ -0,0 +1,22 @@ +"""Tests for GET /missions.""" + + +def test_missions_returns_all_missions(client): + resp = client.get("/missions") + assert resp.status_code == 200 + missions = resp.json() + assert isinstance(missions, list) + assert len(missions) == 14 # all seeded missions + + +def test_missions_have_required_fields(client): + resp = client.get("/missions") + required = {"id", "slug", "name", "description", "tier_required", + "reward_cycles", "reward_synth", "completed", "completed_at"} + for mission in resp.json(): + assert required.issubset(mission.keys()), f"missing fields in: {mission}" + + +def test_missions_none_completed_initially(client): + resp = client.get("/missions") + assert all(not m["completed"] for m in resp.json()) diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..a144259 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,32 @@ +"""Tests for GET /status.""" +from tests.conftest import set_state + + +def test_status_returns_expected_fields(client): + resp = client.get("/status") + assert resp.status_code == 200 + data = resp.json() + for field in ("cycles", "entropy", "synth", "tier", "cycle_multiplier", "uptime"): + assert field in data, f"missing field: {field}" + + +def test_status_initial_values(client): + resp = client.get("/status") + data = resp.json() + assert data["cycles"] == 0.0 + assert data["entropy"] == 0.0 + assert data["synth"] == 0 + assert data["tier"] == 0 + + +def test_status_triggers_the_watcher_mission(client, engine): + """Calling /status 20 times while at Tier 1 completes The Watcher mission.""" + set_state(engine, tier=1) + + completed = [] + for _ in range(20): + resp = client.get("/status") + assert resp.status_code == 200 + completed.extend(resp.json().get("missions_completed", [])) + + assert "the_watcher" in completed From 1cda9a881e4af969c7a67f5a1b3724c3fac5894d Mon Sep 17 00:00:00 2001 From: Shonitbar Date: Sun, 26 Apr 2026 17:15:08 +0300 Subject: [PATCH 2/2] fixed warnings --- scriptrunner/server/mission_engine.py | 14 +++++++------- scriptrunner/server/models.py | 10 +++++----- scriptrunner/server/routes/automate.py | 6 +++--- scriptrunner/server/routes/compress.py | 6 +++--- scriptrunner/server/routes/core.py | 24 ++++++++++++------------ scriptrunner/server/routes/dark_ops.py | 24 ++++++++++++------------ scriptrunner/server/routes/pipeline.py | 8 ++++---- scriptrunner/server/routes/prestige.py | 6 +++--- scriptrunner/server/routes/ws.py | 4 ++-- scriptrunner/server/state.py | 4 ++-- tests/test_mission_engine.py | 4 ++-- 11 files changed, 55 insertions(+), 55 deletions(-) diff --git a/scriptrunner/server/mission_engine.py b/scriptrunner/server/mission_engine.py index 2728041..db2bb88 100644 --- a/scriptrunner/server/mission_engine.py +++ b/scriptrunner/server/mission_engine.py @@ -1,6 +1,6 @@ """Auto-checks and completes missions based on current game state.""" -from datetime import datetime +from datetime import datetime, timezone from typing import List from sqlmodel import Session, select @@ -15,10 +15,10 @@ def _complete(mission: Mission, state: GameState, session: Session, completed: L if mission.completed: return mission.completed = True - mission.completed_at = datetime.utcnow() + mission.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) state.cycles += mission.reward_cycles * state.cycle_multiplier state.synth += mission.reward_synth - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(mission) completed.append(mission.slug) @@ -26,7 +26,7 @@ def _complete(mission: Mission, state: GameState, session: Session, completed: L def _promote_tier(state: GameState, target: int) -> None: if state.tier < target: state.tier = target - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) def check_missions(state: GameState, session: Session) -> List[str]: @@ -55,9 +55,9 @@ def check_missions(state: GameState, session: Session) -> List[str]: if "patience" in missions: m = missions["patience"] if state.patience_first_mine_at is None and state.mines_total >= 1: - state.patience_first_mine_at = datetime.utcnow() + state.patience_first_mine_at = datetime.now(timezone.utc).replace(tzinfo=None) state.patience_first_entropy = state.entropy - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) elif ( state.patience_first_mine_at is not None and state.entropy < 2.0 @@ -78,7 +78,7 @@ def check_missions(state: GameState, session: Session) -> List[str]: m = missions["the_compressor"] if state.entropy > 70: state.compressor_saw_high = True - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) if state.compressor_saw_high and state.entropy < 30: _complete(m, state, session, completed) diff --git a/scriptrunner/server/models.py b/scriptrunner/server/models.py index bbbe554..30916b8 100644 --- a/scriptrunner/server/models.py +++ b/scriptrunner/server/models.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from sqlmodel import Field, SQLModel @@ -42,8 +42,8 @@ class GameState(SQLModel, table=True): dark_ops_unlocked: bool = Field(default=False) # Dark ops: HMAC key shards collected (bitmask 0b11111 = all 5) hmac_shards: int = Field(default=0) - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) class CallLog(SQLModel, table=True): @@ -52,14 +52,14 @@ class CallLog(SQLModel, table=True): method: str = Field(default="POST") status_code: int result_json: str - timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) class Automation(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(unique=True, index=True) interval_sec: int = Field(default=10) - registered_at: datetime = Field(default_factory=datetime.utcnow) + registered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) active: bool = Field(default=True) diff --git a/scriptrunner/server/routes/automate.py b/scriptrunner/server/routes/automate.py index 7dfb5aa..5753693 100644 --- a/scriptrunner/server/routes/automate.py +++ b/scriptrunner/server/routes/automate.py @@ -1,7 +1,7 @@ """POST /automate — register a named automation for passive +0.5 cycles/sec.""" import json -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException @@ -39,7 +39,7 @@ def post_automate(body: AutomateRequest, session: Session = Depends(get_session) if existing: existing.active = True existing.interval_sec = body.interval_sec - existing.registered_at = datetime.utcnow() + existing.registered_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(existing) msg = f"automation '{body.name}' reactivated" else: @@ -56,7 +56,7 @@ def post_automate(body: AutomateRequest, session: Session = Depends(get_session) session.add(state) session.add(CallLog( endpoint="/automate", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.commit() return result diff --git a/scriptrunner/server/routes/compress.py b/scriptrunner/server/routes/compress.py index 230e1c2..27eeab0 100644 --- a/scriptrunner/server/routes/compress.py +++ b/scriptrunner/server/routes/compress.py @@ -1,7 +1,7 @@ """POST /compress — spend 100 cycles to reduce entropy by 20.""" import json -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select @@ -34,7 +34,7 @@ def post_compress(session: Session = Depends(get_session)): state.cycles -= COMPRESS_COST state.entropy = max(0.0, state.entropy - COMPRESS_REDUCTION) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) newly_completed = check_missions(state, session) @@ -49,7 +49,7 @@ def post_compress(session: Session = Depends(get_session)): update_blob(state, "/compress") session.add(CallLog( endpoint="/compress", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() diff --git a/scriptrunner/server/routes/core.py b/scriptrunner/server/routes/core.py index 9c47c9d..d7f6285 100644 --- a/scriptrunner/server/routes/core.py +++ b/scriptrunner/server/routes/core.py @@ -1,7 +1,7 @@ import json import random import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select @@ -27,7 +27,7 @@ def _get_state(session: Session) -> GameState: def _track_scheduler(state: GameState) -> None: """Track timing for The Scheduler mission: mine every 2s ±200ms for 60s.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if not state.scheduler_active: state.scheduler_active = True state.scheduler_mines = 1 @@ -61,7 +61,7 @@ def _log_call(endpoint: str, method: str, status_code: int, result: dict, sessio def get_status(session: Session = Depends(get_session)): state = _get_state(session) state.status_calls += 1 - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) newly_completed = check_missions(state, session) @@ -121,7 +121,7 @@ def post_mine(session: Session = Depends(get_session)): # Track overclock mines for Overclock Runner mission if state.overclock_active and state.overclock_ends_at: - if datetime.utcnow() < state.overclock_ends_at: + if datetime.now(timezone.utc).replace(tzinfo=None) < state.overclock_ends_at: state.overclock_mines += 1 gain *= 2 # doubled during overclock else: @@ -133,7 +133,7 @@ def post_mine(session: Session = Depends(get_session)): if loss_event: state.cycles = max(0, state.cycles - 50) state.entropy = min(100, state.entropy + 0.5) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) newly_completed = check_missions(state, session) result = { "cycles": round(state.cycles, 2), @@ -151,7 +151,7 @@ def post_mine(session: Session = Depends(get_session)): state.cycles += gain state.entropy = min(100, state.entropy + 0.5) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) newly_completed = check_missions(state, session) @@ -180,15 +180,15 @@ def post_overclock(session: Session = Depends(get_session)): if state.tier < 3: raise HTTPException(status_code=403, detail="endpoint locked — reach Tier 3 first") - if state.overclock_active and state.overclock_ends_at and datetime.utcnow() < state.overclock_ends_at: - remaining = (state.overclock_ends_at - datetime.utcnow()).seconds + if state.overclock_active and state.overclock_ends_at and datetime.now(timezone.utc).replace(tzinfo=None) < state.overclock_ends_at: + remaining = (state.overclock_ends_at - datetime.now(timezone.utc).replace(tzinfo=None)).seconds raise HTTPException(status_code=400, detail=f"overclock already active — {remaining}s remaining") state.overclock_active = True state.overclock_mines = 0 - state.overclock_ends_at = datetime.utcnow() + timedelta(seconds=30) + state.overclock_ends_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=30) state.entropy = min(100, state.entropy + 30) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "message": "overclock active — mine yield doubled for 30 seconds", @@ -214,7 +214,7 @@ def post_exploit(session: Session = Depends(get_session)): if success: gain = 500.0 * state.cycle_multiplier state.cycles += gain - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "success", "gained": round(gain, 2), @@ -223,7 +223,7 @@ def post_exploit(session: Session = Depends(get_session)): } else: state.entropy = min(100, state.entropy + 40) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "failure", "entropy": round(state.entropy, 2), diff --git a/scriptrunner/server/routes/dark_ops.py b/scriptrunner/server/routes/dark_ops.py index 32841dd..54127f6 100644 --- a/scriptrunner/server/routes/dark_ops.py +++ b/scriptrunner/server/routes/dark_ops.py @@ -5,7 +5,7 @@ import json import random import time -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException @@ -89,7 +89,7 @@ def get_hint(shard_id: int, session: Session = Depends(get_session)): bit = 1 << (shard_id - 1) if not (state.hmac_shards & bit): state.hmac_shards |= bit - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(state) session.commit() @@ -120,7 +120,7 @@ def post_spoof(body: SpoofRequest, session: Session = Depends(get_session)): if not hmac.compare_digest(expected, body.signature.lower()): # Penalty: entropy spike state.entropy = min(100, state.entropy + 20) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(state) session.commit() raise HTTPException( @@ -130,7 +130,7 @@ def post_spoof(body: SpoofRequest, session: Session = Depends(get_session)): gain = 200.0 * state.cycle_multiplier state.cycles += gain - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "spoof accepted", @@ -141,7 +141,7 @@ def post_spoof(body: SpoofRequest, session: Session = Depends(get_session)): update_blob(state, "/dark-ops/spoof") session.add(CallLog( endpoint="/dark-ops/spoof", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() @@ -159,7 +159,7 @@ def post_inject(session: Session = Depends(get_session)): # Critical failure state.entropy = min(100, state.entropy + 50) state.cycles = max(0, state.cycles - 200) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "critical_failure", "entropy": round(state.entropy, 2), @@ -170,7 +170,7 @@ def post_inject(session: Session = Depends(get_session)): gain = 100.0 * state.cycle_multiplier state.cycles += gain state.entropy = min(100, state.entropy + 15) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "partial_success", "gained": round(gain, 2), @@ -181,7 +181,7 @@ def post_inject(session: Session = Depends(get_session)): # Full success gain = 400.0 * state.cycle_multiplier state.cycles += gain - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "success", "gained": round(gain, 2), @@ -192,7 +192,7 @@ def post_inject(session: Session = Depends(get_session)): update_blob(state, "/dark-ops/inject") session.add(CallLog( endpoint="/dark-ops/inject", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() @@ -230,7 +230,7 @@ def post_finalize(body: FinalizeRequest, session: Session = Depends(get_session) if not hmac.compare_digest(expected, body.signature.lower()): state.entropy = min(100, state.entropy + 30) - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(state) session.commit() raise HTTPException(status_code=401, detail="invalid signature — entropy +30") @@ -239,7 +239,7 @@ def post_finalize(body: FinalizeRequest, session: Session = Depends(get_session) gain = 99999.0 * state.cycle_multiplier state.cycles += gain state.synth += 10 - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) result = { "outcome": "CLASSIFIED", @@ -253,7 +253,7 @@ def post_finalize(body: FinalizeRequest, session: Session = Depends(get_session) update_blob(state, "/dark-ops/finalize") session.add(CallLog( endpoint="/dark-ops/finalize", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() diff --git a/scriptrunner/server/routes/pipeline.py b/scriptrunner/server/routes/pipeline.py index f744034..a6b580c 100644 --- a/scriptrunner/server/routes/pipeline.py +++ b/scriptrunner/server/routes/pipeline.py @@ -2,7 +2,7 @@ import json import random -from datetime import datetime +from datetime import datetime, timezone from typing import List from fastapi import APIRouter, Depends, HTTPException @@ -97,7 +97,7 @@ def post_pipeline(body: PipelineRequest, session: Session = Depends(get_session) }) state.status_calls += 1 - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) newly_completed = check_missions(state, session) all_completed.extend(newly_completed) @@ -109,7 +109,7 @@ def post_pipeline(body: PipelineRequest, session: Session = Depends(get_session) ).first() if pe and not pe.completed: pe.completed = True - pe.completed_at = datetime.utcnow() + pe.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) state.cycles += pe.reward_cycles * state.cycle_multiplier state.synth += pe.reward_synth session.add(pe) @@ -126,7 +126,7 @@ def post_pipeline(body: PipelineRequest, session: Session = Depends(get_session) update_blob(state, "/pipeline") session.add(CallLog( endpoint="/pipeline", method="POST", status_code=200, - result_json=json.dumps(result_payload), timestamp=datetime.utcnow() + result_json=json.dumps(result_payload), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() diff --git a/scriptrunner/server/routes/prestige.py b/scriptrunner/server/routes/prestige.py index 68f0136..31f48fd 100644 --- a/scriptrunner/server/routes/prestige.py +++ b/scriptrunner/server/routes/prestige.py @@ -1,7 +1,7 @@ """POST /prestige — Reset Protocol. Requires 5 Synth.""" import json -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select @@ -61,7 +61,7 @@ def post_prestige(session: Session = Depends(get_session)): state.blob_endpoints_seen = "[]" state.blob_dna_seed = -1 state.blob_call_sequence = "[]" - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) # Reset all missions missions = session.exec(select(Mission)).all() @@ -87,7 +87,7 @@ def post_prestige(session: Session = Depends(get_session)): update_blob(state, "/prestige") session.add(CallLog( endpoint="/prestige", method="POST", status_code=200, - result_json=json.dumps(result), timestamp=datetime.utcnow() + result_json=json.dumps(result), timestamp=datetime.now(timezone.utc).replace(tzinfo=None) )) session.add(state) session.commit() diff --git a/scriptrunner/server/routes/ws.py b/scriptrunner/server/routes/ws.py index 3c6e600..b95ca53 100644 --- a/scriptrunner/server/routes/ws.py +++ b/scriptrunner/server/routes/ws.py @@ -39,8 +39,8 @@ async def websocket_endpoint(websocket: WebSocket): if state: overclock_remaining = 0 if state.overclock_active and state.overclock_ends_at: - from datetime import datetime - remaining = (state.overclock_ends_at - datetime.utcnow()).total_seconds() + from datetime import datetime, timezone + remaining = (state.overclock_ends_at - datetime.now(timezone.utc).replace(tzinfo=None)).total_seconds() overclock_remaining = max(0, int(remaining)) if overclock_remaining == 0: state.overclock_active = False diff --git a/scriptrunner/server/state.py b/scriptrunner/server/state.py index 3b43f66..1a58e77 100644 --- a/scriptrunner/server/state.py +++ b/scriptrunner/server/state.py @@ -4,7 +4,7 @@ import hashlib import json import random -from datetime import datetime +from datetime import datetime, timezone from sqlmodel import Session, select @@ -61,7 +61,7 @@ async def _decay_loop() -> None: }) if changed: - state.updated_at = datetime.utcnow() + state.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) session.add(state) session.commit() diff --git a/tests/test_mission_engine.py b/tests/test_mission_engine.py index f2ad30e..e5bce3a 100644 --- a/tests/test_mission_engine.py +++ b/tests/test_mission_engine.py @@ -1,5 +1,5 @@ """Unit tests for the mission_engine — no HTTP, pure logic.""" -from datetime import datetime +from datetime import datetime, timezone import pytest from sqlmodel import Session, select @@ -86,7 +86,7 @@ def test_patience_mission_completes_when_entropy_drops(db): db, mines_total=2, entropy=1.0, - patience_first_mine_at=datetime.utcnow(), + patience_first_mine_at=datetime.now(timezone.utc).replace(tzinfo=None), ) assert "patience" in completed