diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa621a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitea +.github +.godot +**/bin +**/obj +build +*.user +*.suo diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml new file mode 100644 index 0000000..9357a38 --- /dev/null +++ b/.gitea/workflows/deploy-web.yml @@ -0,0 +1,59 @@ +name: Deploy STEP3 Web Game + +on: + push: + branches: + - main + workflow_dispatch: {} + +jobs: + deploy: + runs-on: self-hosted + + env: + IMAGE_TAR: /tmp/step3-web.tar + NODES: "192.168.86.72 192.168.86.73 192.168.86.74" + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set build identity + run: | + echo "BUILD_TAG=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Build Web game image + run: | + docker build -f web/Dockerfile -t "step3-web:${BUILD_TAG}" . + + - name: Save image + run: | + docker save "step3-web:${BUILD_TAG}" -o "${IMAGE_TAR}" + + - name: Import image on Kubernetes nodes + run: | + for node in ${NODES}; do + scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/step3-web.tar + ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/step3-web.tar && rm -f /tmp/step3-web.tar" + done + + - name: Clean runner image archive + if: always() + run: rm -f "${IMAGE_TAR}" + + - name: Configure kubeconfig + env: + KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }} + run: | + if [ -n "$KUBECONFIG_CONTENT" ]; then + mkdir -p /tmp/kube + printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config + echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV" + fi + + - name: Deploy Web game + run: | + kubectl create namespace step3-web --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f web/k8s/service.yaml -n step3-web + sed "s|step3-web:latest|step3-web:${BUILD_TAG}|" web/k8s/deployment.yaml | kubectl apply -n step3-web -f - + kubectl rollout status deployment/step3-web -n step3-web --timeout=180s diff --git a/README.md b/README.md index 13cbb58..2eca1c7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ The Godot client reads this from `project.godot`: step3/questions_api_url ``` +## Browser Pre-Alpha + +The Web export is deployed at `https://doctor.ranaze.com` through the existing +Cloudflare, Traefik, and Kubernetes stack. See +[WEB_DEPLOYMENT.md](WEB_DEPLOYMENT.md) for DNS, routing, deployment, and +verification details. + ## Question Bank PDF Imports Use [QBANK_PDF_IMPORT_RUNBOOK.md](QBANK_PDF_IMPORT_RUNBOOK.md) for the complete diff --git a/WEB_DEPLOYMENT.md b/WEB_DEPLOYMENT.md new file mode 100644 index 0000000..0c5f200 --- /dev/null +++ b/WEB_DEPLOYMENT.md @@ -0,0 +1,43 @@ +# Browser Pre-Alpha Deployment + +The browser build is served at `https://doctor.ranaze.com`. Gitea builds and +deploys the Web image to the `step3-web` Kubernetes namespace on every push to +`main`. The service is exposed on NodePort `30089`. + +## Cloudflare + +Create a proxied `doctor` DNS record that targets the same public endpoint as +the existing `s3q` and `pauth` records. Keep SSL/TLS mode consistent with those +working services and redirect HTTP to HTTPS. + +## Traefik + +Create three routers for `doctor.ranaze.com`, ordered by priority: + +1. `Host(`doctor.ranaze.com`) && PathPrefix(`/api/Auth`)` routes to the same + backend service used by `pauth.ranaze.com`. +2. `Host(`doctor.ranaze.com`) && PathPrefix(`/api`)` routes to the Kubernetes + nodes on port `30088`, the Questions API NodePort. +3. `Host(`doctor.ranaze.com`)` routes to the Kubernetes nodes on port `30089`, + the Web game NodePort. + +Do not strip any prefixes. Give the auth router the highest priority, the +general API router the next-highest priority, and the Web router the lowest. +Reuse the existing HTTPS entrypoint, certificate resolver, load-balancing node +list, and HTTP-to-HTTPS middleware. + +## Verification + +After DNS and Traefik are configured: + +```bash +curl -fsS https://doctor.ranaze.com/healthz +curl -i -X OPTIONS https://doctor.ranaze.com/api/Auth/login +curl -i -X OPTIONS https://doctor.ranaze.com/api/questions/search +``` + +The health endpoint must return `ok`. Open the site in a desktop browser and +verify registration or login, question loading, images, answer submission, +ratings, and question reporting. Browser requests must remain on +`doctor.ranaze.com`; requests to `pauth.ranaze.com` or `s3q.ranaze.com` indicate +that the Web origin resolver did not run. diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..564488d --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,29 @@ +[preset.0] + +name="Web" +platform="Web" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/web/index.html" +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=true +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false diff --git a/project.godot b/project.godot index 4bd67f4..21dd266 100644 --- a/project.godot +++ b/project.godot @@ -12,7 +12,7 @@ config_version=5 config/name="step3" run/main_scene="res://scenes/main.tscn" -config/features=PackedStringArray("4.6", "Forward Plus") +config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" [autoload] @@ -28,6 +28,8 @@ BlockSessionService="*res://scripts/autoload/block_session_service.gd" window/size/viewport_width=1920 window/size/viewport_height=1080 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" [physics] @@ -35,6 +37,8 @@ window/size/viewport_height=1080 [rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" rendering_device/driver.windows="d3d12" [step3] diff --git a/scripts/autoload/api_endpoint_config.gd b/scripts/autoload/api_endpoint_config.gd new file mode 100644 index 0000000..73ca807 --- /dev/null +++ b/scripts/autoload/api_endpoint_config.gd @@ -0,0 +1,11 @@ +extends RefCounted + + +static func resolve(native_url: String, same_origin_path: String) -> String: + if not OS.has_feature("web"): + return native_url + var browser_origin: Variant = JavaScriptBridge.eval("window.location.origin", true) + var origin := str(browser_origin).strip_edges().rstrip("/") + if origin.is_empty() or not origin.begins_with("http"): + return native_url + return "%s/%s" % [origin, same_origin_path.trim_prefix("/")] diff --git a/scripts/autoload/api_endpoint_config.gd.uid b/scripts/autoload/api_endpoint_config.gd.uid new file mode 100644 index 0000000..15d0095 --- /dev/null +++ b/scripts/autoload/api_endpoint_config.gd.uid @@ -0,0 +1 @@ +uid://c0m4jbetdkadj diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 059fe94..8470955 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -1,5 +1,6 @@ extends Node +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first" const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions" @@ -44,11 +45,21 @@ var active_attempt_index := -1 var attempt_request_active := false func _ready() -> void: - questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) - question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)) - question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)) - ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)) - question_reports_api_url = str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL)) + questions_api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)), + "/api/questions/search") + question_attempt_api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)), + "/api/questions/attempts/first") + question_api_base_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)), + "/api/questions") + ratings_api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)), + "/api/ratings/me") + question_reports_api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL)), + "/api/question-reports") request = HTTPRequest.new() request.request_completed.connect(_on_api_questions_loaded) add_child(request) diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 44415f4..d9fd446 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -1,8 +1,9 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") -const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login" -const AUTH_REGISTER_URL := "https://pauth.ranaze.com/api/Auth/register" +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login" +const DEFAULT_AUTH_REGISTER_URL := "https://pauth.ranaze.com/api/Auth/register" var main: Control var auth_panel: PanelContainer @@ -25,9 +26,13 @@ var register_request: HTTPRequest var auth_mode := "login" var automatic_login_pending := false var login_attempt_username := "" +var auth_login_url := DEFAULT_AUTH_LOGIN_URL +var auth_register_url := DEFAULT_AUTH_REGISTER_URL func _ready() -> void: + auth_login_url = EndpointConfig.resolve(DEFAULT_AUTH_LOGIN_URL, "/api/Auth/login") + auth_register_url = EndpointConfig.resolve(DEFAULT_AUTH_REGISTER_URL, "/api/Auth/register") set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 18) @@ -190,7 +195,7 @@ func _send_login(username: String, password: String, is_automatic: bool) -> void success_label.text = "Account created. Signing you in..." var payload := {"username": username, "password": password} var headers := PackedStringArray(["Content-Type: application/json"]) - var request_error := login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + var request_error := login_request.request(auth_login_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if request_error != OK: _set_busy(false) login_button.text = "Log In" @@ -254,7 +259,7 @@ func _on_register_pressed() -> void: _clear_messages() var payload := {"username": username, "email": email, "password": password} var headers := PackedStringArray(["Content-Type: application/json"]) - var request_error := register_request.request(AUTH_REGISTER_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + var request_error := register_request.request(auth_register_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if request_error != OK: _set_busy(false) register_button.text = "Create Account" diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index efb0948..d3dd99a 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -14,9 +14,21 @@ var current_screen_name := "" func _ready() -> void: Style.apply_app_theme(self) _build_ui() + resized.connect(_update_responsive_layout) + _update_responsive_layout() show_screen("selection" if AuthState.is_logged_in else "login", "exam") +func _update_responsive_layout() -> void: + if header_stats != null: + header_stats.visible = size.x >= 900.0 + if app_menu != null: + app_menu.custom_minimum_size.x = minf(340.0, maxf(280.0, size.x - 32.0)) + if app_menu.visible: + app_menu.size = app_menu.custom_minimum_size + app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 + + func show_screen(screen_name: String, payload: Variant = null) -> void: _close_app_menu() for child in $Root/Screens.get_children(): diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 469af18..1dfa37e 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -357,6 +357,20 @@ func _ready() -> void: _build_labs_modal() _build_report_modal() + resized.connect(_fit_modal_panels) + _fit_modal_panels() + + +func _fit_modal_panels() -> void: + var available := get_viewport_rect().size - Vector2(32, 32) + if report_panel != null: + report_panel.custom_minimum_size = Vector2( + minf(700.0, maxf(320.0, available.x)), + minf(600.0, maxf(560.0, available.y))) + if labs_panel != null: + labs_panel.custom_minimum_size = Vector2( + minf(760.0, maxf(320.0, available.x)), + minf(430.0, maxf(360.0, available.y))) func on_show(_payload: Variant = null) -> void: diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..8c619d0 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,39 @@ +FROM ubuntu:24.04 AS exporter + +ARG GODOT_VERSION=4.6.2 +ARG GODOT_RELEASE=${GODOT_VERSION}-stable +ARG GODOT_TEMPLATE_DIR=${GODOT_VERSION}.stable + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip libasound2t64 libfontconfig1 libgl1 libx11-6 \ + libxcursor1 libxi6 libxinerama1 libxrandr2 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/godot +RUN curl -fsSLO "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_linux.x86_64.zip" \ + && curl -fsSLO "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_export_templates.tpz" \ + && curl -fsSLO "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/SHA512-SUMS.txt" \ + && grep "Godot_v${GODOT_RELEASE}_linux.x86_64.zip\|Godot_v${GODOT_RELEASE}_export_templates.tpz" SHA512-SUMS.txt > selected-checksums.txt \ + && sha512sum --check selected-checksums.txt \ + && unzip -q "Godot_v${GODOT_RELEASE}_linux.x86_64.zip" \ + && install -m 0755 "Godot_v${GODOT_RELEASE}_linux.x86_64" /usr/local/bin/godot \ + && mkdir -p "/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}" /tmp/godot-templates \ + && unzip -q "Godot_v${GODOT_RELEASE}_export_templates.tpz" -d /tmp/godot-templates \ + && cp -a /tmp/godot-templates/templates/. "/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/" + +WORKDIR /src +COPY . . +RUN mkdir -p /build \ + && godot --headless --editor --path /src --quit \ + && godot --headless --path /src --export-release Web /build/index.html \ + && test -s /build/index.html \ + && test -s /build/index.wasm \ + && test -s /build/index.pck + +FROM nginx:1.27-alpine +COPY web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=exporter /build/ /usr/share/nginx/html/ +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/web/k8s/deployment.yaml b/web/k8s/deployment.yaml new file mode 100644 index 0000000..1808efe --- /dev/null +++ b/web/k8s/deployment.yaml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: step3-web + labels: + app: step3-web +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: step3-web + template: + metadata: + labels: + app: step3-web + spec: + containers: + - name: web + image: step3-web:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 128Mi + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 20 + timeoutSeconds: 2 diff --git a/web/k8s/kustomization.yaml b/web/k8s/kustomization.yaml new file mode 100644 index 0000000..a33121c --- /dev/null +++ b/web/k8s/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml diff --git a/web/k8s/service.yaml b/web/k8s/service.yaml new file mode 100644 index 0000000..5089d0a --- /dev/null +++ b/web/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: step3-web + labels: + app: step3-web +spec: + selector: + app: step3-web + type: NodePort + ports: + - name: http + port: 80 + targetPort: 8080 + nodePort: 30089 diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..449726c --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + gzip on; + gzip_min_length 1024; + gzip_types application/javascript application/wasm application/octet-stream text/css; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } + + location / { + add_header Cache-Control "public, max-age=3600" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri $uri/ /index.html; + } +}