diff --git a/.github/workflows/check-doc-snippets.yml b/.github/workflows/check-doc-snippets.yml index 10ea029923f..df2cc90e89a 100644 --- a/.github/workflows/check-doc-snippets.yml +++ b/.github/workflows/check-doc-snippets.yml @@ -18,6 +18,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/skirmish/**' + - 'scripts/dash/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'docs/website/content/blog/**' - 'CodenameOne/src/**/*.java' diff --git a/.github/workflows/dash.yml b/.github/workflows/dash.yml new file mode 100644 index 00000000000..9893797acb3 --- /dev/null +++ b/.github/workflows/dash.yml @@ -0,0 +1,505 @@ +name: Dash (runner sample) + +# scripts/dash is one Maven project holding a game and its server: a +# `rules` module (the pure, deterministic simulation both sides run), a `shared` +# module of transfer objects and @RestClient contracts, the Codename One client +# (`common`), a Codename One Backend that implements the contracts, and an `ios` +# module for the device build. It is modelled on savor.yml. +# +# Five things are checked here, because each fails without the others noticing: +# +# rules the simulation's own tests, on the JVM, with no server +# backend the server's own tests, on the JVM and compiled by ParparVM, +# then the same tests on PostgreSQL and MySQL +# client-e2e the application in the simulator, driven through its real +# screens against a running server (run-e2e.sh builds and starts +# the server itself) +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# +# Savor's `web` job (the browser build hosted by the server) is dropped: Dash +# has no run-web-e2e.sh, so there is nothing to run. The two device builds stop +# at a build: the screens are covered by the simulator run, and neither port +# holds screenshot baselines for this sample. + +on: + # Nightly at 00:11 UTC on master (nightly-watchdog.yml opens an issue when it + # fails), and on a pull request only when this workflow's own tree changes. + # Shared code (core, ports, builders, backend) reaches it through the nightly. + pull_request: + paths: + - 'scripts/dash/**' + - '.github/workflows/dash.yml' + workflow_dispatch: + schedule: + - cron: '11 0 * * *' + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/dash and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/dash + run: | + python3 scripts/sync-initializr-template.py dash + + rules: + name: Rules tests (deterministic simulation) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the rules tests + working-directory: scripts/dash + run: mvn -B -pl rules test -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: dash-rules-tests + path: scripts/dash/rules/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/dash + run: | + mvn -B -Dcodename1.platform=backend -pl rules,shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/dash/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: dash-backend-tests + path: scripts/dash/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/dash \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: dash-backend-${{ matrix.database }}-tests + path: scripts/dash/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: Application against a running server (simulator) + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + # Retried only for a failure that is the network's: a compilation + # error has to fail on the first attempt. + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + bash ../scripts/ci/retry.sh mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (timeout 300 apt-get update && timeout 300 apt-get install -y xvfb) || true + fi + + # Ahead of the simulator run: it is quick, and a template that does not + # build should not wait behind the screens to say so. + - name: Build a project generated from the Initializr template + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/dash/check-template.sh "$RUNNER_TEMP/dash-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/dash/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: dash-e2e + path: | + scripts/dash/target/e2e-shots/** + scripts/dash/target/e2e-server.log + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/dash + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-dash-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-dash- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-dash-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-dash- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/dash + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/dash-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + # No -sdk: it forces every target of the scheme onto the iPhone SDK, the + # embedded watch app included, whose watch-only icon then has nothing to + # compile for. The destination picks each target's own SDK. + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 4127941ddba..27712366c6a 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -21,6 +21,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/skirmish/**' + - 'scripts/dash/**' - 'scripts/parley/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'scripts/reel/**' @@ -140,6 +141,7 @@ jobs: - 'scripts/wayline/**' - 'scripts/savor/**' - 'scripts/skirmish/**' + - 'scripts/dash/**' - 'scripts/parley/**' - 'scripts/reel/**' - 'scripts/bastion/**' diff --git a/.github/workflows/initializr-e2e.yml b/.github/workflows/initializr-e2e.yml index e9b657fda9c..ba2d7d051cd 100644 --- a/.github/workflows/initializr-e2e.yml +++ b/.github/workflows/initializr-e2e.yml @@ -22,6 +22,7 @@ on: - 'scripts/wayline/common/src/test/**' - 'scripts/savor/common/src/test/**' - 'scripts/skirmish/common/src/test/**' + - 'scripts/dash/common/src/test/**' - 'scripts/parley/common/src/test/**' - 'scripts/reel/common/src/test/**' - 'scripts/bastion/common/src/test/**' @@ -45,7 +46,7 @@ jobs: strategy: fail-fast: false matrix: - template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL, PULSE] + template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, PARLEY, BASTION, REEL, PULSE] java: ['17'] # Developers download on current JDKs too: Kotlin 1.6.0 crashed on 25 # ("IllegalArgumentException: 25") while every 17 leg was green. diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index b3b11e22e1a..b515ffc50d6 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -208,8 +208,11 @@ jobs: "${{ steps.build_ios_app.outputs.workspace }}" \ "${{ steps.build_ios_app.outputs.scheme }}" # A cold Release compile of the hello app (now with the Android compatibility gallery, its runtime and the Kotlin stdlib) took 42.5 of 45 minutes. - # The Swing and JavaFX compatibility runtimes took it to 63. - timeout-minutes: 90 + # The Swing and JavaFX compatibility runtimes took it to 63, and the + # Compose, Unity and Flutter galleries to 89.5 -- a run that passed by 30 + # seconds, beside one that timed out. 130 matches the debug build below, + # and the job's 270 still covers both. + timeout-minutes: 130 # The binary half of scripts-ios.yml's private-API gate. App Store Connect scans the # linked executable, so this reads what it would read: every import of the Release diff --git a/.github/workflows/pulse.yml b/.github/workflows/pulse.yml index 80b5f0e6c87..36470aaa904 100644 --- a/.github/workflows/pulse.yml +++ b/.github/workflows/pulse.yml @@ -517,8 +517,10 @@ jobs: if [[ "$WORKSPACE" != *.xcworkspace ]]; then XCB_CONTAINER_FLAG="-project" fi + # No -sdk: it forces every target of the scheme onto the iPhone SDK, the + # embedded watch app included, whose watch-only icon then has nothing to + # compile for. The destination picks each target's own SDK. xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ - -sdk iphonesimulator \ -configuration Debug \ -destination 'generic/platform=iOS Simulator' \ -derivedDataPath "$DERIVED_DATA" \ diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 5f8639dd7f8..70ff29079d0 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -67,6 +67,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/skirmish/**' + - 'scripts/dash/**' - 'scripts/parley/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'scripts/website/**' diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 19ad187b7e8..7eb80aced08 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -4356,6 +4356,52 @@ private void updateBuffer(BufferedImage inputBuf) { */ int blitCounter; + /// The last complete frame, kept only while the simulator recorder runs. + private BufferedImage presented; + + /// While recording, copies the screen as a paint pass finishes. The + /// recorder runs on its own thread and must never read `buffer` + /// directly: outside buffer safe mode that IS the image the EDT is + /// painting into, and a frame read mid-paint comes out torn, with + /// regions still cleared to white. + private void keepPresentedFrame() { + com.codename1.impl.javase.record.SimulatorRecorder rec = simulatorRecorder; + if (rec == null || !rec.isRecording() || edtBuffer == null) { + if (presented != null) { + synchronized (bufferLock) { + presented = null; + } + } + return; + } + synchronized (bufferLock) { + if (presented == null || presented.getWidth() != edtBuffer.getWidth() + || presented.getHeight() != edtBuffer.getHeight()) { + presented = new BufferedImage(edtBuffer.getWidth(), edtBuffer.getHeight(), + BufferedImage.TYPE_INT_RGB); + } + java.awt.Graphics g = presented.getGraphics(); + g.drawImage(edtBuffer, 0, 0, null); + g.dispose(); + } + } + + /// A copy of the last complete frame for the recorder, or null when no + /// frame has been kept yet. + BufferedImage presentedFrame() { + synchronized (bufferLock) { + if (presented == null) { + return null; + } + BufferedImage out = new BufferedImage(presented.getWidth(), presented.getHeight(), + BufferedImage.TYPE_INT_RGB); + java.awt.Graphics g = out.getGraphics(); + g.drawImage(presented, 0, 0, null); + g.dispose(); + return out; + } + } + /** * Snapshot of what this canvas last painted, used by the windowed screenshot * tests: the ordinary screenshot path can only see the primary surface. @@ -4379,6 +4425,7 @@ public void blit() { if(menuDisplayed){ return; } + keepPresentedFrame(); long blitStartNanos = BLIT_TRACE ? System.nanoTime() : 0; long bufferDoneNanos = 0; @@ -12844,7 +12891,10 @@ BufferedImage captureSimulatorFrame(com.codename1.impl.javase.record.RecordingOp if (canvas == null) { return null; } - BufferedImage buf = canvas.buffer; + BufferedImage buf = canvas.presentedFrame(); + if (buf == null) { + buf = canvas.buffer; + } Rectangle sc = getScreenCoordinates(); if (buf == null) { return null; diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index b030acdbae1..0681ec137e4 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1846,7 +1846,10 @@ - (void)session:(WCSession *)session didReceiveUserInfo:(NSDictionary *)info attempt:(int)a } #else - (void)applyMirroredSurface:(NSDictionary *)info { - // Only the watch consumes a mirror. Reaching here on the phone means the payload came back - // the way it went, which nothing sends. + // Only a watch with widgets consumes a mirror. Reaching here on the phone means the payload + // came back the way it went, which nothing sends; on a watch without widgets there is no + // complication to refresh. (void)info; } #endif diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index 7b80ec6a604..8dcb97d93ea 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -222,9 +222,12 @@ void cn1_watch_runtime_markJavaReady(void) { cn1WatchReplayPendingPhase(); // ...and a complication tap that arrived before the VM did. A tap on a terminated watch app // launches it WITH the URL, and SwiftUI delivers onOpenURL as soon as the scene exists -- - // which is before this. Same readiness, same drain point. + // which is before this. Same readiness, same drain point. Only an app with widgets + // (CN1_USE_WIDGETS) has complications to tap, or the surfaces natives that define the drain. +#ifdef CN1_USE_WIDGETS extern void cn1_watch_surface_drainPending(void); cn1_watch_surface_drainPending(); +#endif } /// Whether the Java lifecycle callback has run, for the surface-URL path in IOSNative.m. An int diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 34a5e5eca49..d58949b403b 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -355,6 +355,14 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, #undef CN1_INCLUDE_OIDC #endif +// A watch slice compiles these sources with the iOS app's switches too, and +// watchOS has no CoreNFC either: an app that read NFC tags and carried a watch +// companion failed the watch target on the CoreNFC import. Off, the natives +// answer "unsupported", which is the truth on a watch. +#if TARGET_OS_WATCH +#undef CN1_INCLUDE_NFC +#endif + // CN1_INCLUDE_NEARBY gates the com.codename1.nearby native bridge // (CN1Nearby.{h,m}: Nearby Interaction ranging, MultipeerConnectivity // transport and AccessorySetupKit association). IPhoneBuilder uncomments this diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 15e2f3175b6..775041e59f1 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3305,6 +3305,29 @@ +(CGAffineTransform) currentMutableTransform { return currentMutableTransform; } +#if !TARGET_OS_OSX && !TARGET_OS_WATCH +// Outside the size-publishing branches below: viewSafeAreaInsetsDidChange calls +// it on every UIKit slice, Catalyst and tvOS included, and those take the +// other branch. +// Reads the window's safe-area insets into the globals the Java side asks for. +// Answers whether any of them changed. +- (BOOL)cn1ReadSafeAreaInsets { + UIEdgeInsets in = self.view.window.safeAreaInsets; + JAVA_INT l = (JAVA_INT)in.left * scaleValue; + JAVA_INT r = (JAVA_INT)in.right * scaleValue; + JAVA_INT t = (JAVA_INT)in.top * scaleValue; + JAVA_INT b = (JAVA_INT)in.bottom * scaleValue; + if (l == safeLeft && r == safeRight && t == safeTop && b == safeBottom) { + return NO; + } + safeLeft = l; + safeRight = r; + safeTop = t; + safeBottom = b; + return YES; +} +#endif + #if defined(CN1_USE_METAL) && (TARGET_OS_MACCATALYST || TARGET_OS_TV) // On Mac Catalyst the iOS XIB never compiles (IBAgent-macOS-UIKit crashes // on it under Xcode 26) and on tvOS the iOS XIBs are excluded from the bundle @@ -3402,24 +3425,6 @@ - (void)viewDidLayoutSubviews { // never leaves landscape, so when the user stays in landscape its bounds never // change and no layout pass runs to correct the published size. viewDidAppear // and the transition's completion therefore re-read the real bounds as well. -// Reads the window's safe-area insets into the globals the Java side asks for. -// Answers whether any of them changed. -- (BOOL)cn1ReadSafeAreaInsets { - UIEdgeInsets in = self.view.window.safeAreaInsets; - JAVA_INT l = (JAVA_INT)in.left * scaleValue; - JAVA_INT r = (JAVA_INT)in.right * scaleValue; - JAVA_INT t = (JAVA_INT)in.top * scaleValue; - JAVA_INT b = (JAVA_INT)in.bottom * scaleValue; - if (l == safeLeft && r == safeRight && t == safeTop && b == safeBottom) { - return NO; - } - safeLeft = l; - safeRight = r; - safeTop = t; - safeBottom = b; - return YES; -} - - (void)cn1PublishViewSizeIfChanged { // The same reason viewWillTransitionToSize: gives: iOS lays the view out // at the opposite orientation for its background snapshot. diff --git a/docs/demos/common/src/main/snippets/developer-guide/dash.sh b/docs/demos/common/src/main/snippets/developer-guide/dash.sh new file mode 100644 index 00000000000..c61374923d5 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/dash.sh @@ -0,0 +1,19 @@ +// Snippets of the Dash chapter of the developer guide. Edit them here, not inline. + +// tag::dash-sh-server[] +CN1_PROFILE=dev backend/server.sh run +// end::dash-sh-server[] + +// tag::dash-sh-simulator[] +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::dash-sh-simulator[] + +// tag::dash-sh-ios[] +mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false +// end::dash-sh-ios[] + +// tag::dash-sh-tests[] +mvn -pl rules test +mvn -pl rules,shared,backend -Dcodename1.platform=backend test +./run-e2e.sh +// end::dash-sh-tests[] diff --git a/docs/developer-guide/Dash-Implementors-Guide.asciidoc b/docs/developer-guide/Dash-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..1e4e84c621e --- /dev/null +++ b/docs/developer-guide/Dash-Implementors-Guide.asciidoc @@ -0,0 +1,443 @@ +[[dash-implementors-guide]] +== Dash: Making the runner your own + +Dash is an endless runner built around the things a game engine leaves to you. +Friends race on the same track at the same moment and talk while they run, their +voices going peer to peer over WebRTC (<>). A player picks two points on a +real street map and runs that route through their own city, the street names overhead +and the landmarks beside the track (<>). And every score on a board is a run +the server played again from its inputs, by the same Java rules module the game runs. +In other game frameworks each of those is a plugin, a second language or a separate +backend. Here they're ordinary Codename One code in one project, beside the levels +that are data, the camera that swings from side on into 3D halfway down a track, and +the console that runs the game. + +Dash shares the module layout, the accounts and sign-in, push notifications and the +operations console with <>. This chapter shows +where each part lives and what to change to make the game yours. + +[[dash-launch]] +=== Running it + +The project is in `scripts/dash`, and the Initializr generates it under your own +package and name. Start the server on the development profile, which keeps its +database in memory, seeds the demo accounts and simulates purchases and push +notifications: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/dash.sh[tag=dash-sh-server,indent=0] +---- + +Then run the app in the simulator: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/dash.sh[tag=dash-sh-simulator,indent=0] +---- + +The demo accounts are `player@`, `rival@`, `trouble@` and `admin@dash.example`, each +with the password `dash-demo`. For the native iOS app, generate the Xcode project on a +Mac and open it: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/dash.sh[tag=dash-sh-ios,indent=0] +---- + +The tests run in three layers: the rules on their own, the server with whole races +over its rooms, and the app in the simulator against a real server: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/dash.sh[tag=dash-sh-tests,indent=0] +---- + +[[dash-what-it-does]] +=== What the sample does + +A new player is taken straight into *First Steps*, a short level in which a coach +introduces one move at a time: jump the hurdle, slide under the barrier, change lanes +around the hauler. On a touch screen four buttons sit under the thumbs -- left +with slide above it in one corner, right with jump above it in the other -- drawn by the framework's +`TouchControls`; swipes and taps work anywhere else, and the settings can hide the +buttons. On a desktop the keyboard (arrows, WASD and space) drives the same four +inputs. + +image::img/dash-tutorial-coach.png["First Steps: the coach calls out the next move above the track",scaledwidth=30%] +image::img/dash-tutorial-first-visit.png["The first visit: First Steps opens before anything else",scaledwidth=30%] +image::img/dash-tutorial-result.png["First Steps finished: the coach's last word and the first reward",scaledwidth=30%] + +The home screen leads to the *Levels*. Each official level shows its stars and has +three buttons: *Run* plays this week's track for it, *Race the best* runs it against +the ghost of the week's best run, and *Scores* opens its boards -- all time, this +week, today, friends, and one per crew. Any row on a board opens that run's replay, +with a scrubber, a pause, speeds, and a choice of whom the camera follows. + +image::img/dash-home.png["The home screen: level and currencies, missions, daily run, trophies and replays, the runner on stage, the spotlight banner, chest slots and the PLAY button",scaledwidth=30%] +image::img/dash-run-side.png["The Neon Causeway side on: the runner jumps a hurdle on the 2D opening",scaledwidth=30%] +image::img/dash-run-chase.png["The same level after the camera swing: the chase view over three lanes, coins and a barrier ahead",scaledwidth=30%] +image::img/dash-run-reward.png["The reward card after a run: experience, a chest and an achievement",scaledwidth=30%] +image::img/dash-levels.png["The levels: stars per level and its Run, Race the best and Scores buttons",scaledwidth=30%] +image::img/dash-run-result.png["The result of a run: distance, points, coins and stars",scaledwidth=30%] +image::img/dash-reward-levelup.png["A level up on the reward cards",scaledwidth=30%] +image::img/dash-scores.png["A level board: the top three framed, each row with points, distance, coins and stars",scaledwidth=30%] + +*Quick race* drops the player into a race with whoever is waiting for that level, and +fills the empty lanes with bots after a few seconds. The *Race friends* button opens a party with +a code and a link to share. Everyone who joins can talk: the microphone button +connects each pair of players directly, and a muted player shows as muted to the +rest. When the host starts, every phone runs the same seed in lockstep, the other +runners appear in their own lanes, and the result screen ranks the places and pays +out trophies and coins. The *Daily dash* is one endless track a day, the same for +everyone, with its own board. + +image::img/dash-party-chat.png["A party lobby: the code and link to share, the runners in it, and chat bubbles between them",scaledwidth=30%] +image::img/dash-race.png["A quick race against the waiting runners and bots",scaledwidth=30%] +image::img/dash-party-race.png["A race in progress: the friend in the next lane, live standings and emotes on screen",scaledwidth=30%] + +*Track editor* is where players make levels. A pack holds levels and the pieces they +use, and is published for others to play, rate, race, and remix. Every level in a pack +has its own board. + +image::img/dash-forge-home.png["The track editor's home: your packs and the official one to remix",scaledwidth=30%] +image::img/dash-forge-pack.png["The official pack: each level with its play, Race friends and Scores buttons",scaledwidth=30%] +image::img/dash-forge-editor.png["Editing a pack: its levels, its own obstacles and pickups, save and publish",scaledwidth=30%] +image::img/dash-forge-level-editor.png["The level editor: a strip of lanes over distance with obstacles placed, and the palette of pieces and camera cues",scaledwidth=30%] +image::img/dash-forge-playtest.png["A playtest of the draft, run on the same rules as a published level",scaledwidth=30%] + +The rest -- crews, friends and invitations, the shop, trophies, the runner choice and +the settings -- is the same kind of screen as the rest of the app, styled by one CSS +file. + +image::img/dash-signin.png["Signing in",scaledwidth=30%] +image::img/dash-register-name.png["Choosing the name other runners see",scaledwidth=30%] +image::img/dash-friends.png["Friends, requests and invitations",scaledwidth=30%] +image::img/dash-shop.png["The shop: gem packs, the season pass, runners and trails",scaledwidth=30%] +image::img/dash-runner.png["Choosing and dressing the runner",scaledwidth=30%] +image::img/dash-settings.png["The settings",scaledwidth=30%] + +[[dash-modules]] +=== The modules + +[cols="1,3"] +|=== +| Module | What it holds + +| `rules` | The deterministic simulation: tracks, runners, obstacles, pickups, camera + cues, the bot, replays and level packs. No floating point, no clock, no + `java.util.Random`. +| `shared` | The data transfer objects and `@RestClient` interfaces. +| `common` | The app: the 3D track, the menus, the track editor, the race lobby and + voice, the store, the admin console. +| `backend` | The server: accounts, re-simulated scores and leaderboards, the + lockstep race rooms, level packs and moderation, progression, receipts, push. +|=== + +[[dash-rules]] +=== One simulation, two cameras + +A level is a `LevelDef`: a number of lanes, a speed curve, a length or a goal, the +obstacles and pickups placed along it, a procedural tail built from chunks that get +harder with distance, the coach's lines, and *camera cues*. Here is the official level +that starts as a side-scroller and becomes a 3D runner: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Content.java[tag=dash-causeway,indent=0] +---- + +The cues never reach the simulation. A runner always has a lane, a height and a +distance, and a hurdle is cleared the same way whether the player sees it from the +side or from behind. The camera is the renderer's business: `RunView` reads the cue +under the runner, eases between the two framings, and leaves the rules alone. Because +of that, a level can change its view anywhere without changing who wins. + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java[tag=dash-camera-blend,indent=0] +---- + +The view runs the rules on a fixed tick of 60 per second, whatever the frame rate, +and draws between ticks by interpolating. A slow phone drops frames, never ticks, so +the run stays the same run: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java[tag=dash-fixed-tick,indent=0] +---- + +Gestures and keys become the four input bits in one place, and the same bits reach +every kind of run -- solo, race, replay and playtest -- through a `Driver`: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java[tag=dash-controls,indent=0] +---- + +[[dash-referee]] +=== The server is the referee + +A run is never reported as a number. When it ends, the app sends the seed and the +input log -- a few hundred bytes for a long run -- and the server builds the same +level, plays the log through the same rules, and reads the score off its own +simulation. A score that was typed in, or a log edited to dodge an obstacle the +player hit, simply doesn't reproduce: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Forge.java[tag=dash-forge-result,indent=0] +---- + +The verified run is then entered on every board it qualifies for. The endless +stretch of a level is built from the seed, so no board takes a seed the app picked +for itself: `all` takes only the seeds the server hands out, and `week` and `day` +count only the board's own seed, so everyone is running the same track there: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Scores.java[tag=dash-scores,indent=0] +---- + +A better score on the `all` board tells the friends whose best it just passed, and +only them, with a push that opens that level's board: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Scores.java[tag=dash-beaten-push,indent=0] +---- + +[[dash-races]] +=== Races in lockstep + +A race is run by the server in rooms. Every phone sends its inputs as they happen; +the room collects them per tick, steps its own copy of the run, and broadcasts each +tick's inputs to every seat. A phone only advances when it has the server's tick, so +they all see the same race, and the server's copy is the one that decides the +places: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java[tag=dash-race-room,indent=0] +---- + +On the phone, `RaceDriver` is the other half. It also sends a hash of its state every +second. A phone whose hash disagrees with the server's is sent the race's log to catch +up, and one that keeps disagreeing is flagged for a moderator -- a modified client +can't stay in a race unnoticed: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RaceDriver.java[tag=dash-lockstep,indent=0] +---- + +Voice is peer to peer. The game's own socket carries the offers, answers and ICE +candidates, and Codename One's WebRTC API connects each pair of players in the party +directly; the server never carries audio: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Voice.java[tag=dash-voice,indent=0] +---- + +A mesh suits a party of four. For larger groups, put a media server (an SFU) behind +the same signalling messages; the client code above doesn't change. + +[[dash-editor]] +=== The track editor + +The editor draws the level as a strip: distance runs left to right, one row per lane, +an all-lanes row on top, and the camera cues as flags along the bottom. Tapping a cell +puts the selected piece there: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/TrackTimeline.java[tag=dash-timeline,indent=0] +---- + +The palette is every piece the pack can place -- the game's own and the pack's custom +obstacles and pickups -- plus one tool per camera mode and an eraser: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/LevelEditor.java[tag=dash-level-palette,indent=0] +---- + +*Playtest* runs the draft as a player would, on the same rules, and is never +submitted. Publishing runs the validator the server runs too, and that includes +sending the bot down the level: a level that the bot can't finish can't be +published, so every published level can be beaten. + +[[dash-city-runs]] +=== City runs + +*Run your city* on the levels screen opens a street map. The player taps a start +and a finish, a routing service finds the way between them, and the route becomes +a level: every street a stretch of track, every turn a corner taken on the side it +turns to, with the next street's name called out before it. What lies between the +corners is drawn from the game's own chunks, harder as the run goes on. + +The map is Codename One's `MapView`, which draws OpenStreetMap data itself on every +platform, and the route comes from `Routing`. `CityForm` reduces the route to +streets and turns (`CityForm.toRoute`), reading each turn off the geometry where +two streets meet, and hands the result to the rules as a `CityRoute`: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/CityRouteTest.java[tag=dash-city-route,indent=0] +---- + +`level()` is deterministic, so the server needs no map. A city run's result +carries the encoded route next to its replay; the server builds the same level +from it and plays the replay on it as it would any other. The level's id is a hash +of the route, so each route has a board of its own, and editing a route into an +easier one names a different level with a different board. Since any edit makes a +new level, a city run's coins and experience both come out of the community +levels' daily allowance; a new route never pays beyond it. + +==== Street names and landmarks + +The run follows the map, not just its turns. The street the runner is on sits under +the score and changes at every corner, a green street sign spans the track where a +new street begins, and the places along the way -- churches, museums, stations, +parks, monuments -- stand beside the track with their names on a board as the +runner passes, and the coach calls them out ("Passing Guildhall."). The small map in +the corner shows how far along the route the runner is. + +The places come from OpenStreetMap through the Overpass API: `Landmarks` asks for +named tourist, historic, religious, station and park features within 70 meters of +the route, projects each onto the line, keeps them at least 60 meters apart and at +most twelve, and adds them to the `CityRoute`. They're part of the route the server +receives, so they're part of the level and its hash, and the server places the +same boards: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/CityRouteTest.java[tag=dash-city-landmark,indent=0] +---- + +The public Overpass server is shared and rate limited. A game with players points +`Landmarks.setSource` at an Overpass instance of its own, or at any other source of +places: the interface is one method that takes the route's path. + +image::img/dash-city-route.png["A route picked on the street map, with the landmarks along it pinned",scaledwidth=30%] +image::img/dash-city-run.png["The run down Cannon Street: the street name under the score and the route map",scaledwidth=30%] +image::img/dash-city-result.png["The result of a city run, with the route it followed",scaledwidth=30%] +image::img/dash-city-landmark.png["A landmark's board beside the track as the runner passes it",scaledwidth=30%] + +Before you ship this, point `Routing` at a routing server of your own: +`Routing.setService(new OsrmRouteService("https://osrm.example.com"))`. The +default is OSRM's public demo server, which is rate limited, has no service +guarantee and serves only its car profile, whatever travel mode the request +asks for. The map data is OpenStreetMap's, whose license +asks for credit wherever it's shown; the city screen carries that line, and a +reskin has to keep it. + +[[dash-engagement]] +=== Engagement and live operations + +Players come back for what changes: three quests a day, picked per player from +`Goals` (run 500 meters, collect 100 coins, win a race...), a daily dash with one +track for everyone and its own board, chests that open over time, a level and +trophies to climb, achievements, and boards that reset every week. Rivals already +on the boards from the first run keep a new player company: on a development server +`DemoAccounts` seeds a field of runners whose scores are the rules' bot playing each +week's track, submitted and verified like anyone's. + +The operator's side is in the admin console: + +* *Reports* chart players, runs, races, purchases and invitations over time. +* *Query* finds players by what the game knows about them -- days away, races, + purchases, invitations, trophies -- with conditions such as `daysAway >= 7`. +* *Pipelines* turn a query into an action that runs every hour: a push to players + away for a week that opens the shop, 20 gems to a player who reached 300 trophies + and never bought anything. Each player is acted on once per cool-down, and a push + carries the campaign `pipeline-`, so its opens show in Codename One + analytics. +* *Promote* sends a push to a segment (*broadcast*) and sets the banner on every + player's home screen (*spotlight*), which can open a level, the shop or the + editor. + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Pipelines.java[tag=dash-pipelines-schedule,indent=0] +---- + +On a phone the console is a list of pages; on a desktop or a tablet it's a sidebar +and a page, and the *Promote* tab is shown: + +image::img/dash-admin-overview.png["The admin console on a phone: the live numbers and the pages it leads to",scaledwidth=30%] +image::img/dash-admin-player.png["A flagged player: account, record, wallet, the flag raised automatically and the actions an admin can take",scaledwidth=30%] +image::img/dash-admin-flags.png["Flagged runs waiting for a decision",scaledwidth=30%] +image::img/dash-admin-promote.png["Promote on a phone: a broadcast push to a segment and the home spotlight",scaledwidth=30%] +image::img/dash-desktop-admin-overview.png["The admin console on a desktop: the sidebar and the overview page",scaledwidth=70%] +image::img/dash-desktop-admin-reports.png["Reports: players, runs and purchases over time",scaledwidth=70%] +image::img/dash-desktop-admin-query.png["The player query: conditions over what the game knows about each player",scaledwidth=70%] +image::img/dash-desktop-admin-pipelines.png["Engagement pipelines with their audiences, actions and cool-downs",scaledwidth=70%] +image::img/dash-desktop-admin-pipeline-edit.png["Editing a pipeline: the query, the push or gift, and the cool-down",scaledwidth=70%] +image::img/dash-desktop-admin-broadcast.png["A broadcast push to a segment of players",scaledwidth=70%] +image::img/dash-desktop-admin-promote.png["Promote on a desktop: broadcast, spotlight, a campaign link and how many invited players went on to race",scaledwidth=70%] +image::img/dash-desktop-admin-broadcast-confirm.png["Confirming a broadcast before it's sent",scaledwidth=70%] +image::img/dash-desktop-admin-spotlight.png["Setting the home screen's spotlight banner",scaledwidth=70%] +image::img/dash-desktop-home-spotlight.png["The banner as players see it on the home screen",scaledwidth=70%] + +[[dash-moderation]] +=== Moderation + +Published packs, reports and automatic flags -- a run that doesn't re-simulate, a +race hash that disagrees -- land in the admin console. A moderator can hide, restore +or promote a pack, look at its files, and act on a player: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Moderation.java[tag=dash-moderation,indent=0] +---- + +The console also lists every race being run, and any of them can be watched live +from the same lobby a player uses to spectate. + +[[dash-reskin]] +=== Making it yours + +* *Levels*: `Content` in `rules` holds the official levels and the chunks the + procedural tails are built from. Change one and run `mvn -pl rules test`: the + golden replays will tell you which recorded runs no longer end the same way, and + the bot test whether every level can still be finished. Bump `RulesVersion.ID` + when you change the rules themselves, so old replays aren't judged by new rules. +* *Obstacles and pickups*: add a definition in `Content.catalog()`, a colour or a + model in `RunView`, and the editor offers it at once. +* *Runners*: there are no model files. `Rig` builds every runner from capsules, + rounded boxes and glowing trim and poses each joint in code, and `Portraits` paints the same figure flat + for the menus. Both take their colours from an `Outfit`, so a new runner is one + line: ++ +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Outfit.java[tag=dash-outfits,indent=0] +---- +* *The track*: `Theme` holds each level theme's sky, light, surface, rails and + skyline, and paints the textures it needs at startup. +* *The look*: every colour is a variable at the top of `theme.css`: ++ +[source,css] +---- +include::../../scripts/dash/common/src/main/css/theme.css[tag=dash-palette,indent=0] +---- +* *Store, ads and push*: these work as they do in Skirmish. The product ids are in + `AppConfig`, the receipt validator is chosen in `Services`, and the push key is + read the same way: ++ +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/common/src/main/java/com/codenameone/examples/dash/AppConfig.java[tag=dash-monetization-ids,indent=0] +---- ++ +[source,java,role=cn1-no-try] +---- +include::../../scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Services.java[tag=dash-receipt-choice,indent=0] +---- + +The engagement tools -- campaign links, invitations, the home screen spotlight, +pushes to an audience and scheduled pipelines -- are the ones +<> walks through step by step, and +they work in Dash unchanged. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index a678e0f80b5..15b2cfed12d 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -158,6 +158,8 @@ include::Savor-Implementors-Guide.asciidoc[] include::Skirmish-Implementors-Guide.asciidoc[] +include::Dash-Implementors-Guide.asciidoc[] + include::Parley-Users-Guide.asciidoc[] include::Parley-Implementors-Guide.asciidoc[] diff --git a/docs/developer-guide/img/dash-admin-flags.png b/docs/developer-guide/img/dash-admin-flags.png new file mode 100644 index 00000000000..a81657125a0 Binary files /dev/null and b/docs/developer-guide/img/dash-admin-flags.png differ diff --git a/docs/developer-guide/img/dash-admin-overview.png b/docs/developer-guide/img/dash-admin-overview.png new file mode 100644 index 00000000000..96cd58a11d4 Binary files /dev/null and b/docs/developer-guide/img/dash-admin-overview.png differ diff --git a/docs/developer-guide/img/dash-admin-player.png b/docs/developer-guide/img/dash-admin-player.png new file mode 100644 index 00000000000..4b839ae9fd6 Binary files /dev/null and b/docs/developer-guide/img/dash-admin-player.png differ diff --git a/docs/developer-guide/img/dash-admin-promote.png b/docs/developer-guide/img/dash-admin-promote.png new file mode 100644 index 00000000000..0afdb95764e Binary files /dev/null and b/docs/developer-guide/img/dash-admin-promote.png differ diff --git a/docs/developer-guide/img/dash-city-landmark.png b/docs/developer-guide/img/dash-city-landmark.png new file mode 100644 index 00000000000..d2d71d33392 Binary files /dev/null and b/docs/developer-guide/img/dash-city-landmark.png differ diff --git a/docs/developer-guide/img/dash-city-result.png b/docs/developer-guide/img/dash-city-result.png new file mode 100644 index 00000000000..f6564f63f6c Binary files /dev/null and b/docs/developer-guide/img/dash-city-result.png differ diff --git a/docs/developer-guide/img/dash-city-route.png b/docs/developer-guide/img/dash-city-route.png new file mode 100644 index 00000000000..8d1d96e4698 Binary files /dev/null and b/docs/developer-guide/img/dash-city-route.png differ diff --git a/docs/developer-guide/img/dash-city-run.png b/docs/developer-guide/img/dash-city-run.png new file mode 100644 index 00000000000..756d8a464e7 Binary files /dev/null and b/docs/developer-guide/img/dash-city-run.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-broadcast-confirm.png b/docs/developer-guide/img/dash-desktop-admin-broadcast-confirm.png new file mode 100644 index 00000000000..f5c80706a93 Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-broadcast-confirm.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-broadcast.png b/docs/developer-guide/img/dash-desktop-admin-broadcast.png new file mode 100644 index 00000000000..c2d651e6e9a Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-broadcast.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-overview.png b/docs/developer-guide/img/dash-desktop-admin-overview.png new file mode 100644 index 00000000000..e746c595e6e Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-overview.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-pipeline-edit.png b/docs/developer-guide/img/dash-desktop-admin-pipeline-edit.png new file mode 100644 index 00000000000..7e17085cfda Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-pipeline-edit.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-pipelines.png b/docs/developer-guide/img/dash-desktop-admin-pipelines.png new file mode 100644 index 00000000000..ada4a76ee59 Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-pipelines.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-promote.png b/docs/developer-guide/img/dash-desktop-admin-promote.png new file mode 100644 index 00000000000..fc886011ccc Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-promote.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-query.png b/docs/developer-guide/img/dash-desktop-admin-query.png new file mode 100644 index 00000000000..2c454653720 Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-query.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-reports.png b/docs/developer-guide/img/dash-desktop-admin-reports.png new file mode 100644 index 00000000000..e746c595e6e Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-reports.png differ diff --git a/docs/developer-guide/img/dash-desktop-admin-spotlight.png b/docs/developer-guide/img/dash-desktop-admin-spotlight.png new file mode 100644 index 00000000000..ee0ecd8c99d Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-admin-spotlight.png differ diff --git a/docs/developer-guide/img/dash-desktop-home-spotlight.png b/docs/developer-guide/img/dash-desktop-home-spotlight.png new file mode 100644 index 00000000000..0999f89cdf8 Binary files /dev/null and b/docs/developer-guide/img/dash-desktop-home-spotlight.png differ diff --git a/docs/developer-guide/img/dash-forge-editor.png b/docs/developer-guide/img/dash-forge-editor.png new file mode 100644 index 00000000000..3a453af843d Binary files /dev/null and b/docs/developer-guide/img/dash-forge-editor.png differ diff --git a/docs/developer-guide/img/dash-forge-home.png b/docs/developer-guide/img/dash-forge-home.png new file mode 100644 index 00000000000..59f9d167c78 Binary files /dev/null and b/docs/developer-guide/img/dash-forge-home.png differ diff --git a/docs/developer-guide/img/dash-forge-level-editor.png b/docs/developer-guide/img/dash-forge-level-editor.png new file mode 100644 index 00000000000..0941b4106a2 Binary files /dev/null and b/docs/developer-guide/img/dash-forge-level-editor.png differ diff --git a/docs/developer-guide/img/dash-forge-pack.png b/docs/developer-guide/img/dash-forge-pack.png new file mode 100644 index 00000000000..a28b62dd33c Binary files /dev/null and b/docs/developer-guide/img/dash-forge-pack.png differ diff --git a/docs/developer-guide/img/dash-forge-playtest.png b/docs/developer-guide/img/dash-forge-playtest.png new file mode 100644 index 00000000000..b657043ff70 Binary files /dev/null and b/docs/developer-guide/img/dash-forge-playtest.png differ diff --git a/docs/developer-guide/img/dash-friends.png b/docs/developer-guide/img/dash-friends.png new file mode 100644 index 00000000000..98cdb2e2cfb Binary files /dev/null and b/docs/developer-guide/img/dash-friends.png differ diff --git a/docs/developer-guide/img/dash-home.png b/docs/developer-guide/img/dash-home.png new file mode 100644 index 00000000000..ce45a3a3470 Binary files /dev/null and b/docs/developer-guide/img/dash-home.png differ diff --git a/docs/developer-guide/img/dash-levels.png b/docs/developer-guide/img/dash-levels.png new file mode 100644 index 00000000000..1958df3bb74 Binary files /dev/null and b/docs/developer-guide/img/dash-levels.png differ diff --git a/docs/developer-guide/img/dash-party-chat.png b/docs/developer-guide/img/dash-party-chat.png new file mode 100644 index 00000000000..0be173b252c Binary files /dev/null and b/docs/developer-guide/img/dash-party-chat.png differ diff --git a/docs/developer-guide/img/dash-party-race.png b/docs/developer-guide/img/dash-party-race.png new file mode 100644 index 00000000000..823911ae5af Binary files /dev/null and b/docs/developer-guide/img/dash-party-race.png differ diff --git a/docs/developer-guide/img/dash-race.png b/docs/developer-guide/img/dash-race.png new file mode 100644 index 00000000000..1e2314bb778 Binary files /dev/null and b/docs/developer-guide/img/dash-race.png differ diff --git a/docs/developer-guide/img/dash-register-name.png b/docs/developer-guide/img/dash-register-name.png new file mode 100644 index 00000000000..c57092884bf Binary files /dev/null and b/docs/developer-guide/img/dash-register-name.png differ diff --git a/docs/developer-guide/img/dash-reward-levelup.png b/docs/developer-guide/img/dash-reward-levelup.png new file mode 100644 index 00000000000..58985af737e Binary files /dev/null and b/docs/developer-guide/img/dash-reward-levelup.png differ diff --git a/docs/developer-guide/img/dash-run-chase.png b/docs/developer-guide/img/dash-run-chase.png new file mode 100644 index 00000000000..49a1a37a9f8 Binary files /dev/null and b/docs/developer-guide/img/dash-run-chase.png differ diff --git a/docs/developer-guide/img/dash-run-result.png b/docs/developer-guide/img/dash-run-result.png new file mode 100644 index 00000000000..7eaba1754f7 Binary files /dev/null and b/docs/developer-guide/img/dash-run-result.png differ diff --git a/docs/developer-guide/img/dash-run-reward.png b/docs/developer-guide/img/dash-run-reward.png new file mode 100644 index 00000000000..b0741a93e38 Binary files /dev/null and b/docs/developer-guide/img/dash-run-reward.png differ diff --git a/docs/developer-guide/img/dash-run-side.png b/docs/developer-guide/img/dash-run-side.png new file mode 100644 index 00000000000..220c45407e7 Binary files /dev/null and b/docs/developer-guide/img/dash-run-side.png differ diff --git a/docs/developer-guide/img/dash-runner.png b/docs/developer-guide/img/dash-runner.png new file mode 100644 index 00000000000..5aeec978182 Binary files /dev/null and b/docs/developer-guide/img/dash-runner.png differ diff --git a/docs/developer-guide/img/dash-scores.png b/docs/developer-guide/img/dash-scores.png new file mode 100644 index 00000000000..53c80a2d514 Binary files /dev/null and b/docs/developer-guide/img/dash-scores.png differ diff --git a/docs/developer-guide/img/dash-settings.png b/docs/developer-guide/img/dash-settings.png new file mode 100644 index 00000000000..1936eca4042 Binary files /dev/null and b/docs/developer-guide/img/dash-settings.png differ diff --git a/docs/developer-guide/img/dash-shop.png b/docs/developer-guide/img/dash-shop.png new file mode 100644 index 00000000000..1f5713518d1 Binary files /dev/null and b/docs/developer-guide/img/dash-shop.png differ diff --git a/docs/developer-guide/img/dash-signin.png b/docs/developer-guide/img/dash-signin.png new file mode 100644 index 00000000000..fadd68734e2 Binary files /dev/null and b/docs/developer-guide/img/dash-signin.png differ diff --git a/docs/developer-guide/img/dash-tutorial-coach.png b/docs/developer-guide/img/dash-tutorial-coach.png new file mode 100644 index 00000000000..d2d0adaddf3 Binary files /dev/null and b/docs/developer-guide/img/dash-tutorial-coach.png differ diff --git a/docs/developer-guide/img/dash-tutorial-first-visit.png b/docs/developer-guide/img/dash-tutorial-first-visit.png new file mode 100644 index 00000000000..99002c79b38 Binary files /dev/null and b/docs/developer-guide/img/dash-tutorial-first-visit.png differ diff --git a/docs/developer-guide/img/dash-tutorial-result.png b/docs/developer-guide/img/dash-tutorial-result.png new file mode 100644 index 00000000000..3f7edb168f2 Binary files /dev/null and b/docs/developer-guide/img/dash-tutorial-result.png differ diff --git a/docs/website/content/blog/build-an-endless-runner-in-java.md b/docs/website/content/blog/build-an-endless-runner-in-java.md new file mode 100644 index 00000000000..1c245db75cc --- /dev/null +++ b/docs/website/content/blog/build-an-endless-runner-in-java.md @@ -0,0 +1,37 @@ +--- +title: "Dash: An Endless Runner Template in Java, With Races and a Level Editor" +slug: build-an-endless-runner-in-java +url: /blog/build-an-endless-runner-in-java/ +date: '2026-10-30' +author: Shai Almog +draft: true +description: "Dash is a full-stack endless runner in Java: levels that turn from 2D into 3D mid-run, leaderboards the server re-simulates, live races between friends with voice chat, a track editor, crews, in-app purchases and an admin console. Generate it from the Initializr and make it your own, royalty free." +--- + +Skirmish showed that most of a mobile game is app: the sign-in, the menus, the shop, the friends list, the pushes and the server. Dash is the second game template, and it is a different kind of game: an endless runner. A runner dashes down a track of lanes, jumps hurdles, slides under barriers and changes lanes around traffic. You can generate it from the [Initializr](/initializr/) today, under your own name and package, and the source is MIT-0. + +## A level that turns into 3D + +The trick I like most in Dash is that a level can start as a side-scroller and become a 3D runner halfway through. The first stretch of *Neon Causeway* looks like a flat platformer: one row of hurdles and barriers, seen from the side. At 300 meters the camera swings behind the runner, the lanes open up, and the rest is a chase-camera runner. + +There is no second game behind that. The simulation always has lanes, heights and distances, and a hurdle is cleared the same way from either angle. A level only carries *camera cues*, and the renderer blends between the two framings when the runner reaches one. Because the rules never see the camera, a level can change its view anywhere without changing who wins. + +## Leaderboards you can trust + +A run is never reported as a number. The app sends the seed and the input log, a few hundred bytes, and the server plays it through the same Java rules module the app runs. A typed-in score, or a log edited to dodge an obstacle the player hit, simply does not reproduce. The rules use no floating point and no clock, so the phone, the server and the replay viewer agree to the bit. + +Every level has all-time, weekly, daily, friends and crew boards. Beat a friend's best and they get a push that opens that level's board. + +## Racing your friends + +Open a party, share the link, and everyone runs the same track at the same moment. The server runs the race in lockstep: each phone sends its inputs, the room broadcasts each tick's inputs to every seat, and its own copy of the race decides the places. A client whose state drifts from the server's is caught by a hash it sends every second. + +While you race you can talk. Voice uses Codename One's WebRTC API, peer to peer between each pair of players, with the game's own socket carrying the signalling. The server never touches the audio. + +## Build your own levels + +The track editor is in the app, on every platform. A level is a strip you paint on: tap a lane to drop a hurdle or a coin, tap the top row for a barrier across every lane, and drop camera cues where the view should change. *Playtest* runs the draft on the real rules. Publishing requires the bot to finish the level, so nobody can publish one that cannot be beaten. Published packs can be rated, raced, remixed and moderated from the admin console. + +## Get it + +Choose **Dash** in the [Initializr](/initializr/), or read the [guide chapter](/developer-guide/dash-implementors-guide/) first. It walks through the rules, the camera, the referee, races, voice and the editor, and what to change to make the game yours. diff --git a/docs/website/content/dash.md b/docs/website/content/dash.md new file mode 100644 index 00000000000..02ffe4eebe2 --- /dev/null +++ b/docs/website/content/dash.md @@ -0,0 +1,65 @@ +--- +title: "Build an Endless Runner in Java with Dash" +description: "Dash is a 3D endless runner in Java: live races with WebRTC voice chat, runs through your own city on a real street map, leaderboards the server re-simulates, a track editor and an admin console. Free MIT-0 source." +url: "/dash/" +summary: "A full-stack runner: voice chat while racing friends, runs through your own city, and honest leaderboards." +images: ["/developer-guide/img/dash-run-chase.png"] +ShowToc: true +--- + +Dash is an endless runner that does what a game engine leaves to you. Race your friends and talk to them while you run: the voice goes peer to peer over WebRTC, from the same Java code on iPhone, Android, desktop and the web. Pick two points on a real street map and run that route through your own city, every street a stretch of track with its name overhead and the landmarks beside it. And every score on a board was played again on the server from the run's inputs, by the same rules module the game runs, so a cheated run never shows up. + +Those are the parts other game frameworks leave to plugins, a second language and a separate backend team. In Dash they're ordinary Codename One code in one project: the game, its server, its maps, its voice chat and its admin console. Around them is everything a live game needs: accounts, levels with stars, ghosts and replays, a track editor, crews, a shop and pushes. Use it to build your own game, or to see how a Codename One app and server share one codebase. + +{{< guide-block >}} + +{{< /guide-block >}} + +**[Generate your project](/initializr/)** · **[Read the guide](/developer-guide/dash-implementors-guide/)** · **[Browse the source](https://github.com/codenameone/CodenameOne/tree/master/scripts/dash)** + +In the Initializr, choose the **Dash** endless-runner template, set your application name and package, and generate the project. The application source is licensed under [MIT-0](https://github.com/codenameone/CodenameOne/blob/master/scripts/dash/LICENSE): you can modify it, use it commercially and keep your changes private without royalties. The runners, the track and the city are built in code: there are no third-party models or textures to license. + +## What comes with the application? + +| Part | Included workflows | +| --- | --- | +| The run | A 3D track drawn with `com.codename1.gaming` and runners built and animated in code, swipe, tap and keyboard controls, power-ups, particles, and camera cues that blend between a side view, a chase view and a top-down view | +| Shared rules | One deterministic Java module, with no floating point, that the app, the server, the replays and the tests all run | +| Leaderboards | All-time, weekly, daily, friends and crew boards for every level, holding only runs the server played again from their input logs | +| Races | Quick races and parties with share links, in lockstep over WebSocket, peer-to-peer voice chat through WebRTC, text chat, emotes, spectating and replays | +| City runs | Pick two points on a street map and run the route between them: every street a stretch of track with its name overhead, every turn a corner, and the landmarks along the way beside the track | +| Track editor | Levels painted on a timeline: obstacles, pickups, camera cues and the coach's lines, custom pieces, instant playtests, and publishing that requires the bot to finish the level | +| Social | Crews with their own boards and one-tap parties, invitations, and pushes when a friend beats your score | +| Monetization | Gem packs and outfits through the platform stores with server-side receipt checks, rewarded and interstitial ads | +| Engagement | Daily quests, a daily dash, weekly boards, chests, levels, trophies and achievements, and a seeded field of rivals on every board | +| Live operations | Reports, a player query, hourly engagement pipelines (pushes and gifts to a segment), broadcasts and the home-screen spotlight | +| Administration | Live races, the level moderation queue, automatic and reported flags, player lookup, and audited actions, as a phone app or a desktop console | + +## What does it look like? + +![The home screen](/developer-guide/img/dash-home.png) +![A run in the chase view](/developer-guide/img/dash-run-chase.png) +![A city run down a real street](/developer-guide/img/dash-city-run.png) +![A landmark beside the track](/developer-guide/img/dash-city-landmark.png) +![A party lobby with chat](/developer-guide/img/dash-party-chat.png) +![A live race](/developer-guide/img/dash-party-race.png) +![The level editor](/developer-guide/img/dash-forge-level-editor.png) +![A leaderboard](/developer-guide/img/dash-scores.png) +![The admin console on a desktop](/developer-guide/img/dash-desktop-admin-pipelines.png) + +## From first run to customization + +1. **[Run the project](https://github.com/codenameone/CodenameOne/tree/master/scripts/dash#running-it).** The development profile needs nothing installed: an in-memory database, demo accounts and a simulated store, so you can run, race the bots, or race a second simulator straight away. +2. **[Tour the game](/developer-guide/dash-implementors-guide/).** The guide opens with what the game does, from the first level to races and the editor. +3. **[Make it your own](/developer-guide/dash-implementors-guide/).** The same guide then covers levels and their camera, new obstacles and runners, re-skinning, store products, ads, push and moderation. + +## What does a live game still need? + +Dash supplies the game and its source. Publishing one also requires your own hosting, store products, ad units, a push key, and a TURN server for voice between players behind strict networks. + +## Looking for another kind of game? + +[Skirmish](/skirmish/) is the real-time card battler on the same architecture. diff --git a/docs/website/layouts/partials/cn1-flagship-samples.html b/docs/website/layouts/partials/cn1-flagship-samples.html index 2e5b7cfec6e..dd33062efd0 100644 --- a/docs/website/layouts/partials/cn1-flagship-samples.html +++ b/docs/website/layouts/partials/cn1-flagship-samples.html @@ -36,6 +36,14 @@ "images" (slice "/developer-guide/img/skirmish-home.png" "/developer-guide/img/skirmish-battle.png" "/developer-guide/img/skirmish-finale.png") "guide" "/developer-guide/skirmish-implementors-guide/" "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/skirmish") + (dict + "name" "Dash" + "kind" "Endless runner game" + "url" "/dash/" + "summary" "A 3D endless runner with a Java backend: race friends with WebRTC voice chat, run through your own city on a real map, and climb leaderboards the server re-simulates." + "images" (slice "/developer-guide/img/dash-party-chat.png" "/developer-guide/img/dash-city-run.png" "/developer-guide/img/dash-run-chase.png") + "guide" "/developer-guide/dash-implementors-guide/" + "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/dash") (dict "name" "Pulse" "kind" "Gym and fitness studio" diff --git a/docs/website/static/dash/dash-promo-poster.jpg b/docs/website/static/dash/dash-promo-poster.jpg new file mode 100644 index 00000000000..e32acc61af8 Binary files /dev/null and b/docs/website/static/dash/dash-promo-poster.jpg differ diff --git a/docs/website/static/dash/dash-promo.mp4 b/docs/website/static/dash/dash-promo.mp4 new file mode 100644 index 00000000000..d7f065e84d5 Binary files /dev/null and b/docs/website/static/dash/dash-promo.mp4 differ diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 1a5d4049a0d..afcd1ecb203 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -38,11 +38,11 @@ is_excluded() { local candidate="$1" # What Initializr puts into a generated project is the developer's, not # Codename One's GPLv2 + Classpath Exception source: these trees are released - # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE, scripts/pulse/LICENSE, + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/dash/LICENSE, scripts/parley/LICENSE, scripts/pulse/LICENSE, # scripts/bastion/LICENSE, scripts/reel/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|scripts/pulse/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/dash/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|scripts/pulse/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } diff --git a/scripts/ci/sample-server-tests-on-database.sh b/scripts/ci/sample-server-tests-on-database.sh index 2f74a3de814..fccfffd520c 100755 --- a/scripts/ci/sample-server-tests-on-database.sh +++ b/scripts/ci/sample-server-tests-on-database.sh @@ -93,7 +93,7 @@ print("\n".join([user, password, p.hostname or "localhost", str(p.port or 3306), cd "$SAMPLE" reset_schema -# A sample whose server depends on another reactor module (Skirmish's rules) +# A sample whose server depends on another reactor module (Skirmish and Dash rules) # builds it in the same run; -pl alone would resolve it from the repository. MODULES=shared,backend if [ -f rules/pom.xml ]; then diff --git a/scripts/dash/LICENSE b/scripts/dash/LICENSE new file mode 100644 index 00000000000..af5d69aaeb3 --- /dev/null +++ b/scripts/dash/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Skirmish sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/dash/README.adoc b/scripts/dash/README.adoc new file mode 100644 index 00000000000..046146b5154 --- /dev/null +++ b/scripts/dash/README.adoc @@ -0,0 +1,114 @@ += Dash + +An endless runner and its server, in one Maven project and one language. A runner +races down a track of lanes, jumping hurdles, sliding under barriers and changing +lanes around traffic, while the camera can start side on, like a 2D platformer, +and swing behind the runner into full 3D halfway through a level. Around the run +is what a live game needs: accounts, levels with stars, leaderboards for every +level (all time, this week, today, friends and crews), live races between friends +on the same track with voice chat, ghosts and replays, a track editor with +publishing and moderation, crews, a shop with in-app purchases and rewarded ads, +push notifications, and a console for running the game. + +The rules are one module, `rules`, that the app, the server and the tests all +compile. It uses no floating point and no clock, so a run plays out identically +on a phone, on the server and in a replay. That's what makes the leaderboards +honest: the app sends its input log, and the server plays it again. + +It's a sample of what Codename One does for games, and the project the +Initializr generates when you start from the endless-runner template. + +== Start here + +One chapter of the developer guide covers this project: + +* https://www.codenameone.com/developer-guide/#dash-implementors-guide[The Dash chapter] + (link:../../docs/developer-guide/Dash-Implementors-Guide.asciidoc[source]) + tours what the game does, with the demo accounts, then explains how it's + built and what to change to make it yours: levels and their camera, new + obstacles and pickups, runners, the look, store products, ads, push and + moderation. + +== The modules + +[cols="1,3"] +|=== +| Module | What it holds + +| `rules` | The deterministic simulation: tracks, runners, obstacles, pickups, + camera cues, the bot, replays and level packs. +| `shared` | The data transfer objects and `@RestClient` interfaces. +| `common` | The app: the 3D track, the menus, the track editor, the race lobby and + voice, the store, the admin console. +| `backend` | The server: accounts, re-simulated scores and leaderboards, the + lockstep race rooms, level packs and moderation, progression, receipts, push. +| `ios` | The iOS build, for generating the Xcode project. +|=== + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, purchases and push notifications are simulated, and the +demo accounts `player@`, `rival@`, `trouble@` and `admin@dash.example` exist, +each with the password `dash-demo`. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the app in the simulator: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The app looks for the server at `http://localhost:8080/`; *Settings* changes it. +`backend/server.sh run --port N` serves on another port. + +For the native iOS app, generate the Xcode project on a Mac, open it and run it +from Xcode: + +[source,bash] +---- +mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false +---- + +== Tests + +[source,bash] +---- +mvn -pl rules test # <1> +mvn -pl rules,shared,backend -Dcodename1.platform=backend test # <2> +./run-e2e.sh # <3> +./check-template.sh # <4> +---- +<1> Determinism: the same seed and inputs give the same run, the golden + replays still end the way they were recorded, and the bot can finish every + official level. +<2> The server's tests: whole races over its rooms and sockets, re-simulated + scores, level packs, receipts, progression and moderation. +<3> Starts the server on the `test` profile and runs the app's tests against it + in the simulator. The screenshots the developer guide shows land in + `target/e2e-shots`. +<4> Generates a project from the Initializr template, under another name and + package, and builds it. + +`.github/workflows/dash.yml` runs them, and builds the app for Android and +iOS. + +== The Initializr template + +`scripts/sync-initializr-template.py dash --write` copies this project into the +Initializr's resources; without `--write` it fails when they differ, which is what +CI runs. Change the project here, never the copies. + +== Licence + +Dash is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. + +The game ships no art files. The runners, the track, the skyline and the +portraits are all drawn in code, so there is nothing else to license. diff --git a/scripts/dash/backend/application-dev.properties b/scripts/dash/backend/application-dev.properties new file mode 100644 index 00000000000..4cc002a1ecb --- /dev/null +++ b/scripts/dash/backend/application-dev.properties @@ -0,0 +1,3 @@ +cn1.datasource.url=:memory: +dash.iap.provider=simulated +dash.push.provider=console diff --git a/scripts/dash/backend/application-test.properties b/scripts/dash/backend/application-test.properties new file mode 100644 index 00000000000..805e37726c8 --- /dev/null +++ b/scripts/dash/backend/application-test.properties @@ -0,0 +1,5 @@ +cn1.datasource.url=:memory: +dash.signin.per-minute=1000 +dash.iap.provider=simulated +dash.push.provider=recording +dash.race.bot-after-ms=0 diff --git a/scripts/dash/backend/application.properties b/scripts/dash/backend/application.properties new file mode 100644 index 00000000000..b9e4c12b9c9 --- /dev/null +++ b/scripts/dash/backend/application.properties @@ -0,0 +1,3 @@ +cn1.datasource.url=${DATABASE_URL} +cn1.server.port=8080 +cn1.security.schema.enabled=true diff --git a/scripts/dash/backend/pom.xml b/scripts/dash/backend/pom.xml new file mode 100644 index 00000000000..e228340a0d5 --- /dev/null +++ b/scripts/dash/backend/pom.xml @@ -0,0 +1,169 @@ + + + 4.0.0 + + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + + com.codenameone.examples.dash + dash-backend + 1.0-SNAPSHOT + jar + + dash-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + dash-shared + ${project.version} + + + + ${project.groupId} + dash-rules + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/dash/backend/server.sh b/scripts/dash/backend/server.sh new file mode 100755 index 00000000000..fd7b041e9e5 --- /dev/null +++ b/scripts/dash/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Dash server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, simulated in-app purchases and +# push notifications that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Dash +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[dash-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl rules,shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl rules,shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/dash-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/dash-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Caller.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Caller.java new file mode 100644 index 00000000000..75dbc1f5e2c --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Caller.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Clock.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Clock.java new file mode 100644 index 00000000000..15906350849 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Clock.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +/// Wall-clock time, in one place so day boundaries are computed the same way +/// everywhere (UTC days). +public final class Clock { + public static final long DAY = 24L * 60 * 60 * 1000; + + private Clock() { + } + + public static long now() { + return System.currentTimeMillis(); + } + + /// Midnight UTC at the start of the day `millis` falls in. + public static long dayOf(long millis) { + return millis - millis % DAY; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Ids.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Ids.java new file mode 100644 index 00000000000..6137fa63c24 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Ids.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one delivery says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Roles.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Roles.java new file mode 100644 index 00000000000..be697565d30 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Roles.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +/// The two roles. Every account is a PLAYER; an ADMIN also gets god mode. +public final class Roles { + public static final String PLAYER = "PLAYER"; + public static final String ADMIN = "ADMIN"; + + private Roles() { + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SecurityConfig.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SecurityConfig.java new file mode 100644 index 00000000000..af919ccef91 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SecurityConfig.java @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are PLAYER and ADMIN ([Roles]). `/api/admin` is god mode and +/// for admins only; the rest is for anyone signed in and acts on the caller's +/// own account, runner and wallet. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "dash-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:dash:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("dash").clientId(CLIENT_ID) + .clientName("Dash") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${dash.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `dash.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts is the one thing here that costs something + // each time and that a stranger can ask for. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + // Receipts are checked against the store, in the game's name. + .rateLimit("/api/shop/receipt", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + // tag::dash-api-rules[] + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .anyRequest().authenticated()) + // end::dash-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Services.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Services.java new file mode 100644 index 00000000000..4ea0583123f --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Services.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codenameone.examples.dash.push.CloudPushSender; +import com.codenameone.examples.dash.push.ConsolePushSender; +import com.codenameone.examples.dash.push.MisconfiguredPushSender; +import com.codenameone.examples.dash.push.PushSender; +import com.codenameone.examples.dash.push.RecordingPushSender; +import com.codenameone.examples.dash.shop.MisconfiguredReceipts; +import com.codenameone.examples.dash.shop.ReceiptValidator; +import com.codenameone.examples.dash.shop.SimulatedReceipts; +import com.codenameone.examples.dash.shop.StoreReceipts; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. A development profile +/// (dev, test, local) gets the stand-in that needs no account; any other +/// profile gets the real one, and when its settings are missing the server +/// still starts and fails every call with a message naming them. It never +/// quietly falls back to a stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`DASH_IAP_APPLE_SECRET`), which is where secrets belong. +@Configuration +public class Services { + // tag::dash-receipt-choice[] + /// `dash.iap.provider` is one of: + /// + /// - `store`: receipts are checked with Apple (`dash.iap.apple.secret`) + /// and Google Play (`dash.iap.google.package` and + /// `dash.iap.google.token`). A store with no settings refuses its + /// receipts; the other keeps working. + /// - `simulated`: every receipt is accepted, on a development profile only. + /// + /// Unset, it is `simulated` on a development profile and `store` otherwise. + @Bean + public ReceiptValidator receipts(Config config, + @Value("${dash.iap.provider:}") String chosen, + @Value("${dash.iap.apple.secret:}") String appleSecret, + @Value("${dash.iap.google.package:}") String googlePackage, + @Value("${dash.iap.google.token:}") String googleToken) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "store"; + if ("simulated".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredReceipts("dash.iap.provider=simulated grants every " + + "purchase for free and is refused on the '" + config.getProfile() + + "' profile; set it to store"); + } + return new SimulatedReceipts(); + } + if (!"store".equalsIgnoreCase(kind)) { + return new MisconfiguredReceipts("dash.iap.provider is '" + kind + + "', which is neither store nor simulated"); + } + if (appleSecret.trim().length() == 0 && (googlePackage.trim().length() == 0 + || googleToken.trim().length() == 0)) { + return new MisconfiguredReceipts("in-app purchases are not configured; set " + + "dash.iap.apple.secret, or dash.iap.google.package and " + + "dash.iap.google.token"); + } + return new StoreReceipts(appleSecret.trim(), googlePackage.trim(), googleToken.trim()); + } + // end::dash-receipt-choice[] + + // tag::dash-push-choice[] + /// `dash.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `dash.push.app`, + /// the app's push id, and `dash.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves deliveries: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${dash.push.provider:}") String chosen, + @Value("${dash.push.app:}") String app, + @Value("${dash.push.key:}") String key, + @Value("${dash.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("dash.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("dash.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("dash.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::dash-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SignInCodePage.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SignInCodePage.java new file mode 100644 index 00000000000..16547745052 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Text.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Text.java new file mode 100644 index 00000000000..18022d50143 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/Text.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Courier" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/AccountEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/AccountEndpoint.java new file mode 100644 index 00000000000..6438dd7475d --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/AccountEndpoint.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.api.AccountApiServer; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.ProfileDto; +import com.codenameone.examples.dash.api.PushDeviceDto; +import com.codenameone.examples.dash.api.RegisterDto; +import com.codenameone.examples.dash.api.TicketDto; +import com.codenameone.examples.dash.game.GameTickets; +import com.codenameone.examples.dash.push.PushDevices; + +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final GameTickets tickets; + private final PushDevices devices; + + public AccountEndpoint(Accounts accounts, GameTickets tickets, PushDevices devices) { + this.accounts = accounts; + this.tickets = tickets; + this.devices = devices; + } + + @Override + public PlayerDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public PlayerDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public PlayerDto saveProfile(ProfileDto profile) throws Exception { + return accounts.rename(Caller.name(), profile == null ? null : profile.displayName()); + } + + @Override + public TicketDto gameTicket() throws Exception { + return tickets.issue(Caller.name()); + } + + @Override + public PushDeviceDto registerPush(PushDeviceDto device) throws Exception { + return devices.register(Caller.name(), device); + } + + @Override + public PushDeviceDto unregisterPush(PushDeviceDto device) throws Exception { + return devices.unregister(Caller.name(), device); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Accounts.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Accounts.java new file mode 100644 index 00000000000..2bb093cd6fe --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Accounts.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Roles; +import com.codenameone.examples.dash.Text; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.RegisterDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.shop.Catalog; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/// Accounts: the sign-in record (users and authorities, owned by the security +/// layer) plus the [Profile] row that holds everything the game knows. +@Component +public class Accounts { + public static final int START_COINS = 100; + public static final int START_GEMS = 20; + + private final ProfileRepository profiles; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + + public Accounts(ProfileRepository profiles, JdbcUserDetailsManager users, PasswordEncoder encoder) { + this.profiles = profiles; + this.users = users; + this.encoder = encoder; + } + + public PlayerDto register(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String name = displayName(request.displayName()); + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + try { + create(username, request.password(), name, false); + } catch (IOException | RuntimeException failed) { + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + throw failed; + } + return describe(username); + } + + @Transactional(rollbackFor = IOException.class) + public void create(String username, String password, String name, boolean admin) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(admin ? new String[] {Roles.PLAYER, Roles.ADMIN} : new String[] {Roles.PLAYER}) + .build()); + Profile p = new Profile(); + p.setUsername(username); + p.setDisplayName(name); + p.setCoins(START_COINS); + p.setGems(START_GEMS); + p.setRunner(Catalog.FREE_RUNNER); + p.setOwned(Catalog.DEFAULT_OUTFIT + "," + Catalog.DEFAULT_EMOTES); + p.setOutfit(Catalog.DEFAULT_OUTFIT); + p.setCreatedAt(Instant.now()); + p.setLastSeen(Instant.now()); + profiles.insert(p); + } + + public boolean exists(String username) { + return users.userExists(username); + } + + @Transactional(readOnly = true) + public PlayerDto describe(String username) throws IOException { + Profile p = profiles.findById(username); + if (p == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + return toDto(p); + } + + public PlayerDto toDto(Profile p) { + UserDetails signIn = users.loadUserByUsername(p.getUsername()); + return new PlayerDto(p.getUsername(), p.getDisplayName(), p.getTrophies(), p.getWins(), + p.getLosses(), p.getDraws(), p.getCoins(), p.getGems(), isAdmin(signIn), + !signIn.isEnabled(), p.isMuted(), p.isFlagged(), p.getRunner(), + Lists.split(p.getOwned()), p.getOutfit()); + } + + @Transactional(rollbackFor = IOException.class) + public PlayerDto rename(String username, String displayName) throws IOException { + Profile p = profile(username); + p.setDisplayName(displayName(displayName)); + profiles.save(p); + return toDto(p); + } + + /// Enables or disables sign-in. A disabled account's tokens stop working on + /// its next request, and the game socket is closed by the caller. + public void setEnabled(String username, boolean enabled) { + UserDetails signIn = users.loadUserByUsername(username); + if (signIn.isEnabled() != enabled) { + users.updateUser(User.withUserDetails(signIn).disabled(!enabled).build()); + } + } + + public boolean isEnabled(String username) { + return users.userExists(username) && users.loadUserByUsername(username).isEnabled(); + } + + public boolean isAdmin(String username) { + return users.userExists(username) && isAdmin(users.loadUserByUsername(username)); + } + + private static boolean isAdmin(UserDetails signIn) { + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + if (("ROLE_" + Roles.ADMIN).equals(it.next().getAuthority())) { + return true; + } + } + return false; + } + + @Transactional(readOnly = true) + public Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } + + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[i] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + /// A player name: 3 to 20 characters, shown to other runners, so control + /// characters and leading or trailing spaces are refused. + static String displayName(String value) { + String name = Text.required(value, 20, "your player name").trim(); + if (name.length() < 3) { + throw new ResponseStatusException(400, "A player name needs at least 3 characters"); + } + for (int i = 0; i < name.length(); i++) { + if (name.charAt(i) < ' ') { + throw new ResponseStatusException(400, "That name has characters we cannot show"); + } + } + return name; + } + + static List names(List list) { + List out = new ArrayList(); + for (Profile p : list) { + out.add(p.getUsername()); + } + return out; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoAccounts.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoAccounts.java new file mode 100644 index 00000000000..557eecb759d --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoAccounts.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.dash.admin.Flags; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.PipelineDto; +import com.codenameone.examples.dash.api.SpotlightDto; +import com.codenameone.examples.dash.forge.Scores; +import com.codenameone.examples.dash.progress.Progression; +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Recorder; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.social.Pipelines; +import com.codenameone.examples.dash.social.Promotion; +import java.io.IOException; +import java.util.Arrays; + +/// The accounts a development server starts with: two players to challenge +/// each other, one flagged player for the console to find, and an admin. +/// Anywhere else only the admin is made, and only when +/// `dash.admin.email` and `dash.admin.password` say who. +@Component +public class DemoAccounts { + public static final String PLAYER = "player@dash.example"; + public static final String RIVAL = "rival@dash.example"; + public static final String TROUBLE = "trouble@dash.example"; + public static final String ADMIN = "admin@dash.example"; + public static final String PASSWORD = "dash-demo"; + + private final Accounts accounts; + private final Flags flags; + private final Progression progression; + private final Config config; + private final Promotion promotion; + private final Pipelines pipelines; + private final Scores scores; + private final DemoHistory history; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Flags flags, Progression progression, Config config, + Promotion promotion, Pipelines pipelines, Scores scores, DemoHistory history, + @Value("${dash.admin.email:}") String adminEmail, + @Value("${dash.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.flags = flags; + this.progression = progression; + this.config = config; + this.promotion = promotion; + this.pipelines = pipelines; + this.scores = scores; + this.history = history; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + @PostConstruct + public void seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8 + && !accounts.exists(Accounts.email(adminEmail))) { + accounts.create(Accounts.email(adminEmail), adminPassword, "Administrator", true); + } + return; + } + add(PLAYER, "Riley", false); + add(RIVAL, "Morgan", false); + add(ADMIN, "Game Master", true); + field(); + if (!accounts.exists(TROUBLE)) { + add(TROUBLE, "Sore Loser", false); + flags.auto(TROUBLE, "Left three races in a row", null); + } + // A banner on the home screen from the start, so the spotlight the + // console's Promote tab sets is there to be seen and changed. + String banner = promotion.spotlight().text(); + if (banner == null || banner.length() == 0) { + promotion.spotlight(ADMIN, new SpotlightDto("New: build your own levels in the Forge and share them", + "", "forge", 0L)); + } + // Two pipelines to start from: one brings lapsed players back, the + // other (off until someone turns it on) rewards a keen player who has + // never bought anything. + if (pipelines.list().isEmpty()) { + pipelines.save(ADMIN, new PipelineDto(null, "Win back", true, + Arrays.asList(new ConditionDto("daysAway", ">=", 7), new ConditionDto("races", ">=", 1)), + Pipelines.PUSH, "Your runner misses you", "A new track is live. Come back for a race!", + "race", 0, 14, 0, 0L)); + pipelines.save(ADMIN, new PipelineDto(null, "Keen, never paid", false, + Arrays.asList(new ConditionDto("races", ">=", 10), new ConditionDto("purchases", "=", 0)), + Pipelines.GEMS, "A gift for you", "20 gems for your next crate. Thanks for playing!", + "shop", 20, 30, 0, 0L)); + } + } + + /// Runners already on the boards when a development server starts, so a + /// leaderboard has a field to climb from the first run. Each one's names + /// and skill are fixed; their runs are the rules' own bot, played on this + /// week's seed and submitted like anyone's, so every score is a real, + /// verified run. Most of the field sits between 16 and 46, where a run + /// ends somewhere along the track: above about 55 the bot finishes the + /// causeway, and a field of finishers posts one distance eight times. + static final String[][] FIELD = { + {"nova@dash.example", "Nova", "97"}, + {"kai@dash.example", "Kai", "64"}, + {"juno@dash.example", "Juno", "46"}, + {"ezra@dash.example", "Ezra", "40"}, + {"sasha@dash.example", "Sasha", "34"}, + {"dev@dash.example", "Dev", "28"}, + {"ivy@dash.example", "Ivy", "22"}, + {"theo@dash.example", "Theo", "16"}, + }; + + private void field() throws IOException { + int week = Scores.today() / 7; + for (int i = 0; i < FIELD.length; i++) { + String who = FIELD[i][0]; + if (accounts.exists(who)) { + continue; + } + add(who, FIELD[i][1], false); + int skill = Integer.parseInt(FIELD[i][2]); + for (LevelDef level : Content.levels()) { + long seed = Content.weeklySeed(week, level.id); + post(who, level, seed, skill, i); + if (Scores.DAILY_LEVEL.equals(level.id)) { + post(who, level, Content.dailySeed(Scores.today()), skill, i); + } + } + } + history.seed(FIELD); + } + + private void post(String who, LevelDef level, long seed, int skill, int salt) throws IOException { + Recorder rec = new Recorder(new Run(level, Content.catalog(), seed, 1)); + Bot bot = new Bot(seed * 31 + salt, skill); + while (!rec.run().over()) { + rec.step(new int[] {bot.decide(rec.run(), 0)}); + } + scores.submit(who, level.id, rec.replay(), rec.run()); + } + + private void add(String username, String name, boolean admin) throws IOException { + if (!accounts.exists(username)) { + accounts.create(username, PASSWORD, name, admin); + progression.seedDemo(username); + } + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoHistory.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoHistory.java new file mode 100644 index 00000000000..24d7e71dc46 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/DemoHistory.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.RaceRecord; +import com.codenameone.examples.dash.domain.StorePurchase; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.repository.StorePurchaseRepository; +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Recorder; +import com.codenameone.examples.dash.rules.RulesVersion; +import com.codenameone.examples.dash.rules.Run; +import java.io.IOException; +import java.time.Instant; + +/// A development server's past: what [DemoAccounts] writes behind its field +/// of runners so the console has a month to chart. +@Component +public class DemoHistory { + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final StorePurchaseRepository purchases; + + public DemoHistory(ProfileRepository profiles, RaceRecordRepository races, StorePurchaseRepository purchases) { + this.profiles = profiles; + this.races = races; + this.purchases = purchases; + } + + /// Four weeks behind the field, so the console's charts have a shape on + /// a server started this morning: the runners joined on days spread over + /// the month, raced each other a few times a day -- real races of the + /// rules' bots, replays included -- and now and then bought gems. + @Transactional + public void seed(String[][] field) throws IOException { + // Once per database: a first start that died half way through seeds + // the field again on the next one, but never a second month. + if (races.count() > 0) { + return; + } + long today = Clock.dayOf(Clock.now()); + int days = 28; + for (int i = 0; i < field.length; i++) { + Profile p = profiles.findById(field[i][0]); + if (p != null) { + p.setCreatedAt(Instant.ofEpochMilli(today - (long) (days - 2 - i * 3) * Clock.DAY)); + profiles.save(p); + } + } + LevelDef level = Content.level(Content.CAUSEWAY); + for (int d = days - 1; d >= 1; d--) { + long day = today - (long) d * Clock.DAY; + // Busier as the month goes on and more of the field has joined. + int joined = 0; + for (int i = 0; i < field.length; i++) { + if (days - 2 - i * 3 >= d) { + joined++; + } + } + int count = joined < 2 ? 0 : 1 + (days - d) / 6 + (d % 3); + for (int k = 0; k < count; k++) { + int a = (d * 7 + k * 3) % joined; + int b = (a + 1 + k) % joined; + if (a == b) { + b = (b + 1) % joined; + } + race(field, level, new int[] {a, b}, day + (9 + k * 2L) * 3600000L, d * 131 + k); + } + if (d % 4 == 1 && joined > 0) { + StorePurchase s = new StorePurchase(); + s.setId(Ids.next()); + s.setUsername(field[d % joined][0]); + s.setStore("demo"); + boolean big = d % 8 == 1; + s.setSku(big ? "dash.gems_500" : "dash.gems_80"); + s.setGems(big ? 500 : 80); + s.setCreatedAt(Instant.ofEpochMilli(day + 15 * 3600000L)); + purchases.insert(s); + } + } + } + + private void race(String[][] field, LevelDef level, int[] who, long endedAt, long salt) throws IOException { + long seed = Content.weeklySeed((int) (endedAt / Clock.DAY / 7), level.id) + salt; + Run run = new Run(level, Content.catalog(), seed, who.length); + Recorder rec = new Recorder(run, level.id); + Bot[] bots = new Bot[who.length]; + for (int i = 0; i < who.length; i++) { + bots[i] = new Bot(seed * 31 + i, Integer.parseInt(field[who[i]][2])); + } + int[] in = new int[who.length]; + while (!run.over()) { + for (int i = 0; i < who.length; i++) { + in[i] = bots[i].decide(run, i); + } + rec.step(in); + } + StringBuilder players = new StringBuilder(); + StringBuilder names = new StringBuilder(); + StringBuilder places = new StringBuilder(); + StringBuilder points = new StringBuilder(); + StringBuilder trophies = new StringBuilder(); + for (int i = 0; i < who.length; i++) { + int place = 0; + for (int j = 0; j < who.length; j++) { + if (j != i && ahead(run, j, i)) { + place++; + } + } + String sep = i == 0 ? "" : ","; + players.append(sep).append(field[who[i]][0]); + names.append(sep).append(field[who[i]][1]); + places.append(sep).append(place); + points.append(sep).append(run.runner(i).score()); + int t = place == 0 ? 30 : -10; + trophies.append(sep).append(t); + Profile p = profiles.findById(field[who[i]][0]); + if (p != null) { + p.setTrophies(Math.max(0, p.getTrophies() + t)); + if (place == 0) { + p.setWins(p.getWins() + 1); + } else { + p.setLosses(p.getLosses() + 1); + } + p.setLastSeen(Instant.ofEpochMilli(endedAt)); + profiles.save(p); + } + } + RaceRecord m = new RaceRecord(); + m.setId(Ids.next()); + m.setLevel(level.id); + m.setPlayers(players.toString()); + m.setNames(names.toString()); + m.setPlaces(places.toString()); + m.setScores(points.toString()); + m.setTrophies(trophies.toString()); + m.setRunners(who.length); + m.setHumans(who.length); + m.setTicks(run.tick()); + m.setEndedAt(Instant.ofEpochMilli(endedAt)); + m.setRulesVersion(RulesVersion.ID); + m.setReplay(rec.replay().encode()); + races.insert(m); + } + + /// Whether runner `a` finished ahead of runner `b`: further down the + /// track, or level and with more points (lower index on a full tie). + private static boolean ahead(Run run, int a, int b) { + int za = run.runner(a).z; + int zb = run.runner(b).z; + if (za != zb) { + return za > zb; + } + int sa = run.runner(a).score(); + int sb = run.runner(b).score(); + return sa != sb ? sa > sb : a < b; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Lists.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Lists.java new file mode 100644 index 00000000000..0db91d8e9ab --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/account/Lists.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.account; + +import java.util.ArrayList; +import java.util.List; + +/// Comma-separated id lists, the way a profile stores its owned items. +public final class Lists { + private Lists() { + } + + public static String join(String[] items) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < items.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(items[i]); + } + return sb.toString(); + } + + public static String join(List items) { + return join(items.toArray(new String[items.size()])); + } + + public static List split(String csv) { + List out = new ArrayList(); + if (csv == null || csv.length() == 0) { + return out; + } + int start = 0; + for (int i = 0; i <= csv.length(); i++) { + if (i == csv.length() || csv.charAt(i) == ',') { + if (i > start) { + out.add(csv.substring(start, i)); + } + start = i + 1; + } + } + return out; + } + + /// Like [#split], but keeps empty fields, so positions line up: a race's + /// player list has an empty entry for each bot. + public static List fields(String csv) { + List out = new ArrayList(); + if (csv == null || csv.length() == 0) { + return out; + } + int start = 0; + for (int i = 0; i <= csv.length(); i++) { + if (i == csv.length() || csv.charAt(i) == ',') { + out.add(csv.substring(start, i)); + start = i + 1; + } + } + return out; + } + + public static boolean contains(String csv, String id) { + return split(csv).contains(id); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminChannel.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminChannel.java new file mode 100644 index 00000000000..0a078a327f8 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminChannel.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The admins connected to the game socket right now, so a new flag reaches the +/// console the moment it is raised. +@Component +public class AdminChannel { + private final Map admins = new HashMap(); + + public synchronized void add(String username, WebSocketSession session) { + admins.put(username, session); + } + + public synchronized void remove(String username, WebSocketSession session) { + if (admins.get(username) == session) { + admins.remove(username); + } + } + + public void flagged(String username, String reason) { + List to; + synchronized (this) { + to = new ArrayList(admins.values()); + } + String msg = "{\"type\":\"flag\",\"username\":" + Json.write(username) + ",\"reason\":" + + Json.write(reason) + "}"; + for (WebSocketSession s : to) { + try { + if (s.isOpen()) { + s.sendText(msg); + } + } catch (IOException ignored) { + // Closing; its close callback removes it. + } + } + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminEndpoint.java new file mode 100644 index 00000000000..34472fc3cd4 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/AdminEndpoint.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.AdminActionDto; +import com.codenameone.examples.dash.api.AdminApiServer; +import com.codenameone.examples.dash.api.AdminStatsDto; +import com.codenameone.examples.dash.api.AssetDto; +import com.codenameone.examples.dash.api.AuditDto; +import com.codenameone.examples.dash.api.BroadcastDto; +import com.codenameone.examples.dash.api.FlagDto; +import com.codenameone.examples.dash.api.ForgeActionDto; +import com.codenameone.examples.dash.api.LiveRaceDto; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.api.PlayerDetailDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.PipelineDto; +import com.codenameone.examples.dash.api.PromoteDto; +import com.codenameone.examples.dash.api.QueryDto; +import com.codenameone.examples.dash.api.QueryResultDto; +import com.codenameone.examples.dash.api.InsightsDto; +import com.codenameone.examples.dash.api.SpotlightDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.StorePurchase; +import com.codenameone.examples.dash.forge.Forge; +import com.codenameone.examples.dash.game.GameEndpoint; +import com.codenameone.examples.dash.game.Rooms; +import com.codenameone.examples.dash.repository.RaceEntryRepository; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.StorePurchaseRepository; +import com.codenameone.examples.dash.shop.Shop; +import com.codenameone.examples.dash.social.Insights; +import com.codenameone.examples.dash.social.Pipelines; +import com.codenameone.examples.dash.social.Promotion; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// God mode. The security chain already requires ADMIN for `/api/admin/**`. +// tag::dash-admin[] +@Component +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final StorePurchaseRepository purchases; + private final Flags flags; + private final Moderation moderation; + private final Rooms rooms; + private final Shop shop; + + private final Forge forge; + private final Promotion promotion; + private final Insights insights; + private final Pipelines pipelines; + + public AdminEndpoint(Accounts accounts, ProfileRepository profiles, RaceRecordRepository races, RaceEntryRepository entries, + StorePurchaseRepository purchases, Flags flags, Moderation moderation, Rooms rooms, Shop shop, + Forge forge, Promotion promotion, Insights insights, Pipelines pipelines) { + this.insights = insights; + this.pipelines = pipelines; + this.forge = forge; + this.promotion = promotion; + this.accounts = accounts; + this.profiles = profiles; + this.races = races; + this.entries = entries; + this.purchases = purchases; + this.flags = flags; + this.moderation = moderation; + this.rooms = rooms; + this.shop = shop; + } + + @Override + @Transactional(readOnly = true) + public AdminStatsDto stats() throws Exception { + Instant today = Instant.ofEpochMilli(Clock.dayOf(Clock.now())); + int gems = 0; + List sold = purchases.since(today); + for (StorePurchase s : sold) { + gems += s.getGems(); + } + return new AdminStatsDto((int) profiles.count(), rooms.online(), rooms.live().size(), + (int) races.countByEndedAtGreaterThan(today), (int) flags.pendingCount(), + (int) count(profiles.suspended(Instant.now())), sold.size(), gems); + } + + @Override + @Transactional(readOnly = true) + public List players(String query) throws Exception { + String q = query == null ? "" : query.trim(); + List out = new ArrayList(); + for (Profile p : profiles.search("%" + q + "%", new PageRequest(0, 100))) { + out.add(accounts.toDto(p)); + } + return out; + } + + @Override + @Transactional(readOnly = true) + public PlayerDetailDto player(String username) throws Exception { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return new PlayerDetailDto(accounts.toDto(p), + GameEndpoint.summaries(entries.history(username, new PageRequest(0, 30))), + flags.of(username), moderation.of(username), shop.purchasesOf(username)); + } + + @Override + public PlayerDto act(String username, AdminActionDto action) throws Exception { + return moderation.act(Caller.name(), username, action); + } + + @Override + public List flags(boolean open) throws Exception { + return flags.list(open); + } + + @Override + public FlagDto resolve(String id) throws Exception { + return flags.resolve(Caller.name(), id); + } + + @Override + public List liveRaces() throws Exception { + return rooms.live(); + } + + private static long count(Long n) { + return n == null ? 0 : n.longValue(); + } + + @Override + public List audit() throws Exception { + return moderation.newest(); + } + + @Override + public List forgeQueue() throws Exception { + return forge.queue(); + } + + @Override + public List forgeAssets() throws Exception { + return forge.pendingAssets(); + } + + @Override + public PackSummaryDto forgePackAction(ForgeActionDto body) throws Exception { + if (body == null) { + throw new ResponseStatusException(400, "Nothing to do"); + } + return forge.packAction(Caller.name(), body.target(), body.action(), body.reason()); + } + + @Override + public AssetDto forgeAssetAction(ForgeActionDto body) throws Exception { + if (body == null) { + throw new ResponseStatusException(400, "Nothing to do"); + } + return forge.assetAction(Caller.name(), body.target(), body.action(), body.reason()); + } + + @Override + public PromoteDto promote() throws Exception { + return promotion.overview(); + } + + @Override + public BroadcastDto audience(BroadcastDto body) throws Exception { + return promotion.audience(body); + } + + @Override + public BroadcastDto broadcast(BroadcastDto body) throws Exception { + return promotion.broadcast(Caller.name(), body); + } + + @Override + public SpotlightDto spotlight(SpotlightDto body) throws Exception { + return promotion.spotlight(Caller.name(), body); + } + + @Override + public InsightsDto reports(int days) throws Exception { + return insights.report(days); + } + + @Override + public QueryResultDto query(QueryDto body) throws Exception { + return insights.query(body); + } + + @Override + public List pipelines() throws Exception { + return pipelines.list(); + } + + @Override + public PipelineDto savePipeline(PipelineDto body) throws Exception { + return pipelines.save(Caller.name(), body); + } + + @Override + public PipelineDto deletePipeline(String id) throws Exception { + return pipelines.delete(Caller.name(), id); + } + + @Override + public PipelineDto runPipeline(String id) throws Exception { + return pipelines.run(id); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Flags.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Flags.java new file mode 100644 index 00000000000..334051dd259 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Flags.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.Text; +import com.codenameone.examples.dash.account.Lists; +import com.codenameone.examples.dash.api.FlagDto; +import com.codenameone.examples.dash.domain.Flag; +import com.codenameone.examples.dash.domain.RaceRecord; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.FlagRepository; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Flags: the queue the admin console works through. +/// +/// Two sources fill it. Players report another runner after a race (one report +/// per player per race), and the server raises its own AUTO flags when it sees +/// what an honest client never does: plays it had to refuse, a flood of plays, +/// a state that diverged from the server's, or leaving race after race. +// tag::dash-flags[] +@Component +public class Flags { + public static final String REPORT = "REPORT"; + public static final String AUTO = "AUTO"; + + private final FlagRepository flags; + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final GamePushes pushes; + private final AdminChannel channel; + private final List adminNames; + + public Flags(FlagRepository flags, ProfileRepository profiles, RaceRecordRepository races, + GamePushes pushes, AdminChannel channel, @Value("${dash.admin.notify:}") String adminNames) { + this.flags = flags; + this.profiles = profiles; + this.races = races; + this.pushes = pushes; + this.channel = channel; + this.adminNames = com.codenameone.examples.dash.account.Lists.split(adminNames); + } + + /// A player reporting another runner of a race they ran in. `accused` is + /// the seat number, as the race's lists number them. + @Transactional(rollbackFor = IOException.class) + public FlagDto report(String reporter, String raceId, String accused, String reason) throws IOException { + RaceRecord m = raceId == null ? null : races.findById(raceId); + List players = m == null ? new ArrayList() : Lists.fields(m.getPlayers()); + if (!players.contains(reporter)) { + throw new ResponseStatusException(404, "No such race"); + } + int seat = accused == null ? -1 : players.indexOf(accused); + if (seat < 0) { + int n = accused != null && accused.length() == 1 ? accused.charAt(0) - '0' : -1; + seat = n >= 0 && n < players.size() ? n : -1; + } + String who = seat < 0 ? "" : players.get(seat); + if (who.length() == 0 || who.equals(reporter)) { + throw new ResponseStatusException(400, "Pick a runner from that race to report; bots cannot be"); + } + if (flags.countByReporterAndRaceId(reporter, raceId) > 0) { + throw new ResponseStatusException(409, "You already reported this race"); + } + String why = Text.required(reason, 255, "what happened"); + return raise(who, REPORT, why, raceId, reporter); + } + + /// Something the server noticed itself. Transactional on its own because + /// the demo seeding calls it with no transaction open; inside one (a race + /// being recorded) it joins that one. + @Transactional(rollbackFor = IOException.class) + public void auto(String username, String reason, String raceId) { + try { + raise(username, AUTO, reason, raceId == null ? "" : raceId, ""); + } catch (IOException | RuntimeException failed) { + System.err.println("[flags] could not flag " + username + ": " + failed); + } + } + + FlagDto raise(String username, String kind, String reason, String raceId, String reporter) + throws IOException { + Flag f = store(username, kind, reason, raceId, reporter); + channel.flagged(username, reason); + pushes.flagRaised(adminNames, username, reason); + return toDto(f, profiles.findById(username)); + } + + @Transactional(rollbackFor = IOException.class) + Flag store(String username, String kind, String reason, String raceId, String reporter) + throws IOException { + Flag f = new Flag(); + f.setId(Ids.next()); + f.setUsername(username); + f.setKind(kind); + f.setReason(reason); + f.setRaceId(raceId); + f.setReporter(reporter); + f.setCreatedAt(Instant.now()); + f.setPending(true); + flags.insert(f); + Profile p = profiles.findById(username); + if (p != null && !p.isFlagged()) { + p.setFlagged(true); + profiles.save(p); + } + return f; + } + + @Transactional(rollbackFor = IOException.class) + public FlagDto resolve(String admin, String id) { + Flag f = flags.findById(id); + if (f == null) { + throw new ResponseStatusException(404, "No such flag"); + } + f.setPending(false); + f.setResolvedBy(admin); + flags.save(f); + Profile p = profiles.findById(f.getUsername()); + if (p != null && flags.countByUsernameAndPendingTrue(f.getUsername()) == 0) { + p.setFlagged(false); + profiles.save(p); + } + return toDto(f, p); + } + + @Transactional(readOnly = true) + public List list(boolean pending) { + List out = new ArrayList(); + for (Flag f : flags.inState(pending, new PageRequest(0, 200))) { + out.add(toDto(f, profiles.findById(f.getUsername()))); + } + return out; + } + + @Transactional(readOnly = true) + public List of(String username) { + List out = new ArrayList(); + Profile p = profiles.findById(username); + for (Flag f : flags.of(username, new PageRequest(0, 50))) { + out.add(toDto(f, p)); + } + return out; + } + + public long pendingCount() { + return flags.countByPendingTrue(); + } + + static FlagDto toDto(Flag f, Profile p) { + return new FlagDto(f.getId(), f.getUsername(), p == null ? f.getUsername() : p.getDisplayName(), + f.getKind(), f.getReason(), f.getRaceId(), f.getReporter(), f.getCreatedAt().toEpochMilli(), f.isPending()); + } +} +// end::dash-flags[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Moderation.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Moderation.java new file mode 100644 index 00000000000..ae9d4ca02cb --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/admin/Moderation.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.Text; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.AdminActionDto; +import com.codenameone.examples.dash.api.AuditDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.domain.AdminAction; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.game.Rooms; +import com.codenameone.examples.dash.repository.AdminActionRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.shop.Catalog; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// What an admin can do to a player, and the audit log that records it. +/// +/// Every action is written to `admin_action` in the same transaction as the +/// change itself, so the log cannot miss one. +// tag::dash-moderation[] +@Component +public class Moderation { + public static final String WARN = "WARN"; + public static final String MUTE = "MUTE"; + public static final String UNMUTE = "UNMUTE"; + public static final String SUSPEND = "SUSPEND"; + public static final String BAN = "BAN"; + public static final String UNBAN = "UNBAN"; + public static final String RESET_TROPHIES = "RESET_TROPHIES"; + public static final String REVOKE_ITEMS = "REVOKE_ITEMS"; + static final String[] ACTIONS = {WARN, MUTE, UNMUTE, SUSPEND, BAN, UNBAN, RESET_TROPHIES, REVOKE_ITEMS}; + + private final Accounts accounts; + private final ProfileRepository profiles; + private final AdminActionRepository audit; + private final Rooms rooms; + + public Moderation(Accounts accounts, ProfileRepository profiles, AdminActionRepository audit, Rooms rooms) { + this.accounts = accounts; + this.profiles = profiles; + this.audit = audit; + this.rooms = rooms; + } + + public PlayerDto act(String admin, String username, AdminActionDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Choose an action"); + } + String action = Text.oneOf(request.action(), ACTIONS, "", "the action"); + if (action.length() == 0) { + throw new ResponseStatusException(400, "Choose an action"); + } + if (admin.equals(username) && !WARN.equals(action)) { + throw new ResponseStatusException(400, "Not on your own account"); + } + String reason = Text.required(request.reason(), 255, "the reason"); + boolean closes = apply(admin, username, action, reason, request.days()); + if (closes) { + rooms.kick(username); + } + return accounts.describe(username); + } + + @Transactional(rollbackFor = IOException.class) + boolean apply(String admin, String username, String action, String reason, int days) throws IOException { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + Profile p = profiles.findById(username); + boolean closes = false; + if (MUTE.equals(action) || UNMUTE.equals(action)) { + p.setMuted(MUTE.equals(action)); + } else if (SUSPEND.equals(action)) { + if (days < 1 || days > 365) { + throw new ResponseStatusException(400, "Suspend for 1 to 365 days"); + } + p.setSuspendedUntil(Instant.now().plusMillis(days * Clock.DAY)); + accounts.setEnabled(username, false); + closes = true; + } else if (BAN.equals(action)) { + p.setBanned(true); + accounts.setEnabled(username, false); + closes = true; + } else if (UNBAN.equals(action)) { + p.setBanned(false); + p.setSuspendedUntil(Instant.EPOCH); + accounts.setEnabled(username, true); + } else if (RESET_TROPHIES.equals(action)) { + p.setTrophies(0); + } else if (REVOKE_ITEMS.equals(action)) { + p.setOwned(Catalog.DEFAULT_OUTFIT + "," + Catalog.DEFAULT_EMOTES); + p.setOutfit(Catalog.DEFAULT_OUTFIT); + } + profiles.save(p); + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername(username); + a.setAction(SUSPEND.equals(action) ? SUSPEND + " " + days + "d" : action); + a.setReason(reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + return closes; + } + + /// Lifts suspensions that have run their course. + @Scheduled(fixedDelay = 60000, initialDelay = 60000, lock = "dash-suspensions") + public void liftExpired() { + lift(Clock.now()); + } + + @Transactional + public int lift(long now) { + int lifted = 0; + for (Profile p : profiles.findAll()) { + if (!p.isBanned() && p.getSuspendedUntil().isAfter(Instant.EPOCH) + && p.getSuspendedUntil().toEpochMilli() <= now) { + p.setSuspendedUntil(Instant.EPOCH); + profiles.save(p); + accounts.setEnabled(p.getUsername(), true); + lifted++; + } + } + return lifted; + } + + @Transactional(readOnly = true) + public List newest() { + return toDtos(audit.newest(new PageRequest(0, 200))); + } + + @Transactional(readOnly = true) + public List of(String username) { + return toDtos(audit.of(username, new PageRequest(0, 50))); + } + + private static List toDtos(List rows) { + List out = new ArrayList(); + for (AdminAction a : rows) { + out.add(new AuditDto(a.getId(), a.getAdmin(), a.getUsername(), a.getAction(), a.getReason(), + a.getCreatedAt().toEpochMilli())); + } + return out; + } +} +// end::dash-moderation[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/AdminAction.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/AdminAction.java new file mode 100644 index 00000000000..0b85e196d6a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/AdminAction.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One entry of the admin audit log. Rows are only ever inserted. +@Entity +public class AdminAction { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String admin = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public AdminAction() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAdmin() { + return admin; + } + + public void setAdmin(String admin) { + this.admin = admin; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Broadcast.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Broadcast.java new file mode 100644 index 00000000000..acae8564486 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Broadcast.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A push an operator sent to a segment of players, kept as the record of it. +@Entity +public class Broadcast { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String segment = ""; + + @Column(nullable = false) + private int days; + + @Column(nullable = false, type = "VARCHAR(60)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String body = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String campaign = ""; + + @Column(nullable = false) + private int targets; + + @Column(nullable = false, type = "VARCHAR(190)") + private String sentBy = ""; + + @Column(nullable = false) + private Instant sentAt = Instant.EPOCH; + + public Broadcast() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSegment() { + return segment; + } + + public void setSegment(String segment) { + this.segment = segment; + } + + public int getDays() { + return days; + } + + public void setDays(int days) { + this.days = days; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public String getCampaign() { + return campaign; + } + + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + public int getTargets() { + return targets; + } + + public void setTargets(int targets) { + this.targets = targets; + } + + public String getSentBy() { + return sentBy; + } + + public void setSentBy(String sentBy) { + this.sentBy = sentBy; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Crew.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Crew.java new file mode 100644 index 00000000000..44d5445645b --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Crew.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A crew: friends who race together. Joined by its code, which is also how it +/// is shared. +@Entity +public class Crew { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false) + private int members; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Crew() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getMembers() { + return members; + } + + public void setMembers(int members) { + this.members = members; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/CrewMember.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/CrewMember.java new file mode 100644 index 00000000000..5517684815a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/CrewMember.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player in a [Crew]; the id is `code|username`. +@Entity +public class CrewMember { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(210)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant joinedAt = Instant.EPOCH; + + public CrewMember() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(Instant joinedAt) { + this.joinedAt = joinedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Flag.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Flag.java new file mode 100644 index 00000000000..285bde3a28f --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Flag.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A reason for an admin to look at a player: a fellow runner's report or something the server detected. +@Entity +public class Flag { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String raceId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String reporter = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean pending; + + @Column(nullable = false, type = "VARCHAR(190)") + private String resolvedBy = ""; + + public Flag() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getRaceId() { + return raceId; + } + + public void setRaceId(String raceId) { + this.raceId = raceId; + } + + public String getReporter() { + return reporter; + } + + public void setReporter(String reporter) { + this.reporter = reporter; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isPending() { + return pending; + } + + public void setPending(boolean pending) { + this.pending = pending; + } + + public String getResolvedBy() { + return resolvedBy; + } + + public void setResolvedBy(String resolvedBy) { + this.resolvedBy = resolvedBy; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeAsset.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeAsset.java new file mode 100644 index 00000000000..5a3420be0f3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeAsset.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An uploaded model or image, stored once under the SHA-256 of its bytes. +@Entity +public class ForgeAsset { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String sha256 = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String kind = ""; + + @Column(nullable = false) + private int bytes; + + @Column(nullable = false, type = "VARCHAR(10)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(2000)") + private String animations = ""; + + @Column(nullable = false) + private String data = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public ForgeAsset() { + } + + public String getSha256() { + return sha256; + } + + public void setSha256(String sha256) { + this.sha256 = sha256; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public int getBytes() { + return bytes; + } + + public void setBytes(int bytes) { + this.bytes = bytes; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getAnimations() { + return animations; + } + + public void setAnimations(String animations) { + this.animations = animations; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeEarning.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeEarning.java new file mode 100644 index 00000000000..3253e49154f --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeEarning.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Coins a player earned from community levels on one day, for the daily cap. +@Entity +public class ForgeEarning { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private long day; + + @Column(nullable = false) + private int coins; + + public ForgeEarning() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getDay() { + return day; + } + + public void setDay(long day) { + this.day = day; + } + + public int getCoins() { + return coins; + } + + public void setCoins(int coins) { + this.coins = coins; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePack.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePack.java new file mode 100644 index 00000000000..8751a174ed0 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePack.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A pack in the Forge: its owner's draft and, once published, which version is +/// current. The versions themselves are [ForgePackVersion] rows. +@Entity +public class ForgePack { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(40)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String author = ""; + + @Column(nullable = false, type = "VARCHAR(1200)") + private String description = ""; + + @Column(nullable = false, type = "VARCHAR(12)") + private String status = ""; + + @Column(nullable = false) + private boolean official; + + @Column(nullable = false) + private int version; + + @Column(nullable = false, type = "VARCHAR(16)") + private String hash = ""; + + @Column(nullable = false) + private String draft = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String forkedFrom = ""; + + @Column(nullable = false) + private int plays; + + @Column(nullable = false) + private int ratingSum; + + @Column(nullable = false) + private int ratingCount; + + @Column(nullable = false) + private int reports; + + @Column(nullable = false) + private int levels; + + @Column(nullable = false) + private int defs; + + @Column(nullable = false) + private boolean pendingAssets; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public ForgePack() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public boolean isOfficial() { + return official; + } + + public void setOfficial(boolean official) { + this.official = official; + } + + public int getVersion() { + return version; + } + + public void setVersion(int version) { + this.version = version; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public String getDraft() { + return draft; + } + + public void setDraft(String draft) { + this.draft = draft; + } + + public String getForkedFrom() { + return forkedFrom; + } + + public void setForkedFrom(String forkedFrom) { + this.forkedFrom = forkedFrom; + } + + public int getPlays() { + return plays; + } + + public void setPlays(int plays) { + this.plays = plays; + } + + public int getRatingSum() { + return ratingSum; + } + + public void setRatingSum(int ratingSum) { + this.ratingSum = ratingSum; + } + + public int getRatingCount() { + return ratingCount; + } + + public void setRatingCount(int ratingCount) { + this.ratingCount = ratingCount; + } + + public int getReports() { + return reports; + } + + public void setReports(int reports) { + this.reports = reports; + } + + public int getLevels() { + return levels; + } + + public void setLevels(int levels) { + this.levels = levels; + } + + public int getDefs() { + return defs; + } + + public void setDefs(int defs) { + this.defs = defs; + } + + public boolean isPendingAssets() { + return pendingAssets; + } + + public void setPendingAssets(boolean pendingAssets) { + this.pendingAssets = pendingAssets; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePackVersion.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePackVersion.java new file mode 100644 index 00000000000..1a181dfffc3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgePackVersion.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One published version of a pack, kept for good: replays and level results +/// name the version they were played on. +@Entity +public class ForgePackVersion { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(60)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String packId = ""; + + @Column(nullable = false) + private int version; + + @Column(nullable = false, type = "VARCHAR(16)") + private String hash = ""; + + @Column(nullable = false) + private String content = ""; + + @Column(nullable = false) + private Instant publishedAt = Instant.EPOCH; + + public ForgePackVersion() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPackId() { + return packId; + } + + public void setPackId(String packId) { + this.packId = packId; + } + + public int getVersion() { + return version; + } + + public void setVersion(int version) { + this.version = version; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(Instant publishedAt) { + this.publishedAt = publishedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeRating.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeRating.java new file mode 100644 index 00000000000..7722cc80da3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ForgeRating.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The stars one player gave one pack. +@Entity +public class ForgeRating { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String packId = ""; + + @Column(nullable = false) + private int stars; + + public ForgeRating() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPackId() { + return packId; + } + + public void setPackId(String packId) { + this.packId = packId; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/GameTicket.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/GameTicket.java new file mode 100644 index 00000000000..62ecfd268f4 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/GameTicket.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A ticket for one connection to the live channel, as its hash. +@Entity +public class GameTicket { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String ticketHash = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + public GameTicket() { + } + + public String getTicketHash() { + return ticketHash; + } + + public void setTicketHash(String ticketHash) { + this.ticketHash = ticketHash; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/LevelStars.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/LevelStars.java new file mode 100644 index 00000000000..c6e35d69f09 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/LevelStars.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Perlevel is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's best on one level, and how many of those stars were paid for. +@Entity +public class LevelStars { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String packId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String levelId = ""; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private int paidStars; + + @Column(nullable = false) + private int best; + + @Column(nullable = false) + private int plays; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public LevelStars() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPackId() { + return packId; + } + + public void setPackId(String packId) { + this.packId = packId; + } + + public String getLevelId() { + return levelId; + } + + public void setLevelId(String levelId) { + this.levelId = levelId; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public int getPaidStars() { + return paidStars; + } + + public void setPaidStars(int paidStars) { + this.paidStars = paidStars; + } + + public int getPlays() { + return plays; + } + + public void setPlays(int plays) { + this.plays = plays; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public int getBest() { + return best; + } + + public void setBest(int best) { + this.best = best; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Pipeline.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Pipeline.java new file mode 100644 index 00000000000..4892e091142 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Pipeline.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An engagement pipeline: a query over the players and what to do for each +/// one who matches. See `social.Pipelines`. +@Entity +public class Pipeline { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String name = ""; + + @Column(nullable = false) + private boolean enabled; + + @Column(nullable = false, type = "VARCHAR(500)") + private String conditions = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String body = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false) + private int amount; + + @Column(nullable = false) + private int cooldownDays; + + @Column(nullable = false) + private int reached; + + @Column(nullable = false) + private Instant lastRun = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String createdBy = ""; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getConditions() { + return conditions; + } + + public void setConditions(String conditions) { + this.conditions = conditions; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public int getAmount() { + return amount; + } + + public void setAmount(int amount) { + this.amount = amount; + } + + public int getCooldownDays() { + return cooldownDays; + } + + public void setCooldownDays(int cooldownDays) { + this.cooldownDays = cooldownDays; + } + + public int getReached() { + return reached; + } + + public void setReached(int reached) { + this.reached = reached; + } + + public Instant getLastRun() { + return lastRun; + } + + public void setLastRun(Instant lastRun) { + this.lastRun = lastRun; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PipelineHit.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PipelineHit.java new file mode 100644 index 00000000000..8b5aa1652dc --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PipelineHit.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// When a pipeline last acted on a player, so it waits its cool-down before +/// acting on them again. The id is the pipeline id, a colon and the username. +@Entity +public class PipelineHit { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(230)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String pipeline = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant hitAt = Instant.EPOCH; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPipeline() { + return pipeline; + } + + public void setPipeline(String pipeline) { + this.pipeline = pipeline; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getHitAt() { + return hitAt; + } + + public void setHitAt(Instant hitAt) { + this.hitAt = hitAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Profile.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Profile.java new file mode 100644 index 00000000000..a23ed508552 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Profile.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's account data beyond the sign-in record: rating, currencies, runner, cosmetics and moderation state. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String displayName = ""; + + @Column(nullable = false) + private int trophies; + + @Column(nullable = false) + private int wins; + + @Column(nullable = false) + private int losses; + + @Column(nullable = false) + private int draws; + + @Column(nullable = false) + private int coins; + + @Column(nullable = false) + private int gems; + + @Column(nullable = false, type = "VARCHAR(40)") + private String runner = "runner-dash"; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String owned = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String outfit = ""; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false) + private boolean muted; + + @Column(nullable = false) + private Instant suspendedUntil = Instant.EPOCH; + + @Column(nullable = false) + private boolean banned; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant lastSeen = Instant.EPOCH; + + @Column(nullable = false) + private long adDay; + + @Column(nullable = false) + private int adCount; + + @Column(nullable = false) + private int leftStreak; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public int getTrophies() { + return trophies; + } + + public void setTrophies(int trophies) { + this.trophies = trophies; + } + + public int getWins() { + return wins; + } + + public void setWins(int wins) { + this.wins = wins; + } + + public int getLosses() { + return losses; + } + + public void setLosses(int losses) { + this.losses = losses; + } + + public int getDraws() { + return draws; + } + + public void setDraws(int draws) { + this.draws = draws; + } + + public int getCoins() { + return coins; + } + + public void setCoins(int coins) { + this.coins = coins; + } + + public int getGems() { + return gems; + } + + public void setGems(int gems) { + this.gems = gems; + } + + public String getRunner() { + return runner; + } + + public void setRunner(String runner) { + this.runner = runner; + } + + public String getOwned() { + return owned; + } + + public void setOwned(String owned) { + this.owned = owned; + } + + public String getOutfit() { + return outfit; + } + + public void setOutfit(String outfit) { + this.outfit = outfit; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public boolean isMuted() { + return muted; + } + + public void setMuted(boolean muted) { + this.muted = muted; + } + + public Instant getSuspendedUntil() { + return suspendedUntil; + } + + public void setSuspendedUntil(Instant suspendedUntil) { + this.suspendedUntil = suspendedUntil; + } + + public boolean isBanned() { + return banned; + } + + public void setBanned(boolean banned) { + this.banned = banned; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getLastSeen() { + return lastSeen; + } + + public void setLastSeen(Instant lastSeen) { + this.lastSeen = lastSeen; + } + + public long getAdDay() { + return adDay; + } + + public void setAdDay(long adDay) { + this.adDay = adDay; + } + + public int getAdCount() { + return adCount; + } + + public void setAdCount(int adCount) { + this.adCount = adCount; + } + + public int getLeftStreak() { + return leftStreak; + } + + public void setLeftStreak(int leftStreak) { + this.leftStreak = leftStreak; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Progress.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Progress.java new file mode 100644 index 00000000000..c85c2de0e53 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Progress.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A player's progress: experience, chests, lifetime counters behind the +/// achievements, and today's quest counters. One row per account, created the +/// first time anything asks for it. +@Entity +public class Progress { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private int xp; + + @Column(nullable = false) + private boolean tutorialDone; + + @Column(nullable = false) + private int chests; + + @Column(nullable = false) + private int chestsOpened; + + @Column(nullable = false) + private int races; + + @Column(nullable = false) + private int wins; + + @Column(nullable = false) + private int metres; + + @Column(nullable = false) + private int pickups; + + @Column(nullable = false) + private int podiums; + + @Column(nullable = false) + private int streak; + + @Column(nullable = false) + private int bestStreak; + + @Column(nullable = false, type = "VARCHAR(500)") + private String achievements = ""; + + @Column(nullable = false) + private long questDay; + + @Column(nullable = false) + private int quest0; + + @Column(nullable = false) + private int quest1; + + @Column(nullable = false) + private int quest2; + + @Column(nullable = false) + private int claimed; + + public Progress() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getXp() { + return xp; + } + + public void setXp(int xp) { + this.xp = xp; + } + + public boolean isTutorialDone() { + return tutorialDone; + } + + public void setTutorialDone(boolean tutorialDone) { + this.tutorialDone = tutorialDone; + } + + public int getChests() { + return chests; + } + + public void setChests(int chests) { + this.chests = chests; + } + + public int getChestsOpened() { + return chestsOpened; + } + + public void setChestsOpened(int chestsOpened) { + this.chestsOpened = chestsOpened; + } + + public int getRaces() { + return races; + } + + public void setRaces(int races) { + this.races = races; + } + + public int getWins() { + return wins; + } + + public void setWins(int wins) { + this.wins = wins; + } + + public int getMetres() { + return metres; + } + + public void setMetres(int metres) { + this.metres = metres; + } + + public int getPickups() { + return pickups; + } + + public void setPickups(int pickups) { + this.pickups = pickups; + } + + public int getPodiums() { + return podiums; + } + + public void setPodiums(int podiums) { + this.podiums = podiums; + } + + public int getStreak() { + return streak; + } + + public void setStreak(int streak) { + this.streak = streak; + } + + public int getBestStreak() { + return bestStreak; + } + + public void setBestStreak(int bestStreak) { + this.bestStreak = bestStreak; + } + + public String getAchievements() { + return achievements; + } + + public void setAchievements(String achievements) { + this.achievements = achievements; + } + + public long getQuestDay() { + return questDay; + } + + public void setQuestDay(long questDay) { + this.questDay = questDay; + } + + public int getQuest0() { + return quest0; + } + + public void setQuest0(int quest0) { + this.quest0 = quest0; + } + + public int getQuest1() { + return quest1; + } + + public void setQuest1(int quest1) { + this.quest1 = quest1; + } + + public int getQuest2() { + return quest2; + } + + public void setQuest2(int quest2) { + this.quest2 = quest2; + } + + public int getClaimed() { + return claimed; + } + + public void setClaimed(int claimed) { + this.claimed = claimed; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PushDevice.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PushDevice.java new file mode 100644 index 00000000000..254367c0f98 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/PushDevice.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false, type = "VARCHAR(16)") + private String provider = ""; + + @Column(nullable = false, type = "VARCHAR(1024)") + private String token = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceEntry.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceEntry.java new file mode 100644 index 00000000000..1ca0f30140b --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceEntry.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One player's line of a [RaceRecord], so a player's history is an indexed +/// lookup rather than a search through comma-separated lists. +@Entity +public class RaceEntry { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(230)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String raceId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(160)") + private String level = ""; + + @Column(nullable = false) + private int place; + + @Column(nullable = false) + private int runners; + + @Column(nullable = false) + private int score; + + @Column(nullable = false) + private int trophyChange; + + @Column(nullable = false) + private int ticks; + + @Column(nullable = false) + private Instant endedAt = Instant.EPOCH; + + public RaceEntry() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRaceId() { + return raceId; + } + + public void setRaceId(String raceId) { + this.raceId = raceId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public int getPlace() { + return place; + } + + public void setPlace(int place) { + this.place = place; + } + + public int getRunners() { + return runners; + } + + public void setRunners(int runners) { + this.runners = runners; + } + + public int getScore() { + return score; + } + + public void setScore(int score) { + this.score = score; + } + + public int getTrophyChange() { + return trophyChange; + } + + public void setTrophyChange(int trophyChange) { + this.trophyChange = trophyChange; + } + + public int getTicks() { + return ticks; + } + + public void setTicks(int ticks) { + this.ticks = ticks; + } + + public Instant getEndedAt() { + return endedAt; + } + + public void setEndedAt(Instant endedAt) { + this.endedAt = endedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceRecord.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceRecord.java new file mode 100644 index 00000000000..37c5a5b09f0 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/RaceRecord.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A finished race: the level, who ran, where they placed, and the replay that +/// reproduces every runner. Lists are comma separated, in seat order; a bot's +/// username is empty. +@Entity +public class RaceRecord { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(160)") + private String level = ""; + + @Column(nullable = false, type = "VARCHAR(1200)") + private String players = ""; + + @Column(nullable = false, type = "VARCHAR(300)") + private String names = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String places = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String scores = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String trophies = ""; + + @Column(nullable = false) + private int runners; + + @Column(nullable = false) + private int humans; + + @Column(nullable = false) + private int ticks; + + @Column(nullable = false) + private Instant endedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(16)") + private String rulesVersion = ""; + + @Column(nullable = false) + private String replay = ""; + + public RaceRecord() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public String getPlayers() { + return players; + } + + public void setPlayers(String players) { + this.players = players; + } + + public String getNames() { + return names; + } + + public void setNames(String names) { + this.names = names; + } + + public String getPlaces() { + return places; + } + + public void setPlaces(String places) { + this.places = places; + } + + public String getScores() { + return scores; + } + + public void setScores(String scores) { + this.scores = scores; + } + + public String getTrophies() { + return trophies; + } + + public void setTrophies(String trophies) { + this.trophies = trophies; + } + + public int getRunners() { + return runners; + } + + public void setRunners(int runners) { + this.runners = runners; + } + + public int getHumans() { + return humans; + } + + public void setHumans(int humans) { + this.humans = humans; + } + + public int getTicks() { + return ticks; + } + + public void setTicks(int ticks) { + this.ticks = ticks; + } + + public Instant getEndedAt() { + return endedAt; + } + + public void setEndedAt(Instant endedAt) { + this.endedAt = endedAt; + } + + public String getRulesVersion() { + return rulesVersion; + } + + public void setRulesVersion(String rulesVersion) { + this.rulesVersion = rulesVersion; + } + + public String getReplay() { + return replay; + } + + public void setReplay(String replay) { + this.replay = replay; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Referral.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Referral.java new file mode 100644 index 00000000000..b9534c59a49 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Referral.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player who joined through someone's invite, keyed by that player: one +/// inviter each, for good. +@Entity +public class Referral { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String invitee = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String inviter = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String campaign = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String channel = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String matchType = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String state = ""; + + @Column(nullable = false) + private boolean rewarded; + + @Column(nullable = false) + private Instant joinedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant playedAt = Instant.EPOCH; + + public Referral() { + } + + public String getInvitee() { + return invitee; + } + + public void setInvitee(String invitee) { + this.invitee = invitee; + } + + public String getInviter() { + return inviter; + } + + public void setInviter(String inviter) { + this.inviter = inviter; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getCampaign() { + return campaign; + } + + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public String getMatchType() { + return matchType; + } + + public void setMatchType(String matchType) { + this.matchType = matchType; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public boolean isRewarded() { + return rewarded; + } + + public void setRewarded(boolean rewarded) { + this.rewarded = rewarded; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(Instant joinedAt) { + this.joinedAt = joinedAt; + } + + public Instant getPlayedAt() { + return playedAt; + } + + public void setPlayedAt(Instant playedAt) { + this.playedAt = playedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ReferralCode.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ReferralCode.java new file mode 100644 index 00000000000..b20efac141c --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/ReferralCode.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's invite code: what their links carry, so the server knows whose friend +/// has joined. +@Entity +public class ReferralCode { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public ReferralCode() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Score.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Score.java new file mode 100644 index 00000000000..267c02ae77a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Score.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's best verified run on one level for one board: `all`, `w` +/// or `d`. The id is `board|level|username`, so there is one row per +/// player per board and a better run replaces it. +@Entity +public class Score { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(400)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String board = ""; + + @Column(nullable = false, type = "VARCHAR(160)") + private String level = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String displayName = ""; + + @Column(nullable = false) + private int score; + + @Column(nullable = false) + private int distance; + + @Column(nullable = false) + private int coins; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private long seed; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private String replay = ""; + + public Score() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBoard() { + return board; + } + + public void setBoard(String board) { + this.board = board; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public int getScore() { + return score; + } + + public void setScore(int score) { + this.score = score; + } + + public int getDistance() { + return distance; + } + + public void setDistance(int distance) { + this.distance = distance; + } + + public int getCoins() { + return coins; + } + + public void setCoins(int coins) { + this.coins = coins; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public long getSeed() { + return seed; + } + + public void setSeed(long seed) { + this.seed = seed; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getReplay() { + return replay; + } + + public void setReplay(String replay) { + this.replay = replay; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Spotlight.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Spotlight.java new file mode 100644 index 00000000000..c53ecac39bd --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/Spotlight.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// The home screen's banner. One row, `home`. +@Entity +public class Spotlight { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(10)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(140)") + private String message = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String packId = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String updatedBy = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Spotlight() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPackId() { + return packId; + } + + public void setPackId(String packId) { + this.packId = packId; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/StorePurchase.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/StorePurchase.java new file mode 100644 index 00000000000..a9672f0f1aa --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/domain/StorePurchase.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A verified store transaction. The key is the store's transaction id, so a receipt can only ever be redeemed once. +@Entity +public class StorePurchase { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String store = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String sku = ""; + + @Column(nullable = false) + private int gems; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public StorePurchase() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getStore() { + return store; + } + + public void setStore(String store) { + this.store = store; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public int getGems() { + return gems; + } + + public void setGems(int gems) { + this.gems = gems; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Forge.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Forge.java new file mode 100644 index 00000000000..a0f286f067c --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Forge.java @@ -0,0 +1,861 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.backend.Base64; +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.account.Lists; +import com.codenameone.examples.dash.api.AssetDto; +import com.codenameone.examples.dash.api.AssetUploadDto; +import com.codenameone.examples.dash.api.LevelOutcomeDto; +import com.codenameone.examples.dash.api.LevelStarsDto; +import com.codenameone.examples.dash.api.PackDto; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.domain.AdminAction; +import com.codenameone.examples.dash.domain.ForgeAsset; +import com.codenameone.examples.dash.domain.ForgeEarning; +import com.codenameone.examples.dash.domain.ForgePack; +import com.codenameone.examples.dash.domain.ForgePackVersion; +import com.codenameone.examples.dash.domain.ForgeRating; +import com.codenameone.examples.dash.domain.LevelStars; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.progress.Progression; +import com.codenameone.examples.dash.repository.AdminActionRepository; +import com.codenameone.examples.dash.repository.ForgeAssetRepository; +import com.codenameone.examples.dash.repository.ForgeEarningRepository; +import com.codenameone.examples.dash.repository.ForgePackRepository; +import com.codenameone.examples.dash.repository.ForgePackVersionRepository; +import com.codenameone.examples.dash.repository.ForgeRatingRepository; +import com.codenameone.examples.dash.repository.LevelStarsRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.forge.AssetRef; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.forge.ContentRef; +import com.codenameone.examples.dash.rules.forge.GlbInspector; +import com.codenameone.examples.dash.rules.Json; +import com.codenameone.examples.dash.rules.forge.LevelRun; +import com.codenameone.examples.dash.rules.forge.PackCodec; +import com.codenameone.examples.dash.rules.forge.Samples; +import com.codenameone.examples.dash.rules.forge.Validator; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// The Forge on the server: players' content packs from draft to published +/// version, the files they upload, ratings and reports, and the stars and +/// rewards their levels pay. +/// +/// Nothing a client sends is trusted. A draft is only parsed; publishing runs +/// the same `Validator` the editor runs, and a level result is a replay the +/// server plays again with its own bot (`LevelRun.verify`) before it counts a +/// single star. +@Component +public class Forge { + public static final String DRAFT = "draft"; + public static final String PUBLISHED = "published"; + public static final String HIDDEN = "hidden"; + public static final String DELETED = "deleted"; + + public static final String PENDING = "pending"; + public static final String APPROVED = "approved"; + public static final String REJECTED = "rejected"; + + /// The owner of the packs the game ships with. + public static final String SYSTEM = "system"; + + public static final int MAX_PACKS_PER_PLAYER = 20; + public static final int MAX_UPLOADS_PER_DAY = 20; + /// Reports from this many players hide a pack until a moderator looks. + public static final int REPORTS_TO_HIDE = 3; + /// A player's own pack, or a friend's, must not be a coin farm: an + /// unofficial level pays at most this much, and all of them together at + /// most `DAILY_COMMUNITY_COINS` a day. Official levels pay what their + /// designer set. + /// What each star of an official level pays the first time. + public static final int OFFICIAL_STAR_COINS = 20; + public static final int OFFICIAL_STAR_XP = 15; + public static final int COMMUNITY_LEVEL_COINS = 30; + public static final int COMMUNITY_LEVEL_XP = 20; + public static final int DAILY_COMMUNITY_COINS = 150; + + private static final int PAGE = 50; + + private final ForgePackRepository packs; + private final ForgePackVersionRepository versions; + private final ForgeAssetRepository assets; + private final LevelStarsRepository stars; + private final ForgeRatingRepository ratings; + private final ForgeEarningRepository earnings; + private final ProfileRepository profiles; + private final AdminActionRepository audit; + private final Progression progression; + private final Scores scores; + + public Forge(ForgePackRepository packs, ForgePackVersionRepository versions, ForgeAssetRepository assets, + LevelStarsRepository stars, ForgeRatingRepository ratings, ForgeEarningRepository earnings, + ProfileRepository profiles, AdminActionRepository audit, Progression progression, Scores scores) { + this.scores = scores; + this.packs = packs; + this.versions = versions; + this.assets = assets; + this.stars = stars; + this.ratings = ratings; + this.earnings = earnings; + this.profiles = profiles; + this.audit = audit; + this.progression = progression; + } + + /// Publishes the official campaign the first time the server starts, and + /// again whenever the game's own copy of it changes. + @PostConstruct + @Transactional(rollbackFor = IOException.class) + public void seedOfficial() throws IOException { + ContentPack official = Samples.official(); + ForgePack p = packs.findById(official.id); + boolean fresh = p == null; + if (fresh) { + p = new ForgePack(); + p.setId(official.id); + p.setOwner(SYSTEM); + p.setCreatedAt(Instant.now()); + } else if (!SYSTEM.equals(p.getOwner())) { + return; + } + official.version = p.getVersion(); + String canonical = PackCodec.write(official); + if (!fresh && ContentPack.hashOf(canonical).equals(p.getHash())) { + return; + } + p.setDraft(canonical); + p.setOfficial(true); + publishVersion(p, official, fresh); + } + + // ---- browsing ------------------------------------------------------- + + @Transactional(readOnly = true) + public List list(String username, String scope) throws IOException { + PageRequest page = new PageRequest(0, PAGE); + List rows; + if ("mine".equals(scope)) { + rows = packs.ownedBy(username, page); + List live = new ArrayList<>(); + for (ForgePack p : rows) { + if (!DELETED.equals(p.getStatus())) { + live.add(p); + } + } + rows = live; + } else if ("official".equals(scope)) { + rows = packs.official(PUBLISHED, true, page); + } else if ("new".equals(scope)) { + rows = packs.newest(PUBLISHED, false, page); + } else { + rows = packs.popular(PUBLISHED, false, page); + } + return summaries(rows); + } + + /// The newest published version. Anyone with the link may open a published + /// pack, even one whose files are still waiting for a moderator: that is + /// how a player shares a pack with friends before it is listed. + @Transactional(readOnly = true) + public PackDto pack(String username, boolean admin, String id) throws IOException { + ForgePack p = visible(username, admin, id); + if (p.getVersion() == 0) { + throw new ResponseStatusException(404, "That pack has not been published yet"); + } + return version(username, admin, id, p.getVersion()); + } + + @Transactional(readOnly = true) + public PackDto version(String username, boolean admin, String id, int version) throws IOException { + ForgePack p = visible(username, admin, id); + ForgePackVersion v = versions.findById(versionId(id, version)); + if (v == null) { + throw new ResponseStatusException(404, "No such version"); + } + return new PackDto(summary(p), v.getContent()); + } + + @Transactional(readOnly = true) + public PackDto draft(String username, String id) throws IOException { + ForgePack p = owned(username, id); + return new PackDto(summary(p), p.getDraft()); + } + + // ---- editing -------------------------------------------------------- + + /// Saves a draft. It is parsed, so it is known to be a pack, but not + /// validated: a half-built level is a normal thing to save. + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto saveDraft(String username, String content) throws IOException { + ContentPack pack = parse(content); + if (!Validator.isSlug(pack.id, 3, 40)) { + throw new ResponseStatusException(400, "A pack id is 3 to 40 lower-case letters, digits and dashes"); + } + ForgePack p = packs.findById(pack.id); + if (p == null) { + if (packs.countByOwner(username) >= MAX_PACKS_PER_PLAYER) { + throw new ResponseStatusException(409, "You have " + MAX_PACKS_PER_PLAYER + " packs already"); + } + p = new ForgePack(); + p.setId(pack.id); + p.setOwner(username); + p.setStatus(DRAFT); + p.setCreatedAt(Instant.now()); + fillDraft(p, pack, username); + packs.insert(p); + } else { + if (!username.equals(p.getOwner())) { + throw new ResponseStatusException(409, "That pack id is taken"); + } + if (DELETED.equals(p.getStatus())) { + p.setStatus(p.getVersion() == 0 ? DRAFT : PUBLISHED); + } + fillDraft(p, pack, username); + packs.save(p); + } + return summary(p); + } + + /// Validates the draft and makes it the pack's next version. + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto publish(String username, String id) throws IOException { + ForgePack p = owned(username, id); + ContentPack pack = parse(p.getDraft()); + List problems = Validator.check(pack); + if (!problems.isEmpty()) { + throw new ResponseStatusException(400, problems.get(0)); + } + publishVersion(p, pack, false); + return summary(p); + } + + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto delete(String username, boolean admin, String id) throws IOException { + ForgePack p = admin ? existing(id) : owned(username, id); + if (SYSTEM.equals(p.getOwner())) { + throw new ResponseStatusException(403, "The game's own packs cannot be deleted"); + } + // Versions are kept: a replay names the version it was played on. + p.setStatus(DELETED); + p.setUpdatedAt(Instant.now()); + packs.save(p); + return summary(p); + } + + /// Copies a published pack into a new draft of the caller's own, the way a + /// player remixes a friend's campaign. + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto fork(String username, boolean admin, String id, String newId) throws IOException { + PackDto source = pack(username, admin, id); + ContentPack pack = parse(source.content()); + pack.id = newId == null ? "" : newId; + pack.version = 0; + PackSummaryDto made = saveDraft(username, PackCodec.write(pack)); + ForgePack p = packs.findById(made.id()); + p.setForkedFrom(id); + packs.save(p); + return summary(p); + } + + // ---- ratings and reports --------------------------------------------- + + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto rate(String username, String packId, int value) throws IOException { + if (value < 1 || value > 5) { + throw new ResponseStatusException(400, "A rating is one to five stars"); + } + ForgePack p = listed(packId); + if (username.equals(p.getOwner())) { + throw new ResponseStatusException(409, "You cannot rate your own pack"); + } + String key = Ids.sha256("rate|" + username + "|" + packId); + ForgeRating r = ratings.findById(key); + if (r == null) { + r = new ForgeRating(); + r.setId(key); + r.setUsername(username); + r.setPackId(packId); + r.setStars(value); + ratings.insert(r); + p.setRatingCount(p.getRatingCount() + 1); + p.setRatingSum(p.getRatingSum() + value); + } else { + p.setRatingSum(p.getRatingSum() - r.getStars() + value); + r.setStars(value); + ratings.save(r); + } + packs.save(p); + return summary(p); + } + + /// Counts a player's report once, and hides the pack for review once + /// enough players agree. The row lives beside the ratings, keyed apart + /// from them, so the rating sums never see it. + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto report(String username, String packId, String reason) throws IOException { + ForgePack p = listed(packId); + String key = Ids.sha256("report|" + username + "|" + packId); + if (ratings.findById(key) == null && !username.equals(p.getOwner())) { + ForgeRating r = new ForgeRating(); + r.setId(key); + r.setUsername(username); + r.setPackId(packId); + ratings.insert(r); + p.setReports(p.getReports() + 1); + if (p.getReports() >= REPORTS_TO_HIDE && !p.isOfficial()) { + p.setStatus(HIDDEN); + } + packs.save(p); + record("player:" + username, packId, "REPORT", reason == null ? "" : reason); + } + return summary(p); + } + + // ---- files ------------------------------------------------------------ + + /// Stores a model or an image. Files are addressed by their SHA-256, so the + /// same file uploaded twice is stored once, and a pack names exactly the + /// bytes it was published with. Every new file waits for a moderator + /// before a pack using it is listed. + @Transactional(rollbackFor = IOException.class) + public AssetDto upload(String username, AssetUploadDto file) throws IOException { + if (file == null || file.data() == null) { + throw new ResponseStatusException(400, "Nothing uploaded"); + } + String kind = AssetRef.IMAGE.equals(file.kind()) ? AssetRef.IMAGE : AssetRef.MODEL; + byte[] bytes = Base64.decode(file.data()); + if (bytes == null || bytes.length == 0) { + throw new ResponseStatusException(400, "The file is not base64"); + } + List animations = new ArrayList<>(); + if (AssetRef.MODEL.equals(kind)) { + GlbInspector.Report report = GlbInspector.inspect(bytes); + if (!report.ok()) { + throw new ResponseStatusException(400, "That model cannot be used: " + report.problem); + } + animations = report.animations; + } else if (bytes.length > Validator.MAX_IMAGE_BYTES || !isImage(bytes)) { + throw new ResponseStatusException(400, "An image is a PNG or JPEG of at most 1 MB"); + } + String sha = Ids.hex(Crypto.sha256(bytes)); + ForgeAsset a = assets.findById(sha); + if (a != null) { + return asset(a, false); + } + Instant since = Instant.now().minusMillis(Clock.DAY); + if (assets.countByOwnerAndCreatedAtGreaterThan(username, since) >= MAX_UPLOADS_PER_DAY) { + throw new ResponseStatusException(429, "That is enough uploads for today"); + } + a = new ForgeAsset(); + a.setSha256(sha); + a.setOwner(username); + a.setName(clip(file.name(), 80)); + a.setKind(kind); + a.setBytes(bytes.length); + a.setStatus(PENDING); + a.setAnimations(clip(join(animations), 2000)); + a.setData(file.data()); + a.setCreatedAt(Instant.now()); + assets.insert(a); + return asset(a, false); + } + + /// A file's bytes. A rejected file is gone for everyone but its owner and + /// the moderators. + @Transactional(readOnly = true) + public AssetDto download(String username, boolean admin, String sha) throws IOException { + ForgeAsset a = assets.findById(sha); + if (a == null || REJECTED.equals(a.getStatus()) && !admin && !username.equals(a.getOwner())) { + throw new ResponseStatusException(404, "No such file"); + } + return asset(a, true); + } + + // ---- levels ---------------------------------------------------------- + + /// Counts a finished level. The replay names the pack version it was + /// played on; the server plays the player's own commands again against + /// its own bot and scores what happens, whatever the app claimed. + /// + /// The seed is the app's choice, so a determined player could search for + /// a lucky one offline. Stars are capped by what the level can pay, and + /// community levels by a daily total, which is what keeps that harmless; + /// the boards take only the seeds the app was handed ([Scores#fairSeed]). + @Transactional(rollbackFor = IOException.class) + public LevelOutcomeDto result(String username, String encoded, String city) throws IOException { + Replay replay; + try { + replay = Replay.decode(encoded); + } catch (RuntimeException e) { + throw new ResponseStatusException(400, "That is not a run replay"); + } + if (replay.levelId().startsWith(CityRoute.ID_PREFIX)) { + return cityResult(username, replay, city); + } + ContentRef ref = replay.levelId().indexOf('@') < 0 ? null : ContentRef.parse(replay.levelId()); + String packId = ref == null ? Samples.OFFICIAL_ID : ref.pack; + String levelId = ref == null ? replay.levelId() : ref.level; + ForgePack p = existing(packId); + ContentPack pack = ref == null ? Samples.official() : parse(contentWithHash(p, ref.hash)); + LevelDef level = pack.level(levelId); + if (level == null) { + throw new ResponseStatusException(404, "No such level"); + } + // tag::dash-forge-result[] + Run played; + try { + played = LevelRun.verify(pack, levelId, replay); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(400, e.getMessage()); + } + int won = played.stars(0); + int score = played.runner(0).score(); + int paidBefore = paidStars(username, p.getId(), levelId); + LevelStars row = record(username, p.getId(), levelId, won, score); + int newStars = row.getPaidStars() - paidBefore; + p.setPlays(p.getPlays() + 1); + packs.save(p); + + int coins = OFFICIAL_STAR_COINS * newStars; + int xp = OFFICIAL_STAR_XP * newStars; + if (username.equals(p.getOwner())) { + // Testing your own level is free, and pays nothing. + coins = 0; + xp = 0; + } else if (!p.isOfficial()) { + coins = Math.min(coins, COMMUNITY_LEVEL_COINS * newStars / 3); + xp = Math.min(xp, COMMUNITY_LEVEL_XP * newStars / 3); + coins = spendDailyAllowance(username, coins); + } + // end::dash-forge-result[] + int rank = scores.submit(username, ref == null ? levelId : packId + "/" + levelId, replay, played); + RewardDto reward = progression.levelReward(username, level.name, coins, xp); + return new LevelOutcomeDto(p.getId(), levelId, won, score, row.getBest(), rank, reward); + } + + /// A city run. The result carries the route, and the server builds the + /// level from it ([CityRoute#level]) rather than trusting anything about + /// it: the level's id is a hash of the route, so a route edited to an + /// easier one names another level, with a board of its own. Coins and XP + /// both come out of the community levels' daily allowance, since a new + /// route is always a new level. + private LevelOutcomeDto cityResult(String username, Replay replay, String spec) throws IOException { + CityRoute route; + try { + route = CityRoute.decode(spec); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(400, "That is not a city route"); + } + LevelDef level = route.level(); + if (!level.id.equals(replay.levelId())) { + throw new ResponseStatusException(400, "That run is of another route"); + } + Run played; + try { + played = LevelRun.verify(level, Content.catalog(), replay); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(400, e.getMessage()); + } + int won = played.stars(0); + int score = played.runner(0).score(); + int paidBefore = paidStars(username, CITY_PACK, level.id); + LevelStars row = record(username, CITY_PACK, level.id, won, score); + int newStars = row.getPaidStars() - paidBefore; + int coins = spendDailyAllowance(username, COMMUNITY_LEVEL_COINS * newStars / 3); + // XP follows the coins the allowance granted: any edit of a route is a + // new level with unpaid stars, so an uncapped XP would farm forever. + int xp = COMMUNITY_LEVEL_XP * coins / COMMUNITY_LEVEL_COINS; + int rank = scores.submit(username, level.id, replay, played); + RewardDto reward = progression.levelReward(username, level.name, coins, xp); + return new LevelOutcomeDto(CITY_PACK, level.id, won, score, row.getBest(), rank, reward); + } + + /// The pack id the stars of city runs are kept under; no pack has it. + static final String CITY_PACK = "city"; + + private int paidStars(String username, String packId, String levelId) throws IOException { + LevelStars row = stars.findById(Ids.sha256(username + "|" + packId + "|" + levelId)); + return row == null ? 0 : row.getPaidStars(); + } + + /// Counts a play of a level and keeps the best stars and score; the + /// stars not paid for before are marked paid. + private LevelStars record(String username, String packId, String levelId, int won, int score) + throws IOException { + String key = Ids.sha256(username + "|" + packId + "|" + levelId); + LevelStars row = stars.findById(key); + if (row == null) { + row = new LevelStars(); + row.setId(key); + row.setUsername(username); + row.setPackId(packId); + row.setLevelId(levelId); + stars.insert(row); + } + row.setPlays(row.getPlays() + 1); + row.setStars(Math.max(row.getStars(), won)); + row.setBest(Math.max(row.getBest(), score)); + row.setPaidStars(Math.max(row.getPaidStars(), won)); + row.setUpdatedAt(Instant.now()); + stars.save(row); + return row; + } + + @Transactional(readOnly = true) + public List stars(String username, String packId) throws IOException { + List out = new ArrayList<>(); + for (LevelStars m : stars.of(username, packId, new PageRequest(0, Validator.MAX_LEVELS))) { + out.add(new LevelStarsDto(m.getPackId(), m.getLevelId(), m.getStars(), m.getBest())); + } + return out; + } + + /// The published content of a pack, for a race or a leaderboard; null when + /// it is not public. + @Transactional(readOnly = true) + public ContentPack published(String packId) throws IOException { + if (Samples.OFFICIAL_ID.equals(packId)) { + return Samples.official(); + } + ForgePack p = packs.findById(packId); + if (p == null || !PUBLISHED.equals(p.getStatus())) { + return null; + } + return parse(contentWithHash(p, p.getHash())); + } + + /// One exact version of a pack, as a replay names it. + @Transactional(readOnly = true) + public ContentPack atHash(String packId, String hash) throws IOException { + if (Samples.OFFICIAL_ID.equals(packId)) { + return Samples.official(); + } + return parse(contentWithHash(existing(packId), hash)); + } + + // ---- moderation --------------------------------------------------------- + + @Transactional(readOnly = true) + public List queue() throws IOException { + return summaries(packs.queue(0, HIDDEN, new PageRequest(0, PAGE))); + } + + /// Files waiting for a moderator, without their bytes; `download` fetches + /// one to look at. + @Transactional(readOnly = true) + public List pendingAssets() throws IOException { + List out = new ArrayList<>(); + for (ForgeAsset a : assets.inStatus(PENDING, new PageRequest(0, PAGE))) { + out.add(asset(a, false)); + } + return out; + } + + /// `hide`, `restore`, `official`, `unofficial` or `delete`. + @Transactional(rollbackFor = IOException.class) + public PackSummaryDto packAction(String admin, String id, String action, String reason) throws IOException { + ForgePack p = existing(id); + if ("hide".equals(action)) { + p.setStatus(HIDDEN); + } else if ("restore".equals(action)) { + p.setStatus(p.getVersion() == 0 ? DRAFT : PUBLISHED); + p.setReports(0); + } else if ("official".equals(action) || "unofficial".equals(action)) { + // The game's designer builds a campaign in the same editor any + // player has, and promotes it here. + p.setOfficial("official".equals(action)); + } else if ("delete".equals(action)) { + p.setStatus(DELETED); + } else { + throw new ResponseStatusException(400, "Unknown action"); + } + p.setUpdatedAt(Instant.now()); + packs.save(p); + record(admin, id, "PACK_" + action, reason); + return summary(p); + } + + /// `approve` or `reject`. Either can list a pack that was waiting. + @Transactional(rollbackFor = IOException.class) + public AssetDto assetAction(String admin, String sha, String action, String reason) throws IOException { + ForgeAsset a = assets.findById(sha); + if (a == null) { + throw new ResponseStatusException(404, "No such file"); + } + if ("approve".equals(action)) { + a.setStatus(APPROVED); + } else if ("reject".equals(action)) { + a.setStatus(REJECTED); + } else { + throw new ResponseStatusException(400, "Unknown action"); + } + assets.save(a); + for (ForgePack p : packs.awaitingAssets(true, new PageRequest(0, 500))) { + ForgePackVersion v = versions.findById(versionId(p.getId(), p.getVersion())); + if (v != null) { + String waiting = waitingOn(parse(v.getContent())); + p.setPendingAssets(waiting != null); + packs.save(p); + } + } + record(admin, a.getOwner(), "ASSET_" + action, + clip(a.getName() + ": " + (reason == null ? "" : reason), 255)); + return asset(a, false); + } + + // ---- helpers ------------------------------------------------------------ + + private void publishVersion(ForgePack p, ContentPack pack, boolean insert) throws IOException { + String missing = missingAsset(pack); + if (missing != null) { + throw new ResponseStatusException(400, "The file " + missing + " was never uploaded, or was rejected"); + } + int version = p.getVersion() + 1; + pack.version = version; + String canonical = PackCodec.write(pack); + String hash = ContentPack.hashOf(canonical); + ForgePackVersion v = new ForgePackVersion(); + v.setId(versionId(p.getId(), version)); + v.setPackId(p.getId()); + v.setVersion(version); + v.setHash(hash); + v.setContent(canonical); + v.setPublishedAt(Instant.now()); + versions.insert(v); + p.setVersion(version); + p.setHash(hash); + p.setName(clip(pack.name, 60)); + p.setAuthor(clip(pack.author, 60)); + p.setDescription(clip(pack.description, 1200)); + p.setLevels(pack.levels.size()); + p.setDefs(pack.defs.size()); + p.setPendingAssets(waitingOn(pack) != null); + if (!HIDDEN.equals(p.getStatus())) { + p.setStatus(PUBLISHED); + } + p.setUpdatedAt(Instant.now()); + if (insert) { + packs.insert(p); + } else { + packs.save(p); + } + } + + /// The first file the pack names that the server does not have, or a + /// moderator rejected. + private String missingAsset(ContentPack pack) throws IOException { + for (AssetRef r : pack.assets) { + ForgeAsset a = assets.findById(r.sha256); + if (a == null || REJECTED.equals(a.getStatus())) { + return r.name.isEmpty() ? r.sha256 : r.name; + } + } + return null; + } + + private String waitingOn(ContentPack pack) throws IOException { + for (AssetRef r : pack.assets) { + ForgeAsset a = assets.findById(r.sha256); + if (a == null || !APPROVED.equals(a.getStatus())) { + return r.sha256; + } + } + return null; + } + + /// The content of the version with this hash: a level is scored against + /// exactly what was played, even after the author has published another. + private String contentWithHash(ForgePack p, String hash) throws IOException { + if (hash != null && hash.equals(p.getHash())) { + ForgePackVersion v = versions.findById(versionId(p.getId(), p.getVersion())); + if (v != null) { + return v.getContent(); + } + } + for (ForgePackVersion v : versions.of(p.getId(), new PageRequest(0, 200))) { + if (v.getHash().equals(hash)) { + return v.getContent(); + } + } + throw new ResponseStatusException(404, "That version of the pack is gone"); + } + + /// Takes up to `coins` from what the player may still earn from community + /// levels today. + private int spendDailyAllowance(String username, int coins) throws IOException { + if (coins <= 0) { + return 0; + } + long day = Clock.dayOf(Clock.now()); + String key = Ids.sha256(username + "|" + day); + ForgeEarning e = earnings.findById(key); + if (e == null) { + e = new ForgeEarning(); + e.setId(key); + e.setUsername(username); + e.setDay(day); + earnings.insert(e); + } + int paid = Math.min(coins, Math.max(0, DAILY_COMMUNITY_COINS - e.getCoins())); + e.setCoins(e.getCoins() + paid); + earnings.save(e); + return paid; + } + + private void fillDraft(ForgePack p, ContentPack pack, String username) throws IOException { + // The byline is the player's race name, never what the file says. + Profile who = profiles.findById(username); + pack.author = who == null ? username : who.getDisplayName(); + p.setDraft(PackCodec.write(pack)); + p.setName(clip(pack.name, 60)); + if (p.getVersion() == 0) { + p.setAuthor(clip(pack.author, 60)); + p.setDescription(clip(pack.description, 1200)); + p.setLevels(pack.levels.size()); + p.setDefs(pack.defs.size()); + } + p.setUpdatedAt(Instant.now()); + } + + private static ContentPack parse(String content) { + if (content == null || content.length() > Json.MAX_CHARS) { + throw new ResponseStatusException(400, "A pack is at most " + (Json.MAX_CHARS / 1024) + " KB"); + } + try { + return PackCodec.read(content); + } catch (RuntimeException e) { + throw new ResponseStatusException(400, "That is not a content pack: " + e.getMessage()); + } + } + + private ForgePack existing(String id) throws IOException { + ForgePack p = id == null ? null : packs.findById(id); + if (p == null) { + throw new ResponseStatusException(404, "No such pack"); + } + return p; + } + + private ForgePack owned(String username, String id) throws IOException { + ForgePack p = existing(id); + if (!username.equals(p.getOwner()) || DELETED.equals(p.getStatus())) { + throw new ResponseStatusException(404, "No such pack"); + } + return p; + } + + /// A pack the caller may open: deleted ones are gone, hidden ones are for + /// the owner and the moderators. + private ForgePack visible(String username, boolean admin, String id) throws IOException { + ForgePack p = existing(id); + if (admin) { + return p; + } + if (DELETED.equals(p.getStatus()) || HIDDEN.equals(p.getStatus()) && !username.equals(p.getOwner())) { + throw new ResponseStatusException(404, "No such pack"); + } + return p; + } + + private ForgePack listed(String id) throws IOException { + ForgePack p = existing(id); + if (!PUBLISHED.equals(p.getStatus()) && !HIDDEN.equals(p.getStatus())) { + throw new ResponseStatusException(404, "No such pack"); + } + return p; + } + + private void record(String admin, String target, String action, String reason) throws IOException { + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(clip(admin, 190)); + a.setUsername(clip("pack:" + target, 190)); + a.setAction(clip(action, 24)); + a.setReason(clip(reason == null ? "" : reason, 255)); + a.setCreatedAt(Instant.now()); + audit.insert(a); + } + + private static String versionId(String id, int version) { + return id + ":" + version; + } + + private static List summaries(List rows) { + List out = new ArrayList<>(); + for (ForgePack p : rows) { + out.add(summary(p)); + } + return out; + } + + static PackSummaryDto summary(ForgePack p) { + int rating = p.getRatingCount() == 0 ? 0 : (p.getRatingSum() * 10 + p.getRatingCount() / 2) / p.getRatingCount(); + return new PackSummaryDto(p.getId(), p.getName(), p.getAuthor(), p.getOwner(), p.getDescription(), + p.getVersion(), p.getHash(), p.isOfficial(), p.getStatus(), p.getPlays(), rating, + p.getRatingCount(), p.getLevels(), p.getDefs(), p.isPendingAssets(), p.getUpdatedAt().toEpochMilli()); + } + + private static AssetDto asset(ForgeAsset a, boolean withData) { + List anims = Lists.split(a.getAnimations()); + return new AssetDto(a.getSha256(), a.getName(), a.getKind(), a.getBytes(), a.getStatus(), anims, + withData ? a.getData() : null); + } + + private static boolean isImage(byte[] b) { + boolean png = b.length > 8 && (b[0] & 0xff) == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G'; + boolean jpeg = b.length > 3 && (b[0] & 0xff) == 0xff && (b[1] & 0xff) == 0xd8; + return png || jpeg; + } + + private static String join(List parts) { + StringBuilder sb = new StringBuilder(); + for (String s : parts) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(s.replace(',', '_')); + } + return sb.toString(); + } + + private static String clip(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/ForgeEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/ForgeEndpoint.java new file mode 100644 index 00000000000..e85ac0ea2e9 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/ForgeEndpoint.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.Roles; +import com.codenameone.examples.dash.api.AssetDto; +import com.codenameone.examples.dash.api.AssetUploadDto; +import com.codenameone.examples.dash.api.ForgeApiServer; +import com.codenameone.examples.dash.api.ForkDto; +import com.codenameone.examples.dash.api.LevelOutcomeDto; +import com.codenameone.examples.dash.api.LevelResultDto; +import com.codenameone.examples.dash.api.LevelStarsDto; +import com.codenameone.examples.dash.api.PackDto; +import com.codenameone.examples.dash.api.PackReportDto; +import com.codenameone.examples.dash.api.PackSaveDto; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.api.RateDto; +import java.util.List; + +/// The Forge's REST surface; `Forge` does the work. +@Component +public class ForgeEndpoint implements ForgeApiServer { + private final Forge forge; + + public ForgeEndpoint(Forge forge) { + this.forge = forge; + } + + private static boolean admin() { + return Caller.hasRole(Roles.ADMIN); + } + + @Override + public List packs(String scope) throws Exception { + return forge.list(Caller.name(), scope); + } + + @Override + public PackDto pack(String id) throws Exception { + return forge.pack(Caller.name(), admin(), id); + } + + @Override + public PackDto version(String id, int version) throws Exception { + return forge.version(Caller.name(), admin(), id, version); + } + + @Override + public PackDto draft(String id) throws Exception { + return forge.draft(Caller.name(), id); + } + + @Override + public PackSummaryDto saveDraft(PackSaveDto body) throws Exception { + return forge.saveDraft(Caller.name(), body == null ? null : body.content()); + } + + @Override + public PackSummaryDto publish(String id) throws Exception { + return forge.publish(Caller.name(), id); + } + + @Override + public PackSummaryDto delete(String id) throws Exception { + return forge.delete(Caller.name(), false, id); + } + + @Override + public PackSummaryDto fork(String id, ForkDto body) throws Exception { + return forge.fork(Caller.name(), admin(), id, body == null ? null : body.newId()); + } + + @Override + public PackSummaryDto rate(RateDto body) throws Exception { + return forge.rate(Caller.name(), body == null ? null : body.packId(), body == null ? 0 : body.stars()); + } + + @Override + public PackSummaryDto report(PackReportDto body) throws Exception { + return forge.report(Caller.name(), body == null ? null : body.packId(), body == null ? null : body.reason()); + } + + @Override + public AssetDto upload(AssetUploadDto body) throws Exception { + return forge.upload(Caller.name(), body); + } + + @Override + public AssetDto asset(String sha256) throws Exception { + return forge.download(Caller.name(), admin(), sha256); + } + + @Override + public LevelOutcomeDto result(LevelResultDto body) throws Exception { + return forge.result(Caller.name(), body == null ? null : body.replay(), body == null ? null : body.city()); + } + + @Override + public List stars(String id) throws Exception { + return forge.stars(Caller.name(), id); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Levels.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Levels.java new file mode 100644 index 00000000000..49f48b80a27 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Levels.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.forge.LevelRun; +import com.codenameone.examples.dash.rules.forge.Samples; +import java.io.IOException; + +/// Turns the level names the API takes into something to run. A level is an +/// official id (`neon-causeway`) or `pack/level` for a published community +/// pack; a race always runs the pack's current version. +@Component +public class Levels { + /// A level ready to run, with the id its replays carry. + public static final class Resolved { + public final String ref; + public final String replayLevel; + public final LevelDef level; + public final Catalog catalog; + + Resolved(String ref, String replayLevel, LevelDef level, Catalog catalog) { + this.ref = ref; + this.replayLevel = replayLevel; + this.level = level; + this.catalog = catalog; + } + } + + private final Forge forge; + + public Levels(Forge forge) { + this.forge = forge; + } + + /// The level's canonical name, or null when it is not one the API takes. + /// Cheap and lock free: it only checks the shape, [#resolve] does the rest. + public String normalize(String ref) { + if (ref == null || ref.length() == 0 || ref.length() > 160) { + return null; + } + int slash = ref.indexOf('/'); + if (slash < 0) { + return Content.level(ref) != null || isCity(ref) ? ref : null; + } + if (slash == 0 || slash == ref.length() - 1 || ref.indexOf('/', slash + 1) >= 0) { + return null; + } + return ref; + } + + /// Whether `ref` is the id of a city route's level: [CityRoute#id]'s + /// shape. The route itself is only ever in a result, so the server has + /// boards for a route but cannot build its level from the id alone. + static boolean isCity(String ref) { + if (!ref.startsWith(CityRoute.ID_PREFIX) || ref.length() == CityRoute.ID_PREFIX.length() + || ref.length() > CityRoute.ID_PREFIX.length() + 16) { + return false; + } + for (int i = CityRoute.ID_PREFIX.length(); i < ref.length(); i++) { + char c = ref.charAt(i); + if (!(c >= '0' && c <= '9' || c >= 'a' && c <= 'f')) { + return false; + } + } + return true; + } + + /// The level `ref` names, or null -- for a city route too, whose level is + /// built from a route the server does not keep. + public Resolved resolve(String ref) throws IOException { + String r = normalize(ref); + if (r == null || isCity(r)) { + return null; + } + int slash = r.indexOf('/'); + if (slash < 0) { + return new Resolved(r, r, Content.level(r), Content.catalog()); + } + String packId = r.substring(0, slash); + String levelId = r.substring(slash + 1); + ContentPack pack = forge.published(packId); + LevelDef level = pack == null ? null : pack.level(levelId); + if (level == null) { + return null; + } + Catalog catalog = Samples.OFFICIAL_ID.equals(pack.id) ? Content.catalog() : pack.catalog(); + return new Resolved(r, LevelRun.replayLevelId(pack, levelId), level, catalog); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Scores.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Scores.java new file mode 100644 index 00000000000..712883fb926 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/forge/Scores.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.api.DailyDto; +import com.codenameone.examples.dash.api.GhostDto; +import com.codenameone.examples.dash.api.ScoreDto; +import com.codenameone.examples.dash.domain.CrewMember; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Score; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.CrewMemberRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.ScoreRepository; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Runner; +import com.codenameone.examples.dash.social.Referrals; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// Level leaderboards. Every score is a replay the server played again +/// ([Forge#result]), so a number can never be typed in. +/// +/// The `all` board takes any seed: a player could search seeds offline for a +/// kind track, which is why the timed boards exist. `week` and `day` count a +/// run only on that board's seed, the same track for everyone. +// tag::dash-scores[] +@Component +public class Scores { + public static final String ALL = "all"; + public static final String WEEK = "week"; + public static final String DAY = "day"; + public static final String FRIENDS = "friends"; + public static final String CREW = "crew:"; + /// The daily challenge is always this level, on the day's seed. + public static final String DAILY_LEVEL = Content.ENDLESS; + + private final ScoreRepository scores; + private final ProfileRepository profiles; + private final CrewMemberRepository members; + private final Referrals referrals; + private final GamePushes pushes; + + public Scores(ScoreRepository scores, ProfileRepository profiles, CrewMemberRepository members, + Referrals referrals, GamePushes pushes) { + this.scores = scores; + this.profiles = profiles; + this.members = members; + this.referrals = referrals; + this.pushes = pushes; + } + + /// Days since the epoch, UTC: the day number the app counts in too + /// (`LevelsForm.week`), so both derive the same weekly seed. Not + /// [Clock#dayOf], which answers the day's first millisecond. + public static int today() { + return (int) (Clock.now() / Clock.DAY); + } + + public static String weekBoard() { + return "w" + (today() / 7); + } + + public static String dayBoard() { + return "d" + today(); + } + + public DailyDto daily() { + long next = (today() + 1L) * Clock.DAY; + return new DailyDto(today(), DAILY_LEVEL, Long.toString(Content.dailySeed(today())), + (int) ((next - Clock.now()) / 1000)); + } + + /// Enters a verified run on every board it qualifies for, and answers the + /// run's rank on `all` (1 is first). + @Transactional(rollbackFor = IOException.class) + public int submit(String username, String level, Replay replay, Run played) throws IOException { + Profile p = profiles.findById(username); + if (p == null || p.isBanned()) { + return 0; + } + Runner r = played.runner(0); + if (fairSeed(level, replay.seed(), played.level().contentHash())) { + int before = enter(ALL, level, p, replay, played); + if (before != NOT_BETTER) { + tellFriendsBeaten(p, level, before, r.score()); + } + } + if (replay.seed() == Content.weeklySeed(today() / 7, level)) { + enter(weekBoard(), level, p, replay, played); + } + if (DAILY_LEVEL.equals(level) && replay.seed() == Content.dailySeed(today())) { + enter(dayBoard(), level, p, replay, played); + } + Long above = scores.above(ALL, level, r.score()); + return above == null ? 1 : above.intValue() + 1; + } + + /// Whether `seed` is one the app was handed rather than one it picked. + /// The endless stretch of a level is built from the seed, so a board + /// that took any seed would let a player search offline for an easy + /// track. The app plays this or last week's seed (a run can straddle the + /// change), today's or yesterday's daily seed, a pack level's content + /// hash, or the fixed seed of the tutorial and the guest run. + static boolean fairSeed(String level, long seed, long contentHash) { + int week = today() / 7; + return seed == FIXED_SEED || seed == contentHash + || seed == Content.weeklySeed(week, level) || seed == Content.weeklySeed(week - 1, level) + || DAILY_LEVEL.equals(level) + && (seed == Content.dailySeed(today()) || seed == Content.dailySeed(today() - 1)); + } + + /// The seed the tutorial and the guest run always play. + static final long FIXED_SEED = 1L; + + /// What [#enter] answers when the run is no better than the player's + /// best on that board. + private static final int NOT_BETTER = -2; + + /// The most friends one improved score is pushed to. + private static final int MAX_BEATEN_PUSHES = 20; + + /// Friends whose best the new score passed, and that the player's old best + /// had not: an improvement that passes nobody new sends nothing. + // tag::dash-beaten-push[] + private void tellFriendsBeaten(Profile p, String level, int before, int score) throws IOException { + int sent = 0; + for (String f : friends(p.getUsername())) { + if (sent >= MAX_BEATEN_PUSHES) { + return; + } + if (f.equals(p.getUsername())) { + continue; + } + Score theirs = scores.findById(ALL + "|" + level + "|" + f); + if (theirs != null && theirs.getScore() < score && theirs.getScore() >= before) { + pushes.scoreBeaten(f, p.getDisplayName(), level, score); + sent++; + } + } + } + // end::dash-beaten-push[] + + /// Answers the player's previous best on the board (-1 when there was + /// none), or [#NOT_BETTER] when the run did not improve on it. + private int enter(String board, String level, Profile p, Replay replay, Run played) { + Runner r = played.runner(0); + String id = board + "|" + level + "|" + p.getUsername(); + Score s = scores.findById(id); + if (s != null && s.getScore() >= r.score()) { + return NOT_BETTER; + } + int before = s == null ? -1 : s.getScore(); + boolean fresh = s == null; + if (fresh) { + s = new Score(); + s.setId(id); + s.setBoard(board); + s.setLevel(level); + s.setUsername(p.getUsername()); + } + s.setDisplayName(p.getDisplayName()); + s.setScore(r.score()); + s.setDistance(r.z / 1000); + s.setCoins(r.coins); + s.setStars(played.stars(0)); + s.setSeed(replay.seed()); + s.setCreatedAt(Instant.now()); + s.setReplay(replay.encode()); + if (fresh) { + scores.insert(s); + } else { + scores.save(s); + } + return before; + } + + /// A board, best first. `friends` is the caller, the players they + /// invited and everyone in their crews; `crew:` is one crew. + @Transactional(readOnly = true) + public List top(String username, String level, String board, int limit) throws IOException { + int n = limit < 1 || limit > 100 ? 50 : limit; + String key = boardKey(board); + Set only = null; + if (FRIENDS.equals(board)) { + only = friends(username); + } else if (board != null && board.startsWith(CREW)) { + only = new HashSet(); + for (CrewMember m : members.members(board.substring(CREW.length()), new PageRequest(0, 100))) { + only.add(m.getUsername()); + } + } + List out = new ArrayList(); + int rank = 1; + int page = 0; + while (out.size() < n && page < 20) { + List rows = scores.top(key, level, new PageRequest(page, only == null ? n : 100)); + for (Score s : rows) { + if (only != null && !only.contains(s.getUsername())) { + continue; + } + if (out.size() < n) { + out.add(new ScoreDto(rank, s.getDisplayName(), s.getScore(), s.getDistance(), s.getCoins(), + s.getStars(), s.getId())); + } + rank++; + } + if (only == null || rows.size() < 100) { + break; + } + page++; + } + return out; + } + + /// The run just above the caller's best: a ghost close enough to chase. + @Transactional(readOnly = true) + public GhostDto ghost(String username, String level, String board) throws IOException { + String key = boardKey(board); + Score mine = scores.findById(key + "|" + level + "|" + username); + int target = mine == null ? 0 : mine.getScore(); + Score pick = null; + Score best = null; + // Best first, so the last one above the target is the closest. + for (Score s : scores.top(key, level, new PageRequest(0, 100))) { + if (s.getUsername().equals(username)) { + continue; + } + if (best == null) { + best = s; + } + if (s.getScore() > target) { + pick = s; + } + } + if (pick == null) { + pick = best; + } + if (pick == null) { + return null; + } + return new GhostDto(pick.getDisplayName(), pick.getScore(), pick.getReplay()); + } + + public Score byId(String id) { + return scores.findById(id); + } + + static String boardKey(String board) { + if (WEEK.equals(board)) { + return weekBoard(); + } + if (DAY.equals(board)) { + return dayBoard(); + } + return ALL; + } + + private Set friends(String username) throws IOException { + Set out = new HashSet(); + out.add(username); + out.addAll(referrals.invitees(username)); + for (CrewMember mine : members.crewsOf(username, new PageRequest(0, 20))) { + for (CrewMember m : members.members(mine.getCode(), new PageRequest(0, 100))) { + out.add(m.getUsername()); + } + } + return out; + } +} +// end::dash-scores[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Challenges.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Challenges.java new file mode 100644 index 00000000000..696ca832bf8 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Challenges.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.ChallengeDto; +import com.codenameone.examples.dash.push.GamePushes; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/// Private race links: "open this link and play me". +/// +/// The link opens the installed app straight into the race. Codes live in memory: a challenge is meant to be used in minutes, +/// and losing the open ones on a restart costs a new link, not data. +// tag::dash-challenge[] +@Component +public class Challenges { + public static final int TTL_SECONDS = 600; + + private final GamePushes pushes; + private final String publicUrl; + private final Map hosts = new HashMap(); + private final Map expiry = new HashMap(); + + public Challenges(GamePushes pushes, @Value("${dash.public-url:}") String publicUrl) { + this.pushes = pushes; + this.publicUrl = publicUrl; + } + + public synchronized ChallengeDto create(String host) throws IOException { + sweep(); + String code = Ids.hex(Crypto.randomBytes(5)); + hosts.put(code, host); + expiry.put(code, Long.valueOf(Clock.now() + TTL_SECONDS * 1000L)); + // With a hosted browser build (dash.public-url) the link opens the + // game in a browser; otherwise it opens the installed app through its + // dash: URL scheme. Either way the app reads the code after + // "challenge=". + String link = publicUrl.length() > 0 ? publicUrl + "/?challenge=" + code + : "dash://join?challenge=" + code; + return new ChallengeDto(code, link, TTL_SECONDS); + } + + synchronized String hostOf(String code) { + sweep(); + return code == null ? null : hosts.get(code); + } + + synchronized void used(String code) { + hosts.remove(code); + expiry.remove(code); + } + + /// The challenged player arrived before the host: tell the host by push. + void joinedWhileAway(String code, String host, String challenger) { + pushes.challengeAccepted(host, challenger, code); + } + + private void sweep() { + long now = Clock.now(); + for (Iterator> it = expiry.entrySet().iterator(); it.hasNext();) { + Map.Entry e = it.next(); + if (e.getValue().longValue() < now) { + hosts.remove(e.getKey()); + it.remove(); + } + } + } +} +// end::dash-challenge[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameEndpoint.java new file mode 100644 index 00000000000..4bfd886d178 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameEndpoint.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.account.Lists; +import com.codenameone.examples.dash.admin.Flags; +import com.codenameone.examples.dash.api.ChallengeDto; +import com.codenameone.examples.dash.api.CrewCreateDto; +import com.codenameone.examples.dash.api.CrewDto; +import com.codenameone.examples.dash.api.DailyDto; +import com.codenameone.examples.dash.api.FlagDto; +import com.codenameone.examples.dash.api.GameApiServer; +import com.codenameone.examples.dash.api.GhostDto; +import com.codenameone.examples.dash.api.LeaderDto; +import com.codenameone.examples.dash.api.RaceSummaryDto; +import com.codenameone.examples.dash.api.ReplayDto; +import com.codenameone.examples.dash.api.ReportDto; +import com.codenameone.examples.dash.api.RunnerDto; +import com.codenameone.examples.dash.api.ScoreDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.RaceEntry; +import com.codenameone.examples.dash.domain.RaceRecord; +import com.codenameone.examples.dash.domain.Score; +import com.codenameone.examples.dash.forge.Levels; +import com.codenameone.examples.dash.forge.Scores; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.RaceEntryRepository; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.shop.Catalog; +import com.codenameone.examples.dash.social.Crews; +import java.util.ArrayList; +import java.util.List; + +@Component +public class GameEndpoint implements GameApiServer { + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final Challenges challenges; + private final Flags flags; + private final Scores scores; + private final Levels levels; + private final Crews crews; + + public GameEndpoint(ProfileRepository profiles, RaceRecordRepository races, RaceEntryRepository entries, + Challenges challenges, Flags flags, Scores scores, Levels levels, Crews crews) { + this.profiles = profiles; + this.races = races; + this.entries = entries; + this.challenges = challenges; + this.flags = flags; + this.scores = scores; + this.levels = levels; + this.crews = crews; + } + + @Override + @Transactional(readOnly = true) + public RunnerDto runner() throws Exception { + return new RunnerDto(profile(Caller.name()).getRunner()); + } + + /// The runner is a look, never an advantage: every runner has the same + /// physics. Only one the player owns may be picked. + @Override + @Transactional + public RunnerDto saveRunner(RunnerDto runner) throws Exception { + String id = runner == null ? null : runner.runner(); + Profile p = profile(Caller.name()); + if (id == null || !Catalog.isRunner(id) || !Catalog.FREE_RUNNER.equals(id) + && !Lists.split(p.getOwned()).contains(id)) { + throw new ResponseStatusException(400, "You do not own that runner"); + } + p.setRunner(id); + profiles.save(p); + return new RunnerDto(id); + } + + @Override + @Transactional(readOnly = true) + public List history() throws Exception { + return summaries(entries.history(Caller.name(), new PageRequest(0, 30))); + } + + public static List summaries(List rows) { + List out = new ArrayList(); + for (RaceEntry e : rows) { + out.add(new RaceSummaryDto(e.getRaceId(), e.getLevel(), e.getPlace(), e.getRunners(), e.getScore(), + e.getTrophyChange(), e.getEndedAt().toEpochMilli(), e.getTicks())); + } + return out; + } + + /// A race, or a leaderboard run (its id has `|` in it). Anyone signed in + /// may watch: replays are how players learn, and they hold nothing private. + @Override + @Transactional(readOnly = true) + public ReplayDto replay(String id) throws Exception { + if (id != null && id.indexOf('|') >= 0) { + Score s = scores.byId(id); + if (s == null) { + throw new ResponseStatusException(404, "No such run"); + } + List names = new ArrayList(); + names.add(s.getDisplayName()); + List points = new ArrayList(); + points.add(Integer.valueOf(s.getScore())); + return new ReplayDto(s.getId(), s.getLevel(), names, points, s.getReplay(), + com.codenameone.examples.dash.rules.RulesVersion.ID, s.getCreatedAt().toEpochMilli()); + } + RaceRecord m = id == null ? null : races.findById(id); + if (m == null) { + throw new ResponseStatusException(404, "No such race"); + } + List points = new ArrayList(); + for (String v : Lists.split(m.getScores())) { + points.add(Integer.valueOf(Integer.parseInt(v))); + } + return new ReplayDto(m.getId(), m.getLevel(), Lists.split(m.getNames()), points, m.getReplay(), + m.getRulesVersion(), m.getEndedAt().toEpochMilli()); + } + + @Override + @Transactional(readOnly = true) + public List leaderboard(int limit) throws Exception { + int n = limit < 1 || limit > 100 ? 50 : limit; + List out = new ArrayList(); + int rank = 1; + for (Profile p : profiles.leaders(new PageRequest(0, n))) { + out.add(new LeaderDto(rank++, p.getDisplayName(), p.getTrophies(), p.getWins())); + } + return out; + } + + @Override + public List scores(String level, String board, int limit) throws Exception { + String ref = levels.normalize(level); + if (ref == null) { + throw new ResponseStatusException(404, "No such level"); + } + return scores.top(Caller.name(), ref, board == null ? Scores.ALL : board, limit); + } + + @Override + public GhostDto ghost(String level, String board) throws Exception { + String ref = levels.normalize(level); + GhostDto g = ref == null ? null : scores.ghost(Caller.name(), ref, board); + if (g == null) { + throw new ResponseStatusException(404, "Nobody has run that board yet"); + } + return g; + } + + @Override + public DailyDto daily() throws Exception { + return scores.daily(); + } + + @Override + public ChallengeDto challenge() throws Exception { + return challenges.create(Caller.name()); + } + + @Override + public List crews() throws Exception { + return crews.of(Caller.name()); + } + + @Override + public CrewDto createCrew(CrewCreateDto crew) throws Exception { + return crews.create(Caller.name(), crew == null ? null : crew.name()); + } + + @Override + public CrewDto joinCrew(String code) throws Exception { + return crews.join(Caller.name(), code); + } + + @Override + public CrewDto leaveCrew(String code) throws Exception { + return crews.leave(Caller.name(), code); + } + + @Override + public FlagDto report(ReportDto report) throws Exception { + if (report == null) { + throw new ResponseStatusException(400, "Nothing to report"); + } + return flags.report(Caller.name(), report.raceId(), report.player(), report.reason()); + } + + private Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameSocket.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameSocket.java new file mode 100644 index 00000000000..c258c7791ad --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameSocket.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.domain.Profile; +import java.util.Map; + +/// The game socket. It is opened with a one-shot ticket from +/// `POST /api/game/ticket`, then carries [GameProtocol] messages. +// tag::dash-socket[] +@WebSocketMapping("/ws/game") +public class GameSocket implements WebSocket { + private final GameTickets tickets; + private final Accounts accounts; + private final Rooms rooms; + + public GameSocket(GameTickets tickets, Accounts accounts, Rooms rooms) { + this.tickets = tickets; + this.accounts = accounts; + this.rooms = rooms; + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + String username = tickets.redeem(ticket(session.getQuery())); + if (username == null || !accounts.isEnabled(username)) { + session.close(4401, "ticket"); + return; + } + session.setAttachment(username); + session.sendText(Messages.simple(GameProtocol.READY)); + rooms.connected(username, accounts.isAdmin(username), session); + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + Object who = session.getAttachment(); + if (!(who instanceof String) || message == null || message.length() > GameProtocol.MAX_MESSAGE) { + return; + } + String username = (String) who; + Map m; + try { + m = Json.parseObject(message); + } catch (Exception bad) { + session.sendText(Messages.error("Not a message")); + return; + } + String type = string(m, "type"); + if (GameProtocol.PING.equals(type)) { + session.sendText(Messages.simple(GameProtocol.PONG)); + } else if (GameProtocol.INPUT.equals(type)) { + rooms.input(username, number(m, "b")); + } else if (GameProtocol.SYNC.equals(type)) { + rooms.sync(username, number(m, "t"), string(m, "h")); + } else if (GameProtocol.VOICE.equals(type)) { + rooms.voice(username, string(m, "to"), string(m, "data")); + } else if (GameProtocol.MUTE.equals(type)) { + rooms.mute(username, Boolean.TRUE.equals(m.get("on"))); + } else if (GameProtocol.EMOTE.equals(type) || GameProtocol.CHAT.equals(type)) { + Profile p = accounts.profile(username); + if (p.isMuted()) { + return; + } + if (GameProtocol.EMOTE.equals(type)) { + rooms.emote(username, p.getDisplayName(), string(m, "id")); + } else { + rooms.chat(username, p.getDisplayName(), string(m, "text")); + } + } else if (GameProtocol.QUICK.equals(type)) { + rooms.quick(accounts.profile(username), session, string(m, "level")); + } else if (GameProtocol.HOST.equals(type)) { + rooms.host(accounts.profile(username), session, string(m, "level"), string(m, "crew")); + } else if (GameProtocol.JOIN.equals(type)) { + rooms.join(accounts.profile(username), session, string(m, "code")); + } else if (GameProtocol.GO.equals(type)) { + rooms.go(username, string(m, "level")); + } else if (GameProtocol.CANCEL.equals(type)) { + rooms.cancel(username); + } else if (GameProtocol.LEAVE.equals(type)) { + rooms.leave(username); + } else if (GameProtocol.RESUME.equals(type)) { + rooms.connected(username, false, session); + } else if (GameProtocol.SPECTATE.equals(type)) { + if (!accounts.isAdmin(username) || !rooms.spectate(string(m, "race"), session)) { + session.sendText(Messages.error("No such race")); + } + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) throws Exception { + session.close(1003, "text only"); + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + Object who = session.getAttachment(); + if (who instanceof String) { + rooms.disconnected((String) who, session); + } + } + + private static String string(Map m, String key) { + Object v = m.get(key); + return v instanceof String ? (String) v : null; + } + + private static int number(Map m, String key) { + Object v = m.get(key); + return v instanceof Number ? ((Number) v).intValue() : -1; + } + + static String ticket(String query) { + if (query == null) { + return null; + } + int at = 0; + while (at < query.length()) { + int end = query.indexOf('&', at); + if (end < 0) { + end = query.length(); + } + if (query.startsWith("ticket=", at)) { + return query.substring(at + 7, end); + } + at = end + 1; + } + return null; + } +} +// end::dash-socket[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameTickets.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameTickets.java new file mode 100644 index 00000000000..697372cf146 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/GameTickets.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.TicketDto; +import com.codenameone.examples.dash.domain.GameTicket; +import com.codenameone.examples.dash.repository.GameTicketRepository; + +import java.io.IOException; +import java.time.Instant; + +/// Tickets for the live channel. +/// +/// A WebSocket handshake is one request the app cannot attach its bearer token +/// to on every platform -- a browser's WebSocket takes no headers at all -- and a +/// token in the address would end up in every access log on the way. So the app +/// asks for a ticket over the authenticated API and presents that instead: it +/// is random, it names nothing, it is good for one connection, and it is stale +/// in half a minute. +@Component +public class GameTickets { + static final int TICKET_SECONDS = 30; + + private final GameTicketRepository tickets; + + public GameTickets(GameTicketRepository tickets) { + this.tickets = tickets; + } + + /// Not held against the account being closed, as the rows that last are. + /// A ticket issued in the instant an account is erased outlives it by at + /// most [#TICKET_SECONDS] plus one [#sweep], and opens nothing meanwhile: + /// `LiveEndpoint` checks the account again when the ticket is redeemed. + /// A row lock on every socket connect is not worth that minute. + @Transactional + public TicketDto issue(String username) throws IOException { + String ticket = Ids.hex(Crypto.randomBytes(24)); + GameTicket row = new GameTicket(); + row.setTicketHash(Ids.sha256(ticket)); + row.setUsername(username); + row.setExpiresAt(Instant.now().plusMillis(TICKET_SECONDS * 1000L)); + tickets.insert(row); + return new TicketDto(ticket, TICKET_SECONDS); + } + + /// The account a ticket was issued to, or null when it is unknown, used or + /// stale. Redeeming it uses it up: the row is deleted, and only the + /// connection whose delete removed it is let in, so a ticket presented twice + /// at the same moment still opens one connection. + @Transactional + public String redeem(String ticket) throws IOException { + if (ticket == null || ticket.length() == 0 || ticket.length() > 128) { + return null; + } + String hash = Ids.sha256(ticket); + GameTicket row = tickets.findById(hash); + if (row == null) { + return null; + } + String username = row.getUsername(); + Instant expiresAt = row.getExpiresAt(); + // One delete statement: the count it answers is what says this + // connection, and not another presenting the same ticket, used it. + int removed = tickets.deleteByTicketHash(hash); + if (removed != 1 || expiresAt.isBefore(Instant.now())) { + return null; + } + return username; + } + + /// Clears the tickets nobody came back for. + @Scheduled(fixedDelay = 60000, initialDelay = 60000) + @Transactional + public void sweep() throws IOException { + tickets.deleteByExpiresAtLessThan(Instant.now()); + } + + /// Forgets the tickets of an account that is being closed. + @Transactional + public void forget(String username) { + tickets.deleteByUsername(username); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Messages.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Messages.java new file mode 100644 index 00000000000..b5c4673da5f --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Messages.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.Json; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.rules.RulesVersion; +import java.util.List; + +/// Builds the game socket's outgoing JSON. Strings go through [Json#write], so a +/// player name can never break a message. +final class Messages { + private Messages() { + } + + static String simple(String type) { + return "{\"type\":\"" + type + "\"}"; + } + + static String error(String message) { + return "{\"type\":\"" + GameProtocol.ERROR + "\",\"message\":" + Json.write(message) + "}"; + } + + /// `startIn` is how many milliseconds of countdown are left; `you` is -1 + /// for a spectator. + static String start(RaceRoom room, int you, long startIn) { + StringBuilder names = new StringBuilder(); + StringBuilder runners = new StringBuilder(); + StringBuilder players = new StringBuilder(); + for (int i = 0; i < room.seats.length; i++) { + if (i > 0) { + names.append(','); + runners.append(','); + players.append(','); + } + names.append(Json.write(room.seats[i].name)); + runners.append(Json.write(room.seats[i].runner)); + // A bot has no account, so no username: an empty one, which a + // client knows not to call. + String u = room.seats[i].username; + players.append(Json.write(u == null ? "" : u)); + } + return "{\"type\":\"" + GameProtocol.START + "\",\"race\":\"" + room.id + "\",\"level\":" + + Json.write(room.level) + ",\"seed\":\"" + room.run.seed() + "\",\"you\":" + you + + ",\"names\":[" + names + "],\"runners\":[" + runners + "],\"players\":[" + players + "],\"rules\":" + + Json.write(RulesVersion.ID) + ",\"tick\":" + room.run.tick() + ",\"startIn\":" + + Math.max(0, startIn) + ",\"log\":" + Json.write(inputs(room.log, 0)) + "}"; + } + + static String ticks(int through, List log, int from) { + return "{\"type\":\"" + GameProtocol.TICKS + "\",\"t\":" + through + ",\"i\":" + + Json.write(inputs(log, from)) + "}"; + } + + static String hash(int tick, long hash) { + return "{\"type\":\"" + GameProtocol.HASH + "\",\"t\":" + tick + ",\"h\":\"" + Long.toHexString(hash) + "\"}"; + } + + /// `players` are the members' usernames, in the order of `names`: what a + /// client addresses voice signaling to. + static String party(String code, String url, String level, String host, List names, + List players) { + return "{\"type\":\"" + GameProtocol.PARTY + "\",\"code\":" + Json.write(code) + ",\"url\":" + + Json.write(url) + ",\"level\":" + Json.write(level) + ",\"host\":" + Json.write(host) + + ",\"names\":[" + array(names) + "],\"players\":[" + array(players) + "]}"; + } + + private static String array(List items) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(Json.write(items.get(i))); + } + return sb.toString(); + } + + static String emote(String from, String id) { + return "{\"type\":\"" + GameProtocol.EMOTE + "\",\"from\":" + Json.write(from) + ",\"id\":" + Json.write(id) + "}"; + } + + static String chat(String from, String text) { + return "{\"type\":\"" + GameProtocol.CHAT + "\",\"from\":" + Json.write(from) + ",\"text\":" + Json.write(text) + "}"; + } + + /// WebRTC signaling, relayed untouched: `data` is an offer, an answer or + /// an ICE candidate, opaque to the server. + static String voice(String from, String data) { + return "{\"type\":\"" + GameProtocol.VOICE + "\",\"from\":" + Json.write(from) + ",\"data\":" + Json.write(data) + "}"; + } + + static String mute(String from, boolean on) { + return "{\"type\":\"" + GameProtocol.MUTE + "\",\"from\":" + Json.write(from) + ",\"on\":" + on + "}"; + } + + static String end(String raceId, int[] places, int[] scores, int trophies, int coins) { + return "{\"type\":\"" + GameProtocol.END + "\",\"race\":\"" + raceId + "\",\"places\":\"" + join(places) + + "\",\"scores\":\"" + join(scores) + "\",\"trophies\":" + trophies + ",\"coins\":" + coins + "}"; + } + + /// Inputs as `tick,seat,bits` joined by `;`, the replay's own format. + static String inputs(List log, int from) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i < log.size(); i++) { + if (i > from) { + sb.append(';'); + } + int[] e = log.get(i); + sb.append(e[0]).append(',').append(e[1]).append(',').append(e[2]); + } + return sb.toString(); + } + + static String join(int[] v) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < v.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(v[i]); + } + return sb.toString(); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java new file mode 100644 index 00000000000..a7b8609aeb1 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.WebSocketSession; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.Input; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Runner; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// One race in progress. The server runs the same [Run] every client runs: +/// a player's input is stamped [GameProtocol#INPUT_DELAY] ticks ahead, so it +/// reaches every client before that tick is simulated, and the room's log is +/// the whole race. Nothing here is thread safe; [Rooms] holds the lock. +// tag::dash-race-room[] +final class RaceRoom { + /// Inputs one seat may send in [#FLOOD_WINDOW] ticks. A thumb manages + /// about eight a second; a script manages sixty. + static final int FLOOD_LIMIT = 16; + static final int FLOOD_WINDOW = Run.TICKS_PER_SECOND; + + final String id; + final String level; + final Seat[] seats; + final Run run; + final Replay replay; + final Bot[] bots; + /// When tick 0 runs: the end of the countdown. + final long startedAt; + final List spectators = new ArrayList(); + /// Every input in stamp order, as `{tick, seat, bits}`; what TICKS sends. + final List log = new ArrayList(); + private final Map pending = new HashMap(); + /// The server's hash every [GameProtocol#HASH_EVERY] ticks, for SYNC. + final Map hashes = new HashMap(); + int sent; + int lastSentTick; + + RaceRoom(String id, String level, LevelDef def, Catalog catalog, long seed, Seat[] seats, long startedAt) { + this.id = id; + this.level = level; + this.seats = seats; + this.run = new Run(def, catalog, seed, seats.length); + this.replay = new Replay(def.id, def.contentHash(), seed, seats.length); + this.startedAt = startedAt; + this.bots = new Bot[seats.length]; + for (int i = 0; i < seats.length; i++) { + if (seats[i].isBot()) { + // Bots of mixed skill, seeded from the race so a replay of the + // room makes the same choices. + bots[i] = new Bot(seed + 977L * i, 55 + 12 * (i % 4)); + } + } + } + + int seatOf(String username) { + for (int i = 0; i < seats.length; i++) { + if (seats[i].username.equals(username)) { + return i; + } + } + return -1; + } + + /// A player's input. Answers false when it is dropped: a flood, a runner + /// that is out, or a race that has not started. + boolean intent(int seat, int bits) { + if (seat < 0 || run.over() || !run.runner(seat).running()) { + return false; + } + Seat s = seats[seat]; + int now = run.tick(); + int inWindow = 0; + for (int i = 0; i < s.recentCount; i++) { + if (now - s.recent[i] < FLOOD_WINDOW) { + inWindow++; + } + } + if (inWindow >= FLOOD_LIMIT) { + s.floods++; + return false; + } + s.recent[s.recentCount % s.recent.length] = now; + s.recentCount = s.recentCount < s.recent.length ? s.recentCount + 1 : s.recentCount; + int at = now + GameProtocol.INPUT_DELAY; + int clean = bits & Input.ALL; + if (clean == 0) { + return false; + } + int[] row = pending.get(Integer.valueOf(at)); + if (row == null) { + row = new int[seats.length]; + pending.put(Integer.valueOf(at), row); + } + row[seat] |= clean; + log.add(new int[] {at, seat, clean}); + return true; + } + + void step() { + int now = run.tick(); + int[] row = pending.remove(Integer.valueOf(now)); + if (row == null) { + row = new int[seats.length]; + } + for (int i = 0; i < seats.length; i++) { + if (bots[i] != null) { + int b = bots[i].decide(run, i); + if (b != 0) { + row[i] |= b; + log.add(new int[] {now, i, b}); + } + } + if (row[i] != 0) { + replay.record(now, i, row[i]); + } + } + run.step(row); + if (run.tick() % GameProtocol.HASH_EVERY == 0) { + hashes.put(Integer.valueOf(run.tick()), Long.valueOf(run.stateHash())); + hashes.remove(Integer.valueOf(run.tick() - GameProtocol.HASH_EVERY * 20)); + } + } + + /// Over when the rules say so, or when no player is still running: bots + /// racing each other is not worth simulating. + boolean isOver() { + if (run.over()) { + return true; + } + for (int i = 0; i < seats.length; i++) { + if (!seats[i].isBot() && !seats[i].left && run.runner(i).running()) { + return false; + } + } + return true; + } + + /// Each seat's place, 0 first: runners who finished by when they did, + /// then everyone else by score. + int[] places() { + int n = seats.length; + int[] order = new int[n]; + for (int i = 0; i < n; i++) { + // Insertion sort: six runners at most, and no comparator object. + int j = i; + while (j > 0 && ahead(i, order[j - 1])) { + order[j] = order[j - 1]; + j--; + } + order[j] = i; + } + int[] place = new int[n]; + for (int p = 0; p < n; p++) { + place[order[p]] = p; + } + return place; + } + + private boolean ahead(int a, int b) { + Runner ra = run.runner(a); + Runner rb = run.runner(b); + if (ra.finished != rb.finished) { + return ra.finished; + } + if (ra.finished && ra.endTick != rb.endTick) { + return ra.endTick < rb.endTick; + } + return ra.score() > rb.score(); + } + + int humans() { + int h = 0; + for (Seat s : seats) { + if (!s.isBot()) { + h++; + } + } + return h; + } + + /// The seat in front right now, for the live list. + int leader() { + int best = 0; + for (int i = 1; i < seats.length; i++) { + if (run.runner(i).z > run.runner(best).z) { + best = i; + } + } + return best; + } +} +// end::dash-race-room[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Results.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Results.java new file mode 100644 index 00000000000..e2fa16eb8db --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Results.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.RaceEntry; +import com.codenameone.examples.dash.domain.RaceRecord; +import com.codenameone.examples.dash.progress.Progression; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.RaceEntryRepository; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.rules.Runner; +import com.codenameone.examples.dash.rules.RulesVersion; +import com.codenameone.examples.dash.social.Referrals; +import java.io.IOException; +import java.time.Instant; + +/// Records a finished race: the replay, and what it did to each player. +// tag::dash-results[] +@Component +public class Results { + /// Trophies per place gained or lost: in a race of `n`, place `p` moves + /// `(n - 1 - 2p) * TROPHY_STEP`, so the winner of four gets +30 and the + /// last -30, and the sum is zero. + public static final int TROPHY_STEP = 10; + /// Coins are a twentieth of the score, capped. + public static final int MAX_RACE_COINS = 50; + + /// What one runner got out of a race. + public static final class Outcome { + public final int trophies; + public final int coins; + + public Outcome(int trophies, int coins) { + this.trophies = trophies; + this.coins = coins; + } + } + + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final ProfileRepository profiles; + private final Progression progression; + private final Referrals referrals; + + public Results(RaceRecordRepository races, RaceEntryRepository entries, ProfileRepository profiles, + Progression progression, Referrals referrals) { + this.races = races; + this.entries = entries; + this.profiles = profiles; + this.progression = progression; + this.referrals = referrals; + } + + /// The trophy change for `place` of `runners` with `humans` players in + /// the race. Against bots alone it is halved and never negative: losing + /// to the server should not cost a ranking. + public static int trophies(int place, int runners, int humans) { + int change = (runners - 1 - 2 * place) * TROPHY_STEP; + if (humans <= 1) { + change = Math.max(0, change / 2); + } + return change; + } + + @Transactional(rollbackFor = IOException.class) + Outcome[] record(RaceRoom room) throws IOException { + Outcome[] out = save(room); + // After every profile is saved: paying an inviter writes a profile, + // and that inviter may be another runner of this very race. + for (Seat s : room.seats) { + if (!s.isBot()) { + referrals.played(s.username); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + Outcome[] save(RaceRoom room) throws IOException { + int n = room.seats.length; + int humans = room.humans(); + int[] places = room.places(); + Outcome[] out = new Outcome[n]; + Instant now = Instant.now(); + StringBuilder players = new StringBuilder(); + StringBuilder names = new StringBuilder(); + StringBuilder placeList = new StringBuilder(); + StringBuilder scoreList = new StringBuilder(); + StringBuilder trophyList = new StringBuilder(); + for (int i = 0; i < n; i++) { + Seat s = room.seats[i]; + Runner r = room.run.runner(i); + out[i] = new Outcome(0, 0); + if (!s.isBot()) { + Profile p = profiles.findById(s.username); + if (p != null) { + int t = trophies(places[i], n, humans); + t = Math.max(t, -p.getTrophies()); + int coins = s.left ? 0 : Math.min(MAX_RACE_COINS, r.score() / 20); + p.setTrophies(p.getTrophies() + t); + p.setCoins(p.getCoins() + coins); + if (places[i] == 0) { + p.setWins(p.getWins() + 1); + } else { + p.setLosses(p.getLosses() + 1); + } + p.setLeftStreak(s.left ? p.getLeftStreak() + 1 : 0); + p.setLastSeen(now); + progression.afterRace(p, places[i], n, r.z / 1000, r.coins); + profiles.save(p); + out[i] = new Outcome(t, coins); + RaceEntry e = new RaceEntry(); + e.setId(room.id + "|" + s.username); + e.setRaceId(room.id); + e.setUsername(s.username); + e.setLevel(room.level); + e.setPlace(places[i]); + e.setRunners(n); + e.setScore(r.score()); + e.setTrophyChange(t); + e.setTicks(room.run.tick()); + e.setEndedAt(now); + entries.insert(e); + } + } + String sep = i == 0 ? "" : ","; + players.append(sep).append(s.username); + names.append(sep).append(s.name.replace(',', ' ')); + placeList.append(sep).append(places[i]); + scoreList.append(sep).append(r.score()); + trophyList.append(sep).append(out[i].trophies); + } + RaceRecord m = new RaceRecord(); + m.setId(room.id); + m.setLevel(room.level); + m.setPlayers(players.toString()); + m.setNames(names.toString()); + m.setPlaces(placeList.toString()); + m.setScores(scoreList.toString()); + m.setTrophies(trophyList.toString()); + m.setRunners(n); + m.setHumans(humans); + m.setTicks(room.run.tick()); + m.setEndedAt(now); + m.setRulesVersion(RulesVersion.ID); + m.setReplay(room.replay.encode()); + races.insert(m); + return out; + } +} +// end::dash-results[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Rooms.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Rooms.java new file mode 100644 index 00000000000..12657663644 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Rooms.java @@ -0,0 +1,712 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.admin.AdminChannel; +import com.codenameone.examples.dash.admin.Flags; +import com.codenameone.examples.dash.api.ChallengeDto; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.api.LiveRaceDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.forge.Levels; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.social.Crews; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Parties, quick-race queues and every race in progress. +/// +/// All state is guarded by this object's monitor: socket callbacks and the +/// ticker run on different threads. Database work and the final messages of a +/// race happen outside the lock, in [#tick], so a slow database never stalls +/// the other races. +// tag::dash-rooms[] +@Component +public class Rooms { + /// The ticker runs every 50 ms and steps each race to the wall clock. + static final long TICKER_MILLIS = 50; + /// How many ticks one ticker run may catch up, so a long pause cannot stall + /// the server re-simulating minutes of race. + static final int MAX_CATCH_UP = 30; + /// A quick race starts with this many runners, bots filling the gaps. + public static final int QUICK_RUNNERS = 4; + static final String[] BOT_NAMES = {"Pip", "Vector", "Juniper", "Bolt", "Mara"}; + + private final Results results; + private final Flags flags; + private final Challenges challenges; + private final AdminChannel admins; + private final Levels levels; + private final Crews crews; + private final long botAfterMillis; + + private final Map byId = new HashMap(); + private final Map byPlayer = new HashMap(); + /// Quick-race queues, one per level. + private final Map> queues = new HashMap>(); + /// Parties by challenge code, and the code each member is in. + private final Map parties = new HashMap(); + private final Map partyOf = new HashMap(); + + static final class Waiting { + final Profile profile; + WebSocketSession session; + final long since; + + Waiting(Profile profile, WebSocketSession session, long since) { + this.profile = profile; + this.session = session; + this.since = since; + } + } + + /// Friends gathered on one code before the host says go. Voice and chat + /// work in the lobby too, so the party can talk before it races. + static final class Party { + final String code; + final String url; + final String host; + String level; + final List members = new ArrayList(); + + Party(String code, String url, String host, String level) { + this.code = code; + this.url = url; + this.host = host; + this.level = level; + } + } + + public Rooms(Results results, Flags flags, Challenges challenges, AdminChannel admins, Levels levels, + Crews crews, @Value("${dash.race.bot-after-ms:6000}") long botAfterMillis) { + this.crews = crews; + this.results = results; + this.flags = flags; + this.challenges = challenges; + this.admins = admins; + this.levels = levels; + this.botAfterMillis = botAfterMillis; + } + + // ------------------------------------------------------------ connections + + synchronized void connected(String username, boolean admin, WebSocketSession session) { + if (admin) { + admins.add(username, session); + } + RaceRoom room = byPlayer.get(username); + if (room != null) { + // Back from a drop: the new socket gets the whole log and catches up. + int seat = room.seatOf(username); + room.seats[seat].session = session; + send(session, Messages.start(room, seat, room.startedAt - Clock.now())); + return; + } + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (w.profile.getUsername().equals(username)) { + w.session = session; + } + } + send(session, partyMessage(party)); + } + } + + synchronized void disconnected(String username, WebSocketSession session) { + admins.remove(username, session); + removeQueued(username); + RaceRoom room = byPlayer.get(username); + if (room != null) { + int seat = room.seatOf(username); + if (room.seats[seat].session == session) { + room.seats[seat].session = null; + } + } + for (RaceRoom r : byId.values()) { + r.spectators.remove(session); + } + // A party member who drops keeps their place until the race starts or + // they leave; the socket is simply gone until they reconnect. + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (w.session == session) { + w.session = null; + } + } + } + } + + /// Closes everything `username` has open: used when an admin suspends or bans. + public void kick(String username) { + WebSocketSession session = null; + synchronized (this) { + removeQueued(username); + leaveParty(username); + RaceRoom room = byPlayer.get(username); + if (room != null) { + Seat s = room.seats[room.seatOf(username)]; + session = s.session; + s.left = true; + } + } + if (session != null) { + send(session, Messages.simple(GameProtocol.KICKED)); + session.close(4403, "suspended"); + } + } + + // ------------------------------------------------------------ parties + + /// Opens a party on `level` and answers its share link. With a `crew` + /// code the host belongs to, the rest of that crew is pushed the invite. + void host(Profile player, WebSocketSession session, String level, String crew) throws IOException { + String ref = levels.normalize(level); + if (ref == null) { + send(session, Messages.error("No such level")); + return; + } + ChallengeDto c = challenges.create(player.getUsername()); + synchronized (this) { + if (byPlayer.containsKey(player.getUsername())) { + return; + } + leaveParty(player.getUsername()); + removeQueued(player.getUsername()); + Party party = new Party(c.code(), c.url(), player.getUsername(), ref); + party.members.add(new Waiting(player, session, Clock.now())); + parties.put(party.code, party); + partyOf.put(player.getUsername(), party.code); + broadcast(party, partyMessage(party)); + } + if (crew != null) { + crews.callToRace(player.getUsername(), player.getDisplayName(), crew, c.code()); + } + } + + synchronized void join(Profile player, WebSocketSession session, String code) { + Party party = code == null ? null : parties.get(code); + if (party == null || challenges.hostOf(code) == null) { + send(session, Messages.error("That party has closed")); + return; + } + if (byPlayer.containsKey(player.getUsername())) { + return; + } + if (code.equals(partyOf.get(player.getUsername()))) { + send(session, partyMessage(party)); + return; + } + if (party.members.size() >= GameProtocol.MAX_RUNNERS) { + send(session, Messages.error("That party is full")); + return; + } + leaveParty(player.getUsername()); + removeQueued(player.getUsername()); + party.members.add(new Waiting(player, session, Clock.now())); + partyOf.put(player.getUsername(), code); + challenges.joinedWhileAway(code, party.host, player.getDisplayName()); + broadcast(party, partyMessage(party)); + } + + /// The host starts the race. A party of one gets bots, so hosting alone + /// is still a race. + synchronized void go(String username, String level) { + Party party = parties.get(partyOf.get(username)); + if (party == null || !party.host.equals(username)) { + return; + } + if (level != null) { + String ref = levels.normalize(level); + if (ref != null) { + party.level = ref; + } + } + parties.remove(party.code); + for (Waiting w : party.members) { + partyOf.remove(w.profile.getUsername()); + } + challenges.used(party.code); + start(party.level, party.members, party.members.size() == 1 ? 2 : party.members.size()); + } + + private Party leaveParty(String username) { + String code = partyOf.remove(username); + Party party = code == null ? null : parties.get(code); + if (party == null) { + return null; + } + for (Iterator it = party.members.iterator(); it.hasNext();) { + if (it.next().profile.getUsername().equals(username)) { + it.remove(); + } + } + if (party.members.isEmpty() || party.host.equals(username)) { + // The host leaving closes the party for everyone. + parties.remove(code); + for (Waiting w : party.members) { + partyOf.remove(w.profile.getUsername()); + send(w.session, Messages.error("The host closed the party")); + } + } else { + broadcast(party, partyMessage(party)); + } + return party; + } + + private static String partyMessage(Party party) { + List names = new ArrayList(); + List players = new ArrayList(); + for (Waiting w : party.members) { + names.add(w.profile.getDisplayName()); + players.add(w.profile.getUsername()); + } + return Messages.party(party.code, party.url, party.level, party.host, names, players); + } + + // ------------------------------------------------------------ quick races + + synchronized void quick(Profile player, WebSocketSession session, String level) { + String ref = levels.normalize(level); + if (ref == null) { + send(session, Messages.error("No such level")); + return; + } + if (byPlayer.containsKey(player.getUsername())) { + return; + } + removeQueued(player.getUsername()); + leaveParty(player.getUsername()); + List q = queues.get(ref); + if (q == null) { + q = new ArrayList(); + queues.put(ref, q); + } + q.add(new Waiting(player, session, Clock.now())); + send(session, Messages.simple(GameProtocol.QUEUED)); + match(Clock.now()); + } + + synchronized void cancel(String username) { + removeQueued(username); + leaveParty(username); + } + + private void removeQueued(String username) { + for (List q : queues.values()) { + for (Iterator it = q.iterator(); it.hasNext();) { + if (it.next().profile.getUsername().equals(username)) { + it.remove(); + } + } + } + } + + /// Starts a queue's race once it is full, or once its oldest player has + /// waited [#botAfterMillis], with bots in the empty lanes. + private void match(long now) { + for (Iterator>> it = queues.entrySet().iterator(); it.hasNext();) { + Map.Entry> e = it.next(); + List q = e.getValue(); + if (q.isEmpty()) { + it.remove(); + continue; + } + if (q.size() >= QUICK_RUNNERS || now - q.get(0).since >= botAfterMillis) { + List runners = new ArrayList(); + while (!q.isEmpty() && runners.size() < QUICK_RUNNERS) { + runners.add(q.remove(0)); + } + start(e.getKey(), runners, QUICK_RUNNERS); + } + } + } + + private void start(String level, List humans, int runners) { + Levels.Resolved resolved; + try { + resolved = levels.resolve(level); + } catch (IOException | RuntimeException gone) { + resolved = null; + } + if (resolved == null) { + for (Waiting w : humans) { + send(w.session, Messages.error("That level is no longer available")); + } + return; + } + String id; + long seed; + try { + id = Ids.next().substring(0, 24); + byte[] r = Crypto.randomBytes(8); + seed = 0; + for (int i = 0; i < 8; i++) { + seed = (seed << 8) | (r[i] & 0xff); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + int n = Math.max(humans.size(), Math.min(runners, GameProtocol.MAX_RUNNERS)); + Seat[] seats = new Seat[n]; + int avg = 0; + for (int i = 0; i < humans.size(); i++) { + Profile p = humans.get(i).profile; + seats[i] = new Seat(p.getUsername(), p.getDisplayName(), p.getRunner(), p.getTrophies(), + humans.get(i).session); + avg += p.getTrophies(); + } + avg = humans.isEmpty() ? 0 : avg / humans.size(); + for (int i = humans.size(); i < n; i++) { + seats[i] = new Seat("", BOT_NAMES[(int) (((seed >>> 3) + i) % BOT_NAMES.length)], "", avg, null); + } + RaceRoom room = new RaceRoom(id, resolved.replayLevel, resolved.level, resolved.catalog, seed, seats, + Clock.now() + GameProtocol.COUNTDOWN_MILLIS); + byId.put(id, room); + for (Seat s : seats) { + if (!s.isBot()) { + byPlayer.put(s.username, room); + } + } + for (int i = 0; i < n; i++) { + send(seats[i].session, Messages.start(room, i, GameProtocol.COUNTDOWN_MILLIS)); + } + } + + // ------------------------------------------------------------ in a race + + synchronized void input(String username, int bits) { + RaceRoom room = byPlayer.get(username); + // An input during the countdown is stamped for tick INPUT_DELAY like + // any other: everyone hears of it in time, so it is no head start. + if (room == null) { + return; + } + if (room.intent(room.seatOf(username), bits)) { + flush(room); + } + } + + /// Emotes, chat and voice signaling go to whoever shares a race or a party + /// with the sender. + /// Both carry the sender's display name, never the account name: that is + /// an email address, and other runners see it. + synchronized void emote(String username, String name, String emote) { + if (emote == null || emote.length() > 32) { + return; + } + relay(username, Messages.emote(name, emote), false); + } + + synchronized void chat(String username, String name, String text) { + if (text == null || text.trim().length() == 0) { + return; + } + String t = text.length() > 200 ? text.substring(0, 200) : text; + relay(username, Messages.chat(name, t), false); + } + + /// One WebRTC signaling message to one peer. Only peers in the same race or + /// party can be reached, so the socket is no relay for strangers. + synchronized void voice(String username, String to, String data) { + if (to == null || data == null || data.length() > GameProtocol.MAX_MESSAGE) { + return; + } + for (WebSocketSession s : peers(username, to)) { + send(s, Messages.voice(username, data)); + } + } + + synchronized void mute(String username, boolean on) { + relay(username, Messages.mute(username, on), true); + } + + private void relay(String username, String msg, boolean includeSelf) { + RaceRoom room = byPlayer.get(username); + if (room != null) { + for (Seat s : room.seats) { + if (includeSelf || !s.username.equals(username)) { + send(s.session, msg); + } + } + return; + } + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (includeSelf || !w.profile.getUsername().equals(username)) { + send(w.session, msg); + } + } + } + } + + private List peers(String username, String to) { + List out = new ArrayList(); + RaceRoom room = byPlayer.get(username); + if (room != null) { + int seat = room.seatOf(to); + if (seat >= 0 && room.seats[seat].session != null) { + out.add(room.seats[seat].session); + } + return out; + } + Party party = parties.get(partyOf.get(username)); + if (party != null && party.code.equals(partyOf.get(to))) { + for (Waiting w : party.members) { + if (w.profile.getUsername().equals(to) && w.session != null) { + out.add(w.session); + } + } + } + return out; + } + + /// A client's own hash at `tick`. A mismatch means it computed a different + /// race: it is sent the log again, and repeated mismatches raise a flag. + void sync(String username, int tick, String hash) { + String flagReason = null; + String raceId = null; + synchronized (this) { + RaceRoom room = byPlayer.get(username); + if (room == null || hash == null) { + return; + } + Long ours = room.hashes.get(Integer.valueOf(tick)); + if (ours == null || Long.toHexString(ours.longValue()).equals(hash)) { + return; + } + int seat = room.seatOf(username); + Seat s = room.seats[seat]; + s.desyncs++; + send(s.session, Messages.start(room, seat, 0)); + if (s.desyncs == 3) { + flagReason = "Client state diverged from the server's three times in one race"; + raceId = room.id; + } + } + if (flagReason != null) { + flags.auto(username, flagReason, raceId); + } + } + + synchronized void leave(String username) { + RaceRoom room = byPlayer.get(username); + if (room != null) { + room.seats[room.seatOf(username)].left = true; + byPlayer.remove(username); + } + leaveParty(username); + removeQueued(username); + } + + synchronized boolean spectate(String raceId, WebSocketSession session) { + RaceRoom room = byId.get(raceId); + if (room == null) { + return false; + } + room.spectators.add(session); + send(session, Messages.start(room, -1, room.startedAt - Clock.now())); + return true; + } + + public synchronized String partyCode(String username) { + String code = partyOf.get(username); + return code == null ? "" : code; + } + + public synchronized String raceOf(String username) { + RaceRoom room = byPlayer.get(username); + return room == null ? "" : room.id; + } + + public synchronized List live() { + List out = new ArrayList(); + for (RaceRoom r : byId.values()) { + List players = new ArrayList(); + List names = new ArrayList(); + for (Seat s : r.seats) { + players.add(s.username); + names.add(s.name); + } + out.add(new LiveRaceDto(r.id, r.level, players, names, r.run.tick(), r.seats[r.leader()].name, + r.seats.length - r.humans())); + } + return out; + } + + public synchronized int online() { + int waiting = 0; + for (List q : queues.values()) { + waiting += q.size(); + } + return byPlayer.size() + waiting + partyOf.size(); + } + + // ------------------------------------------------------------ the ticker + + @Scheduled(fixedRate = TICKER_MILLIS, initialDelay = TICKER_MILLIS) + public void ticker() { + tick(Clock.now()); + } + + /// Advances every race to where the wall clock says it should be, then + /// records the ones that ended. Tests call this with their own clock. + public void tick(long now) { + List ended = new ArrayList(); + synchronized (this) { + match(now); + for (RaceRoom room : byId.values()) { + if (now < room.startedAt) { + continue; + } + long due = (now - room.startedAt) * Run.TICKS_PER_SECOND / 1000; + int steps = 0; + while (!room.isOver() && room.run.tick() < due && steps < MAX_CATCH_UP) { + step(room); + steps++; + } + if (steps > 0) { + flush(room); + } + if (room.isOver()) { + ended.add(room); + } + } + for (RaceRoom room : ended) { + detach(room); + } + } + for (RaceRoom room : ended) { + finish(room); + } + } + + private void step(RaceRoom room) { + room.step(); + if (room.run.tick() % GameProtocol.HASH_EVERY == 0) { + broadcast(room, Messages.hash(room.run.tick(), room.run.stateHash())); + } + } + + /// Steps one room straight to the end without waiting for the clock; tests use + /// it to run a whole race in milliseconds. + public void fastForward(String raceId) { + RaceRoom room; + synchronized (this) { + room = byId.get(raceId); + if (room == null) { + return; + } + while (!room.isOver()) { + step(room); + } + flush(room); + // Taken out under the same lock, so this thread records it and a + // caller reading the history straight after this returns finds it. + detach(room); + } + finish(room); + } + + private void detach(RaceRoom room) { + byId.remove(room.id); + for (Seat s : room.seats) { + if (byPlayer.get(s.username) == room) { + byPlayer.remove(s.username); + } + } + } + + private void finish(RaceRoom room) { + Results.Outcome[] outcome; + try { + outcome = results.record(room); + } catch (IOException | RuntimeException failed) { + System.err.println("[race] could not record " + room.id + ": " + failed); + outcome = new Results.Outcome[room.seats.length]; + for (int i = 0; i < outcome.length; i++) { + outcome[i] = new Results.Outcome(0, 0); + } + } + int[] places = room.places(); + int[] scores = new int[room.seats.length]; + for (int i = 0; i < scores.length; i++) { + scores[i] = room.run.runner(i).score(); + } + for (int i = 0; i < room.seats.length; i++) { + Seat s = room.seats[i]; + send(s.session, Messages.end(room.id, places, scores, outcome[i].trophies, outcome[i].coins)); + if (!s.isBot() && s.floods >= 25) { + flags.auto(s.username, "Sent " + s.floods + " inputs over the rate limit in one race", room.id); + } + } + String end = Messages.end(room.id, places, scores, 0, 0); + for (WebSocketSession s : room.spectators) { + send(s, end); + } + } + + private void flush(RaceRoom room) { + String msg = Messages.ticks(room.run.tick(), room.log, room.sent); + room.sent = room.log.size(); + room.lastSentTick = room.run.tick(); + broadcast(room, msg); + } + + private void broadcast(RaceRoom room, String msg) { + for (Seat s : room.seats) { + send(s.session, msg); + } + for (WebSocketSession s : room.spectators) { + send(s, msg); + } + } + + private void broadcast(Party party, String msg) { + for (Waiting w : party.members) { + send(w.session, msg); + } + } + + static void send(WebSocketSession session, String msg) { + try { + // A seat with no session is a headless runner: a bot, a dropped + // player, or a test driving the room directly. + if (session != null && session.isOpen()) { + session.sendText(msg); + } + } catch (IOException ignored) { + // The socket is going away; its close callback cleans up. + } + } +} +// end::dash-rooms[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Seat.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Seat.java new file mode 100644 index 00000000000..624ef1d6d77 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/Seat.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.WebSocketSession; + +/// One runner of a [RaceRoom]: a player and the socket they are on now, which +/// changes when they reconnect, or one of the server's bots. +final class Seat { + final String username; + final String name; + final String runner; + final int trophies; + WebSocketSession session; + /// Ticks at which this seat's recent inputs arrived, for flood detection. + final int[] recent = new int[16]; + int recentCount; + int floods; + int desyncs; + /// The player left the race; their runner keeps going with no input. + boolean left; + + Seat(String username, String name, String runner, int trophies, WebSocketSession session) { + this.username = username; + this.name = name; + this.runner = runner; + this.trophies = trophies; + this.session = session; + } + + boolean isBot() { + return username.length() == 0; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Goals.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Goals.java new file mode 100644 index 00000000000..20dd108750a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Goals.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.progress; + +/// The quests and achievements, as data. A quest or an achievement counts one +/// of the [Progress][com.codenameone.examples.dash.domain.Progress] +/// statistics; adding one is a line here. +public final class Goals { + public static final int WINS = 0; + public static final int RACES = 1; + public static final int METRES = 2; + public static final int PICKUPS = 3; + public static final int PODIUMS = 4; + public static final int STREAK = 5; + public static final int TUTORIAL = 6; + public static final int LEVEL = 7; + + /// A goal: the statistic it counts, how many, and what reaching it pays. + public static final class Goal { + public final String id; + public final String title; + public final String detail; + public final int stat; + public final int goal; + public final int coins; + public final int gems; + public final int xp; + + Goal(String id, String title, String detail, int stat, int goal, int coins, int gems, int xp) { + this.id = id; + this.title = title; + this.detail = detail; + this.stat = stat; + this.goal = goal; + this.coins = coins; + this.gems = gems; + this.xp = xp; + } + } + + /// The daily pool. Each player gets three of these a day, picked by + /// [#questsFor]. + static final Goal[] QUESTS = { + new Goal("win-1", "Win a race", "", WINS, 1, 25, 0, 40), + new Goal("win-2", "Win 2 races", "", WINS, 2, 45, 0, 70), + new Goal("play-3", "Run 3 races", "", RACES, 3, 35, 0, 50), + new Goal("metres-2000", "Run 2,000 metres", "", METRES, 2000, 30, 0, 40), + new Goal("pickups-60", "Grab 60 coins", "", PICKUPS, 60, 35, 0, 45), + new Goal("podium-3", "Finish 3 races on the podium", "", PODIUMS, 3, 50, 0, 70), + }; + + static final Goal[] ACHIEVEMENTS = { + new Goal("recruit", "Recruit", "Finish the tutorial run", TUTORIAL, 1, 0, 10, 50), + new Goal("first-blood", "First Blood", "Win your first race", WINS, 1, 0, 10, 50), + new Goal("veteran", "Veteran", "Run 10 races", RACES, 10, 0, 15, 80), + new Goal("conqueror", "Conqueror", "Win 10 races", WINS, 10, 0, 25, 120), + new Goal("medalist", "Medalist", "Finish 10 races on the podium", PODIUMS, 10, 0, 20, 100), + new Goal("marathon", "Marathon", "Run 42,195 metres", METRES, 42195, 0, 20, 100), + new Goal("magpie", "Magpie", "Grab 1,000 coins", PICKUPS, 1000, 0, 20, 100), + new Goal("unstoppable", "Unstoppable", "Win 3 races in a row", STREAK, 3, 0, 25, 120), + new Goal("seasoned", "Seasoned", "Reach level 5", LEVEL, 5, 0, 30, 0), + }; + + private Goals() { + } + + /// Today's three quests for a player: the same all day, different between + /// players and days, and never two alike. + static Goal[] questsFor(String username, long day) { + int h = (username + ":" + day).hashCode(); + int first = (h & 0x7fffffff) % QUESTS.length; + int step = 1 + ((h >>> 8) & 0x7fffffff) % (QUESTS.length - 1); + Goal[] out = new Goal[3]; + for (int n = 0; n < 3; n++) { + int at = (first + n * step) % QUESTS.length; + while (taken(out, n, QUESTS[at])) { + at = (at + 1) % QUESTS.length; + } + out[n] = QUESTS[at]; + } + return out; + } + + private static boolean taken(Goal[] picked, int count, Goal g) { + for (int i = 0; i < count; i++) { + if (picked[i] == g) { + return true; + } + } + return false; + } + + static Goal quest(String id) { + for (Goal g : QUESTS) { + if (g.id.equals(id)) { + return g; + } + } + return null; + } + + /// Experience at which `level` starts: 0, 100, 300, 600, 1000... + public static int levelStart(int level) { + return 50 * level * (level - 1); + } + + public static int levelOf(int xp) { + int level = 1; + while (xp >= levelStart(level + 1)) { + level++; + } + return level; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/ProgressEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/ProgressEndpoint.java new file mode 100644 index 00000000000..c0d63f9c5c4 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/ProgressEndpoint.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.progress; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.api.ClaimDto; +import com.codenameone.examples.dash.api.ProgressApiServer; +import com.codenameone.examples.dash.api.ProgressDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.social.Referrals; + +@Component +public class ProgressEndpoint implements ProgressApiServer { + private final Progression progression; + private final Referrals referrals; + + public ProgressEndpoint(Progression progression, Referrals referrals) { + this.progression = progression; + this.referrals = referrals; + } + + @Override + public ProgressDto progress() throws Exception { + return progression.view(Caller.name()); + } + + @Override + public RewardDto claimQuest(ClaimDto quest) throws Exception { + return progression.claim(Caller.name(), quest == null ? null : quest.questId()); + } + + @Override + public RewardDto openChest() throws Exception { + return progression.openChest(Caller.name()); + } + + @Override + public RewardDto tutorialDone() throws Exception { + RewardDto reward = progression.tutorialDone(Caller.name()); + // The tutorial run is a race: an invited player who finished it + // has played, and their inviter is paid. + referrals.played(Caller.name()); + return reward; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Progression.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Progression.java new file mode 100644 index 00000000000..cbbe4fed5ae --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/progress/Progression.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.progress; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.api.AchievementDto; +import com.codenameone.examples.dash.api.ProgressDto; +import com.codenameone.examples.dash.api.QuestDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Progress; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.ProgressRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Experience and levels, daily quests, chests and achievements: the reasons +/// to play one more race. Every reward is computed and granted here; the +/// client only shows what it is told. +// tag::dash-progression[] +@Component +public class Progression { + /// Chests wait in slots; a win with every slot full earns none. + public static final int CHEST_SLOTS = 4; + public static final int WIN_XP = 40; + public static final int PODIUM_XP = 25; + public static final int RACE_XP = 15; + /// A metre is worth a hundredth of an experience point, up to this. + public static final int METRE_XP_CAP = 30; + public static final int TUTORIAL_XP = 100; + public static final int TUTORIAL_COINS = 100; + public static final int LEVEL_UP_COINS = 60; + public static final int LEVEL_UP_GEMS = 5; + + private final ProgressRepository progress; + private final ProfileRepository profiles; + + public Progression(ProgressRepository progress, ProfileRepository profiles) { + this.progress = progress; + this.profiles = profiles; + } + + /// Counts a finished race for one runner. Called from inside the + /// transaction that records the race, with the profile it is about to + /// save, so the coins and gems this grants are saved with it. + public RewardDto afterRace(Profile p, int place, int runners, int metres, int pickups) throws IOException { + Progress g = load(p.getUsername()); + int[] before = stats(g); + boolean won = place == 0; + boolean podium = place < 3 && runners > 1; + g.setRaces(g.getRaces() + 1); + g.setMetres(g.getMetres() + metres); + g.setPickups(g.getPickups() + pickups); + g.setPodiums(g.getPodiums() + (podium ? 1 : 0)); + int chests = 0; + if (won) { + g.setWins(g.getWins() + 1); + g.setStreak(g.getStreak() + 1); + g.setBestStreak(Math.max(g.getBestStreak(), g.getStreak())); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + chests = 1; + } + } else { + g.setStreak(0); + } + int[] after = stats(g); + Goals.Goal[] quests = Goals.questsFor(p.getUsername(), g.getQuestDay()); + for (int i = 0; i < 3; i++) { + int gained = after[quests[i].stat] - before[quests[i].stat]; + setQuest(g, i, Math.min(quests[i].goal, quest(g, i) + gained)); + } + int xp = (won ? WIN_XP : podium ? PODIUM_XP : RACE_XP) + Math.min(METRE_XP_CAP, metres / 100); + Reward r = new Reward(won ? "First place!" : podium ? "On the podium" : "Race over"); + r.chests = chests; + r.xp(g, p, xp); + unlock(g, p, r); + progress.save(g); + return r.dto(g); + } + + @Transactional(rollbackFor = IOException.class) + public ProgressDto view(String username) throws IOException { + Profile p = profile(username); + // Reading never creates the row: the first race's result, recorded + // on the race ticker's thread, may be inserting it at this moment, + // and a second insert of the same key fails (MySQL waits on the + // uncommitted row, then reports a duplicate). + boolean stored = progress.findById(username) != null; + Progress g = stored ? load(username) : fresh(username); + if (stored) { + progress.save(g); + } + return dto(g, p); + } + + @Transactional(rollbackFor = IOException.class) + public RewardDto claim(String username, String questId) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Goals.Goal[] quests = Goals.questsFor(username, g.getQuestDay()); + int index = -1; + for (int i = 0; i < 3; i++) { + if (quests[i].id.equals(questId)) { + index = i; + } + } + if (index < 0) { + throw new ResponseStatusException(404, "That is not one of today's quests"); + } + if ((g.getClaimed() & (1 << index)) != 0) { + throw new ResponseStatusException(409, "Already claimed"); + } + Goals.Goal q = quests[index]; + if (quest(g, index) < q.goal) { + throw new ResponseStatusException(409, "Not finished yet"); + } + g.setClaimed(g.getClaimed() | (1 << index)); + Reward r = new Reward(q.title); + r.coins(p, q.coins); + r.xp(g, p, q.xp); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + return r.dto(g); + } + + /// Opens the oldest chest. What is inside grows with the level; every + /// third chest also holds gems. + @Transactional(rollbackFor = IOException.class) + public RewardDto openChest(String username) throws IOException { + Profile p = profile(username); + Progress g = load(username); + if (g.getChests() <= 0) { + throw new ResponseStatusException(409, "No chest to open"); + } + int level = Goals.levelOf(g.getXp()); + g.setChests(g.getChests() - 1); + g.setChestsOpened(g.getChestsOpened() + 1); + boolean rare = g.getChestsOpened() % 3 == 0; + Reward r = new Reward(rare ? "Gold Chest" : "Wooden Chest"); + r.coins(p, 40 + 15 * level + (rare ? 40 : 0)); + if (rare) { + r.gems(p, 4 + level); + } + r.xp(g, p, 20); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + return r.dto(g); + } + + /// The tutorial run pays once. + @Transactional(rollbackFor = IOException.class) + public RewardDto tutorialDone(String username) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Reward r = new Reward("Training complete"); + if (!g.isTutorialDone()) { + g.setTutorialDone(true); + r.coins(p, TUTORIAL_COINS); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + r.chests = 1; + } + r.xp(g, p, TUTORIAL_XP); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + } + return r.dto(g); + } + + /// Pays for stars won in a level. The caller has already verified + /// the result and decided the amounts; this only books them, so experience + /// still levels the player up and reaches achievements as it does anywhere. + @Transactional(rollbackFor = IOException.class) + public RewardDto levelReward(String username, String title, int coins, int xp) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Reward r = new Reward(title); + if (coins > 0 || xp > 0) { + r.coins(p, coins); + r.xp(g, p, xp); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + } + return r.dto(g); + } + + /// Gives a demo account some history -- a few levels, two chests, a + /// finished quest waiting to be claimed -- so the development server's + /// home screen shows what a regular player sees, and the tutorial does not + /// start on every sign-in. + @Transactional(rollbackFor = IOException.class) + public void seedDemo(String username) throws IOException { + Progress g = load(username); + if (g.getRaces() > 0) { + return; + } + g.setTutorialDone(true); + g.setXp(420); + g.setChests(2); + g.setChestsOpened(1); + g.setRaces(7); + g.setWins(4); + g.setMetres(6200); + g.setPickups(140); + g.setPodiums(6); + g.setStreak(2); + g.setBestStreak(2); + Goals.Goal[] today = Goals.questsFor(username, g.getQuestDay()); + g.setQuest0(today[0].goal); + g.setQuest1(today[1].goal / 2); + // Unlocked for what the counters say, without paying for it: the + // profile here is a throwaway, never saved. + Profile scratch = new Profile(); + unlock(g, scratch, new Reward("")); + g.setXp(420); + progress.save(g); + } + + private Profile profile(String username) throws IOException { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such player"); + } + return p; + } + + /// The player's row, created on first use, with today's quests reset when + /// the day has turned. + private Progress load(String username) throws IOException { + Progress g = progress.findById(username); + long today = Clock.dayOf(Clock.now()); + if (g == null) { + g = fresh(username); + progress.insert(g); + } + if (g.getQuestDay() != today) { + g.setQuestDay(today); + g.setQuest0(0); + g.setQuest1(0); + g.setQuest2(0); + g.setClaimed(0); + } + return g; + } + + private static Progress fresh(String username) { + Progress g = new Progress(); + g.setUsername(username); + g.setQuestDay(Clock.dayOf(Clock.now())); + return g; + } + + private static int[] stats(Progress g) { + return new int[] {g.getWins(), g.getRaces(), g.getMetres(), g.getPickups(), g.getPodiums(), + g.getBestStreak(), g.isTutorialDone() ? 1 : 0, Goals.levelOf(g.getXp())}; + } + + private static int quest(Progress g, int i) { + return i == 0 ? g.getQuest0() : i == 1 ? g.getQuest1() : g.getQuest2(); + } + + private static void setQuest(Progress g, int i, int value) { + if (i == 0) { + g.setQuest0(value); + } else if (i == 1) { + g.setQuest1(value); + } else { + g.setQuest2(value); + } + } + + private static boolean unlocked(Progress g, String id) { + return ("," + g.getAchievements() + ",").indexOf("," + id + ",") >= 0; + } + + /// Unlocks every achievement now reached. Unlocking pays experience, which + /// can level up, which can reach the level achievement: hence the loop. + private static void unlock(Progress g, Profile p, Reward r) { + boolean more = true; + while (more) { + more = false; + int[] s = stats(g); + for (Goals.Goal a : Goals.ACHIEVEMENTS) { + if (!unlocked(g, a.id) && s[a.stat] >= a.goal) { + g.setAchievements(g.getAchievements().isEmpty() ? a.id : g.getAchievements() + "," + a.id); + r.unlocked.add(a.title); + r.gems(p, a.gems); + r.xp(g, p, a.xp); + more = true; + } + } + } + } + + private static ProgressDto dto(Progress g, Profile p) { + int level = Goals.levelOf(g.getXp()); + Goals.Goal[] today = Goals.questsFor(g.getUsername(), g.getQuestDay()); + List quests = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + Goals.Goal q = today[i]; + quests.add(new QuestDto(q.id, q.title, quest(g, i), q.goal, q.coins, q.xp, + (g.getClaimed() & (1 << i)) != 0)); + } + int[] s = stats(g); + List achievements = new ArrayList<>(); + for (Goals.Goal a : Goals.ACHIEVEMENTS) { + achievements.add(new AchievementDto(a.id, a.title, a.detail, Math.min(a.goal, s[a.stat]), a.goal, + unlocked(g, a.id))); + } + return new ProgressDto(level, g.getXp(), Goals.levelStart(level), Goals.levelStart(level + 1), + g.isTutorialDone(), g.getChests(), p.getCoins(), p.getGems(), g.getStreak(), g.getWins(), + quests, achievements); + } + + /// A reward being put together; granting through it keeps the popup and + /// the balances in step. + private static final class Reward { + final String title; + final List unlocked = new ArrayList<>(); + int coins; + int gems; + int xp; + int chests; + boolean levelUp; + + Reward(String title) { + this.title = title; + } + + void coins(Profile p, int n) { + p.setCoins(p.getCoins() + n); + coins += n; + } + + void gems(Profile p, int n) { + p.setGems(p.getGems() + n); + gems += n; + } + + /// Adds experience; each level gained pays coins, gems and a chest. + void xp(Progress g, Profile p, int n) { + int from = Goals.levelOf(g.getXp()); + g.setXp(g.getXp() + n); + xp += n; + int to = Goals.levelOf(g.getXp()); + for (int l = from; l < to; l++) { + levelUp = true; + coins(p, LEVEL_UP_COINS); + gems(p, LEVEL_UP_GEMS); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + chests++; + } + } + } + + RewardDto dto(Progress g) { + return new RewardDto(title, coins, gems, xp, chests, Goals.levelOf(g.getXp()), levelUp, unlocked); + } + } +} +// end::dash-progression[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/CloudPushSender.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/CloudPushSender.java new file mode 100644 index 00000000000..4491db029a9 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/CloudPushSender.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.Tasks; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::dash-push-cloud[] +/// Sends through the Codename One push service, which delivers to Firebase, +/// Apple, Huawei and the browsers on the app's behalf. +/// +/// The call to the service is made on a thread of its own, so the request that +/// changed a delivery answers without waiting for it, and a refusal is logged and +/// not thrown: the customer is still told over the live channel and on their next +/// look at the delivery. +public class CloudPushSender implements PushSender { + private final CloudPush push; + + /// @param push the client for the service, holding the app's id and the + /// server key + public CloudPushSender(CloudPush push) { + this.push = push; + } + + @Override + public void send(List targets, final PushNotification message, + final Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + final List to = new ArrayList(targets); + Tasks.platform(() -> deliver(to, message, delivered)); + } + + private void deliver(List to, PushNotification message, Runnable delivered) { + boolean accepted = false; + try { + PushResult result = push.send(message, to); + accepted = result.isAccepted(); + if (!accepted) { + System.err.println("[push] the push service refused a notification: " + + result.getStatus() + " " + result.getError()); + } + } catch (IOException | RuntimeException err) { + System.err.println("[push] a notification was not sent: " + err); + } + if (accepted && delivered != null) { + try { + delivered.run(); + } catch (RuntimeException err) { + System.err.println("[push] a notification was sent, and what follows it failed: " + + err); + } + } + } +} +// end::dash-push-cloud[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/ConsolePushSender.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/ConsolePushSender.java new file mode 100644 index 00000000000..66391b5a862 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/ConsolePushSender.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// Prints each notification on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.dash.Services] hands it +/// out on a development profile alone. +public class ConsolePushSender implements PushSender { + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + System.out.println("[push] to " + targets.size() + " device(s): " + + message.toJson()); + // Printed is as delivered as this sender gets. + if (delivered != null) { + delivered.run(); + } + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/GamePushes.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/GamePushes.java new file mode 100644 index 00000000000..e7628f858dc --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/GamePushes.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; +import java.util.ArrayList; +import java.util.List; + +/// The notifications the game sends. Each one brings a player back for a +/// reason they care about; none is sent for the sake of sending. +// tag::dash-pushes[] +@Component +public class GamePushes { + private final PushDevices devices; + private final PushSender sender; + + public GamePushes(PushDevices devices, PushSender sender) { + this.devices = devices; + this.sender = sender; + } + + /// Someone opened the host's challenge link while the host was not waiting. + public void challengeAccepted(String host, String challenger, String code) { + send(host, PushNotification.builder() + .title("Challenge accepted") + .body(challenger + " is waiting for you at the start line") + .collapseKey("challenge-" + code) + .ttlSeconds(600) + .data("challenge", code)); + } + + /// A friend's run went past the player's best on a level they both ran. + /// One per level at a time: a newer one replaces it in the tray. + public void scoreBeaten(String username, String friend, String level, int score) { + send(username, PushNotification.builder() + .title(friend + " beat your score") + .body(score + " points on " + level + ". Run it again?") + .collapseKey("beaten-" + level) + .ttlSeconds(86400) + .data("screen", "level:" + level)); + } + + /// A crewmate opened a party: the crew's race is about to start. + public void crewRacing(String username, String host, String crew, String code) { + send(username, PushNotification.builder() + .title(crew + " is racing") + .body(host + " opened a party. Jump in before the start") + .collapseKey("party-" + code) + .ttlSeconds(900) + .data("challenge", code)); + } + + /// The daily rewarded-ad allowance is back. + public void rewardsReady(String username) { + send(username, PushNotification.builder() + .title("Free chest ready") + .body("Your daily rewards are back. Come and claim them") + .collapseKey("rewards") + .data("screen", "shop")); + } + + /// An admin learns about a new flag without watching the console. + public void flagRaised(List admins, String username, String reason) { + for (String admin : admins) { + send(admin, PushNotification.builder() + .title("Player flagged") + .body(username + ": " + reason) + .collapseKey("flags") + .data("screen", "admin") + .data("player", username)); + } + } + + /// Someone joined through the player's invite. + public void friendJoined(String inviter, String friend) { + send(inviter, PushNotification.builder() + .title("Your friend joined") + .body(friend + " joined Dash through your invite") + .collapseKey("friends") + .data("screen", "friends")); + } + + /// That friend finished a race, and the inviter was paid for it. + public void friendPlayed(String inviter, String friend, int coins) { + send(inviter, PushNotification.builder() + .title("Invite reward") + .body(friend + " played their first race. " + coins + " coins are yours") + .collapseKey("friends") + .data("screen", "friends")); + } + + /// An operator's message to many players at once, through the same + /// Codename One push service as everything else. `screen` and `campaign` + /// ride in the data: the app opens that screen and reports the open to + /// Codename One analytics under the campaign. Answers how many of the + /// players had a device to send to. + public int broadcast(List usernames, String id, String title, String body, String screen, + String campaign) { + List targets = new ArrayList(); + int reached = 0; + for (String u : usernames) { + List mine = devices.targetsOf(u); + if (!mine.isEmpty()) { + reached++; + targets.addAll(mine); + } + } + if (!targets.isEmpty()) { + sender.send(targets, PushNotification.builder() + .id(id) + .title(title) + .body(body) + .collapseKey("broadcast") + .ttlSeconds(24 * 60 * 60) + .data("screen", screen) + .data("campaign", campaign) + .data("broadcast", id) + .build(), null); + } + return reached; + } + + private void send(String username, PushNotification.Builder message) { + List targets = devices.targets(username); + if (!targets.isEmpty()) { + sender.send(targets, message.build(), null); + } + } +} +// end::dash-pushes[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/MisconfiguredPushSender.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/MisconfiguredPushSender.java new file mode 100644 index 00000000000..2cc019bebcd --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/MisconfiguredPushSender.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// What the server sends with when the push settings are incomplete: nothing +/// goes out, and the log says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings to fix. Unlike a text message, a notification that does +/// not go out costs nobody a sign-in, so it is not an error to the caller. +public class MisconfiguredPushSender implements PushSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPushSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets != null && !targets.isEmpty()) { + System.err.println("[config] a push notification was not sent: " + problem); + } + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushDevices.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushDevices.java new file mode 100644 index 00000000000..cf85261f002 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushDevices.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.Text; +import com.codenameone.examples.dash.api.PushDeviceDto; +import com.codenameone.examples.dash.domain.PushDevice; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.PushDeviceRepository; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +// tag::dash-push-devices[] +/// The devices each account is sent notifications on. +/// +/// The app registers its token when somebody signs in and removes it when they +/// sign out. A token registered by a second account moves to it: the phone +/// belongs to whoever signed in on it last, and the one before is not to be +/// told about the new owner's deliveries. +@Component +public class PushDevices { + /// How many devices one account keeps. A new one past this replaces the + /// one heard from longest ago: a token the platform has replaced is never + /// removed by its app, and would otherwise be sent to for good. + static final int MAX_PER_ACCOUNT = 8; + + private final PushDeviceRepository devices; + private final ProfileRepository profiles; + + public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) { + this.devices = devices; + this.profiles = profiles; + } + + @Transactional + public PushDeviceDto register(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + String id = key(target); + long now = System.currentTimeMillis(); + // Before anything is read, the account's profile row is held: a no-op + // update that answers 0 when the profile is gone and otherwise keeps + // the row locked until this commits. It settles two races. + // + // Against closing the account: Accounts.erase holds the same row + // first and deletes it before it commits, so a registration either + // runs first (and erase's cleanup then deletes the device with the + // rest) or waits, finds no profile and is refused. Without it, a + // registration authorized just before the closure could insert its + // device after erase removed the account's, leaving a device of an + // account that no longer exists and that nobody can unregister. + // + // Against itself: two devices registering at once for an account one + // short of the cap would both count the same rows in makeRoom(), both + // skip the eviction and both insert, one past the cap. Behind the hold + // the second counts after the first committed, and evicts. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The device row itself is not locked. Two accounts registering one + // token at the same moment -- a registration of the account that + // signed out still in flight as the next one signs in -- can leave it + // with the earlier account. The app settles it: a registration that + // answers after its account signed out is sent again for the account + // signed in now, and that one, sent after the stale one committed, + // lands last (Notifications.signedIn). + PushDevice row = devices.findById(id); + if (row == null || !username.equals(row.getUsername())) { + // A device new to this account, whether its token is new or moves + // here from another account, takes a place under the cap. + makeRoom(username); + } + if (row == null) { + row = new PushDevice(); + row.setId(id); + row.setProvider(target.getProvider()); + row.setToken(target.getToken()); + fill(row, username, device, now); + devices.insert(row); + } else { + fill(row, username, device, now); + } + return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform()); + } + + /// Removes `username`'s devices heard from longest ago until one more fits + /// under [#MAX_PER_ACCOUNT]. + private void makeRoom(String username) { + List held = new ArrayList(devices.findByUsername(username)); + while (held.size() >= MAX_PER_ACCOUNT) { + PushDevice oldest = held.get(0); + for (PushDevice each : held) { + if (each.getUpdatedAt().isBefore(oldest.getUpdatedAt())) { + oldest = each; + } + } + devices.deleteById(oldest.getId()); + held.remove(oldest); + } + } + + /// Forgets a device, if it is this account's. Somebody else's is left alone. + @Transactional + public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + devices.deleteByIdAndUsername(key(target), username); + return new PushDeviceDto(target.getProvider(), target.getToken(), ""); + } + + /// Where to reach `username`: every device they registered. + @Transactional(readOnly = true) + public List targets(String username) { + return targetsOf(username); + } + + /// [#targets] for a caller already inside a transaction. + public List targetsOf(String username) { + List out = new ArrayList(); + if (username == null || username.length() == 0) { + return out; + } + for (PushDevice each : devices.findByUsername(username)) { + out.add(PushTarget.of(each.getProvider(), each.getToken())); + } + return out; + } + // end::dash-push-devices[] + + private static void fill(PushDevice row, String username, PushDeviceDto device, long now) { + row.setUsername(username); + row.setPlatform(Text.optional(device.platform(), 32, "the platform")); + row.setUpdatedAt(Instant.ofEpochMilli(now)); + } + + /// The device as a target of the push service, which also spells its + /// provider the one way: `gcm` and `fcm` are one device. + private static PushTarget target(PushDeviceDto device) { + if (device == null) { + throw new ResponseStatusException(400, "Tell us the device"); + } + String token = Text.required(device.token(), 1024, "the device's token"); + try { + return PushTarget.of(Text.required(device.provider(), 16, "the push provider"), token); + } catch (IllegalArgumentException err) { + throw new ResponseStatusException(400, err.getMessage()); + } + } + + private static String key(PushTarget target) throws IOException { + return Ids.sha256(target.getProvider() + ":" + target.getToken()); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushSender.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushSender.java new file mode 100644 index 00000000000..f30c9823a5a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/PushSender.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +// tag::dash-push-sender[] +/// Sends push notifications to devices. +/// +/// [com.codenameone.examples.dash.Services] chooses the implementation from +/// `dash.push.provider`. A notification is a courtesy on top of what the +/// app reads from the server, so sending one never fails the delivery it is about: +/// an implementation logs what went wrong and returns. +public interface PushSender { + /// Sends `message` to every one of `targets`. Returns at once; the sending + /// itself may happen later, on another thread. + /// + /// @param delivered run once the push service has taken the message, on + /// whatever thread learnt it; never when it was refused or could not be + /// sent. Null when nobody needs to know. + void send(List targets, PushNotification message, Runnable delivered); +} +// end::dash-push-sender[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/RecordingPushSender.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/RecordingPushSender.java new file mode 100644 index 00000000000..c5d5660ec09 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/push/RecordingPushSender.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Keeps every notification instead of sending it, for the tests to read. +/// +/// What the `test` profile gets: a test signs a phone in, drives a delivery, and +/// asks this what each device was sent. Refused on any profile that is not a +/// development one. +public class RecordingPushSender implements PushSender { + private final List sent = new ArrayList(); + private int refusals; + private int unconfirmed; + + /// One notification as one device was sent it. + public static final class Sent { + private final String provider; + private final String token; + private final Map message; + + Sent(String provider, String token, Map message) { + this.provider = provider; + this.token = token; + this.message = message; + } + + public String provider() { + return provider; + } + + public String token() { + return token; + } + + /// The notification as it would go on the wire: `title`, `body`, + /// `data`, `surface` and the rest. + public Map message() { + return message; + } + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + // Outside the lock: what follows a delivery writes to the database. + if (record(targets, message) && delivered != null) { + delivered.run(); + } + } + + /// Records a send, and answers whether the push service would confirm it. + private synchronized boolean record(List targets, PushNotification message) { + if (refusals > 0) { + refusals--; + throw new IllegalStateException("The push provider is unavailable"); + } + if (targets != null) { + for (PushTarget target : targets) { + sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap())); + } + } + if (unconfirmed > 0) { + unconfirmed--; + return false; + } + return true; + } + + /// What the device with `token` was sent, oldest first. + public synchronized List sentTo(String token) { + List out = new ArrayList(); + for (Sent each : sent) { + if (each.token.equals(token)) { + out.add(each); + } + } + return out; + } + + /// Everything sent so far, oldest first. + public synchronized List all() { + return new ArrayList(sent); + } + + public synchronized void clear() { + sent.clear(); + refusals = 0; + unconfirmed = 0; + } + + /// Makes the next `count` sends go out and never be confirmed, as a + /// service that refuses them later, on its own thread, does. + public synchronized void leaveUnconfirmed(int count) { + unconfirmed = count; + } + + /// Makes the next `count` sends throw, as a provider that is down does. + public synchronized void refuse(int count) { + refusals = count; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/AdminActionRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/AdminActionRepository.java new file mode 100644 index 00000000000..5e6014054c6 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/AdminActionRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.AdminAction; +import java.util.List; + +@Repository(entity = AdminAction.class, key = String.class) +public interface AdminActionRepository extends CrudRepository { + @Query("select a from AdminAction a order by a.createdAt desc, a.id asc") + List newest(PageRequest page); + + @Query("select a from AdminAction a where a.username = :u order by a.createdAt desc, a.id asc") + List of(@Param("u") String username, PageRequest page); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/BroadcastRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/BroadcastRepository.java new file mode 100644 index 00000000000..feebcd949f7 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/BroadcastRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Broadcast; +import java.time.Instant; +import java.util.List; + +@Repository(entity = Broadcast.class, key = String.class) +public interface BroadcastRepository extends CrudRepository { + @Query("select b from Broadcast b order by b.sentAt desc, b.id asc") + List newest(PageRequest page); + + long countBySentAtGreaterThan(Instant since); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewMemberRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewMemberRepository.java new file mode 100644 index 00000000000..d72672a20a0 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewMemberRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.CrewMember; +import java.util.List; + +@Repository(entity = CrewMember.class, key = String.class) +public interface CrewMemberRepository extends CrudRepository { + @Query("select m from CrewMember m where m.username = :u order by m.joinedAt asc") + List crewsOf(@Param("u") String username, PageRequest page); + + @Query("select m from CrewMember m where m.code = :c order by m.joinedAt asc") + List members(@Param("c") String code, PageRequest page); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewRepository.java new file mode 100644 index 00000000000..f5f1d0a8fb3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/CrewRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Crew; + +@Repository(entity = Crew.class, key = String.class) +public interface CrewRepository extends CrudRepository { +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/FlagRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/FlagRepository.java new file mode 100644 index 00000000000..fbdd0a23b7b --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/FlagRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Flag; +import java.util.List; + +@Repository(entity = Flag.class, key = String.class) +public interface FlagRepository extends CrudRepository { + @Query("select f from Flag f where f.pending = :pending order by f.createdAt desc, f.id asc") + List inState(@Param("pending") boolean pending, PageRequest page); + + @Query("select f from Flag f where f.username = :u order by f.createdAt desc, f.id asc") + List of(@Param("u") String username, PageRequest page); + + long countByPendingTrue(); + + long countByUsernameAndPendingTrue(String username); + + long countByReporterAndRaceId(String reporter, String raceId); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeAssetRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeAssetRepository.java new file mode 100644 index 00000000000..6a62b4aa538 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeAssetRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ForgeAsset; +import java.time.Instant; +import java.util.List; + +@Repository(entity = ForgeAsset.class, key = String.class) +public interface ForgeAssetRepository extends CrudRepository { + @Query("select a from ForgeAsset a where a.status = :s order by a.createdAt asc, a.sha256 asc") + List inStatus(@Param("s") String status, PageRequest page); + + long countByOwnerAndCreatedAtGreaterThan(String owner, Instant since); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeEarningRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeEarningRepository.java new file mode 100644 index 00000000000..ed6be2b5078 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeEarningRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ForgeEarning; + +@Repository(entity = ForgeEarning.class, key = String.class) +public interface ForgeEarningRepository extends CrudRepository { +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackRepository.java new file mode 100644 index 00000000000..8444c69020c --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackRepository.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ForgePack; +import java.util.List; + +@Repository(entity = ForgePack.class, key = String.class) +public interface ForgePackRepository extends CrudRepository { + @Query("select p from ForgePack p where p.owner = :u order by p.updatedAt desc, p.id asc") + List ownedBy(@Param("u") String owner, PageRequest page); + + @Query("select p from ForgePack p where p.status = :s and p.official = :o order by p.name asc, p.id asc") + List official(@Param("s") String status, @Param("o") boolean official, PageRequest page); + + /// Published packs every file of which a moderator has approved. + @Query("select p from ForgePack p where p.status = :s and p.pendingAssets = :pa " + + "order by p.plays desc, p.id asc") + List popular(@Param("s") String status, @Param("pa") boolean pendingAssets, PageRequest page); + + @Query("select p from ForgePack p where p.status = :s and p.pendingAssets = :pa " + + "order by p.updatedAt desc, p.id asc") + List newest(@Param("s") String status, @Param("pa") boolean pendingAssets, PageRequest page); + + /// What a moderator should look at: reported packs and hidden ones. + @Query("select p from ForgePack p where p.reports > :r or p.status = :s order by p.reports desc, p.id asc") + List queue(@Param("r") int minReports, @Param("s") String status, PageRequest page); + + /// Packs still waiting on a file a moderator has not approved. + @Query("select p from ForgePack p where p.pendingAssets = :pa order by p.id asc") + List awaitingAssets(@Param("pa") boolean pendingAssets, PageRequest page); + + long countByOwner(String owner); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackVersionRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackVersionRepository.java new file mode 100644 index 00000000000..2f7f7765530 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgePackVersionRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ForgePackVersion; +import java.util.List; + +@Repository(entity = ForgePackVersion.class, key = String.class) +public interface ForgePackVersionRepository extends CrudRepository { + @Query("select v from ForgePackVersion v where v.packId = :p order by v.version desc") + List of(@Param("p") String packId, PageRequest page); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeRatingRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeRatingRepository.java new file mode 100644 index 00000000000..791c530677e --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ForgeRatingRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ForgeRating; + +@Repository(entity = ForgeRating.class, key = String.class) +public interface ForgeRatingRepository extends CrudRepository { +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/GameTicketRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/GameTicketRepository.java new file mode 100644 index 00000000000..14124d8ce70 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/GameTicketRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.GameTicket; +import java.time.Instant; + +/// The tickets that open a live connection. Only the hash of a ticket is +/// stored, and it is the row's key. +@Repository(entity = GameTicket.class, key = String.class) +public interface GameTicketRepository extends CrudRepository { + /// Deletes a ticket and answers how many rows that removed: 1 for the one + /// caller that used it, 0 for anyone presenting it after. A statement and + /// not a read followed by a removal, because that count is the whole + /// point. + int deleteByTicketHash(String ticketHash); + + /// Deletes the tickets that went stale before `now`. + int deleteByExpiresAtLessThan(Instant now); + + int deleteByUsername(String username); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/LevelStarsRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/LevelStarsRepository.java new file mode 100644 index 00000000000..11069a0b97a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/LevelStarsRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Perlevel is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.LevelStars; +import java.util.List; + +@Repository(entity = LevelStars.class, key = String.class) +public interface LevelStarsRepository extends CrudRepository { + @Query("select m from LevelStars m where m.username = :u and m.packId = :p order by m.levelId asc") + List of(@Param("u") String username, @Param("p") String packId, PageRequest page); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineHitRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineHitRepository.java new file mode 100644 index 00000000000..cbfcdc3ef57 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineHitRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.PipelineHit; +import java.util.List; + +@Repository(entity = PipelineHit.class, key = String.class) +public interface PipelineHitRepository extends CrudRepository { + List findByPipeline(String pipeline); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineRepository.java new file mode 100644 index 00000000000..4ad82a0201b --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PipelineRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Pipeline; +import java.util.List; + +@Repository(entity = Pipeline.class, key = String.class) +public interface PipelineRepository extends CrudRepository { + @Query("select p from Pipeline p order by p.name asc, p.id asc") + List byName(); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProfileRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProfileRepository.java new file mode 100644 index 00000000000..309e21376c6 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProfileRepository.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Profile; +import java.time.Instant; +import java.util.List; + +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Locks the row for the rest of the transaction; answers 0 when there is none. + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); + + @Query("select p from Profile p where p.banned = false order by p.trophies desc, p.username asc") + List leaders(PageRequest page); + + @Query("select p from Profile p where p.username like :q or p.displayName like :q " + + "order by p.lastSeen desc, p.username asc") + List search(@Param("q") String like, PageRequest page); + + long countByFlaggedTrue(); + + @Query("select count(p) from Profile p where p.banned = true or p.suspendedUntil > :now") + Long suspended(@Param("now") Instant now); + + /// The players a broadcast reaches, a page at a time. Banned players are + /// never sent anything; `seenBefore` and `joinedAfter` narrow it to the + /// lapsed and the new (pass `Instant.ofEpochMilli(Long.MAX_VALUE)` and + /// `Instant.ofEpochMilli(Long.MIN_VALUE)` to leave them out). + @Query("select p from Profile p where p.banned = false and p.lastSeen < :seen and p.createdAt > :joined " + + "order by p.username asc") + List audience(@Param("seen") Instant seenBefore, @Param("joined") Instant joinedAfter, PageRequest page); + + @Query("select count(p) from Profile p where p.banned = false and p.lastSeen < :seen and p.createdAt > :joined") + Long audienceSize(@Param("seen") Instant seenBefore, @Param("joined") Instant joinedAfter); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProgressRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProgressRepository.java new file mode 100644 index 00000000000..07d865f38fe --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ProgressRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Progress; + +/// Each account's levels, quests, chests and achievements. +@Repository(entity = Progress.class, key = String.class) +public interface ProgressRepository extends CrudRepository { +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PushDeviceRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PushDeviceRepository.java new file mode 100644 index 00000000000..651cb84eb66 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/PushDeviceRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.PushDevice; + +import java.util.List; + +/// The devices each account is sent notifications on. +@Repository(entity = PushDevice.class, key = String.class) +public interface PushDeviceRepository extends CrudRepository { + List findByUsername(String username); + + int deleteByUsername(String username); + + int deleteByIdAndUsername(String id, String username); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceEntryRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceEntryRepository.java new file mode 100644 index 00000000000..989b27bce7c --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceEntryRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.RaceEntry; +import java.time.Instant; +import java.util.List; + +@Repository(entity = RaceEntry.class, key = String.class) +public interface RaceEntryRepository extends CrudRepository { + @Query("select e from RaceEntry e where e.username = :u order by e.endedAt desc, e.id asc") + List history(@Param("u") String username, PageRequest page); + + @Query("select count(e) from RaceEntry e where e.username = :u") + Long countFor(@Param("u") String username); + + @Query("select count(e) from RaceEntry e where e.username = :u and e.endedAt > :since") + Long countForSince(@Param("u") String username, @Param("since") Instant since); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceRecordRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceRecordRepository.java new file mode 100644 index 00000000000..671893106fe --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/RaceRecordRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.RaceRecord; +import java.time.Instant; +import java.util.List; + +@Repository(entity = RaceRecord.class, key = String.class) +public interface RaceRecordRepository extends CrudRepository { + long countByEndedAtGreaterThan(Instant since); + + @Query("select r from RaceRecord r where r.endedAt >= :since") + List since(@Param("since") Instant since); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralCodeRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralCodeRepository.java new file mode 100644 index 00000000000..0e096f5de92 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralCodeRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.ReferralCode; +import java.util.List; + +@Repository(entity = ReferralCode.class, key = String.class) +public interface ReferralCodeRepository extends CrudRepository { + List findByUsername(String username); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralRepository.java new file mode 100644 index 00000000000..5bbe7e27075 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ReferralRepository.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Referral; +import java.util.List; + +@Repository(entity = Referral.class, key = String.class) +public interface ReferralRepository extends CrudRepository { + @Query("select r from Referral r where r.inviter = :u order by r.joinedAt desc, r.invitee asc") + List of(@Param("u") String inviter, PageRequest page); + + @Query("select r from Referral r order by r.joinedAt desc, r.invitee asc") + List newest(PageRequest page); + + long countByInviter(String inviter); + + long countByInviterAndRewardedTrue(String inviter); + + @Query("select count(r) from Referral r where r.state = :s") + Long inState(@Param("s") String state); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ScoreRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ScoreRepository.java new file mode 100644 index 00000000000..00908ab961d --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/ScoreRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Score; +import java.util.List; + +@Repository(entity = Score.class, key = String.class) +public interface ScoreRepository extends CrudRepository { + @Query("select s from Score s where s.board = :b and s.level = :l order by s.score desc, s.createdAt asc") + List top(@Param("b") String board, @Param("l") String level, PageRequest page); + + @Query("select count(s) from Score s where s.board = :b and s.level = :l and s.score > :score") + Long above(@Param("b") String board, @Param("l") String level, @Param("score") int score); + + @Modifying + @Query("delete from Score s where s.username = :u") + int deleteFor(@Param("u") String username); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/SpotlightRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/SpotlightRepository.java new file mode 100644 index 00000000000..f25ba4f4519 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/SpotlightRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.Spotlight; + +@Repository(entity = Spotlight.class, key = String.class) +public interface SpotlightRepository extends CrudRepository { +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/StorePurchaseRepository.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/StorePurchaseRepository.java new file mode 100644 index 00000000000..73f95bf1d44 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/repository/StorePurchaseRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.dash.domain.StorePurchase; +import java.time.Instant; +import java.util.List; + +@Repository(entity = StorePurchase.class, key = String.class) +public interface StorePurchaseRepository extends CrudRepository { + @Query("select s from StorePurchase s where s.username = :u order by s.createdAt desc, s.id asc") + List of(@Param("u") String username); + + @Query("select s from StorePurchase s where s.createdAt >= :since") + List since(@Param("since") Instant since); +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Catalog.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Catalog.java new file mode 100644 index 00000000000..9e82211f7d8 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Catalog.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import com.codenameone.examples.dash.api.ShopItemDto; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Everything the shop sells. Cosmetic only: nothing here changes the physics, so a +/// purchase can never decide a race. +/// +/// The `sku` of a real-money item must match the product id configured in App +/// Store Connect and the Play Console. +// tag::dash-catalog[] +public final class Catalog { + public static final String GEMS = "gems"; + public static final String OUTFIT = "outfit"; + public static final String RUNNER = "runner"; + public static final String EMOTES = "emotes"; + public static final String PASS = "pass"; + + public static final String DEFAULT_OUTFIT = "trail-spark"; + public static final String FREE_RUNNER = "runner-dash"; + public static final String DEFAULT_EMOTES = "emotes-classic"; + public static final String GOLD_PASS = "gold-pass"; + + private static final List ITEMS; + + static { + List items = new ArrayList(); + items.add(new ShopItemDto("gems-80", "Pouch of 80 gems", GEMS, 0, 0, "dash.gems_80")); + items.add(new ShopItemDto("gems-500", "Crate of 500 gems", GEMS, 0, 0, "dash.gems_500")); + items.add(new ShopItemDto(GOLD_PASS, "Gold Pass: the golden trail", PASS, 0, 0, "dash.gold_pass")); + items.add(new ShopItemDto("runner-nova", "Nova, the runner", RUNNER, 500, 0, "")); + items.add(new ShopItemDto("runner-kite", "Kite, the runner", RUNNER, 0, 60, "")); + items.add(new ShopItemDto("trail-neon", "Neon trail", OUTFIT, 400, 0, "")); + items.add(new ShopItemDto("trail-comet", "Comet trail", OUTFIT, 0, 40, "")); + items.add(new ShopItemDto("emotes-taunt", "Taunt emotes", EMOTES, 0, 30, "")); + ITEMS = Collections.unmodifiableList(items); + } + + private Catalog() { + } + + public static List items() { + return ITEMS; + } + + public static ShopItemDto item(String id) { + for (ShopItemDto i : ITEMS) { + if (i.id().equals(id)) { + return i; + } + } + return null; + } + + public static ShopItemDto bySku(String sku) { + for (ShopItemDto i : ITEMS) { + if (i.sku().length() > 0 && i.sku().equals(sku)) { + return i; + } + } + return null; + } + + /// Gems a gem pack grants. + public static int gems(ShopItemDto item) { + if ("gems-80".equals(item.id())) { + return 80; + } + if ("gems-500".equals(item.id())) { + return 500; + } + return 0; + } + + /// The trail an owned item unlocks, or null. + public static String outfitOf(String itemId) { + if (GOLD_PASS.equals(itemId)) { + return "trail-gold"; + } + return itemId.startsWith("trail-") ? itemId : null; + } + + /// Whether `id` names a runner: the free one, or one the shop sells. + public static boolean isRunner(String id) { + if (FREE_RUNNER.equals(id)) { + return true; + } + ShopItemDto item = item(id); + return item != null && RUNNER.equals(item.kind()); + } +} +// end::dash-catalog[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/MisconfiguredReceipts.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/MisconfiguredReceipts.java new file mode 100644 index 00000000000..b4e25a289b3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/MisconfiguredReceipts.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import com.codename1.backend.ResponseStatusException; + +/// Stands in when the store settings are missing, so the server still starts and +/// says what is wrong instead of granting purchases it cannot check. +public class MisconfiguredReceipts implements ReceiptValidator { + private final String problem; + + public MisconfiguredReceipts(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public String verify(String store, String sku, String transactionId, String receipt) { + throw new ResponseStatusException(503, "Purchases are not available right now"); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ReceiptValidator.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ReceiptValidator.java new file mode 100644 index 00000000000..72a86be38ad --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ReceiptValidator.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import java.io.IOException; + +/// Asks a store whether a purchase is real. The server grants nothing on the +/// app's word: a modified client can claim any purchase it likes. +// tag::dash-receipt-validator[] +public interface ReceiptValidator { + /// The store's transaction id when `receipt` proves a purchase of `sku`, or + /// null when it does not. + String verify(String store, String sku, String transactionId, String receipt) throws IOException; +} +// end::dash-receipt-validator[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Shop.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Shop.java new file mode 100644 index 00000000000..878362238fe --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/Shop.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.Text; +import com.codenameone.examples.dash.account.Lists; +import com.codenameone.examples.dash.api.ReceiptDto; +import com.codenameone.examples.dash.api.ShopItemDto; +import com.codenameone.examples.dash.api.WalletDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.StorePurchase; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.StorePurchaseRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// The wallet: coins (earned by winning and by rewarded ads), gems (bought) and +/// the cosmetic items they unlock. +// tag::dash-shop[] +@Component +public class Shop { + public static final int AD_REWARDS_PER_DAY = 3; + public static final int AD_REWARD_COINS = 25; + + private final ProfileRepository profiles; + private final StorePurchaseRepository purchases; + private final ReceiptValidator validator; + + public Shop(ProfileRepository profiles, StorePurchaseRepository purchases, ReceiptValidator validator) { + this.profiles = profiles; + this.purchases = purchases; + this.validator = validator; + } + + @Transactional(readOnly = true) + public WalletDto wallet(String username) { + return toDto(profile(username)); + } + + /// Spends coins or gems on a cosmetic. + @Transactional(rollbackFor = IOException.class) + public WalletDto buy(String username, String itemId) { + ShopItemDto item = Catalog.item(itemId); + if (item == null || item.sku().length() > 0) { + throw new ResponseStatusException(404, "That is not for sale for coins or gems"); + } + hold(username); + Profile p = profile(username); + if (Lists.contains(p.getOwned(), item.id())) { + throw new ResponseStatusException(409, "You already own it"); + } + if (p.getCoins() < item.priceCoins() || p.getGems() < item.priceGems()) { + throw new ResponseStatusException(402, "Not enough coins or gems"); + } + p.setCoins(p.getCoins() - item.priceCoins()); + p.setGems(p.getGems() - item.priceGems()); + grant(p, item.id()); + profiles.save(p); + return toDto(p); + } + + @Transactional(rollbackFor = IOException.class) + public WalletDto equip(String username, String outfit) { + Profile p = profile(username); + boolean owned = false; + for (String id : Lists.split(p.getOwned())) { + String unlocked = Catalog.outfitOf(id); + owned |= unlocked != null && unlocked.equals(outfit); + } + if (!owned) { + throw new ResponseStatusException(403, "You do not own that trail"); + } + p.setOutfit(outfit); + profiles.save(p); + return toDto(p); + } + + /// Grants a store purchase once its receipt checks out. The store's + /// transaction id is the row's key, so replaying a receipt grants nothing. + public WalletDto receipt(String username, ReceiptDto receipt) throws IOException { + if (receipt == null) { + throw new ResponseStatusException(400, "No receipt"); + } + String store = Text.oneOf(receipt.store(), new String[] {"itunes", "play", "simulator"}, "", + "the store"); + ShopItemDto item = Catalog.bySku(receipt.sku()); + if (item == null) { + throw new ResponseStatusException(404, "Unknown product"); + } + String transaction = validator.verify(store, receipt.sku(), + Text.optional(receipt.transactionId(), 190, "the transaction"), receipt.receipt()); + if (transaction == null) { + throw new ResponseStatusException(402, "The store did not confirm this purchase"); + } + return redeem(username, store, item, transaction); + } + + @Transactional(rollbackFor = IOException.class) + WalletDto redeem(String username, String store, ShopItemDto item, String transaction) throws IOException { + hold(username); + Profile p = profile(username); + String key = Ids.sha256(transaction); + StorePurchase seen = purchases.findById(key); + if (seen != null) { + if (!seen.getUsername().equals(username)) { + throw new ResponseStatusException(409, "That purchase belongs to another account"); + } + return toDto(p); + } + StorePurchase row = new StorePurchase(); + row.setId(key); + row.setUsername(username); + row.setStore(store); + row.setSku(item.sku()); + int gems = Catalog.gems(item); + row.setGems(gems); + row.setCreatedAt(Instant.now()); + purchases.insert(row); + if (gems > 0) { + p.setGems(p.getGems() + gems); + } else { + grant(p, item.id()); + } + profiles.save(p); + return toDto(p); + } + + /// The reward for a watched rewarded ad: a few coins, a few times a day. + /// + /// The cap is the guard here. In production, have the ad network call the + /// server directly (AdMob's server-side verification) and grant from that + /// callback, so not even a modified app can claim a reward it did not earn. + @Transactional(rollbackFor = IOException.class) + public WalletDto adReward(String username) { + hold(username); + Profile p = profile(username); + long today = Clock.dayOf(Clock.now()); + if (p.getAdDay() != today) { + p.setAdDay(today); + p.setAdCount(0); + } + if (p.getAdCount() >= AD_REWARDS_PER_DAY) { + throw new ResponseStatusException(429, "That is all the rewards for today"); + } + p.setAdCount(p.getAdCount() + 1); + p.setCoins(p.getCoins() + AD_REWARD_COINS); + profiles.save(p); + return toDto(p); + } + + @Transactional(readOnly = true) + public List purchasesOf(String username) { + List out = new ArrayList(); + for (StorePurchase s : purchases.of(username)) { + out.add(s.getStore() + " " + s.getSku() + " " + s.getCreatedAt()); + } + return out; + } + + private static void grant(Profile p, String itemId) { + if (!Lists.contains(p.getOwned(), itemId)) { + p.setOwned(p.getOwned().length() == 0 ? itemId : p.getOwned() + "," + itemId); + } + } + + private void hold(String username) { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + private Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } + + static WalletDto toDto(Profile p) { + int left = p.getAdDay() == Clock.dayOf(Clock.now()) ? AD_REWARDS_PER_DAY - p.getAdCount() : AD_REWARDS_PER_DAY; + return new WalletDto(p.getCoins(), p.getGems(), Lists.split(p.getOwned()), p.getOutfit(), left); + } +} +// end::dash-shop[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ShopEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ShopEndpoint.java new file mode 100644 index 00000000000..71b5c741ab3 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/ShopEndpoint.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.api.AdRewardDto; +import com.codenameone.examples.dash.api.BuyDto; +import com.codenameone.examples.dash.api.EquipDto; +import com.codenameone.examples.dash.api.ReceiptDto; +import com.codenameone.examples.dash.api.ShopApiServer; +import com.codenameone.examples.dash.api.ShopItemDto; +import com.codenameone.examples.dash.api.WalletDto; +import java.util.List; + +@Component +public class ShopEndpoint implements ShopApiServer { + private final Shop shop; + + public ShopEndpoint(Shop shop) { + this.shop = shop; + } + + @Override + public List items() throws Exception { + return Catalog.items(); + } + + @Override + public WalletDto wallet() throws Exception { + return shop.wallet(Caller.name()); + } + + @Override + public WalletDto buy(BuyDto item) throws Exception { + return shop.buy(Caller.name(), item == null ? null : item.itemId()); + } + + @Override + public WalletDto equip(EquipDto outfit) throws Exception { + return shop.equip(Caller.name(), outfit == null ? null : outfit.outfit()); + } + + @Override + public WalletDto receipt(ReceiptDto receipt) throws Exception { + return shop.receipt(Caller.name(), receipt); + } + + @Override + public WalletDto adReward(AdRewardDto reward) throws Exception { + return shop.adReward(Caller.name()); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/SimulatedReceipts.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/SimulatedReceipts.java new file mode 100644 index 00000000000..391c43cdcbd --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/SimulatedReceipts.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +/// Development and tests: accepts the receipts the simulator's store produces, +/// which carry no signature to check. Refused outside development profiles. +public class SimulatedReceipts implements ReceiptValidator { + @Override + public String verify(String store, String sku, String transactionId, String receipt) { + if (transactionId == null || transactionId.length() == 0 || sku == null) { + return null; + } + return "sim:" + transactionId; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/StoreReceipts.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/StoreReceipts.java new file mode 100644 index 00000000000..c60af450d7d --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/shop/StoreReceipts.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.shop; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// Verifies receipts with the stores themselves. +/// +/// - Apple: the receipt is posted to `verifyReceipt` with the app's shared +/// secret; a sandbox receipt (status 21007) is retried against the sandbox. +/// Apple now recommends the App Store Server API for new code; this endpoint +/// still answers, and keeps the sample free of a signing-key setup. Swap the +/// method for a call to `/inApps/v1/transactions/{id}` when you outgrow it. +/// - Google Play: the purchase token is checked with the Play Developer API, +/// authorized by an OAuth access token for a service account with the +/// "View financial data" perlevel (`dash.iap.google.token`). Minting +/// that token from the service account key is left to your deployment. +// tag::dash-store-receipts[] +public class StoreReceipts implements ReceiptValidator { + static final String APPLE_PRODUCTION = "https://buy.itunes.apple.com/verifyReceipt"; + static final String APPLE_SANDBOX = "https://sandbox.itunes.apple.com/verifyReceipt"; + + private final String appleSecret; + private final String googlePackage; + private final String googleToken; + + public StoreReceipts(String appleSecret, String googlePackage, String googleToken) { + this.appleSecret = appleSecret; + this.googlePackage = googlePackage; + this.googleToken = googleToken; + } + + @Override + public String verify(String store, String sku, String transactionId, String receipt) throws IOException { + if ("itunes".equals(store)) { + return apple(sku, transactionId, receipt); + } + if ("play".equals(store)) { + return google(sku, receipt); + } + return null; + } + + private String apple(String sku, String transactionId, String receipt) throws IOException { + if (appleSecret.length() == 0) { + return null; + } + String body = "{\"receipt-data\":" + Json.write(receipt) + ",\"password\":" + Json.write(appleSecret) + + ",\"exclude-old-transactions\":true}"; + Map answer = Json.parseObject(Web.postJson(APPLE_PRODUCTION, body, null).getBodyAsString()); + if (answer.get("status") instanceof Number && ((Number) answer.get("status")).intValue() == 21007) { + answer = Json.parseObject(Web.postJson(APPLE_SANDBOX, body, null).getBodyAsString()); + } + if (!(answer.get("status") instanceof Number) || ((Number) answer.get("status")).intValue() != 0) { + return null; + } + Object r = answer.get("receipt"); + Object inApp = r instanceof Map ? ((Map) r).get("in_app") : null; + if (!(inApp instanceof List)) { + return null; + } + for (Object each : (List) inApp) { + if (each instanceof Map) { + Map t = (Map) each; + if (sku.equals(t.get("product_id")) && transactionId != null + && transactionId.equals(t.get("transaction_id"))) { + return "itunes:" + transactionId; + } + } + } + return null; + } + + private String google(String sku, String purchaseToken) throws IOException { + if (googleToken.length() == 0 || googlePackage.length() == 0 || purchaseToken == null) { + return null; + } + String url = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/" + + googlePackage + "/purchases/products/" + sku + "/tokens/" + purchaseToken; + Web.Result answer = Web.getJson(url, googleToken); + if (!answer.isSuccess()) { + return null; + } + Map purchase = Json.parseObject(answer.getBodyAsString()); + Object state = purchase.get("purchaseState"); + Object order = purchase.get("orderId"); + if (!(state instanceof Number) || ((Number) state).intValue() != 0 || !(order instanceof String)) { + return null; + } + return "play:" + order; + } +} +// end::dash-store-receipts[] diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Audience.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Audience.java new file mode 100644 index 00000000000..0085b44b14a --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Audience.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.ResponseStatusException; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Referral; +import com.codenameone.examples.dash.domain.StorePurchase; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The player query the console's query tool and every engagement pipeline +/// share: a list of conditions such as `daysAway >= 7` and `purchases = 0`, +/// all of which a player must meet. +/// +/// The fields are what the game itself knows about a player -- their +/// account, races, purchases and invites -- never events tracked for +/// analytics, which live in Codename One analytics. +/// +/// It reads every account into memory, which is right for a sample and a +/// game of a few thousand players. A larger game turns the conditions into +/// a `where` clause instead; the list of fields stays the same. +// tag::dash-audience[] +public final class Audience { + /// Every field a condition may name. + public static final String[] FIELDS = {"daysAway", "daysSinceJoin", "trophies", "races", "wins", + "gems", "coins", "purchases", "friends"}; + public static final String[] OPS = {">=", "<=", "=", ">", "<", "!="}; + public static final int MAX_CONDITIONS = 6; + /// Every field is a count of something, never negative. + public static final int MAX_VALUE = 1000000; + + private final List conditions; + private final long now; + private final Map purchases; + private final Map friends; + + private Audience(List conditions, long now, Map purchases, + Map friends) { + this.conditions = conditions; + this.now = now; + this.purchases = purchases; + this.friends = friends; + } + + /// Checks the conditions and gathers what they need to be tested. + public static Audience of(List conditions, List sales, List invites) { + check(conditions); + Map paid = new HashMap<>(); + for (StorePurchase s : sales) { + add(paid, s.getUsername()); + } + Map brought = new HashMap<>(); + for (Referral r : invites) { + add(brought, r.getInviter()); + } + return new Audience(conditions, Clock.now(), paid, brought); + } + + /// Refuses an unknown field or operator, and more than [#MAX_CONDITIONS]. + public static void check(List conditions) { + if (conditions == null || conditions.size() > MAX_CONDITIONS) { + throw new ResponseStatusException(400, "Up to " + MAX_CONDITIONS + " conditions"); + } + for (ConditionDto c : conditions) { + if (c == null || indexOf(FIELDS, c.field()) < 0 || indexOf(OPS, c.op()) < 0 || c.value() < 0 + || c.value() > MAX_VALUE) { + throw new ResponseStatusException(400, "Unknown condition " + + (c == null ? "" : c.field() + " " + c.op())); + } + } + } + + /// True when the player meets every condition. Banned players never do. + public boolean matches(Profile p) { + if (p.isBanned()) { + return false; + } + for (ConditionDto c : conditions) { + if (!test(value(p, c.field()), c.op(), c.value())) { + return false; + } + } + return true; + } + + /// The players who match, in the order given. + public List filter(List all) { + List out = new ArrayList<>(); + for (Profile p : all) { + if (matches(p)) { + out.add(p); + } + } + return out; + } + + private int value(Profile p, String field) { + switch (field) { + case "daysAway": + return days(now - p.getLastSeen().toEpochMilli()); + case "daysSinceJoin": + return days(now - p.getCreatedAt().toEpochMilli()); + case "trophies": + return p.getTrophies(); + case "races": + return p.getWins() + p.getLosses() + p.getDraws(); + case "wins": + return p.getWins(); + case "gems": + return p.getGems(); + case "coins": + return p.getCoins(); + case "purchases": + return count(purchases, p.getUsername()); + default: + return count(friends, p.getUsername()); + } + } + + private static boolean test(int v, String op, int against) { + switch (op) { + case ">=": + return v >= against; + case "<=": + return v <= against; + case "=": + return v == against; + case ">": + return v > against; + case "<": + return v < against; + default: + return v != against; + } + } + // end::dash-audience[] + + /// Conditions as one line, `daysAway >= 7; purchases = 0`, for the database. + public static String encode(List conditions) { + StringBuilder sb = new StringBuilder(); + for (ConditionDto c : conditions) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(c.field()).append(' ').append(c.op()).append(' ').append(c.value()); + } + return sb.toString(); + } + + public static List decode(String line) { + List out = new ArrayList<>(); + if (line == null) { + return out; + } + // String.split is a JDK method the compiled backend does not have. + for (String part : words(line, ';')) { + List w = words(part, ' '); + if (w.size() == 3) { + out.add(new ConditionDto(w.get(0), w.get(1), Integer.parseInt(w.get(2)))); + } + } + return out; + } + + private static List words(String s, char sep) { + List out = new ArrayList<>(); + int start = 0; + for (int i = 0; i <= s.length(); i++) { + if (i == s.length() || s.charAt(i) == sep) { + String w = s.substring(start, i).trim(); + if (w.length() > 0) { + out.add(w); + } + start = i + 1; + } + } + return out; + } + + private static int days(long millis) { + return millis <= 0 ? 0 : (int) (millis / Clock.DAY); + } + + private static int count(Map m, String key) { + Integer n = m.get(key); + return n == null ? 0 : n; + } + + private static void add(Map m, String key) { + m.put(key, count(m, key) + 1); + } + + private static int indexOf(String[] all, String s) { + for (int i = 0; i < all.length; i++) { + if (all[i].equals(s)) { + return i; + } + } + return -1; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Crews.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Crews.java new file mode 100644 index 00000000000..32ad0103d27 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Crews.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.CrewDto; +import com.codenameone.examples.dash.domain.Crew; +import com.codenameone.examples.dash.domain.CrewMember; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.CrewMemberRepository; +import com.codenameone.examples.dash.repository.CrewRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Crews: small groups of friends with their own leaderboard. A crew is +/// joined by its code, which is also its share link. +@Component +public class Crews { + public static final int MAX_MEMBERS = 30; + public static final int MAX_CREWS = 5; + + private final CrewRepository crews; + private final CrewMemberRepository members; + private final ProfileRepository profiles; + private final GamePushes pushes; + + public Crews(CrewRepository crews, CrewMemberRepository members, ProfileRepository profiles, + GamePushes pushes) { + this.crews = crews; + this.members = members; + this.profiles = profiles; + this.pushes = pushes; + } + + /// The host opened a party for their crew: every other member hears about + /// it. Only a member may call their crew, so a crew code alone cannot be + /// used to push strangers. + @Transactional(readOnly = true) + public void callToRace(String host, String hostName, String code, String party) throws IOException { + Crew c = code == null ? null : crews.findById(code); + if (c == null || members.findById(code + "|" + host) == null) { + return; + } + for (CrewMember m : members.members(code, new PageRequest(0, MAX_MEMBERS))) { + if (!m.getUsername().equals(host)) { + pushes.crewRacing(m.getUsername(), hostName, c.getName(), party); + } + } + } + + @Transactional(readOnly = true) + public List of(String username) throws IOException { + List out = new ArrayList(); + for (CrewMember m : members.crewsOf(username, new PageRequest(0, MAX_CREWS))) { + Crew c = crews.findById(m.getCode()); + if (c != null) { + out.add(view(c)); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto create(String username, String name) throws IOException { + String n = name == null ? "" : name.trim(); + if (n.length() < 2 || n.length() > 40) { + throw new ResponseStatusException(400, "A crew name is 2 to 40 characters"); + } + if (members.crewsOf(username, new PageRequest(0, MAX_CREWS)).size() >= MAX_CREWS) { + throw new ResponseStatusException(409, "You are in " + MAX_CREWS + " crews already"); + } + Crew c = new Crew(); + c.setCode(Ids.hex(Crypto.randomBytes(4)).toUpperCase(java.util.Locale.ROOT)); + c.setName(n); + c.setOwner(username); + c.setCreatedAt(Instant.now()); + crews.insert(c); + return join(username, c.getCode()); + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto join(String username, String code) throws IOException { + Crew c = code == null ? null : crews.findById(code); + if (c == null) { + throw new ResponseStatusException(404, "No crew has that code"); + } + String id = code + "|" + username; + if (members.findById(id) == null) { + if (c.getMembers() >= MAX_MEMBERS) { + throw new ResponseStatusException(409, "That crew is full"); + } + if (members.crewsOf(username, new PageRequest(0, MAX_CREWS)).size() >= MAX_CREWS) { + throw new ResponseStatusException(409, "You are in " + MAX_CREWS + " crews already"); + } + CrewMember m = new CrewMember(); + m.setId(id); + m.setCode(code); + m.setUsername(username); + m.setJoinedAt(Instant.now()); + members.insert(m); + c.setMembers(c.getMembers() + 1); + crews.save(c); + } + return view(c); + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto leave(String username, String code) throws IOException { + Crew c = code == null ? null : crews.findById(code); + CrewMember m = c == null ? null : members.findById(code + "|" + username); + if (m == null) { + throw new ResponseStatusException(404, "You are not in that crew"); + } + members.deleteById(m.getId()); + c.setMembers(Math.max(0, c.getMembers() - 1)); + crews.save(c); + return view(c); + } + + private CrewDto view(Crew c) { + List usernames = new ArrayList(); + List names = new ArrayList(); + for (CrewMember m : members.members(c.getCode(), new PageRequest(0, MAX_MEMBERS))) { + Profile p = profiles.findById(m.getUsername()); + usernames.add(m.getUsername()); + names.add(p == null ? m.getUsername() : p.getDisplayName()); + } + return new CrewDto(c.getCode(), c.getName(), c.getOwner(), usernames, names); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Insights.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Insights.java new file mode 100644 index 00000000000..914be807bcb --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Insights.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.NameCountDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.QueryDto; +import com.codenameone.examples.dash.api.QueryResultDto; +import com.codenameone.examples.dash.api.InsightsDto; +import com.codenameone.examples.dash.account.Lists; +import com.codenameone.examples.dash.domain.RaceRecord; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Referral; +import com.codenameone.examples.dash.domain.StorePurchase; +import com.codenameone.examples.dash.repository.RaceRecordRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.ReferralRepository; +import com.codenameone.examples.dash.repository.StorePurchaseRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// The console's reports and its query tool, read from the game's own +/// records: accounts, finished races, store purchases and invites. +/// +/// These are the numbers the game can answer about itself. What players do +/// inside the app -- screens, taps, sessions, invite shares, push opens -- is +/// reported by the app to Codename One analytics and read there. +// tag::dash-insights[] +@Component +public class Insights { + public static final int MAX_DAYS = 90; + /// How many matching players a query lists; the count covers them all. + public static final int LISTED = 50; + private static final int[] RETURNED_AFTER = {1, 7, 30}; + private static final int[] BANDS = {0, 100, 300, 600, 1000}; + + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final StorePurchaseRepository purchases; + private final ReferralRepository referrals; + private final Accounts accounts; + + public Insights(ProfileRepository profiles, RaceRecordRepository races, StorePurchaseRepository purchases, + ReferralRepository referrals, Accounts accounts) { + this.profiles = profiles; + this.races = races; + this.purchases = purchases; + this.referrals = referrals; + this.accounts = accounts; + } + + @Transactional(readOnly = true) + public InsightsDto report(int days) { + int n = days < 1 ? 1 : days > MAX_DAYS ? MAX_DAYS : days; + long now = Clock.now(); + long first = Clock.dayOf(now) - (n - 1) * Clock.DAY; + List all = profiles.findAll(); + int[] joined = new int[n]; + for (Profile p : all) { + bump(joined, first, p.getCreatedAt().toEpochMilli()); + } + int[] raced = new int[n]; + List> fought = new ArrayList<>(); + for (int i = 0; i < n; i++) { + fought.add(new HashSet()); + } + for (RaceRecord m : races.since(Instant.ofEpochMilli(first))) { + int d = dayIndex(first, m.getEndedAt().toEpochMilli(), n); + if (d >= 0) { + raced[d]++; + fought.get(d).addAll(Lists.split(m.getPlayers())); + } + } + Set known = new HashSet<>(); + for (Profile p : all) { + known.add(p.getUsername()); + } + int[] active = new int[n]; + for (int i = 0; i < n; i++) { + // Only players count, never a bot. + fought.get(i).retainAll(known); + active[i] = fought.get(i).size(); + } + int[] gems = new int[n]; + List sales = purchases.since(Instant.EPOCH); + Set paid = new HashSet<>(); + for (StorePurchase s : sales) { + int d = dayIndex(first, s.getCreatedAt().toEpochMilli(), n); + if (d >= 0) { + gems[d] += s.getGems(); + } + paid.add(s.getUsername()); + } + Set inviters = new HashSet<>(); + for (Referral r : referrals.findAll()) { + inviters.add(r.getInviter()); + } + int played = 0; + int won = 0; + for (Profile p : all) { + if (p.getWins() + p.getLosses() + p.getDraws() > 0) { + played++; + } + if (p.getWins() > 0) { + won++; + } + } + List funnel = new ArrayList<>(); + funnel.add(new NameCountDto("Joined", all.size())); + funnel.add(new NameCountDto("Played", played)); + funnel.add(new NameCountDto("Won", won)); + funnel.add(new NameCountDto("Paid", paid.size())); + funnel.add(new NameCountDto("Invited", inviters.size())); + return new InsightsDto(first, list(active), list(joined), list(raced), list(gems), funnel, + retention(all, now), bands(all)); + } + + /// Of the players who joined at least N days ago, the share seen N days + /// or more after joining. The server keeps when a player was last seen, + /// not every visit, so this is "came back at least once that late". + private static List retention(List all, long now) { + List out = new ArrayList<>(); + for (int after : RETURNED_AFTER) { + long span = after * Clock.DAY; + int cohort = 0; + int back = 0; + for (Profile p : all) { + if (p.getCreatedAt().toEpochMilli() <= now - span) { + cohort++; + if (p.getLastSeen().toEpochMilli() - p.getCreatedAt().toEpochMilli() >= span) { + back++; + } + } + } + out.add(new NameCountDto("Day " + after, cohort == 0 ? 0 : back * 100 / cohort)); + } + return out; + } + + private static List bands(List all) { + int[] counts = new int[BANDS.length]; + for (Profile p : all) { + int b = 0; + while (b + 1 < BANDS.length && p.getTrophies() >= BANDS[b + 1]) { + b++; + } + counts[b]++; + } + List out = new ArrayList<>(); + for (int b = 0; b < BANDS.length; b++) { + out.add(new NameCountDto(b + 1 < BANDS.length ? BANDS[b] + "-" + (BANDS[b + 1] - 1) : BANDS[b] + "+", + counts[b])); + } + return out; + } + + /// The players meeting every condition: how many, and the first + /// [#LISTED] of them, the longest away first. + @Transactional(readOnly = true) + public QueryResultDto query(QueryDto q) { + Audience audience = audience(q == null ? null : q.conditions()); + List found = audience.filter(profiles.findAll()); + Collections.sort(found, (a, b) -> a.getLastSeen().isBefore(b.getLastSeen()) ? -1 + : a.getLastSeen().isAfter(b.getLastSeen()) ? 1 : a.getUsername().compareTo(b.getUsername())); + List listed = new ArrayList<>(); + for (int i = 0; i < found.size() && i < LISTED; i++) { + listed.add(accounts.toDto(found.get(i))); + } + return new QueryResultDto(found.size(), listed); + } + + /// The audience for a list of conditions, with the purchases and invites + /// the conditions may ask about. + public Audience audience(List conditions) { + Audience.check(conditions); + return Audience.of(conditions, purchases.since(Instant.EPOCH), referrals.findAll()); + } + // end::dash-insights[] + + private static void bump(int[] series, long first, long at) { + int d = dayIndex(first, at, series.length); + if (d >= 0) { + series[d]++; + } + } + + private static int dayIndex(long first, long at, int n) { + if (at < first) { + return -1; + } + long d = (at - first) / Clock.DAY; + return d >= n ? -1 : (int) d; + } + + private static List list(int[] values) { + List out = new ArrayList<>(); + for (int v : values) { + out.add(v); + } + return out; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Pipelines.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Pipelines.java new file mode 100644 index 00000000000..0213ac90b92 --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Pipelines.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.PipelineDto; +import com.codenameone.examples.dash.domain.AdminAction; +import com.codenameone.examples.dash.domain.Pipeline; +import com.codenameone.examples.dash.domain.PipelineHit; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.AdminActionRepository; +import com.codenameone.examples.dash.repository.PipelineHitRepository; +import com.codenameone.examples.dash.repository.PipelineRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Engagement pipelines: "a player who has not played for 7 days gets a +/// push that opens the shop", "a player who reached 300 trophies and never +/// bought anything gets 20 gems". +/// +/// A pipeline is a query ([Audience]) and an action. Once an hour the +/// server runs every enabled one: each matching player it has not acted on +/// within the pipeline's cool-down gets the action, and the time is kept in +/// `pipeline_hit`, so nobody is pushed twice for the same reason. Pushes go +/// through Codename One push with the campaign `pipeline-`, so an open +/// is attributed to the pipeline in Codename One analytics. +// tag::dash-pipelines[] +@Component +public class Pipelines { + public static final String PUSH = "push"; + public static final String GEMS = "gems"; + public static final int MAX_PIPELINES = 20; + public static final int MAX_GEMS = 500; + public static final long EVERY = 60L * 60 * 1000; + + private final PipelineRepository pipelines; + private final PipelineHitRepository hits; + private final ProfileRepository profiles; + private final Insights insights; + private final GamePushes pushes; + private final AdminActionRepository audit; + + public Pipelines(PipelineRepository pipelines, PipelineHitRepository hits, ProfileRepository profiles, + Insights insights, GamePushes pushes, AdminActionRepository audit) { + this.pipelines = pipelines; + this.hits = hits; + this.profiles = profiles; + this.insights = insights; + this.pushes = pushes; + this.audit = audit; + } + + // tag::dash-pipelines-schedule[] + /// Every enabled pipeline, once an hour, on one server at a time. + /// Transactional itself: a call to [#run] from here is a call on `this`, + /// which does not go through the transaction wrapper. + @Scheduled(fixedDelay = EVERY, initialDelay = 5L * 60 * 1000, lock = "dash-pipelines") + @Transactional(rollbackFor = IOException.class) + public void runAll() throws IOException { + for (Pipeline p : pipelines.byName()) { + if (p.isEnabled()) { + run(p.getId()); + } + } + } + // end::dash-pipelines-schedule[] + + /// Runs one pipeline now: answers it with how many players it reached + /// in total. + @Transactional(rollbackFor = IOException.class) + public PipelineDto run(String id) throws IOException { + Pipeline p = find(id); + long now = Clock.now(); + Audience audience = insights.audience(Audience.decode(p.getConditions())); + // One read of this pipeline's hits, by its index, not one per player. + Map last = new HashMap<>(); + for (PipelineHit h : hits.findByPipeline(p.getId())) { + last.put(h.getUsername(), h); + } + List due = new ArrayList<>(); + for (Profile player : audience.filter(profiles.findAll())) { + String key = p.getId() + ":" + player.getUsername(); + PipelineHit hit = last.get(player.getUsername()); + if (hit != null && hit.getHitAt().toEpochMilli() > now - p.getCooldownDays() * Clock.DAY) { + continue; + } + if (GEMS.equals(p.getAction())) { + player.setGems(player.getGems() + p.getAmount()); + profiles.save(player); + } + if (hit == null) { + hit = new PipelineHit(); + hit.setId(key); + hit.setPipeline(p.getId()); + hit.setUsername(player.getUsername()); + hit.setHitAt(Instant.ofEpochMilli(now)); + hits.insert(hit); + } else { + hit.setHitAt(Instant.ofEpochMilli(now)); + hits.save(hit); + } + due.add(player.getUsername()); + } + // "Reached" counts the players the pipeline acted on. A push goes to + // the devices a player registered; one without any still had their + // turn, and gets the gems, so they are not chosen again every hour. + if (!due.isEmpty()) { + pushes.broadcast(due, Ids.next(), p.getTitle(), p.getBody(), p.getScreen(), "pipeline-" + p.getName()); + } + p.setReached(p.getReached() + due.size()); + p.setLastRun(Instant.ofEpochMilli(now)); + pipelines.save(p); + return dto(p); + } + // end::dash-pipelines[] + + @Transactional(readOnly = true) + public List list() { + List out = new ArrayList<>(); + for (Pipeline p : pipelines.byName()) { + out.add(dto(p)); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public PipelineDto save(String admin, PipelineDto d) throws IOException { + check(d); + boolean fresh = d.id() == null || d.id().length() == 0; + Pipeline p; + if (fresh) { + if (pipelines.count() >= MAX_PIPELINES) { + throw new ResponseStatusException(400, "At most " + MAX_PIPELINES + " pipelines"); + } + p = new Pipeline(); + p.setId(Ids.next()); + p.setCreatedBy(admin); + } else { + p = find(d.id()); + } + p.setName(d.name().trim()); + p.setEnabled(d.enabled()); + p.setConditions(Audience.encode(d.conditions())); + p.setAction(d.action()); + p.setTitle(d.title().trim()); + p.setBody(d.body().trim()); + p.setScreen(d.screen() == null ? "" : d.screen()); + p.setAmount(GEMS.equals(d.action()) ? d.amount() : 0); + p.setCooldownDays(d.cooldownDays()); + if (fresh) { + pipelines.insert(p); + } else { + pipelines.save(p); + } + record(admin, fresh ? "PIPELINE_NEW" : "PIPELINE_EDIT", p.getName() + ": " + p.getConditions()); + return dto(p); + } + + @Transactional(rollbackFor = IOException.class) + public PipelineDto delete(String admin, String id) throws IOException { + Pipeline p = find(id); + for (PipelineHit h : hits.findByPipeline(id)) { + hits.deleteById(h.getId()); + } + pipelines.deleteById(p.getId()); + record(admin, "PIPELINE_DELETE", p.getName()); + return dto(p); + } + + private Pipeline find(String id) { + Pipeline p = id == null ? null : pipelines.findById(id); + if (p == null) { + throw new ResponseStatusException(404, "No such pipeline"); + } + return p; + } + + private static void check(PipelineDto d) { + if (d == null || d.name() == null || d.name().trim().length() == 0 || d.name().trim().length() > 40) { + throw new ResponseStatusException(400, "A name of up to 40 characters"); + } + Audience.check(d.conditions()); + if (d.conditions().isEmpty()) { + throw new ResponseStatusException(400, "At least one condition: a pipeline for everyone is a broadcast"); + } + if (Audience.encode(d.conditions()).length() > 500) { + throw new ResponseStatusException(400, "The conditions are too long"); + } + if (!PUSH.equals(d.action()) && !GEMS.equals(d.action())) { + throw new ResponseStatusException(400, "The action is push or gems"); + } + if (GEMS.equals(d.action()) && (d.amount() < 1 || d.amount() > MAX_GEMS)) { + throw new ResponseStatusException(400, "Between 1 and " + MAX_GEMS + " gems"); + } + int title = d.title() == null ? 0 : d.title().trim().length(); + int body = d.body() == null ? 0 : d.body().trim().length(); + if (title == 0 || title > 60 || body == 0 || body > 200) { + throw new ResponseStatusException(400, "A title of up to 60 characters and a message of up to 200"); + } + if (d.cooldownDays() < 1 || d.cooldownDays() > 365) { + throw new ResponseStatusException(400, "The cool-down is between 1 and 365 days"); + } + boolean known = false; + for (String s : Promotion.SCREENS) { + known |= s.equals(d.screen() == null ? "" : d.screen()); + } + if (!known) { + throw new ResponseStatusException(400, "Unknown screen " + d.screen()); + } + } + + private static PipelineDto dto(Pipeline p) { + List c = Audience.decode(p.getConditions()); + return new PipelineDto(p.getId(), p.getName(), p.isEnabled(), c, p.getAction(), p.getTitle(), p.getBody(), + p.getScreen(), p.getAmount(), p.getCooldownDays(), p.getReached(), p.getLastRun().toEpochMilli()); + } + + private void record(String admin, String action, String reason) throws IOException { + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername("promote"); + a.setAction(action); + a.setReason(reason.length() > 255 ? reason.substring(0, 255) : reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Promotion.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Promotion.java new file mode 100644 index 00000000000..d58f2dd657e --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Promotion.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.BroadcastDto; +import com.codenameone.examples.dash.api.InviterDto; +import com.codenameone.examples.dash.api.PromoteDto; +import com.codenameone.examples.dash.api.SpotlightDto; +import com.codenameone.examples.dash.domain.AdminAction; +import com.codenameone.examples.dash.domain.Broadcast; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Referral; +import com.codenameone.examples.dash.domain.Spotlight; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.AdminActionRepository; +import com.codenameone.examples.dash.repository.BroadcastRepository; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.ReferralRepository; +import com.codenameone.examples.dash.repository.SpotlightRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The operator's side of growing the game: who brought whom in, pushes to a +/// segment of players, and the banner on every home screen. +/// +/// Engagement itself -- daily players, retention, the invite funnel, revenue +/// per campaign -- is measured by Codename One analytics, from the events the +/// app reports, and read in the Codename One console. This class keeps only +/// what the game needs to act on: the invites it paid for, the pushes it sent +/// and the banner. Pushes go out through Codename One push ([GamePushes]), +/// with the campaign in their data so an open is attributed in analytics. +@Component +public class Promotion { + public static final String ALL = "all"; + public static final String LAPSED = "lapsed"; + public static final String NEW = "new"; + public static final String INVITED = "invited"; + /// What a broadcast or the banner may open in the app. + public static final String[] SCREENS = {"", "shop", "forge", "friends", "runner", "race", "daily", "crews"}; + public static final int MAX_BROADCASTS_PER_DAY = 5; + public static final int MAX_AUDIENCE = 20000; + private static final String HOME = "home"; + private static final int PAGE = 500; + private static final int TOP_INVITERS = 5; + private static final int RECENT = 10; + + private final ProfileRepository profiles; + private final ReferralRepository referrals; + private final BroadcastRepository broadcasts; + private final SpotlightRepository spotlights; + private final AdminActionRepository audit; + private final GamePushes pushes; + + public Promotion(ProfileRepository profiles, ReferralRepository referrals, BroadcastRepository broadcasts, + SpotlightRepository spotlights, AdminActionRepository audit, GamePushes pushes) { + this.profiles = profiles; + this.referrals = referrals; + this.broadcasts = broadcasts; + this.spotlights = spotlights; + this.audit = audit; + this.pushes = pushes; + } + + @Transactional(readOnly = true) + public PromoteDto overview() throws IOException { + long now = Clock.now(); + Map by = new HashMap<>(); + for (Referral r : referrals.newest(new PageRequest(0, PAGE))) { + int[] n = by.get(r.getInviter()); + if (n == null) { + n = new int[2]; + by.put(r.getInviter(), n); + } + n[0]++; + if (Referrals.PLAYED.equals(r.getState())) { + n[1]++; + } + } + List top = new ArrayList<>(); + List names = new ArrayList<>(by.keySet()); + while (top.size() < TOP_INVITERS && !names.isEmpty()) { + String best = names.get(0); + for (String n : names) { + int[] a = by.get(n); + int[] b = by.get(best); + if (a[0] > b[0] || a[0] == b[0] && n.compareTo(best) < 0) { + best = n; + } + } + names.remove(best); + Profile p = profiles.findById(best); + top.add(new InviterDto(p == null ? best : p.getDisplayName(), by.get(best)[0], by.get(best)[1])); + } + List sent = new ArrayList<>(); + for (Broadcast b : broadcasts.newest(new PageRequest(0, RECENT))) { + sent.add(dto(b)); + } + return new PromoteDto((int) profiles.count(), + profiles.audienceSize(Instant.ofEpochMilli(Long.MAX_VALUE), Instant.ofEpochMilli(now - 7 * Clock.DAY)).intValue(), + (int) referrals.count(), referrals.inState(Referrals.PLAYED).intValue(), top, sent, spotlightOf()); + } + + /// The broadcast as it would go: checked, and with the number of players + /// it would reach in `targets`. + @Transactional(readOnly = true) + public BroadcastDto audience(BroadcastDto b) throws IOException { + check(b, false); + return with(b, audienceOf(b).size(), "", 0); + } + + // tag::dash-promotion[] + @Transactional(rollbackFor = IOException.class) + public BroadcastDto broadcast(String admin, BroadcastDto b) throws IOException { + check(b, true); + long now = Clock.now(); + if (broadcasts.countBySentAtGreaterThan(Instant.ofEpochMilli(now - Clock.DAY)) >= MAX_BROADCASTS_PER_DAY) { + throw new ResponseStatusException(429, "At most " + MAX_BROADCASTS_PER_DAY + + " broadcasts a day: players who are pushed too often switch notifications off"); + } + String id = Ids.next(); + String campaign = b.campaign() == null || b.campaign().trim().length() == 0 + ? "broadcast-" + id.substring(0, 8) : b.campaign().trim(); + int reached = pushes.broadcast(audienceOf(b), id, b.title().trim(), b.body().trim(), screen(b.screen()), + campaign); + Broadcast row = new Broadcast(); + row.setId(id); + row.setSegment(b.segment()); + row.setDays(b.days()); + row.setTitle(b.title().trim()); + row.setBody(b.body().trim()); + row.setScreen(screen(b.screen())); + row.setCampaign(campaign.length() > 40 ? campaign.substring(0, 40) : campaign); + row.setTargets(reached); + row.setSentBy(admin); + row.setSentAt(Instant.ofEpochMilli(now)); + broadcasts.insert(row); + record(admin, "BROADCAST", b.segment() + ": " + b.title().trim()); + return dto(row); + } + // end::dash-promotion[] + + @Transactional(readOnly = true) + public SpotlightDto spotlight() throws IOException { + return spotlightOf(); + } + + @Transactional(rollbackFor = IOException.class) + public SpotlightDto spotlight(String admin, SpotlightDto s) throws IOException { + String text = s == null || s.text() == null ? "" : s.text().trim(); + if (text.length() > 140) { + throw new ResponseStatusException(400, "The banner is at most 140 characters"); + } + Spotlight row = spotlights.findById(HOME); + boolean fresh = row == null; + if (fresh) { + row = new Spotlight(); + row.setId(HOME); + } + row.setMessage(text); + row.setPackId(s.packId() == null ? "" : s.packId().trim()); + row.setScreen(screen(s.screen())); + row.setUpdatedBy(admin); + row.setUpdatedAt(Instant.now()); + if (fresh) { + spotlights.insert(row); + } else { + spotlights.save(row); + } + record(admin, "SPOTLIGHT", text.length() == 0 ? "(cleared)" : text); + return spotlightOf(); + } + + private SpotlightDto spotlightOf() { + Spotlight s = spotlights.findById(HOME); + return s == null ? new SpotlightDto("", "", "", 0) + : new SpotlightDto(s.getMessage(), s.getPackId(), s.getScreen(), s.getUpdatedAt().toEpochMilli()); + } + + private List audienceOf(BroadcastDto b) { + List out = new ArrayList<>(); + if (INVITED.equals(b.segment())) { + for (int page = 0; out.size() < MAX_AUDIENCE; page++) { + List rows = referrals.newest(new PageRequest(page, PAGE)); + for (Referral r : rows) { + out.add(r.getInvitee()); + } + if (rows.size() < PAGE) { + break; + } + } + return out; + } + long now = Clock.now(); + Instant seen = Instant.ofEpochMilli(LAPSED.equals(b.segment()) ? now - b.days() * Clock.DAY : Long.MAX_VALUE); + Instant joined = Instant.ofEpochMilli(NEW.equals(b.segment()) ? now - b.days() * Clock.DAY : Long.MIN_VALUE); + for (int page = 0; out.size() < MAX_AUDIENCE; page++) { + List rows = profiles.audience(seen, joined, new PageRequest(page, PAGE)); + for (Profile p : rows) { + out.add(p.getUsername()); + } + if (rows.size() < PAGE) { + break; + } + } + return out; + } + + private static void check(BroadcastDto b, boolean sending) { + if (b == null || b.segment() == null) { + throw new ResponseStatusException(400, "Choose who to send to"); + } + String s = b.segment(); + if (!ALL.equals(s) && !LAPSED.equals(s) && !NEW.equals(s) && !INVITED.equals(s)) { + throw new ResponseStatusException(400, "Unknown segment " + s); + } + if ((LAPSED.equals(s) || NEW.equals(s)) && (b.days() < 1 || b.days() > 365)) { + throw new ResponseStatusException(400, "Days must be between 1 and 365"); + } + if (sending) { + int title = b.title() == null ? 0 : b.title().trim().length(); + int body = b.body() == null ? 0 : b.body().trim().length(); + if (title == 0 || title > 60 || body == 0 || body > 200) { + throw new ResponseStatusException(400, "A title of up to 60 characters and a message of up to 200"); + } + } + screen(b.screen()); + } + + private static String screen(String s) { + String v = s == null ? "" : s; + for (String ok : SCREENS) { + if (ok.equals(v)) { + return v; + } + } + throw new ResponseStatusException(400, "Unknown screen " + v); + } + + private static BroadcastDto with(BroadcastDto b, int targets, String by, long at) { + return new BroadcastDto(b.id(), b.segment(), b.days(), b.title(), b.body(), b.screen(), b.campaign(), + targets, by, at); + } + + private static BroadcastDto dto(Broadcast b) { + return new BroadcastDto(b.getId(), b.getSegment(), b.getDays(), b.getTitle(), b.getBody(), b.getScreen(), + b.getCampaign(), b.getTargets(), b.getSentBy(), b.getSentAt().toEpochMilli()); + } + + private void record(String admin, String action, String reason) throws IOException { + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername("promote"); + a.setAction(action); + a.setReason(reason.length() > 255 ? reason.substring(0, 255) : reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Referrals.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Referrals.java new file mode 100644 index 00000000000..f33d821c9ff --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/Referrals.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.dash.Clock; +import com.codenameone.examples.dash.Ids; +import com.codenameone.examples.dash.api.FriendDto; +import com.codenameone.examples.dash.api.InviteClaimDto; +import com.codenameone.examples.dash.api.InviteDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.domain.Referral; +import com.codenameone.examples.dash.domain.ReferralCode; +import com.codenameone.examples.dash.progress.Progression; +import com.codenameone.examples.dash.push.GamePushes; +import com.codenameone.examples.dash.repository.ProfileRepository; +import com.codenameone.examples.dash.repository.ReferralCodeRepository; +import com.codenameone.examples.dash.repository.ReferralRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Invite a friend, from the server's side. +/// +/// The link is not made here. The app mints it with Codename One's +/// `Invites`, which carries the player's code in the link's payload, follows +/// it through the store to the friend's first launch, and reports the +/// campaign and the referrer to Codename One analytics with every event the +/// friend sends after. What the server adds is the economy: whose code it was, +/// so that the friend gets a welcome gift on joining and the inviter is paid +/// once the friend has played a race -- not for an install that never +/// plays. +/// +/// The rules are what keep it from being farmed: only an account younger +/// than [#CLAIM_DAYS] can be claimed for, once, never by its own inviter or by +/// someone it invited, and an inviter is paid for at most [#MAX_REWARDED] +/// friends. +@Component +public class Referrals { + public static final String JOINED = "joined"; + public static final String PLAYED = "played"; + public static final int FRIEND_COINS = 100; + public static final int INVITER_COINS = 200; + public static final int INVITER_XP = 20; + public static final int MAX_REWARDED = 10; + public static final int CLAIM_DAYS = 7; + private static final int CODE_LENGTH = 10; + private static final int PAGE = 100; + + private final ReferralCodeRepository codes; + private final ReferralRepository referrals; + private final ProfileRepository profiles; + private final Progression progression; + private final GamePushes pushes; + + public Referrals(ReferralCodeRepository codes, ReferralRepository referrals, ProfileRepository profiles, + Progression progression, GamePushes pushes) { + this.codes = codes; + this.referrals = referrals; + this.profiles = profiles; + this.progression = progression; + this.pushes = pushes; + } + + /// The player's code, made the first time they ask for it. + @Transactional(rollbackFor = IOException.class) + public InviteDto invite(String username) throws IOException { + List mine = codes.findByUsername(username); + String code; + if (mine.isEmpty()) { + code = Ids.next().substring(0, CODE_LENGTH); + while (codes.findById(code) != null) { + code = Ids.next().substring(0, CODE_LENGTH); + } + ReferralCode c = new ReferralCode(); + c.setCode(code); + c.setUsername(username); + c.setCreatedAt(Instant.now()); + codes.insert(c); + } else { + code = mine.get(0).getCode(); + } + return new InviteDto(code, (int) referrals.countByInviter(username), + (int) referrals.countByInviterAndRewardedTrue(username), MAX_REWARDED, INVITER_COINS, FRIEND_COINS); + } + + // tag::dash-referrals[] + /// The friend's side: the app found an invite and the player is signed in. + @Transactional(rollbackFor = IOException.class) + public RewardDto claim(String username, InviteClaimDto claim) throws IOException { + String code = claim == null || claim.code() == null ? "" : claim.code().trim(); + ReferralCode owner = code.length() == 0 ? null : codes.findById(code); + if (owner == null) { + throw new ResponseStatusException(404, "That invite is not known"); + } + String inviter = owner.getUsername(); + if (inviter.equals(username)) { + throw new ResponseStatusException(400, "That is your own invite"); + } + if (referrals.findById(username) != null) { + throw new ResponseStatusException(409, "An invite was already counted for you"); + } + Referral back = referrals.findById(inviter); + if (back != null && back.getInviter().equals(username)) { + throw new ResponseStatusException(400, "You invited that player"); + } + Profile p = profiles.findById(username); + if (p == null || p.getCreatedAt().isBefore(Instant.now().minusMillis(CLAIM_DAYS * Clock.DAY))) { + throw new ResponseStatusException(400, "Invites count for new players only"); + } + Referral r = new Referral(); + r.setInvitee(username); + r.setInviter(inviter); + r.setCode(code); + r.setCampaign(clip(claim.campaign(), 40)); + r.setChannel(clip(claim.channel(), 40)); + r.setMatchType(clip(claim.match(), 20)); + r.setState(JOINED); + r.setJoinedAt(Instant.now()); + referrals.insert(r); + Profile from = profiles.findById(inviter); + pushes.friendJoined(inviter, p.getDisplayName()); + return progression.levelReward(username, + "A gift from " + (from == null ? "your friend" : from.getDisplayName()), FRIEND_COINS, 0); + } + // end::dash-referrals[] + + /// Called after every race the player finishes. The first one makes an + /// invited player count, and pays whoever invited them. + @Transactional(rollbackFor = IOException.class) + public void played(String username) throws IOException { + Referral r = referrals.findById(username); + if (r == null || !JOINED.equals(r.getState())) { + return; + } + r.setState(PLAYED); + r.setPlayedAt(Instant.now()); + boolean pay = referrals.countByInviterAndRewardedTrue(r.getInviter()) < MAX_REWARDED; + r.setRewarded(pay); + referrals.save(r); + if (pay) { + Profile friend = profiles.findById(username); + String name = friend == null ? "Your friend" : friend.getDisplayName(); + progression.levelReward(r.getInviter(), name + " played", INVITER_COINS, INVITER_XP); + pushes.friendPlayed(r.getInviter(), name, INVITER_COINS); + } + } + + @Transactional(readOnly = true) + public List friends(String username) throws IOException { + List out = new ArrayList<>(); + for (Referral r : referrals.of(username, new PageRequest(0, PAGE))) { + Profile p = profiles.findById(r.getInvitee()); + if (p != null) { + out.add(new FriendDto(p.getDisplayName(), p.getTrophies(), r.getState(), r.isRewarded(), + r.getJoinedAt().toEpochMilli())); + } + } + return out; + } + + /// The usernames `username` invited, for the friends leaderboard. + @Transactional(readOnly = true) + public List invitees(String username) throws IOException { + List out = new ArrayList<>(); + for (Referral r : referrals.of(username, new PageRequest(0, PAGE))) { + out.add(r.getInvitee()); + } + return out; + } + + private static String clip(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max) : s; + } +} diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/SocialEndpoint.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/SocialEndpoint.java new file mode 100644 index 00000000000..9f5d4f89a4f --- /dev/null +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/social/SocialEndpoint.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.social; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.dash.Caller; +import com.codenameone.examples.dash.api.FriendDto; +import com.codenameone.examples.dash.api.InviteClaimDto; +import com.codenameone.examples.dash.api.InviteDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.api.SocialApiServer; +import com.codenameone.examples.dash.api.SpotlightDto; +import java.util.List; + +@Component +public class SocialEndpoint implements SocialApiServer { + private final Referrals referrals; + private final Promotion promotion; + + public SocialEndpoint(Referrals referrals, Promotion promotion) { + this.referrals = referrals; + this.promotion = promotion; + } + + @Override + public InviteDto invite() throws Exception { + return referrals.invite(Caller.name()); + } + + @Override + public RewardDto claim(InviteClaimDto claim) throws Exception { + return referrals.claim(Caller.name(), claim); + } + + @Override + public List friends() throws Exception { + return referrals.friends(Caller.name()); + } + + @Override + public SpotlightDto spotlight() throws Exception { + return promotion.spotlight(); + } +} diff --git a/scripts/dash/backend/src/main/resources/db/migration/V1__game.sql b/scripts/dash/backend/src/main/resources/db/migration/V1__game.sql new file mode 100644 index 00000000000..efb92f54a30 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/V1__game.sql @@ -0,0 +1,77 @@ +-- The tables every database shares. Portable SQL only: no generated keys, no +-- native boolean (a yes/no is a SMALLINT 0/1), times are epoch milliseconds. +-- The replay table differs per database and lives in V2 beside this directory. + +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(40) NOT NULL, + trophies INTEGER NOT NULL, + wins INTEGER NOT NULL, + losses INTEGER NOT NULL, + draws INTEGER NOT NULL, + coins INTEGER NOT NULL, + gems INTEGER NOT NULL, + runner VARCHAR(40) NOT NULL, + owned VARCHAR(1000) NOT NULL, + outfit VARCHAR(40) NOT NULL, + flagged SMALLINT NOT NULL, + muted SMALLINT NOT NULL, + suspended_until BIGINT NOT NULL, + banned SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + ad_day BIGINT NOT NULL, + ad_count INTEGER NOT NULL, + left_streak INTEGER NOT NULL +); +CREATE INDEX profile_trophies ON profile (trophies); + +CREATE TABLE flag ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + kind VARCHAR(16) NOT NULL, + reason VARCHAR(255) NOT NULL, + race_id VARCHAR(32) NOT NULL, + reporter VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + pending SMALLINT NOT NULL, + resolved_by VARCHAR(190) NOT NULL +); +CREATE INDEX flag_username ON flag (username, created_at); +CREATE INDEX flag_pending ON flag (pending, created_at); + +CREATE TABLE admin_action ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + admin VARCHAR(190) NOT NULL, + username VARCHAR(190) NOT NULL, + action VARCHAR(24) NOT NULL, + reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX admin_action_username ON admin_action (username, created_at); + +CREATE TABLE store_purchase ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + store VARCHAR(16) NOT NULL, + sku VARCHAR(64) NOT NULL, + gems INTEGER NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX store_purchase_username ON store_purchase (username); + +CREATE TABLE game_ticket ( + ticket_hash VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + expires_at BIGINT NOT NULL +); + +CREATE TABLE push_device ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + provider VARCHAR(16) NOT NULL, + token VARCHAR(1024) NOT NULL, + platform VARCHAR(32) NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX push_device_username ON push_device (username); diff --git a/scripts/dash/backend/src/main/resources/db/migration/V4__progress.sql b/scripts/dash/backend/src/main/resources/db/migration/V4__progress.sql new file mode 100644 index 00000000000..f535eee5184 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/V4__progress.sql @@ -0,0 +1,22 @@ +-- Levels, daily quests, chests and achievements. Portable, like V1: counters +-- are INTEGER, a yes/no is a SMALLINT 0/1 and a day is epoch milliseconds. +CREATE TABLE progress ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + xp INTEGER NOT NULL, + tutorial_done SMALLINT NOT NULL, + chests INTEGER NOT NULL, + chests_opened INTEGER NOT NULL, + races INTEGER NOT NULL, + wins INTEGER NOT NULL, + metres INTEGER NOT NULL, + pickups INTEGER NOT NULL, + podiums INTEGER NOT NULL, + streak INTEGER NOT NULL, + best_streak INTEGER NOT NULL, + achievements VARCHAR(500) NOT NULL, + quest_day BIGINT NOT NULL, + quest0 INTEGER NOT NULL, + quest1 INTEGER NOT NULL, + quest2 INTEGER NOT NULL, + claimed INTEGER NOT NULL +); diff --git a/scripts/dash/backend/src/main/resources/db/migration/V6__social.sql b/scripts/dash/backend/src/main/resources/db/migration/V6__social.sql new file mode 100644 index 00000000000..077ccbe7445 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/V6__social.sql @@ -0,0 +1,46 @@ +-- Inviting friends and promoting the game: invite codes, who joined through +-- whom, the pushes operators sent and the home banner. Portable, like V4. + +CREATE TABLE referral_code ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE referral ( + invitee VARCHAR(190) NOT NULL PRIMARY KEY, + inviter VARCHAR(190) NOT NULL, + code VARCHAR(16) NOT NULL, + campaign VARCHAR(40) NOT NULL, + channel VARCHAR(40) NOT NULL, + match_type VARCHAR(20) NOT NULL, + state VARCHAR(10) NOT NULL, + rewarded SMALLINT NOT NULL, + joined_at BIGINT NOT NULL, + played_at BIGINT NOT NULL +); + +CREATE TABLE broadcast ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + segment VARCHAR(10) NOT NULL, + days INTEGER NOT NULL, + title VARCHAR(60) NOT NULL, + body VARCHAR(200) NOT NULL, + screen VARCHAR(20) NOT NULL, + campaign VARCHAR(40) NOT NULL, + targets INTEGER NOT NULL, + sent_by VARCHAR(190) NOT NULL, + sent_at BIGINT NOT NULL +); + +CREATE TABLE spotlight ( + id VARCHAR(10) NOT NULL PRIMARY KEY, + message VARCHAR(140) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + screen VARCHAR(20) NOT NULL, + updated_by VARCHAR(190) NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX referral_by_inviter ON referral (inviter); +CREATE INDEX referral_code_by_user ON referral_code (username); diff --git a/scripts/dash/backend/src/main/resources/db/migration/V7__engagement.sql b/scripts/dash/backend/src/main/resources/db/migration/V7__engagement.sql new file mode 100644 index 00000000000..257e7928aaf --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/V7__engagement.sql @@ -0,0 +1,27 @@ +-- Engagement pipelines: a query over the players and an action, run every +-- hour, and when each one last acted on each player. Portable, like V6. + +CREATE TABLE pipeline ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + enabled SMALLINT NOT NULL, + conditions VARCHAR(500) NOT NULL, + action VARCHAR(10) NOT NULL, + title VARCHAR(60) NOT NULL, + body VARCHAR(200) NOT NULL, + screen VARCHAR(20) NOT NULL, + amount INTEGER NOT NULL, + cooldown_days INTEGER NOT NULL, + reached INTEGER NOT NULL, + last_run BIGINT NOT NULL, + created_by VARCHAR(190) NOT NULL +); + +CREATE TABLE pipeline_hit ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + pipeline VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + hit_at BIGINT NOT NULL +); + +CREATE INDEX pipeline_hit_by_pipeline ON pipeline_hit (pipeline); diff --git a/scripts/dash/backend/src/main/resources/db/migration/mysql/V2__races.sql b/scripts/dash/backend/src/main/resources/db/migration/mysql/V2__races.sql new file mode 100644 index 00000000000..c7f76e7b3d4 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/mysql/V2__races.sql @@ -0,0 +1,69 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases: +-- LONGTEXT, which is what the ORM maps a large String to and validates against. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay LONGTEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay LONGTEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/dash/backend/src/main/resources/db/migration/mysql/V3__collation.sql b/scripts/dash/backend/src/main/resources/db/migration/mysql/V3__collation.sql new file mode 100644 index 00000000000..2fceef5f902 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/mysql/V3__collation.sql @@ -0,0 +1,30 @@ +-- Makes this database's text case sensitive and NO PAD: the tables the +-- earlier scripts created, and the default every later table inherits. +-- +-- The ORM needs that of a text key: "A" and "a" are two keys, and "token " +-- keeps its trailing space -- what SQLite and PostgreSQL do. MySQL's default, +-- utf8mb4_0900_ai_ci, folds both away, and the server refuses to start on a +-- key column that uses it. +-- +-- The latest version, not the first: a database that already ran the earlier +-- scripts takes a new one only when it sorts after them, and its tables are +-- already there to convert. On a new database this runs after them too, and +-- converts the tables they have just created. +-- +-- MySQL and MariaDB share no name for that collation (utf8mb4_bin, which both +-- have, pads), and this directory serves both. The comments are executable +-- ones, each read by one family only: MySQL runs /*!80000 ...*/ and skips the +-- MariaDB-only /*M! ...*/; MariaDB runs /*M!100400 ...*/ and skips MySQL's 8.0 +-- comments. +ALTER DATABASE CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE admin_action CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE flag CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE game_ticket CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE race_record CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE race_entry CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE score CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE crew CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE crew_member CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE profile CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE push_device CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE store_purchase CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; diff --git a/scripts/dash/backend/src/main/resources/db/migration/mysql/V5__forge.sql b/scripts/dash/backend/src/main/resources/db/migration/mysql/V5__forge.sql new file mode 100644 index 00000000000..6a9ad2043a1 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/mysql/V5__forge.sql @@ -0,0 +1,77 @@ +-- The Forge's packs and files. The large text columns are LONGTEXT here, the +-- type the ORM maps a large String to on MySQL and validates against. +CREATE TABLE forge_pack ( + id VARCHAR(40) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(60) NOT NULL, + author VARCHAR(60) NOT NULL, + description VARCHAR(1200) NOT NULL, + status VARCHAR(12) NOT NULL, + official SMALLINT NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + draft LONGTEXT NOT NULL, + forked_from VARCHAR(40) NOT NULL, + plays INTEGER NOT NULL, + rating_sum INTEGER NOT NULL, + rating_count INTEGER NOT NULL, + reports INTEGER NOT NULL, + levels INTEGER NOT NULL, + defs INTEGER NOT NULL, + pending_assets SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_pack_version ( + id VARCHAR(60) NOT NULL PRIMARY KEY, + pack_id VARCHAR(40) NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + content LONGTEXT NOT NULL, + published_at BIGINT NOT NULL +); + +CREATE TABLE forge_asset ( + sha256 VARCHAR(64) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(80) NOT NULL, + kind VARCHAR(8) NOT NULL, + bytes INTEGER NOT NULL, + status VARCHAR(10) NOT NULL, + animations VARCHAR(2000) NOT NULL, + data LONGTEXT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE level_stars ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + level_id VARCHAR(32) NOT NULL, + stars INTEGER NOT NULL, + paid_stars INTEGER NOT NULL, + best INTEGER NOT NULL, + plays INTEGER NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_rating ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + stars INTEGER NOT NULL +); + +CREATE TABLE forge_earning ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + day BIGINT NOT NULL, + coins INTEGER NOT NULL +); +CREATE INDEX forge_pack_owner ON forge_pack (owner, updated_at); +CREATE INDEX forge_pack_status ON forge_pack (status, official, updated_at); +CREATE INDEX forge_pack_version_pack ON forge_pack_version (pack_id, version); +CREATE INDEX forge_asset_status ON forge_asset (status, created_at); +CREATE INDEX level_stars_player ON level_stars (username, pack_id); +CREATE INDEX forge_rating_pack ON forge_rating (pack_id); diff --git a/scripts/dash/backend/src/main/resources/db/migration/postgresql/V2__races.sql b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V2__races.sql new file mode 100644 index 00000000000..baa7972678b --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V2__races.sql @@ -0,0 +1,68 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/dash/backend/src/main/resources/db/migration/postgresql/V3__collation.sql b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V3__collation.sql new file mode 100644 index 00000000000..5bde8ca0fb6 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V3__collation.sql @@ -0,0 +1,2 @@ +-- Text is already case sensitive and NO PAD here; see mysql/V3__collation.sql. +SELECT 1; diff --git a/scripts/dash/backend/src/main/resources/db/migration/postgresql/V5__forge.sql b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V5__forge.sql new file mode 100644 index 00000000000..9654639d62c --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/postgresql/V5__forge.sql @@ -0,0 +1,76 @@ +-- The Forge's packs, published versions, uploaded files and level stars. +CREATE TABLE forge_pack ( + id VARCHAR(40) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(60) NOT NULL, + author VARCHAR(60) NOT NULL, + description VARCHAR(1200) NOT NULL, + status VARCHAR(12) NOT NULL, + official SMALLINT NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + draft TEXT NOT NULL, + forked_from VARCHAR(40) NOT NULL, + plays INTEGER NOT NULL, + rating_sum INTEGER NOT NULL, + rating_count INTEGER NOT NULL, + reports INTEGER NOT NULL, + levels INTEGER NOT NULL, + defs INTEGER NOT NULL, + pending_assets SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_pack_version ( + id VARCHAR(60) NOT NULL PRIMARY KEY, + pack_id VARCHAR(40) NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + published_at BIGINT NOT NULL +); + +CREATE TABLE forge_asset ( + sha256 VARCHAR(64) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(80) NOT NULL, + kind VARCHAR(8) NOT NULL, + bytes INTEGER NOT NULL, + status VARCHAR(10) NOT NULL, + animations VARCHAR(2000) NOT NULL, + data TEXT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE level_stars ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + level_id VARCHAR(32) NOT NULL, + stars INTEGER NOT NULL, + paid_stars INTEGER NOT NULL, + best INTEGER NOT NULL, + plays INTEGER NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_rating ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + stars INTEGER NOT NULL +); + +CREATE TABLE forge_earning ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + day BIGINT NOT NULL, + coins INTEGER NOT NULL +); +CREATE INDEX forge_pack_owner ON forge_pack (owner, updated_at); +CREATE INDEX forge_pack_status ON forge_pack (status, official, updated_at); +CREATE INDEX forge_pack_version_pack ON forge_pack_version (pack_id, version); +CREATE INDEX forge_asset_status ON forge_asset (status, created_at); +CREATE INDEX level_stars_player ON level_stars (username, pack_id); +CREATE INDEX forge_rating_pack ON forge_rating (pack_id); diff --git a/scripts/dash/backend/src/main/resources/db/migration/sqlite/V2__races.sql b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V2__races.sql new file mode 100644 index 00000000000..baa7972678b --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V2__races.sql @@ -0,0 +1,68 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/dash/backend/src/main/resources/db/migration/sqlite/V3__collation.sql b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V3__collation.sql new file mode 100644 index 00000000000..5bde8ca0fb6 --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V3__collation.sql @@ -0,0 +1,2 @@ +-- Text is already case sensitive and NO PAD here; see mysql/V3__collation.sql. +SELECT 1; diff --git a/scripts/dash/backend/src/main/resources/db/migration/sqlite/V5__forge.sql b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V5__forge.sql new file mode 100644 index 00000000000..9654639d62c --- /dev/null +++ b/scripts/dash/backend/src/main/resources/db/migration/sqlite/V5__forge.sql @@ -0,0 +1,76 @@ +-- The Forge's packs, published versions, uploaded files and level stars. +CREATE TABLE forge_pack ( + id VARCHAR(40) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(60) NOT NULL, + author VARCHAR(60) NOT NULL, + description VARCHAR(1200) NOT NULL, + status VARCHAR(12) NOT NULL, + official SMALLINT NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + draft TEXT NOT NULL, + forked_from VARCHAR(40) NOT NULL, + plays INTEGER NOT NULL, + rating_sum INTEGER NOT NULL, + rating_count INTEGER NOT NULL, + reports INTEGER NOT NULL, + levels INTEGER NOT NULL, + defs INTEGER NOT NULL, + pending_assets SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_pack_version ( + id VARCHAR(60) NOT NULL PRIMARY KEY, + pack_id VARCHAR(40) NOT NULL, + version INTEGER NOT NULL, + hash VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + published_at BIGINT NOT NULL +); + +CREATE TABLE forge_asset ( + sha256 VARCHAR(64) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(80) NOT NULL, + kind VARCHAR(8) NOT NULL, + bytes INTEGER NOT NULL, + status VARCHAR(10) NOT NULL, + animations VARCHAR(2000) NOT NULL, + data TEXT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE level_stars ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + level_id VARCHAR(32) NOT NULL, + stars INTEGER NOT NULL, + paid_stars INTEGER NOT NULL, + best INTEGER NOT NULL, + plays INTEGER NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE forge_rating ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + stars INTEGER NOT NULL +); + +CREATE TABLE forge_earning ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + day BIGINT NOT NULL, + coins INTEGER NOT NULL +); +CREATE INDEX forge_pack_owner ON forge_pack (owner, updated_at); +CREATE INDEX forge_pack_status ON forge_pack (status, official, updated_at); +CREATE INDEX forge_pack_version_pack ON forge_pack_version (pack_id, version); +CREATE INDEX forge_asset_status ON forge_asset (status, created_at); +CREATE INDEX level_stars_player ON level_stars (username, pack_id); +CREATE INDEX forge_rating_pack ON forge_rating (pack_id); diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/AccountTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/AccountTest.java new file mode 100644 index 00000000000..0c7b4ec8b5b --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/AccountTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.account.Accounts; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Opening an account, naming yourself and building a deck. +@BackendTest +class AccountTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void aNewPlayerStartsWithTheFreeRunnerAndAPurse() throws Exception { + Map me = Api.json(mvc, Api.body(post("/api/account/register"), + "{\"email\":\"newcomer@test.example\",\"password\":\"" + Api.PASSWORD + + "\",\"displayName\":\"Newcomer\"}")); + String who = (String) me.get("username"); + assertEquals("Newcomer", me.get("displayName")); + assertEquals("runner-dash", me.get("runner")); + assertEquals(100L, Api.number(me, "coins")); + assertEquals(20L, Api.number(me, "gems")); + assertEquals(Boolean.FALSE, me.get("admin")); + + Map again = Api.json(mvc, Api.as(get("/api/me"), who)); + assertEquals(who, again.get("username")); + } + + @Test + void aNameHasToBeReadable() throws Exception { + String who = Api.player(accounts, "renamer"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/me/profile"), + "{\"displayName\":\"x\"}"), who))); + Map me = Api.json(mvc, Api.as(Api.body(post("/api/me/profile"), + "{\"displayName\":\"Sir Renamer\"}"), who)); + assertEquals("Sir Renamer", me.get("displayName")); + } + + @Test + void onlyAnOwnedRunnerCanBePicked() throws Exception { + String who = Api.player(accounts, "picker"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/runner"), + "{\"runner\":\"runner-nova\"}"), who))); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/runner"), + "{\"runner\":\"no-such-runner\"}"), who))); + Api.json(mvc, Api.as(Api.body(post("/api/runner"), "{\"runner\":\"runner-dash\"}"), who)); + assertEquals("runner-dash", Api.json(mvc, Api.as(get("/api/runner"), who)).get("runner")); + } + + @Test + void theApiNeedsAToken() throws Exception { + assertEquals(401, Api.statusOf(mvc, get("/api/me"))); + assertEquals(401, Api.statusOf(mvc, get("/api/runner"))); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Api.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Api.java new file mode 100644 index 00000000000..5c6222048e8 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Api.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Json; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.RegisterDto; +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.jwt; + +/// The steps most tests take: making a player, calling the API as them, and +/// reading what came back. +public final class Api { + public static final String PASSWORD = "a-long-password"; + + private Api() { + } + + /// A fresh player of the test's own, so no test meets what another left. + public static String player(Accounts accounts, String name) throws Exception { + return accounts.register(new RegisterDto(name + "@test.example", PASSWORD, name)).username(); + } + + public static MockRequestBuilder as(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_PLAYER")); + } + + public static MockRequestBuilder asAdmin(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_PLAYER", "ROLE_ADMIN")); + } + + public static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + public static Map json(MockMvc mvc, MockRequestBuilder request) throws Exception { + return Json.parseObject(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + public static List list(MockMvc mvc, MockRequestBuilder request) throws Exception { + return (List) Json.parse(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + public static int statusOf(MockMvc mvc, MockRequestBuilder request) throws Exception { + return mvc.perform(request).andReturn().getResponse().getStatus(); + } + + public static long number(Map object, String field) { + return ((Number) object.get(field)).longValue(); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/CityTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/CityTest.java new file mode 100644 index 00000000000..aadffee6636 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/CityTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// City runs: the server builds the level from the route the result carries +/// and verifies the run on it, with no map of its own. +@BackendTest +class CityTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static CityRoute route() { + return new CityRoute("Old Harbour").leg(420, CityRoute.STRAIGHT, "Quay Street") + .leg(380, CityRoute.LEFT, "Rope Walk").leg(500, CityRoute.RIGHT, "Lantern Avenue"); + } + + private static String body(String replay, String city) { + Map m = new HashMap<>(); + m.put("replay", replay); + m.put("city", city); + return Json.write(m); + } + + @Test + void aCityRunIsVerifiedOnTheRouteItCarriesAndHasItsOwnBoard() throws Exception { + String who = Api.player(accounts, "city-runner"); + CityRoute r = route(); + LevelDef level = r.level(); + // The app's seed for a route: its content hash, the same every time. + String run = Runs.bot(level, Content.catalog(), level.contentHash(), 99).encode(); + Map out = Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), body(run, r.encode())), who)); + assertEquals(level.id, out.get("levelId")); + assertTrue(Api.number(out, "score") > 0); + List board = Api.list(mvc, Api.as(get("/api/scores?level=" + level.id + "&board=all&limit=10"), who)); + assertEquals(1, board.size()); + // An easier route under the same run is another level: refused. + CityRoute easier = new CityRoute("Old Harbour").leg(1300, CityRoute.STRAIGHT, "Quay Street"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/results"), + body(run, easier.encode())), who))); + // A city run without its route cannot be checked. + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/results"), body(run, null)), who))); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ForgeTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ForgeTest.java new file mode 100644 index 00000000000..e5aed07c400 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ForgeTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Base64; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.forge.Forge; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.forge.PackCodec; +import com.codenameone.examples.dash.rules.forge.Samples; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The Forge: drafts, publishing, sharing, files, and solo runs the +/// server checks by playing them again. +@BackendTest +class ForgeTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static String draftBody(ContentPack p) { + Map m = new HashMap<>(); + m.put("content", PackCodec.write(p)); + return Json.write(m); + } + + private static String replayBody(String replay) { + Map m = new HashMap<>(); + m.put("replay", replay); + return Json.write(m); + } + + private static ContentPack mine(String id) { + ContentPack p = Samples.official(); + p.id = id; + p.name = "Remix " + id; + return p; + } + + @Test + void theOfficialCampaignIsPublishedOnStartup() throws Exception { + String who = Api.player(accounts, "forge-reader"); + List official = Api.list(mvc, Api.as(get("/api/forge/packs?scope=official"), who)); + assertEquals(1, official.size()); + Map summary = (Map) official.get(0); + assertEquals(Samples.OFFICIAL_ID, summary.get("id")); + assertEquals(Boolean.TRUE, summary.get("official")); + Map pack = Api.json(mvc, Api.as(get("/api/forge/packs/" + Samples.OFFICIAL_ID), who)); + ContentPack read = PackCodec.read((String) pack.get("content")); + assertEquals(summary.get("hash"), read.hash()); + } + + @Test + void aDraftIsPrivateUntilPublishedAndTheIdBelongsToItsOwner() throws Exception { + String author = Api.player(accounts, "forge-author"); + String friend = Api.player(accounts, "forge-friend"); + Api.json(mvc, Api.as(Api.body(post("/api/forge/drafts"), draftBody(mine("my-war"))), author)); + assertEquals(404, Api.statusOf(mvc, Api.as(get("/api/forge/drafts/my-war"), friend))); + assertEquals(404, Api.statusOf(mvc, Api.as(get("/api/forge/packs/my-war"), friend))); + // Someone else's id cannot be taken over by saving a draft under it. + assertEquals(409, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/drafts"), + draftBody(mine("my-war"))), friend))); + Map published = Api.json(mvc, Api.as(post("/api/forge/drafts/my-war/publish"), author)); + assertEquals(1L, Api.number(published, "version")); + assertEquals("published", published.get("status")); + // The byline is the author's battle name, whatever the file said. + assertEquals("forge-author", published.get("author")); + Map shared = Api.json(mvc, Api.as(get("/api/forge/packs/my-war"), friend)); + assertTrue(((String) shared.get("content")).contains("Remix my-war")); + // A remix is the friend's own draft, and remembers where it came from. + Map fork = Api.json(mvc, Api.as(Api.body(post("/api/forge/packs/my-war/fork"), + "{\"newId\":\"friends-war\"}"), friend)); + assertEquals("draft", fork.get("status")); + assertEquals(friend, fork.get("owner")); + } + + @Test + void anInvalidPackIsNotPublished() throws Exception { + String author = Api.player(accounts, "forge-sloppy"); + ContentPack p = mine("broken-war"); + p.levels.get(0).id = "Not A Slug!"; + Api.json(mvc, Api.as(Api.body(post("/api/forge/drafts"), draftBody(p)), author)); + assertEquals(400, Api.statusOf(mvc, Api.as(post("/api/forge/drafts/broken-war/publish"), author))); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/drafts"), + "{\"content\":\"{not json\"}"), author))); + } + + @Test + void reportsHideAPackAndAModeratorRestoresIt() throws Exception { + String author = Api.player(accounts, "forge-edgy"); + Api.json(mvc, Api.as(Api.body(post("/api/forge/drafts"), draftBody(mine("edgy-war"))), author)); + Api.json(mvc, Api.as(post("/api/forge/drafts/edgy-war/publish"), author)); + Map last = null; + for (int i = 0; i < Forge.REPORTS_TO_HIDE; i++) { + String reporter = Api.player(accounts, "forge-reporter" + i); + // The same player reporting twice counts once. + Api.json(mvc, Api.as(Api.body(post("/api/forge/report"), + "{\"packId\":\"edgy-war\",\"reason\":\"rude\"}"), reporter)); + last = Api.json(mvc, Api.as(Api.body(post("/api/forge/report"), + "{\"packId\":\"edgy-war\",\"reason\":\"rude\"}"), reporter)); + } + assertEquals("hidden", last.get("status")); + String other = Api.player(accounts, "forge-passerby"); + assertEquals(404, Api.statusOf(mvc, Api.as(get("/api/forge/packs/edgy-war"), other))); + assertEquals(403, Api.statusOf(mvc, Api.as(get("/api/admin/forge/packs"), other))); + String admin = Api.player(accounts, "forge-admin"); + assertFalse(Api.list(mvc, Api.asAdmin(get("/api/admin/forge/packs"), admin)).isEmpty()); + Map restored = Api.json(mvc, Api.asAdmin(Api.body(post("/api/admin/forge/packs/action"), + "{\"target\":\"edgy-war\",\"action\":\"restore\",\"reason\":\"fine\"}"), admin)); + assertEquals("published", restored.get("status")); + } + + @Test + void anUploadedModelIsCheckedAndWaitsForAModerator() throws Exception { + String who = Api.player(accounts, "forge-modeller"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/assets"), + "{\"name\":\"x.glb\",\"kind\":\"model\",\"data\":\"" + Base64.encode(new byte[64]) + "\"}"), who))); + byte[] png = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0, 0}; + Map image = Api.json(mvc, Api.as(Api.body(post("/api/forge/assets"), + "{\"name\":\"banner.png\",\"kind\":\"image\",\"data\":\"" + Base64.encode(png) + "\"}"), who)); + assertEquals("pending", image.get("status")); + String admin = Api.player(accounts, "forge-curator"); + Map approved = Api.json(mvc, Api.asAdmin(Api.body(post("/api/admin/forge/assets/action"), + "{\"target\":\"" + image.get("sha256") + "\",\"action\":\"approve\",\"reason\":\"ok\"}"), admin)); + assertEquals("approved", approved.get("status")); + } + + // tag::dash-forge-result-test[] + @Test + void aRunPaysForStarsTheServerSawOnceAndEntersTheBoard() throws Exception { + String who = Api.player(accounts, "forge-hero"); + Replay run = Runs.bot(Content.tutorial(), Content.catalog(), 1L, 99); + String replay = run.encode(); + String body = replayBody(replay); + Map first = Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), body), who)); + assertTrue(Api.number(first, "stars") > 0); + assertEquals(1L, Api.number(first, "rank")); + // The same stars again pay nothing more. + Map again = Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), body), who)); + assertEquals(0L, Api.number((Map) again.get("reward"), "coins")); + List board = Api.list(mvc, Api.as(get("/api/scores?level=" + Content.TUTORIAL + "&board=all&limit=10"), who)); + assertEquals(Api.number(first, "score"), Api.number((Map) board.get(0), "score")); + // A replay of content the server never published is refused. + String forged = replay.replace("|" + Content.TUTORIAL + "|", + "|" + Samples.OFFICIAL_ID + "@0000000000000000#" + Content.TUTORIAL + "|"); + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/results"), + replayBody(forged)), who))); + // A run that was edited after the fact no longer replays to its claim. + String tampered = replay.substring(0, replay.lastIndexOf('|') + 1); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/forge/results"), + replayBody(tampered + "0,0,99")), who))); + } + // end::dash-forge-result-test[] +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java new file mode 100644 index 00000000000..b813a2d2eb1 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Json; +import com.codename1.backend.Tcp; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.game.GameTickets; +import java.io.IOException; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.rules.Content; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The game over a real socket: a ticket opens it, a party seats two +/// friends, inputs come back stamped for both, and leaving ends the race. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class GameSocketTest { + private static final int TEXT = 0x1; + private static final int CLOSE = 0x8; + + @LocalServerPort + private int port; + @Autowired + private GameTickets tickets; + @Autowired + private Accounts accounts; + + private int opcode; + private byte[] payload; + + @Test + void twoFriendsMeetInAPartyAndSeeTheSameInputs() throws Exception { + String host = Api.player(accounts, "sockethost"); + String guest = Api.player(accounts, "socketguest"); + Tcp a = open("/ws/game?ticket=" + tickets.issue(host).ticket()); + Tcp b = open("/ws/game?ticket=" + tickets.issue(guest).ticket()); + try { + assertEquals("ready", next(a).get("type")); + assertEquals("ready", next(b).get("type")); + writeText(a, "{\"type\":\"host\",\"level\":\"" + Content.CAUSEWAY + "\"}"); + String code = (String) until(a, "party").get("code"); + writeText(b, "{\"type\":\"join\",\"code\":\"" + code + "\"}"); + Map party = until(b, "party"); + assertEquals(2, ((java.util.List) party.get("names")).size()); + // Voice signaling reaches the other member, and only them. + writeText(b, "{\"type\":\"voice\",\"to\":\"" + host + "\",\"data\":\"offer\"}"); + Map voice = until(a, "voice"); + assertEquals(guest, voice.get("from")); + assertEquals("offer", voice.get("data")); + + writeText(a, "{\"type\":\"go\"}"); + Map startA = until(a, "start"); + Map startB = until(b, "start"); + assertEquals(startA.get("race"), startB.get("race")); + assertEquals(startA.get("seed"), startB.get("seed")); + assertEquals(0L, ((Number) startA.get("you")).longValue()); + assertEquals(1L, ((Number) startB.get("you")).longValue()); + + // After the countdown the host's jump reaches the guest, stamped + // for a tick to come. + Thread.sleep(GameProtocol.COUNTDOWN_MILLIS + 200); + writeText(a, "{\"type\":\"input\",\"b\":4}"); + String stamped = firstInput(b); + assertTrue(stamped.endsWith(",0,4"), stamped); + assertEquals(stamped, firstInput(a)); + + writeText(b, "{\"type\":\"leave\"}"); + writeText(a, "{\"type\":\"leave\"}"); + Map end = until(a, "end"); + assertEquals(startA.get("race"), end.get("race")); + } finally { + a.close(); + b.close(); + } + } + + @Test + void noTicketNoGame() throws Exception { + String[] paths = {"/ws/game", "/ws/game?ticket=", "/ws/game?ticket=0123456789abcdef"}; + for (String path : paths) { + Tcp stranger = open(path); + try { + readFrame(stranger); + assertEquals(CLOSE, opcode, path); + assertEquals(4401, closeCode()); + } finally { + stranger.close(); + } + } + } + + private Map next(Tcp connection) throws IOException { + readFrame(connection); + return Json.parseObject(text()); + } + + /// Reads messages until one of `type`, skipping the tick stream. + private Map until(Tcp connection, String type) throws IOException { + for (int i = 0; i < 2000; i++) { + Map m = next(connection); + if (type.equals(m.get("type"))) { + return m; + } + } + throw new IOException("no " + type + " message"); + } + + /// The first input a `ticks` message carries. + private String firstInput(Tcp connection) throws IOException { + for (int i = 0; i < 2000; i++) { + Map m = until(connection, "ticks"); + String c = (String) m.get("i"); + if (c != null && c.length() > 0) { + return c; + } + } + throw new IOException("no input arrived"); + } + + private Tcp open(String target) throws IOException { + Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + byte[] request = ascii("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"); + connection.write(request, 0, request.length); + StringBuilder head = new StringBuilder(); + // The head ends at the first empty line: CR LF CR LF. + int ending = 0; + while (head.length() < 8192 && ending < 4) { + int c = readByte(connection); + if (c < 0) { + break; + } + head.append((char) c); + ending = c == (ending % 2 == 0 ? '\r' : '\n') ? ending + 1 : (c == '\r' ? 1 : 0); + } + assertTrue(head.toString().startsWith("HTTP/1.1 101"), head.toString()); + return connection; + } + + private String text() throws IOException { + assertEquals(TEXT, opcode); + return new String(payload, "UTF-8"); + } + + private int closeCode() { + return payload.length < 2 ? -1 : ((payload[0] & 0xff) << 8) | (payload[1] & 0xff); + } + + private void readFrame(Tcp connection) throws IOException { + int first = readByte(connection); + if (first < 0) { + throw new IOException("the connection ended with no frame"); + } + opcode = first & 0xf; + int length = readByte(connection) & 0x7f; + if (length == 126) { + length = (readByte(connection) << 8) | readByte(connection); + } + payload = new byte[length]; + int got = 0; + while (got < length) { + int read = connection.read(payload, got, length - got); + if (read <= 0) { + throw new IOException("the connection ended after " + got + " of " + length); + } + got += read; + } + } + + /// A client's frames are masked; a server refuses one that is not. + private static void writeText(Tcp connection, String text) throws IOException { + byte[] body = ascii(text); + byte[] mask = Crypto.randomBytes(4); + byte[] frame = new byte[6 + body.length]; + frame[0] = (byte) (0x80 | TEXT); + frame[1] = (byte) (0x80 | body.length); + System.arraycopy(mask, 0, frame, 2, 4); + for (int iter = 0; iter < body.length; iter++) { + frame[6 + iter] = (byte) (body[iter] ^ mask[iter & 3]); + } + connection.write(frame, 0, frame.length); + } + + private static int readByte(Tcp connection) throws IOException { + byte[] one = new byte[1]; + int read = connection.read(one, 0, 1); + return read <= 0 ? -1 : one[0] & 0xff; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ModerationTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ModerationTest.java new file mode 100644 index 00000000000..f904617b8a5 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ModerationTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.account.DemoAccounts; +import com.codenameone.examples.dash.admin.Flags; +import com.codenameone.examples.dash.admin.Moderation; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// God mode: finding a problem player, acting on them, and the record kept. +@BackendTest +class ModerationTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Flags flags; + @Autowired + private Moderation moderation; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return Api.asAdmin(request, DemoAccounts.ADMIN); + } + + @Test + void onlyAdminsSeeTheConsole() throws Exception { + String who = Api.player(accounts, "curious"); + assertEquals(403, Api.statusOf(mvc, Api.as(get("/api/admin/stats"), who))); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/admin/players/" + who + + "/action"), "{\"action\":\"BAN\",\"reason\":\"me\"}"), who))); + Map stats = Api.json(mvc, admin(get("/api/admin/stats"))); + assertTrue(Api.number(stats, "players") >= 4); + } + + @Test + void aFlaggedPlayerIsFoundSuspendedAndTheRecordSaysWhy() throws Exception { + String who = Api.player(accounts, "griefer"); + flags.auto(who, "Sent 40 invalid plays in one match", null); + List open = Api.list(mvc, admin(get("/api/admin/flags?open=true"))); + Map flag = null; + for (Object o : open) { + if (who.equals(((Map) o).get("username"))) { + flag = (Map) o; + } + } + assertTrue(flag != null, "the automatic flag is in the queue"); + assertEquals(Flags.AUTO, flag.get("kind")); + + List found = Api.list(mvc, admin(get("/api/admin/players?q=griefer"))); + assertEquals(1, found.size()); + assertEquals(Boolean.TRUE, ((Map) found.get(0)).get("flagged")); + + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/players/" + who + + "/action"), "{\"action\":\"SUSPEND\",\"reason\":\"griefing\",\"days\":0}")))); + Map player = Api.json(mvc, admin(Api.body(post("/api/admin/players/" + who + "/action"), + "{\"action\":\"SUSPEND\",\"reason\":\"griefing\",\"days\":3}"))); + assertEquals(Boolean.TRUE, player.get("suspended")); + assertFalse(accounts.isEnabled(who)); + + Api.json(mvc, admin(post("/api/admin/flags/" + flag.get("id") + "/resolve"))); + Map detail = Api.json(mvc, admin(get("/api/admin/players/" + who))); + List actions = (List) detail.get("actions"); + assertTrue(((String) ((Map) actions.get(0)).get("action")).startsWith(Moderation.SUSPEND)); + assertEquals(DemoAccounts.ADMIN, ((Map) actions.get(0)).get("admin")); + assertEquals("griefing", ((Map) actions.get(0)).get("reason")); + assertEquals(Boolean.FALSE, ((Map) ((List) detail.get("flags")).get(0)).get("open")); + + // A suspension ends by itself. + assertTrue(moderation.lift(System.currentTimeMillis() + 4L * 24 * 3600 * 1000) >= 1); + assertTrue(accounts.isEnabled(who)); + } + + @Test + void anAdminCannotActOnThemselves() throws Exception { + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/players/" + + DemoAccounts.ADMIN + "/action"), "{\"action\":\"BAN\",\"reason\":\"oops\"}")))); + } + + @Test + void theDemoTroublemakerIsWaitingInTheQueue() throws Exception { + Map detail = Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.TROUBLE))); + assertEquals(Boolean.TRUE, ((Map) detail.get("player")).get("flagged")); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Runs.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Runs.java new file mode 100644 index 00000000000..85a6e56a4f5 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/Runs.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Recorder; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; + +/// Solo runs the way the app records them: a bot plays, a [Recorder] keeps +/// every input. +public final class Runs { + private Runs() { + } + + public static Replay bot(LevelDef level, Catalog catalog, long seed, int skill) { + Recorder rec = new Recorder(new Run(level, catalog, seed, 1)); + Bot bot = new Bot(seed, skill); + while (!rec.run().over()) { + rec.step(new int[] {bot.decide(rec.run(), 0)}); + } + return rec.replay(); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ScoresTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ScoresTest.java new file mode 100644 index 00000000000..6dab9d89d0b --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ScoresTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.rules.Content; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Leaderboards and crews. A daily score counts only on the day's seed, and a +/// crew's board lists only its members. +@BackendTest +class ScoresTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static String result(String replay) { + Map m = new HashMap<>(); + m.put("replay", replay); + return Json.write(m); + } + + @Test + void theDailyBoardTakesOnlyTheDaysSeed() throws Exception { + String who = Api.player(accounts, "daily-runner"); + Map daily = Api.json(mvc, Api.as(get("/api/daily"), who)); + assertEquals(Content.ENDLESS, daily.get("level")); + long seed = Long.parseLong((String) daily.get("seed")); + // A clumsy bot, so the endless level ends soon. + String today = Runs.bot(Content.endless(), Content.catalog(), seed, 10).encode(); + String other = Runs.bot(Content.endless(), Content.catalog(), seed + 1, 10).encode(); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(other)), who)); + // The development server's seeded field is on the board too, so the + // test looks for its own runner rather than counting rows. + assertTrue(row(Api.list(mvc, Api.as(get("/api/scores?level=endless&board=day&limit=50"), who)), + "daily-runner") == null); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(today)), who)); + Map mine = row(Api.list(mvc, Api.as(get("/api/scores?level=endless&board=day&limit=50"), who)), + "daily-runner"); + assertTrue(mine != null); + // Anyone can watch the run behind a score. + String id = (String) mine.get("replayId"); + String watcher = Api.player(accounts, "daily-watcher"); + assertEquals(today, Api.json(mvc, Api.as(get("/api/races/" + id), watcher)).get("replay")); + // The ghost is the closest run above the watcher's (none): whichever + // of the day's runners that is, it was run on the day's seed. + String ghost = (String) Api.json(mvc, Api.as(get("/api/ghost?level=endless&board=day"), watcher)).get("replay"); + assertTrue(ghost.contains("|" + seed + "|"), ghost); + } + + @Test + void theAllTimeBoardRefusesASeedTheAppWasNotHanded() throws Exception { + String who = Api.player(accounts, "seed-picker"); + // Any seed but the ones the server hands out: searched for offline. + long picked = 987654321L; + String run = Runs.bot(Content.endless(), Content.catalog(), picked, 10).encode(); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(run)), who)); + for (Object row : Api.list(mvc, Api.as(get("/api/scores?level=endless&board=all&limit=50"), who))) { + assertTrue(!"seed-picker".equals(((Map) row).get("displayName")), "picked seed reached the board"); + } + String fixed = Runs.bot(Content.endless(), Content.catalog(), 1L, 10).encode(); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(fixed)), who)); + boolean listed = false; + for (Object row : Api.list(mvc, Api.as(get("/api/scores?level=endless&board=all&limit=50"), who))) { + listed |= "seed-picker".equals(((Map) row).get("displayName")); + } + assertTrue(listed, "the fixed seed is fair"); + } + + @Test + void theWeeksSeedTheAppDerivesCountsOnTheWeekAndAllTime() throws Exception { + String who = Api.player(accounts, "week-runner"); + // The way LevelsForm.week derives it: days since the epoch, over 7. + int week = (int) (System.currentTimeMillis() / 86400000L) / 7; + long seed = Content.weeklySeed(week, Content.ENDLESS); + String run = Runs.bot(Content.endless(), Content.catalog(), seed, 10).encode(); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(run)), who)); + for (String board : new String[] {"week", "all"}) { + boolean listed = false; + for (Object row : Api.list(mvc, Api.as(get("/api/scores?level=endless&board=" + board + "&limit=50"), who))) { + listed |= "week-runner".equals(((Map) row).get("displayName")); + } + assertTrue(listed, "the week's seed missed the " + board + " board"); + } + } + + @Test + void aCrewBoardListsItsMembersOnly() throws Exception { + String owner = Api.player(accounts, "crew-owner"); + String mate = Api.player(accounts, "crew-mate"); + String stranger = Api.player(accounts, "crew-stranger"); + Map crew = Api.json(mvc, Api.as(Api.body(post("/api/crews"), "{\"name\":\"Night Owls\"}"), owner)); + String code = (String) crew.get("code"); + Map joined = Api.json(mvc, Api.as(post("/api/crews/" + code + "/join"), mate)); + assertEquals(2, ((List) joined.get("members")).size()); + for (String who : new String[] {owner, mate, stranger}) { + String run = Runs.bot(Content.tutorial(), Content.catalog(), 1L, 80).encode(); + Api.json(mvc, Api.as(Api.body(post("/api/forge/results"), result(run)), who)); + } + List board = Api.list(mvc, Api.as(get("/api/scores?level=first-steps&board=crew%3A" + code + "&limit=50"), owner)); + assertEquals(2, board.size()); + List all = Api.list(mvc, Api.as(get("/api/scores?level=first-steps&board=all&limit=50"), owner)); + assertTrue(all.size() >= 3); + assertEquals(1, Api.list(mvc, Api.as(get("/api/crews"), mate)).size()); + Api.json(mvc, Api.as(post("/api/crews/" + code + "/leave"), mate)); + assertEquals(0, Api.list(mvc, Api.as(get("/api/crews"), mate)).size()); + assertEquals(404, Api.statusOf(mvc, Api.as(post("/api/crews/NOPE/join"), stranger))); + } + + private static Map row(List board, String name) { + for (Object o : board) { + if (name.equals(((Map) o).get("displayName"))) { + return (Map) o; + } + } + return null; + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ShopTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ShopTest.java new file mode 100644 index 00000000000..0b53f91aff3 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/ShopTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.shop.Shop; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Spending coins and gems, store receipts and rewarded ads. The test profile +/// checks receipts with the simulated store, which accepts any of them. +@BackendTest +class ShopTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void anItemCostsWhatTheCatalogSaysAndIsOwnedOnce() throws Exception { + String who = Api.player(accounts, "shopper"); + assertTrue(Api.list(mvc, Api.as(get("/api/shop"), who)).size() >= 6); + // 100 coins buys nothing that costs 500. + assertEquals(402, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"runner-nova\"}"), who))); + // A real-money item is never sold for coins. + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"gems-80\"}"), who))); + assertEquals(402, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"emotes-taunt\"}"), who))); + Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), "{\"store\":\"play\",\"sku\":" + + "\"dash.gems_80\",\"transactionId\":\"tx-shopper\",\"receipt\":\"r\"}"), who)); + Map wallet = Api.json(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"emotes-taunt\"}"), who)); + assertEquals(70L, Api.number(wallet, "gems")); + assertTrue(((List) wallet.get("owned")).size() >= 2); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/equip"), + "{\"outfit\":\"trail-comet\"}"), who))); + } + + // tag::dash-receipt-test[] + @Test + void aReceiptIsRedeemedOnceAndOnlyByItsOwner() throws Exception { + String who = Api.player(accounts, "buyer"); + String thief = Api.player(accounts, "thief"); + String receipt = "{\"store\":\"itunes\",\"sku\":\"dash.gems_80\"," + + "\"transactionId\":\"tx-1001\",\"receipt\":\"opaque\"}"; + Map first = Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), receipt), who)); + assertEquals(100L, Api.number(first, "gems")); + // The app retries after a lost answer: the same purchase, granted once. + Map again = Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), receipt), who)); + assertEquals(100L, Api.number(again, "gems")); + assertEquals(409, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/receipt"), + receipt), thief))); + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/receipt"), + "{\"store\":\"itunes\",\"sku\":\"nope\",\"transactionId\":\"tx-2\",\"receipt\":\"r\"}"), + who))); + } + // end::dash-receipt-test[] + + @Test + void rewardedAdsAreCappedPerDay() throws Exception { + String who = Api.player(accounts, "watcher"); + long coins = 100; + for (int i = 0; i < Shop.AD_REWARDS_PER_DAY; i++) { + Map w = Api.json(mvc, Api.as(Api.body(post("/api/shop/ad-reward"), "{\"nonce\":\"n" + i + + "\"}"), who)); + coins += Shop.AD_REWARD_COINS; + assertEquals(coins, Api.number(w, "coins")); + } + assertEquals(429, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/ad-reward"), + "{\"nonce\":\"more\"}"), who))); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/SocialTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/SocialTest.java new file mode 100644 index 00000000000..6120c0b33c9 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/SocialTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.account.DemoAccounts; +import com.codenameone.examples.dash.social.Referrals; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Inviting friends and the operator's Promote tab: who is paid for an +/// invite and when, the claims that are refused, broadcasts and the banner. +@BackendTest +class SocialTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static MockRequestBuilder admin(MockRequestBuilder request) { + return Api.asAdmin(request, DemoAccounts.ADMIN); + } + + private String code(String who) throws Exception { + return (String) Api.json(mvc, Api.as(get("/api/social/invite"), who)).get("code"); + } + + private int claim(String who, String code) throws Exception { + return Api.statusOf(mvc, Api.as(Api.body(post("/api/social/claim"), + "{\"code\":\"" + code + "\",\"campaign\":\"friends\",\"channel\":\"share_sheet\",\"match\":\"direct\"}"), + who)); + } + + private long coins(String who) throws Exception { + return Api.number(Api.json(mvc, Api.as(get("/api/me"), who)), "coins"); + } + + // tag::dash-referral-test[] + @Test + void aFriendIsWelcomedOnJoiningAndTheInviterPaidOnceTheyPlay() throws Exception { + String host = Api.player(accounts, "inviter-one"); + String friend = Api.player(accounts, "invited-one"); + String code = code(host); + assertEquals(code, code(host), "a player keeps one code"); + + long before = coins(friend); + assertEquals(200, claim(friend, code)); + assertEquals(before + Referrals.FRIEND_COINS, coins(friend)); + assertEquals(409, claim(friend, code), "an invite counts once"); + + List friends = Api.list(mvc, Api.as(get("/api/social/friends"), host)); + assertEquals(1, friends.size()); + assertEquals(Referrals.JOINED, ((Map) friends.get(0)).get("state")); + + long hostBefore = coins(host); + Api.json(mvc, Api.as(post("/api/progress/tutorial"), friend)); + assertEquals(hostBefore + Referrals.INVITER_COINS, coins(host), "paid when the friend has played"); + Map mine = Api.json(mvc, Api.as(get("/api/social/invite"), host)); + assertEquals(1L, Api.number(mine, "rewarded")); + assertEquals(Referrals.PLAYED, ((Map) Api.list(mvc, Api.as(get("/api/social/friends"), host)).get(0)) + .get("state")); + + // A second battle pays nothing more. + Api.json(mvc, Api.as(post("/api/progress/tutorial"), friend)); + assertEquals(hostBefore + Referrals.INVITER_COINS, coins(host)); + } + // end::dash-referral-test[] + + @Test + void invitesThatWouldFarmCoinsAreRefused() throws Exception { + String a = Api.player(accounts, "farm-a"); + String b = Api.player(accounts, "farm-b"); + assertEquals(400, claim(a, code(a)), "not your own code"); + assertEquals(404, claim(a, "nosuchcode")); + assertEquals(200, claim(b, code(a))); + assertEquals(400, claim(a, code(b)), "not the player you invited"); + } + + @Test + void anOperatorBroadcastsToASegmentAndSetsTheBanner() throws Exception { + String everyone = "{\"segment\":\"all\",\"days\":0,\"title\":\"\",\"body\":\"\",\"screen\":\"\"}"; + Map size = Api.json(mvc, admin(Api.body(post("/api/admin/promote/audience"), everyone))); + assertTrue(Api.number(size, "targets") >= 4, "the demo accounts at least"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/promote/broadcast"), everyone))), + "a push needs a title and a message"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/promote/audience"), + "{\"segment\":\"lapsed\",\"days\":0,\"screen\":\"\"}"))), "lapsed for how long"); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/admin/promote/broadcast"), everyone), + DemoAccounts.PLAYER))); + + Map sent = Api.json(mvc, admin(Api.body(post("/api/admin/promote/broadcast"), + "{\"segment\":\"new\",\"days\":7,\"title\":\"Double coins\",\"body\":\"All weekend long\"," + + "\"screen\":\"shop\",\"campaign\":\"weekend\"}"))); + assertEquals("weekend", sent.get("campaign")); + Map promote = Api.json(mvc, admin(get("/api/admin/promote"))); + assertTrue(((List) promote.get("broadcasts")).size() >= 1); + + Api.json(mvc, admin(Api.body(post("/api/admin/promote/spotlight"), + "{\"text\":\"The Border War campaign is here\",\"packId\":\"border-war\",\"screen\":\"\"}"))); + Map banner = Api.json(mvc, Api.as(get("/api/social/spotlight"), DemoAccounts.PLAYER)); + assertEquals("The Border War campaign is here", banner.get("text")); + assertEquals("border-war", banner.get("packId")); + } + + @Test + void reportsAndTheQueryToolReadTheGamesOwnRecords() throws Exception { + Map report = Api.json(mvc, admin(get("/api/admin/reports?days=14"))); + assertEquals(14, ((List) report.get("joined")).size()); + assertEquals(14, ((List) report.get("active")).size()); + List funnel = (List) report.get("funnel"); + assertEquals("Joined", ((Map) funnel.get(0)).get("name")); + assertTrue(Api.number((Map) funnel.get(0), "count") >= 4, "the demo accounts at least"); + assertEquals(3, ((List) report.get("retention")).size()); + + Map all = Api.json(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"trophies\",\"op\":\">=\",\"value\":0}]}"))); + assertTrue(Api.number(all, "count") >= 4); + Map none = Api.json(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"trophies\",\"op\":\">\",\"value\":1000000}]}"))); + assertEquals(0, Api.number(none, "count")); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"password\",\"op\":\"=\",\"value\":1}]}")))); + assertEquals(403, Api.statusOf(mvc, Api.as(get("/api/admin/reports?days=7"), DemoAccounts.PLAYER))); + } + + @Test + void aPipelineActsOnceAPlayerPerCoolDown() throws Exception { + assertTrue(Api.list(mvc, admin(get("/api/admin/pipelines"))).size() >= 2, "the two the server starts with"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/pipelines"), + "{\"name\":\"Everyone\",\"enabled\":true,\"conditions\":[],\"action\":\"push\"," + + "\"title\":\"Hi\",\"body\":\"Hello\",\"screen\":\"\",\"cooldownDays\":1}"))), + "a pipeline for everyone is a broadcast"); + long before = Api.number((Map) Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.PLAYER))) + .get("player"), "gems"); + Map saved = Api.json(mvc, admin(Api.body(post("/api/admin/pipelines"), + "{\"name\":\"Gift\",\"enabled\":false,\"conditions\":[{\"field\":\"trophies\"," + + "\"op\":\">=\",\"value\":0}],\"action\":\"gems\",\"title\":\"A gift\"," + + "\"body\":\"Five gems\",\"screen\":\"shop\",\"amount\":5,\"cooldownDays\":30}"))); + String id = (String) saved.get("id"); + Map first = Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/run"))); + long reached = Api.number(first, "reached"); + assertTrue(reached >= 4); + Map player = (Map) Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.PLAYER))).get("player"); + assertEquals(before + 5, Api.number(player, "gems")); + Map again = Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/run"))); + assertEquals(reached, Api.number(again, "reached"), "nobody twice within the cool-down"); + Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/delete"))); + assertEquals(404, Api.statusOf(mvc, admin(post("/api/admin/pipelines/" + id + "/run")))); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java new file mode 100644 index 00000000000..5875a34b31b --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.Api; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.api.PushDeviceDto; +import com.codenameone.examples.dash.domain.Profile; +import com.codenameone.examples.dash.push.PushDevices; +import com.codenameone.examples.dash.push.PushSender; +import com.codenameone.examples.dash.push.RecordingPushSender; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.Input; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.social.Crews; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Whole races, run on the server's own rooms. The seats have no socket: +/// what goes over the wire is [GameSocket]'s, and these are about what the +/// server decides. +@BackendTest +class OnlineRaceTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rooms rooms; + @Autowired + private Crews crews; + @Autowired + private PushDevices devices; + @Autowired + private PushSender sender; + + /// Opening a party for a crew pushes everyone else in it, and only them. + @Test + void aCrewPartyCallsTheRestOfTheCrew() throws Exception { + String host = Api.player(accounts, "crewhost"); + String mate = Api.player(accounts, "crewmate"); + String stranger = Api.player(accounts, "stranger"); + devices.register(mate, new PushDeviceDto("fcm", "tok-mate", "android")); + devices.register(stranger, new PushDeviceDto("fcm", "tok-stranger", "android")); + String code = crews.create(host, "Night Shift").code(); + crews.join(mate, code); + RecordingPushSender pushes = (RecordingPushSender) sender; + rooms.host(profile(host), null, Content.CAUSEWAY, code); + assertEquals(1, pushes.sentTo("tok-mate").size(), "the crewmate hears about the party"); + assertTrue(pushes.sentTo("tok-stranger").isEmpty(), "nobody outside the crew does"); + // A crew code alone does not let a non-member call that crew. + rooms.host(profile(stranger), null, Content.CAUSEWAY, code); + assertEquals(1, pushes.sentTo("tok-mate").size()); + } + + // tag::dash-race-test[] + @Test + void aPartyRacesIsRecordedAndReplaysToTheSameScores() throws Exception { + String host = Api.player(accounts, "host"); + String guest = Api.player(accounts, "guest"); + rooms.host(profile(host), null, Content.CAUSEWAY, null); + String code = rooms.partyCode(host); + assertFalse(code.isEmpty()); + rooms.join(profile(guest), null, code); + assertEquals("", rooms.raceOf(host)); + // Only the host starts the race. + rooms.go(guest, null); + assertEquals("", rooms.raceOf(host)); + rooms.go(host, null); + String id = rooms.raceOf(host); + assertFalse(id.isEmpty()); + assertEquals(id, rooms.raceOf(guest)); + + // Past the countdown, an input is stamped a few ticks ahead. + rooms.tick(System.currentTimeMillis() + GameProtocol.COUNTDOWN_MILLIS + 100); + rooms.input(host, Input.JUMP); + rooms.fastForward(id); + assertEquals("", rooms.raceOf(host)); + + List mine = Api.list(mvc, Api.as(get("/api/races"), host)); + assertEquals(1, mine.size()); + assertEquals(id, ((Map) mine.get(0)).get("id")); + assertEquals(2L, Api.number((Map) mine.get(0), "runners")); + + // The record is the level, the seed and every input. Run again here, + // with the same rules, it ends exactly as it did on the server. + Map stored = Api.json(mvc, Api.as(get("/api/races/" + id), guest)); + Replay replay = Replay.decode((String) stored.get("replay")); + assertTrue(replay.eventCount() >= 1); + Run again = replay.play(Content.causeway(), Content.catalog()); + List scores = (List) stored.get("scores"); + for (int i = 0; i < 2; i++) { + assertEquals(((Number) scores.get(i)).intValue(), again.runner(i).score()); + } + } + // end::dash-race-test[] + + @Test + void aQuickRaceFillsWithBotsAndNeverCostsTrophies() throws Exception { + String who = Api.player(accounts, "lonely"); + int before = profile(who).getTrophies(); + rooms.quick(profile(who), null, Content.TUTORIAL); + String id = rooms.raceOf(who); + assertFalse(id.isEmpty(), "the test profile gets bots at once"); + rooms.fastForward(id); + assertTrue(profile(who).getTrophies() >= before); + Map record = (Map) Api.list(mvc, Api.as(get("/api/races"), who)).get(0); + assertEquals((long) Rooms.QUICK_RUNNERS, Api.number(record, "runners")); + // A bot is not someone to report. + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/reports"), + "{\"raceId\":\"" + id + "\",\"player\":\"1\",\"reason\":\"too good\"}"), who))); + } + + @Test + void anInputFloodIsCut() throws Exception { + String who = Api.player(accounts, "masher"); + rooms.quick(profile(who), null, Content.CAUSEWAY); + String id = rooms.raceOf(who); + rooms.tick(System.currentTimeMillis() + GameProtocol.COUNTDOWN_MILLIS + 100); + for (int i = 0; i < 100; i++) { + rooms.input(who, Input.LEFT); + } + rooms.fastForward(id); + Map stored = Api.json(mvc, Api.as(get("/api/races/" + id), who)); + Replay replay = Replay.decode((String) stored.get("replay")); + int mine = 0; + for (int i = 0; i < replay.eventCount(); i++) { + if (replay.event(i)[1] == 0) { + mine++; + } + } + assertTrue(mine <= RaceRoom.FLOOD_LIMIT, "accepted " + mine); + } + + @Test + void trophiesSumToZeroAndAloneNeverGoNegative() { + int sum = 0; + for (int p = 0; p < 4; p++) { + sum += Results.trophies(p, 4, 4); + assertTrue(Results.trophies(p, 4, 1) >= 0); + } + assertEquals(0, sum); + assertEquals(30, Results.trophies(0, 4, 4)); + } + + private Profile profile(String username) { + return accounts.profile(username); + } +} diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/ProgressTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/ProgressTest.java new file mode 100644 index 00000000000..5c928edf701 --- /dev/null +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/ProgressTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.game; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.dash.Api; +import com.codenameone.examples.dash.account.Accounts; +import com.codenameone.examples.dash.domain.Profile; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Levels, quests, chests and achievements, all granted by the server. +@BackendTest +class ProgressTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rooms rooms; + + @Test + void aNewPlayerStartsAtLevelOneWithThreeDistinctQuests() throws Exception { + String who = Api.player(accounts, "rookie"); + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertEquals(1L, Api.number(p, "level")); + assertEquals(100L, Api.number(p, "levelEnd")); + List quests = (List) p.get("quests"); + assertEquals(3, quests.size()); + assertTrue(!((Map) quests.get(0)).get("id").equals(((Map) quests.get(1)).get("id"))); + assertTrue(!((Map) quests.get(1)).get("id").equals(((Map) quests.get(2)).get("id"))); + assertEquals(409, Api.statusOf(mvc, Api.as(Api.body(post("/api/progress/quest"), + "{\"questId\":\"" + ((Map) quests.get(0)).get("id") + "\"}"), who))); + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/progress/quest"), + "{\"questId\":\"nope\"}"), who))); + assertEquals(409, Api.statusOf(mvc, Api.as(post("/api/progress/chest"), who))); + } + + @Test + void theTutorialPaysOnceAndLevelsUp() throws Exception { + String who = Api.player(accounts, "trainee"); + long coins = accounts.profile(who).getCoins(); + Map r = Api.json(mvc, Api.as(post("/api/progress/tutorial"), who)); + assertTrue(Boolean.TRUE.equals(r.get("levelUp")) || "true".equals(String.valueOf(r.get("levelUp")))); + assertEquals("Recruit", ((List) r.get("unlocked")).get(0)); + Map again = Api.json(mvc, Api.as(post("/api/progress/tutorial"), who)); + assertEquals(0L, Api.number(again, "coins")); + Profile after = accounts.profile(who); + assertEquals(coins + Api.number(r, "coins"), after.getCoins()); + // The tutorial chest and the level-up chest. + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertEquals(2L, Api.number(p, "chests")); + Map chest = Api.json(mvc, Api.as(post("/api/progress/chest"), who)); + assertTrue(Api.number(chest, "coins") > 0); + assertEquals(1L, Api.number(Api.json(mvc, Api.as(get("/api/progress"), who)), "chests")); + } + + @Test + void aRaceCountsTowardsQuestsAndExperience() throws Exception { + String who = Api.player(accounts, "grinder"); + rooms.quick(accounts.profile(who), null, com.codenameone.examples.dash.rules.Content.TUTORIAL); + rooms.fastForward(rooms.raceOf(who)); + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertTrue(Api.number(p, "xp") >= 15); + Map veteran = null; + for (Object a : (List) p.get("achievements")) { + if ("veteran".equals(((Map) a).get("id"))) { + veteran = (Map) a; + } + } + assertEquals(1L, Api.number(veteran, "progress")); + } +} diff --git a/scripts/dash/check-template.sh b/scripts/dash/check-template.sh new file mode 100755 index 00000000000..10bbfcc453f --- /dev/null +++ b/scripts/dash/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-template.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[dash-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-template.py" dash + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/DASH-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl rules,shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/AccountApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/AccountApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/dash/common/codenameone_settings.properties b/scripts/dash/common/codenameone_settings.properties new file mode 100644 index 00000000000..18afab9c7a1 --- /dev/null +++ b/scripts/dash/common/codenameone_settings.properties @@ -0,0 +1,49 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the game talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +codename1.arg.desktop.titleBar=native +codename1.arg.ios.newStorageLocation=true +# Push notifications: challenges, chest reminders, and flags for an admin. +codename1.arg.ios.includePush=true +# dash://tour starts the self-driving walkthrough (see Tour.java). +codename1.arg.ios.urlScheme=dash +codename1.arg.android.xintent_filter= +# Android push is off until the game has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The shop sells through Google Play, and an Android build that uses in-app +# purchase needs the app's licensing key. This is a placeholder that builds +# but verifies nothing: paste the Base64 RSA public key from the Play +# Console's "Monetization setup" before release. +codename1.arg.android.licenseKey=CN1TESTPLACEHOLDERKEYNOTFORPRODUCTIONxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB +# Runs are played in portrait. +codename1.arg.android.screenOrientation=portrait +codename1.arg.ios.interface_orientation=UIInterfaceOrientationPortrait +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.cssTheme=true +codename1.displayName=Dash +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.dash +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Dash +codename1.packageName=com.codenameone.examples.dash +codename1.secondaryTitle=Dash +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/dash/common/icon.png b/scripts/dash/common/icon.png new file mode 100644 index 00000000000..8dabd3715eb Binary files /dev/null and b/scripts/dash/common/icon.png differ diff --git a/scripts/dash/common/pom.xml b/scripts/dash/common/pom.xml new file mode 100644 index 00000000000..dbf7bd90f47 --- /dev/null +++ b/scripts/dash/common/pom.xml @@ -0,0 +1,1380 @@ + + + 4.0.0 + + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + + com.codenameone.examples.dash + dash-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + ${project.groupId} + dash-shared + ${project.version} + + + com.codenameone.examples.dash + dash-rules + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/dash/common/src/desktop/resources/NativeTheme.res b/scripts/dash/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/dash/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/dash/common/src/main/css/theme.css b/scripts/dash/common/src/main/css/theme.css new file mode 100644 index 00000000000..1d563bb8e80 --- /dev/null +++ b/scripts/dash/common/src/main/css/theme.css @@ -0,0 +1,1864 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of Dash. + * + * Every screen is built from ordinary Codename One widgets -- Button, + * TextField, Label, Tabs, Switch -- and this file is what makes them look like + * a game instead of a form: a night-blue backdrop, glass panels with a hairline + * edge, bright pill-shaped calls to action and a volt-green GO. Change the + * palette below to re-skin the game; change a rule to re-shape one widget + * everywhere it appears. + * + * The track itself is not styled here: run/RunView.java draws it with + * com.codename1.gaming, in the colours of run/Theme.java. + */ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + endsWith3PointsBool: true; + transitionSpeedInt: "250"; + /* Alerts fade in on the game's own dialog, with carved buttons, instead + of the native theme's pop-in white card. */ + dialogTransitionIn: "fade"; + dialogTransitionOut: "fade"; + dlgCommandGridBool: false; + dlgButtonCommandUIID: "DialogAction"; + /* Modals dim the game to a deep navy, not the default grey. */ + tintColor: "d0060818"; + /* Lists scroll by touch; the bar is noise on a phone. */ + scrollVisibleBool: false; + /* The admin console's tabs are the desk's own flat cells (DeskTab). The + native selection capsule drew its glass pill around them as well, two + highlights colliding on one tab. */ + tabsSelectionCapsuleBool: false; + tabsAnimatedIndicatorBool: false; + /* tag::dash-palette[] */ + --bg: #0b0e1d; + --surface: #151a33; + --surface-light: #1f2647; + --edge: #2c3563; + --edge-light: #4a5594; + --coral: #ff4d5e; + --coral-light: #ff8a4c; + --coral-dark: #c22745; + --volt: #d4ff3a; + --volt-dark: #8fb800; + --cyan: #2ee6ff; + --violet: #8a5cff; + --violet-glow: #c3b0ff; + --ice: #eaf6ff; + --ink: #0b0e1d; + --text: #ffffff; + --text-muted: #93a0c8; + --danger: #ff3b5c; + /* end::dash-palette[] */ +} + +Form, GameForm, RunScreen { + background: linear-gradient(to bottom, #1a1f45, var(--bg)); + color: var(--text); +} + +RunScreen { + padding: 0; +} + +GameToolbar { + background-color: var(--bg); + border-bottom: 0.2mm solid var(--edge); + padding: 1mm; +} + +GameTitle { + color: var(--volt); + font-family: "native:MainBold"; + font-size: 4mm; + text-align: center; +} + +Container { + background-color: transparent; +} + +Label { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 3mm; +} + +Logo { + color: var(--volt); + font-family: "native:MainBlack"; + font-size: 9mm; + text-align: center; + margin: 4mm 0 0 0; +} + +/* A dark pill, so the line reads over the skyline and the sun. */ +Tagline { + background-color: rgba(11, 14, 29, 0.78); + color: var(--ice); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: center; + border-radius: 3mm; + padding: 0.8mm 3mm; + margin: 0 0 4mm 0; +} + +SectionTitle { + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.8mm; + margin: 3.5mm 2mm 1mm 2.5mm; +} + +/* tag::dash-primary-button[] */ +/* The call to action: a glossy coral pill, sunset orange at the top, that + darkens when touched. */ +PrimaryButton { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.3mm solid var(--coral-dark); + border-radius: 5mm; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.8mm; + text-align: center; + padding: 2.6mm; + /* The same 2mm inset as the section titles and panels above it. */ + margin: 1.2mm 2mm; +} + +PrimaryButton.pressed { + background: linear-gradient(to bottom, var(--coral), var(--coral-dark)); + color: var(--ice); +} + +PrimaryButton.disabled { + background-color: var(--surface-light); + color: var(--text-muted); +} +/* end::dash-primary-button[] */ + +GhostButton, PriceButton, GemButton, ReplayButton { + background-color: var(--surface); + border: 0.35mm solid var(--edge-light); + border-radius: 5mm; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 1.9mm; + margin: 1mm 2mm; +} + +GhostButton.pressed, PriceButton.pressed, GemButton.pressed, ReplayButton.pressed { + background-color: var(--edge); +} + +GemButton { + background: linear-gradient(to bottom, #7cf2ff, #12b4d6); + border: 0.3mm solid #0b7c96; + color: var(--ink); +} + +DangerButton { + background-color: transparent; + border: 0.35mm solid var(--danger); + border-radius: 5mm; + color: var(--danger); + font-family: "native:MainBold"; + text-align: center; + padding: 1.8mm; + margin: 1mm 2mm; +} + +AdminButton { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 1.5mm; + color: var(--volt); + font-family: "native:MainBold"; + text-align: center; + padding: 1.8mm; + margin: 2mm; +} + +LinkButton { + background-color: transparent; + color: var(--text-muted); + text-align: center; + font-size: 2.5mm; + padding: 1mm; +} + +/* A glass card that groups fields and buttons. */ +Panel { + background-color: var(--surface); + border: 0.25mm solid var(--edge); + border-radius: 4mm; + padding: 2.5mm; + margin: 1.5mm 2mm; +} + +GameField { + background-color: var(--bg); + border: 0.3mm solid var(--edge-light); + border-radius: 3mm; + color: var(--text); + font-size: 3.2mm; + padding: 2.2mm; + margin: 1mm 0; +} + +TextHint { + color: #5f6a95; +} + +/* tag::dash-name-field-css[] */ +/* The runner-name entry is a volt-green plate: the name is the one thing a + player types that everyone else sees. */ +NameField { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 4mm; + color: var(--ink); + font-family: "native:MainBold"; + font-size: 5mm; + /* Left, like every other field: a centred native editor put the caret + at one edge and the text at the other while typing. */ + text-align: left; + padding: 2.5mm 3mm; + margin: 1mm 0; +} + +/* end::dash-name-field-css[] */ + +WalletBar { + background-color: var(--surface); + padding: 1.5mm; +} + +Trophies, Coins, Gems { + font-family: "native:MainBold"; + font-size: 3.2mm; + text-align: center; +} + +Trophies { + color: var(--volt); +} + +Coins { + color: #ffc93a; +} + +Gems { + color: var(--cyan); +} + +/* tag::dash-card-slot[] */ +CardSlot, CardSlotSelected { + background-color: var(--surface); + border: 0.3mm solid var(--edge); + border-radius: 4mm; + color: var(--violet-glow); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 1.2mm; + margin: 0.8mm; +} + +CardSlotSelected { + border: 0.6mm solid var(--volt); + background-color: var(--surface-light); +} + +CardSlot.disabled { + background-color: var(--bg); + color: #4a5280; +} +/* end::dash-card-slot[] */ + +ReplayBar { + background-color: var(--surface); + padding: 0.8mm; +} + +PlayerName { + color: var(--cyan); +} + +PlayerName { + color: #8db8ff; +} + +RunClock { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4mm; + text-align: center; +} + +/* tag::dash-elixir-css[] */ + +/* end::dash-elixir-css[] */ + +/* Quick chat in a race: glassy chips with a cyan rim, inset from the edges. */ +EmoteButton { + background-color: rgba(21, 26, 51, 0.85); + border: 0.3mm solid var(--cyan); + border-radius: 3mm; + color: var(--ice); + font-family: "native:MainBold"; + font-size: 2.4mm; + text-align: center; + padding: 0.9mm 0.5mm; + margin: 0.5mm 0.8mm 1.5mm 0.8mm; +} + +ChallengeCode { + background-color: var(--ice); + border-radius: 2mm; + color: var(--ink); + font-family: "native:MainBold"; + font-size: 7mm; + padding: 2mm 4mm; +} + +ChallengeUrl, Empty { + color: var(--text-muted); + font-size: 2.6mm; + text-align: center; + padding: 1mm 2mm; + margin: 0; +} + +Spotlight { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 3mm; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 2.4mm; + text-align: left; + padding: 1mm 2mm; + margin: 0 4mm 1.5mm 4mm; +} + +Spotlight.pressed { + background-color: var(--bg); +} + +SpotlightIcon { + color: var(--volt); + padding: 0 1.5mm 0 0; +} + +SpotlightText { + color: var(--volt); + font-family: "native:MainBold"; + font-size: 2.4mm; + text-align: left; +} + +InviteCode { + color: var(--volt); + font-family: "native:MainBold"; + font-size: 5mm; + text-align: center; + margin: 2mm; +} + +Warning { + color: #ffb74d; + font-size: 2.8mm; + margin: 1mm 2mm; +} + +ShopItem, LeaderRow, LeaderTop, SettingRow { + background-color: var(--surface); + border-radius: 2mm; + padding: 2mm; + margin: 1mm 2mm; +} + +LeaderTop { + border: 0.4mm solid var(--volt); +} + +ShopItemName, LeaderName, SettingLabel { + color: var(--text); + font-size: 3mm; +} + +LeaderRank { + color: var(--text-muted); + font-family: "native:MainBold"; + padding: 0 2mm 0 0; +} + +Owned { + color: #7be38a; + font-family: "native:MainBold"; +} + +HistoryRow, AdminRow, FlagReport, FlagAuto { + background-color: var(--surface); + border-radius: 2mm; + padding: 1.5mm; + margin: 0.8mm 2mm; +} + +FlagReport { + border: 0.4mm solid #ffb74d; +} + +FlagAuto { + border: 0.4mm solid var(--coral); +} + +HistoryWin { + color: var(--volt); + font-family: "native:MainBold"; +} + +HistoryLoss { + color: #ff9a92; + font-family: "native:MainBold"; +} + +HistoryDraw { + color: var(--text-muted); + font-family: "native:MainBold"; +} + +MultiLine1 { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; +} + +MultiLine2, MultiLine3 { + color: var(--text-muted); + font-size: 2.5mm; +} + +AdminStat { + background-color: var(--surface); + border: 0.4mm solid var(--edge); + border-radius: 2mm; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 3.2mm; + padding: 2.5mm; + margin: 1mm; +} + +AdminDetail { + color: var(--text); + font-size: 2.8mm; +} + +AdminTabs { + background-color: var(--bg); +} + +Tab { + background-color: var(--surface); + color: var(--text-muted); + font-size: 2.6mm; + padding: 1.5mm; +} + +Tab.selected { + color: var(--volt); + border-bottom: 0.6mm solid var(--volt); +} + +Dialog { + background-color: var(--surface); + border: 0.5mm solid var(--coral); + border-radius: 3mm; + padding: 2mm; +} + +DialogBody, DialogTitle { + color: var(--text); +} + +DialogTitle { + color: var(--volt); + font-family: "native:MainBold"; +} + +AdDialog { + background-color: #000000; + color: #ffffff; +} + +Slider { + background-color: var(--edge); + border-radius: 1mm; + padding: 0.8mm; +} + +SliderFull { + background-color: var(--volt); + border-radius: 1mm; +} + +/* Text sits on the panel or backdrop under it: the native theme gives a + label an opaque ground, which a game screen must not have. */ +Label, SpanLabel, SpanLabelText, Logo, Tagline, SectionTitle, Trophies, Coins, Gems, PlayerName, RunClock, ChallengeUrl, Empty, Warning, ShopItemName, LeaderName, SettingLabel, LeaderRank, Owned, HistoryWin, HistoryLoss, HistoryDraw, MultiLine1, MultiLine2, MultiLine3, AdminDetail, GameTitle, Title, TextHint, ForgeLabel, ForgeHelp, ForgeHelpText, ForgeByline, ForgeValue, ForgeChoiceArrow, LobbyMemberName, LobbyHostTag, LobbySay, InviteCode, SpotlightText, SpotlightIcon, Container { + background-color: transparent; +} + +StatusBar, StatusBarSideMenu { + background-color: var(--surface); +} + +TabsContainer { + background-color: var(--surface); + border: none; + padding: 0; + margin: 0; +} + +Tab, Tab.selected, Tab.pressed { + background-color: var(--surface); + border: none; + border-radius: 0; + margin: 0; +} + +Tab.selected { + border-bottom: 0.6mm solid var(--volt); +} + +ContentPane, Toolbar, TitleArea { + background-color: transparent; +} + +FlagReport, FlagAuto { + color: var(--text); +} + +/* tag::dash-progress-css[] */ +/* Home, progress and celebrations. Every piece of chrome around the art is + one of these; the art itself (cards, heroes) comes from ui/CardArt.java. */ +HomeHeader { + background-color: var(--surface); + border-bottom: 0.25mm solid var(--edge); +} + +PlayerNameBig { + background-color: transparent; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.6mm; + padding: 0 1.5mm; +} + +LevelBadge, LevelBadgeBig { + background-color: var(--violet); + border: 0.3mm solid var(--violet-glow); + border-radius: 3mm; + color: #ffffff; + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 1.8mm; + margin: 0 1.5mm; +} + +LevelBadgeBig { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + color: var(--ink); + border: 0.4mm solid var(--volt-dark); +} + +XpText, Muted { + background-color: transparent; + color: var(--text-muted); + font-size: 2.4mm; + padding: 0 1.5mm; +} + +Muted { + background-color: transparent; + text-align: center; +} + +HeroStage { + background-color: transparent; + padding: 1mm 0 0 0; +} + +LeagueName { + background-color: transparent; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 3.4mm; + margin: 1.6mm 0 0 0; + text-align: center; +} + +GoButton { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 7mm; + color: var(--ink); + font-family: "native:MainBlack"; + font-size: 7mm; + text-align: center; + padding: 3.5mm; + margin: 2mm 4mm; +} + +GoButton.pressed { + background: linear-gradient(to bottom, var(--volt), var(--volt-dark)); + color: var(--ink); +} + +CrateSlot, CrateSlotEmpty { + background-color: var(--surface); + border: 0.35mm solid var(--cyan); + border-radius: 3.5mm; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.2mm; + text-align: center; + padding: 0.8mm 0.3mm; + margin: 0 1.2mm 1.5mm 1.2mm; +} + +CrateSlot.pressed { + background-color: var(--edge); +} + +CrateSlotEmpty { + background-color: var(--bg); + border: 0.3mm solid var(--edge); + color: var(--edge-light); +} + +QuestCard { + background-color: var(--surface); + border: 0.4mm solid var(--edge); + border-radius: 2mm; + padding: 1.5mm 2mm; + margin: 0.8mm 0; +} + +QuestTitle { + background-color: transparent; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; +} + +QuestReward { + color: #ffc93a; +} + +ClaimButton { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + border: 0.3mm solid var(--volt-dark); + border-radius: 4mm; + color: var(--ink); + font-family: "native:MainBold"; + font-size: 2.8mm; + text-align: center; + padding: 1.2mm 2.8mm; + margin: 0 0 0 1.5mm; +} + +ClaimButton.pressed { + background-color: var(--volt-dark); +} + +Claimed { + color: var(--volt); +} + +/* The runner in play: CardAction's box, so its card ends level with the + others' buttons. */ +RunnerChip { + background-color: rgba(212, 255, 58, 0.12); + border: 0.3mm solid var(--volt); + border-radius: 3mm; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 1.4mm 0.5mm; + margin: 2mm 0.8mm 0 0.8mm; +} + +RunnerStageName { + background-color: transparent; + color: var(--text); + font-family: "native:MainBlack"; + font-size: 5mm; + text-align: center; + padding: 0 0 3mm 0; + margin: 0; +} + +NavTile { + background: linear-gradient(to bottom, var(--edge-light), var(--edge)); + border: 0.4mm solid var(--edge); + border-radius: 2mm; + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 1.5mm 0.5mm; + margin: 0.8mm; +} + +NavTile.pressed { + background-color: var(--edge); +} + +RewardDialog { + background: linear-gradient(to bottom, #222a5a, var(--bg)); + border: 0.8mm solid var(--volt); + border-radius: 4mm; + padding: 2mm 3mm; +} + +RewardCard { + background: linear-gradient(to bottom, #222a5a, var(--bg)); + border: 0.8mm solid var(--volt); + border-radius: 4mm; + padding: 2mm 3mm; + margin: 2mm; +} + +RewardBody { + background-color: transparent; + padding: 0; +} + +RewardTitle, LevelUpTitle { + background-color: transparent; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 4mm; + text-align: center; +} + +LevelUpTitle { + background-color: transparent; + color: #ffffff; + font-size: 4.4mm; +} + +RewardHero { + background-color: transparent; + padding: 0; + margin: 0; +} + +RewardCoins, RewardGems, RewardXp, RewardChest { + background-color: #0e1224; + border-radius: 2mm; + font-family: "native:MainBold"; + font-size: 2.8mm; + padding: 0.8mm 1.6mm; + margin: 0.6mm; +} + +RewardCoins { color: #ffc93a; } +RewardGems { color: var(--cyan); } +RewardXp { color: var(--volt); } +RewardChest { color: var(--volt); } + +RewardAchievement { + background-color: transparent; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 3mm; +} + +AchievementRow, AchievementLocked { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 2mm; + padding: 1.5mm; + margin: 0.8mm 0; +} + +AchievementLocked { + border: 0.4mm solid var(--edge); +} + +CoachBubble { + background-color: var(--surface-light); + border: 0.35mm solid var(--cyan); + border-radius: 4mm; + padding: 2mm; + margin: 2mm; +} + +CoachName { + background-color: transparent; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.6mm; +} + +CoachText { + background-color: transparent; + color: var(--text); + font-size: 3.2mm; +} + +CoachHint { + background-color: transparent; + color: var(--text-muted); + font-size: 2.4mm; +} + +TutorialSkip { + background-color: transparent; + color: #ffffff; + font-size: 2.6mm; + padding: 1mm 2mm; +} + +SignInHero { + background-color: transparent; + padding: 0; + margin: 0; + text-align: center; +} + +/* end::dash-progress-css[] */ + +/* The toolbar's back arrow: a bare gold glyph, no native button chrome. */ +BackCommand, BackCommand.pressed, TitleCommand, TitleCommand.pressed { + background-color: transparent; + border: none; + color: var(--volt); + padding: 1mm; + margin: 0; +} + +BackCommand.pressed, TitleCommand.pressed { + color: var(--coral-light); +} + +DialogCommandArea { + background-color: transparent; + padding: 1.5mm 0 0 0; +} + +DialogAction { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.5mm solid var(--coral-dark); + border-radius: 1.5mm; + color: var(--ink); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 1.5mm 4mm; + margin: 0 1mm; +} + +DialogAction.pressed { + background-color: var(--coral-dark); + color: var(--ice); +} + +/* The Forge: the pack browser and the editors. Rows are stone panels, labels + sit left and values right, and the small steppers are carved like the big + buttons so the editor feels like part of the game. */ +ForgeRow { + background-color: var(--edge); + border-radius: 1.5mm; + padding: 1.5mm 2mm; + margin: 0.8mm 2mm; +} + +ForgeLabel { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: left; + padding: 0.5mm 2mm 0.5mm 0.5mm; + margin: 0; +} + +ForgeValue { + color: var(--volt); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 0.5mm 1.5mm; + margin: 0; +} + +ForgeField { + background-color: var(--bg); + border: 0.3mm solid var(--edge-light); + border-radius: 2.5mm; + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 1.2mm; + margin: 0.5mm 0; +} + +ForgeChoice { + background-color: transparent; + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 0; + margin: 0; +} + +ForgeChoiceArrow { + background-color: transparent; + color: var(--text); + padding: 0 0 0 1mm; + margin: 0; +} + +ForgeStep { + background-color: rgba(46, 230, 255, 0.12); + border: 0.35mm solid var(--cyan); + border-radius: 3mm; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 1mm 2mm; + margin: 0 0.5mm; +} + +ForgeStep.pressed { + background-color: var(--cyan); + color: var(--ink); +} + +ForgeCheck { + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 1mm; + margin: 0.5mm 2mm; +} + +ForgeHelp, ForgeHelpText, ForgeByline { + color: var(--text-muted); + font-size: 2.6mm; + text-align: left; + padding: 0.5mm 2mm; + margin: 0; +} + +ForgeProblem { + color: var(--coral); + font-size: 2.8mm; + text-align: left; + padding: 0.5mm 2mm; + margin: 0; +} + +ForgeOfficial { + background-color: transparent; + color: var(--volt); + font-family: "native:MainBold"; + font-size: 3.4mm; + padding: 0.3mm 0; + margin: 0; +} + +ForgeTab, ForgeTabOn { + background-color: var(--edge); + border-radius: 1.5mm; + color: var(--edge-light); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 1.5mm; + margin: 0.5mm; +} + +ForgeTabOn { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + color: var(--ink); +} + +/* The arena editor's tools: an icon over a short word, small enough that + five of them share a phone's width without "To...". */ +ForgeTool, ForgeToolOn { + background-color: var(--edge); + border-radius: 1.5mm; + color: var(--edge-light); + font-family: "native:MainBold"; + font-size: 2.4mm; + text-align: center; + padding: 1mm 0.5mm; + margin: 0.5mm; +} + +ForgeToolOn { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + color: var(--ink); +} + +/* tag::dash-desk-css[] */ +/* The admin console. It is a tool for the people running the game, not part + of the game, so it looks like a desktop application: a light page, a side + bar, plain fields in one aligned column, flat buttons and charts. Nothing + here is wood or stone. admin/Desk.java builds every widget with these. */ +DeskForm, DeskPage, DeskTabs { + background-color: #f4f5f7; + padding: 0; + margin: 0; +} + +DeskPage { + padding: 2mm 3mm; +} + +DeskToolbar { + background-color: #1f2633; + padding: 1mm 2mm; +} + +DeskTitle { + background-color: transparent; + color: #ffffff; + font-family: "native:MainBold"; + font-size: 3.2mm; + text-align: left; +} + +DeskSide { + background-color: #e8eaee; + border-right: 0.2mm solid #d3d7de; + padding: 1mm 0; +} + +DeskTab, DeskTab.selected, DeskTab.pressed { + background-color: transparent; + color: #3c4454; + font-family: "native:MainRegular"; + font-size: 2.7mm; + text-align: left; + border: none; + border-radius: 1mm; + padding: 1.4mm 2.5mm; + margin: 0.3mm 1mm; +} + +DeskTab.selected { + background-color: #ffffff; + color: #1d4ed8; + font-family: "native:MainBold"; +} + +DeskSection { + background-color: transparent; + color: #1d2330; + font-family: "native:MainBold"; + font-size: 3.2mm; + padding: 0; + margin: 3mm 0 1.2mm 0; +} + +DeskPanel, DeskCard, DeskChartBox { + background-color: #ffffff; + border: 0.2mm solid #d9dde3; + border-radius: 1.2mm; + padding: 2mm 2.5mm; + margin: 0.8mm; +} + +DeskPanelTitle { + background-color: transparent; + color: #1d2330; + font-family: "native:MainBold"; + font-size: 2.9mm; + padding: 0 0 0.6mm 0; + margin: 0; +} + +DeskCardValue { + background-color: transparent; + color: #1d2330; + font-family: "native:MainBold"; + font-size: 5mm; + padding: 0; + margin: 0; +} + +DeskCardName { + background-color: transparent; + color: #4a5263; + font-size: 2.6mm; + padding: 0; + margin: 0; +} + +DeskCardDetail, DeskHelp { + background-color: transparent; + color: #6b7280; + font-size: 2.4mm; + padding: 0; + margin: 0; +} + +DeskHelpBox { + background-color: transparent; + padding: 0.8mm 0; + margin: 0; +} + +DeskFieldRow { + background-color: transparent; + padding: 0.6mm 0; + margin: 0; +} + +DeskLabel { + background-color: transparent; + color: #4a5263; + font-size: 2.7mm; + text-align: right; + padding: 1.2mm 2.5mm 1.2mm 0; + margin: 0; +} + +DeskValue, DeskValueAlert { + background-color: transparent; + color: #1d2330; + font-size: 2.7mm; + padding: 1.2mm 0; + margin: 0; +} + +DeskValueAlert { + color: #b42318; +} + +DeskInput { + background-color: #ffffff; + border: 0.2mm solid #c4c9d2; + border-radius: 0.8mm; + color: #1d2330; + font-size: 2.7mm; + text-align: left; + padding: 1mm 1.4mm; + margin: 0 0.6mm 0 0; +} + +DeskConditionRow { + background-color: transparent; + padding: 0.4mm 0; + margin: 0; +} + +DeskActions { + background-color: transparent; + padding: 1mm 0 0 0; + margin: 0; +} + +DeskButton, DeskPrimary, DeskDanger { + background-color: #ffffff; + border: 0.2mm solid #c4c9d2; + border-radius: 0.8mm; + color: #1d2330; + font-family: "native:MainRegular"; + font-size: 2.7mm; + text-align: center; + padding: 1mm 2.5mm; + margin: 0 0 0 1mm; +} + +DeskButton.pressed { + background-color: #e8eaee; +} + +DeskPrimary { + background-color: #2563eb; + border: 0.2mm solid #1d4ed8; + color: #ffffff; + font-family: "native:MainBold"; +} + +DeskPrimary.pressed { + background-color: #1d4ed8; +} + +DeskDanger { + color: #b42318; + border: 0.2mm solid #e4a39c; +} + +DeskDanger.pressed { + background-color: #fdecea; +} + +DeskLink { + background-color: transparent; + color: #1d4ed8; + font-size: 2.6mm; + text-align: left; + padding: 1mm; + margin: 0; +} + +DeskRow, DeskRowWarn, DeskRowAlert { + background-color: #ffffff; + border: 0.2mm solid #d9dde3; + border-radius: 1mm; + padding: 1.4mm 2mm; + margin: 0.5mm 0; +} + +DeskRowWarn { + border-left: 1mm solid #f59e0b; +} + +DeskRowAlert { + border-left: 1mm solid #dc2626; +} + +DeskRowTitle { + background-color: transparent; + color: #1d2330; + font-family: "native:MainBold"; + font-size: 2.8mm; +} + +DeskRowDetail { + background-color: transparent; + color: #6b7280; + font-size: 2.5mm; +} + +DeskTableHeader, DeskTableRow { + background-color: #ffffff; + border-bottom: 0.2mm solid #e5e7eb; + padding: 0 1mm; + margin: 0; +} + +DeskTableHeader { + background-color: #eef0f3; +} + +DeskTableHead, DeskCell { + background-color: transparent; + color: #1d2330; + font-size: 2.6mm; + text-align: left; + padding: 1mm; + margin: 0; +} + +DeskTableHead { + color: #4a5263; + font-family: "native:MainBold"; +} + +DeskEmpty { + background-color: transparent; + color: #6b7280; + font-size: 2.7mm; + padding: 2mm 0; +} + +DeskBadge, DeskBadgeOn { + background-color: #e5e7eb; + border-radius: 1mm; + color: #4a5263; + font-family: "native:MainBold"; + font-size: 2.3mm; + padding: 0.4mm 1.5mm; + margin: 0; +} + +DeskBadgeOn { + background-color: #dcfce7; + color: #166534; +} + +/* The charts read their colours from these: the page, the grid lines and + the three series. */ +DeskChart { + background-color: #ffffff; + color: #4a5263; + padding: 0; + margin: 0; +} + +DeskChartGrid { + color: #e2e5ea; +} + +DeskChartA { + color: #2563eb; +} + +DeskChartB { + color: #16a34a; +} + +DeskChartC { + color: #f59e0b; +} +/* end::dash-desk-css[] */ + +/* ---- Run HUD: drawn over the 3D view, so every piece keeps its own + translucent backing and stays readable against bright scenery. ---- */ +EmoteBar { + background-color: transparent; + padding: 0 2mm 3mm 2mm; + margin: 0; +} +HudBar { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0.75), rgba(5, 7, 18, 0)); + padding: 1.5mm 2mm 4mm 2mm; +} +HudDistance, HudScore, HudCoins, HudPlace, HudPower { + background-color: transparent; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.2mm; + padding: 0.3mm 1.5mm; + margin: 0; +} +HudDistance { + font-family: "native:MainBlack"; + font-size: 8mm; + text-align: center; +} +HudScore { + background-color: rgba(10, 14, 32, 0.72); + border: 0.25mm solid #3a4380; + border-radius: 3mm; + color: var(--ice); + text-align: center; + padding: 0.4mm 2.2mm; +} +HudStreet { + background-color: #0f6b45; + border: 0.25mm solid #ffffff; + border-radius: 1.2mm; + color: #ffffff; + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 1.8mm; + margin-top: 0.8mm; +} +HudCoins { + background-color: rgba(10, 14, 32, 0.8); + border: 0.3mm solid #ffc93a; + border-radius: 3.5mm; + color: #ffc93a; + font-size: 3.6mm; + padding: 0.8mm 2.2mm; + margin: 0.5mm 0; +} +HudPower { + color: var(--violet-glow); + text-align: right; +} +HudPlace { + color: var(--cyan); + font-family: "native:MainBlack"; + font-size: 4.6mm; + text-align: right; +} +HudCountdown { + background-color: transparent; + color: var(--volt); + font-family: "native:MainBlack"; + font-size: 24mm; + text-align: center; +} +HudButton { + background-color: rgba(10, 14, 32, 0.8); + border: 0.3mm solid var(--edge-light); + color: var(--text); + font-size: 3mm; + border-radius: 5mm; + padding: 1.6mm; + margin: 1mm; +} +HudButton.pressed { + background-color: var(--edge); +} +ChatLog { + background-color: rgba(10, 14, 28, 0.45); + padding: 1mm; + margin: 1mm; +} +ChatLine { + background-color: transparent; + color: var(--text); + font-size: 2.8mm; + padding: 0; + margin: 0; +} + +ChatFrom { + background-color: transparent; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.2mm; + padding: 0; + margin: 0; +} + +LobbyMember { + background-color: rgba(255, 255, 255, 0.04); + border-radius: 2mm; + padding: 1mm 2mm; + margin: 0.6mm 2mm; +} + +LobbyMemberName { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; + padding: 0; + margin: 0; +} + +LobbyHostTag { + color: var(--volt); + font-family: "native:MainBold"; + font-size: 2.2mm; + padding: 0.3mm 1mm; + margin: 0; +} + +LobbySay { + padding: 0; + margin: 0.6mm 2mm; +} + +ChatBubble { + background-color: #1e2350; + border: 1px solid #3a4290; + border-radius: 2mm; + padding: 1mm 2mm; + margin: 0.6mm 2mm; +} + +/* ---- Results, score boards and the track editor ---- */ +ResultLine { + color: var(--text); + font-size: 3.2mm; + padding: 0.8mm 2mm; + margin: 0; +} +DialogButton { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.3mm solid var(--coral-dark); + color: #ffffff; + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: center; + border-radius: 4mm; + padding: 1.5mm 1mm; + margin: 1mm; +} +BoardTab { + background-color: var(--surface); + color: var(--edge-light); + font-size: 2.9mm; + border-radius: 4mm; + padding: 1.2mm 2.2mm; + margin: 0.6mm; +} +BoardTab.selected, BoardTab.pressed { + background-color: var(--cyan); + color: var(--ink); +} +Timeline { + background-color: var(--bg); + padding: 0; + margin: 1mm 0; +} + +/* ---- Home lobby ---- */ + +TopBar { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0.85), rgba(5, 7, 18, 0)); + padding: 2mm 2.5mm 3mm 2.5mm; +} + +CurrencyPill { + background-color: rgba(10, 14, 32, 0.78); + border: 0.3mm solid var(--edge-light); + border-radius: 4mm; + padding: 0.4mm 0.6mm 0.4mm 1.4mm; + margin: 0 0.8mm; +} + +Trophies, Coins, Gems { + font-size: 2.8mm; + padding: 0.4mm 0.8mm; + margin: 0; + background-color: transparent; +} + +PillPlus { + background-color: var(--volt); + border-radius: 2.5mm; + color: var(--ink); + padding: 0.2mm; + margin: 0 0.2mm 0 0.6mm; +} + +IconButton { + background-color: rgba(10, 14, 32, 0.78); + border: 0.3mm solid var(--edge-light); + border-radius: 4mm; + color: var(--text); + padding: 1mm; + margin: 0 0 0 0.8mm; +} + +Rail { + background-color: transparent; + padding: 1mm 1.5mm; +} + +RailTile { + background: linear-gradient(to bottom, rgba(46, 54, 104, 0.9), rgba(16, 20, 44, 0.9)); + border: 0.35mm solid var(--edge-light); + border-radius: 3.5mm; + color: var(--text); + font-family: "native:MainBold"; + font-size: 1.8mm; + text-align: center; + padding: 0.8mm 1.1mm 0.6mm 1.1mm; + margin: 0.5mm 0; +} + +RailTile.pressed { + background-color: var(--violet); + border-color: var(--cyan); +} + +HomeBottom { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0), rgba(5, 7, 18, 0.92)); + padding: 0; +} + +RaceChip { + background-color: rgba(18, 22, 50, 0.9); + border: 0.35mm solid var(--cyan); + border-radius: 5mm; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 2mm; + margin: 0 4mm 2mm 4mm; +} + +RaceChip.pressed { + background-color: var(--cyan); + color: var(--ink); +} + +NavBar { + background-color: rgba(6, 8, 20, 0.96); + border-top: 0.3mm solid var(--edge); + padding: 1mm 0 0.6mm 0; +} + +NavItem, NavItemOn { + background-color: transparent; + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.2mm; + text-align: center; + padding: 1mm 0.5mm; + margin: 0; +} + +NavItemOn { + color: var(--volt); +} + +/* ---- End of a run ---- */ + +/* The veil a result or reward card sits on, over the scene where the run + ended. opacity sets the background's transparency only. */ +RunDim { + background-color: rgba(6, 8, 24, 0.6); + padding: 6mm 3mm; + margin: 0; +} + +ResultCard { + background: linear-gradient(to bottom, #1d2350, #0b0e1d); + border: 0.4mm solid var(--violet-glow); + border-radius: 5mm; + padding: 4mm 3mm 3mm 3mm; + margin: 2mm; +} + +ResultTitle { + color: var(--volt); + font-family: "native:MainBlack"; + font-size: 5.5mm; + text-align: center; + background-color: transparent; +} + +ResultScore { + color: #ffffff; + font-family: "native:MainBlack"; + font-size: 9mm; + text-align: center; + background-color: transparent; + padding: 1mm 0 0 0; +} + +ResultCaption { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.4mm; + text-align: center; + background-color: transparent; +} + +StatChip { + background-color: rgba(46, 230, 255, 0.12); + border: 0.3mm solid var(--cyan); + border-radius: 3.5mm; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.2mm; + padding: 0.8mm 1.6mm; + margin: 1.5mm 0.8mm; +} + +/* ---- Level cards ---- */ + +LevelCard { + background: linear-gradient(to bottom, #1d2350, #121633); + border: 0.3mm solid var(--edge-light); + border-radius: 4mm; + padding: 2mm; + margin: 1.5mm 3mm; +} + +LevelThumb { + background-color: transparent; + padding: 0 2.5mm 0 0; + margin: 0; +} + +CityIcon { + background-color: #1f2a5e; + border-radius: 3mm; + color: var(--volt); + text-align: center; + padding: 2mm; + margin: 0 2mm 0 0; +} + +CityCard { + background: linear-gradient(to bottom, #1d2350, #0b0e1d); + border: 0.4mm solid var(--violet-glow); + border-radius: 5mm; + padding: 3mm; + margin: 2mm; +} + +CityStatus { + background-color: transparent; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 0 0 2mm 0; + margin: 0; +} + +MapCredit { + background-color: rgba(255, 255, 255, 0.8); + color: #333333; + font-family: "native:MainRegular"; + font-size: 1.7mm; + padding: 0.3mm 1mm; + margin: 0; +} + +CityMinimap { + background-color: rgba(10, 14, 32, 0.72); + border: 0.25mm solid #3a4380; + border-radius: 3mm; + padding: 0; + margin: 1mm 0 0 0; +} + +LevelLength { + background-color: transparent; + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.4mm 0 0 0.6mm; + margin: 0; +} + +LevelName { + background-color: transparent; + color: var(--text); + font-family: "native:MainBlack"; + font-size: 3.2mm; + padding: 0.5mm 0; +} + +TagChip { + background-color: rgba(181, 108, 255, 0.2); + border: 0.3mm solid var(--violet-glow); + border-radius: 2.5mm; + color: var(--violet-glow); + font-family: "native:MainBold"; + font-size: 2.2mm; + padding: 0.4mm 1.6mm; + margin: 0.5mm 1mm 0.5mm 0; +} + +PlayRound { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 7mm; + color: var(--ink); + padding: 2.4mm; + margin: 0 0 0 1mm; +} + +PlayRound.pressed { + background-color: var(--volt-dark); +} + +CardAction { + background-color: rgba(46, 230, 255, 0.08); + border: 0.3mm solid var(--edge-light); + border-radius: 3mm; + color: var(--cyan); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 1.4mm 0.5mm; + margin: 2mm 0.8mm 0 0.8mm; +} + +CardAction.pressed { + background-color: var(--edge); +} + +CardGo { + background: linear-gradient(to bottom, #ecff9a, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 6mm; + color: var(--ink); + font-family: "native:MainBlack"; + font-size: 4.6mm; + text-align: center; + padding: 2.6mm; + margin: 2mm 1mm 1.5mm 1mm; +} + +CardGo.pressed { + background-color: var(--volt-dark); +} + +/* Text that sits on a card or a dialog shows what is under it. */ +Claimed, DialogBody, DialogTitle, ForgeCheck, ForgeProblem, QuestReward, ResultLine { + background-color: transparent; +} + +/* ---- Shop cards ---- */ + +ShopArt { + background-color: transparent; + padding: 0 2.5mm 0 0; + margin: 0; +} + +ShopItemKind { + background-color: transparent; + color: var(--violet-glow); + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 0.3mm 0; +} + +/* The name on the home screen's top bar. */ +PlayerName { + background-color: transparent; + color: var(--text); + font-family: "native:MainBold"; + font-size: 3mm; + padding: 0.4mm 1mm 0.4mm 1.5mm; +} + +LevelBadge { + font-size: 2.2mm; + padding: 0.3mm 1.4mm; + margin: 0.4mm 0; +} + +/* The sign-in's server switch: a developer setting, kept small and quiet. */ +/* The invite code, on a card of its own with a caption over it. */ +InviteCard { + background: linear-gradient(to bottom, var(--surface-light), var(--surface)); + border: 0.3mm solid var(--edge-light); + border-radius: 3mm; + padding: 2mm; + margin: 1.5mm 0; +} + +InviteCaption { + background-color: transparent; + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2mm; + padding: 0; + margin: 0; +} + +/* A MultiButton's emblem (the stars on a score row): no plate behind it. */ +Emblem { + background-color: transparent; + padding: 0 0 0 1mm; + margin: 0; +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/AppConfig.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/AppConfig.java new file mode 100644 index 00000000000..9eb5e321563 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/AppConfig.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; + +/// The few things about the app that are settings and not code. +public final class AppConfig { + /// Where the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. Change this to your deployment's address + /// before you ship; until then the settings screen can point a build at + /// another server, which is how a phone reaches a laptop on the same network. + public static final String DEFAULT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by. The same constant is in the + /// server's `SecurityConfig`. + public static final String CLIENT_ID = "dash-app"; + /// The address the server hands the authorization code back to. The app + /// reads the code out of the redirect itself and never opens this address, + /// so it only has to be one the server has on its list. + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + // tag::dash-push-key[] + /// The app's push key, from the Push section of the Codename One console. + /// Empty, the app registers for no notifications: everything still works, + /// and a phone in a pocket simply hears nothing until it is opened. + public static final String PUSH_APP_KEY = ""; + // end::dash-push-key[] + /// Where the game's analytics are read: engagement, retention, the invite + /// funnel and revenue per campaign, all from what `Telemetry` and + /// `Friends` report. The console's Promote tab opens it. + public static final String ANALYTICS_CONSOLE = "https://cloud.codenameone.com/console/analytics"; + + // tag::dash-monetization-ids[] + /// Google's published test ad units. They always fill, never pay, and are + /// safe to click; replace them with your own AdMob units before release. + public static final String REWARDED_AD_UNIT = "ca-app-pub-3940256099942544/5224354917"; + public static final String INTERSTITIAL_AD_UNIT = "ca-app-pub-3940256099942544/1033173712"; + public static final String BANNER_AD_UNIT = "ca-app-pub-3940256099942544/6300978111"; + /// The store products, as configured in App Store Connect and the Play + /// Console. The server's `Catalog` names the same ones. + public static final String SKU_GEMS_80 = "dash.gems_80"; + public static final String SKU_GEMS_500 = "dash.gems_500"; + public static final String SKU_ROYAL_PASS = "dash.royal_pass"; + // end::dash-monetization-ids[] + + private static final String SERVER_KEY = "dash.server"; + private static final String MODE_KEY = "dash.mode"; + private static final String MODE_OWNER_KEY = "dash.mode.owner"; + + private AppConfig() { + } + + /// The server's address, with no trailing slash. A `dash.server.url` + /// system property wins, which is what the tests use; then the address saved + /// from the settings screen; then, in a browser, the address the page came + /// from, since the server that hosts the app is the server it talks to; + /// then [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("dash.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + // In a browser it is always the page's own server: see + // serverSwitchable(). An address saved before that is ignored. + url = origin != null ? origin : Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Whether the app may be pointed at another server. Not in a browser: + /// there the authorization code is sent back to the page's own server + /// ([#redirectUri]), and another server has not registered that address + /// as one of its own, so every sign-in after a switch would be refused. + /// The hosted app is the server's own, and talks to that server. + public static boolean serverSwitchable() { + return pageOrigin() == null; + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. + /// + /// The installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back. + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + public static void setServerUrl(String url) { + Preferences.set(SERVER_KEY, clean(url)); + } + + /// The mode the account `owner` (see + /// [com.codenameone.examples.dash.net.Session#owner(String)]) chose last + /// -- `player` or `admin` -- or + /// empty when they have chosen none, or the choice on record is someone + /// else's. A choice recorded with no name is anyone's: that is how a test + /// puts the app in a mode before it signs in. + public static String mode(String owner) { + String chosenBy = Preferences.get(MODE_OWNER_KEY, ""); + if (chosenBy.length() > 0 && !chosenBy.equals(owner)) { + return ""; + } + return Preferences.get(MODE_KEY, ""); + } + + /// Records `mode` as chosen by whoever signs in next. + public static void setMode(String mode) { + setMode(mode, ""); + } + + public static void setMode(String mode, String username) { + Preferences.set(MODE_KEY, mode == null ? "" : mode); + Preferences.set(MODE_OWNER_KEY, username == null ? "" : username); + } + + /// Whether `url` names the server `current` does, as [#setServerUrl] + /// would store it. + public static boolean sameServer(String url, String current) { + return clean(url).equals(current); + } + + + + /// The challenge code the app was opened with -- a challenge link opened in + /// a browser, or handed to the installed app -- or null. + public static String challengeCode() { + return linkParameter("challenge"); + } + + /// The Forge pack a shared link opened the app with, or null. + public static String packId() { + return linkParameter("pack"); + } + + /// The link that opens a Forge pack, for a player to send a friend. + public static String packLink(String id) { + String origin = pageOrigin(); + return (origin == null ? serverUrl() : origin) + "/?pack=" + id; + } + + private static String linkParameter(String name) { + String key = name + "="; + String link = CN.getProperty("browser.window.location.href", null); + if (link == null || link.indexOf(key) < 0) { + link = CN.getProperty("AppArg", null); + } + if (link == null) { + return null; + } + int at = link.indexOf(key); + if (at < 0) { + return null; + } + int end = at + key.length(); + while (end < link.length() && link.charAt(end) != '&' && link.charAt(end) != '#') { + end++; + } + String code = link.substring(at + key.length(), end); + return code.length() == 0 ? null : code; + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return DEFAULT_SERVER; + } + // Compared without folding the case: a scheme is ASCII, and + // toLowerCase() follows the device's language. + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "http://" + text; + } + return text; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/Dash.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/Dash.java new file mode 100644 index 00000000000..3abb0e31508 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/Dash.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.appreview.AppReview; +import com.codename1.payment.PurchaseCallback; +import com.codename1.system.Lifecycle; +import com.codenameone.examples.dash.run.Sfx; +import com.codenameone.examples.dash.engage.Ads; +import com.codenameone.examples.dash.engage.Friends; +import com.codenameone.examples.dash.engage.Notifications; +import com.codenameone.examples.dash.engage.Store; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.ui.Nav; +import com.codenameone.examples.dash.ui.SettingsForm; +import com.codenameone.examples.dash.ui.Ui; + +/// The game. It opens on whatever the stored session allows -- home, the +/// admin console, or a challenge link it was opened with -- and on the sign-in +/// screen otherwise. +/// +/// Where things are: +/// +/// - `rules` (its own module) -- the simulation. The server runs the same +/// classes, so the client never decides a score on its own. +/// - `run` -- the track, drawn with `com.codename1.gaming` in 3D and, where +/// a level says so, as a side-scroller, and the drivers that feed it: solo, +/// live race, replay. +/// - `ui` -- every other screen, built from ordinary widgets that +/// `src/main/css/theme.css` dresses as a game. +/// - `admin` -- the operator's console. +/// - `engage` -- analytics, push, ads and in-app purchases. +/// - `net` -- the session, the REST clients and the game socket. +/// +/// The class is the `PurchaseCallback` because that is how Codename One +/// reports a finished store purchase: to the main class. +public class Dash extends Lifecycle implements PurchaseCallback { + @Override + public void runApp() { + SettingsForm.apply(); + Sfx.load(); + Telemetry.start(); + Ads.start(); + // tag::dash-app-review[] + // The store's own rating prompt, asked only of a player who has come + // back: five launches over three days, and never twice in a month. + // ResultForm asks after a win, the moment a player is happiest. + AppReview.getInstance().setMinimumLaunches(5).setMinimumDaysInstalled(3) + .setDaysBetweenPrompts(30).registerSession(); + // end::dash-app-review[] + Session.start(AppConfig.serverUrl()); + Nav.launch(); + } + + @Override + public void start() { + super.start(); + Notifications.start(); + Friends.start(); + } + + @Override + public void stop() { + super.stop(); + Telemetry.flush(); + } + + // tag::dash-purchase-callback[] + @Override + public void itemPurchased(String sku) { + Store.purchased(sku); + } + + @Override + public void itemPurchaseError(String sku, String errorMessage) { + Ui.toast("The purchase did not go through: " + errorMessage); + } + // end::dash-purchase-callback[] + + @Override + public void itemRefunded(String sku) { + // The server hears about refunds from the store itself. + } + + @Override + public void subscriptionStarted(String sku) { + Store.purchased(sku); + } + + @Override + public void subscriptionCanceled(String sku) { + } + + @Override + public void paymentFailed(String paymentCode, String failureReason) { + } + + @Override + public void paymentSucceeded(String paymentCode, double amount, String currency) { + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/AdminConsole.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/AdminConsole.java new file mode 100644 index 00000000000..66335dc4986 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/AdminConsole.java @@ -0,0 +1,495 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.components.MultiButton; +import com.codename1.ui.CN; +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.dash.api.AdminActionDto; +import com.codenameone.examples.dash.api.AssetDto; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.AuditDto; +import com.codenameone.examples.dash.api.FlagDto; +import com.codenameone.examples.dash.api.ForgeActionDto; +import com.codenameone.examples.dash.api.LiveRaceDto; +import com.codenameone.examples.dash.api.RaceSummaryDto; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.api.PlayerDetailDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.forge.PackScreen; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.rules.forge.AssetRef; +import com.codenameone.examples.dash.ui.HistoryForm; +import com.codenameone.examples.dash.ui.RaceLobby; +import com.codenameone.examples.dash.ui.Nav; +import com.codenameone.examples.dash.ui.ReplayForm; +import com.codenameone.examples.dash.ui.Ui; +import java.util.List; + +/// "God mode": the game as its operator sees it. Every number and list comes +/// from `/api/admin`, which only an account with the admin role may call -- +/// the server enforces that, not this screen. +/// +/// - **Overview**: what is happening now, and charts of players, races, +/// sales, the funnel, retention and the trophy ladder ([ReportsTab]). +/// - **Flags**: reports from players and the server's own suspicions (a +/// desynced client, impossible play rates), newest first. New ones raise a +/// notification here and, on a phone, a push. +/// - **Players**: search, a player's history, flags and purchases, and the +/// actions -- warn, mute, suspend, ban, reset trophies, revoke items. Each +/// one is written to the audit log with the reason given. +/// - **Live**: every race being run, each one watchable as it happens. +/// - **Audit**: who did what to whom. +/// - **Query**: which players meet a set of conditions ([QueryTab]). +/// - **Pipelines**: when a player meets conditions, push or reward them, +/// every hour, with a cool-down ([PipelinesTab]). +/// - **Promote**: push broadcasts, the home spotlight and campaign links. +/// +/// The console is a tool, not part of the game, so it has a look of its own +/// ([Desk]): a desktop application's, in the same app. +// tag::dash-admin[] +public final class AdminConsole { + private static final int POLL_MILLIS = 15000; + private static int knownFlags = -1; + + private static final String[] TABS = {"Overview", "Flags", "Players", "Live", "Audit", "Forge", "Query", + "Pipelines", "Promote"}; + // Not MATERIAL_DASHBOARD: the Initializr renames every "Dash" in this + // template to the new app's name, and the constant would be renamed too. + private static final char[] ICONS = {FontImage.MATERIAL_ANALYTICS, FontImage.MATERIAL_FLAG, + FontImage.MATERIAL_PEOPLE, FontImage.MATERIAL_SPORTS_ESPORTS, FontImage.MATERIAL_HISTORY, + FontImage.MATERIAL_BUILD, FontImage.MATERIAL_SEARCH, FontImage.MATERIAL_ALT_ROUTE, + FontImage.MATERIAL_CAMPAIGN}; + + private final Form form = new Form("Dash console", new BorderLayout()); + private final ReportsTab overview = new ReportsTab(); + private final Container flags = Desk.page(); + private final Container players = Desk.page(); + private final Container live = Desk.page(); + private final Container audit = Desk.page(); + private final Container forge = Desk.page(); + private final QueryTab query = new QueryTab(this); + private final PipelinesTab pipelines = new PipelinesTab(); + private final PromoteTab promote = new PromoteTab(); + private Tabs tabs; + /// The desktop side bar's width. + private static final float SIDE_MM = 42f; + + private AdminConsole() { + } + + public static void show() { + new AdminConsole().build(); + } + + private void build() { + form.setName("admin"); + form.setUIID("DeskForm"); + form.getToolbar().setUIID("DeskToolbar"); + form.getToolbar().getTitleComponent().setUIID("DeskTitle"); + form.setScrollableY(false); + Telemetry.screen("admin"); + form.getToolbar().addMaterialCommandToRightBar("", FontImage.MATERIAL_SPORTS_ESPORTS, + e -> Nav.home()); + tabs = new Tabs(); + tabs.setUIID("DeskTabs"); + tabs.setTabUIID("DeskTab"); + tabs.getTabsContainer().setUIID("DeskSide"); + tabs.setName("adminTabs"); + Component[] pages = {overview.page(), flags, playersTab(), live, audit, forge, query.page(), + pipelines.page(), promote.column()}; + for (int i = 0; i < TABS.length; i++) { + tabs.addTab(TABS[i], FontImage.createMaterial(ICONS[i], "DeskTab", 3.2f), pages[i]); + tabs.setTabSelectedIcon(i, FontImage.createMaterial(ICONS[i], + UIManager.getInstance().getComponentSelectedStyle("DeskTab"), 3.2f)); + } + // tag::dash-admin-desktop[] + // On a desktop or a tablet the console is a side bar and a page, the + // way an operator's tools usually are; on a phone, tabs along the top. + if (wide()) { + tabs.setTabPlacement(Component.LEFT); + tabs.setTabTextPosition(Component.RIGHT); + // Tabs lays a side bar out as a grid, which spreads nine tabs over + // the full height of a desktop window, and sizes it to its widest + // label. A side bar is a list from the top, wide enough to read. + Container side = tabs.getTabsContainer(); + side.setLayout(BoxLayout.y()); + side.setPreferredW(Math.max(side.getPreferredW(), CN.convertToPixels(SIDE_MM))); + } else { + // Nine tabs do not fit across a phone: they scroll sideways. + tabs.getTabsContainer().setScrollableX(true); + tabs.setTabTextPosition(Component.BOTTOM); + } + // end::dash-admin-desktop[] + tabs.addSelectionListener((was, now) -> refresh(now)); + form.add(BorderLayout.CENTER, tabs); + refresh(0); + // The queue is watched while the console is open; a phone also gets a + // push for each new flag, sent by the server's GamePushes. + UITimer.timer(POLL_MILLIS, true, form, this::pollFlags); + form.show(); + } + + private void refresh(int tab) { + if (tab == 0) { + overview.refresh(); + } else if (tab == 1) { + Api.admin().flags(true, Net.to(list -> Ui.later(() -> showFlags(list)), Ui::failed)); + } else if (tab == 2) { + search(""); + } else if (tab == 3) { + Api.admin().liveRaces(Net.to(list -> Ui.later(() -> showLive(list)), Ui::failed)); + } else if (tab == 4) { + Api.admin().audit(Net.to(list -> Ui.later(() -> showAudit(list)), Ui::failed)); + } else if (tab == 5) { + Api.admin().forgeQueue(Net.to(packs -> Api.admin().forgeAssets(Net.to( + files -> Ui.later(() -> showForge(packs, files)), Ui::failed)), Ui::failed)); + } else if (tab == 6) { + query.refresh(); + } else if (tab == 7) { + pipelines.refresh(); + } else { + promote.refresh(); + } + } + + /// From the query tool: a pipeline that starts with its conditions. + void newPipeline(List conditions) { + pipelines.editNext(PipelinesTab.blank(conditions)); + if (tabs.getSelectedIndex() == 7) { + refresh(7); + } else { + tabs.setSelectedIndex(7); + } + } + + void openPlayer(String username) { + player(username, null); + } + + /// Player content waiting for a moderator: packs players reported or a + /// moderator hid, and uploaded models and images nobody has seen yet. A + /// file stays usable by its owner and anyone they share it with, and is + /// public once approved; promoting a pack to official is how the game's + /// own designers ship a campaign made with the same editor. + private void showForge(List packs, List files) { + forge.removeAll(); + forge.add(Desk.section("Packs (" + packs.size() + ")")); + if (packs.isEmpty()) { + forge.add(Desk.empty("No packs waiting.")); + } + for (final PackSummaryDto p : packs) { + Container row = Desk.panel(); + row.add(new Label(p.name() + " by " + p.author(), "DeskPanelTitle")); + row.add(new Label(p.status() + (p.official() ? ", official" : "") + ", " + p.plays() + " plays", + "DeskHelp")); + row.add(Desk.actions( + Desk.button("Open", "forge-open-" + p.id(), e -> PackScreen.show(p.id())), + Desk.button("hidden".equals(p.status()) ? "Restore" : "Hide", "forge-hide-" + p.id(), + e -> packAction(p, "hidden".equals(p.status()) ? "restore" : "hide")), + Desk.button(p.official() ? "Unofficial" : "Official", "forge-official-" + p.id(), + e -> packAction(p, p.official() ? "unofficial" : "official")))); + forge.add(row); + } + forge.add(Desk.section("Uploaded files (" + files.size() + ")")); + if (files.isEmpty()) { + forge.add(Desk.empty("No files waiting.")); + } + for (final AssetDto a : files) { + Container row = Desk.panel(); + row.add(new Label(a.name() + " (" + a.kind() + ", " + a.bytes() / 1024 + " KB)", "DeskPanelTitle")); + if (a.animations() != null && !a.animations().isEmpty()) { + StringBuilder names = new StringBuilder("Animations:"); + for (String n : a.animations()) { + names.append(' ').append(n); + } + row.add(new Label(names.toString(), "DeskHelp")); + } + row.add(Desk.actions( + Desk.button("Look", "asset-look-" + a.sha256(), e -> look(a)), + Desk.danger("Reject", "asset-reject-" + a.sha256(), e -> assetAction(a, "reject")), + Desk.primary("Approve", "asset-approve-" + a.sha256(), e -> assetAction(a, "approve")))); + forge.add(row); + } + forge.revalidate(); + } + + private void packAction(PackSummaryDto p, String action) { + Api.admin().forgePackAction(new ForgeActionDto(p.id(), action, ""), + Net.to(x -> Ui.later(() -> refresh(5)), Ui::failed)); + } + + private void assetAction(AssetDto a, String action) { + Api.admin().forgeAssetAction(new ForgeActionDto(a.sha256(), action, ""), + Net.to(x -> Ui.later(() -> refresh(5)), Ui::failed)); + } + + /// An image as it is, or what a model holds, before deciding. The file + /// comes from the server as base64, the way an editor uploaded it. + private void look(final AssetDto a) { + Api.forge().asset(a.sha256(), Net.to(full -> Ui.later(() -> { + Form f = Ui.form(a.name(), "assetLook"); + f.setLayout(new BorderLayout()); + Ui.back(f, () -> form.showBack()); + if (AssetRef.MODEL.equals(a.kind())) { + f.add(BorderLayout.CENTER, Ui.text("A model of " + a.bytes() / 1024 + " KB with " + + a.animations().size() + " animations: " + a.animations(), "DeskValue")); + } else { + try { + f.add(BorderLayout.CENTER, new Label(com.codename1.ui.EncodedImage.create( + com.codename1.util.Base64.decode(full.data().getBytes("UTF-8"))), "Container")); + } catch (java.io.UnsupportedEncodingException | RuntimeException err) { + f.add(BorderLayout.CENTER, Ui.label("This file is not an image", "ForgeProblem")); + } + } + f.show(); + }), Ui::failed)); + } + + private void pollFlags() { + Api.admin().stats(Net.to(s -> Ui.later(() -> { + if (knownFlags >= 0 && s.openFlags() > knownFlags) { + Ui.toast((s.openFlags() - knownFlags) + " new flag(s) to review"); + } + knownFlags = s.openFlags(); + }), (st, m) -> { })); + } + + private void showFlags(List list) { + flags.removeAll(); + if (list.isEmpty()) { + flags.add(Desk.empty("Nothing to review.")); + } + for (final FlagDto fl : list) { + MultiButton b = Desk.row(fl.displayName() + " (" + fl.kind() + ")", fl.reason(), + fl.reporter() == null || fl.reporter().length() == 0 ? "raised by the server" + : "reported by " + fl.reporter()); + b.setUIID(Flag.AUTO.equals(fl.kind()) ? "DeskRowAlert" : "DeskRowWarn"); + b.setName("flag-" + fl.id()); + b.addActionListener(e -> player(fl.username(), fl.id())); + flags.add(b); + } + form.revalidate(); + } + + private Container playersTab() { + final TextField q = new TextField("", "Search name or email", 20, TextField.ANY); + q.setUIID("DeskInput"); + q.setName("playerSearch"); + q.addActionListener(e -> search(q.getText().trim())); + Container c = new Container(new BorderLayout()); + c.setUIID("DeskPage"); + c.add(BorderLayout.NORTH, Desk.field("Search", q)).add(BorderLayout.CENTER, players); + return c; + } + + private void search(String query) { + Api.admin().players(query, Net.to(list -> Ui.later(() -> { + players.removeAll(); + for (final PlayerDto p : list) { + MultiButton b = Desk.row(p.displayName(), p.username() + " " + p.trophies() + " trophies", + status(p)); + if (p.flagged()) { + b.setUIID("DeskRowWarn"); + } + b.setName("player-" + p.username()); + b.addActionListener(e -> player(p.username(), null)); + players.add(b); + } + form.revalidate(); + }), Ui::failed)); + } + + private static String joined(List names) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < names.size(); i++) { + sb.append(i == 0 ? "" : ", ").append(names.get(i)); + } + return sb.toString(); + } + + private void showLive(List list) { + live.removeAll(); + if (list.isEmpty()) { + live.add(Desk.empty("No races being run.")); + } + for (final LiveRaceDto m : list) { + MultiButton b = Desk.row(m.level() + ": " + joined(m.names()), "leading: " + m.leader() + + " " + (m.tick() / 60) + "s" + (m.bots() > 0 ? " (" + m.bots() + " bots)" : ""), null); + b.setName("live-" + m.id()); + b.addActionListener(e -> RaceLobby.spectate(m.id())); + live.add(b); + } + form.revalidate(); + } + + private void showAudit(List list) { + audit.removeAll(); + for (AuditDto a : list) { + MultiButton b = Desk.row(a.action() + " " + a.username(), a.reason(), "by " + a.admin()); + audit.add(b); + } + form.revalidate(); + } + + /// One player, everything known about them, and what can be done. + private void player(final String username, final String flagId) { + Api.admin().player(username, Net.to(d -> Ui.later(() -> showPlayer(d, flagId)), Ui::failed)); + } + + private void showPlayer(final PlayerDetailDto d, final String flagId) { + final PlayerDto p = d.player(); + Form f = new Form(p.displayName(), new BorderLayout()); + f.setName("adminPlayer"); + f.setUIID("DeskForm"); + f.getToolbar().setUIID("DeskToolbar"); + f.getToolbar().getTitleComponent().setUIID("DeskTitle"); + Ui.back(f, AdminConsole::show); + Container page = Desk.page(); + f.add(BorderLayout.CENTER, page); + Container facts = Desk.panel(); + facts.add(Desk.field("Account", new Label(p.username(), "DeskValue"))); + facts.add(Desk.field("Record", new Label(p.trophies() + " trophies, " + p.wins() + " won, " + p.losses() + + " lost", "DeskValue"))); + facts.add(Desk.field("Wallet", new Label(p.coins() + " coins, " + p.gems() + " gems", "DeskValue"))); + facts.add(Desk.field("Standing", new Label(status(p), p.suspended() ? "DeskValueAlert" : "DeskValue"))); + page.add(facts); + if (flagId != null) { + page.add(Desk.actions(Desk.primary("Resolve this flag", "resolveFlag", e -> Api.admin().resolve(flagId, + Net.to(done -> Ui.later(() -> { + Ui.toast("Resolved"); + AdminConsole.show(); + }), Ui::failed))))); + } + page.add(Desk.section("Actions")); + page.add(Desk.actions( + act(p, Moderation.WARN, "Warn", 0), + act(p, p.muted() ? Moderation.UNMUTE : Moderation.MUTE, p.muted() ? "Unmute" : "Mute", 0), + act(p, Moderation.SUSPEND, "Suspend 3 days", 3), + act(p, Moderation.RESET_TROPHIES, "Reset trophies", 0), + act(p, Moderation.REVOKE_ITEMS, "Revoke items", 0), + act(p, p.suspended() ? Moderation.UNBAN : Moderation.BAN, p.suspended() ? "Lift ban" : "Ban", 0))); + section(page, "Flags", d.flags().size()); + for (FlagDto fl : d.flags()) { + page.add(new Label(fl.kind() + ": " + fl.reason(), fl.open() ? "DeskValueAlert" : "DeskValue")); + } + section(page, "Races", d.races().size()); + for (final RaceSummaryDto m : d.races()) { + MultiButton b = Desk.row(HistoryForm.place(m.place()) + " of " + m.runners() + " on " + m.level(), + m.score() + " points, " + (m.trophyChange() >= 0 ? "+" : "") + m.trophyChange() + " trophies", + null); + b.addActionListener(e -> ReplayForm.load(m.id())); + page.add(b); + } + section(page, "Purchases", d.purchases().size()); + for (String purchase : d.purchases()) { + page.add(new Label(purchase, "DeskValue")); + } + section(page, "Moderation history", d.actions().size()); + for (AuditDto a : d.actions()) { + page.add(new Label(a.action() + ": " + a.reason(), "DeskValue")); + } + f.show(); + } + + private Button act(final PlayerDto p, final String action, final String text, final int days) { + ActionListener go = e -> { + TextField reason = new TextField("", "Reason (kept in the audit log)", 30, TextField.ANY); + reason.setName("reason"); + if (Dialog.show(text + " " + p.displayName() + "?", reason, new Command("Cancel"), + new Command("Confirm")).getCommandName().equals("Confirm")) { + Api.admin().act(p.username(), new AdminActionDto(action, reason.getText().trim(), days), + Net.to(updated -> Ui.later(() -> { + Ui.toast(text + ": done"); + player(p.username(), null); + }), Ui::failed)); + } + }; + Button b = Moderation.BAN.equals(action) ? Desk.danger(text, "act-" + action, go) + : Desk.button(text, "act-" + action, go); + return b; + } + // end::dash-admin[] + + private static String status(PlayerDto p) { + StringBuilder sb = new StringBuilder(); + if (p.suspended()) { + sb.append("suspended "); + } + if (p.muted()) { + sb.append("muted "); + } + if (p.flagged()) { + sb.append("flagged "); + } + if (p.admin()) { + sb.append("admin "); + } + return sb.length() == 0 ? "in good standing" : sb.toString().trim(); + } + + private static void section(Container page, String title, int count) { + page.add(Desk.section(title + " (" + count + ")")); + } + + /// Wider than a phone held sideways: a desktop window or a tablet. + static boolean wide() { + return CN.isDesktop() || CN.isTablet() + || CN.getDisplayWidth() > CN.convertToPixels(160); + } + + /// The action and flag names, as the server spells them. + static final class Moderation { + static final String WARN = "WARN"; + static final String MUTE = "MUTE"; + static final String UNMUTE = "UNMUTE"; + static final String SUSPEND = "SUSPEND"; + static final String BAN = "BAN"; + static final String UNBAN = "UNBAN"; + static final String RESET_TROPHIES = "RESET_TROPHIES"; + static final String REVOKE_ITEMS = "REVOKE_ITEMS"; + + private Moderation() { + } + } + + static final class Flag { + static final String AUTO = "AUTO"; + + private Flag() { + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Conditions.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Conditions.java new file mode 100644 index 00000000000..4093236e65a --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Conditions.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.Component; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.api.ConditionDto; +import java.util.ArrayList; +import java.util.List; + +/// The list of conditions a query or a pipeline is made of: "days away is +/// at least 7", "purchases equals 0". Each line is a field, a comparison and +/// a number; a player must meet every line. The fields are the server's +/// (`social.Audience`), which refuses anything else. +// tag::dash-conditions[] +final class Conditions { + static final String[] FIELDS = {"daysAway", "daysSinceJoin", "trophies", "races", "wins", "gems", "coins", + "purchases", "friends"}; + static final String[] FIELD_NAMES = {"Days away", "Days since joining", "Trophies", "Races", "Wins", "Gems", + "Coins", "Purchases", "Friends invited"}; + /// The same names as they read inside a sentence. + static final String[] FIELD_WORDS = {"days away", "days since joining", "trophies", "races", "wins", "gems", + "coins", "purchases", "friends invited"}; + static final String[] OPS = {">=", "<=", "=", ">", "<", "!="}; + static final String[] OP_NAMES = {"at least", "at most", "equals", "more than", "less than", "is not"}; + static final int MAX = 6; + + private final List lines = new ArrayList<>(); + private final Container box = new Container(BoxLayout.y()); + private final String name; + + Conditions(String name, List start) { + this.name = name; + if (start != null) { + lines.addAll(start); + } + box.setName(name); + build(); + } + + Container component() { + return box; + } + + List value() { + return new ArrayList<>(lines); + } + + /// "days away at least 7 and purchases equals 0", for a list. + static String describe(List conditions) { + StringBuilder sb = new StringBuilder(); + for (ConditionDto c : conditions) { + if (sb.length() > 0) { + sb.append(" and "); + } + sb.append(FIELD_WORDS[index(FIELDS, c.field())]).append(' ') + .append(OP_NAMES[index(OPS, c.op())]).append(' ').append(c.value()); + } + return sb.length() == 0 ? "every player" : sb.toString(); + } + + private void build() { + box.removeAll(); + for (int i = 0; i < lines.size(); i++) { + box.add(line(i)); + } + if (lines.size() < MAX) { + Button add = Desk.button("Add condition", name + "-add", e -> { + lines.add(new ConditionDto("daysAway", ">=", 7)); + build(); + }); + FontImage.setMaterialIcon(add, FontImage.MATERIAL_ADD); + box.add(Desk.actions(add)); + } + if (box.getComponentForm() != null) { + box.getComponentForm().revalidate(); + } + } + + private Container line(final int i) { + ConditionDto c = lines.get(i); + Component field = Desk.choice(name + "-field-" + i, FIELD_NAMES, index(FIELDS, c.field()), + v -> set(i, FIELDS[v], null, null)); + Component op = Desk.choice(name + "-op-" + i, OP_NAMES, index(OPS, c.op()), v -> set(i, null, OPS[v], null)); + Component value = Desk.number(name + "-value-" + i, c.value(), v -> set(i, null, null, v)); + Container fields; + if (AdminConsole.wide()) { + fields = GridLayout.encloseIn(3, field, op, value); + } else { + // A phone has no room for three pickers side by side: the field + // on its own line, the comparison under it. + fields = BoxLayout.encloseY(field, GridLayout.encloseIn(2, op, value)); + } + Button remove = Desk.button("", name + "-remove-" + i, e -> { + lines.remove(i); + build(); + }); + FontImage.setMaterialIcon(remove, FontImage.MATERIAL_CLOSE); + Container row = new Container(new BorderLayout()); + row.setUIID("DeskConditionRow"); + // One width for "Where" and "and", so the rows' fields line up. + com.codename1.ui.Label joint = new com.codename1.ui.Label(i == 0 ? "Where" : "and", "DeskLabel"); + joint.setPreferredW(new com.codename1.ui.Label("Where", "DeskLabel").getPreferredW()); + row.add(BorderLayout.WEST, joint); + row.add(BorderLayout.CENTER, fields).add(BorderLayout.EAST, remove); + return row; + } + + private void set(int i, String field, String op, Integer value) { + ConditionDto c = lines.get(i); + lines.set(i, new ConditionDto(field == null ? c.field() : field, op == null ? c.op() : op, + value == null ? c.value() : value)); + } + + static int index(String[] all, String s) { + for (int i = 0; i < all.length; i++) { + if (all[i].equals(s)) { + return i; + } + } + return 0; + } +} +// end::dash-conditions[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Desk.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Desk.java new file mode 100644 index 00000000000..d86c9bddefe --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/Desk.java @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.LineChart; +import com.codename1.components.MultiButton; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.spinner.Picker; +import java.util.List; + +/// The console's own widgets. The game is all night track and neon; the +/// console is a tool, so it looks like a desktop application: a light page, +/// a side bar, plain fields in one aligned column and charts. Every look is +/// a `Desk*` style in `theme.css`. +// tag::dash-desk[] +final class Desk { + /// The width of the label column on a phone; a wide window gets more. + private static final float LABEL_MM = 24f; + private static final float WIDE_LABEL_MM = 34f; + + interface Text { + void set(String value); + } + + interface Choice { + void set(int index); + } + + private Desk() { + } + + static Container page() { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskPage"); + c.setScrollableY(true); + return c; + } + + static Label section(String title) { + return new Label(title, "DeskSection"); + } + + /// A white panel, the way a settings window groups its fields. + static Container panel() { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskPanel"); + return c; + } + + static SpanLabel help(String text) { + SpanLabel l = new SpanLabel(text); + l.setUIID("DeskHelpBox"); + l.setTextUIID("DeskHelp"); + return l; + } + + // tag::dash-desk-field[] + /// A label and its field on one line, the label in a column of its own, + /// right aligned, so every field of a form starts at the same place. + static Container field(String label, Component input) { + Label l = new Label(label, "DeskLabel"); + l.setPreferredW(CN.convertToPixels(AdminConsole.wide() ? WIDE_LABEL_MM : LABEL_MM)); + Container c = new Container(new BorderLayout()); + c.setUIID("DeskFieldRow"); + c.add(BorderLayout.WEST, l).add(BorderLayout.CENTER, input); + return c; + } + // end::dash-desk-field[] + + static TextField text(String name, String value, String hint, int max, final Text sink) { + final TextField t = new TextField(value == null ? "" : value, hint, 20, TextArea.ANY); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(max); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + return t; + } + + static TextArea area(String name, String value, int max, final Text sink) { + final TextArea t = new TextArea(value == null ? "" : value, 3, 20); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(max); + t.setGrowByContent(true); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + return t; + } + + static TextField number(String name, int value, final Choice sink) { + final TextField t = new TextField(String.valueOf(value), "", 6, TextArea.NUMERIC); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(7); + t.addDataChangedListener((type, index) -> { + try { + sink.set(Integer.parseInt(t.getText().trim())); + } catch (NumberFormatException notYet) { + // Blank or half typed: keep the last number, Save checks it. + } + }); + return t; + } + + static Picker choice(String name, final String[] options, int selected, final Choice sink) { + final Picker p = new Picker(); + p.setType(com.codename1.ui.Display.PICKER_TYPE_STRINGS); + p.setStrings(options); + p.setSelectedString(options[Math.max(0, Math.min(options.length - 1, selected))]); + p.setUIID("DeskInput"); + p.setName(name); + p.addActionListener(e -> { + for (int i = 0; i < options.length; i++) { + if (options[i].equals(p.getSelectedString())) { + sink.set(i); + return; + } + } + }); + return p; + } + + static Button button(String text, String name, ActionListener go) { + return make(text, "DeskButton", name, go); + } + + static Button primary(String text, String name, ActionListener go) { + return make(text, "DeskPrimary", name, go); + } + + static Button danger(String text, String name, ActionListener go) { + return make(text, "DeskDanger", name, go); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Button make(String text, String uiid, String name, ActionListener go) { + Button b = new Button(text, uiid); + b.setName(name); + b.addActionListener(go); + return b; + } + + /// Buttons in a row at the right, under the fields they act on. + static Container actions(Component... buttons) { + Container c = new Container(new FlowLayout(Component.RIGHT)); + c.setUIID("DeskActions"); + for (Component b : buttons) { + c.add(b); + } + return c; + } + + /// A number with its name under it. + static Container card(String name, String value, String detail) { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskCard"); + c.setName("stat-" + name); + c.add(new Label(value, "DeskCardValue")); + c.add(new Label(name, "DeskCardName")); + if (detail != null) { + c.add(new Label(detail, "DeskCardDetail")); + } + return c; + } + + static Container cards(int columns, Component... cards) { + Container c = new Container(new GridLayout(columns)); + for (Component card : cards) { + c.add(card); + } + return c; + } + + static MultiButton row(String line1, String line2, String line3) { + MultiButton b = new MultiButton(line1); + b.setUIID("DeskRow"); + b.setUIIDLine1("DeskRowTitle"); + b.setUIIDLine2("DeskRowDetail"); + b.setUIIDLine3("DeskRowDetail"); + if (line2 != null) { + b.setTextLine2(line2); + } + if (line3 != null) { + b.setTextLine3(line3); + } + return b; + } + + static Label empty(String text) { + return new Label(text, "DeskEmpty"); + } + + /// One row of a table: cells of equal width. + static Container tableRow(boolean header, String... cells) { + Container c = new Container(new GridLayout(1, cells.length)); + c.setUIID(header ? "DeskTableHeader" : "DeskTableRow"); + for (String cell : cells) { + c.add(new Label(cell, header ? "DeskTableHead" : "DeskCell")); + } + return c; + } + // end::dash-desk[] + + // tag::dash-desk-charts[] + /// A line chart of daily numbers, drawn by Codename One's charts + /// package. `labels` names the days along the bottom. + static Component line(String name, String[] series, List[] values, String[] labels) { + if (labels.length == 0) { + return named(name, empty("No data yet.")); + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYMultipleSeriesRenderer r = renderer(); + int[] colors = colors(); + double most = 1; + for (int s = 0; s < series.length; s++) { + XYSeries xy = new XYSeries(series[s]); + for (int i = 0; i < values[s].size(); i++) { + xy.add(i, values[s].get(i)); + most = Math.max(most, values[s].get(i)); + } + data.addSeries(xy); + XYSeriesRenderer line = new XYSeriesRenderer(); + line.setColor(colors[s % colors.length]); + line.setLineWidth(CN.convertToPixels(0.5f)); + r.addSeriesRenderer(line); + } + axis(r, labels, most); + // The chart's own legend is drawn in its bottom margin and can fall + // outside the box; a row of labels under it always shows. + r.setShowLegend(false); + Component chart = wrap(name, new ChartComponent(new LineChart(data, r))); + if (series.length < 2) { + return chart; + } + Container legend = new Container(new FlowLayout(Component.CENTER)); + for (int s = 0; s < series.length; s++) { + Style dot = new Style(UIManager.getInstance().getComponentStyle("DeskHelp")); + dot.setFgColor(colors[s % colors.length]); + legend.add(new Label(series[s], FontImage.createMaterial(FontImage.MATERIAL_CIRCLE, dot, 2), "DeskHelp")); + } + return BoxLayout.encloseY(chart, legend); + } + + /// Bars, one per named count: a funnel, a distribution. + static Component bars(String name, List labels, List values) { + if (values.isEmpty()) { + return named(name, empty("No data yet.")); + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYMultipleSeriesRenderer r = renderer(); + XYSeries xy = new XYSeries(name); + double most = 1; + String[] names = new String[labels.size()]; + for (int i = 0; i < values.size(); i++) { + xy.add(i, values.get(i)); + most = Math.max(most, values.get(i)); + names[i] = labels.get(i); + } + data.addSeries(xy); + XYSeriesRenderer bar = new XYSeriesRenderer(); + bar.setColor(colors()[0]); + bar.setDisplayChartValues(true); + bar.setChartValuesTextSize(CN.convertToPixels(2.4f)); + r.addSeriesRenderer(bar); + r.setBarSpacing(0.4); + axis(r, names, most); + r.setXAxisMin(-0.6); + r.setXAxisMax(values.size() - 0.4); + r.setShowLegend(false); + return wrap(name, new ChartComponent(new BarChart(data, r, BarChart.Type.DEFAULT))); + } + + private static Component named(String name, Component c) { + c.setName(name); + return c; + } + + private static XYMultipleSeriesRenderer renderer() { + Style page = UIManager.getInstance().getComponentStyle("DeskChart"); + XYMultipleSeriesRenderer r = new XYMultipleSeriesRenderer(); + int text = page.getFgColor(); + r.setApplyBackgroundColor(true); + r.setBackgroundColor(0xff000000 | page.getBgColor()); + r.setMarginsColor(0xff000000 | page.getBgColor()); + r.setAxesColor(0xff000000 | UIManager.getInstance().getComponentStyle("DeskChartGrid").getFgColor()); + r.setGridColor(0xff000000 | UIManager.getInstance().getComponentStyle("DeskChartGrid").getFgColor()); + r.setLabelsColor(0xff000000 | text); + r.setXLabelsColor(0xff000000 | text); + r.setYLabelsColor(0, 0xff000000 | text); + r.setShowGrid(true); + r.setLabelsTextSize(CN.convertToPixels(2.4f)); + r.setLegendTextSize(CN.convertToPixels(2.4f)); + int m = CN.convertToPixels(3); + r.setMargins(new int[] {m, m * 2, m * 2, m}); + r.setPanEnabled(false, false); + r.setZoomEnabled(false, false); + r.setYLabelsAlign(Component.RIGHT); + r.setYLabelsPadding(CN.convertToPixels(1)); + return r; + } + + private static void axis(XYMultipleSeriesRenderer r, String[] labels, double most) { + r.setXLabels(0); + // As many names along the bottom as fit the width without touching: + // seven dates fill a desktop chart, and overlap on a phone's. + int longest = 1; + for (String l : labels) { + longest = Math.max(longest, l.length()); + } + // A digit or letter of the label font is about 0.6 of its size, and + // 3 mm of air keeps neighbours apart. The plot is about 60% of the + // screen's width once the card and the axis margins are taken. + float labelPx = longest * 0.6f * CN.convertToPixels(2.4f) + CN.convertToPixels(3); + int fit = Math.max(2, Math.min(7, (int) (CN.getDisplayWidth() * 0.6f / labelPx))); + int every = Math.max(1, (labels.length + fit - 1) / fit); + for (int i = 0; i < labels.length; i += every) { + r.addXTextLabel(i, labels[i]); + } + r.setYAxisMin(0); + r.setYAxisMax(most * 1.15); + r.setYLabels(5); + } + + /// The series colours: the accent first. + private static int[] colors() { + UIManager m = UIManager.getInstance(); + return new int[] {0xff000000 | m.getComponentStyle("DeskChartA").getFgColor(), + 0xff000000 | m.getComponentStyle("DeskChartB").getFgColor(), + 0xff000000 | m.getComponentStyle("DeskChartC").getFgColor()}; + } + + private static Component wrap(String name, ChartComponent chart) { + chart.setName(name); + chart.setUIID("DeskChart"); + Container c = new Container(new BorderLayout()) { + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension(CN.convertToPixels(60), CN.convertToPixels(45)); + } + }; + c.setUIID("DeskChartBox"); + c.add(BorderLayout.CENTER, chart); + return c; + } + // end::dash-desk-charts[] +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PipelinesTab.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PipelinesTab.java new file mode 100644 index 00000000000..ed7d6689944 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PipelinesTab.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.PipelineDto; +import com.codenameone.examples.dash.api.QueryDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.ui.Ui; +import java.util.Date; +import java.util.List; + +/// Engagement pipelines: "when a player matches these conditions, do this, +/// at most once every N days". The server runs every enabled pipeline each +/// hour (`social.Pipelines`); this page lists them, edits them and runs one +/// at once. +/// +/// A push from a pipeline carries the campaign `pipeline-`, so how +/// many players it brought back is read in Codename One analytics next to +/// every other campaign. +// tag::dash-pipelines-tab[] +final class PipelinesTab { + static final String[] ACTIONS = {"push", "gems"}; + static final String[] ACTION_NAMES = {"Send a push", "Give gems, with a push"}; + + private final Container page = Desk.page(); + + PipelinesTab() { + page.setName("pipelines"); + } + + Container page() { + return page; + } + + /// Set by the query tool just before it selects this tab: selecting a tab + /// refreshes it, and the list arriving afterwards would replace the editor. + private PipelineDto pending; + + void refresh() { + if (pending != null) { + PipelineDto start = pending; + pending = null; + edit(start); + return; + } + Api.admin().pipelines(Net.to(list -> Ui.later(() -> show(list)), Ui::failed)); + } + + private void show(List list) { + page.removeAll(); + Container head = new Container(new BorderLayout()); + head.add(BorderLayout.CENTER, Desk.section("Pipelines")); + Button add = Desk.primary("New pipeline", "pipelineNew", e -> edit(blank(null))); + FontImage.setMaterialIcon(add, FontImage.MATERIAL_ADD); + head.add(BorderLayout.EAST, add); + page.add(head); + page.add(Desk.help("Each pipeline runs every hour. A player it acts on is left alone for its cool-down, " + + "so nobody hears the same thing twice in a row.")); + if (list.isEmpty()) { + page.add(Desk.empty("No pipelines yet.")); + } + for (final PipelineDto p : list) { + page.add(card(p)); + } + page.revalidate(); + } + + private Container card(final PipelineDto p) { + Container c = Desk.panel(); + c.setName("pipeline-" + p.name()); + Container title = new Container(new BorderLayout()); + title.add(BorderLayout.CENTER, new Label(p.name(), "DeskPanelTitle")); + title.add(BorderLayout.EAST, new Label(p.enabled() ? "On" : "Off", p.enabled() ? "DeskBadgeOn" : "DeskBadge")); + c.add(title); + c.add(Desk.field("When", new Label(Conditions.describe(p.conditions()), "DeskValue"))); + c.add(Desk.field("Then", new Label("gems".equals(p.action()) + ? "give " + p.amount() + " gems: \"" + p.title() + "\"" : "push \"" + p.title() + "\"", "DeskValue"))); + c.add(Desk.field("At most", new Label("once every " + p.cooldownDays() + " days a player", "DeskValue"))); + c.add(Desk.field("Reached", new Label(p.reached() + " players" + + (p.lastRun() == 0 ? ", not run yet" : ", last run " + new SimpleDateFormat("MMM d, HH:mm").format(new Date(p.lastRun()))), "DeskValue"))); + c.add(Desk.actions( + Desk.danger("Delete", "pipelineDelete-" + p.name(), e -> { + if (Dialog.show("Delete pipeline", "Delete \"" + p.name() + "\"?", "Delete", "Cancel")) { + Api.admin().deletePipeline(p.id(), Net.to(x -> Ui.later(this::refresh), Ui::failed)); + } + }), + Desk.button("Edit", "pipelineEdit-" + p.name(), e -> edit(p)), + Desk.button("Run now", "pipelineRun-" + p.name(), e -> Api.admin().runPipeline(p.id(), + Net.to(done -> Ui.later(() -> { + Ui.toast("Reached " + (done.reached() - p.reached()) + " players"); + refresh(); + }), Ui::failed))))); + return c; + } + + static PipelineDto blank(List conditions) { + return new PipelineDto(null, "", true, conditions == null ? java.util.Collections.emptyList() + : conditions, "push", "", "", "", 20, 14, 0, 0L); + } + + /// The editor, in place of the list. Every field is in one aligned + /// column; the count tells the operator who it would reach today. + void editNext(PipelineDto start) { + pending = start; + } + + void edit(PipelineDto start) { + final PipelineDto[] d = {start}; + final Conditions when = new Conditions("pipelineWhen", start.conditions()); + page.removeAll(); + page.add(Desk.section(start.id() == null ? "New pipeline" : "Edit " + start.name())); + Container form = Desk.panel(); + form.add(Desk.field("Name", Desk.text("pipelineName", start.name(), "Win back", 40, + v -> d[0] = with(d[0], v, null, null, null, null, null, -1, -1)))); + final Switch on = new Switch(); + on.setValue(start.enabled()); + on.setName("pipelineEnabled"); + form.add(Desk.field("Enabled", on)); + form.add(Desk.field("When", when.component())); + form.add(Desk.field("Then", Desk.choice("pipelineAction", ACTION_NAMES, + Conditions.index(ACTIONS, start.action()), i -> d[0] = with(d[0], null, ACTIONS[i], null, null, null, + null, -1, -1)))); + form.add(Desk.field("Gems", Desk.number("pipelineGems", start.amount(), + v -> d[0] = with(d[0], null, null, null, null, null, null, v, -1)))); + form.add(Desk.field("Title", Desk.text("pipelineTitle", start.title(), "Your troops miss you", 60, + v -> d[0] = with(d[0], null, null, v, null, null, null, -1, -1)))); + form.add(Desk.field("Message", Desk.area("pipelineBody", start.body(), 200, + v -> d[0] = with(d[0], null, null, null, v, null, null, -1, -1)))); + form.add(Desk.field("Opens", Desk.choice("pipelineScreen", PromoteTab.SCREEN_NAMES, + Conditions.index(PromoteTab.SCREENS, start.screen()), + i -> d[0] = with(d[0], null, null, null, null, PromoteTab.SCREENS[i], null, -1, -1)))); + form.add(Desk.field("Cool-down", Desk.number("pipelineCooldown", start.cooldownDays(), + v -> d[0] = with(d[0], null, null, null, null, null, null, -1, v)))); + form.add(Desk.help("Gems is used only when the pipeline gives gems. The cool-down is in days.")); + final Label count = new Label("", "DeskHelp"); + count.setName("pipelineCount"); + form.add(count); + form.add(Desk.actions( + Desk.button("Cancel", "pipelineCancel", e -> refresh()), + Desk.button("Count players", "pipelineCountButton", e -> Api.admin().query( + new QueryDto(when.value()), Net.to(r -> Ui.later(() -> { + count.setText(r.count() + " players match today"); + page.revalidate(); + }), Ui::failed))), + Desk.primary("Save", "pipelineSave", e -> { + PipelineDto p = d[0]; + if (p.name().trim().length() == 0 + || "push".equals(p.action()) && p.title().trim().length() == 0) { + Ui.toast("Give the pipeline a name, and its push a title"); + return; + } + if (p.cooldownDays() < 1 || "gems".equals(p.action()) && p.amount() < 1) { + Ui.toast("Set a cool-down of at least a day, and the gems to give"); + return; + } + PipelineDto save = new PipelineDto(p.id(), p.name(), on.isValue(), when.value(), p.action(), + p.title(), p.body(), p.screen(), p.amount(), p.cooldownDays(), p.reached(), p.lastRun()); + Api.admin().savePipeline(save, Net.to(x -> Ui.later(() -> { + Ui.toast("Saved"); + refresh(); + }), Ui::failed)); + }))); + page.add(form); + page.revalidate(); + } + + private static PipelineDto with(PipelineDto p, String name, String action, String title, String body, + String screen, Boolean enabled, int amount, int cooldown) { + return new PipelineDto(p.id(), name == null ? p.name() : name, enabled == null ? p.enabled() : enabled, + p.conditions(), action == null ? p.action() : action, title == null ? p.title() : title, + body == null ? p.body() : body, screen == null ? p.screen() : screen, amount < 0 ? p.amount() : amount, + cooldown < 0 ? p.cooldownDays() : cooldown, p.reached(), p.lastRun()); + } +} +// end::dash-pipelines-tab[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PromoteTab.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PromoteTab.java new file mode 100644 index 00000000000..96a3bebfff2 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/PromoteTab.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.analytics.invite.Invite; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Label; +import com.codenameone.examples.dash.api.BroadcastDto; +import com.codenameone.examples.dash.api.InviterDto; +import com.codenameone.examples.dash.api.PromoteDto; +import com.codenameone.examples.dash.api.SpotlightDto; +import com.codenameone.examples.dash.engage.Friends; +import com.codenameone.examples.dash.forge.Fields; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.ui.Ui; +import java.util.Date; + +/// The console's Promote tab: how a game developer brings players in and back. +/// +/// The reports, the query tool and the pipelines have pages of their own; +/// this one is the hand-sent side. The numbers here are the game's own +/// economy: who joined through an invite and who was paid for it. Behaviour +/// inside the app, and what each campaign brought back, is in Codename One +/// analytics. +/// +/// - **Broadcast** sends a Codename One push to a segment of players, with the +/// screen a tap opens and a campaign name. The app reports the tap under +/// that campaign, so the console shows what each broadcast brought back. +/// - **Spotlight** is the banner on every player's home screen. +/// - **Campaign link** makes an invite link for an advert or a post, with no +/// player's code in it; everyone it brings in is reported under its +/// campaign. +// tag::dash-promote-tab[] +final class PromoteTab { + private static final String[] SEGMENTS = {"all", "lapsed", "new", "invited"}; + private static final String[] SEGMENT_NAMES = {"Everyone", "Away for a while", "Joined recently", + "Came through an invite"}; + static final String[] SCREENS = {"", "shop", "forge", "friends", "runner", "race", "daily", "crews"}; + static final String[] SCREEN_NAMES = {"Home", "Shop", "Forge", "Friends", "Runner", "Quick race", "Daily run", "Crews"}; + + private final Container col = Desk.page(); + private String segment = SEGMENTS[0]; + private int days = 7; + private String title = ""; + private String body = ""; + private String screen = ""; + private String campaign = ""; + private String spotText = ""; + private String spotPack = ""; + private String spotScreen = ""; + private String linkCampaign = "launch"; + + PromoteTab() { + col.setName("promote"); + } + + Container column() { + return col; + } + + void refresh() { + Api.admin().promote(Net.to(p -> Ui.later(() -> show(p)), Ui::failed)); + } + + private void show(PromoteDto p) { + col.removeAll(); + broadcastComposer(); + if (!p.broadcasts().isEmpty()) { + col.add(Desk.section("Sent")); + col.add(Desk.tableRow(true, "Title", "Campaign", "Players", "Sent")); + SimpleDateFormat fmt = new SimpleDateFormat("MMM d, HH:mm"); + for (BroadcastDto b : p.broadcasts()) { + col.add(Desk.tableRow(false, b.title(), b.campaign(), String.valueOf(b.targets()), + fmt.format(new Date(b.sentAt())))); + } + } + spotlightEditor(p.spotlight()); + campaignLink(); + col.add(Desk.section("Invitations")); + col.add(Desk.cards(AdminConsole.wide() ? 4 : 2, + Desk.card("Players", String.valueOf(p.players()), null), + Desk.card("New this week", String.valueOf(p.joinedThisWeek()), null), + Desk.card("Invited", String.valueOf(p.invited()), "joined through a link"), + Desk.card("Played", String.valueOf(p.invitedPlayed()), "of those, ran a race"))); + if (!p.inviters().isEmpty()) { + col.add(Desk.tableRow(true, "Top inviters", "Joined", "Played")); + for (InviterDto i : p.inviters()) { + col.add(Desk.tableRow(false, i.displayName(), String.valueOf(i.joined()), String.valueOf(i.played()))); + } + } + col.revalidate(); + } + + private void broadcastComposer() { + col.add(Desk.section("Broadcast a push")); + Container form = Desk.panel(); + form.add(Desk.field("To", Desk.choice("promoteSegment", SEGMENT_NAMES, Fields.indexOf(SEGMENTS, segment), + i -> segment = SEGMENTS[i]))); + form.add(Desk.field("Days", Desk.number("promoteDays", days, v -> days = v))); + form.add(Desk.field("Title", Desk.text("promoteTitle", title, "Season two is here", 60, v -> title = v))); + form.add(Desk.field("Message", Desk.area("promoteBody", body, 200, v -> body = v))); + form.add(Desk.field("Opens", Desk.choice("promoteScreen", SCREEN_NAMES, Fields.indexOf(SCREENS, screen), + i -> screen = SCREENS[i]))); + form.add(Desk.field("Campaign", Desk.text("promoteCampaign", campaign, "season-two", 40, + v -> campaign = v))); + form.add(Desk.help("Days counts back from today: how long a lapsed player has been away, or how " + + "recently a new one joined. The campaign names this push in analytics. For a push that goes " + + "out by itself whenever a player qualifies, make a pipeline.")); + form.add(Desk.actions( + Desk.button("Count players", "promoteCount", e -> Api.admin().audience(draft(), Net.to( + b -> Ui.later(() -> Ui.toast(b.targets() + " players would get this")), Ui::failed))), + Desk.primary("Send", "promoteSend", e -> send()))); + col.add(form); + } + + private BroadcastDto draft() { + return new BroadcastDto(null, segment, days, title.trim(), body.trim(), screen, campaign.trim(), 0, + null, 0L); + } + + /// Counted first and confirmed: a push to every player cannot be taken + /// back, and the server allows only a few a day. + private void send() { + final BroadcastDto b = draft(); + Api.admin().audience(b, Net.to(counted -> Ui.later(() -> { + if (!Dialog.show("Send push", "Send \"" + b.title() + "\" to " + counted.targets() + " players?", + "Send", "Cancel")) { + return; + } + Api.admin().broadcast(b, Net.to(sent -> Ui.later(() -> { + Ui.toast("Sent to " + sent.targets() + " players"); + title = ""; + body = ""; + campaign = ""; + refresh(); + }), Ui::failed)); + }), Ui::failed)); + } + + private void spotlightEditor(SpotlightDto current) { + if (current != null) { + spotText = current.text() == null ? "" : current.text(); + spotPack = current.packId() == null ? "" : current.packId(); + spotScreen = current.screen() == null ? "" : current.screen(); + } + col.add(Desk.section("Home spotlight")); + Container form = Desk.panel(); + form.add(Desk.field("Banner", Desk.text("spotlightText", spotText, "", 140, v -> spotText = v))); + form.add(Desk.field("Forge pack", Desk.text("spotlightPack", spotPack, "pack id", 40, v -> spotPack = v))); + form.add(Desk.field("Opens", Desk.choice("spotlightScreen", SCREEN_NAMES, Fields.indexOf(SCREENS, spotScreen), + i -> spotScreen = SCREENS[i]))); + form.add(Desk.help("Shown at the top of every player's home screen. A pack id wins over the " + + "screen. Save an empty banner to take it down.")); + form.add(Desk.actions(Desk.primary("Save spotlight", "spotlightSave", e -> Api.admin().spotlight( + new SpotlightDto(spotText.trim(), spotPack.trim(), spotScreen, 0L), + Net.to(saved -> Ui.later(() -> Ui.toast("Spotlight saved")), Ui::failed))))); + col.add(form); + } + + private void campaignLink() { + col.add(Desk.section("Campaign link")); + Container form = Desk.panel(); + form.add(Desk.field("Campaign", Desk.text("linkCampaign", linkCampaign, "launch", 40, + v -> linkCampaign = v))); + form.add(Desk.help("An invite link for an advert, a post or a newsletter. It opens the store for " + + "anyone without the game, and the players it brings in are reported under its campaign.")); + final Container shown = new Container(); + form.add(shown); + form.add(Desk.actions(Desk.primary("Make link", "linkMake", e -> { + String name = linkCampaign.trim(); + if (name.length() == 0) { + Ui.toast("Name the campaign first"); + return; + } + Invite link = Friends.campaignLink(name); + Display.getInstance().copyToClipboard(link.getUrl()); + shown.removeAll(); + shown.add(Desk.field("Link", new Label(link.getUrl(), "DeskValue"))); + Ui.toast("Link copied"); + col.revalidate(); + }))); + col.add(form); + } +} +// end::dash-promote-tab[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/QueryTab.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/QueryTab.java new file mode 100644 index 00000000000..fc6d8b4157d --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/QueryTab.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.ConditionDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.QueryDto; +import com.codenameone.examples.dash.api.QueryResultDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.ui.Ui; +import java.util.Arrays; + +/// The query tool: which players meet a set of conditions, how many, and +/// who they are. The same conditions make a pipeline's audience, so an +/// operator tries a query here first, then turns it into a pipeline. +// tag::dash-query-tab[] +final class QueryTab { + private final Container page = Desk.page(); + /// Starts as "never bought anything": the players a first offer is for. + private final Conditions conditions = new Conditions("query", Arrays.asList( + new ConditionDto("purchases", "=", 0))); + private final Container results = new Container(BoxLayout.y()); + private final AdminConsole console; + + QueryTab(AdminConsole console) { + this.console = console; + page.setName("query"); + page.add(Desk.section("Find players")); + Container panel = Desk.panel(); + panel.add(conditions.component()); + panel.add(Desk.actions( + Desk.button("Make a pipeline", "queryToPipeline", e -> console.newPipeline(conditions.value())), + run())); + page.add(panel); + page.add(Desk.help("Days away counts from the last time the player opened the game. Purchases are store " + + "purchases; friends invited are players who joined through this player's link.")); + page.add(results); + } + + private Button run() { + Button b = Desk.primary("Run query", "queryRun", e -> Api.admin().query( + new QueryDto(conditions.value()), Net.to(r -> Ui.later(() -> show(r)), Ui::failed))); + FontImage.setMaterialIcon(b, FontImage.MATERIAL_SEARCH); + return b; + } + + Container page() { + return page; + } + + void refresh() { + // The query runs when asked: it reads every player. + } + + private void show(QueryResultDto r) { + results.removeAll(); + com.codename1.ui.Label count = Desk.section(r.count() == 1 ? "1 player" : r.count() + " players"); + count.setName("queryCount"); + results.add(count); + if (r.count() > r.players().size()) { + results.add(Desk.help("The first " + r.players().size() + ", longest away first.")); + } + results.add(Desk.tableRow(true, "Player", "Trophies", "Races", "Gems")); + for (final PlayerDto p : r.players()) { + Container row = Desk.tableRow(false, "", String.valueOf(p.trophies()), + String.valueOf(p.wins() + p.losses() + p.draws()), String.valueOf(p.gems())); + // The name is a link to everything the console knows about them. + Button name = new Button(p.displayName(), "DeskLink"); + name.setName("queryPlayer-" + p.username()); + name.addActionListener(e -> console.openPlayer(p.username())); + row.replace(row.getComponentAt(0), name, null); + results.add(row); + } + page.revalidate(); + } +} +// end::dash-query-tab[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/ReportsTab.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/ReportsTab.java new file mode 100644 index 00000000000..98da5ed4bbc --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/admin/ReportsTab.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.admin; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.api.AdminStatsDto; +import com.codenameone.examples.dash.api.InsightsDto; +import com.codenameone.examples.dash.api.NameCountDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.ui.Ui; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/// The console's first page: what is happening now, and how the game has +/// done over the last days -- players, new accounts, races, sales, the +/// funnel from joining to paying, retention and the trophy ladder. +/// +/// Every number is read from the game's own records by the server's +/// `Insights`. Behaviour inside the app (screens, sessions, shares, push +/// opens) is in Codename One analytics; the last button opens it. +// tag::dash-reports-tab[] +final class ReportsTab { + private static final String[] RANGES = {"Last 7 days", "Last 14 days", "Last 30 days", "Last 90 days"}; + private static final int[] RANGE_DAYS = {7, 14, 30, 90}; + private static final long DAY = 24L * 60 * 60 * 1000; + + private final Container page = Desk.page(); + private final Container now = new Container(BoxLayout.y()); + private final Container trends = new Container(BoxLayout.y()); + private int range = 2; + + ReportsTab() { + page.setName("reports"); + page.add(Desk.section("Right now")); + page.add(now); + Container head = new Container(new BorderLayout()); + head.add(BorderLayout.CENTER, Desk.section("Trends")); + head.add(BorderLayout.EAST, Desk.choice("reportRange", RANGES, range, i -> { + range = i; + loadTrends(); + })); + page.add(head); + page.add(trends); + } + + Container page() { + return page; + } + + void refresh() { + Api.admin().stats(Net.to(s -> Ui.later(() -> showNow(s)), Ui::failed)); + loadTrends(); + } + + private void loadTrends() { + final int asked = range; + Api.admin().reports(RANGE_DAYS[asked], Net.to(r -> Ui.later(() -> { + if (asked == range) { + showTrends(r); + } + }), Ui::failed)); + } + + private void showNow(AdminStatsDto s) { + now.removeAll(); + int columns = AdminConsole.wide() ? 4 : 2; + now.add(Desk.cards(columns, + Desk.card("Players", String.valueOf(s.players()), s.online() + " online"), + Desk.card("Live races", String.valueOf(s.liveRaces()), s.racesToday() + " today"), + Desk.card("Open flags", String.valueOf(s.openFlags()), s.suspended() + " suspended"), + Desk.card("Sales today", String.valueOf(s.purchasesToday()), s.gemsSoldToday() + " gems"))); + page.revalidate(); + } + + @SuppressWarnings("unchecked") + private void showTrends(InsightsDto r) { + trends.removeAll(); + int days = r.active().size(); + String[] labels = new String[days]; + SimpleDateFormat fmt = new SimpleDateFormat("MMM d"); + for (int i = 0; i < days; i++) { + labels[i] = fmt.format(new Date(r.firstDay() + i * DAY)); + } + Container charts = new Container(new GridLayout(AdminConsole.wide() ? 2 : 1)); + charts.add(titled("Daily players", "Ran a race or a level that day, and accounts created", + Desk.line("chartPlayers", new String[] {"Played", "Joined"}, + new List[] {r.active(), r.joined()}, labels))); + charts.add(titled("Races", "Finished races a day", + Desk.line("chartRaces", new String[] {"Races"}, new List[] {r.races()}, labels))); + charts.add(titled("Funnel", "Every player so far, by how far they got", + bars("chartFunnel", r.funnel()))); + charts.add(titled("Gems sold", "Through the store, a day", + Desk.line("chartGems", new String[] {"Gems"}, new List[] {r.gems()}, labels))); + charts.add(titled("Trophy ladder", "Players in each trophy band", bars("chartTrophies", r.trophies()))); + Container back = Desk.panel(); + back.add(new Label("Retention", "DeskPanelTitle")); + back.add(new Label("Seen again at least this long after joining", "DeskHelp")); + List cards = new ArrayList<>(); + for (NameCountDto d : r.retention()) { + cards.add(Desk.card(d.name(), d.count() + "%", null)); + } + if (cards.isEmpty()) { + back.add(Desk.empty("No players yet.")); + } else { + int columns = AdminConsole.wide() ? cards.size() : Math.min(2, cards.size()); + back.add(Desk.cards(columns, cards.toArray(new Component[cards.size()]))); + } + charts.add(back); + trends.add(charts); + trends.add(Desk.help("Read from the game's own records: accounts, races and purchases. What players " + + "do inside the app (screens, sessions, shared invites, opened pushes) is reported to Codename " + + "One analytics.")); + trends.add(Desk.actions(Desk.button("Open Codename One analytics", "openAnalytics", + e -> CN.execute(AppConfig.ANALYTICS_CONSOLE)))); + page.revalidate(); + } + + private static Component bars(String name, List counts) { + List names = new ArrayList<>(); + List values = new ArrayList<>(); + for (NameCountDto c : counts) { + names.add(c.name()); + values.add(c.count()); + } + return Desk.bars(name, names, values); + } + + private static Container titled(String title, String detail, Component chart) { + Container c = Desk.panel(); + c.add(new Label(title, "DeskPanelTitle")); + c.add(new Label(detail, "DeskHelp")); + c.add(chart); + return c; + } +} +// end::dash-reports-tab[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Ads.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Ads.java new file mode 100644 index 00000000000..0e4a070c4c0 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Ads.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.engage; + +import com.codename1.ads.AdConfig; +import com.codename1.ads.AdError; +import com.codename1.ads.AdFormat; +import com.codename1.ads.AdListener; +import com.codename1.ads.AdManager; +import com.codename1.ads.BannerAd; +import com.codename1.ads.InterstitialAd; +import com.codename1.ads.RewardedAd; +import com.codename1.ads.ServerSideVerificationOptions; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Dialog; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.net.Session; + +/// Advertising, through `com.codename1.ads`. Three placements, each the kind +/// players accept in a free game: +/// +/// - **Rewarded**: opt in, watch, get coins. The server grants the coins and +/// caps them per day, so a modified client gains nothing. +/// - **Interstitial**: between runs, at most every third one, never +/// during play and never for a Royal Pass holder. +/// - **Banner**: on the home screen only. +/// +/// The ad network is a cn1lib (AdMob, AppLovin, ...) registered with +/// [AdManager]; this class names none of them. Where no network is installed +/// -- the simulator, the web build, a fork that has not added one yet -- a +/// rewarded ad is a short stand-in dialog, so the reward flow can be built and +/// tested end to end, and the other placements are simply absent. +// tag::dash-ads[] +public final class Ads { + private static final int INTERSTITIAL_EVERY = 3; + private static int runsSinceAd; + + private Ads() { + } + + public static void start() { + if (AdManager.getProvider() != null) { + AdManager.initialize(new AdConfig().testMode(true), ready -> { }); + } + } + + /// Plays a rewarded ad and runs `granted` once the network says it was + /// watched to the end. + public static void rewarded(final Runnable granted, final Runnable unavailable) { + Telemetry.adWatched("rewarded_offer"); + if (!AdManager.isInitialized() || !AdManager.isSupported(AdFormat.REWARDED)) { + standIn(granted); + return; + } + final RewardedAd ad = new RewardedAd(AppConfig.REWARDED_AD_UNIT); + // Server-side verification: the network calls the game's server with + // this user id, so a reward could be granted there and nowhere else. + String who = Session.user() == null ? "" : Session.user().username(); + ad.setServerSideVerificationOptions(new ServerSideVerificationOptions(who, "coins")); + ad.setAdListener(new AdListener() { + @Override + public void onLoaded() { + ad.show(reward -> CN.callSerially(granted)); + } + + @Override + public void onFailedToLoad(AdError error) { + CN.callSerially(unavailable); + } + }); + ad.load(); + } + + /// Called after every run; shows an interstitial every few. + public static void betweenRuns(boolean adFree) { + runsSinceAd++; + if (adFree || runsSinceAd < INTERSTITIAL_EVERY || !AdManager.isInitialized() + || !AdManager.isSupported(AdFormat.INTERSTITIAL)) { + return; + } + runsSinceAd = 0; + final InterstitialAd ad = new InterstitialAd(AppConfig.INTERSTITIAL_AD_UNIT); + ad.setAdListener(new AdListener() { + @Override + public void onLoaded() { + ad.show(); + } + }); + ad.load(); + } + + /// A banner for the home screen, or null where there is no network. + public static Component banner(boolean adFree) { + if (adFree || !AdManager.isInitialized() || !AdManager.isSupported(AdFormat.BANNER)) { + return null; + } + BannerAd banner = new BannerAd(AppConfig.BANNER_AD_UNIT); + banner.load(); + return banner; + } + + private static void standIn(final Runnable granted) { + Dialog d = new Dialog("Sponsored", new com.codename1.ui.layouts.BorderLayout()); + d.setUIID("AdDialog"); + d.add(com.codename1.ui.layouts.BorderLayout.CENTER, + new com.codename1.components.SpanLabel("An ad would play here. Install an ad network cn1lib " + + "to show real ones.")); + d.setTimeout(1500); + d.show(); + granted.run(); + } +} +// end::dash-ads[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Friends.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Friends.java new file mode 100644 index 00000000000..8a93f83b28c --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Friends.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.engage; + +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.InviteAttribution; +import com.codename1.analytics.invite.InviteListener; +import com.codename1.analytics.invite.InviteRequest; +import com.codename1.analytics.invite.Invites; +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codenameone.examples.dash.api.InviteClaimDto; +import com.codenameone.examples.dash.api.InviteDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.ui.Rewards; +import com.codenameone.examples.dash.ui.Ui; + +/// Invite a friend, through Codename One's `com.codename1.analytics.invite`. +/// +/// The inviter's side mints a link with [Invites#create] -- the campaign, the +/// channel, and the player's own code from the server in the payload -- and +/// hands it to the native share sheet. The friend's side calls +/// [Invites#checkForInvite] on every start: a link that opened the installed +/// app, or one that went through the store first (the Play install referrer, +/// an iOS App Clip), comes back as an [InviteAttribution]. From then on every +/// analytics event the friend's app sends carries the campaign and the +/// referrer, so the invite funnel, retention and revenue per campaign are in +/// the Codename One console without the game counting anything itself. +/// +/// The server's part is the reward: the code is kept until the friend is +/// signed in, then claimed, and the inviter is paid once the friend has +/// played. [Invites#conversion] marks those steps in analytics too. +public final class Friends { + public static final String CAMPAIGN = "friends"; + private static final String PENDING = "dash.invite."; + + private Friends() { + } + + // tag::dash-invites[] + /// From the app's start(): pick up an invite this launch carries. + public static void start() { + Invites.setInviteListener(new InviteListener() { + @Override + public void inviteReceived(InviteAttribution a) { + if (a.getPayload() == null || a.getPayload().length() == 0) { + // A campaign link from the console: attributed in + // analytics, nobody to reward. + return; + } + Preferences.set(PENDING + "code", a.getPayload()); + Preferences.set(PENDING + "campaign", a.getCampaign() == null ? "" : a.getCampaign()); + Preferences.set(PENDING + "channel", a.getChannel() == null ? "" : a.getChannel()); + Preferences.set(PENDING + "match", a.getMatchType() == null ? "" : a.getMatchType()); + if (Session.user() != null) { + Ui.later(Friends::claimPending); + } + } + + @Override + public void attributionUnavailable(String reason) { + // The ordinary case: most installs were not invited. + } + }); + Invites.checkForInvite(); + } + // end::dash-invites[] + + /// Signed in: credit a waiting invite to this account, once. + public static void claimPending() { + final String code = Preferences.get(PENDING + "code", ""); + if (code.length() == 0 || Session.user() == null) { + return; + } + InviteClaimDto claim = new InviteClaimDto(code, Preferences.get(PENDING + "campaign", ""), + Preferences.get(PENDING + "channel", ""), Preferences.get(PENDING + "match", "")); + Api.social().claim(claim, Net.to(reward -> { + clear(); + Telemetry.inviteAccepted(); + Ui.later(() -> Rewards.show(reward, () -> { })); + }, (status, message) -> { + // Refused for good (an old account, a second claim, our own code) + // or the code is unknown: forget it. Anything else is retried on + // the next sign-in. + if (status >= 400 && status < 500) { + clear(); + } + Log.p("Invite not credited: " + status + " " + message); + })); + } + + private static void clear() { + Preferences.delete(PENDING + "code"); + Preferences.delete(PENDING + "campaign"); + Preferences.delete(PENDING + "channel"); + Preferences.delete(PENDING + "match"); + } + + // tag::dash-invite-share[] + /// Opens the share sheet with the player's invite link. + public static void share(InviteDto mine) { + Invite invite = Invites.create(InviteRequest.create() + .campaign(CAMPAIGN) + .channel("share_sheet") + .payload(mine.code()) + .title("Join me in Dash") + .description("An endless runner. Race me, same track, live.") + .build()); + Invites.share(invite, "Race me in Dash! You get " + mine.friendCoins() + + " coins to start with: " + invite.getUrl()); + Telemetry.inviteSent(CAMPAIGN); + } + // end::dash-invite-share[] + + /// A link for a marketing campaign, made from the admin console: no + /// player's code in it, so it rewards nobody, and everything the players + /// it brings in do is reported under `campaign` in analytics. + public static Invite campaignLink(String campaign) { + return Invites.create(InviteRequest.create() + .campaign(campaign) + .channel("console") + .title("Dash") + .description("An endless runner you can race your friends in") + .build()); + } + + /// Marks a step of an invited player's way in, for the invite funnel: + /// the first race and the first win, once each. A no-op on an install + /// no invite brought in. + public static void raceFinished(boolean won) { + once("first_race"); + if (won) { + once("first_win"); + } + } + + private static void once(String action) { + String key = PENDING + "conv." + action; + if (!Preferences.get(key, false)) { + Preferences.set(key, true); + Invites.conversion(action); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Notifications.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Notifications.java new file mode 100644 index 00000000000..d5e67e243a1 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Notifications.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.engage; + +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codename1.push.PushClient; +import com.codename1.push.PushError; +import com.codename1.push.PushListener; +import com.codename1.push.PushMessage; +import com.codename1.push.PushSubscription; +import com.codename1.ui.CN; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.api.PushDeviceDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.ui.Nav; + +/// Push notifications, the game's way of bringing a player back: a friend +/// accepted your challenge, your daily rewards are ready, and -- for admins +/// -- a player was flagged. +/// +/// The device registers with `com.codename1.push`, then hands its token to +/// the server, which sends through the Codename One push service. A player +/// can turn them off in Settings; the server is told, and stops. +// tag::dash-push[] +public final class Notifications { + private static final String ENABLED = "dash.push"; + private static PushClient client; + private static PushSubscription subscription; + + private Notifications() { + } + + public static boolean enabled() { + return Preferences.get(ENABLED, true); + } + + public static void setEnabled(boolean on) { + Preferences.set(ENABLED, on); + if (on) { + start(); + } else { + signingOut(() -> { }); + } + } + + public static void start() { + if (!available() || !enabled()) { + return; + } + if (client == null) { + client = PushClient.builder(AppConfig.PUSH_APP_KEY).listener(new Listener()).build(); + } + client.register(); + } + + /// Tells the server where to reach this device; called after signing in. + public static void signedIn() { + PushDeviceDto device = device(); + if (device != null && Session.user() != null && enabled()) { + Api.account().registerPush(device, Net.to(saved -> { }, (s, m) -> { })); + } + } + + public static void signingOut(Runnable then) { + PushDeviceDto device = device(); + if (device == null || Session.user() == null) { + then.run(); + return; + } + Api.account().unregisterPush(device, Net.to(gone -> then.run(), (s, m) -> then.run())); + } + + /// Only on a phone with a push key configured; the simulator and the web + /// build simply never ask. + public static boolean available() { + if (AppConfig.PUSH_APP_KEY.length() == 0 || CN.isSimulator()) { + return false; + } + String platform = CN.getPlatformName(); + return "and".equals(platform) || "ios".equals(platform); + } + + private static PushDeviceDto device() { + if (subscription == null) { + return null; + } + return new PushDeviceDto(subscription.getTransportId(), subscription.getToken(), + CN.getPlatformName()); + } + + private static final class Listener implements PushListener { + @Override + public void onRegistration(PushSubscription registered) { + subscription = registered; + signedIn(); + } + + /// A tap on a notification opens the screen it is about. + @Override + public void onMessage(PushMessage message) { + if (Session.user() == null) { + return; + } + // The keys are the ones the server's GamePushes puts in `data`. + Object challenge = message.getData().get("challenge"); + Object screen = message.getData().get("screen"); + Object campaign = message.getData().get("campaign"); + if (campaign != null) { + Telemetry.pushOpened(String.valueOf(campaign)); + } + if (challenge != null) { + Nav.challenge(String.valueOf(challenge)); + } else if ("admin".equals(screen) && Session.user().admin()) { + Nav.adminConsole(); + } else { + Nav.screen(screen == null ? "" : String.valueOf(screen)); + } + } + + @Override + public void onError(PushError error) { + Log.p("Push registration failed: " + error.getCode() + " " + error.getMessage()); + } + } +} +// end::dash-push[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Store.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Store.java new file mode 100644 index 00000000000..db9df2e5f46 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Store.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.engage; + +import com.codename1.io.Log; +import com.codename1.payment.Product; +import com.codename1.payment.Purchase; +import com.codename1.payment.Receipt; +import com.codename1.ui.CN; +import com.codenameone.examples.dash.api.ReceiptDto; +import com.codenameone.examples.dash.api.WalletDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import java.util.List; + +/// In-app purchases, through `com.codename1.payment`. +/// +/// The store takes the money; the game's server grants what was bought. A +/// purchase the device reports is sent to the server as a receipt, the server +/// asks Apple or Google whether it is real, and only then adds the gems -- +/// once, however many times the receipt arrives. A client that claims a +/// purchase it did not make gets nothing. +/// +/// The main class implements `PurchaseCallback`, which is how Codename One +/// reports a finished purchase; it calls [#purchased]. +// tag::dash-store[] +public final class Store { + /// Told about the wallet after the server granted a purchase. + public interface Granted { + void granted(WalletDto wallet); + } + + private static Granted listener; + + private Store() { + } + + public static void listen(Granted granted) { + listener = granted; + } + + /// Whether a real store takes the money. The simulator's own store stands + /// in for one only when the app's main class is running to hear back, so + /// here the simulator takes the simulated-receipt path instead, which + /// exercises the same server validation. + public static boolean available() { + Purchase p = Purchase.getInAppPurchase(); + return !CN.isSimulator() && p != null && p.isManagedPaymentSupported(); + } + + /// The store's own price for `sku`, in the player's currency, or null + /// where there is no store to ask (the simulator) or it has not said. + public static String price(String sku) { + if (!available()) { + return null; + } + Product[] products = Purchase.getInAppPurchase().getProducts(new String[] {sku}); + if (products == null || products.length == 0 || products[0] == null) { + return null; + } + String p = products[0].getLocalizedPrice(); + return p == null || p.length() == 0 ? null : p; + } + + /// Starts buying `sku` from the platform's store. In the simulator the + /// purchase completes at once with a simulated receipt, which the server + /// accepts on its development profiles only. + public static void buy(String sku) { + if (available()) { + Purchase.getInAppPurchase().purchase(sku); + } else { + redeem(new ReceiptDto(Receipt.STORE_CODE_SIMULATOR, sku, + "sim-" + System.currentTimeMillis(), "")); + } + } + + /// From `PurchaseCallback.itemPurchased`: finds the store's receipt and + /// sends it to the server. + public static void purchased(String sku) { + Telemetry.purchase(sku); + Purchase p = Purchase.getInAppPurchase(); + if (p == null) { + return; + } + Receipt[] receipts = p.getReceipts(sku); + if (receipts == null || receipts.length == 0) { + return; + } + Receipt r = receipts[receipts.length - 1]; + redeem(new ReceiptDto(r.getStoreCode(), sku, r.getTransactionId(), r.getOrderData())); + } + + /// Sends every receipt the store still holds. The server grants each only + /// once, so this is safe to repeat; it picks up a purchase whose first + /// report was lost when the app was killed mid-way. + public static void resend() { + Purchase p = Purchase.getInAppPurchase(); + if (p == null || !available()) { + return; + } + List all = p.getReceipts(); + for (int i = 0; i < all.size(); i++) { + Receipt r = all.get(i); + redeem(new ReceiptDto(r.getStoreCode(), r.getSku(), r.getTransactionId(), r.getOrderData())); + } + } + + private static void redeem(ReceiptDto receipt) { + Api.shop().receipt(receipt, Net.to(wallet -> { + if (listener != null) { + CN.callSerially(() -> listener.granted(wallet)); + } + }, (status, message) -> Log.p("Receipt refused: " + status + " " + message))); + } +} +// end::dash-store[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Telemetry.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Telemetry.java new file mode 100644 index 00000000000..2a070bb88c8 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/engage/Telemetry.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.engage; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.AnalyticsProvider; +import com.codename1.analytics.CodenameOneAnalyticsProvider; +import com.codename1.analytics.LoggingAnalyticsProvider; +import com.codename1.ui.CN; + +/// What the game reports about how it is played, through +/// `com.codename1.analytics`. +/// +/// The events are a funnel a game designer reads top to bottom: installed, +/// signed up, first run, first race win, level published, first purchase. +/// Runs also report the level, how far the runner got and how it ended, +/// which is what tuning a level's difficulty needs. Nothing is sent until the player says yes on the +/// consent screen, and nothing that names them is sent at all. +/// +/// The simulator logs every event to the console instead of sending it; swap +/// in your own provider (Firebase, Amplitude, your own server) with [#use]. +// tag::dash-analytics[] +public final class Telemetry { + private static final String ACCOUNT = "account"; + private static final String RUN = "run"; + private static final String ECONOMY = "economy"; + private static boolean started; + private static String screen; + + private Telemetry() { + } + + public static void start() { + if (started) { + return; + } + started = true; + Analytics.addProvider(CN.isSimulator() ? new LoggingAnalyticsProvider() + : new CodenameOneAnalyticsProvider()); + } + + /// Replaces the providers; the tests use it to read what was sent. + public static void use(AnalyticsProvider... providers) { + started = true; + Analytics.clearProviders(); + for (AnalyticsProvider p : providers) { + Analytics.addProvider(p); + } + screen = null; + } + + public static boolean asked() { + return Analytics.getConsent() != null; + } + + public static boolean allowed() { + AnalyticsConsent c = Analytics.getConsent(); + return c != null && c.isAnalytics(); + } + + public static void allow(boolean yes) { + Analytics.setConsent(AnalyticsConsent.builder() + .analytics(yes) + .crashReporting(yes) + .personalization(false) + .adStorage(false) + .build()); + } + + public static void flush() { + Analytics.flush(); + } + + public static void screen(String name) { + if (name.equals(screen) || !allowed()) { + return; + } + Analytics.screen(name, screen); + screen = name; + } + + public static void registered() { + send(ACCOUNT, "sign_up", "method", "password"); + } + + public static void signedIn() { + send(ACCOUNT, "login", "method", "password"); + } + + /// `mode` is solo, daily, ghost, race or tutorial. + public static void runStarted(String mode, String level) { + send(RUN, "run_start", "mode", mode, "level", level); + } + + /// `place` counts from 0; a solo run is place 0 of 1. + public static void runEnded(String mode, String level, int place, int metres, boolean crashed) { + send(RUN, "run_end", "mode", mode, "level", level, "place", Integer.valueOf(place), + "metres", Integer.valueOf(metres), "crashed", crashed ? "yes" : "no"); + } + + public static void levelPublished(String pack) { + send(RUN, "level_published", "pack", pack); + } + + public static void runnerChosen(String runner) { + send(ECONOMY, "runner_chosen", "runner", runner); + } + + public static void itemBought(String item, String currency) { + send(ECONOMY, "spend_virtual_currency", "item", item, "currency", currency); + } + + public static void purchase(String sku) { + send(ECONOMY, "purchase", "sku", sku); + } + + public static void adWatched(String placement) { + send(ECONOMY, "ad_reward", "placement", placement); + } + + public static void challengeShared() { + send(ACCOUNT, "share", "content", "challenge"); + } + + public static void inviteSent(String campaign) { + send(ACCOUNT, "share", "content", "invite", "campaign", campaign); + } + + public static void inviteAccepted() { + send(ACCOUNT, "invite_accepted"); + } + + /// A tap on a push notification. The broadcast's campaign rides in its + /// data, so the console reports what each broadcast brought back. + public static void pushOpened(String campaign) { + send(ACCOUNT, "push_open", "campaign", campaign); + } + + /// Who the player is, as segments the console slices every report by: + /// how far they got. No names, no ids. + public static void describe(int level, int trophies) { + if (!allowed()) { + return; + } + Analytics.setUserProperty("level_band", level < 5 ? "1-4" : level < 10 ? "5-9" : "10+"); + Analytics.setUserProperty("trophy_band", trophies < 100 ? "0-99" : trophies < 500 ? "100-499" : "500+"); + } + + private static void send(String category, String name, Object... pairs) { + if (!allowed()) { + return; + } + AnalyticsEvent.Builder event = AnalyticsEvent.create(name).category(category); + for (int i = 0; i + 1 < pairs.length; i += 2) { + event.param(String.valueOf(pairs[i]), pairs[i + 1]); + } + Analytics.event(event.build()); + } +} +// end::dash-analytics[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/DefEditor.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/DefEditor.java new file mode 100644 index 00000000000..9967617ce2a --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/DefEditor.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.ui.Command; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.rules.Clearance; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.PickupDef; +import com.codenameone.examples.dash.rules.PickupKind; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.ui.Ui; + +/// A custom obstacle or pickup of the pack. Definitions are immutable values +/// in the rules, so every change builds a new one and puts it back in the +/// pack's list at the same place; levels refer to it by id, which never +/// changes once it is made. +public final class DefEditor { + private static final String[] CLEARANCES = {"Jump it", "Slide under it", "Change lanes"}; + private static final String[] KINDS = {"Coin", "Gem", "Magnet", "Shield", "Boost"}; + + private DefEditor() { + } + + static String clearanceName(Clearance c) { + return CLEARANCES[c.ordinal()]; + } + + static String kindName(PickupKind k) { + return KINDS[k.ordinal()]; + } + + static void create(boolean obstacle) { + final TextField name = new TextField("", obstacle ? "Name, e.g. log" : "Name, e.g. star"); + name.setName("defName"); + Command go = new Command("Create"); + if (Dialog.show(obstacle ? "New obstacle" : "New pickup", name, go, new Command("Cancel")) != go + || name.getText().trim().length() == 0) { + return; + } + String id = unique("u-" + Fields.slug(name.getText().trim(), "piece")); + if (obstacle) { + PackEditor.pack.defs.add(new ObstacleDef(id, Clearance.JUMP, Track.UNIT, 500, false)); + } else { + PackEditor.pack.defs.add(new PickupDef(id, PickupKind.COIN, 5, 0)); + } + edit(PackEditor.pack.defs.size() - 1); + } + + private static String unique(String id) { + String out = id; + int n = 2; + while (PackEditor.pack.catalog().has(out)) { + out = id + "-" + n++; + } + return out; + } + + static void edit(final int index) { + Object d = PackEditor.pack.defs.get(index); + Form f = Ui.form("Edit piece", "defEditor"); + Ui.back(f, PackEditor::show); + if (d instanceof ObstacleDef) { + final ObstacleDef[] o = {(ObstacleDef) d}; + f.add(Ui.label(o[0].id, "SectionTitle")); + f.add(Fields.choice("Way past", "defClearance", CLEARANCES, o[0].clearance.ordinal(), i -> { + o[0] = new ObstacleDef(o[0].id, Clearance.values()[i], o[0].depth, o[0].height, o[0].lethal); + PackEditor.pack.defs.set(index, o[0]); + })); + f.add(Fields.tenths("Depth", "defDepth", o[0].depth / 100, 5, 80, 5, "m", v -> { + o[0] = new ObstacleDef(o[0].id, o[0].clearance, v * 100, o[0].height, o[0].lethal); + PackEditor.pack.defs.set(index, o[0]); + })); + f.add(Fields.tenths("Height", "defHeight", o[0].height / 100, 0, 30, 1, "m", v -> { + o[0] = new ObstacleDef(o[0].id, o[0].clearance, o[0].depth, v * 100, o[0].lethal); + PackEditor.pack.defs.set(index, o[0]); + })); + f.add(Fields.help("A height of 0 is a gap in the floor. A lethal piece ends the run; the others " + + "only stumble the runner.")); + f.add(Fields.check("Lethal", "defLethal", o[0].lethal, v -> { + o[0] = new ObstacleDef(o[0].id, o[0].clearance, o[0].depth, o[0].height, v); + PackEditor.pack.defs.set(index, o[0]); + })); + } else if (d instanceof PickupDef) { + final PickupDef[] p = {(PickupDef) d}; + f.add(Ui.label(p[0].id, "SectionTitle")); + f.add(Fields.choice("Kind", "defKind", KINDS, p[0].kind.ordinal(), i -> { + p[0] = new PickupDef(p[0].id, PickupKind.values()[i], p[0].points, p[0].ticks); + PackEditor.pack.defs.set(index, p[0]); + })); + f.add(Fields.stepper("Points", "defPoints", p[0].points, 0, 500, 5, "", v -> { + p[0] = new PickupDef(p[0].id, p[0].kind, v, p[0].ticks); + PackEditor.pack.defs.set(index, p[0]); + })); + f.add(Fields.stepper("Lasts", "defTicks", p[0].ticks / 60, 0, 30, 1, "s", v -> { + p[0] = new PickupDef(p[0].id, p[0].kind, p[0].points, v * 60); + PackEditor.pack.defs.set(index, p[0]); + })); + } + f.add(Ui.secondary("Delete piece", "deleteDef", e -> { + if (Dialog.show("Delete?", "Levels that place it will stop validating.", "Delete", "Keep")) { + PackEditor.pack.defs.remove(index); + PackEditor.show(); + } + })); + f.show(); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/Fields.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/Fields.java new file mode 100644 index 00000000000..3a0db241edf --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/Fields.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.spinner.Picker; + +/// The editor's inputs: each one a labelled row that writes its value back +/// the moment it changes, so a screen never has a "read the form" step to +/// forget a field in. +public final class Fields { + public interface IntSink { + void set(int value); + } + + public interface TextSink { + void set(String value); + } + + public interface BoolSink { + void set(boolean value); + } + + private static final float LABEL_MM = 23f; + + private Fields() { + } + + public static Container row(String label, Component input) { + Container c = new Container(new BorderLayout()); + c.setUIID("ForgeRow"); + Label l = new Label(label, "ForgeLabel"); + // One label column: the fields of a form start at the same place + // unless a label is longer than the column. + l.setPreferredW(Math.max(l.getPreferredW(), CN.convertToPixels(LABEL_MM))); + c.add(BorderLayout.WEST, l).add(BorderLayout.CENTER, input); + return c; + } + + public static Container text(String label, String name, String value, int max, final TextSink sink) { + final TextField t = new TextField(value == null ? "" : value, label, 20, TextArea.ANY); + t.setName(name); + t.setMaxSize(max); + t.setUIID("ForgeField"); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + return row(label, t); + } + + /// Several lines of text: a briefing, a line of dialog. + public static Container area(String label, String name, String value, int max, final TextSink sink) { + final TextArea t = new TextArea(value == null ? "" : value, 3, 20); + t.setName(name); + t.setMaxSize(max); + t.setGrowByContent(true); + t.setUIID("ForgeField"); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + Container c = BoxLayout.encloseY(new Label(label, "ForgeLabel"), t); + c.setUIID("ForgeRow"); + return c; + } + + /// A whole number with buttons either side: easier on a phone than a + /// keyboard, and it can never hold a value out of range. + public static Container stepper(String label, String name, int value, final int min, final int max, final int step, + final String unit, final IntSink sink) { + return stepper(label, name, value, min, max, step, 1, unit, sink); + } + + /// A stepper over tenths, shown as a decimal: "2.5 tiles". + public static Container tenths(String label, String name, int value, int min, int max, int step, String unit, + IntSink sink) { + return stepper(label, name, value, min, max, step, 10, unit, sink); + } + + private static String show(int v, int scale, String unit) { + String n = scale == 10 ? v / 10 + "." + v % 10 : String.valueOf(v); + return unit.length() == 0 ? n : n + " " + unit; + } + + private static Container stepper(String label, String name, int value, final int min, final int max, + final int step, final int scale, final String unit, final IntSink sink) { + final int[] v = {Math.max(min, Math.min(max, value))}; + final Label shown = new Label(show(v[0], scale, unit), "ForgeValue"); + shown.setName(name); + // One width on every stepper, or the widest value this one can show + // if that is wider, so the + button stays put as the value grows and + // the steppers on a form line up. + String wide = show(max, scale, unit).length() >= show(min, scale, unit).length() + ? show(max, scale, unit) : show(min, scale, unit); + shown.setText(wide); + shown.setPreferredW(Math.max(shown.getPreferredW(), com.codename1.ui.CN.convertToPixels(19))); + shown.setText(show(v[0], scale, unit)); + Button less = new Button("-", "ForgeStep"); + Button more = new Button("+", "ForgeStep"); + less.setName(name + "-less"); + more.setName(name + "-more"); + less.addActionListener(e -> { + v[0] = Math.max(min, v[0] - step); + shown.setText(show(v[0], scale, unit)); + shown.getParent().revalidate(); + sink.set(v[0]); + }); + more.addActionListener(e -> { + v[0] = Math.min(max, v[0] + step); + shown.setText(show(v[0], scale, unit)); + shown.getParent().revalidate(); + sink.set(v[0]); + }); + // The buttons sit at the two ends of the input column and the value + // between them, so every stepper on a form lines up with the next + // whatever its unit. + return row(label, BorderLayout.centerEastWest(shown, more, less)); + } + + public static Container choice(String label, String name, String[] options, int selected, final IntSink sink) { + final Picker p = new Picker(); + p.setType(com.codename1.ui.Display.PICKER_TYPE_STRINGS); + p.setStrings(options); + p.setSelectedString(options[Math.max(0, Math.min(options.length - 1, selected))]); + p.setName(name); + p.setUIID("ForgeChoice"); + // A drop-down arrow at the far end of the field, so it reads as a + // choice, not as a field to type into. The field is the picker's lead, + // so a tap on the arrow opens it too. + Label arrow = new Label("", "ForgeChoiceArrow"); + arrow.setMaterialIcon(com.codename1.ui.FontImage.MATERIAL_ARROW_DROP_DOWN, 3.6f); + Container field = BorderLayout.center(p).add(BorderLayout.EAST, arrow); + field.setUIID("ForgeField"); + field.setLeadComponent(p); + p.addActionListener(e -> { + String s = p.getSelectedString(); + for (int i = 0; i < options.length; i++) { + if (options[i].equals(s)) { + sink.set(i); + return; + } + } + }); + return row(label, field); + } + + public static CheckBox check(String label, String name, boolean value, final BoolSink sink) { + final CheckBox c = new CheckBox(label); + c.setUIID("ForgeCheck"); + c.setName(name); + c.setSelected(value); + c.addActionListener(e -> sink.set(c.isSelected())); + return c; + } + + public static SpanLabel help(String text) { + SpanLabel l = new SpanLabel(text); + l.setTextUIID("ForgeHelp"); + return l; + } + + /// Index of `value` in `options`, or 0. + public static int indexOf(String[] options, String value) { + for (int i = 0; i < options.length; i++) { + if (options[i].equals(value)) { + return i; + } + } + return 0; + } + + /// A slug made from a name: what a new card, arena or mission is called + /// inside its pack until its author renames it. + public static String slug(String name, String fallback) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length() && sb.length() < 30; i++) { + char c = name.charAt(i); + if (c >= 'A' && c <= 'Z') { + c = (char) (c + 32); + } + if (c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { + sb.append(c); + } else if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '-') { + sb.append('-'); + } + } + while (sb.length() > 0 && sb.charAt(sb.length() - 1) == '-') { + sb.setLength(sb.length() - 1); + } + return sb.length() < 2 ? fallback : sb.toString(); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/ForgeHome.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/ForgeHome.java new file mode 100644 index 00000000000..cbcdde9179c --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/ForgeHome.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.components.MultiButton; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.ui.Nav; +import com.codenameone.examples.dash.ui.Ui; + +/// The track editor's front door: the game's own levels, what other players +/// made, and the player's own packs, with a button to start a new one. +public final class ForgeHome { + private static final String[] SCOPES = {"official", "popular", "new", "mine"}; + private static final String[] TITLES = {"Official", "Popular", "New", "Yours"}; + + private ForgeHome() { + } + + public static void show() { + show("official"); + } + + static void show(final String scope) { + final Form f = Ui.form("Forge", "forge"); + Ui.back(f, Nav::home); + Container tabs = new Container(new GridLayout(1, SCOPES.length)); + for (int i = 0; i < SCOPES.length; i++) { + final String s = SCOPES[i]; + Button b = Ui.button(TITLES[i], s.equals(scope) ? "ForgeTabOn" : "ForgeTab", "forge-" + s, + e -> show(s)); + tabs.add(b); + } + f.add(tabs); + f.add(Ui.primary("Create a pack", "forgeCreate", e -> PackEditor.create())); + Api.forge().packs(scope, Net.to(list -> Ui.later(() -> { + if (list.isEmpty()) { + f.add(Ui.text("mine".equals(scope) + ? "Nothing yet. A pack holds your own levels and obstacles; create one and " + + "share it with friends." + : "No packs here yet.", "Empty")); + } + for (final PackSummaryDto p : list) { + f.add(row(p, "mine".equals(scope))); + } + if ("official".equals(scope)) { + f.add(Ui.text("Levels players make show up under Popular and New. " + + "Make your own with Create a pack.", "Empty")); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } + + private static MultiButton row(final PackSummaryDto p, boolean mine) { + MultiButton b = new MultiButton(p.name().length() == 0 ? p.id() : p.name()); + b.setUIID("HistoryRow"); + b.setUIIDLine1(p.official() ? "ForgeOfficial" : "HistoryDraw"); + StringBuilder line = new StringBuilder("by ").append(p.author()).append(" ") + .append(p.levels()).append(p.levels() == 1 ? " level" : " levels"); + if (p.ratings() > 0) { + line.append(" ").append(p.rating() / 10).append('.').append(p.rating() % 10).append(" stars"); + } + b.setTextLine2(line.toString()); + if (mine) { + b.setTextLine3(p.version() == 0 ? "Draft, not published" : "Version " + p.version() + ", " + + p.status() + (p.pendingAssets() ? ", files waiting for review" : "")); + } + // A lit view of a track as the pack's cover, its theme picked by the + // pack's id so a pack keeps its cover. + String[] themes = {"neon", "ember", "daybreak"}; + int pick = (p.id().hashCode() & 0x7fffffff) % themes.length; + int th = com.codename1.ui.CN.convertToPixels(12); + b.setIcon(com.codenameone.examples.dash.ui.Kit.thumb(themes[pick], th * 3 / 5, th)); + b.setName("pack-" + p.id()); + b.addActionListener(e -> { + if (mine && p.version() == 0) { + PackEditor.open(p.id()); + } else { + PackScreen.show(p.id()); + } + }); + return b; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/LevelEditor.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/LevelEditor.java new file mode 100644 index 00000000000..c68c12956f3 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/LevelEditor.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.RadioButton; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.DialogLine; +import com.codenameone.examples.dash.rules.Goal; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.PickupDef; +import com.codenameone.examples.dash.rules.Placement; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.rules.Validator; +import com.codenameone.examples.dash.rules.ViewCue; +import com.codenameone.examples.dash.rules.ViewMode; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.run.Theme; +import com.codenameone.examples.dash.ui.RunForm; +import com.codenameone.examples.dash.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// One level of the pack on the bench: its settings, the track itself as a +/// [TrackTimeline] to paint pieces and camera cues on, the coach's lines, and +/// a playtest that runs the draft exactly as a player would -- the same +/// [com.codenameone.examples.dash.rules.Run], so what passes here passes on +/// the server. +public final class LevelEditor { + private static final String[] GOALS = {"Reach the finish", "Collect coins", "Survive", "Endless"}; + private static final String ERASE = ""; + + private LevelEditor() { + } + + static void create() { + int n = PackEditor.pack.levels.size() + 1; + while (PackEditor.pack.level("level-" + n) != null) { + n++; + } + LevelDef l = new LevelDef("level-" + n, "Level " + n); + l.length = 400 * Track.UNIT; + l.stars = new int[] {300, 400, 500}; + l.cue(0, ViewMode.SIDE_2D, 0); + l.cue(200, ViewMode.CHASE_3D, 30); + PackEditor.pack.levels.add(l); + edit(PackEditor.pack.levels.size() - 1); + } + + static void edit(final int index) { + final LevelDef l = PackEditor.pack.levels.get(index); + final Catalog catalog = PackEditor.pack.catalog(); + Form f = Ui.form(l.name, "levelEditor"); + Ui.back(f, PackEditor::show); + f.add(Fields.text("Name", "levelName", l.name, 40, v -> l.name = v)); + f.add(Fields.choice("Theme", "levelTheme", Theme.IDS, Math.max(0, Fields.indexOf(Theme.IDS, l.theme)), + i -> l.theme = Theme.IDS[i])); + f.add(Fields.stepper("Lanes", "levelLanes", l.lanes, 1, 5, 1, "", v -> l.lanes = v)); + f.add(Fields.tenths("Start speed", "levelStart", l.startSpeed / 100, 40, 200, 5, "m/s", + v -> l.startSpeed = v * 100)); + f.add(Fields.tenths("Top speed", "levelTop", l.maxSpeed / 100, 60, 300, 5, "m/s", + v -> l.maxSpeed = v * 100)); + f.add(Fields.stepper("Length", "levelLength", l.length / Track.UNIT, 0, 5000, 50, "m", + v -> l.length = v * Track.UNIT)); + f.add(Fields.choice("Goal", "levelGoal", GOALS, l.goal.ordinal(), i -> l.goal = Goal.values()[i])); + f.add(Fields.stepper("Goal value", "levelGoalValue", l.goalValue, 0, 10000, 10, "", v -> l.goalValue = v)); + f.add(Fields.stepper("Endless from", "levelProcedural", l.proceduralFrom / Track.UNIT, 0, 5000, 50, "m", + v -> l.proceduralFrom = v * Track.UNIT)); + f.add(Fields.help("Past \"endless from\" the track builds itself out of the game's chunks, harder " + + "every stretch. At 0 m the whole level is hand built.")); + for (int s = 0; s < 3; s++) { + final int star = s; + f.add(Fields.stepper("Star " + (s + 1), "levelStar" + s, l.stars[s], 0, 100000, 25, "pts", + v -> l.stars[star] = v)); + } + + f.add(Ui.label("Track", "SectionTitle")); + final TrackTimeline timeline = new TrackTimeline(l, catalog); + final SpanLabel problems = new SpanLabel("", "ForgeProblem"); + timeline.setChanged(() -> problems.setText(problemsOf(l, catalog))); + f.add(palette(l, catalog, timeline)); + Container strip = new Container(BoxLayout.x()); + strip.setScrollableX(true); + strip.add(timeline); + f.add(strip); + f.add(Fields.help("Tap a lane to place the piece you picked; tap the top row for one across every " + + "lane. Tap a piece with \"Erase\" to take it away. A camera cue goes where you tap.")); + problems.setText(problemsOf(l, catalog)); + f.add(problems); + + f.add(Ui.label("Coach", "SectionTitle")); + for (int i = 0; i < l.dialog.size(); i++) { + final DialogLine d = l.dialog.get(i); + f.add(Ui.row(Ui.text(d.at / Track.UNIT + " m: " + d.text, "QuestReward"), + Ui.secondary("Remove", "removeLine" + i, e -> { + l.dialog.remove(d); + edit(index); + }))); + } + f.add(Ui.secondary("Add a line", "addLine", e -> addLine(l, index))); + + f.add(Ui.row(Ui.primary("Playtest", "playtest", e -> RunForm.playtest( + Levels.draft(PackEditor.pack, l), () -> edit(index))), + Ui.secondary("Delete level", "deleteLevel", e -> { + if (Dialog.show("Delete?", "Delete " + l.name + "?", "Delete", "Keep")) { + PackEditor.pack.levels.remove(index); + PackEditor.show(); + } + }))); + f.show(); + } + + /// The pieces to paint with: every obstacle and pickup the pack can use, + /// the camera modes, and an eraser. + // tag::dash-level-palette[] + private static Container palette(final LevelDef l, final Catalog catalog, final TrackTimeline timeline) { + Container c = new Container(new FlowLayout()); + ButtonGroup group = new ButtonGroup(); + List ids = new ArrayList(); + ids.add(Content.HURDLE); + ids.add(Content.BARRIER); + ids.add(Content.HAULER); + ids.add(Content.GAP); + ids.add(Content.COIN); + ids.add(Content.GEM); + ids.add(Content.MAGNET); + ids.add(Content.SHIELD); + ids.add(Content.BOOST); + for (Object d : PackEditor.pack.defs) { + if (d instanceof ObstacleDef) { + ids.add(((ObstacleDef) d).id); + } else if (d instanceof PickupDef) { + ids.add(((PickupDef) d).id); + } + } + boolean first = true; + for (final String id : ids) { + RadioButton b = tool(group, id, "tool-" + id); + b.addActionListener(e -> timeline.setTool((level, metres, lane) -> place(level, catalog, id, metres, lane))); + if (first) { + b.setSelected(true); + timeline.setTool((level, metres, lane) -> place(level, catalog, id, metres, lane)); + first = false; + } + c.add(b); + } + ViewMode[] modes = ViewMode.values(); + for (int i = 0; i < modes.length; i++) { + final ViewMode m = modes[i]; + RadioButton b = tool(group, "Cue " + TrackTimeline.modeName(m), "tool-cue-" + m.name()); + b.addActionListener(e -> timeline.setTool((level, metres, lane) -> cue(level, m, metres))); + c.add(b); + } + RadioButton erase = tool(group, "Erase", "tool-erase"); + erase.addActionListener(e -> timeline.setTool((level, metres, lane) -> place(level, catalog, ERASE, metres, + lane))); + c.add(erase); + return c; + } + // end::dash-level-palette[] + + private static RadioButton tool(ButtonGroup group, String text, String name) { + RadioButton b = new RadioButton(text); + b.setToggle(true); + b.setUIID("BoardTab"); + b.setName(name); + group.add(b); + return b; + } + + /// Puts `id` at the spot, or erases what is there. A piece already in the + /// cell is replaced rather than stacked. + private static boolean place(LevelDef l, Catalog catalog, String id, int metres, int lane) { + int z = metres * Track.UNIT; + boolean removed = false; + for (int i = l.placements.size() - 1; i >= 0; i--) { + Placement p = l.placements.get(i); + ObstacleDef o = catalog.obstacle(p.defId); + int depth = o == null ? 2 * Track.UNIT : Math.max(Track.UNIT, o.depth); + if (p.lane == lane && z >= p.z && z < p.z + Math.max(5 * Track.UNIT, depth)) { + l.placements.remove(i); + removed = true; + } + } + if (ERASE.equals(id)) { + return removed; + } + if (l.placements.size() >= com.codenameone.examples.dash.rules.forge.Validator.MAX_PLACEMENTS) { + Ui.toast("That is as many pieces as a level can hold"); + return removed; + } + l.placements.add(new Placement(z, id, lane)); + return true; + } + + /// A camera cue at `metres`, replacing one already within ten metres. + private static boolean cue(LevelDef l, ViewMode mode, int metres) { + for (int i = l.cues.size() - 1; i >= 0; i--) { + if (Math.abs(l.cues.get(i).at - metres * Track.UNIT) < 10 * Track.UNIT) { + l.cues.remove(i); + } + } + int blend = metres == 0 ? 0 : 30 * Track.UNIT; + ViewCue c = new ViewCue(metres * Track.UNIT, mode, blend); + int at = 0; + while (at < l.cues.size() && l.cues.get(at).at < c.at) { + at++; + } + l.cues.add(at, c); + return true; + } + + private static void addLine(final LevelDef l, final int index) { + final TextField text = new TextField("", "What the coach says", 40, TextField.ANY); + final TextField at = new TextField("0", "At metres", 6, TextField.NUMERIC); + Container body = BoxLayout.encloseY(text, at); + Command ok = new Command("Add"); + if (Dialog.show("Coach line", body, ok, new Command("Cancel")) != ok || text.getText().trim().length() == 0) { + return; + } + int metres = parse(at.getText()); + DialogLine d = new DialogLine(metres * Track.UNIT, "Coach", text.getText().trim(), 0); + int i = 0; + while (i < l.dialog.size() && l.dialog.get(i).at <= d.at) { + i++; + } + l.dialog.add(i, d); + edit(index); + } + + private static int parse(String s) { + int v = 0; + for (int i = 0; i < s.length() && i < 6; i++) { + char c = s.charAt(i); + if (c >= '0' && c <= '9') { + v = v * 10 + (c - '0'); + } + } + return v; + } + + private static String problemsOf(LevelDef l, Catalog catalog) { + List ps = Validator.check(l, catalog); + if (ps.isEmpty()) { + return "Ready to publish."; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ps.size() && i < 5; i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(ps.get(i)); + } + return sb.toString(); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackEditor.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackEditor.java new file mode 100644 index 00000000000..9a719aea381 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackEditor.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.components.MultiButton; +import com.codename1.ui.Command; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.api.PackSaveDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.PickupDef; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.forge.PackCodec; +import com.codenameone.examples.dash.rules.forge.Samples; +import com.codenameone.examples.dash.rules.forge.Validator; +import com.codenameone.examples.dash.ui.LevelsForm; +import com.codenameone.examples.dash.ui.Ui; +import java.util.List; + +/// A pack being edited: its name and description, and the levels and the +/// custom obstacles and pickups in it, each opening its own editor. +/// +/// The pack lives in memory while it is edited and goes to the server as a +/// draft on "Save". A draft can be half built; "Publish" is where the +/// [Validator] -- the one the server runs too -- has to be satisfied. +public final class PackEditor { + /// The pack on the bench. Every sub-editor edits it in place. + static ContentPack pack; + + private PackEditor() { + } + + /// Starts a pack from a name, with one level to run straight away. + public static void create() { + final TextField name = new TextField("", "Name your pack"); + name.setName("packName"); + Command go = new Command("Create"); + if (Dialog.show("New pack", name, go, new Command("Cancel")) != go || name.getText().trim().length() == 0) { + return; + } + create(name.getText().trim()); + } + + /// A new pack under this name, saved as a draft and opened in the editor. + public static void create(String name) { + String id = Fields.slug(name, "pack") + "-" + Long.toString(System.currentTimeMillis() % 46656L, 36); + pack = Samples.blank(id, Session.user() == null ? "" : Session.user().displayName()); + pack.name = name; + save(PackEditor::show); + } + + public static void open(String id) { + Api.forge().draft(id, Net.to(dto -> Ui.later(() -> { + try { + pack = PackCodec.read(dto.content()); + } catch (IllegalArgumentException e) { + Ui.failed(0, "That draft could not be read: " + e.getMessage()); + return; + } + show(); + }), Ui::failed)); + } + + static void show() { + final ContentPack p = pack; + Form f = Ui.form("Edit pack", "packEditor"); + Ui.back(f, () -> save(() -> ForgeHome.show("mine"))); + f.add(Fields.text("Name", "packName", p.name, 60, v -> p.name = v)); + f.add(Fields.area("Description", "packDescription", p.description, Validator.MAX_LONG_TEXT, + v -> p.description = v)); + + f.add(Ui.label("Levels", "SectionTitle")); + f.add(Fields.help("Each level is a track: a length, a speed, the obstacles and pickups along it " + + "and where the camera swings between the side view and the chase view.")); + for (int i = 0; i < p.levels.size(); i++) { + final int at = i; + LevelDef l = p.levels.get(i); + f.add(item(l.name, LevelsForm.describe(l), "level-" + l.id, () -> LevelEditor.edit(at))); + } + f.add(Ui.secondary("Add a level", "addLevel", e -> LevelEditor.create())); + + f.add(Ui.label("Obstacles and pickups", "SectionTitle")); + f.add(Fields.help("Your own pieces, on top of the game's hurdles, barriers, haulers, gaps, coins " + + "and power-ups. Every level in the pack can place them.")); + for (int i = 0; i < p.defs.size(); i++) { + final int at = i; + Object d = p.defs.get(i); + if (d instanceof ObstacleDef) { + ObstacleDef o = (ObstacleDef) d; + f.add(item(o.id, "Obstacle, " + DefEditor.clearanceName(o.clearance), "def-" + o.id, + () -> DefEditor.edit(at))); + } else if (d instanceof PickupDef) { + PickupDef k = (PickupDef) d; + f.add(item(k.id, "Pickup, " + DefEditor.kindName(k.kind), "def-" + k.id, () -> DefEditor.edit(at))); + } + } + f.add(Ui.row(Ui.secondary("Add an obstacle", "addObstacle", e -> DefEditor.create(true)), + Ui.secondary("Add a pickup", "addPickup", e -> DefEditor.create(false)))); + + List problems = Validator.check(p); + if (!problems.isEmpty()) { + f.add(Ui.label("Before it can be published", "SectionTitle")); + for (int i = 0; i < problems.size() && i < 6; i++) { + f.add(Ui.text(problems.get(i), "ForgeProblem")); + } + } + f.add(Ui.row(Ui.secondary("Save", "savePack", e -> save(() -> Ui.toast("Saved"))), + Ui.primary("Publish", "publishPack", e -> publish()))); + f.add(Ui.secondary("Delete pack", "deletePack", e -> { + if (Dialog.show("Delete?", "Players who have the link can no longer open it.", "Delete", "Keep")) { + Api.forge().delete(p.id, Net.to(s -> Ui.later(() -> ForgeHome.show("mine")), Ui::failed)); + } + })); + f.show(); + } + + private static MultiButton item(String title, String detail, String name, final Runnable open) { + MultiButton b = new MultiButton(title); + b.setUIID("HistoryRow"); + b.setUIIDLine1("HistoryDraw"); + b.setTextLine2(detail); + b.setName(name); + b.addActionListener(e -> open.run()); + return b; + } + + /// Sends the draft, then runs `then`. + static void save(final Runnable then) { + Api.forge().saveDraft(new PackSaveDto(PackCodec.write(pack)), Net.to(s -> Ui.later(then), Ui::failed)); + } + + private static void publish() { + List problems = Validator.check(pack); + if (!Validator.isValid(pack)) { + Dialog.show("Not yet", problems.get(0), "OK", null); + return; + } + final String id = pack.id; + save(() -> Api.forge().publish(id, Net.to(s -> Ui.later(() -> { + Ui.toast("Published version " + s.version()); + PackScreen.show(id); + }), Ui::failed))); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackScreen.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackScreen.java new file mode 100644 index 00000000000..4c2d91ff38c --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/PackScreen.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.components.ShareButton; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.api.ForkDto; +import com.codenameone.examples.dash.api.LevelStarsDto; +import com.codenameone.examples.dash.api.PackDto; +import com.codenameone.examples.dash.api.PackReportDto; +import com.codenameone.examples.dash.api.PackSummaryDto; +import com.codenameone.examples.dash.api.RateDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.forge.PackCodec; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.ui.LevelsForm; +import com.codenameone.examples.dash.ui.RaceLobby; +import com.codenameone.examples.dash.ui.RunForm; +import com.codenameone.examples.dash.ui.ScoresForm; +import com.codenameone.examples.dash.ui.Ui; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A published pack: its levels in order with the stars already won, and what +/// a player can do with it -- run a level, race friends on it, see its +/// leaderboards, rate, remix, share. +public final class PackScreen { + private PackScreen() { + } + + public static void show(final String id) { + Api.forge().pack(id, Net.to(dto -> Api.forge().stars(id, Net.to(stars -> Ui.later(() -> show(dto, stars)), + Ui::failed)), Ui::failed)); + } + + private static void show(final PackDto dto, List stars) { + final PackSummaryDto s = dto.summary(); + final ContentPack pack; + try { + pack = PackCodec.read(dto.content()); + } catch (IllegalArgumentException e) { + Ui.failed(0, "That pack could not be read: " + e.getMessage()); + return; + } + Levels.remember(pack); + final Map won = new HashMap(); + for (LevelStarsDto m : stars) { + won.put(m.levelId(), Integer.valueOf(m.stars())); + } + Form f = Ui.form(s.name(), "pack"); + Ui.back(f, () -> ForgeHome.show(s.official() ? "official" : "popular")); + f.add(Ui.label("by " + s.author() + (s.official() ? " Official" : ""), "ForgeByline")); + if (s.description().length() > 0) { + f.add(Ui.text(s.description(), "ForgeHelpText")); + } + if (s.pendingAssets()) { + f.add(Ui.text("Some of this pack's files are waiting for a moderator. Friends with the link can play " + + "it now; it is listed for everyone once they are approved.", "ForgeHelpText")); + } + f.add(Ui.label("Levels", "SectionTitle")); + for (LevelDef l : pack.levels) { + Integer w = won.get(l.id); + f.add(level(pack, l, w == null ? 0 : w.intValue())); + } + f.add(Ui.label("Share", "SectionTitle")); + ShareButton share = new ShareButton(); + share.setText("Send to a friend"); + share.setUIID("PrimaryButton"); + // The share icon ShareButton brings is drawn on white; a material + // icon takes the button's own colours. + share.setMaterialIcon(com.codename1.ui.FontImage.MATERIAL_SHARE); + share.setName("sharePack"); + share.setTextToShare("Play \"" + s.name() + "\" in Dash: " + AppConfig.packLink(s.id())); + f.add(share); + f.add(Ui.row(Ui.secondary("Copy the link", "copyPack", e -> { + Display.getInstance().copyToClipboard(AppConfig.packLink(s.id())); + Ui.toast("Copied"); + }), Ui.secondary("Remix", "remixPack", e -> remix(s)))); + String me = Session.user() == null ? "" : Session.user().username(); + if (me.equals(s.owner())) { + f.add(Ui.secondary("Edit", "editPack", e -> PackEditor.open(s.id()))); + } else { + f.add(Ui.row(Ui.secondary("Rate", "ratePack", e -> rate(s)), + Ui.secondary("Report", "reportPack", e -> report(s)))); + } + f.show(); + } + + /// A level's card, the same as the official ones on the level list. A + /// community run is on a seed of the level's own, the same every time, + /// so its all-time board compares like with like. + private static com.codename1.ui.Container level(final ContentPack pack, final LevelDef l, int stars) { + final Levels.Playable p = Levels.of(pack, l.id); + final String ref = pack.id + "/" + l.id; + return LevelsForm.card(l, stars, + e -> RunForm.solo(p, l.contentHash(), "solo", null, () -> show(pack.id)), + "Race friends", com.codename1.ui.FontImage.MATERIAL_GROUPS, e -> RaceLobby.host(ref), + e -> ScoresForm.show(ref, () -> show(pack.id))); + } + + private static void remix(final PackSummaryDto s) { + final TextField name = new TextField(Fields.slug("my-" + s.id(), "my-pack")); + name.setName("remixId"); + if (Dialog.show("Remix", name, new com.codename1.ui.Command("Remix"), + new com.codename1.ui.Command("Cancel")).getCommandName().equals("Remix")) { + Api.forge().fork(s.id(), new ForkDto(name.getText()), Net.to(made -> Ui.later( + () -> PackEditor.open(made.id())), Ui::failed)); + } + } + + private static void rate(final PackSummaryDto s) { + String[] choices = {"1 star", "2 stars", "3 stars", "4 stars", "5 stars"}; + Dialog d = new Dialog("Rate " + s.name()); + final int[] picked = {0}; + d.add(Fields.choice("Rating", "rating", choices, 4, v -> picked[0] = v + 1)); + picked[0] = 5; + d.add(Ui.primary("Rate", "rateSend", e -> { + d.dispose(); + Api.forge().rate(new RateDto(s.id(), picked[0]), Net.to(r -> Ui.toast("Thanks for rating"), + Ui::failed)); + })); + d.show(); + } + + private static void report(final PackSummaryDto s) { + final TextField why = new TextField("", "What is wrong with it?"); + why.setName("reportReason"); + if (Dialog.show("Report", why, new com.codename1.ui.Command("Report"), + new com.codename1.ui.Command("Cancel")).getCommandName().equals("Report")) { + Api.forge().report(new PackReportDto(s.id(), why.getText()), Net.to(r -> Ui.toast("Reported"), + Ui::failed)); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/TrackTimeline.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/TrackTimeline.java new file mode 100644 index 00000000000..aa0c441e4dd --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/forge/TrackTimeline.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.forge; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.Clearance; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.PickupDef; +import com.codenameone.examples.dash.rules.PickupKind; +import com.codenameone.examples.dash.rules.Placement; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.rules.ViewCue; +import com.codenameone.examples.dash.rules.ViewMode; + +/// The level as a strip you can paint on: distance runs left to right, one +/// row per lane and an "all lanes" row on top, camera cues as flags along the +/// bottom. Scroll it sideways; tap a cell to put the selected piece there, +/// tap a piece to remove it, and with a camera tool tap anywhere to drop a +/// cue -- the swing from the side-on view to the chase view is a flag here. +// tag::dash-timeline[] +public final class TrackTimeline extends Component { + /// What a tap does. + public interface Tool { + /// Applies the tool at `metres` in `lane` ([Placement#ALL_LANES] for + /// the top row); returns true when the level changed. + boolean apply(LevelDef level, int metres, int lane); + } + + /// Hears about every change, to refresh the problems list. + public interface Changed { + void changed(); + } + + private static final int STEP_METRES = 5; + private final LevelDef level; + private final Catalog catalog; + private Tool tool; + private Changed changed; + private int pressX; + private int pressY; + + public TrackTimeline(LevelDef level, Catalog catalog) { + this.level = level; + this.catalog = catalog; + setUIID("Timeline"); + setFocusable(true); + setName("timeline"); + } + + public void setTool(Tool tool) { + this.tool = tool; + } + + public void setChanged(Changed changed) { + this.changed = changed; + } + + private int ppm() { + return Math.max(4, CN.convertToPixels(1.6f)); + } + + private int rowH() { + return CN.convertToPixels(6); + } + + private int lanes() { + return Math.max(1, level.lanes); + } + + /// Metres the strip shows: the level's length, or the hand-built part of + /// an endless one plus room to keep building. + private int metres() { + int end = level.length > 0 ? level.length / Track.UNIT : 0; + for (Placement p : level.placements) { + end = Math.max(end, p.z / Track.UNIT + 40); + } + for (ViewCue c : level.cues) { + end = Math.max(end, c.at / Track.UNIT + 40); + } + return Math.max(end, 200); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(metres() * ppm() + rowH(), rowH() * (lanes() + 2)); + } + + @Override + public void paint(Graphics g) { + int x0 = getX(); + int y0 = getY(); + int ppm = ppm(); + int rh = rowH(); + int w = metres() * ppm; + g.setColor(0x1d2233); + g.fillRect(x0, y0, w + rh, rh * (lanes() + 2)); + Font small = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL); + g.setFont(small); + for (int r = 0; r <= lanes(); r++) { + g.setColor(r == 0 ? 0x2b3350 : r % 2 == 0 ? 0x252b40 : 0x2a3148); + g.fillRect(x0, y0 + r * rh, w + rh, rh - 1); + } + for (int m = 0; m <= metres(); m += 10) { + g.setColor(m % 50 == 0 ? 0x5a6488 : 0x3a4262); + g.drawLine(x0 + m * ppm, y0, x0 + m * ppm, y0 + rh * (lanes() + 1)); + if (m % 50 == 0) { + g.setColor(0xaab4d8); + g.drawString(m + " m", x0 + m * ppm + 2, y0 + rh * (lanes() + 1) + 2); + } + } + if (level.length > 0) { + int fx = x0 + level.length / Track.UNIT * ppm; + g.setColor(0xffffff); + g.fillRect(fx, y0, 3, rh * (lanes() + 1)); + } + for (Placement p : level.placements) { + int px = x0 + p.z / Track.UNIT * ppm; + int row = p.lane == Placement.ALL_LANES ? 0 : 1 + Math.min(p.lane, lanes() - 1); + ObstacleDef o = catalog.obstacle(p.defId); + int width = o == null ? ppm * 2 : Math.max(ppm, o.depth / Track.UNIT * ppm); + g.setColor(color(p.defId)); + if (row == 0) { + g.fillRect(px, y0 + 2, width, rh * (lanes() + 1) - 4); + g.setColor(0x000000); + } else if (o == null) { + g.fillArc(px, y0 + row * rh + rh / 4, rh / 2, rh / 2, 0, 360); + } else { + g.fillRect(px, y0 + row * rh + 3, width, rh - 6); + } + } + for (ViewCue c : level.cues) { + int cx = x0 + c.at / Track.UNIT * ppm; + int by = y0 + rh * (lanes() + 1); + g.setColor(0xffd34d); + g.drawLine(cx, y0, cx, by + rh); + int bw = Math.max(ppm, c.blendMillis / Track.UNIT * ppm); + g.setAlpha(90); + g.fillRect(cx, by, bw, rh / 2); + g.setAlpha(255); + g.drawString(modeName(c.mode), cx + 3, by + rh / 2); + } + } + + private int color(String defId) { + ObstacleDef o = catalog.obstacle(defId); + if (o != null) { + if (o.clearance == Clearance.JUMP) { + return o.height == 0 ? 0x101018 : 0xff9a3c; + } + return o.clearance == Clearance.SLIDE ? 0xe0405a : 0x4a78c8; + } + PickupDef k = catalog.pickup(defId); + return k != null && k.kind == PickupKind.COIN ? 0xffcc33 : 0x3ce0ff; + } + + static String modeName(ViewMode m) { + return m == ViewMode.SIDE_2D ? "2D" : m == ViewMode.TOP_DOWN ? "Top" : "3D"; + } + + @Override + public void pointerPressed(int x, int y) { + super.pointerPressed(x, y); + pressX = x; + pressY = y; + } + + @Override + public void pointerReleased(int x, int y) { + super.pointerReleased(x, y); + int slop = CN.convertToPixels(2); + if (tool == null || Math.abs(x - pressX) > slop || Math.abs(y - pressY) > slop) { + return; + } + int lx = x - getAbsoluteX() + getScrollX(); + int ly = y - getAbsoluteY() + getScrollY(); + int metres = (lx / ppm()) / STEP_METRES * STEP_METRES; + int row = ly / rowH(); + int lane = row == 0 ? Placement.ALL_LANES : Math.min(lanes() - 1, row - 1); + if (tool.apply(level, metres, lane)) { + setShouldCalcPreferredSize(true); + getParent().revalidateLater(); + repaint(); + if (changed != null) { + changed.changed(); + } + } + } +} +// end::dash-timeline[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Api.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Api.java new file mode 100644 index 00000000000..5db04a5d598 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Api.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.net; + +import com.codename1.io.rest.RestClients; +import com.codenameone.examples.dash.api.AccountApi; +import com.codenameone.examples.dash.api.AdminApi; +import com.codenameone.examples.dash.api.ForgeApi; +import com.codenameone.examples.dash.api.GameApi; +import com.codenameone.examples.dash.api.ProgressApi; +import com.codenameone.examples.dash.api.ShopApi; +import com.codenameone.examples.dash.api.SocialApi; + +/// The generated REST clients, one per contract in the shared module, all +/// pointed at the server [Session] is using. +public final class Api { + private static AccountApi account; + private static GameApi game; + private static ShopApi shop; + private static AdminApi admin; + private static ProgressApi progress; + private static ForgeApi forge; + private static SocialApi social; + + private Api() { + } + + static void connect(String baseUrl) { + account = RestClients.create(AccountApi.class, baseUrl); + game = RestClients.create(GameApi.class, baseUrl); + shop = RestClients.create(ShopApi.class, baseUrl); + admin = RestClients.create(AdminApi.class, baseUrl); + progress = RestClients.create(ProgressApi.class, baseUrl); + forge = RestClients.create(ForgeApi.class, baseUrl); + social = RestClients.create(SocialApi.class, baseUrl); + } + + public static AccountApi account() { + return account; + } + + public static GameApi game() { + return game; + } + + public static ShopApi shop() { + return shop; + } + + public static AdminApi admin() { + return admin; + } + + public static ProgressApi progress() { + return progress; + } + + public static ForgeApi forge() { + return forge; + } + + public static SocialApi social() { + return social; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/GameLink.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/GameLink.java new file mode 100644 index 00000000000..d2f6901e97a --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/GameLink.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.net; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.JSONParser; +import com.codename1.io.WebSocket; +import com.codename1.io.WebSocketState; +import com.codename1.ui.CN; +import java.io.IOException; +import java.util.Map; +import java.util.Timer; + +/// The game socket, `/ws/game` on the server. One is open while the player is +/// queued, playing, or (as an admin) watching. +/// +/// Opening it takes a one-time ticket from the REST API, because a browser +/// cannot put a bearer token on a WebSocket. Every message arrives on the EDT, +/// already parsed. +// tag::dash-game-link[] +public final class GameLink { + /// What a screen does with the socket. + public interface Listener { + void message(Map message); + + /// The socket is gone. `code` is the WebSocket close code, or 0 when + /// it never opened or dropped without one. + void closed(int code); + } + + private static final int PING_MILLIS = 20000; + private final Listener listener; + private WebSocket socket; + private Timer pinger; + private boolean closed; + + private GameLink(Listener listener) { + this.listener = listener; + } + + /// Opens a socket; `listener` hears `ready` first, then whatever follows. + public static GameLink open(Listener listener) { + final GameLink link = new GameLink(listener); + if (!WebSocket.isSupported()) { + CN.callSerially(() -> link.lost(0)); + return link; + } + Api.account().gameTicket(Net.to(ticket -> link.connect(ticket.ticket()), + (status, message) -> link.lost(0))); + return link; + } + + private void connect(String ticket) { + if (closed) { + return; + } + String base = Session.serverUrl(); + String url = "ws" + base.substring(4) + "/ws/game?ticket=" + ticket; + socket = WebSocket.build(url) + .onConnect(ws -> CN.callSerially(this::opened)) + .onTextMessage((ws, text) -> CN.callSerially(() -> frame(text))) + .onClose((ws, code, reason) -> CN.callSerially(() -> lost(code))) + .onError((ws, err) -> CN.callSerially(() -> lost(0))) + .connect(10000); + } + + private void opened() { + pinger = CN.setInterval(PING_MILLIS, () -> send("{\"type\":\"ping\"}")); + } + + private void frame(String text) { + if (closed) { + return; + } + try { + listener.message(new JSONParser().parseJSON(new CharArrayReader(text.toCharArray()))); + } catch (IOException malformed) { + // A frame that is not JSON is not from our server; ignore it. + } + } + + private void lost(int code) { + if (closed) { + return; + } + close(); + listener.closed(code); + } + + public void send(String json) { + if (!closed && socket != null && socket.getReadyState() == WebSocketState.OPEN) { + socket.send(json); + } + } + + public void close() { + closed = true; + if (pinger != null) { + pinger.cancel(); + pinger = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } + + public boolean isClosed() { + return closed; + } + + public static String text(Map m, String key) { + Object v = m.get(key); + return v == null ? "" : v.toString(); + } + + public static int number(Map m, String key) { + Object v = m.get(key); + return v instanceof Number ? ((Number) v).intValue() : 0; + } +} +// end::dash-game-link[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Net.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Net.java new file mode 100644 index 00000000000..e9442f6e357 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Net.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.net; + +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Makes a call through a generated client and sorts out what came back. +/// +/// A generated method answers with a `Response` whatever happened: null when +/// the server could not be reached, and otherwise a status with either the +/// typed result or, for a refusal, the server's reason as text. This turns that +/// into one of two callbacks, so a screen writes what to do with the result and +/// what to do with a failure, and nothing else. +/// +/// ```java +/// Api.game().runner(Net.to(r -> show(r), Ui::fail)); +/// ``` +public final class Net { + /// A call to a generated client method, waiting for its callback. + public interface Call { + void run(OnComplete> callback); + } + + public interface Ok { + void got(T value); + } + + public interface Failed { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param message something to show the user + void failed(int status, String message); + } + + private Net() { + } + + /// The callback to hand a generated method: `ok` gets the result, `failed` + /// everything else. + public static OnComplete> to(final Ok ok, final Failed failed) { + return new OnComplete>() { + @Override + public void completed(Response response) { + if (response == null) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + int status = response.getResponseCode(); + if (status >= 200 && status < 300) { + ok.got(response.getResponseData()); + return; + } + failed.failed(status, reason(response, status)); + } + }; + } + + /// Makes a call that was handed over as a value, which is how one screen + /// serves several calls of the same shape. + public static void send(Call call, Ok ok, Failed failed) { + call.run(to(ok, failed)); + } + + /// What the server said when it refused. Read as an `Object` and tested, + /// never cast to the type the call was declared with: on a refusal the body + /// is text whatever that type is. + private static String reason(Response response, int status) { + Object body = response.getResponseData(); + if (body instanceof String && ((String) body).length() > 0 + && ((String) body).length() < 200 && ((String) body).indexOf('<') < 0 + && ((String) body).indexOf('{') < 0) { + return (String) body; + } + if (status == 401) { + return "Please sign in again."; + } + if (status == 403) { + return "Your account is not allowed to do that."; + } + if (status == 429) { + return "Too many attempts. Wait a moment and try again."; + } + return "Something went wrong (" + status + "). Try again."; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Session.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Session.java new file mode 100644 index 00000000000..4ea35ad5acb --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/net/Session.java @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcRequestAuthorizer; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.PkceChallenge; +import com.codename1.io.oidc.SecureStorageTokenStore; +import com.codename1.io.oidc.TokenStore; +import com.codename1.ui.CN; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.api.PlayerDto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; + +/// Who is signed in, and the tokens that say so. +/// +/// Signing in is OAuth's authorization-code flow with PKCE against the app's +/// own server, made without a browser: the app shows its own sign-in screen, +/// posts the password to the server for a short-lived session, exchanges the +/// session for an authorization code and the code for tokens. The password is +/// sent once, to the server that owns it, and is never stored; what is stored +/// is the token pair, in the device's secure storage. +/// +/// From then on [OidcRequestAuthorizer] does the work: it puts the access token +/// on every request under `/api`, renews it shortly before it expires, and +/// renews and retries when the server refuses one. When renewing fails -- the +/// refresh token ran out, or the account was suspended -- it says so, and the +/// app goes back to the welcome screen. +public final class Session { + private static final String STORE_PREFIX = "dash"; + private static final String SESSION_COOKIE = "CN1SESSION"; + + private static String base; + private static OidcClient client; + private static OidcRequestAuthorizer authorizer; + private static TokenStore store; + private static PlayerDto user; + private static Runnable onSignedOut; + /// Counts the sessions: moved on whenever the server changes, an account + /// signs in, or the session ends. An answer to a request made in an + /// earlier session describes somebody who is no longer signed in here -- + /// another server's account, or the account before a sign-out on this + /// one -- and is dropped rather than installed for whoever is now. + private static int session; + + private Session() { + } + + /// Points the app at a server. Called at start-up, and again when the + /// server address is changed. + public static void start(String baseUrl) { + if (store == null) { + // Falls back to ordinary storage where the platform has no keystore + // to offer, which is the simulator. + store = new SecureStorageTokenStore().allowPlainStorageFallback(true); + } + if (base != null) { + NetworkManager.getInstance().setAuthorizer(base + "/api", null); + if (!base.equals(baseUrl)) { + // Changing the server ends the session with the old one, and + // its tokens go with it. + store.clear(storeKey(base)); + } + } + base = baseUrl; + setUser(null); + session++; + client = OidcClient.create(OidcConfiguration.newBuilder() + .issuer(base) + .authorizationEndpoint(base + "/oauth2/authorize") + .tokenEndpoint(base + "/oauth2/token") + .revocationEndpoint(base + "/oauth2/revoke") + .jwksUri(base + "/oauth2/jwks") + .build()) + .setClientId(AppConfig.CLIENT_ID) + .setRedirectUri(AppConfig.redirectUri()) + .setScopes("openid", "profile") + .setTokenStore(store) + .setStoreKey(storeKey(base)); + authorizer = new OidcRequestAuthorizer(client); + authorizer.install(base + "/api"); + // Not tied to this start(). A request to an earlier server still in + // flight when the app switched, answered 401 with its refresh refused, + // signs the new server out too: one more sign-in, after a server + // switch that already asked for one, and nothing of either account is + // exposed by it. + authorizer.addSignInRequiredListener((source, reason) -> { + setUser(null); + session++; + if (onSignedOut != null) { + onSignedOut.run(); + } + }); + Api.connect(base); + } + + /// Where the tokens issued by one server are kept: under a name made from + /// that server's address. + /// + /// One name for every server would hand the tokens of the last one to + /// whichever address is typed next -- the app would restore them and send + /// them, refresh token included, to a server that never issued them. + /// Every character that is not a letter or a digit is written as its code, + /// so two addresses never share a name and the name is one any platform's + /// storage accepts. + static String storeKey(String server) { + StringBuilder key = new StringBuilder(STORE_PREFIX).append('-'); + for (int iter = 0; iter < server.length(); iter++) { + char c = server.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + key.append(c); + } else { + key.append('_').append(Integer.toHexString(c)).append('_'); + } + } + return key.toString(); + } + + /// Keeps the tokens somewhere else. The tests keep them in memory, so one + /// run leaves nothing behind for the next. + public static void useStore(TokenStore tokens) { + store = tokens; + } + + /// What to do when the session ends without the user asking: go back to + /// the welcome screen. + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + /// The signed-in user as the server last described them, or null. + public static PlayerDto user() { + return user; + } + + public static void setUser(PlayerDto described) { + user = described; + } + + /// Who what the device keeps belongs to: the signed-in account, or null. + /// See [#owner(String)]. + public static String owner() { + return user == null || user.username() == null ? null : owner(user.username()); + } + + /// The device's name for the account `username` on the server in use. A + /// name is unique on one server only: the same e-mail address signed in + /// to two servers is two accounts, and what one left -- recent places, + /// a change it has not sent yet -- is not the other's to see or send. + public static String owner(String username) { + return storeKey(base == null ? "" : base) + "/" + accountName(username); + } + + /// Picks up the session a previous run left, if it is still good. + /// + /// @param signedIn there is a session, and [#user] is set + /// @param signedOut there is none + /// @param unreachable there is one, but the server did not answer + public static void restore(final Runnable signedIn, final Runnable signedOut, + final Runnable unreachable) { + // Loading the tokens can outlast the session it was asked in -- the + // server is changed while it reads -- and then neither answer is this + // session's to act on: see [#session]. + final int asked = session; + authorizer.load().ready(new SuccessCallback() { + @Override + public void onSucess(OidcTokens tokens) { + if (asked != session) { + return; + } + if (tokens == null || tokens.getAccessToken() == null) { + signedOut.run(); + return; + } + refreshUser(signedIn, (status, message) -> { + if (status == 0) { + unreachable.run(); + } else { + signedOut.run(); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == session) { + signedOut.run(); + } + } + }); + } + + /// Asks the server who this is. The roles in the answer decide which modes + /// the app offers, so it is asked again whenever that could have changed. + public static void refreshUser(final Runnable done, Net.Failed failed) { + // An answer from a session that has since ended -- the server was + // changed while an approval poll waited, or the account signed out + // and another signed in -- describes that account, not this one; + // it is dropped. See [#session]. + final int asked = session; + Api.account().me(Net.to(described -> { + if (asked != session) { + return; + } + setUser(described); + done.run(); + }, (status, message) -> { + if (asked == session) { + failed.failed(status, message); + } + })); + } + + /// Signs in with an e-mail address and password. + /// An e-mail address the way the server names its account: trimmed, with + /// its ASCII letters in lower case (`Accounts.email` on the server). Signing + /// in as `Player@Dash.example` must reach, and record its mode under, the + /// same account as `player@dash.example`. Folded by hand: `toLowerCase()` + /// follows the device locale, and under Turkish the `I` would not fold to `i`. + public static String accountName(String email) { + if (email == null) { + return null; + } + char[] chars = email.trim().toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + public static void signIn(String email, String password, final Runnable done, + final Net.Failed failed) { + // The server can be changed while the sign-in is under way, and + // start() then puts a new authorizer in place. Tokens the old server + // issued are dropped rather than installed in the new one, which would + // send them -- the refresh token too -- to a server that never issued + // them. See [#session]. + final int asked = session; + authenticate(email, password, tokens -> { + if (asked != session) { + return; + } + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }, failed); + } + + /// Exchanges an e-mail address and password for tokens, and does nothing + /// with them: [#signIn] is this, and then keeping what comes back. + public static void authenticate(final String email, final String password, + final Net.Ok got, final Net.Failed failed) { + final PkceChallenge pkce = PkceChallenge.generate(); + // Any unguessable value does for the state; this is one. + final String state = PkceChallenge.generate().getVerifier(); + // In a browser the app sees neither the session cookie nor a redirect: + // the browser keeps the first and follows the second. So there the + // password's answer is its status, and the code is read from the page + // the redirect ends on -- see AppConfig.redirectUri(). + final boolean web = AppConfig.pageOrigin() != null; + final String redirect = AppConfig.redirectUri(); + + // Every step goes to the server the sign-in started with, and none goes + // anywhere once the user has changed server: the session cookie of + // step 1 and the verifier of step 3 are only for the first server, and + // a new one that received them could replay them there for tokens. + final String server = base; + // 1. The password, for a session. + final Step login = new Step("POST", server + "/login"); + login.addArgument("username", accountName(email)); + login.addArgument("password", password); + login.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + if (login.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + if (login.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + return; + } + if (web ? login.status != 200 : login.session == null) { + failed.failed(401, "That e-mail and password do not match an account."); + return; + } + // 2. The session, for an authorization code. + final Step authorize = new Step("GET", server + "/oauth2/authorize"); + if (web) { + // Or a session that did not take is answered with a page. + authorize.addRequestHeader("Accept", "application/json"); + } else { + authorize.addRequestHeader("Cookie", login.session); + } + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", redirect); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + String code = web ? authorize.answer("code") + : parameter(authorize.location, "code"); + String returned = web ? authorize.answer("state") + : parameter(authorize.location, "state"); + // The state has to come back as it went: a code that arrives + // with another was not issued for this sign-in. + if (code == null || !state.equals(returned)) { + failed.failed(authorize.status, "Signing in failed. Try again."); + return; + } + // 3. The code, for tokens. + final Step token = new Step("POST", server + "/oauth2/token"); + token.addArgument("grant_type", "authorization_code"); + token.addArgument("code", code); + token.addArgument("redirect_uri", redirect); + token.addArgument("code_verifier", pkce.getVerifier()); + token.addArgument("client_id", AppConfig.CLIENT_ID); + token.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + OidcTokens tokens = token.status == 200 ? token.tokens() : null; + if (tokens == null || tokens.getAccessToken() == null) { + failed.failed(token.status, "Signing in failed. Try again."); + return; + } + // 4. The session has done its work, and is ended rather + // than left to run out: in a browser it is a cookie that + // would go on signing this user in to whoever sat down + // next. Nothing waits on the answer; the tokens stand + // without it. + Step end = new Step("POST", server + "/logout"); + if (!web) { + end.addRequestHeader("Cookie", login.session); + } + end.send(() -> { }); + got.got(tokens); + }); + }); + }); + } + + /// Signs out: the refresh token is revoked at the server, so the session + /// is over there too and not only forgotten here. + public static void signOut(final Runnable done) { + OidcTokens tokens = authorizer.getTokens(); + if (tokens != null && tokens.getRefreshToken() != null) { + // Sent and not waited for. Signing out is something the user does + // to this phone, and it is done here whether or not the server + // answers: the tokens are gone from the device either way. A + // revocation that is lost leaves a refresh token only someone who + // already copied it off the phone could use, and it expires on + // its own; holding the sign-out until the network allows would + // trap the user signed in on a phone with no connection. + client.revoke(tokens.getRefreshToken()); + } + setUser(null); + session++; + authorizer.signOut().ready(new SuccessCallback() { + @Override + public void onSucess(Boolean cleared) { + done.run(); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + done.run(); + } + }); + } + + /// A parameter of the query string of `url`, or null. + static String parameter(String url, String name) { + int query = url == null ? -1 : url.indexOf('?'); + if (query < 0) { + return null; + } + int at = query + 1; + while (at < url.length()) { + int end = url.indexOf('&', at); + if (end < 0) { + end = url.length(); + } + if (url.startsWith(name + "=", at)) { + return Util.decode(url.substring(at + name.length() + 1, end), "UTF-8", false); + } + at = end + 1; + } + return null; + } + + /// One request of the sign-in. It follows no redirect and keeps no cookie + /// jar, because the redirect and the cookie are the answers it is after; + /// and it carries no bearer token, because there is not one yet. + private static final class Step extends ConnectionRequest { + int status; + String location; + String session; + private byte[] body; + private Runnable then; + private boolean reported; + + Step(String method, String url) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method)); + setFollowRedirects(false); + setCookiesEnabled(false); + setReadResponseForErrors(true); + setDuplicateSupported(true); + setFailSilently(true); + setAuthorizer(RequestAuthorizer.NONE); + } + + void send(Runnable next) { + then = next; + NetworkManager.getInstance().addToQueue(this); + } + + /// A text member of the JSON this request was answered with, or null. + String answer(String name) { + if (status != 200 || body == null) { + return null; + } + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body), "UTF-8")); + Object value = json == null ? null : json.get(name); + return value instanceof String ? (String) value : null; + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + OidcTokens tokens() { + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body == null ? new byte[0] : body), "UTF-8")); + return OidcTokens.fromTokenResponse(json, null); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + @Override + protected void readHeaders(Object connection) throws IOException { + location = getHeader(connection, "Location"); + String[] cookies = getHeaders(connection, "Set-Cookie"); + for (int iter = 0; cookies != null && iter < cookies.length; iter++) { + if (cookies[iter] != null && cookies[iter].startsWith(SESSION_COOKIE + "=")) { + int end = cookies[iter].indexOf(';'); + session = end < 0 ? cookies[iter] : cookies[iter].substring(0, end); + } + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + status = code; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (status == 0) { + status = getResponseCode(); + } + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (status == 0) { + status = getResponseCode(); + } + report(); + } + + @Override + protected void handleException(Exception err) { + status = 0; + report(); + } + + private void report() { + if (!reported) { + reported = true; + CN.callSerially(then); + } + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/CityPath.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/CityPath.java new file mode 100644 index 00000000000..c2f705a0043 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/CityPath.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.GeneralPath; +import java.util.List; + +/// The line a city route draws on the street map, kept for the run's minimap: +/// the route's shape, north up, with the stretch already run lit. +/// +/// It is cosmetic. The level is built from the route's legs alone +/// ([com.codenameone.examples.dash.rules.CityRoute]), so a replay without +/// this line plays the same and only goes without a minimap. +public final class CityPath { + private final float[] x; + private final float[] y; + private final float[] along; + + private CityPath(float[] x, float[] y, float[] along) { + this.x = x; + this.y = y; + this.along = along; + } + + /// The route's vertices projected flat around their middle, scaled into + /// a unit square and centred in it. + public static CityPath of(List points) { + int n = points.size(); + if (n < 2) { + return null; + } + double lat0 = 0; + for (int i = 0; i < n; i++) { + lat0 += points.get(i).getLatitude(); + } + double k = Math.cos(Math.toRadians(lat0 / n)); + double[] px = new double[n]; + double[] py = new double[n]; + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + for (int i = 0; i < n; i++) { + px[i] = points.get(i).getLongitude() * k; + // North up: latitude grows up the screen, y grows down it. + py[i] = -points.get(i).getLatitude(); + minX = Math.min(minX, px[i]); + minY = Math.min(minY, py[i]); + maxX = Math.max(maxX, px[i]); + maxY = Math.max(maxY, py[i]); + } + double span = Math.max(1e-9, Math.max(maxX - minX, maxY - minY)); + double offX = (span - (maxX - minX)) / 2; + double offY = (span - (maxY - minY)) / 2; + float[] x = new float[n]; + float[] y = new float[n]; + float[] along = new float[n]; + for (int i = 0; i < n; i++) { + x[i] = (float) ((px[i] - minX + offX) / span); + y[i] = (float) ((py[i] - minY + offY) / span); + if (i > 0) { + double dx = x[i] - x[i - 1]; + double dy = y[i] - y[i - 1]; + along[i] = along[i - 1] + (float) Math.sqrt(dx * dx + dy * dy); + } + } + return new CityPath(x, y, along); + } + + /// A small square that draws the path, lit up to `progress`. + public static final class Minimap extends Component { + private final CityPath path; + private float progress; + + public Minimap(CityPath path) { + this.path = path; + setUIID("CityMinimap"); + setFocusable(false); + } + + /// How far along the route the runner is, 0 to 1. + public void setProgress(float p) { + float clamped = Math.max(0f, Math.min(1f, p)); + if (Math.abs(clamped - progress) > 0.002f) { + progress = clamped; + repaint(); + } + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + int s = com.codename1.ui.CN.convertToPixels(17); + return new com.codename1.ui.geom.Dimension(s, s); + } + + @Override + public void paint(Graphics g) { + super.paint(g); + int pad = com.codename1.ui.CN.convertToPixels(1.8f); + int ox = getX() + pad; + int oy = getY() + pad; + int size = Math.min(getWidth(), getHeight()) - pad * 2; + if (size <= 0) { + return; + } + g.setAntiAliased(true); + float total = path.along[path.along.length - 1]; + float lit = total * progress; + GeneralPath all = new GeneralPath(); + GeneralPath done = new GeneralPath(); + float hx = path.x[0]; + float hy = path.y[0]; + all.moveTo(ox + path.x[0] * size, oy + path.y[0] * size); + done.moveTo(ox + path.x[0] * size, oy + path.y[0] * size); + for (int i = 1; i < path.x.length; i++) { + all.lineTo(ox + path.x[i] * size, oy + path.y[i] * size); + if (path.along[i] <= lit) { + done.lineTo(ox + path.x[i] * size, oy + path.y[i] * size); + hx = path.x[i]; + hy = path.y[i]; + } else if (path.along[i - 1] < lit) { + float t = (lit - path.along[i - 1]) / (path.along[i] - path.along[i - 1]); + hx = path.x[i - 1] + (path.x[i] - path.x[i - 1]) * t; + hy = path.y[i - 1] + (path.y[i] - path.y[i - 1]) * t; + done.lineTo(ox + hx * size, oy + hy * size); + } + } + int w = Math.max(2, com.codename1.ui.CN.convertToPixels(0.6f)); + int alpha = g.getAlpha(); + g.setColor(0x6b7bd6); + g.setAlpha(170); + g.drawShape(all, new Stroke(w, Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 1f)); + g.setAlpha(255); + g.setColor(0xd4ff3a); + g.drawShape(done, new Stroke(w + 1, Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 1f)); + // The finish, and the runner as a dot on the line. + int fx = (int) (ox + path.x[path.x.length - 1] * size); + int fy = (int) (oy + path.y[path.y.length - 1] * size); + g.setColor(0xff4f9a); + g.fillArc(fx - w * 2, fy - w * 2, w * 4, w * 4, 0, 360); + g.setColor(0xffffff); + g.fillArc((int) (ox + hx * size) - w * 2, (int) (oy + hy * size) - w * 2, w * 4, w * 4, 0, 360); + g.setAlpha(alpha); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Driver.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Driver.java new file mode 100644 index 00000000000..b69f1ffada9 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Driver.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.rules.Run; + +/// Where a run's ticks come from. The view asks whether it may step, steps, +/// and draws; a driver decides which inputs the step sees. +/// +/// - [SoloDriver]: offline, every tick at once, recorded for the leaderboard. +/// - [RaceDriver]: lockstep with the server, never past the tick it allows. +/// - [ReplayDriver]: a finished run's log, at any speed, seekable. +/// +/// All three step the same [Run] -- the class the server runs -- which is why +/// a rule changed in the rules module is changed in every one of them. +// tag::dash-driver[] +public abstract class Driver { + protected Run run; + /// The runner the camera follows. + protected int me; + + public Run run() { + return run; + } + + public int me() { + return me; + } + + public String name(int runner) { + return runner == me ? "You" : "Runner " + (runner + 1); + } + + /// Whether the next tick may be simulated now. + public abstract boolean canStep(); + + /// Simulates one tick. + public abstract void step(); + + /// The player pressed `bits` ([com.codenameone.examples.dash.rules.Input]). + /// The driver decides on which tick -- or whether -- it lands. + public abstract void input(int bits); + + /// Ghost runs drawn beside the player's, which never touch the player's + /// run: a leaderboard ghost or a bot to race offline. Empty by default. + public Run[] ghosts() { + return new Run[0]; + } + + /// Whether the player steers at all (not in a replay). + public boolean interactive() { + return true; + } + + /// How many ticks to simulate per tick of wall time; the replay viewer + /// speeds up and slows down. + public int speedPercent() { + return 100; + } + + /// Ticks still to wait before the first step: a race's countdown. + public int countdownTicks() { + return 0; + } + + public void dispose() { + } +} +// end::dash-driver[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Glow.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Glow.java new file mode 100644 index 00000000000..2436f4891fa --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Glow.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gaming.GameCamera; +import com.codename1.gaming.Model; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.gpu.Mesh; +import com.codename1.gpu.RenderState; +import com.codename1.gpu.Texture; +import com.codename1.util.MathUtil; + +/// Neon light without a bloom pass: soft white textures drawn additively on +/// flat quads, tinted by their material. A halo is a quad that turns to face +/// the camera; a streak is a stretched one along a tube or a beam; a +/// reflection is one lying on the floor under a light. +/// +/// The textures are painted once, in code, as alpha ramps -- white where the +/// light is, transparent where it is not -- so one texture serves every +/// colour. +final class Glow { + /// A round falloff, bright in the middle. + final Texture soft; + /// A long bar of light, soft across and fading at the ends. + final Texture streak; + /// A four-pointed sparkle with a hot core. + final Texture glint; + /// A dark round blot for a contact shadow, drawn with ordinary blending. + final Texture shadow; + + Glow(GraphicsDevice d) { + soft = texture(d, 64, 64, 0); + streak = texture(d, 64, 32, 1); + glint = texture(d, 64, 64, 2); + shadow = texture(d, 64, 64, 3); + } + + /// An additive, unlit material in `argb` over `tex`; its alpha sets how + /// strong the light is. + static Material additive(Texture tex, int argb) { + RenderState rs = RenderState.transparent().setBlendMode(RenderState.BlendMode.ADDITIVE) + .setDepthWrite(false).setCullMode(RenderState.CullMode.NONE); + return new Material(Material.Type.UNLIT).setTexture(tex).setColor(argb).setRenderState(rs); + } + + /// An alpha blended, unlit material, for shadows and haze. + static Material blended(Texture tex, int argb) { + RenderState rs = RenderState.transparent().setDepthWrite(false).setCullMode(RenderState.CullMode.NONE); + return new Material(Material.Type.UNLIT).setTexture(tex).setColor(argb).setRenderState(rs); + } + + /// A quad of `w` by `h` with `mat`, at a place. + static Model sprite(Mesh quad, Material mat, float x, float y, float z, float w, float h) { + Model m = new Model(quad, mat); + m.setScale(w, h, 1f); + m.setPosition(x, y, z); + return m; + } + + /// Turns an upright quad about Y so its face points at the camera. + static void face(Model m, GameCamera cam) { + float dx = cam.getEyeX() - m.getX(); + float dz = cam.getEyeZ() - m.getZ(); + if (dx * dx + dz * dz < 1e-6f) { + return; + } + float yaw = (float) Math.toDegrees(MathUtil.atan2(dx, dz)); + m.setRotation(m.getRotationX(), yaw, m.getRotationZ()); + } + + private static Texture texture(GraphicsDevice d, int w, int h, int kind) { + int[] px = new int[w * h]; + for (int y = 0; y < h; y++) { + float fy = (y + 0.5f) / h * 2f - 1f; + for (int x = 0; x < w; x++) { + float fx = (x + 0.5f) / w * 2f - 1f; + float a; + int rgb = 0xffffff; + if (kind == 1) { + float across = clamp(1f - Math.abs(fy)); + float along = clamp((1f - Math.abs(fx)) * 4f); + a = across * across * along; + } else if (kind == 2) { + float r = (float) Math.sqrt(fx * fx + fy * fy); + float core = clamp(1f - r * 2.2f); + float rays = clamp(1f - Math.abs(fx) * 14f) * clamp(1f - Math.abs(fy)) + + clamp(1f - Math.abs(fy) * 14f) * clamp(1f - Math.abs(fx)); + float halo = clamp(1f - r); + a = clamp(core * core + rays * 0.9f + halo * halo * 0.25f); + } else { + float r = (float) Math.sqrt(fx * fx + fy * fy); + float k = clamp(1f - r); + if (kind == 3) { + rgb = 0; + a = k * k * (3f - 2f * k); + } else { + a = k * k * k + k * 0.15f; + } + } + px[y * w + x] = ((int) (clamp(a) * 255f) << 24) | rgb; + } + } + Texture t = d.createTexture(w, h, px); + t.setFilter(Texture.Filter.LINEAR); + return t; + } + + private static float clamp(float f) { + return f < 0f ? 0f : f > 1f ? 1f : f; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Landmarks.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Landmarks.java new file mode 100644 index 00000000000..6a9e2757986 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Landmarks.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.maps.LatLng; +import com.codename1.ui.CN; +import com.codenameone.examples.dash.rules.CityRoute; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// The named places a route passes: landmarks, churches, museums, parks, +/// stations and monuments within a short walk of it, each placed by how far +/// along the route it stands so a city run can show it beside the track. +/// +/// The default source asks OpenStreetMap's data through the public Overpass +/// API, which is rate limited and has no service guarantee; point +/// [#setSource] at your own Overpass server before you ship. Tests set a +/// source of their own. +public final class Landmarks { + /// A place found near the route. + public static final class Place { + public final LatLng at; + public final int kind; + public final String name; + + public Place(LatLng at, int kind, String name) { + this.at = at; + this.kind = kind; + this.name = name; + } + } + + /// Finds places near a path. Answers on the EDT, an empty list when + /// there is nothing or the lookup failed: landmarks are a nicety, never a + /// reason a run cannot start. + public interface Source { + void find(List path, Callback done); + } + + public interface Callback { + void found(List places); + } + + /// The most landmarks a run shows, and how far apart they stand at least. + static final int MAX = 12; + private static final int SPACING = 60; + private static final int RADIUS = 70; + private static final int MAX_QUERY_POINTS = 40; + + private static Source source = new Overpass("https://overpass-api.de/api/interpreter"); + + private Landmarks() { + } + + public static void setSource(Source s) { + source = s; + } + + /// Looks up the places along `path` and adds the best of them to + /// `route`, then calls `done` with how many were added. + public static void addTo(final CityRoute route, final List path, final Runnable done) { + source.find(path, places -> { + place(route, path, places); + done.run(); + }); + } + + /// Projects each place onto the path and keeps those far enough apart, + /// in route order. Scaled from the path's own length to the route's, as + /// the routed distance and the drawn line differ a little. + static void place(CityRoute route, List path, List places) { + if (path.size() < 2 || places.isEmpty()) { + return; + } + double[] along = new double[path.size()]; + for (int i = 1; i < path.size(); i++) { + along[i] = along[i - 1] + metres(path.get(i - 1), path.get(i)); + } + double scale = along[along.length - 1] <= 0 ? 0 : route.metres() / along[along.length - 1]; + List spots = new ArrayList(); + for (int p = 0; p < places.size(); p++) { + LatLng at = places.get(p).at; + double best = Double.MAX_VALUE; + double where = 0; + for (int i = 1; i < path.size(); i++) { + LatLng a = path.get(i - 1); + LatLng b = path.get(i); + double seg = along[i] - along[i - 1]; + double t = seg <= 0 ? 0 : project(a, b, at); + double ex = a.getLatitude() + (b.getLatitude() - a.getLatitude()) * t; + double ey = a.getLongitude() + (b.getLongitude() - a.getLongitude()) * t; + double d = metres(at, new LatLng(ex, ey)); + if (d < best) { + best = d; + where = along[i - 1] + seg * t; + } + } + if (best <= RADIUS * 1.5) { + spots.add(new int[] {(int) (where * scale), p}); + } + } + // Route order, then keep each one only if it is clear of the last. + for (int i = 1; i < spots.size(); i++) { + int[] x = spots.get(i); + int j = i - 1; + while (j >= 0 && spots.get(j)[0] > x[0]) { + spots.set(j + 1, spots.get(j)); + j--; + } + spots.set(j + 1, x); + } + int last = -SPACING; + int added = 0; + for (int i = 0; i < spots.size() && added < MAX; i++) { + int[] s = spots.get(i); + if (s[0] - last < SPACING) { + continue; + } + Place pl = places.get(s[1]); + try { + route.landmark(s[0], pl.kind, pl.name); + last = s[0]; + added++; + } catch (IllegalArgumentException e) { + // A name that cleans to nothing; skip it. + } + } + } + + /// Where `p` falls along `a`-`b`, from 0 to 1, in a flat local frame. + private static double project(LatLng a, LatLng b, LatLng p) { + double k = Math.cos(Math.toRadians(a.getLatitude())); + double bx = (b.getLongitude() - a.getLongitude()) * k; + double by = b.getLatitude() - a.getLatitude(); + double px = (p.getLongitude() - a.getLongitude()) * k; + double py = p.getLatitude() - a.getLatitude(); + double len = bx * bx + by * by; + if (len <= 0) { + return 0; + } + double t = (px * bx + py * by) / len; + return t < 0 ? 0 : t > 1 ? 1 : t; + } + + /// The distance between two points in metres, flat-earth: good for the + /// few kilometres a run covers. + public static double metres(LatLng a, LatLng b) { + double k = Math.cos(Math.toRadians((a.getLatitude() + b.getLatitude()) / 2)); + double dx = (b.getLongitude() - a.getLongitude()) * k * 111320.0; + double dy = (b.getLatitude() - a.getLatitude()) * 110540.0; + return Math.sqrt(dx * dx + dy * dy); + } + + /// OpenStreetMap's named places near a line, through an Overpass server. + public static final class Overpass implements Source { + private final String url; + + public Overpass(String url) { + this.url = url; + } + + @Override + public void find(List path, final Callback done) { + StringBuilder line = new StringBuilder(); + int step = Math.max(1, (path.size() + MAX_QUERY_POINTS - 1) / MAX_QUERY_POINTS); + for (int i = 0; i < path.size(); i += step) { + append(line, path.get(i)); + } + append(line, path.get(path.size() - 1)); + String around = "(around:" + RADIUS + "," + line + ")"; + String q = "[out:json][timeout:15];(" + + "nwr" + around + "[name][tourism~\"attraction|museum|gallery|viewpoint\"];" + + "nwr" + around + "[name][historic];" + + "nwr" + around + "[name][amenity=place_of_worship];" + + "nwr" + around + "[name][railway=station];" + + "nwr" + around + "[name][leisure=park];" + + ");out center 80;"; + final List out = new ArrayList(); + ConnectionRequest r = new ConnectionRequest() { + @Override + protected void readResponse(InputStream input) throws IOException { + read(JSONParser.parseJSON(com.codename1.io.Util.readInputStream(input)), out); + } + + @Override + protected void postResponse() { + done.found(out); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + CN.callSerially(() -> done.found(new ArrayList())); + } + + @Override + protected void handleException(Exception err) { + CN.callSerially(() -> done.found(new ArrayList())); + } + }; + r.setUrl(url); + r.setPost(true); + r.addArgument("data", q); + r.setFailSilently(true); + NetworkManager.getInstance().addToQueue(r); + } + + private static void append(StringBuilder sb, LatLng p) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(round(p.getLatitude())).append(',').append(round(p.getLongitude())); + } + + private static String round(double v) { + return String.valueOf(Math.round(v * 100000) / 100000.0); + } + + @SuppressWarnings("unchecked") + static void read(Map json, List out) { + Object els = json.get("elements"); + if (!(els instanceof List)) { + return; + } + for (Object o : (List) els) { + if (!(o instanceof Map)) { + continue; + } + Map e = (Map) o; + Object c = e.get("center"); + Map pos = c instanceof Map ? (Map) c : e; + Object tags = e.get("tags"); + if (!(tags instanceof Map) || !(pos.get("lat") instanceof Number) + || !(pos.get("lon") instanceof Number)) { + continue; + } + Map t = (Map) tags; + Object name = t.get("name"); + if (!(name instanceof String)) { + continue; + } + out.add(new Place(new LatLng(((Number) pos.get("lat")).doubleValue(), + ((Number) pos.get("lon")).doubleValue()), kind(t), (String) name)); + } + } + + private static int kind(Map t) { + if ("place_of_worship".equals(t.get("amenity"))) { + return CityRoute.WORSHIP; + } + Object tourism = t.get("tourism"); + if ("museum".equals(tourism) || "gallery".equals(tourism)) { + return CityRoute.MUSEUM; + } + if ("park".equals(t.get("leisure"))) { + return CityRoute.PARK; + } + if ("station".equals(t.get("railway"))) { + return CityRoute.STATION; + } + if (t.get("historic") != null) { + return CityRoute.MONUMENT; + } + return CityRoute.LANDMARK; + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Levels.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Levels.java new file mode 100644 index 00000000000..27b3f37cd2b --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Levels.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.forge.ContentPack; +import com.codenameone.examples.dash.rules.forge.ContentRef; +import com.codenameone.examples.dash.rules.forge.LevelRun; +import com.codenameone.examples.dash.rules.forge.PackCodec; +import com.codenameone.examples.dash.rules.forge.Samples; +import java.util.HashMap; +import java.util.Map; + +/// The levels this device can play, by the name a replay or a race gives them: +/// a bare id for an official level, `pack@hash#level` for a community one. +/// +/// Community packs arrive from the server ([#remember]) when a browser screen +/// opens one, a party picks one or a replay names one; a reference to a pack +/// that was never fetched resolves to null, and the caller fetches it. +public final class Levels { + /// A playable level and the catalog its placements name. + public static final class Playable { + public final String ref; + public final LevelDef level; + public final Catalog catalog; + + Playable(String ref, LevelDef level, Catalog catalog) { + this.ref = ref; + this.level = level; + this.catalog = catalog; + } + } + + private static final Map PACKS = new HashMap(); + private static final Map CITIES = new HashMap(); + private static final Map PATHS = new HashMap(); + + private Levels() { + } + + /// Keeps `pack` for later references to it; returns it. + public static ContentPack remember(ContentPack pack) { + PACKS.put(pack.id + "@" + pack.hash(), pack); + PACKS.put(pack.id, pack); + return pack; + } + + /// Decodes a pack the server sent and keeps it. + public static ContentPack remember(String content) { + return remember(PackCodec.read(content)); + } + + public static Playable official(String levelId) { + LevelDef l = Content.level(levelId); + return l == null ? null : new Playable(levelId, l, Content.catalog()); + } + + /// `levelId` of `pack`, named the way a replay names it. + public static Playable of(ContentPack pack, String levelId) { + if (Samples.OFFICIAL_ID.equals(pack.id)) { + return official(levelId); + } + LevelDef l = pack.level(levelId); + return l == null ? null : new Playable(LevelRun.replayLevelId(pack, levelId), l, pack.catalog()); + } + + /// A level of a pack still being edited, for a playtest. Its reference + /// names the pack as it is right now, which the server has never + /// published, so a playtest is never submitted. + public static Playable draft(ContentPack pack, LevelDef level) { + return new Playable(pack.id + "@" + pack.hash() + "#" + level.id, level, pack.catalog()); + } + + /// A city route's level, kept so a run of it can be submitted with its + /// route, run again and replayed. `path` is the street map's line for the + /// minimap, or null. + public static Playable city(CityRoute route, CityPath path) { + LevelDef l = route.level(); + // Only the routes of this session's last few runs are kept. + if (CITIES.size() >= 16 && !CITIES.containsKey(l.id)) { + CITIES.clear(); + PATHS.clear(); + } + CITIES.put(l.id, route); + if (path != null) { + PATHS.put(l.id, path); + } + return new Playable(l.id, l, Content.catalog()); + } + + /// The encoded route of the city level `levelId`, or null when it is not + /// one. + public static String citySpec(String levelId) { + CityRoute r = levelId == null ? null : CITIES.get(levelId); + return r == null ? null : r.encode(); + } + + /// The route of the city level `levelId`, or null when it is not one + /// this app built. + public static CityRoute cityRoute(String levelId) { + return levelId == null ? null : CITIES.get(levelId); + } + + /// The minimap line of the city level `levelId`, or null. + public static CityPath cityPath(String levelId) { + return levelId == null ? null : PATHS.get(levelId); + } + + /// The level a replay or race calls `ref`, or null when its pack is not + /// here yet (see [#packOf]). + public static Playable resolve(String ref) { + if (ref == null) { + return null; + } + if (ref.startsWith(CityRoute.ID_PREFIX)) { + CityRoute r = CITIES.get(ref); + return r == null ? null : city(r, null); + } + if (ref.indexOf('@') < 0) { + return official(ref); + } + ContentRef r = ContentRef.parse(ref); + ContentPack p = PACKS.get(r.pack + "@" + r.hash); + return p == null ? null : of(p, r.level); + } + + /// The pack id a community reference names, for fetching it; null for an + /// official level. + public static String packOf(String ref) { + int at = ref == null ? -1 : ref.indexOf('@'); + return at <= 0 ? null : ref.substring(0, at); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Meshes.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Meshes.java new file mode 100644 index 00000000000..157ec01f2a5 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Meshes.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gpu.IndexBuffer; +import com.codename1.gpu.Mesh; +import com.codename1.gpu.PrimitiveType; +import com.codename1.gpu.VertexBuffer; +import com.codename1.gpu.VertexFormat; + +/// Smooth shapes built in code: capsules for limbs and beams, rounded boxes +/// for anything that would otherwise be a hard cube, a coin with a rim, a +/// ring, a curved band for a visor and a faceted gem. +/// +/// Every shape carries its own smooth normals, so PHONG lighting slides +/// across it the way it does across a sphere. Most are surfaces of +/// revolution about Y, swept by [#lathe] from a profile -- a list of +/// (radius, height) points with their normals -- in the layout +/// `Primitives.sphere` uses. Triangles are wound counter-clockwise seen from +/// the side their normals face, which [#tri] checks rather than trusts. +/// +/// Build a shape once and share it: the mesh is the expensive part, a model +/// using it is not. +final class Meshes { + private float[] v = new float[512]; + private int vn; + private int[] ix = new int[512]; + private int in; + + private Meshes() { + } + + /// An empty builder, for a part assembled from several [#loft] pieces + /// and turned into one mesh by [#done]. + static Meshes builder() { + return new Meshes(); + } + + /// The mesh of everything added to this builder so far. + Mesh done() { + return build(); + } + + /// Adds a hard-surface piece lofted along Y through `ys.length` rings: + /// ring `i` is an octagon at height `ys[i]`, `hw[i]` wide and `hd[i]` + /// deep either side of its centre `(cx, cz + fz[i])`, with flat sides + /// facing +-x and +-z. Every face carries its own normal, so the light + /// breaks across the panels in steps -- the cut, machined look of a + /// low-poly athlete -- rather than sliding round a balloon. The ends are + /// capped unless a ring there has no width. Returns this builder. + Meshes loft(float cx, float cz, float[] ys, float[] hw, float[] hd, float[] fz) { + int sides = 8; + int n = ys.length; + float k = 1f / (float) Math.cos(Math.PI / sides); + float[] px = new float[n * sides]; + float[] pz = new float[n * sides]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < sides; j++) { + double a = Math.PI / sides + 2 * Math.PI * j / sides; + px[i * sides + j] = cx + hw[i] * k * (float) Math.cos(a); + pz[i * sides + j] = cz + fz[i] + hd[i] * k * (float) Math.sin(a); + } + } + for (int i = 0; i < n - 1; i++) { + float midZ = cz + (fz[i] + fz[i + 1]) / 2f; + for (int j = 0; j < sides; j++) { + int a = i * sides + j; + int b = i * sides + (j + 1) % sides; + int c = b + sides; + int d = a + sides; + float ux = px[c] - px[a]; + float uy = ys[i + 1] - ys[i]; + float uz = pz[c] - pz[a]; + float wx = px[d] - px[b]; + float wy = uy; + float wz = pz[d] - pz[b]; + float nx = uy * wz - uz * wy; + float ny = uz * wx - ux * wz; + float nz = ux * wy - uy * wx; + float ox = (px[a] + px[b] + px[c] + px[d]) / 4f - cx; + float oz = (pz[a] + pz[b] + pz[c] + pz[d]) / 4f - midZ; + if (nx * ox + nz * oz < 0f) { + nx = -nx; + ny = -ny; + nz = -nz; + } + int v0 = vert(px[a], ys[i], pz[a], nx, ny, nz, 0f, 0f); + int v1 = vert(px[b], ys[i], pz[b], nx, ny, nz, 1f, 0f); + int v2 = vert(px[c], ys[i + 1], pz[c], nx, ny, nz, 1f, 1f); + int v3 = vert(px[d], ys[i + 1], pz[d], nx, ny, nz, 0f, 1f); + tri(v0, v1, v2); + tri(v0, v2, v3); + } + } + for (int e = 0; e < 2; e++) { + int i = e == 0 ? 0 : n - 1; + if (hw[i] <= 0f || hd[i] <= 0f) { + continue; + } + float ny = ys[n - 1] > ys[0] == (e == 1) ? 1f : -1f; + int c0 = vert(cx, ys[i], cz + fz[i], 0f, ny, 0f, 0.5f, 0.5f); + for (int j = 0; j < sides; j++) { + int a = vert(px[i * sides + j], ys[i], pz[i * sides + j], 0f, ny, 0f, 0f, 0f); + int jb = i * sides + (j + 1) % sides; + int b = vert(px[jb], ys[i], pz[jb], 0f, ny, 0f, 1f, 0f); + tri(c0, a, b); + } + } + return this; + } + + /// [#loft] with every ring centred on the same axis. + Meshes loft(float cx, float cz, float[] ys, float[] hw, float[] hd) { + return loft(cx, cz, ys, hw, hd, new float[ys.length]); + } + + /// A capsule along Y: a cylinder `length` long with a hemisphere of + /// `radius` on each end, so `length + 2 * radius` tall overall. + static Mesh capsule(float radius, float length, int rings, int segs) { + int n = (rings + 1) * 2; + float[] r = new float[n]; + float[] y = new float[n]; + float[] nr = new float[n]; + float[] ny = new float[n]; + int o = 0; + for (int half = 0; half < 2; half++) { + float cy = half == 0 ? length / 2f : -length / 2f; + for (int k = 0; k <= rings; k++) { + double t = Math.PI / 2 * (half + k / (double) rings); + float s = (float) Math.sin(t); + float c = (float) Math.cos(t); + r[o] = radius * s; + y[o] = cy + radius * c; + nr[o] = s; + ny[o] = c; + o++; + } + } + Meshes m = new Meshes(); + m.lathe(r, y, nr, ny, n, segs, 0, Math.PI * 2); + return m.build(); + } + + /// A box `w` by `h` by `d` whose edges and corners are rounded to + /// `radius`, with flat faces between them. Texture coordinates run + /// across the front and back faces, so a striped texture reads there. + static Mesh roundedBox(float w, float h, float d, float radius, int segs) { + float rad = Math.min(radius, Math.min(w, Math.min(h, d)) / 2f - 0.001f); + rad = Math.max(rad, 0.001f); + float a = w / 2f - rad; + float b = h / 2f - rad; + float c = d / 2f - rad; + Meshes m = new Meshes(); + int rows = 2 * (segs + 1); + // Four quadrants of columns, each with its own edge vertices so the + // faces between them stay flat, and the first column repeated to + // close the last face. + int cols = 4 * (segs + 1) + 1; + for (int row = 0; row < rows; row++) { + int hy = row / (segs + 1); + int k = row % (segs + 1); + double th = Math.PI / 2 * (hy + k / (double) segs); + float st = (float) Math.sin(th); + float ct = (float) Math.cos(th); + float sy = hy == 0 ? 1f : -1f; + for (int col = 0; col < cols; col++) { + int q = col == cols - 1 ? 0 : col / (segs + 1); + int j = col == cols - 1 ? 0 : col % (segs + 1); + double ph = Math.PI / 2 * (q + j / (double) segs); + float sx = q < 2 ? 1f : -1f; + float sz = q == 0 || q == 3 ? 1f : -1f; + float nx = st * (float) Math.sin(ph); + float nz = st * (float) Math.cos(ph); + float px = sx * a + nx * rad; + float py = sy * b + ct * rad; + float pz = sz * c + nz * rad; + m.vert(px, py, pz, nx, ct, nz, px / w + 0.5f, 0.5f - py / h); + } + } + m.grid(0, rows, cols); + for (int s = 0; s < 2; s++) { + float sy = s == 0 ? 1f : -1f; + float py = sy * (b + rad); + int p0 = m.vert(-a, py, -c, 0, sy, 0, 0.5f - a / w, 0.5f - c / d); + int p1 = m.vert(a, py, -c, 0, sy, 0, 0.5f + a / w, 0.5f - c / d); + int p2 = m.vert(a, py, c, 0, sy, 0, 0.5f + a / w, 0.5f + c / d); + int p3 = m.vert(-a, py, c, 0, sy, 0, 0.5f - a / w, 0.5f + c / d); + m.tri(p0, p1, p2); + m.tri(p0, p2, p3); + } + return m.build(); + } + + /// A coin with its faces on +-Y: a disc of `radius`, `thick` through, + /// with a raised rounded rim around a recessed face. + static Mesh coin(float radius, float thick, int segs) { + float h = thick / 2f; + float bev = Math.min(h * 0.9f, radius * 0.12f); + // The top half, from the centre outwards and over the edge. + float[] tr = {0f, radius * 0.66f, radius * 0.72f, radius * 0.76f, 0f, 0f, 0f, 0f, radius}; + float[] ty = {h * 0.45f, h * 0.45f, h, h, 0f, 0f, 0f, 0f, 0f}; + float[] tnr = {0f, 0f, -0.55f, 0f, 0f, 0f, 0f, 0f, 1f}; + float[] tny = {1f, 1f, 0.83f, 1f, 0f, 0f, 0f, 0f, 0f}; + for (int k = 0; k < 4; k++) { + double t = Math.PI / 2 * k / 3.0; + float s = (float) Math.sin(t); + float c = (float) Math.cos(t); + tr[4 + k] = radius - bev + bev * s; + ty[4 + k] = h - bev + bev * c; + tnr[4 + k] = s; + tny[4 + k] = c; + } + int t = tr.length; + float[] r = new float[t * 2]; + float[] y = new float[t * 2]; + float[] nr = new float[t * 2]; + float[] ny = new float[t * 2]; + for (int i = 0; i < t; i++) { + r[i] = tr[i]; + y[i] = ty[i]; + nr[i] = tnr[i]; + ny[i] = tny[i]; + int j = t * 2 - 1 - i; + r[j] = tr[i]; + y[j] = -ty[i]; + nr[j] = tnr[i]; + ny[j] = -tny[i]; + } + Meshes m = new Meshes(); + m.lathe(r, y, nr, ny, t * 2, segs, 0, Math.PI * 2); + return m.build(); + } + + /// A ring round Y: a tube of `tube` radius bent into a circle of `radius`. + static Mesh torus(float radius, float tube, int rings, int segs) { + int n = rings + 1; + float[] r = new float[n]; + float[] y = new float[n]; + float[] nr = new float[n]; + float[] ny = new float[n]; + for (int k = 0; k < n; k++) { + double a = Math.PI * 2 * k / rings; + float c = (float) Math.cos(a); + float s = (float) Math.sin(a); + r[k] = radius + tube * c; + y[k] = tube * s; + nr[k] = c; + ny[k] = s; + } + Meshes m = new Meshes(); + m.lathe(r, y, nr, ny, n, segs, 0, Math.PI * 2); + return m.build(); + } + + /// A curved strip `height` tall at `radius` round Y, `arc` degrees wide + /// and centred on -z: the wrap-around visor of a helmet. + static Mesh band(float radius, float height, float arc, int segs) { + float h = height / 2f; + float[] r = {radius - height * 0.18f, radius, radius - height * 0.18f}; + float[] y = {h, 0f, -h}; + float[] nr = {0.7f, 1f, 0.7f}; + float[] ny = {0.7f, 0f, -0.7f}; + double half = Math.toRadians(arc) / 2; + Meshes m = new Meshes(); + m.lathe(r, y, nr, ny, 3, segs, Math.PI - half, Math.PI + half); + return m.build(); + } + + /// An octahedron with one flat normal per face, so each facet catches + /// the light on its own: a cut gem. + static Mesh gem(float radius, float height) { + Meshes m = new Meshes(); + float[][] ring = {{radius, 0f}, {0f, radius}, {-radius, 0f}, {0f, -radius}}; + for (int half = 0; half < 2; half++) { + float tip = half == 0 ? height / 2f : -height / 2f; + for (int i = 0; i < 4; i++) { + float[] p = ring[i]; + float[] q = ring[(i + 1) % 4]; + float ux = p[0]; + float uy = -tip; + float uz = p[1]; + float wx = q[0]; + float wy = -tip; + float wz = q[1]; + float nx = uy * wz - uz * wy; + float ny = uz * wx - ux * wz; + float nz = ux * wy - uy * wx; + // Point the face normal away from the centre. + if (nx * (p[0] + q[0]) + ny * tip + nz * (p[1] + q[1]) < 0) { + nx = -nx; + ny = -ny; + nz = -nz; + } + int t0 = m.vert(0f, tip, 0f, nx, ny, nz, 0.5f, half); + int t1 = m.vert(p[0], 0f, p[1], nx, ny, nz, 0f, 0.5f); + int t2 = m.vert(q[0], 0f, q[1], nx, ny, nz, 1f, 0.5f); + m.tri(t0, t1, t2); + } + } + return m.build(); + } + + /// Sweeps a profile of `n` points round Y from `phi0` to `phi1`; angle 0 + /// is +z, as in `Primitives.sphere`. + private void lathe(float[] r, float[] y, float[] nr, float[] ny, int n, int segs, double phi0, double phi1) { + int base = vn / 8; + for (int i = 0; i < n; i++) { + for (int j = 0; j <= segs; j++) { + double ph = phi0 + (phi1 - phi0) * j / segs; + float s = (float) Math.sin(ph); + float c = (float) Math.cos(ph); + vert(r[i] * s, y[i], r[i] * c, nr[i] * s, ny[i], nr[i] * c, j / (float) segs, + i / (float) (n - 1)); + } + } + grid(base, n, segs + 1); + } + + private void grid(int base, int rows, int cols) { + for (int rr = 0; rr < rows - 1; rr++) { + for (int cc = 0; cc < cols - 1; cc++) { + int a = base + rr * cols + cc; + int b = a + cols; + tri(a, b, a + 1); + tri(b, b + 1, a + 1); + } + } + } + + private int vert(float x, float y, float z, float nx, float ny, float nz, float u, float t) { + if (vn + 8 > v.length) { + float[] g = new float[v.length * 2]; + System.arraycopy(v, 0, g, 0, vn); + v = g; + } + float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0f) { + nx /= len; + ny /= len; + nz /= len; + } + v[vn] = x; + v[vn + 1] = y; + v[vn + 2] = z; + v[vn + 3] = nx; + v[vn + 4] = ny; + v[vn + 5] = nz; + v[vn + 6] = u; + v[vn + 7] = t; + vn += 8; + return vn / 8 - 1; + } + + /// Adds a triangle, flipped if need be so it winds counter-clockwise + /// seen from the side its vertex normals face. A degenerate one (at a + /// pole) is skipped. + private void tri(int a, int b, int c) { + int pa = a * 8; + int pb = b * 8; + int pc = c * 8; + float ux = v[pb] - v[pa]; + float uy = v[pb + 1] - v[pa + 1]; + float uz = v[pb + 2] - v[pa + 2]; + float wx = v[pc] - v[pa]; + float wy = v[pc + 1] - v[pa + 1]; + float wz = v[pc + 2] - v[pa + 2]; + float fx = uy * wz - uz * wy; + float fy = uz * wx - ux * wz; + float fz = ux * wy - uy * wx; + if (fx * fx + fy * fy + fz * fz < 1e-14f) { + return; + } + float nx = v[pa + 3] + v[pb + 3] + v[pc + 3]; + float ny = v[pa + 4] + v[pb + 4] + v[pc + 4]; + float nz = v[pa + 5] + v[pb + 5] + v[pc + 5]; + if (in + 3 > ix.length) { + int[] g = new int[ix.length * 2]; + System.arraycopy(ix, 0, g, 0, in); + ix = g; + } + ix[in] = a; + if (fx * nx + fy * ny + fz * nz >= 0) { + ix[in + 1] = b; + ix[in + 2] = c; + } else { + ix[in + 1] = c; + ix[in + 2] = b; + } + in += 3; + } + + private Mesh build() { + int count = vn / 8; + if (count > 65536) { + throw new IllegalStateException("mesh exceeds the 16 bit index range"); + } + float[] data = new float[vn]; + System.arraycopy(v, 0, data, 0, vn); + int[] idx = new int[in]; + System.arraycopy(ix, 0, idx, 0, in); + VertexBuffer vb = new VertexBuffer(VertexFormat.POSITION_NORMAL_TEXCOORD, count); + vb.setData(data); + IndexBuffer ib = new IndexBuffer(idx.length); + ib.setData(idx); + return new Mesh(vb, ib, PrimitiveType.TRIANGLES); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Outfit.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Outfit.java new file mode 100644 index 00000000000..6777c32a949 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Outfit.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +/// The colours a runner wears: a suit, a glowing trim (the visor, the shoe +/// soles and the stripes) and a dark base for the gloves and the helmet shell. +/// +/// The run draws every runner from the same procedural body ([Rig]) and the +/// menus paint the same body flat ([com.codenameone.examples.dash.ui.Portraits]), +/// so a runner the shop sells is nothing but a row here. Add one by adding a +/// catalog item on the server and a case to [#of]. +public final class Outfit { + public final String id; + public final int suit; + public final int trim; + public final int shell; + + private Outfit(String id, int suit, int trim, int shell) { + this.id = id; + this.suit = suit; + this.trim = trim; + this.shell = shell; + } + + /// The outfits bots and unknown runners wear, in turn. + private static final Outfit[] CROWD = { + new Outfit("crowd-slate", 0xff5d6b86, 0xffffb547, 0xff20263a), + new Outfit("crowd-moss", 0xff4f7a5e, 0xfff4f1e8, 0xff1d2a24), + new Outfit("crowd-plum", 0xff7a4f78, 0xff7dffd6, 0xff2a1d2a), + new Outfit("crowd-sand", 0xffb59a6d, 0xff3fd0ff, 0xff3a3022), + }; + + // tag::dash-outfits[] + /// The outfit for a shop runner id; `fallback` picks one for a bot. + public static Outfit of(String runnerId, int fallback) { + if ("runner-nova".equals(runnerId)) { + return new Outfit(runnerId, 0xff6c4dff, 0xffc8ff3d, 0xff1a1440); + } + if ("runner-kite".equals(runnerId)) { + return new Outfit(runnerId, 0xff0fb5a8, 0xffffe14d, 0xff0b2f33); + } + if ("runner-dash".equals(runnerId) || runnerId == null && fallback == 0) { + return new Outfit("runner-dash", 0xffff5a3c, 0xff35e8ff, 0xff241326); + } + return CROWD[Math.abs(fallback) % CROWD.length]; + } + // end::dash-outfits[] + + /// This outfit faded towards grey, for a ghost from an earlier run. + public Outfit ghost() { + return new Outfit(id, mix(suit, 0xff9aa4b8, 0.65f), mix(trim, 0xffffffff, 0.5f), mix(shell, 0xff9aa4b8, 0.6f)); + } + + static int mix(int a, int b, float k) { + int r = (int) (((a >> 16) & 0xff) * (1 - k) + ((b >> 16) & 0xff) * k); + int g = (int) (((a >> 8) & 0xff) * (1 - k) + ((b >> 8) & 0xff) * k); + int bl = (int) ((a & 0xff) * (1 - k) + (b & 0xff) * k); + return 0xff000000 | (r << 16) | (g << 8) | bl; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RaceDriver.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RaceDriver.java new file mode 100644 index 00000000000..5dad1753a84 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RaceDriver.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.net.GameLink; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Text; +import java.util.List; +import java.util.Map; + +/// A live race, in lockstep with the server. +/// +/// The server owns the clock. A few times a second it says how far a client +/// may simulate (`t`) and which inputs it accepted since last time, each +/// stamped [GameProtocol#INPUT_DELAY] ticks in the future. Every client runs +/// the same rules on the same seed up to `t` and never further, so every +/// runner's jump lands on the same tick on every screen. Every +/// [GameProtocol#HASH_EVERY] ticks a client sends a hash of its state; one +/// that differs gets the whole log again and re-simulates. +/// +/// A spectator -- an admin in the console -- is the same thing with no seat. +// tag::dash-lockstep[] +public final class RaceDriver extends Driver { + /// What the race screen wants to hear besides the ticks. + public interface Listener { + void emote(String from, String id); + + void chat(String from, String text); + + /// The race is over: `places[i]` is runner i's place, 0 for the winner. + void ended(int[] places, int[] scores, int trophies, int coins, String raceId); + + void lost(); + } + + private final GameLink link; + private final boolean spectator; + private Listener listener; + private Levels.Playable level; + private String raceId; + private String[] names = new String[0]; + private int allowed; + private long startsAt; + private int[] log = new int[96]; + private int logSize; + private int next; + private int[] inputs; + private int lastSent; + private int sentThisTick; + + public RaceDriver(GameLink link, Levels.Playable level, Map start, boolean spectator) { + this.link = link; + this.level = level; + this.spectator = spectator; + restart(start); + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + public String raceId() { + return raceId; + } + + /// A `start` message: the first one, or the server's answer to a hash + /// that did not match. Either way the run is rebuilt from its log. + private void restart(Map m) { + raceId = GameLink.text(m, "race"); + me = spectator ? 0 : GameLink.number(m, "you"); + names = strings(m.get("names")); + run = new Run(level.level, level.catalog, Long.parseLong(GameLink.text(m, "seed")), + Math.max(1, names.length)); + inputs = new int[run.runnerCount()]; + logSize = 0; + next = 0; + append(GameLink.text(m, "log")); + allowed = GameLink.number(m, "tick"); + startsAt = System.currentTimeMillis() + GameLink.number(m, "startIn"); + // Catch up at once to where the server already is. + while (run.tick() < allowed && !run.over()) { + stepRun(); + } + } + + /// Everything the server sends during a race comes through here. + public void message(Map m) { + String type = GameLink.text(m, "type"); + if (GameProtocol.TICKS.equals(type)) { + append(GameLink.text(m, "i")); + allowed = Math.max(allowed, GameLink.number(m, "t")); + } else if (GameProtocol.START.equals(type)) { + restart(m); + } else if (listener == null) { + return; + } else if (GameProtocol.EMOTE.equals(type)) { + listener.emote(GameLink.text(m, "from"), GameLink.text(m, "id")); + } else if (GameProtocol.CHAT.equals(type)) { + listener.chat(GameLink.text(m, "from"), GameLink.text(m, "text")); + } else if (GameProtocol.END.equals(type)) { + listener.ended(ints(GameLink.text(m, "places")), ints(GameLink.text(m, "scores")), + GameLink.number(m, "trophies"), GameLink.number(m, "coins"), GameLink.text(m, "race")); + } else if (GameProtocol.KICKED.equals(type)) { + listener.lost(); + } + } + + @Override + public boolean canStep() { + return run.tick() < allowed && !run.over(); + } + + @Override + public void step() { + stepRun(); + if (!spectator && run.tick() % GameProtocol.HASH_EVERY == 0) { + link.send("{\"type\":\"" + GameProtocol.SYNC + "\",\"t\":" + run.tick() + ",\"h\":\"" + + Long.toHexString(run.stateHash()) + "\"}"); + } + } + + private void stepRun() { + for (int i = 0; i < inputs.length; i++) { + inputs[i] = 0; + } + int tick = run.tick(); + while (next < logSize && log[next * 3] <= tick) { + int seat = log[next * 3 + 1]; + if (log[next * 3] == tick && seat >= 0 && seat < inputs.length) { + inputs[seat] |= log[next * 3 + 2]; + } + next++; + } + run.step(inputs); + } + + /// Only asks. The input takes effect when the server stamps it and sends + /// it back -- to every runner at once. At most one message a tick: a + /// swipe fires once, not once per frame it is held. + @Override + public void input(int bits) { + if (spectator || bits == 0 || run.over()) { + return; + } + if (lastSent == run.tick() && (sentThisTick & bits) == bits) { + return; + } + if (lastSent != run.tick()) { + sentThisTick = 0; + } + lastSent = run.tick(); + sentThisTick |= bits; + link.send("{\"type\":\"" + GameProtocol.INPUT + "\",\"b\":" + bits + "}"); + } + + @Override + public boolean interactive() { + return !spectator; + } + + @Override + public int countdownTicks() { + long left = startsAt - System.currentTimeMillis(); + return left <= 0 ? 0 : (int) (left * Run.TICKS_PER_SECOND / 1000); + } + + public void emote(String id) { + link.send("{\"type\":\"" + GameProtocol.EMOTE + "\",\"id\":\"" + id + "\"}"); + } + + public void leave() { + link.send("{\"type\":\"" + GameProtocol.LEAVE + "\"}"); + } + + public GameLink link() { + return link; + } + + @Override + public String name(int runner) { + if (!spectator && runner == me) { + return "You"; + } + return runner < names.length ? names[runner] : super.name(runner); + } + + @Override + public void dispose() { + link.close(); + } + + /// `tick,seat,bits;...`, appended in the order the server stamped them. + private void append(String encoded) { + List items = Text.split(encoded, ';'); + for (int i = 0; i < items.size(); i++) { + List f = Text.split(items.get(i), ','); + if (f.size() != 3) { + continue; + } + if ((logSize + 1) * 3 > log.length) { + int[] n = new int[log.length * 2]; + System.arraycopy(log, 0, n, 0, logSize * 3); + log = n; + } + log[logSize * 3] = Integer.parseInt(f.get(0)); + log[logSize * 3 + 1] = Integer.parseInt(f.get(1)); + log[logSize * 3 + 2] = Integer.parseInt(f.get(2)); + logSize++; + } + } + + static int[] ints(String csv) { + if (csv == null || csv.length() == 0) { + return new int[0]; + } + List f = Text.split(csv, ','); + int[] out = new int[f.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = f.get(i).length() == 0 ? 0 : Integer.parseInt(f.get(i)); + } + return out; + } + + static String[] strings(Object list) { + if (!(list instanceof List)) { + return new String[0]; + } + List l = (List) list; + String[] out = new String[l.size()]; + for (int i = 0; i < out.length; i++) { + Object v = l.get(i); + out[i] = v == null ? "" : v.toString(); + } + return out; + } +} +// end::dash-lockstep[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayDriver.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayDriver.java new file mode 100644 index 00000000000..ee25670cbaf --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayDriver.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; + +/// Plays a finished run or race back from its log, at any speed. Seeking +/// backwards plays it again from the start: the run is deterministic, so the +/// replay is the log and nothing else. +public final class ReplayDriver extends Driver { + private final Levels.Playable level; + private final Replay replay; + private final String[] names; + private ReplayFeed feed; + private final int length; + private int speed = 100; + private boolean paused; + + public ReplayDriver(Levels.Playable level, Replay replay, String[] names, int follow) { + this.level = level; + this.replay = replay; + this.names = names == null ? new String[0] : names; + this.me = Math.max(0, Math.min(follow, replay.runners() - 1)); + restart(); + while (!run.over()) { + feed.step(run); + } + length = run.tick(); + restart(); + } + + /// How many ticks the whole replay lasts. + public int length() { + return length; + } + + private void restart() { + run = new Run(level.level, level.catalog, replay.seed(), replay.runners()); + feed = new ReplayFeed(replay); + } + + @Override + public boolean canStep() { + return !paused && !run.over(); + } + + @Override + public void step() { + feed.step(run); + } + + @Override + public void input(int bits) { + // A replay is not steered. + } + + @Override + public boolean interactive() { + return false; + } + + @Override + public int speedPercent() { + return speed; + } + + public void setSpeedPercent(int percent) { + speed = Math.max(25, Math.min(800, percent)); + } + + public void setPaused(boolean p) { + paused = p; + } + + public boolean isPaused() { + return paused; + } + + /// Jumps to `tick`, re-simulating from the start when it lies behind. + public void seek(int tick) { + if (tick < run.tick()) { + restart(); + } + while (run.tick() < tick && !run.over()) { + feed.step(run); + } + } + + /// Follows another runner. + public void follow(int runner) { + me = Math.max(0, Math.min(runner, run.runnerCount() - 1)); + } + + @Override + public String name(int runner) { + return runner < names.length && names[runner] != null && names[runner].length() > 0 + ? names[runner] : super.name(runner); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayFeed.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayFeed.java new file mode 100644 index 00000000000..481db88b272 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/ReplayFeed.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; + +/// Feeds a recorded [Replay] into a [Run] one tick at a time, in step with +/// whatever else is running: a ghost beside the player, or the replay viewer. +final class ReplayFeed { + private final Replay replay; + private final int[] inputs; + private int next; + + ReplayFeed(Replay replay) { + this.replay = replay; + this.inputs = new int[replay.runners()]; + } + + /// Steps `run` once with the inputs recorded for its current tick. + void step(Run run) { + if (run.over()) { + return; + } + for (int i = 0; i < inputs.length; i++) { + inputs[i] = 0; + } + int tick = run.tick(); + while (next < replay.eventCount()) { + int[] e = replay.event(next); + if (e[0] > tick) { + break; + } + if (e[0] == tick && e[1] >= 0 && e[1] < inputs.length) { + inputs[e[1]] |= e[2]; + } + next++; + } + run.step(inputs); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Rig.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Rig.java new file mode 100644 index 00000000000..71aa2d82df8 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Rig.java @@ -0,0 +1,422 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gaming.GameView; +import com.codename1.gaming.Model; +import com.codename1.gpu.Material; +import com.codename1.gpu.Mesh; + +/// A runner's body -- a sleek low-poly android athlete -- built in code +/// and posed by code: no model files, no animation clips. Each joint is an +/// angle, the pose is the angle set for what the runner is doing -- running, +/// in the air, sliding, down, or celebrating -- and the body eases towards +/// it every frame, so one move flows into the next. +/// +/// Every segment is lofted from octagonal cross sections ([Meshes]'s `loft`) +/// with one normal per face, so the light breaks across it in machined +/// panels instead of sliding round a balloon. The torso is a real torso: +/// hips, a narrow waist, a chest that widens to the shoulders and a neck, +/// with a chest plate and a back pack over it. Arms and legs are tapered +/// upper and lower segments meeting at armoured elbows and knees, ending in +/// fists and trainers with soles; the head is a faceted helmet with a face +/// plate. Each segment is up to three meshes sharing one origin at its joint +/// -- the suit, the dark armour shell and the glowing trim -- so an [Outfit] +/// recolours the whole body. +/// +/// Angles are in degrees in the runner's side plane: 0 hangs straight down +/// (a limb) or stands straight up (the torso), and a positive angle swings +/// the far end forward, towards the way the runner faces. +final class Rig { + private static final int LEAN = 0; + private static final int HIP = 1; + private static final int THIGH_L = 2; + private static final int SHIN_L = 3; + private static final int THIGH_R = 4; + private static final int SHIN_R = 5; + private static final int ARM_L = 6; + private static final int FORE_L = 7; + private static final int ARM_R = 8; + private static final int FORE_R = 9; + private static final int JOINTS = 10; + + private static final float THIGH = 0.5f; + private static final float SHIN = 0.48f; + private static final float TORSO = 0.54f; + private static final float UPPER_ARM = 0.3f; + private static final float STAND = 1.02f; + + /// The meshes every runner shares, built once per run. Each has its + /// origin at the joint it hangs from -- the torso at the hip, the head at + /// the neck, a limb at its upper joint -- and runs along -Y (+Y for the + /// torso and head), with forward at -z. + static final class Kit { + final Mesh torsoSuit; + final Mesh torsoShell; + final Mesh torsoGlow; + final Mesh headShell; + final Mesh headSuit; + final Mesh headGlow; + final Mesh armSuit; + final Mesh armShell; + final Mesh foreSuit; + final Mesh foreShell; + final Mesh foreGlow; + final Mesh thighSuit; + final Mesh thighShell; + final Mesh thighGlow; + final Mesh shinSuit; + final Mesh shinShell; + final Mesh shoe; + final Mesh sole; + final Mesh quad; + final Material shadow; + + Kit(Mesh quad, Glow glow) { + this.quad = quad; + shadow = Glow.blended(glow.shadow, 0x99000000); + // Hips, a waist narrower than both, the rib cage widening to + // the shoulders, then the trapezius sloping into the neck. + torsoSuit = Meshes.builder() + .loft(0f, 0f, f(-0.07f, 0.04f, 0.15f, 0.27f, 0.38f, 0.46f, 0.51f, 0.55f), + f(0.125f, 0.14f, 0.112f, 0.15f, 0.205f, 0.22f, 0.16f, 0.085f), + f(0.085f, 0.09f, 0.078f, 0.095f, 0.11f, 0.105f, 0.085f, 0.065f), + f(0f, 0f, 0f, -0.008f, -0.016f, -0.008f, 0.005f, 0.01f)) + .loft(0f, 0.012f, f(0.53f, 0.64f), f(0.045f, 0.04f), f(0.045f, 0.04f)) + .done(); + torsoShell = Meshes.builder() + // Hip guard, chest plate, back pack and shoulder caps. + .loft(0f, 0f, f(-0.09f, -0.06f, 0.07f, 0.1f), f(0.12f, 0.145f, 0.152f, 0.125f), + f(0.08f, 0.1f, 0.102f, 0.085f)) + .loft(0f, -0.118f, f(0.29f, 0.33f, 0.44f, 0.47f), f(0.1f, 0.14f, 0.165f, 0.12f), + f(0.018f, 0.025f, 0.025f, 0.015f)) + .loft(0f, 0.15f, f(0.2f, 0.23f, 0.46f, 0.49f), f(0.09f, 0.12f, 0.12f, 0.09f), + f(0.03f, 0.045f, 0.045f, 0.03f)) + .loft(-0.225f, 0f, f(0.4f, 0.45f, 0.51f, 0.535f), f(0.055f, 0.074f, 0.072f, 0.045f), + f(0.065f, 0.082f, 0.077f, 0.05f)) + .loft(0.225f, 0f, f(0.4f, 0.45f, 0.51f, 0.535f), f(0.055f, 0.074f, 0.072f, 0.045f), + f(0.065f, 0.082f, 0.077f, 0.05f)) + .done(); + torsoGlow = Meshes.builder() + // The belt line, the seam under the rib cage, the light + // down the chest plate and the strip on the pack. + .loft(0f, 0f, f(0.0f, 0.025f), f(0.155f, 0.155f), f(0.104f, 0.104f)) + .loft(0f, 0f, f(0.235f, 0.25f), f(0.142f, 0.144f), f(0.094f, 0.095f), f(-0.006f, -0.007f)) + .loft(0f, -0.145f, f(0.31f, 0.45f), f(0.016f, 0.016f), f(0.006f, 0.006f)) + .loft(0f, 0.198f, f(0.25f, 0.44f), f(0.022f, 0.022f), f(0.005f, 0.005f)) + .done(); + // A faceted helmet: a narrow jaw, wide temples, a crown. + headShell = Meshes.builder() + .loft(0f, -0.005f, f(0.02f, 0.06f, 0.15f, 0.22f, 0.27f, 0.295f), + f(0.06f, 0.088f, 0.1f, 0.098f, 0.08f, 0.04f), + f(0.07f, 0.1f, 0.115f, 0.112f, 0.09f, 0.045f), + f(-0.012f, -0.008f, 0f, 0.004f, 0.008f, 0.01f)) + .done(); + headSuit = Meshes.builder() + // Ear pods and the chin guard. + .loft(-0.1f, 0.01f, f(0.09f, 0.11f, 0.17f, 0.19f), f(0.012f, 0.02f, 0.02f, 0.012f), + f(0.03f, 0.042f, 0.042f, 0.03f)) + .loft(0.1f, 0.01f, f(0.09f, 0.11f, 0.17f, 0.19f), f(0.012f, 0.02f, 0.02f, 0.012f), + f(0.03f, 0.042f, 0.042f, 0.03f)) + .loft(0f, -0.085f, f(0.025f, 0.05f, 0.085f), f(0.045f, 0.06f, 0.07f), f(0.018f, 0.025f, 0.02f)) + .done(); + headGlow = Meshes.builder() + // The face plate, wrapping a little round the sides, and + // a crest along the crown. + .loft(0f, -0.105f, f(0.1f, 0.115f, 0.175f, 0.19f), f(0.07f, 0.092f, 0.09f, 0.072f), + f(0.015f, 0.026f, 0.026f, 0.015f), f(0.006f, 0f, 0f, 0.006f)) + .loft(0f, 0f, f(0.25f, 0.305f), f(0.014f, 0.01f), f(0.1f, 0.06f), f(-0.005f, 0.01f)) + .done(); + armSuit = Meshes.builder() + .loft(0f, 0f, f(0.02f, -0.05f, -0.2f, -0.29f), f(0.055f, 0.062f, 0.05f, 0.04f), + f(0.055f, 0.065f, 0.052f, 0.042f)) + .done(); + armShell = Meshes.builder() + // The elbow guard. + .loft(0f, 0f, f(-0.27f, -0.295f, -0.33f), f(0.045f, 0.052f, 0.04f), f(0.045f, 0.055f, 0.045f)) + .done(); + foreSuit = Meshes.builder() + .loft(0f, 0f, f(0f, -0.08f, -0.27f), f(0.042f, 0.046f, 0.032f), f(0.044f, 0.05f, 0.034f)) + .done(); + foreShell = Meshes.builder() + // The bracer and the fist. + .loft(0f, 0f, f(-0.05f, -0.08f, -0.2f, -0.23f), f(0.044f, 0.052f, 0.045f, 0.036f), + f(0.048f, 0.056f, 0.048f, 0.038f)) + .loft(0f, -0.005f, f(-0.255f, -0.28f, -0.33f, -0.36f), f(0.028f, 0.034f, 0.034f, 0.026f), + f(0.032f, 0.045f, 0.046f, 0.034f)) + .done(); + foreGlow = Meshes.builder() + .loft(0f, 0f, f(-0.12f, -0.14f), f(0.053f, 0.052f), f(0.058f, 0.057f)) + .done(); + thighSuit = Meshes.builder() + .loft(0f, 0f, f(0.04f, -0.08f, -0.3f, -0.45f, -0.52f), f(0.08f, 0.09f, 0.075f, 0.058f, 0.05f), + f(0.085f, 0.098f, 0.085f, 0.062f, 0.055f), f(0f, -0.006f, -0.004f, 0f, 0f)) + .done(); + thighShell = Meshes.builder() + // The knee guard. + .loft(0f, -0.045f, f(-0.43f, -0.46f, -0.53f, -0.56f), f(0.045f, 0.06f, 0.058f, 0.04f), + f(0.02f, 0.032f, 0.032f, 0.018f)) + .done(); + thighGlow = Meshes.builder() + .loft(0f, 0f, f(-0.06f, -0.34f), f(0.014f, 0.012f), f(0.008f, 0.008f), f(-0.1f, -0.088f)) + .done(); + shinSuit = Meshes.builder() + .loft(0f, 0f, f(0f, -0.13f, -0.3f, -0.42f, -0.48f), f(0.052f, 0.06f, 0.048f, 0.036f, 0.034f), + f(0.056f, 0.07f, 0.055f, 0.04f, 0.038f), f(0f, 0.01f, 0.006f, 0f, 0f)) + .done(); + shinShell = Meshes.builder() + // The shin guard down the front. + .loft(0f, 0f, f(-0.05f, -0.09f, -0.33f, -0.38f), f(0.035f, 0.048f, 0.04f, 0.03f), + f(0.012f, 0.018f, 0.016f, 0.01f), f(-0.058f, -0.066f, -0.05f, -0.042f)) + .done(); + // A trainer from the ankle forwards: a low toe, a raised heel + // collar, and a sole that runs a little proud of the upper. + shoe = Meshes.builder() + .loft(0f, 0f, f(-0.075f, -0.035f, 0.005f, 0.045f), f(0.056f, 0.058f, 0.05f, 0.04f), + f(0.15f, 0.148f, 0.105f, 0.055f), f(-0.065f, -0.065f, -0.035f, 0f)) + .done(); + sole = Meshes.builder() + .loft(0f, 0f, f(-0.1f, -0.075f), f(0.062f, 0.062f), f(0.16f, 0.16f), f(-0.066f, -0.066f)) + .done(); + } + + private static float[] f(float... v) { + return v; + } + } + + private final Model[] torso = new Model[3]; + private final Model[] head = new Model[3]; + private final Model[][] arm = new Model[2][2]; + private final Model[][] fore = new Model[2][3]; + private final Model[][] thigh = new Model[2][3]; + private final Model[][] shin = new Model[2][2]; + private final Model[][] foot = new Model[2][2]; + private final Model shadow; + private final Model[] all; + private final float[] pose = new float[JOINTS]; + private final float[] want = new float[JOINTS]; + private final float[] end = new float[2]; + private int count; + private float phase; + + Rig(Kit k, Outfit o) { + // A matte suit and satin armour, flat shaded, so each panel takes + // the light as one tone; the gloss is kept for the helmet. + Material suit = new Material(Material.Type.PHONG).setColor(o.suit).setShininess(10f); + Material shell = new Material(Material.Type.PHONG).setColor(o.shell).setShininess(40f); + Material helmet = new Material(Material.Type.PHONG).setColor(Outfit.mix(o.shell, o.suit, 0.3f)) + .setShininess(90f); + Material kicks = new Material(Material.Type.PHONG).setColor(Outfit.mix(o.shell, 0xffffffff, 0.8f)) + .setShininess(50f); + Material glow = new Material(Material.Type.UNLIT).setColor(o.trim); + torso[0] = new Model(k.torsoSuit, suit); + torso[1] = new Model(k.torsoShell, shell); + torso[2] = new Model(k.torsoGlow, glow); + head[0] = new Model(k.headShell, helmet); + head[1] = new Model(k.headSuit, suit); + head[2] = new Model(k.headGlow, glow); + for (int s = 0; s < 2; s++) { + arm[s][0] = new Model(k.armSuit, suit); + arm[s][1] = new Model(k.armShell, shell); + fore[s][0] = new Model(k.foreSuit, suit); + fore[s][1] = new Model(k.foreShell, shell); + fore[s][2] = new Model(k.foreGlow, glow); + thigh[s][0] = new Model(k.thighSuit, suit); + thigh[s][1] = new Model(k.thighShell, shell); + thigh[s][2] = new Model(k.thighGlow, glow); + shin[s][0] = new Model(k.shinSuit, suit); + shin[s][1] = new Model(k.shinShell, shell); + foot[s][0] = new Model(k.shoe, kicks); + foot[s][1] = new Model(k.sole, glow); + } + shadow = new Model(k.quad, k.shadow); + shadow.setRotation(-90f, 0f, 0f); + all = new Model[1 + 6 + 2 * 12]; + all[count++] = shadow; + add(torso); + add(head); + for (int s = 0; s < 2; s++) { + add(arm[s]); + add(fore[s]); + add(thigh[s]); + add(shin[s]); + add(foot[s]); + } + running(0f); + System.arraycopy(want, 0, pose, 0, JOINTS); + } + + private void add(Model[] parts) { + for (int i = 0; i < parts.length; i++) { + all[count++] = parts[i]; + } + } + + void addTo(GameView view) { + for (int i = 0; i < all.length; i++) { + view.addModel(all[i]); + } + } + + void setVisible(boolean v) { + for (int i = 0; i < all.length; i++) { + all[i].setVisible(v); + } + } + + + /// Poses the body standing at `x, y, z` (feet on the ground plane at + /// `y`), facing down the track, for one frame of `dt` seconds. + void pose(float x, float y, float z, boolean alive, boolean finished, boolean air, boolean slide, + float speed, float dt) { + phase += dt * (5f + 6f * Math.min(1.6f, speed)); + if (!alive) { + down(); + } else if (finished) { + cheer(); + } else if (slide) { + sliding(); + } else if (air) { + airborne(); + } else { + running(phase); + } + float k = Math.min(1f, dt * (alive ? 14f : 6f)); + for (int i = 0; i < JOINTS; i++) { + pose[i] += (want[i] - pose[i]) * k; + } + place(x, y, z); + float lift = Math.max(0f, y); + float s = Math.max(0.35f, 0.9f - lift * 0.18f); + shadow.setScale(s * 1.05f, s * 1.3f, 1f); + shadow.setPosition(x, 0.03f, z); + } + + private void running(float ph) { + float sin = (float) Math.sin(ph); + float cos = (float) Math.cos(ph); + set(14f, STAND - 0.05f + Math.abs(cos) * 0.06f); + legs(40f * sin, 22f + 58f * (0.5f - 0.5f * sin), -40f * sin, 22f + 58f * (0.5f + 0.5f * sin)); + arms(-48f * sin, 80f, 48f * sin, 80f); + } + + private void airborne() { + set(18f, STAND - 0.12f); + legs(75f, 95f, 25f, 70f); + arms(130f, 20f, -40f, 60f); + } + + private void sliding() { + set(-62f, 0.32f); + legs(82f, 0f, 70f, -10f); + arms(-50f, -20f, -60f, -30f); + } + + private void down() { + set(84f, 0.16f); + legs(-80f, 0f, -96f, -10f); + arms(165f, 0f, 150f, -10f); + } + + private void cheer() { + set(-4f, STAND); + legs(6f, 6f, -6f, -6f); + arms(170f, 0f, 160f, -10f); + } + + private void set(float lean, float hip) { + want[LEAN] = lean; + want[HIP] = hip; + } + + /// Thigh angle and knee bend for each leg. + private void legs(float tl, float bl, float tr, float br) { + want[THIGH_L] = tl; + want[SHIN_L] = tl - bl; + want[THIGH_R] = tr; + want[SHIN_R] = tr - br; + } + + /// Upper arm angle and elbow bend for each arm. + private void arms(float al, float el, float ar, float er) { + want[ARM_L] = al; + want[FORE_L] = al + el; + want[ARM_R] = ar; + want[FORE_R] = ar + er; + } + + // The body is solved in the runner's side plane -- height and distance + // forward -- and turned into world space at the end: forward is -z. + // Every part hangs from its joint, so placing one is a position and a + // pitch. + private void place(float x, float y, float z) { + float hipY = y + pose[HIP]; + float lean = pose[LEAN]; + float sinL = sin(lean); + float cosL = cos(lean); + put(torso, x, hipY, z, -lean); + // The head rides on the neck and lifts against the lean, so the + // face plate looks down the track. + float neck = TORSO + 0.05f; + put(head, x, hipY + cosL * neck, z - sinL * neck, -lean * 0.45f); + float sh = TORSO - 0.065f; + float shY = hipY + cosL * sh; + float shF = sinL * sh; + for (int s = 0; s < 2; s++) { + float side = s == 0 ? -1f : 1f; + float lx = x + side * 0.095f; + float ta = pose[s == 0 ? THIGH_L : THIGH_R]; + put(thigh[s], lx, hipY, z, ta); + reach(hipY, 0f, ta, THIGH); + float sa = pose[s == 0 ? SHIN_L : SHIN_R]; + float kneeY = end[0]; + float kneeF = end[1]; + put(shin[s], lx, kneeY, z - kneeF, sa); + reach(kneeY, kneeF, sa, SHIN); + put(foot[s], lx, end[0], z - end[1], sa * 0.3f); + float ax = x + side * 0.235f; + float aa = pose[s == 0 ? ARM_L : ARM_R] + lean * 0.3f; + put(arm[s], ax, shY, z - shF, aa); + reach(shY, shF, aa, UPPER_ARM); + put(fore[s], ax, end[0], z - end[1], pose[s == 0 ? FORE_L : FORE_R] + lean * 0.3f); + } + } + + /// Where a limb hanging from `(y, f)` at angle `a` ends, left in `end`. + private void reach(float y, float f, float a, float len) { + end[0] = y - cos(a) * len; + end[1] = f + sin(a) * len; + } + + private static void put(Model[] parts, float x, float y, float z, float rotX) { + for (int i = 0; i < parts.length; i++) { + parts[i].setPosition(x, y, z); + parts[i].setRotation(rotX, 0f, 0f); + } + } + + private static float sin(float deg) { + return (float) Math.sin(Math.toRadians(deg)); + } + + private static float cos(float deg) { + return (float) Math.cos(Math.toRadians(deg)); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java new file mode 100644 index 00000000000..b092e1da3cc --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/RunView.java @@ -0,0 +1,1045 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gaming.GameCamera; +import com.codename1.gaming.GameInput; +import com.codename1.gaming.GameView; +import com.codename1.gaming.Model; +import com.codename1.gaming.ParticleEmitter; +import com.codename1.gaming.TouchControls; +import com.codename1.gaming.VirtualButton; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.gpu.Mesh; +import com.codename1.gpu.Primitives; +import com.codename1.gpu.RenderState; +import com.codename1.gpu.Texture; +import com.codename1.io.Preferences; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.Clearance; +import com.codenameone.examples.dash.rules.Input; +import com.codenameone.examples.dash.rules.PickupKind; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Runner; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.rules.ViewMode; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/// Draws a run in 3D, and -- for a stretch a level cues as `SIDE_2D` -- as a +/// flat side-scroller. +/// +/// There is one scene and one simulation. "Faux 2D" is a camera: off to the +/// side, low and looking across the track, so the runner moves left to right +/// against the skyline and the track reads as a platformer. +/// When the runner reaches a cue, the camera's position, target and field of +/// view are interpolated, over the cue's blend distance, to the chase view +/// behind the runner. Nothing the player does means anything different +/// either side of the swing: [Track#viewAt] is for the renderer, and the +/// rules never read it. +/// +/// World units: one per metre. The track runs toward -z; lane 0 is on the +/// left. +// tag::dash-run-view[] +public class RunView extends GameView { + /// What the run screen wants to hear: a tick happened, or the run ended. + public interface Hud { + void ticked(Run run); + + void ended(Run run); + } + + public static final double TICK = 1.0 / Run.TICKS_PER_SECOND; + private static final int MAX_STEPS_PER_FRAME = 8; + private static final float LANE_W = 1.5f; + private static final float SEGMENT = 10f; + private static final int SEGMENTS = 16; + private static final float AHEAD = 90f; + private static final float BEHIND = 12f; + /// How much of the track behind the runner the side camera sees. It + /// stands out to the right and looks across, so its frame reaches far + /// back along the track -- some sixty metres at the skyline on a wide + /// desktop window -- where the chase camera sees nothing behind at all. + /// Scenery recycled at BEHIND there vanished from the left of the frame. + private static final float SIDE_BEHIND = 75f; + private static final float RUNNER_HEIGHT = 1.7f; + private static final int SWIPE = 24; + private static final int TOWERS = 2; + + private final Driver driver; + private Runnable beforeTick; + private Hud hud; + private GraphicsDevice device; + private Mesh cube; + private Mesh quad; + private Mesh sphere; + private Mesh gemMesh; + private Mesh coinMesh; + private Glow glow; + private Rig.Kit kit; + private final Map shapes = new HashMap(); + private final Model[] floor = new Model[SEGMENTS]; + /// Raised, rounded curbs along both edges, one pair per segment with a + /// gap between, so they tick past. + private final Model[] curbs = new Model[SEGMENTS * 2]; + /// What does not change along the track travels with the runner: the + /// neon tube on each curb, its halo, and its reflection on the floor. + private final Model[] neon = new Model[6]; + /// Towers of the skyline in three layers. The near ones, TOWERS per + /// segment with a setback crown and a beacon on top, and the middle ones + /// stand on the left only, so the side-on camera, which sits out on the + /// right, looks across the track at them; each layer is hazier than + /// the one before it. The far layer has a row on the right too, past the + /// side camera, where it is only ever seen from behind the runner. + private final Model[] towers = new Model[SEGMENTS * TOWERS]; + /// The side of the causeway the track runs on. In the side view the + /// city drops away below the track and this face fills the bottom of + /// the frame, where a bare field of ground would otherwise be. + private final Model[] decks = new Model[SEGMENTS]; + /// The metres behind the runner kept in the scene this frame. + private float behind = BEHIND; + private Model ground; + /// How far the city sinks below the track when seen from the side. + private static final float DROP = 9f; + private final Model[] crowns = new Model[SEGMENTS * TOWERS]; + private final Model[] beacons = new Model[SEGMENTS * TOWERS]; + private final Model[] midTowers = new Model[SEGMENTS]; + private final Model[] farTowers = new Model[SEGMENTS * 2]; + private final Model[] haze = new Model[3]; + /// Light gantries over the track, one every other segment: two legs, a + /// rounded beam, the light tube under it, its halo and its pool of light + /// on the floor. + private static final int GANTRY_PARTS = 6; + private final Model[] gantries = new Model[SEGMENTS / 2 * GANTRY_PARTS]; + private Model sky; + private Model sun; + private Model sunHalo; + private Model sunCorona; + private Theme theme; + private Rig[] runners = new Rig[0]; + private Rig[] ghostModels = new Rig[0]; + private final Map entries = new HashMap(); + private ParticleEmitter streaks; + private ParticleEmitter trail; + private Material hurdleFrame; + private Material hurdleBar; + private Material hurdleHalo; + private Material holo; + private Material warning; + private Material warningHalo; + private Material pod; + private Material podGlow; + private Material podHalo; + private Material pit; + private Material pitRim; + private Material pitHalo; + private Material pitDepth; + private Material coinMaterial; + private Material coinGlint; + private final Map gemMaterials = new HashMap(); + private int firstEntry; + private ParticleEmitter sparks; + private ParticleEmitter dust; + private double accumulator; + private float alpha; + private float[] prevX = new float[0]; + private float[] prevY = new float[0]; + private float[] prevZ = new float[0]; + private int lastCoins; + private int lastStumbles; + private boolean lastAirborne; + private boolean endedSent; + private float time; + private String[] runnerIds = new String[0]; + private int pressX = -1; + private int pressY = -1; + private final boolean[] keyWas = new boolean[4]; + private Signage signage; + + public RunView(Driver driver) { + this.driver = driver; + } + + public void setHud(Hud hud) { + this.hud = hud; + } + + /// The shop id of each runner's character, for its model; missing ones + /// get a crowd model. + public void setRunnerIds(String[] ids) { + runnerIds = ids == null ? new String[0] : ids; + } + + public Driver driver() { + return driver; + } + + /// Runs `hook` on the EDT before every simulation tick, however many + /// ticks a slow frame catches up on -- an autopilot that decides per + /// tick never misses a jump the way one polling per frame does. The + /// tests and the promo recordings steer with it; null removes it. + public void setBeforeTick(Runnable hook) { + beforeTick = hook; + } + + @Override + protected void onSetup(GraphicsDevice d) { + device = d; + theme = Theme.of(driver.run().level().theme); + setClearColor(theme.skyHorizon); + getLight().setDirection(-0.5f, -1f, -0.35f).setColor(theme.light).setAmbientColor(theme.ambient); + getCamera().setPerspective(60f, 0.5f, 400f); + touchButtons(); + cube = Primitives.cube(d, 1f); + quad = Primitives.quad(d, 1f); + sphere = Primitives.sphere(d, 0.5f, 12, 18, false); + gemMesh = Meshes.gem(0.5f, 1f); + coinMesh = Meshes.coin(0.34f, 0.1f, 24); + glow = new Glow(d); + kit = new Rig.Kit(quad, glow); + + Texture skyTex = d.createTexture(4, 128, Theme.skyTexture(theme, 128)); + skyTex.setFilter(Texture.Filter.LINEAR); + sky = new Model(Primitives.sphere(d, 0.5f, 16, 16, true), new Material(Material.Type.UNLIT).setTexture(skyTex)); + sky.setScale(700f); + addModel(sky); + Texture hazeTex = d.createTexture(4, 64, Theme.hazeTexture(theme)); + hazeTex.setFilter(Texture.Filter.LINEAR); + Material hazeMaterial = Glow.blended(hazeTex, 0xffffffff); + for (int i = 0; i < haze.length; i++) { + haze[i] = Glow.sprite(quad, hazeMaterial, 0f, 28f, 0f, 900f, 66f); + // Ahead, to the left, and to the right past the side camera. + haze[i].setRotation(0f, i == 0 ? 0f : i == 1 ? 90f : -90f, 0f); + addModel(haze[i]); + } + sun = new Model(sphere, new Material(Material.Type.UNLIT).setColor(theme.sun)); + sun.setScale(46f, 46f, 1f); + addModel(sun); + sunCorona = Glow.sprite(quad, Glow.additive(glow.soft, (theme.sun & 0x00ffffff) | 0xc0000000), + 0f, 0f, 0f, 120f, 120f); + sunHalo = Glow.sprite(quad, Glow.additive(glow.soft, (theme.sun & 0x00ffffff) | 0x60000000), + 0f, 0f, 0f, 320f, 220f); + addModel(sunHalo); + addModel(sunCorona); + + int lanes = Math.max(1, driver.run().level().lanes); + float half = lanes * LANE_W / 2f + 0.3f; + Texture lane = d.createTexture(128, 128, Theme.laneTexture(theme, lanes)); + lane.setFilter(Texture.Filter.LINEAR); + Material floorMaterial = new Material(Material.Type.PHONG).setTexture(lane).setShininess(70f); + Mesh curb = Meshes.roundedBox(0.38f, 0.26f, SEGMENT - 0.25f, 0.1f, 3); + Material curbMaterial = new Material(Material.Type.PHONG).setColor(Theme.shade(theme.tower, 0.55f)) + .setShininess(90f); + for (int i = 0; i < SEGMENTS; i++) { + Model m = new Model(quad, floorMaterial); + m.setRotation(-90f, 0f, 0f); + m.setScale(lanes * LANE_W + 0.6f, SEGMENT, 1f); + floor[i] = m; + addModel(m); + for (int s = 0; s < 2; s++) { + Model r = new Model(curb, curbMaterial); + curbs[i * 2 + s] = r; + addModel(r); + } + } + Material tube = new Material(Material.Type.UNLIT).setColor(Theme.mix(theme.rail, 0xffffffff, 0.3f)); + Material tubeHalo = Glow.additive(glow.streak, (theme.rail & 0x00ffffff) | 0x90000000); + Material tubeShine = Glow.additive(glow.streak, (theme.rail & 0x00ffffff) | 0x58000000); + for (int s = 0; s < 2; s++) { + neon[s] = box(cube, tube, 0f, 0f, 0f, 0.07f, 0.07f, AHEAD + SIDE_BEHIND); + neon[2 + s] = Glow.sprite(quad, tubeHalo, 0f, 0f, 0f, AHEAD + SIDE_BEHIND, 0.7f); + neon[2 + s].setRotation(0f, 90f, 0f); + neon[4 + s] = Glow.sprite(quad, tubeShine, 0f, 0f, 0f, AHEAD + SIDE_BEHIND, 1.5f); + neon[4 + s].setRotation(-90f, 90f, 0f); + } + for (int i = 0; i < neon.length; i++) { + addModel(neon[i]); + } + Texture deckTex = d.createTexture(64, 64, Theme.deckTexture(theme)); + deckTex.setFilter(Texture.Filter.LINEAR); + Material deckMaterial = new Material(Material.Type.LAMBERT).setTexture(deckTex); + for (int i = 0; i < decks.length; i++) { + decks[i] = new Model(cube, deckMaterial); + decks[i].setVisible(false); + addModel(decks[i]); + } + ground = new Model(quad, new Material(Material.Type.LAMBERT).setColor(theme.ground)); + ground.setRotation(-90f, 0f, 0f); + ground.setScale(800f, 800f, 1f); + ground.setPosition(0f, -0.05f, 0f); + ground.setUserData("ground"); + addModel(ground); + Material nearTower = towerMaterial(d, 0.08f); + Material midTower = towerMaterial(d, 0.42f); + Material farTower = towerMaterial(d, 0.7f); + Material beacon = Glow.additive(glow.soft, (theme.rail & 0x00ffffff) | 0xe0000000); + for (int i = 0; i < towers.length; i++) { + towers[i] = new Model(cube, nearTower); + crowns[i] = new Model(cube, nearTower); + beacons[i] = Glow.sprite(quad, beacon, 0f, 0f, 0f, 1.6f, 1.6f); + addModel(towers[i]); + addModel(crowns[i]); + addModel(beacons[i]); + } + for (int i = 0; i < midTowers.length; i++) { + midTowers[i] = new Model(cube, midTower); + addModel(midTowers[i]); + } + for (int i = 0; i < farTowers.length; i++) { + farTowers[i] = new Model(cube, farTower); + addModel(farTowers[i]); + } + float span = half * 2f + 0.8f; + Mesh legMesh = Meshes.roundedBox(0.3f, 4.6f, 0.3f, 0.1f, 3); + Mesh beamMesh = Meshes.capsule(0.14f, span, 4, 12); + Material leg = new Material(Material.Type.PHONG).setColor(Theme.shade(theme.tower, 0.6f)).setShininess(80f); + Material beamLight = new Material(Material.Type.UNLIT).setColor(Theme.mix(theme.line, 0xffffffff, 0.3f)); + Material beamHalo = Glow.additive(glow.streak, (theme.line & 0x00ffffff) | 0xa0000000); + Material pool = Glow.additive(glow.soft, (theme.line & 0x00ffffff) | 0x48000000); + for (int g = 0; g < SEGMENTS / 2; g++) { + int o = g * GANTRY_PARTS; + gantries[o] = new Model(legMesh, leg); + gantries[o + 1] = new Model(legMesh, leg); + gantries[o + 2] = new Model(beamMesh, leg); + gantries[o + 2].setRotation(0f, 0f, 90f); + gantries[o + 3] = box(cube, beamLight, 0f, 0f, 0f, span - 0.4f, 0.06f, 0.08f); + gantries[o + 4] = Glow.sprite(quad, beamHalo, 0f, 0f, 0f, span + 2.5f, 1.8f); + gantries[o + 5] = Glow.sprite(quad, pool, 0f, 0f, 0f, span, 4f); + gantries[o + 5].setRotation(-90f, 0f, 0f); + for (int p = 0; p < GANTRY_PARTS; p++) { + addModel(gantries[o + p]); + } + } + + hurdleFrame = new Material(Material.Type.PHONG).setColor(0xff2a2f3d).setShininess(80f); + hurdleBar = new Material(Material.Type.UNLIT).setColor(0xffff6d88); + hurdleHalo = Glow.additive(glow.streak, 0xc0ff3d6d); + Texture holoTex = d.createTexture(64, 64, Theme.holoTexture(0xe0ffd23a, 0x5014141c)); + holoTex.setFilter(Texture.Filter.LINEAR); + holo = Glow.blended(holoTex, 0xffffffff); + warning = new Material(Material.Type.UNLIT).setColor(0xffff5a5a); + warningHalo = Glow.additive(glow.streak, 0xc0ff3b3b); + Texture podTex = d.createTexture(64, 64, Theme.podTexture(0xff9aa3c0, 0xff35e8ff)); + podTex.setFilter(Texture.Filter.LINEAR); + pod = new Material(Material.Type.PHONG).setTexture(podTex).setShininess(110f); + podGlow = new Material(Material.Type.UNLIT).setColor(0xff5af0ff); + podHalo = Glow.additive(glow.soft, 0x9035e8ff); + pit = new Material(Material.Type.UNLIT).setColor(0xff05050a); + pitRim = new Material(Material.Type.UNLIT).setColor(0xffffb44a); + pitHalo = Glow.additive(glow.streak, 0xd0ff8a2a); + pitDepth = Glow.additive(glow.soft, 0x70ff5a1a); + coinMaterial = new Material(Material.Type.PHONG).setColor(0xffffc93a).setShininess(128f); + coinGlint = Glow.additive(glow.glint, 0xd0fff0b0); + CityRoute city = Levels.cityRoute(driver.run().level().id); + if (city != null) { + signage = new Signage(d, city, cube, quad, legMesh, half); + for (Model m : signage.models()) { + addModel(m); + } + } + + Run run = driver.run(); + runners = new Rig[run.runnerCount()]; + for (int i = 0; i < runners.length; i++) { + String id = i < runnerIds.length ? runnerIds[i] : null; + runners[i] = rig(Outfit.of(id, i == driver.me() ? 0 : i + 1)); + } + Run[] ghosts = driver.ghosts(); + ghostModels = new Rig[ghosts.length]; + for (int i = 0; i < ghosts.length; i++) { + ghostModels[i] = rig(Outfit.of(null, i + 2).ghost()); + } + prevX = new float[runners.length + ghostModels.length]; + prevY = new float[prevX.length]; + prevZ = new float[prevX.length]; + remember(); + + sparks = new ParticleEmitter(d, 160).setColor(0xffffd65a).setAdditive(true) + .setSpeed(1.5f, 4f).setLifetime(0.15f, 0.4f).setSize(0.25f, 0.05f) + .setGravity(-6f).setDrag(1f); + dust = new ParticleEmitter(d, 120).setColor((Theme.mix(theme.ground, 0xffffffff, 0.6f) & 0x00ffffff) | 0x80000000).setAdditive(false) + .setSpeed(0.6f, 1.8f).setLifetime(0.3f, 0.7f).setSize(0.3f, 0.8f) + .setGravity(0.4f).setDrag(3f).setDirection(0f, 0.3f, 0f, 85f); + streaks = new ParticleEmitter(d, 200).setColor((Theme.mix(theme.line, 0xffffffff, 0.5f) & 0x00ffffff) | 0x70000000).setAdditive(true) + .setSpeed(26f, 40f).setLifetime(0.18f, 0.32f).setSize(0.07f, 0.02f) + .setGravity(0f).setDrag(0f).setDirection(0f, 0f, 1f, 4f); + trail = new ParticleEmitter(d, 160).setColor(runnerTrim()).setAdditive(true) + .setSpeed(0.4f, 1.2f).setLifetime(0.2f, 0.45f).setSize(0.14f, 0.02f) + .setGravity(0.5f).setDrag(2f).setDirection(0f, 0.2f, 1f, 50f); + dust.addTo(this); + sparks.addTo(this); + streaks.addTo(this); + trail.addTo(this); + Sfx.load(); + } + // end::dash-run-view[] + + private Rig rig(Outfit o) { + Rig r = new Rig(kit, o); + r.addTo(this); + return r; + } + + private Material towerMaterial(GraphicsDevice d, float hazeK) { + Texture t = d.createTexture(Theme.TOWER_TEX_W, Theme.TOWER_TEX_H, Theme.towerTexture(theme, hazeK)); + t.setFilter(Texture.Filter.LINEAR); + return new Material(hazeK > 0.5f ? Material.Type.UNLIT : Material.Type.LAMBERT).setTexture(t); + } + + /// A rounded box or a capsule of the given size, built the first time an + /// obstacle needs it and shared after that. + private Mesh rounded(float w, float h, float d, float r) { + String key = "b" + (int) (w * 100) + "," + (int) (h * 100) + "," + (int) (d * 100) + "," + (int) (r * 100); + Mesh m = shapes.get(key); + if (m == null) { + m = Meshes.roundedBox(w, h, d, r, 3); + shapes.put(key, m); + } + return m; + } + + private Mesh capsule(float r, float len) { + String key = "c" + (int) (r * 100) + "," + (int) (len * 100); + Mesh m = shapes.get(key); + if (m == null) { + m = Meshes.capsule(r, Math.max(0.01f, len), 4, 12); + shapes.put(key, m); + } + return m; + } + + private int runnerTrim() { + int me = driver.me(); + String id = me < runnerIds.length ? runnerIds[me] : null; + return (Outfit.of(id, 0).trim & 0x00ffffff) | 0xb0000000; + } + + // tag::dash-fixed-tick[] + @Override + protected void update(double dt) { + if (device == null || getWidth() <= 0 || getHeight() <= 0) { + return; + } + time += (float) dt; + input(); + if (driver.countdownTicks() > 0) { + accumulator = 0; + } else { + accumulator += dt * driver.speedPercent() / 100.0; + } + int steps = 0; + while (accumulator >= TICK && steps < MAX_STEPS_PER_FRAME) { + if (!driver.canStep()) { + // Online and waiting for the server: hold at the last tick. + accumulator = Math.min(accumulator, TICK); + break; + } + remember(); + if (beforeTick != null) { + beforeTick.run(); + } + driver.step(); + events(); + accumulator -= TICK; + steps++; + } + alpha = (float) Math.min(1, accumulator / TICK); + // end::dash-fixed-tick[] + draw((float) dt); + notifyHud(steps > 0); + } + + /// Positions before a tick, so a frame can draw between two ticks. + private void remember() { + Run run = driver.run(); + for (int i = 0; i < runners.length; i++) { + Runner r = run.runner(i); + prevX[i] = laneX(r); + prevY[i] = r.y / (float) Track.UNIT; + prevZ[i] = r.z / (float) Track.UNIT; + } + Run[] ghosts = driver.ghosts(); + for (int i = 0; i < ghostModels.length && i < ghosts.length; i++) { + Runner r = ghosts[i].runner(0); + int k = runners.length + i; + prevX[k] = laneX(r); + prevY[k] = r.y / (float) Track.UNIT; + prevZ[k] = r.z / (float) Track.UNIT; + } + } + + private float laneX(Runner r) { + int lanes = Math.max(1, driver.run().level().lanes); + return (r.laneX - (lanes - 1) * 500) / (float) Track.UNIT * LANE_W; + } + + /// Sounds and particles from what changed in the tick. + private void events() { + Runner me = driver.run().runner(driver.me()); + float x = laneX(me); + float z = -me.z / (float) Track.UNIT; + if (me.coins != lastCoins) { + sparks.burst(x, 1.2f + me.y / (float) Track.UNIT, z, 10); + Sfx.coin(); + } + if (me.stumbles != lastStumbles) { + dust.burst(x, 0.2f, z, 18); + if (me.alive) { + Sfx.stumble(); + } else { + Sfx.crash(); + } + } + boolean air = me.airborne(); + if (air && !lastAirborne) { + Sfx.jump(); + } else if (!air && lastAirborne) { + dust.burst(x, 0.1f, z, 8); + } + lastAirborne = air; + lastCoins = me.coins; + lastStumbles = me.stumbles; + } + + private void draw(float dt) { + Run run = driver.run(); + Runner me = run.runner(driver.me()); + float meZ = lerp(prevZ[driver.me()], me.z / (float) Track.UNIT); + float meX = lerp(prevX[driver.me()], laneX(me)); + float meY = lerp(prevY[driver.me()], me.y / (float) Track.UNIT); + + for (int i = 0; i < runners.length; i++) { + Runner r = run.runner(i); + pose(runners[i], r, lerp(prevX[i], laneX(r)), lerp(prevY[i], r.y / (float) Track.UNIT), + lerp(prevZ[i], r.z / (float) Track.UNIT), dt); + } + Run[] ghosts = driver.ghosts(); + for (int i = 0; i < ghostModels.length && i < ghosts.length; i++) { + Runner r = ghosts[i].runner(0); + int k = runners.length + i; + pose(ghostModels[i], r, lerp(prevX[k], laneX(r)), lerp(prevY[k], r.y / (float) Track.UNIT), + lerp(prevZ[k], r.z / (float) Track.UNIT), dt); + } + + scenery(run, meX, meZ, dt); + entries(run, me, meZ); + camera(run, me, meX, meY, meZ); + sparks.update(dt, getCamera()); + streaks.update(dt, getCamera()); + trail.update(dt, getCamera()); + dust.update(dt, getCamera()); + } + + private void pose(Rig m, Runner r, float x, float y, float z, float dt) { + m.pose(x, y, -z, r.alive, r.finished, r.airborne(), r.sliding(), r.speed / 12000f, dt); + } + + /// The floor, the rails, the skyline and the gantries leapfrog each + /// other as the runner passes them; the sky and the sun travel with the + /// camera so they stay at the horizon. + private void scenery(Run run, float meX, float meZ, float dt) { + if (signage != null) { + signage.update(meZ); + } + int lanes = Math.max(1, run.level().lanes); + float half = lanes * LANE_W / 2f + 0.3f; + boolean side = run.track().viewAt(run.runner(driver.me()).z) == ViewMode.SIDE_2D + && run.track().blendAt(run.runner(driver.me()).z) > 500; + GameCamera cam = getCamera(); + // The city sinks as the camera swings out to the side, and rises + // back as it swings in behind the runner; the chase camera never + // strays more than a few metres off centre, so it sees none of it. + float sink = Math.max(0f, Math.min(1f, (cam.getEyeX() - 6f) / 12f)); + float drop = DROP * sink; + // The recycled stretch slides back with the camera: 12 m behind the + // runner in the chase view, SIDE_BEHIND seen from the side. Its + // SEGMENTS * SEGMENT metres still reach well past the side frame. + behind = BEHIND + (SIDE_BEHIND - BEHIND) * sink; + int first = (int) Math.floor((meZ - behind) / SEGMENT); + ground.setPosition(0f, -0.05f - drop, 0f); + for (int i = 0; i < SEGMENTS; i++) { + int seg = first + i; + float cz = -(seg * SEGMENT + SEGMENT / 2f); + decks[i].setVisible(sink > 0.02f); + decks[i].setScale(0.4f, drop + 0.1f, SEGMENT); + decks[i].setPosition(half + 0.5f, (0.1f - drop) / 2f, cz); + floor[i].setPosition(0f, 0f, cz); + curbs[i * 2].setPosition(-half - 0.12f, 0.12f, cz); + curbs[i * 2 + 1].setPosition(half + 0.12f, 0.12f, cz); + for (int t = 0; t < TOWERS; t++) { + int k = i * TOWERS + t; + // Lower from the side, so the skyline stops short of the top + // of a portrait frame and leaves it sky. + float h = (6f + jitter(seg, 31 + t) * 22f) * (1f - 0.5f * sink); + float w = 3.5f + jitter(seg, 41 + t) * 4f; + float tx = -half - 7f - t * 9f - jitter(seg, 3 + t) * 5f; + float tz = -(seg * SEGMENT + jitter(seg, 5 + t) * SEGMENT); + towers[k].setScale(w, h, w); + towers[k].setPosition(tx, h / 2f - drop, tz); + // A setback crown on most towers, narrower and offset. + float ch = h * (0.2f + jitter(seg, 51 + t) * 0.3f); + float cw = w * (0.45f + jitter(seg, 61 + t) * 0.25f); + boolean crowned = jitter(seg, 71 + t) > 0.25f; + crowns[k].setVisible(crowned); + crowns[k].setScale(cw, ch, cw); + crowns[k].setPosition(tx + (w - cw) * 0.25f, h + ch / 2f - drop, tz); + float top = crowned ? h + ch : h; + beacons[k].setVisible(jitter(seg, 81 + t) > 0.35f); + beacons[k].setPosition(tx + (crowned ? (w - cw) * 0.25f : 0f), top + 0.5f - drop, tz); + Glow.face(beacons[k], cam); + } + float mh = (14f + jitter(seg, 91) * 32f) * (1f - 0.4f * sink); + float mw = 6f + jitter(seg, 92) * 8f; + midTowers[i].setScale(mw, mh, mw); + midTowers[i].setPosition(-half - 32f - jitter(seg, 93) * 12f, mh / 2f - drop, + -(seg * SEGMENT + jitter(seg, 94) * SEGMENT)); + for (int f = 0; f < 2; f++) { + float fh = 24f + jitter(seg, 101 + f) * 50f; + float fw = 9f + jitter(seg, 103 + f) * 12f; + // The right-hand row stands past x = 88, behind the side + // camera, so it never blocks the side-scroller view. + float fx = f == 0 ? -half - 62f - jitter(seg, 105) * 25f : 88f + jitter(seg, 106) * 25f; + farTowers[i * 2 + f].setScale(fw, fh, fw); + farTowers[i * 2 + f].setPosition(fx, fh / 2f - drop, -(seg * SEGMENT + jitter(seg, 107 + f) * SEGMENT)); + } + } + float mid = -meZ - (AHEAD - SIDE_BEHIND) / 2f; + for (int s = 0; s < 2; s++) { + float sx = s == 0 ? -1f : 1f; + neon[s].setPosition(sx * (half + 0.12f), 0.27f, mid); + neon[2 + s].setPosition(sx * (half + 0.12f), 0.3f, mid); + neon[4 + s].setPosition(sx * (half - 0.45f), 0.012f, mid); + } + for (int g = 0; g < SEGMENTS / 2; g++) { + int seg = first + g * 2 + (first & 1); + float gz = -(seg * SEGMENT); + int o = g * GANTRY_PARTS; + gantries[o].setPosition(-half - 0.45f, 2.3f, gz); + gantries[o + 1].setPosition(half + 0.45f, 2.3f, gz); + gantries[o + 2].setPosition(0f, 4.6f, gz); + gantries[o + 3].setPosition(0f, 4.43f, gz); + gantries[o + 4].setPosition(0f, 4.45f, gz + 0.1f); + gantries[o + 5].setPosition(0f, 0.015f, gz); + // Seen from the side, the near leg would sweep across the runner. + for (int p = 0; p < GANTRY_PARTS; p++) { + gantries[o + p].setVisible(!side); + } + } + sky.setPosition(cam.getEyeX(), 0f, cam.getEyeZ()); + float sunZ = -meZ - 300f; + sun.setPosition(0f, 22f, sunZ); + sunCorona.setPosition(0f, 22f, sunZ + 2f); + sunHalo.setPosition(0f, 20f, sunZ + 4f); + haze[0].setPosition(0f, 26f, -meZ - 250f); + haze[1].setPosition(-150f, 26f, -meZ - 60f); + haze[2].setPosition(150f, 26f, -meZ - 60f); + Runner me = run.runner(driver.me()); + if (me.alive && !me.finished && me.speed > 0) { + float fast = Math.min(1f, me.speed / 16000f); + int n = (int) (fast * 3f + (Math.random() < fast ? 1 : 0)); + for (int i = 0; i < n; i++) { + float a = (float) (Math.random() * Math.PI * 2); + float rr = 2f + (float) Math.random() * 3f; + streaks.burst(meX + (float) Math.cos(a) * rr, 1.6f + (float) Math.sin(a) * rr * 0.6f, + -meZ - 14f - (float) Math.random() * 10f, 1); + } + float y = me.y / (float) Track.UNIT; + trail.burst(meX, 0.15f + y, -meZ + 0.25f, me.airborne() ? 1 : 2); + } + } + + /// Obstacles and pickups from just behind the camera to the horizon: + /// models are made as entries come into view and dropped behind. + private void entries(Run run, Runner me, float meZ) { + Track track = run.track(); + while (firstEntry < track.size()) { + Track.Entry e = track.get(firstEntry); + if ((e.z + e.depth) / (float) Track.UNIT < meZ - behind) { + Model[] gone = entries.remove(Integer.valueOf(firstEntry)); + if (gone != null) { + remove(gone); + } + firstEntry++; + } else { + break; + } + } + for (int i = firstEntry; i < track.size(); i++) { + Track.Entry e = track.get(i); + if (e.z / (float) Track.UNIT > meZ + AHEAD) { + break; + } + Integer key = Integer.valueOf(i); + Model[] m = entries.get(key); + if (m == null) { + m = model(run, e); + entries.put(key, m); + for (int p = 0; p < m.length; p++) { + addModel(m[p]); + } + } + if (e.pickup != null) { + boolean taken = me.took(e.index); + for (int p = 0; p < m.length; p++) { + m[p].setVisible(!taken); + } + if (!taken) { + float bob = (float) Math.sin(time * 3f + i) * 0.12f; + for (int p = 0; p < m.length; p++) { + m[p].setPosition(m[p].getX(), 1.0f + bob, m[p].getZ()); + } + boolean coin = e.pickup.kind == PickupKind.COIN; + m[0].setRotation(coin ? 90f : 0f, time * 140f + i * 20f, 0f); + // The halo faces the camera and, on a coin, twinkles. + Glow.face(m[1], getCamera()); + float pulse = coin ? 0.75f + 0.35f * (float) Math.sin(time * 9f + i * 1.7f) : 1f; + m[1].setScale(coin ? pulse : 1.7f, coin ? pulse : 1.7f, 1f); + } + } + } + // Defensive: anything left over from before a restart. + Iterator> it = entries.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry en = it.next(); + if (en.getKey().intValue() < firstEntry) { + remove(en.getValue()); + it.remove(); + } + } + } + + private void remove(Model[] parts) { + for (int i = 0; i < parts.length; i++) { + removeModel(parts[i]); + } + } + + /// The models for one obstacle or pickup. Each kind has a shape a + /// player learns at a glance from any camera: a low bar with a glowing + /// top to jump, striped boards hanging overhead to slide under, a cargo + /// pod to steer around, and a black pit with hot edges. + private Model[] model(Run run, Track.Entry e) { + int lanes = Math.max(1, run.level().lanes); + float width = e.spansAllLanes() ? lanes * LANE_W : LANE_W * 0.85f; + float x = e.spansAllLanes() ? 0f : (e.lane * 1000 - (lanes - 1) * 500) / (float) Track.UNIT * LANE_W; + float depth = Math.max(0.2f, e.depth / (float) Track.UNIT); + float z = -(e.z / (float) Track.UNIT + depth / 2f); + if (e.pickup != null) { + float pz = -(e.z / (float) Track.UNIT); + if (e.pickup.kind == PickupKind.COIN) { + Model m = new Model(coinMesh, coinMaterial); + m.setPosition(x, 1.0f, pz); + return new Model[] {m, Glow.sprite(quad, coinGlint, x, 1.0f, pz + 0.05f, 1f, 1f)}; + } + Material[] mats = gemMaterials.get(e.pickup.kind); + if (mats == null) { + int c = pickupColor(e.pickup.kind); + mats = new Material[] { + new Material(Material.Type.PHONG).setColor(Theme.mix(c, 0xffffffff, 0.15f)).setShininess(110f), + Glow.additive(glow.soft, (c & 0x00ffffff) | 0xa0000000), + }; + gemMaterials.put(e.pickup.kind, mats); + } + Model m = new Model(gemMesh, mats[0]); + m.setScale(0.55f, 0.8f, 0.55f); + m.setPosition(x, 1.0f, pz); + return new Model[] {m, Glow.sprite(quad, mats[1], x, 1.0f, pz, 1f, 1f)}; + } + Clearance c = e.obstacle.clearance; + if (c == Clearance.JUMP && e.obstacle.height == 0) { + float w = width + 0.1f; + Model hole = box(quad, pit, x, 0.02f, z, w, depth, 1f); + hole.setRotation(-90f, 0f, 0f); + Model heat = box(quad, pitDepth, x, 0.03f, z, w * 0.9f, depth * 0.8f, 1f); + heat.setRotation(-90f, 0f, 0f); + Model near = box(capsule(0.06f, w - 0.12f), pitRim, x, 0.06f, z + depth / 2f, 1f, 1f, 1f); + Model far = box(capsule(0.06f, w - 0.12f), pitRim, x, 0.06f, z - depth / 2f, 1f, 1f, 1f); + near.setRotation(0f, 0f, 90f); + far.setRotation(0f, 0f, 90f); + Model nearHalo = box(quad, pitHalo, x, 0.04f, z + depth / 2f, w + 0.8f, 0.9f, 1f); + Model farHalo = box(quad, pitHalo, x, 0.04f, z - depth / 2f, w + 0.8f, 0.9f, 1f); + nearHalo.setRotation(-90f, 0f, 0f); + farHalo.setRotation(-90f, 0f, 0f); + return new Model[] {hole, heat, near, far, nearHalo, farHalo}; + } + if (c == Clearance.JUMP) { + float h = e.obstacle.height / (float) Track.UNIT; + float post = 0.12f; + Mesh postMesh = rounded(post, h, post, 0.05f); + Model bar = box(capsule(0.075f, width - 0.15f), hurdleBar, x, h - 0.075f, z, 1f, 1f, 1f); + bar.setRotation(0f, 0f, 90f); + return new Model[] { + box(postMesh, hurdleFrame, x - width / 2f + post, h / 2f, z, 1f, 1f, 1f), + box(postMesh, hurdleFrame, x + width / 2f - post, h / 2f, z, 1f, 1f, 1f), + box(rounded(width - post * 2f, h * 0.45f, 0.06f, 0.03f), hurdleFrame, x, h * 0.42f, z, 1f, 1f, 1f), + bar, + Glow.sprite(quad, hurdleHalo, x, h - 0.075f, z + 0.1f, width + 1f, 0.9f), + }; + } + if (c == Clearance.SLIDE) { + float d = Math.min(depth, 0.3f); + Model edge = box(capsule(0.05f, width - 0.1f), warning, x, 1.25f, z, 1f, 1f, 1f); + edge.setRotation(0f, 0f, 90f); + Model top = box(capsule(0.07f, width), hurdleFrame, x, 2.0f, z, 1f, 1f, 1f); + top.setRotation(0f, 0f, 90f); + Mesh postMesh = rounded(0.12f, 2.2f, 0.12f, 0.05f); + return new Model[] { + box(rounded(width, 0.7f, d, 0.06f), holo, x, 1.63f, z, 1f, 1f, 1f), + edge, + top, + box(postMesh, hurdleFrame, x - width / 2f, 1.1f, z, 1f, 1f, 1f), + box(postMesh, hurdleFrame, x + width / 2f, 1.1f, z, 1f, 1f, 1f), + Glow.sprite(quad, warningHalo, x, 1.25f, z + d / 2f + 0.05f, width + 1f, 0.8f), + }; + } + float pw = width * 0.92f; + Model under = box(quad, podHalo, x, 0.02f, z, pw * 1.5f, depth * 1.3f, 1f); + under.setRotation(-90f, 0f, 0f); + return new Model[] { + box(rounded(pw, 2.1f, depth, 0.36f), pod, x, 1.4f, z, 1f, 1f, 1f), + box(rounded(width * 0.8f, 0.32f, depth * 0.9f, 0.1f), hurdleFrame, x, 0.2f, z, 1f, 1f, 1f), + box(rounded(pw + 0.04f, 0.08f, depth + 0.04f, 0.04f), podGlow, x, 0.62f, z, 1f, 1f, 1f), + under, + Glow.sprite(quad, podHalo, x, 1.9f, z + depth / 2f + 0.08f, pw * 1.1f, 0.9f), + }; + } + + private static Model box(Mesh mesh, Material mat, float x, float y, float z, float sx, float sy, float sz) { + Model m = new Model(mesh, mat); + m.setScale(sx, sy, sz); + m.setPosition(x, y, z); + return m; + } + + private static int pickupColor(PickupKind k) { + switch (k) { + case COIN: + return 0xffffcc33; + case GEM: + return 0xff3ce0ff; + case MAGNET: + return 0xffff3d9a; + case SHIELD: + return 0xff8a7bff; + default: + return 0xffc8ff3d; + } + } + + // tag::dash-camera-blend[] + /// Places the camera for `mode`, into `out`: eye x, y, z, target x, y, z, + /// field of view, then the up vector's z (0 for upright, -1 looking down). + private static void framing(ViewMode mode, float x, float y, float z, float[] out) { + if (mode == ViewMode.SIDE_2D) { + // Off to the right, at the deck's height and tilted up: the runner + // a third in from the left with the track ahead in view, the deck + // in the lower third, and open sky over the skyline. A portrait + // frame is narrow along the track, so the camera stands well back + // to see enough of it ahead. + out[0] = 24f; + out[1] = 0.9f + y * 0.3f; + out[2] = -z - 1.3f; + out[3] = 0f; + out[4] = 4.6f + y * 0.3f; + out[5] = -z - 1.3f; + out[6] = 42f; + out[7] = 0f; + } else if (mode == ViewMode.TOP_DOWN) { + out[0] = x * 0.4f; + out[1] = 26f; + out[2] = -z + 2f; + out[3] = x * 0.4f; + out[4] = 0f; + out[5] = -z - 6f; + out[6] = 40f; + out[7] = -1f; + } else { + out[0] = x * 0.6f; + out[1] = 3.4f + y * 0.5f; + out[2] = -z + 6.5f; + out[3] = x * 0.6f; + out[4] = 1.0f + y * 0.3f; + out[5] = -z - 9f; + out[6] = 62f; + out[7] = 0f; + } + } + + private final float[] from = new float[8]; + private final float[] to = new float[8]; + private final float[] blended = new float[8]; + + private void camera(Run run, Runner me, float x, float y, float z) { + Track t = run.track(); + int mz = me.z; + float k = t.blendAt(mz) / 1000f; + k = k * k * (3f - 2f * k); + framing(t.previousViewAt(mz), x, y, z, from); + framing(t.viewAt(mz), x, y, z, to); + float[] v = blended; + for (int i = 0; i < 8; i++) { + v[i] = from[i] + (to[i] - from[i]) * k; + } + GameCamera cam = getCamera(); + cam.setPerspective(v[6], 0.5f, 400f); + cam.setPosition(v[0], v[1], v[2]); + cam.setTarget(v[3], v[4], v[5]); + cam.setUp(0f, 1f + v[7], v[7]); + } + // end::dash-camera-blend[] + + private float lerp(float a, float b) { + return a + (b - a) * alpha; + } + + /// A stable pseudo-random 0..1 per track segment, for scenery: the same + /// trees stand in the same places every run. + private static float jitter(int seg, int salt) { + int h = seg * 73856093 ^ salt * 19349663; + h ^= h >>> 13; + h *= 0x5bd1e995; + h ^= h >>> 15; + return (h & 0xffff) / 65535f; + } + + // tag::dash-controls[] + /// Swipes, the on-screen buttons and keys become [Input] bits. A tap jumps; + /// a swipe goes the way it points. Arrow keys and WASD do the same, Space + /// jumps. + private void input() { + if (!driver.interactive()) { + return; + } + GameInput in = getInput(); + int bits = 0; + if (in.wasPointerPressed()) { + boolean button = onButton(in.getPointerX(), in.getPointerY()); + pressX = button ? -1 : in.getPointerX(); + pressY = in.getPointerY(); + } + if (in.wasPointerReleased() && pressX >= 0) { + int dx = in.getPointerX() - pressX; + int dy = in.getPointerY() - pressY; + int min = CN.convertToPixels(SWIPE / 8f); + if (Math.abs(dx) < min && Math.abs(dy) < min) { + bits |= Input.JUMP; + } else if (Math.abs(dx) > Math.abs(dy)) { + bits |= dx < 0 ? Input.LEFT : Input.RIGHT; + } else { + bits |= dy < 0 ? Input.JUMP : Input.SLIDE; + } + pressX = -1; + } + bits |= edge(0, in.isGameKeyDown(Display.GAME_LEFT) || in.isKeyDown('a') || in.isKeyDown('A'), Input.LEFT); + bits |= edge(1, in.isGameKeyDown(Display.GAME_RIGHT) || in.isKeyDown('d') || in.isKeyDown('D'), Input.RIGHT); + bits |= edge(2, in.isGameKeyDown(Display.GAME_UP) || in.isKeyDown('w') || in.isKeyDown('W') + || in.isKeyDown(' '), Input.JUMP); + bits |= edge(3, in.isGameKeyDown(Display.GAME_DOWN) || in.isKeyDown('s') || in.isKeyDown('S'), Input.SLIDE); + if (bits != 0) { + driver.input(bits); + } + } + // end::dash-controls[] + + /// The preference that hides the on-screen buttons. + public static final String BUTTONS_KEY = "dash.touchButtons"; + private VirtualButton[] buttons = new VirtualButton[0]; + + /// On a touch screen the four moves are buttons too, drawn and tracked by + /// the framework's [TouchControls]: left in the bottom left corner with + /// slide (v) above it, right in the bottom right corner with jump (^) + /// above it, so each thumb steers toward its own side. They press the same + /// game keys the arrow keys do, so [#input()] reads them with the keyboard, + /// and swipes keep working everywhere else on the screen. A desktop has a + /// keyboard, and a player who swipes can turn them off in the settings. + private void touchButtons() { + if (!driver.interactive() || !Display.getInstance().isTouchScreenDevice() || CN.isDesktop() + || !Preferences.get(BUTTONS_KEY, true)) { + return; + } + TouchControls c = getControls(); + float r = CN.convertToPixels(6.5f); + float gap = CN.convertToPixels(3); + // A corner anchor takes one margin for both edges, so the second + // button of a corner sits diagonally inward from the first. + float inner = gap + r * 1.6f; + buttons = new VirtualButton[] { + button(c, Display.GAME_LEFT, "<", r, TouchControls.LEFT, gap), + button(c, Display.GAME_DOWN, "v", r, TouchControls.LEFT, inner), + button(c, Display.GAME_RIGHT, ">", r, TouchControls.RIGHT, gap), + button(c, Display.GAME_UP, "^", r, TouchControls.RIGHT, inner) + }; + } + + private static VirtualButton button(TouchControls c, int action, String label, float r, int h, float margin) { + VirtualButton b = c.addButton(Display.getInstance().getKeyCode(action), r, h, TouchControls.BOTTOM, margin); + b.setLabel(label); + b.setColor(0x662ee6ff); + return b; + } + + /// Whether a touch at view-local `x`, `y` lands on one of the buttons, so + /// it is not read as a tap or a swipe as well. + private boolean onButton(int x, int y) { + for (int i = 0; i < buttons.length; i++) { + float dx = x - buttons[i].getCenterX(); + float dy = y - buttons[i].getCenterY(); + float r = buttons[i].getRadius() * 1.15f; + if (dx * dx + dy * dy <= r * r) { + return true; + } + } + return false; + } + + private int edge(int key, boolean down, int bit) { + boolean was = keyWas[key]; + keyWas[key] = down; + return down && !was ? bit : 0; + } + + private int hudTick = -1; + + private void notifyHud(boolean stepped) { + final Run run = driver.run(); + if (hud == null) { + return; + } + if (stepped && run.tick() / 6 != hudTick) { + hudTick = run.tick() / 6; + CN.callSerially(() -> { + if (hud != null) { + hud.ticked(run); + } + }); + } + if (run.over() && !endedSent) { + endedSent = true; + CN.callSerially(() -> { + if (hud != null) { + hud.ended(run); + } + }); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Sfx.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Sfx.java new file mode 100644 index 00000000000..e76803e5e5d --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Sfx.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gaming.SoundEffect; +import com.codename1.gaming.SoundPool; +import java.io.ByteArrayInputStream; + +/// The run's sound effects, synthesized at start-up into short WAV clips +/// and played through a [SoundPool], which mixes overlapping voices with low +/// latency where the platform has a native mixer. +/// +/// Swap [#tone] for `pool.load("/coin.wav")` to use recorded sounds. +public final class Sfx { + private static SoundPool pool; + private static SoundEffect coin; + private static SoundEffect jump; + private static SoundEffect stumble; + private static SoundEffect crash; + private static SoundEffect power; + private static boolean muted; + private static long lastCoin; + + private Sfx() { + } + + public static void setMuted(boolean m) { + muted = m; + } + + public static boolean isMuted() { + return muted; + } + + /// Loads the clips; quietly does nothing where audio is unavailable. + /// + /// Only a native mixer is used. Without one, a pool falls back to a media + /// player per voice -- in the simulator a browser engine per sound -- which + /// a run collecting a coin every few frames would drown in, so the game + /// stays silent instead (a simulator or CI runner with no audio device). + public static void load() { + if (pool != null) { + return; + } + try { + SoundPool created = SoundPool.create(6); + if (!created.isNativeAccelerated()) { + return; + } + pool = created; + coin = pool.load(new ByteArrayInputStream(tone(990, 1480, 60, 0.25)), "audio/wav"); + jump = pool.load(new ByteArrayInputStream(tone(330, 660, 110, 0.3)), "audio/wav"); + stumble = pool.load(new ByteArrayInputStream(tone(220, 120, 140, 0.35)), "audio/wav"); + crash = pool.load(new ByteArrayInputStream(tone(110, 40, 420, 0.6)), "audio/wav"); + power = pool.load(new ByteArrayInputStream(tone(440, 1320, 260, 0.35)), "audio/wav"); + } catch (Throwable unavailable) { + pool = null; + } + } + + /// Coins come in rows; one chirp every 50 ms is enough to hear the row. + public static void coin() { + long now = System.currentTimeMillis(); + if (now - lastCoin > 50) { + lastCoin = now; + play(coin); + } + } + + public static void jump() { + play(jump); + } + + public static void stumble() { + play(stumble); + } + + public static void crash() { + play(crash); + } + + public static void power() { + play(power); + } + + private static void play(SoundEffect e) { + if (!muted && pool != null && e != null) { + try { + pool.play(e); + } catch (Throwable ignored) { + // Sound is decoration; a failure must never stop a run. + } + } + } + + /// A mono 16-bit 22 kHz WAV of a square-ish tone sliding from `fromHz` + /// to `toHz` over `millis`, fading out. + static byte[] tone(int fromHz, int toHz, int millis, double volume) { + int rate = 22050; + int n = rate * millis / 1000; + byte[] out = new byte[44 + n * 2]; + header(out, n * 2, rate); + double phase = 0; + for (int i = 0; i < n; i++) { + double t = (double) i / n; + double hz = fromHz + (toHz - fromHz) * t; + phase += hz / rate; + double wave = (phase - Math.floor(phase)) < 0.5 ? 1 : -1; + double sample = wave * volume * (1 - t) * 32767 * 0.5; + int s = (int) sample; + out[44 + i * 2] = (byte) (s & 0xff); + out[45 + i * 2] = (byte) ((s >> 8) & 0xff); + } + return out; + } + + private static void header(byte[] b, int dataLen, int rate) { + put(b, 0, "RIFF"); + le32(b, 4, 36 + dataLen); + put(b, 8, "WAVE"); + put(b, 12, "fmt "); + le32(b, 16, 16); + le16(b, 20, 1); + le16(b, 22, 1); + le32(b, 24, rate); + le32(b, 28, rate * 2); + le16(b, 32, 2); + le16(b, 34, 16); + put(b, 36, "data"); + le32(b, 40, dataLen); + } + + private static void put(byte[] b, int at, String s) { + for (int i = 0; i < s.length(); i++) { + b[at + i] = (byte) s.charAt(i); + } + } + + private static void le32(byte[] b, int at, int v) { + le16(b, at, v & 0xffff); + le16(b, at + 2, (v >> 16) & 0xffff); + } + + private static void le16(byte[] b, int at, int v) { + b[at] = (byte) (v & 0xff); + b[at + 1] = (byte) ((v >> 8) & 0xff); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Signage.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Signage.java new file mode 100644 index 00000000000..b5e858c6555 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Signage.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.gaming.Model; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.gpu.Mesh; +import com.codename1.gpu.Texture; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codenameone.examples.dash.rules.CityRoute; +import java.util.ArrayList; +import java.util.List; + +/// What a city run shows of the real streets it follows: a sign over the +/// track with each street's name where the runner turns into it, and a +/// board on a lit column beside the track for every landmark the route +/// passes, coloured by what kind of place it is. The text is drawn with the +/// app's own font into an image and uploaded as a texture, once per sign. +final class Signage { + /// How far ahead a sign is shown; beyond that it would be a speck. + private static final float SHOW = 260f; + private static final int TEX_W = 1024; + private static final int TEX_H = 256; + + private final List models = new ArrayList(); + private final List spans = new ArrayList(); + + Signage(GraphicsDevice d, CityRoute route, Mesh cube, Mesh quad, Mesh post, float half) { + for (CityRoute.Sign s : route.signs()) { + float z = -s.metres; + int first = models.size(); + if (s.kind < 0) { + street(d, s.text, cube, quad, post, half, z); + } else { + landmark(d, s, cube, quad, half, z); + } + spans.add(new float[] {first, models.size(), s.metres}); + } + } + + List models() { + return models; + } + + /// Shows the signs between just behind the runner and [#SHOW] ahead. + void update(float meZ) { + for (int i = 0; i < spans.size(); i++) { + float[] sp = spans.get(i); + boolean on = sp[2] > meZ - 15f && sp[2] < meZ + SHOW; + for (int m = (int) sp[0]; m < (int) sp[1]; m++) { + models.get(m).setVisible(on); + } + } + } + + /// A green street sign spanning the track on two posts. + private void street(GraphicsDevice d, String name, Mesh cube, Mesh quad, Mesh post, float half, float z) { + Material frame = new Material(Material.Type.PHONG).setColor(0xff3a4152).setShininess(60f); + add(model(post, frame, -half - 0.5f, 2.3f, z, 1f, 1f, 1f)); + add(model(post, frame, half + 0.5f, 2.3f, z, 1f, 1f, 1f)); + float w = half * 2f + 0.6f; + add(model(cube, frame, 0f, 4.75f, z + 0.06f, w + 0.25f, 1.45f, 0.1f)); + Texture t = text(d, name, null, 0xff0f6b45, 0xffffffff); + add(model(quad, new Material(Material.Type.UNLIT).setTexture(t), 0f, 4.75f, z + 0.12f, w, 1.25f, 1f)); + } + + /// A board on a lit column at the right of the track. + private void landmark(GraphicsDevice d, CityRoute.Sign s, Mesh cube, Mesh quad, float half, float z) { + int c = color(s.kind); + float x = half + 4.2f; + Material column = new Material(Material.Type.UNLIT).setColor(c); + Material base = new Material(Material.Type.PHONG).setColor(0xff262b38).setShininess(40f); + add(model(cube, base, x, 0.4f, z, 1.4f, 0.8f, 1.4f)); + // The column stands behind the board, so its lit face never crosses + // the name. + add(model(cube, column, x, 4f, z - 0.4f, 0.35f, 7f, 0.35f)); + add(model(cube, base, x - 1.6f, 6.4f, z + 0.05f, 6.1f, 1.95f, 0.12f)); + Texture t = text(d, s.text, label(s.kind), 0xff141828, c); + add(model(quad, new Material(Material.Type.UNLIT).setTexture(t), x - 1.6f, 6.4f, z + 0.13f, 5.9f, + 1.8f * 0.82f, 1f)); + } + + private void add(Model m) { + models.add(m); + } + + private static Model model(Mesh mesh, Material mat, float x, float y, float z, float sx, float sy, float sz) { + Model m = new Model(mesh, mat); + m.setScale(sx, sy, sz); + m.setPosition(x, y, z); + return m; + } + + static String label(int kind) { + switch (kind) { + case CityRoute.WORSHIP: + return "PLACE OF WORSHIP"; + case CityRoute.MUSEUM: + return "MUSEUM"; + case CityRoute.PARK: + return "PARK"; + case CityRoute.STATION: + return "STATION"; + case CityRoute.MONUMENT: + return "HISTORIC SITE"; + default: + return "LANDMARK"; + } + } + + static int color(int kind) { + switch (kind) { + case CityRoute.WORSHIP: + return 0xffb38cff; + case CityRoute.MUSEUM: + return 0xffffc93a; + case CityRoute.PARK: + return 0xff5be37a; + case CityRoute.STATION: + return 0xff35e8ff; + case CityRoute.MONUMENT: + return 0xffff8a5a; + default: + return 0xffd4ff3a; + } + } + + /// `title` (and a small `kicker` over it) on a rounded panel, as a + /// texture. The name shrinks until it fits the board. + private static Texture text(GraphicsDevice d, String title, String kicker, int bg, int accent) { + Image img = Image.createImage(TEX_W, TEX_H, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + g.setAntiAliasedText(true); + g.setColor(bg); + g.fillRoundRect(0, 0, TEX_W, TEX_H, 56, 56); + g.setColor(kicker == null ? 0xffffffff : accent); + for (int i = 0; i < 5; i++) { + g.drawRoundRect(6 + i, 6 + i, TEX_W - 13 - 2 * i, TEX_H - 13 - 2 * i, 44 - 2 * i, 44 - 2 * i); + } + int top = 26; + if (kicker != null) { + Font k = font(40); + g.setFont(k); + g.setColor(accent); + g.drawString(kicker, 48, top); + top += k.getHeight(); + } + int size = kicker == null ? 128 : 92; + Font f = font(size); + while (f.stringWidth(title) > TEX_W - 88 && size > 44) { + size -= 4; + f = font(size); + } + g.setFont(f); + g.setColor(kicker == null ? 0xffffffff : 0xfff2f4ff); + int y = kicker == null ? (TEX_H - f.getHeight()) / 2 : top + (TEX_H - top - f.getHeight()) / 2 - 2; + int x = kicker == null ? (TEX_W - f.stringWidth(title)) / 2 : 48; + g.drawString(title, x, y); + Texture t = d.createTexture(TEX_W, TEX_H, img.getRGB()); + t.setFilter(Texture.Filter.LINEAR); + return t; + } + + private static Font font(int px) { + return Font.createTrueTypeFont("native:MainBold", "native:MainBold").derive(px, Font.STYLE_BOLD); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/SoloDriver.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/SoloDriver.java new file mode 100644 index 00000000000..53f20190086 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/SoloDriver.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Recorder; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import java.util.ArrayList; +import java.util.List; + +/// A run on this device alone. Every input lands on the next tick, and every +/// tick is recorded, so the finished [#replay] can go to the server, which +/// plays it again under the same rules before it believes the score. +/// +/// Company is optional and never touches the player's run: a leaderboard +/// ghost replays someone's best, and bots each run a copy of the level of +/// their own, on the same seed, so the track they meet is the same. +public final class SoloDriver extends Driver { + private final Recorder recorder; + private final List ghosts = new ArrayList(); + private final List feeds = new ArrayList(); + private final List bots = new ArrayList(); + private final List names = new ArrayList(); + private final int[] inputs = new int[1]; + private final int[] botInput = new int[1]; + private int pending; + + public SoloDriver(Levels.Playable level, long seed) { + run = new Run(level.level, level.catalog, seed, 1); + recorder = new Recorder(run, level.ref); + } + + /// Adds a recorded run to race against. Ignored when it was recorded on + /// other content: a ghost of another track would run through walls. + public SoloDriver ghost(Levels.Playable level, Replay replay, String name) { + if (replay != null && replay.levelHash() == level.level.contentHash() && replay.runners() == 1) { + ghosts.add(new Run(level.level, level.catalog, replay.seed(), 1)); + feeds.add(new ReplayFeed(replay)); + bots.add(null); + names.add(name); + } + return this; + } + + /// Adds a bot of `skill` (0 to 100) to race against. + public SoloDriver bot(Levels.Playable level, int skill, String name) { + ghosts.add(new Run(level.level, level.catalog, run.seed(), 1)); + feeds.add(null); + bots.add(new Bot(run.seed() + 977L * (bots.size() + 1), skill)); + names.add(name); + return this; + } + + @Override + public boolean canStep() { + return !run.over(); + } + + @Override + public void step() { + inputs[0] = pending; + pending = 0; + recorder.step(inputs); + for (int i = 0; i < ghosts.size(); i++) { + Run g = ghosts.get(i); + if (g.over()) { + continue; + } + Bot b = bots.get(i); + if (b != null) { + botInput[0] = b.decide(g, 0); + g.step(botInput); + } else { + feeds.get(i).step(g); + } + } + } + + @Override + public void input(int bits) { + if (!run.over()) { + pending |= bits; + } + } + + @Override + public Run[] ghosts() { + return ghosts.toArray(new Run[ghosts.size()]); + } + + /// The name of ghost `i` of [#ghosts]. + public String ghostName(int i) { + return names.get(i); + } + + /// The run so far, ready to submit once [Run#over]. + public Replay replay() { + return recorder.replay(); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Theme.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Theme.java new file mode 100644 index 00000000000..e5a6ae7a1f7 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Theme.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +/// The look a level's `theme` names: the sky, the light, the track and the +/// city along it. A level author picks one in the editor; a new theme is a +/// new entry in [#of]. +public final class Theme { + public final String id; + /// The sky straight up, and at the horizon. + public final int skyTop; + public final int skyHorizon; + /// The sun or moon sitting on the horizon ahead. + public final int sun; + public final int ambient; + public final int light; + public final int ground; + /// The running surface, and the light lines painted on it. + public final int asphalt; + public final int line; + /// The glowing rails along both edges, and the gantries over the track. + public final int rail; + public final int tower; + public final int window; + + private Theme(String id, int skyTop, int skyHorizon, int sun, int ambient, int light, int ground, + int asphalt, int line, int rail, int tower, int window) { + this.id = id; + this.skyTop = skyTop; + this.skyHorizon = skyHorizon; + this.sun = sun; + this.ambient = ambient; + this.light = light; + this.ground = ground; + this.asphalt = asphalt; + this.line = line; + this.rail = rail; + this.tower = tower; + this.window = window; + } + + /// The themes an editor offers, in order. + public static final String[] IDS = {"daybreak", "neon", "ember"}; + + public static Theme of(String id) { + if ("neon".equals(id)) { + return new Theme("neon", 0xff07051a, 0xff3a1258, 0xffff3d9a, 0xff4a4070, 0xffb8c8ff, 0xff0a0816, + 0xff15132a, 0xff35e8ff, 0xffff3d9a, 0xff12102a, 0xff35e8ff); + } + if ("ember".equals(id)) { + return new Theme("ember", 0xff2a1a4a, 0xffff8a4a, 0xffffd27a, 0xff7a5a6a, 0xffffd0a0, 0xff2a1a24, + 0xff2c2430, 0xffffc35a, 0xffff6a3c, 0xff3a2438, 0xffffc35a); + } + return new Theme("daybreak", 0xff3c8ae6, 0xffc8ecff, 0xfffff6d8, 0xff8a96aa, 0xfffff4e0, 0xff4f8a5e, + 0xff3a4152, 0xfff4f7ff, 0xffff5a3c, 0xffdfe8f4, 0xff7ab8ff); + } + + /// A 128x128 tile of the track: dark glossy lanes with a sheen down + /// their middle, a glowing dashed line between each pair, a bright edge + /// on both sides, panel seams across, and a chevron per tile pointing + /// down the track, so speed reads even on a straight. + static int[] laneTexture(Theme t, int lanes) { + int n = 128; + int[] px = new int[n * n]; + int edge = 5; + float laneW = (n - edge * 2) / (float) Math.max(1, lanes); + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + int grain = ((x * 73856093) ^ (y * 19349663)) & 7; + float lx = (x - edge) / laneW; + int laneIdx = (int) Math.floor(lx); + float in = lx - laneIdx; + // Sheen: a little brighter in the middle of each lane. + float off = Math.abs(in - 0.5f) * 2f; + int c = shade(t.asphalt, 0.9f + grain * 0.01f + (1f - off * off) * 0.14f); + // Panel seams: a dark groove with a lit lip behind it. + int sy = y % 64; + if (sy == 0) { + c = shade(c, 0.55f); + } else if (sy == 1) { + c = mix(c, t.line, 0.12f); + } + // Chevron down the lane centre. + float dx = Math.abs(in - 0.5f) * laneW; + if (dx < laneW * 0.22f) { + float d = Math.abs(((y + dx * 1.2f) % 64f) - 10f); + if (d < 3f) { + c = mix(c, t.line, 0.28f * (1f - d / 3f)); + } + } + // Lane dividers, dashed, with a soft glow either side. + if (lanes > 1 && x >= edge && x < n - edge) { + float dist = Math.min(in, 1f - in) * laneW; + boolean between = !(laneIdx == 0 && in < 0.5f) && !(laneIdx == lanes - 1 && in > 0.5f); + if (between && (y / 16) % 2 == 0) { + if (dist < 1.2f) { + c = mix(t.line, 0xffffffff, 0.35f); + } else if (dist < 5f) { + c = mix(c, t.line, 0.45f * (1f - (dist - 1.2f) / 3.8f)); + } + } + } + // The edges: a hot line fading inwards. + int e = Math.min(x, n - 1 - x); + if (e < 2) { + c = mix(t.line, 0xffffffff, 0.4f); + } else if (e < edge + 4) { + c = mix(c, t.line, 0.6f * (1f - (e - 2) / (float) (edge + 2))); + } + px[y * n + x] = c; + } + } + return px; + } + + /// A tall strip of sky, `skyTop` overhead fading to `skyHorizon`, with + /// the horizon band brightest just above the ground. + static int[] skyTexture(Theme t, int h) { + int[] px = new int[4 * h]; + for (int y = 0; y < h; y++) { + float k = y / (float) (h - 1); + // Rows run top to bottom; the lower half of the dome is under + // the ground, so the horizon is the middle row. + float up = Math.max(0f, 1f - k * 2f); + int c = mix(t.skyHorizon, t.skyTop, (float) Math.sqrt(up)); + for (int x = 0; x < 4; x++) { + px[y * 4 + x] = c; + } + } + return px; + } + + /// A tower face: the theme's wall with rows of windows, some lit, the + /// whole of it pulled `haze` of the way to the horizon colour so far + /// towers fade into the sky. + static int[] towerTexture(Theme t, float haze) { + int w = TOWER_TEX_W; + int h = TOWER_TEX_H; + int[] px = new int[w * h]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + // A soft vertical gradient: lighter up top, where the sky is. + int c = shade(t.tower, 0.8f + 0.35f * (1f - y / (float) h)); + // Window bays of 8 by 8 texels, with a mullion and a floor + // slab between them, so the facade stays crisp up close. + boolean win = x % 8 > 1 && y % 8 > 2; + if (win) { + int cell = (x / 8) * 7 + (y / 8) * 13; + int r = (int) ((cell * 2654435761L) >>> 7 & 7); + if (r < 2) { + c = mix(t.window, 0xffffffff, 0.35f); + } else if (r < 3) { + c = mix(t.window, t.tower, 0.5f); + } else { + c = shade(t.tower, 0.6f); + } + // A lighter top edge on every pane reads as glass. + if (y % 8 == 3) { + c = mix(c, 0xffffffff, 0.18f); + } + } else if (y % 8 == 2) { + c = shade(c, 0.82f); + } + // A vertical accent stripe on the corner. + if (x < 3) { + c = mix(c, t.rail, 0.5f); + } + px[y * w + x] = mix(c, t.skyHorizon, haze); + } + } + return px; + } + + /// The face of the causeway the track stands on in the side view: dark + /// panels with a lit rail along the top and a strip light on each. + static int[] deckTexture(Theme t) { + int n = 64; + int[] px = new int[n * n]; + int body = shade(t.tower, 0.45f); + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + // Darker toward the bottom, where the light does not reach. + int c = shade(body, 1.1f - 0.5f * (y / (float) n)); + if (y < 3) { + c = mix(t.rail, 0xffffffff, 0.25f); + } else if (y < 6) { + c = mix(t.rail, body, 0.55f); + } else if (x % 16 == 0) { + c = shade(body, 0.6f); + } else if (y == 10 && x % 16 > 3 && x % 16 < 13) { + // A strip light per panel: a thin line stays crisp when + // the texture stretches over a tall deck, a square lamp + // blurred into a blob. + c = mix(t.window, 0xffffffff, 0.3f); + } + px[y * n + x] = c; + } + } + return px; + } + + /// The facade texture's size: tall, because towers are. + static final int TOWER_TEX_W = 64; + static final int TOWER_TEX_H = 256; + + /// A vertical ramp of the horizon colour, opaque at the bottom and clear + /// at the top: the haze that sits over the far city. + static int[] hazeTexture(Theme t) { + int h = 64; + int[] px = new int[4 * h]; + for (int y = 0; y < h; y++) { + float k = y / (float) (h - 1); + float a = k * k * (3f - 2f * k); + int c = (t.skyHorizon & 0x00ffffff) | ((int) (a * 235f) << 24); + for (int x = 0; x < 4; x++) { + px[y * 4 + x] = c; + } + } + return px; + } + + /// A holographic barrier panel: bright diagonal bars over a translucent + /// field, with scan lines and a lit border. + static int[] holoTexture(int a, int b) { + int n = 64; + int[] px = new int[n * n]; + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + boolean bar = ((x + y) / 10) % 2 == 0; + int c = bar ? a : b; + if (y % 4 == 0) { + c = shade(c, 0.7f); + } + if (x < 3 || x >= n - 3 || y < 3 || y >= n - 3) { + c = mix(a, 0xffffffff, 0.5f); + } + px[y * n + x] = c; + } + } + return px; + } + + /// A cargo pod's side: a dark band of lit windows between panel lines. + static int[] podTexture(int body, int light) { + int n = 64; + int[] px = new int[n * n]; + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + int c = shade(body, 0.85f + 0.3f * (1f - y / (float) n)); + if (y > 14 && y < 26) { + c = x % 12 < 9 ? mix(light, 0xffffffff, 0.25f) : shade(body, 0.4f); + } else if (y == 40 || y == 41) { + c = light; + } else if (x % 32 == 0) { + c = shade(c, 0.6f); + } + px[y * n + x] = c; + } + } + return px; + } + + /// Diagonal hazard stripes, for the barriers a runner slides under. + static int[] hazardTexture(int a, int b) { + int n = 32; + int[] px = new int[n * n]; + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + px[y * n + x] = ((x + y) / 8) % 2 == 0 ? a : b; + } + } + return px; + } + + static int shade(int argb, float k) { + int r = Math.min(255, (int) (((argb >> 16) & 0xff) * k)); + int g = Math.min(255, (int) (((argb >> 8) & 0xff) * k)); + int b = Math.min(255, (int) ((argb & 0xff) * k)); + return (argb & 0xff000000) | (r << 16) | (g << 8) | b; + } + + static int mix(int a, int b, float k) { + return Outfit.mix(a, b, k); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Voice.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Voice.java new file mode 100644 index 00000000000..065dae0387f --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/run/Voice.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.run; + +import com.codename1.io.Log; +import com.codename1.webrtc.IceCandidate; +import com.codename1.webrtc.IceServer; +import com.codename1.webrtc.LocalMedia; +import com.codename1.webrtc.MediaTrack; +import com.codename1.webrtc.PeerConnection; +import com.codename1.webrtc.PeerConnectionConfig; +import com.codename1.webrtc.SessionDescription; +import com.codename1.webrtc.WebRtc; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.net.GameLink; +import com.codenameone.examples.dash.rules.Json; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Party voice: a mesh of [PeerConnection]s, one per other member, audio +/// only. A party is at most [GameProtocol#MAX_RUNNERS], so each device sends +/// its voice a handful of times at most and no media server is needed. +/// +/// The game's own socket carries the signaling. A `voice` message names who +/// it is for, and the server relays it to that member only if both are in +/// the same party or race; the text inside -- an offer, an answer or an ICE +/// candidate -- is opaque to it. Of each pair the member with the smaller +/// username offers, so two members never call each other at once. +/// +/// Muting disables the local track and tells the party, so every screen can +/// show who is listening. The same instance carries over from the lobby into +/// the race, and is closed with the socket. +// tag::dash-voice[] +public final class Voice implements PeerConnection.Listener { + /// Who is talking, for the lobby to draw. + public interface Listener { + void peers(String[] connected); + + void muted(String username, boolean on); + } + + private static Voice current; + private final GameLink link; + private final String me; + private final Map peers = new HashMap(); + private final Map owners = new HashMap(); + private final List early = new ArrayList(); + private String[] members = new String[0]; + private LocalMedia media; + private boolean muted; + private boolean closed; + private Listener listener; + + private Voice(GameLink link, String me) { + this.link = link; + this.me = me; + } + + /// Whether this device can do voice at all. + public static boolean available() { + return WebRtc.isSupported(); + } + + /// Starts voice over `link` for the signed-in `username`, replacing any + /// earlier call. Asks for the microphone; nothing is sent until it is + /// granted. + public static Voice start(GameLink link, String username) { + stop(); + final Voice v = new Voice(link, username); + current = v; + WebRtc.openUserMedia(true, false, false).ready(m -> { + if (v.closed) { + m.release(); + return; + } + v.media = m; + v.connect(); + for (String[] s : v.early) { + v.signal(s[0], s[1]); + } + v.early.clear(); + }).except(err -> Log.p("Voice unavailable: " + err)); + return v; + } + + /// The call in progress, or null. + public static Voice current() { + return current; + } + + public static void stop() { + if (current != null) { + current.close(); + current = null; + } + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + /// The party or race now has `usernames`; empty entries are bots. + public void members(String[] usernames) { + members = usernames == null ? new String[0] : usernames; + if (media != null) { + connect(); + } + } + + private void connect() { + List keep = new ArrayList(); + for (String u : members) { + if (u == null || u.length() == 0 || u.equals(me)) { + continue; + } + keep.add(u); + if (!peers.containsKey(u) && me.compareTo(u) < 0) { + offer(u); + } + } + for (String u : new ArrayList(peers.keySet())) { + if (!keep.contains(u)) { + PeerConnection pc = peers.remove(u); + owners.remove(pc); + pc.close(); + } + } + notifyPeers(); + } + + private PeerConnection peer(String u) { + PeerConnection pc = peers.get(u); + if (pc == null) { + PeerConnectionConfig cfg = new PeerConnectionConfig() + .addIceServer(IceServer.stun("stun:stun.l.google.com:19302")); + pc = WebRtc.createPeerConnection(cfg, this); + MediaTrack audio = media.getAudio(); + if (audio != null) { + audio.setEnabled(!muted); + pc.addTrack(audio); + } + peers.put(u, pc); + owners.put(pc, u); + } + return pc; + } + + private void offer(final String u) { + final PeerConnection pc = peer(u); + pc.createOffer().ready(o -> pc.setLocalDescription(o).ready(ok -> send(u, "o\n" + o.getSdp()))); + } + + /// A `voice` message from `from`. + public void signal(String from, String data) { + if (closed || from == null || data == null || data.length() < 2) { + return; + } + if (media == null) { + early.add(new String[] {from, data}); + return; + } + char kind = data.charAt(0); + String body = data.substring(2); + if (kind == 'o') { + final PeerConnection pc = peer(from); + final String to = from; + pc.setRemoteDescription(new SessionDescription(SessionDescription.OFFER, body)).ready( + ok -> pc.createAnswer().ready(a -> pc.setLocalDescription(a).ready( + done -> send(to, "a\n" + a.getSdp())))); + } else if (kind == 'a') { + PeerConnection pc = peers.get(from); + if (pc != null) { + pc.setRemoteDescription(new SessionDescription(SessionDescription.ANSWER, body)); + } + } else if (kind == 'c') { + PeerConnection pc = peers.get(from); + int a = body.indexOf('\n'); + int b = a < 0 ? -1 : body.indexOf('\n', a + 1); + if (pc != null && b > a) { + pc.addIceCandidate(new IceCandidate(body.substring(0, a), + Integer.parseInt(body.substring(a + 1, b)), body.substring(b + 1))); + } + } + } + + /// A `mute` message: `username` turned their microphone on or off. + public void mutedBy(String username, boolean on) { + if (listener != null) { + listener.muted(username, on); + } + } + + public void setMuted(boolean m) { + muted = m; + if (media != null && media.getAudio() != null) { + media.getAudio().setEnabled(!m); + } + link.send("{\"type\":\"" + GameProtocol.MUTE + "\",\"on\":" + m + "}"); + } + + public boolean isMuted() { + return muted; + } + + private void send(String to, String data) { + if (!closed) { + link.send("{\"type\":\"" + GameProtocol.VOICE + "\",\"to\":" + Json.write(to) + ",\"data\":" + + Json.write(data) + "}"); + } + } + + public void close() { + closed = true; + for (PeerConnection pc : peers.values()) { + pc.close(); + } + peers.clear(); + owners.clear(); + if (media != null) { + media.release(); + media = null; + } + } + + @Override + public void iceCandidate(PeerConnection connection, IceCandidate c) { + String u = owners.get(connection); + if (u != null) { + send(u, "c\n" + c.getSdpMid() + "\n" + c.getSdpMLineIndex() + "\n" + c.getCandidate()); + } + } + + @Override + public void stateChanged(PeerConnection connection, String state) { + notifyPeers(); + } + + @Override + public void remoteTrack(PeerConnection connection, MediaTrack track) { + // Remote audio plays on its own once the track arrives. + WebRtc.setSpeakerphone(true); + } + + private void notifyPeers() { + if (listener == null) { + return; + } + List on = new ArrayList(); + for (Map.Entry e : peers.entrySet()) { + if (PeerConnection.CONNECTED.equals(e.getValue().getState())) { + on.add(e.getKey()); + } + } + listener.peers(on.toArray(new String[on.size()])); + } +} +// end::dash-voice[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/AchievementsForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/AchievementsForm.java new file mode 100644 index 00000000000..ceb62ff4e1e --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/AchievementsForm.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.AchievementDto; +import com.codenameone.examples.dash.api.ProgressDto; + +/// Every achievement, unlocked or still to go, with how far along each is. +public final class AchievementsForm { + private AchievementsForm() { + } + + public static void show() { + final Form f = Ui.form("Trophy room", "achievements"); + Ui.back(f, Nav::home); + final Container col = Ui.column(); + f.add(Ui.centeredColumn(col)); + Rewards.refresh(() -> { + ProgressDto p = Rewards.progress(); + if (p == null) { + col.add(Ui.label("Could not load your progress.", "Empty")); + } else { + int done = 0; + for (AchievementDto a : p.achievements()) { + done += a.unlocked() ? 1 : 0; + } + col.add(Ui.label(done + " of " + p.achievements().size() + " unlocked", "LeagueName")); + for (AchievementDto a : p.achievements()) { + col.add(row(a)); + } + } + f.revalidate(); + }); + f.show(); + } + + private static Container row(AchievementDto a) { + Container c = new Container(new BorderLayout()); + c.setUIID(a.unlocked() ? "AchievementRow" : "AchievementLocked"); + c.setName("achievement-" + a.id()); + Label icon = new Label("", "Container"); + icon.setIcon(FontImage.createMaterial(a.unlocked() ? FontImage.MATERIAL_MILITARY_TECH : FontImage.MATERIAL_LOCK, + a.unlocked() ? "RewardAchievement" : "XpText", 7)); + c.add(BorderLayout.WEST, icon); + Container text = new Container(BoxLayout.y()); + text.add(new Label(a.title(), "QuestTitle")); + text.add(new Label(a.detail(), "XpText")); + Container bar = new Container(new BorderLayout()); + bar.add(BorderLayout.CENTER, Ui.bar(a.progress(), a.goal(), a.unlocked() ? 0xffd34d : 0x7a849e)); + bar.add(BorderLayout.EAST, new Label(" " + a.progress() + "/" + a.goal(), "XpText")); + text.add(bar); + c.add(BorderLayout.CENTER, text); + return c; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CityForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CityForm.java new file mode 100644 index 00000000000..488f60199b0 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CityForm.java @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.Marker; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.Polyline; +import com.codename1.maps.routing.Route; +import com.codename1.maps.routing.RouteCallback; +import com.codename1.maps.routing.RouteRequest; +import com.codename1.maps.routing.RouteStep; +import com.codename1.maps.routing.RouteLeg; +import com.codename1.maps.routing.Routing; +import com.codename1.maps.routing.TravelMode; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.run.CityPath; +import com.codenameone.examples.dash.run.Landmarks; +import com.codenameone.examples.dash.run.Levels; +import java.util.ArrayList; +import java.util.List; + +/// Runs real streets. The player taps a start and a finish on a street map, +/// a routing service finds the way between them, and the route becomes a +/// level: each street a stretch of track, each turn a corner taken on its +/// side, the street names called out on the way ([CityRoute]). +/// +/// The map is Codename One's own [MapView] over OpenStreetMap data, and the +/// route comes from [Routing], whose default service is OSRM's public demo +/// server: fine for trying this, not for shipping it (see the guide). The +/// server never sees the map. The run's result carries the route, and the +/// server builds the same level from it to check the run. +public final class CityForm { + /// Where the map opens: a city centre with a grid of named streets. + static final LatLng START = new LatLng(51.5136, -0.0890); + /// Steps shorter than this are folded into the street before them: a + /// slip road or a kink in a junction is not a street to run. + private static final int MIN_LEG = 40; + + private LatLng from; + private LatLng to; + private Marker fromMarker; + private Marker toMarker; + private Polyline line; + private final List landmarkMarkers = new ArrayList(); + private CityRoute route; + private CityPath path; + private final MapView map = new MapView(); + private final SpanLabel status = new SpanLabel(); + private final Button run; + + private CityForm() { + run = Ui.primary("Run it", "runCity", e -> start()); + run.setMaterialIcon(FontImage.MATERIAL_DIRECTIONS_RUN, 4f); + } + + public static void show() { + new CityForm().open(); + } + + private void open() { + Form f = new Form("City run", new BorderLayout()); + f.setName("city"); + f.setUIID("GameForm"); + f.getToolbar().setUIID("GameToolbar"); + f.getToolbar().getTitleComponent().setUIID("GameTitle"); + Ui.back(f, LevelsForm::show); + map.setName("cityMap"); + map.moveCamera(START, 15); + map.addTapListener((surface, at, x, y) -> tapped(at)); + status.setUIID("Container"); + status.setTextUIID("CityStatus"); + status.setName("cityStatus"); + Container card = new Container(BoxLayout.y()); + card.setUIID("CityCard"); + card.setSafeArea(true); + card.add(status); + card.add(run); + Container layers = new Container(new LayeredLayout()); + layers.add(map); + Container over = new Container(new BorderLayout()); + // OpenStreetMap's licence asks for this wherever its data is shown. + over.add(BorderLayout.NORTH, FlowLayout.encloseRight( + new com.codename1.ui.Label("(c) OpenStreetMap contributors", "MapCredit"))); + over.add(BorderLayout.SOUTH, card); + layers.add(over); + f.add(BorderLayout.CENTER, layers); + say("Tap the map where the run starts."); + run.setEnabled(false); + f.show(); + } + + /// The first tap is the start, the second the finish; a third starts + /// again from the spot tapped. + private void tapped(LatLng at) { + if (from == null || to != null) { + clear(); + from = at; + fromMarker = map.addMarker(new MarkerOptions(at).label("Start")); + say("Now tap where it ends."); + } else { + to = at; + toMarker = map.addMarker(new MarkerOptions(at).label("Finish")); + find(); + } + } + + /// Asks for the route between the two points, as given by + /// [#pick(LatLng, LatLng)] in a test. + void pick(LatLng a, LatLng b) { + clear(); + tapped(a); + tapped(b); + } + + private void clear() { + if (fromMarker != null) { + map.removeMarker(fromMarker); + } + if (toMarker != null) { + map.removeMarker(toMarker); + } + if (line != null) { + map.removePolyline(line); + } + for (Marker m : landmarkMarkers) { + map.removeMarker(m); + } + landmarkMarkers.clear(); + from = null; + to = null; + fromMarker = null; + toMarker = null; + line = null; + route = null; + run.setEnabled(false); + } + + private void find() { + say("Finding the way..."); + final LatLng a = from; + final LatLng b = to; + Routing.findRoute(new RouteRequest(a, b).setTravelMode(TravelMode.WALKING).setSteps(true), + new RouteCallback() { + @Override + public void routesFound(List routes) { + if (a != from || b != to) { + // Superseded by newer taps. + return; + } + if (routes == null || routes.isEmpty()) { + say("No way through from there. Try two points on streets."); + return; + } + found((Route) routes.get(0)); + } + + @Override + public void routeFailed(String message, Throwable error) { + if (a == from && b == to) { + say("The route service did not answer. Try again in a moment."); + } + } + }); + } + + private void found(Route r) { + CityRoute c = toRoute(r); + if (c == null) { + say("That is too short a run. Pick a finish further away."); + return; + } + route = c; + path = CityPath.of(points(r)); + line = map.addPolyline(r.toPolyline().setStrokeColor(0xd4ff3a).setStrokeWidth(CN.convertToPixels(1.2f))); + map.fitBounds(r.getBounds(), CN.convertToPixels(14)); + int turns = 0; + for (CityRoute.Leg l : c.legs()) { + if (l.turn != CityRoute.STRAIGHT) { + turns++; + } + } + say(c.place + "\n" + (c.metres() >= 1000 ? c.metres() / 100 / 10.0 + " km" : c.metres() + " m") + + ", " + c.legs().size() + (c.legs().size() == 1 ? " street, " : " streets, ") + + turns + (turns == 1 ? " turn" : " turns")); + run.setEnabled(true); + run.getComponentForm().revalidate(); + final String summary = status.getText(); + final CityRoute asked = c; + Landmarks.addTo(c, points(r), () -> { + if (route != asked) { + return; + } + for (CityRoute.Landmark m : asked.landmarks()) { + landmarkMarkers.add(map.addMarker(new MarkerOptions(near(r, m.metres, asked.metres())) + .title(m.name).label(m.name))); + } + int n = asked.landmarks().size(); + if (n > 0) { + say(summary + "\n" + n + (n == 1 ? " landmark" : " landmarks") + " on the way"); + } + }); + } + + /// The point `metres` along the route's line, for a landmark's pin. + private static LatLng near(Route r, int metres, int total) { + List pts = points(r); + double len = 0; + for (int i = 1; i < pts.size(); i++) { + len += Landmarks.metres(pts.get(i - 1), pts.get(i)); + } + double want = total <= 0 ? 0 : len * metres / total; + double at = 0; + for (int i = 1; i < pts.size(); i++) { + double seg = Landmarks.metres(pts.get(i - 1), pts.get(i)); + if (at + seg >= want && seg > 0) { + double t = (want - at) / seg; + LatLng a = pts.get(i - 1); + LatLng b = pts.get(i); + return new LatLng(a.getLatitude() + (b.getLatitude() - a.getLatitude()) * t, + a.getLongitude() + (b.getLongitude() - a.getLongitude()) * t); + } + at += seg; + } + return pts.get(pts.size() - 1); + } + + private void start() { + if (route == null) { + return; + } + Levels.Playable p = Levels.city(route, path); + // The route's own seed: everyone on this route runs the same track. + RunForm.solo(p, p.level.contentHash(), "city", null, CityForm::show); + } + + private void say(String text) { + status.setText(text); + Component c = status.getParent(); + if (c != null && c.getComponentForm() != null) { + c.getComponentForm().revalidate(); + } + } + + @SuppressWarnings("unchecked") + private static List points(Route r) { + return (List) r.getPoints(); + } + + /// Reduces a routed path to the streets and turns a run needs: steps + /// on the same street run together, short ones fold into the one before, + /// and each turn is read off the geometry where two streets meet. + /// Null when nothing is left to run. + static CityRoute toRoute(Route r) { + List steps = new ArrayList(); + for (Object leg : r.getLegs()) { + for (Object s : ((RouteLeg) leg).getSteps()) { + RouteStep step = (RouteStep) s; + if (step.getDistanceMeters() >= 1) { + steps.add(step); + } + } + } + if (steps.isEmpty()) { + return null; + } + List lengths = new ArrayList(); + List names = new ArrayList(); + RouteStep prev = null; + for (RouteStep s : steps) { + String name = s.getRoadName() == null ? "" : s.getRoadName(); + int metres = (int) Math.round(s.getDistanceMeters()); + int turn = prev == null ? CityRoute.STRAIGHT : turn(prev, s); + int last = lengths.size() - 1; + if (last >= 0 && (metres < MIN_LEG || name.equals(names.get(last)) && turn == CityRoute.STRAIGHT)) { + lengths.get(last)[0] += metres; + } else { + lengths.add(new int[] {metres, turn}); + names.add(name); + } + prev = s; + } + String first = firstNamed(names, false); + String lastName = firstNamed(names, true); + String place = first.length() == 0 ? "City run" + : lastName.length() == 0 || lastName.equals(first) ? first : first + " to " + lastName; + if (place.length() > CityRoute.MAX_NAME) { + place = first.length() > 0 ? first : "City run"; + } + CityRoute c = new CityRoute(place); + int legs = Math.min(lengths.size(), CityRoute.MAX_LEGS); + for (int i = 0; i < legs; i++) { + int[] l = lengths.get(i); + // Past the cap, the rest of the way is the last street, run on. + int metres = l[0]; + if (i == legs - 1) { + for (int j = legs; j < lengths.size(); j++) { + metres += lengths.get(j)[0]; + } + } + c.leg(Math.min(metres, 100000), l[1], names.get(i)); + } + return c.metres() < 100 ? null : c; + } + + private static String firstNamed(List names, boolean fromEnd) { + for (int i = 0; i < names.size(); i++) { + String n = names.get(fromEnd ? names.size() - 1 - i : i); + if (n.length() > 0) { + return n; + } + } + return ""; + } + + /// Left, right or straight on from `a` into `b`: the angle between the + /// last stretch of one and the first of the other, on a local flat + /// projection. Within 30 degrees is straight on. + static int turn(RouteStep a, RouteStep b) { + List pa = a.getPoints(); + List pb = b.getPoints(); + if (pa == null || pb == null || pa.size() < 2 || pb.size() < 2) { + return CityRoute.STRAIGHT; + } + LatLng a0 = (LatLng) pa.get(pa.size() - 2); + LatLng a1 = (LatLng) pa.get(pa.size() - 1); + LatLng b0 = (LatLng) pb.get(0); + LatLng b1 = (LatLng) pb.get(1); + double k = Math.cos(Math.toRadians(a1.getLatitude())); + double ax = (a1.getLongitude() - a0.getLongitude()) * k; + double ay = a1.getLatitude() - a0.getLatitude(); + double bx = (b1.getLongitude() - b0.getLongitude()) * k; + double by = b1.getLatitude() - b0.getLatitude(); + double la = Math.sqrt(ax * ax + ay * ay); + double lb = Math.sqrt(bx * bx + by * by); + if (la == 0 || lb == 0 || (ax * bx + ay * by) / (la * lb) > 0.866) { + return CityRoute.STRAIGHT; + } + // East is x, north is y: a positive cross product turns + // anticlockwise, which is to the left. + return ax * by - ay * bx > 0 ? CityRoute.LEFT : CityRoute.RIGHT; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CrewsForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CrewsForm.java new file mode 100644 index 00000000000..0792ba37625 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/CrewsForm.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.ShareButton; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.CrewCreateDto; +import com.codenameone.examples.dash.api.CrewDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.Content; + +/// Crews: a standing group of friends with its own leaderboard on every +/// level and a one-tap party. Joining takes the crew's code, which a member +/// shares however they like. +public final class CrewsForm { + private CrewsForm() { + } + + public static void show() { + final Form f = Ui.form("Crews", "crews"); + Ui.back(f, Nav::home); + final Container list = Ui.column(); + f.add(Ui.centeredColumn(list)); + final TextField name = new TextField("", "New crew name", 24, TextField.ANY); + name.setName("crewName"); + final TextField code = new TextField("", "Crew code", 12, TextField.ANY); + code.setName("crewCode"); + Container make = Ui.column(Ui.label("Start a crew", "SectionTitle"), name, + Ui.primary("Create", "createCrew", e -> { + if (name.getText().trim().length() > 0) { + Api.game().createCrew(new CrewCreateDto(name.getText().trim()), + Net.to(c -> Ui.later(CrewsForm::show), Ui::failed)); + } + }), + Ui.label("Join a crew", "SectionTitle"), code, + Ui.secondary("Join", "joinCrew", e -> { + if (code.getText().trim().length() > 0) { + Api.game().joinCrew(code.getText().trim().toUpperCase(), + Net.to(c -> Ui.later(CrewsForm::show), Ui::failed)); + } + })); + f.add(Ui.centeredColumn(make)); + Api.game().crews(Net.to(crews -> Ui.later(() -> { + if (crews.isEmpty()) { + list.add(Ui.text("No crew yet. Start one, or ask a friend for theirs.", "Empty")); + } + for (CrewDto c : crews) { + list.add(card(c)); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } + + private static Container card(final CrewDto c) { + Container card = new Container(new BorderLayout()); + card.setUIID("QuestCard"); + card.setName("crew-" + c.code()); + Container text = new Container(BoxLayout.y()); + text.add(new Label(c.name() + " " + c.code(), "QuestTitle")); + StringBuilder who = new StringBuilder(); + for (int i = 0; i < c.names().size(); i++) { + if (i > 0) { + who.append(", "); + } + who.append(c.names().get(i)); + } + text.add(Ui.text(who.toString(), "QuestReward")); + card.add(BorderLayout.CENTER, text); + ShareButton share = new ShareButton(); + share.setText("Share"); + share.setUIID("DialogButton"); + share.setTextToShare("Join my crew in Dash: code " + c.code()); + card.add(BorderLayout.SOUTH, Ui.row( + Ui.button("Party", "ClaimButton", "crewParty-" + c.code(), e -> RaceLobby.host(null, c.code())), + Ui.button("Board", "DialogButton", "crewBoard-" + c.code(), + e -> ScoresForm.show(Content.CAUSEWAY, CrewsForm::show)), + share, + Ui.button("Leave", "DialogButton", "leaveCrew-" + c.code(), + e -> Api.game().leaveCrew(c.code(), Net.to(x -> Ui.later(CrewsForm::show), Ui::failed))))); + if (Session.user() != null && Session.user().username().equals(c.owner())) { + text.add(new Label("You started this crew", "XpText")); + } + return card; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/FriendsForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/FriendsForm.java new file mode 100644 index 00000000000..d184e79c4ac --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/FriendsForm.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.FriendDto; +import com.codenameone.examples.dash.api.InviteDto; +import com.codenameone.examples.dash.engage.Friends; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import java.util.List; + +/// Invite a friend: the player's link, what an invite pays, and everyone who +/// joined through it. +/// +/// The link itself is made on the device by Codename One's invite links +/// ([Friends#share]); the server only knows the code inside it, and pays +/// both players once the friend has run a race. +public final class FriendsForm { + private FriendsForm() { + } + + public static void show() { + final Form f = Ui.form("Friends", "friends"); + Ui.back(f, Nav::home); + Telemetry.screen("friends"); + final Container col = Ui.column(); + f.add(Ui.centeredColumn(col)); + f.show(); + Api.social().invite(Net.to(mine -> Ui.later(() -> { + fillInvite(col, mine); + f.revalidate(); + Api.social().friends(Net.to(list -> Ui.later(() -> { + fillFriends(col, list); + f.revalidate(); + }), Ui::failed)); + }), Ui::failed)); + } + + private static void fillInvite(Container col, final InviteDto mine) { + // The crew they could race, in front: who an invite brings in. + col.add(SignInForm.heroes()); + col.add(Ui.label("Invite a friend", "SectionTitle")); + col.add(Ui.text("Your friend starts with " + mine.friendCoins() + " coins. When they have played " + + "their first race, you get " + mine.inviterCoins() + " coins, for up to " + + mine.maxRewarded() + " friends.", "ForgeHelpText")); + Label code = Ui.label(mine.code(), "InviteCode"); + code.setName("inviteCode"); + Container codeCard = new Container(BoxLayout.y()); + codeCard.setUIID("InviteCard"); + codeCard.add(Ui.centered(Ui.label("YOUR INVITE CODE", "InviteCaption"))); + codeCard.add(Ui.centered(code)); + col.add(codeCard); + col.add(Ui.primary("Invite friends", "inviteButton", e -> Friends.share(mine))); + Label count = Ui.label(mine.joined() + " joined, " + mine.rewarded() + " rewarded", "LeagueName"); + count.setName("inviteCount"); + col.add(count); + } + + private static void fillFriends(Container col, List list) { + col.add(Ui.label("Joined through you", "SectionTitle")); + if (list.isEmpty()) { + col.add(Ui.label("Nobody yet. Share your link!", "Empty")); + return; + } + for (FriendDto d : list) { + Container c = new Container(new BorderLayout()); + c.setUIID("AchievementRow"); + Label icon = new Label("", "Container"); + icon.setIcon(FontImage.createMaterial(d.rewarded() ? FontImage.MATERIAL_MONETIZATION_ON + : FontImage.MATERIAL_HOURGLASS_EMPTY, d.rewarded() ? "RewardAchievement" : "XpText", 6)); + c.add(BorderLayout.WEST, icon); + Container text = new Container(BoxLayout.y()); + text.add(new Label(d.displayName(), "QuestTitle")); + text.add(new Label(d.trophies() + " trophies, " + + ("played".equals(d.state()) ? "has played" : "has not played yet"), "XpText")); + c.add(BorderLayout.CENTER, text); + col.add(c); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HistoryForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HistoryForm.java new file mode 100644 index 00000000000..ccba596a862 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HistoryForm.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.MultiButton; +import com.codename1.ui.Form; +import com.codenameone.examples.dash.api.RaceSummaryDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; + +/// The player's recent races, each one a replay away. +public final class HistoryForm { + private HistoryForm() { + } + + public static void show() { + final Form f = Ui.form("Replays", "history"); + Ui.back(f, Nav::home); + Api.game().history(Net.to(list -> Ui.later(() -> { + if (list.isEmpty()) { + f.add(Ui.text("No races yet. Your live races appear here, ready to replay.", "Empty")); + } + for (final RaceSummaryDto r : list) { + MultiButton b = new MultiButton(place(r.place()) + " of " + r.runners() + " " + r.score() + " pts"); + b.setUIID("HistoryRow"); + b.setUIIDLine1(r.place() == 0 ? "HistoryWin" : r.place() * 2 < r.runners() ? "HistoryDraw" + : "HistoryLoss"); + b.setTextLine2(r.level() + " " + (r.trophyChange() > 0 ? "+" : "") + r.trophyChange() + + " trophies"); + b.setName("race-" + r.id()); + b.addActionListener(e -> ReplayForm.load(r.id())); + f.add(b); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } + + /// `place` counted from 0, as a word: 1st, 2nd, 3rd... + public static String place(int place) { + int n = place + 1; + int tens = n % 100; + if (tens >= 11 && tens <= 13) { + return n + "th"; + } + switch (n % 10) { + case 1: + return n + "st"; + case 2: + return n + "nd"; + case 3: + return n + "rd"; + default: + return n + "th"; + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HomeForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HomeForm.java new file mode 100644 index 00000000000..e04be01a9c8 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/HomeForm.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.SpanButton; +import com.codename1.io.Preferences; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Dialog; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.api.ClaimDto; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.ProgressDto; +import com.codenameone.examples.dash.api.QuestDto; +import com.codenameone.examples.dash.api.SpotlightDto; +import com.codenameone.examples.dash.engage.Ads; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.forge.PackScreen; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; + +/// The menu between runs, built to pull the player into the next one: their +/// runner and level up top, the run button where the thumb is, a race with +/// friends one tap away, chests waiting to be opened and today's quests with +/// a claim button the moment one is done. +// tag::dash-home[] +public final class HomeForm { + private HomeForm() { + } + + /// The banner the operator sets from the console's Promote tab, as of the + /// last time home was shown. + private static SpotlightDto spotlight; + + public static void show() { + final PlayerDto p = Session.user(); + Form f = Ui.form("", "home"); + f.setLayout(new LayeredLayout()); + f.getToolbar().hideToolbar(); + f.setScrollable(false); + f.add(Kit.backdrop()); + final Container front = new Container(new BorderLayout()); + front.setSafeArea(true); + f.add(front); + fill(f, front, p, Rewards.progress()); + f.show(); + Api.social().spotlight(Net.to(found -> { + spotlight = found; + if (Session.user() != null && CN.getCurrentForm() == f) { + fill(f, front, Session.user(), Rewards.progress()); + f.revalidate(); + } + }, (s, m) -> { })); + Rewards.refresh(() -> { + if (Session.user() == null || CN.getCurrentForm() != f) { + return; + } + ProgressDto now = Rewards.progress(); + if (now != null) { + Telemetry.describe(now.level(), Session.user().trophies()); + } + fill(f, front, Session.user(), now); + f.revalidate(); + offerTutorial(now); + }); + } + + /// A new player is walked through the tutorial level before anything else. + /// Offered once per account on this device; "First steps", at the top of + /// the level list, brings it back. + private static void offerTutorial(ProgressDto now) { + if (now == null || now.tutorialDone()) { + return; + } + String key = "tutorialOffered-" + Session.user().username(); + if (Preferences.get(key, false)) { + return; + } + Preferences.set(key, true); + RunForm.tutorial(); + } + + private static void fill(Form f, Container front, PlayerDto p, ProgressDto g) { + front.removeAll(); + String runner = Portraits.of(p.runner()); + + // Top bar: the avatar ringed by level progress, the name and level + // over what the player holds, and the settings gear. + Container top = new Container(new BorderLayout()); + top.setUIID("TopBar"); + int into = g == null ? 0 : g.xp() - g.levelStart(); + int span = g == null ? 1 : g.levelEnd() - g.levelStart(); + top.add(BorderLayout.WEST, Kit.xpRing(Portraits.face(runner, CN.convertToPixels(9)), into, span)); + Container nameRow = new Container(BoxLayout.x()); + nameRow.add(new Label(p.displayName(), "PlayerName")); + if (g != null) { + Label lvl = new Label("LV " + g.level(), "LevelBadge"); + lvl.setName("level"); + nameRow.add(lvl); + } + Container wallet = new Container(BoxLayout.x()); + wallet.add(Kit.pill(FontImage.MATERIAL_MONETIZATION_ON, p.coins(), "Coins", "Coins", e -> ShopForm.show())); + wallet.add(Kit.pill(FontImage.MATERIAL_DIAMOND, p.gems(), "Gems", "Gems", e -> ShopForm.show())); + top.add(BorderLayout.CENTER, BoxLayout.encloseY(nameRow, wallet)); + Button gear = Ui.button("", "IconButton", "settingsButton", e -> SettingsForm.show()); + gear.setMaterialIcon(FontImage.MATERIAL_SETTINGS, 4.2f); + top.add(BorderLayout.EAST, FlowLayout.encloseMiddle(gear)); + front.add(BorderLayout.NORTH, top); + + // The middle: rails down both sides, the runner on its stage between. + Container mid = new Container(new BorderLayout()); + int ready = 0; + if (g != null) { + for (QuestDto q : g.quests()) { + if (!q.claimed() && q.progress() >= q.goal()) { + ready++; + } + } + } + final ProgressDto prog = g; + mid.add(BorderLayout.WEST, Kit.rail( + Kit.tile("Missions", FontImage.MATERIAL_TASK_ALT, "missionsButton", ready, + e -> missions(prog)), + Kit.tile("Daily", FontImage.MATERIAL_TODAY, "dailyButton", 0, e -> RunForm.daily()), + Kit.tile("Trophies", FontImage.MATERIAL_MILITARY_TECH, "achievementsButton", 0, + e -> AchievementsForm.show()), + Kit.tile("Replays", FontImage.MATERIAL_REPLAY, "replaysButton", 0, e -> HistoryForm.show()))); + mid.add(BorderLayout.EAST, Kit.rail( + Kit.tile("Runner", FontImage.MATERIAL_CHECKROOM, "runnerButton", 0, e -> RunnerForm.show()), + Kit.tile("Friends", FontImage.MATERIAL_GROUP_ADD, "friendsButton", 0, e -> FriendsForm.show()), + Kit.tile("Crews", FontImage.MATERIAL_GROUPS, "crewsButton", 0, e -> CrewsForm.show()), + Kit.tile("Leaders", FontImage.MATERIAL_LEADERBOARD, "leadersButton", 0, + e -> LeaderboardForm.show()))); + Container center = new Container(new BorderLayout()); + Component spot = spotlightBanner(); + if (p.suspended()) { + center.add(BorderLayout.NORTH, Ui.text("Your account is suspended. Races and leaderboards are " + + "closed to you until it is lifted.", "Warning")); + } + Component hero = Kit.stage(Portraits.figure(runner, CN.convertToPixels(26))); + hero.setName("hero"); + center.add(BorderLayout.CENTER, hero); + Label league = new Label(leagueName(p.trophies()), "LeagueName"); + league.setMaterialIcon(FontImage.MATERIAL_SHIELD, 3.4f); + Label trophies = new Label(String.valueOf(p.trophies()), "Trophies"); + trophies.setName("Trophies"); + trophies.setMaterialIcon(FontImage.MATERIAL_EMOJI_EVENTS, 3.4f); + center.add(BorderLayout.SOUTH, FlowLayout.encloseCenter(league, trophies)); + mid.add(BorderLayout.CENTER, center); + front.add(BorderLayout.CENTER, mid); + + // The bottom: crates, the big button, races, then the bar. + Container bottom = new Container(BoxLayout.y()); + bottom.setUIID("HomeBottom"); + if (spot != null) { + bottom.add(spot); + } + if (g != null) { + bottom.add(chests(g)); + } + Button run = Ui.button("PLAY", "GoButton", "runButton", e -> LevelsForm.show()); + run.setMaterialIcon(FontImage.MATERIAL_PLAY_ARROW, 6f); + bottom.add(run); + Button quick = Ui.button("Quick race", "RaceChip", "quickRaceButton", e -> RaceLobby.quick(null)); + quick.setMaterialIcon(FontImage.MATERIAL_BOLT, 3.6f); + Button party = Ui.button("Party", "RaceChip", "partyButton", e -> RaceLobby.host(null)); + party.setMaterialIcon(FontImage.MATERIAL_HEADSET_MIC, 3.6f); + bottom.add(GridLayout.encloseIn(2, quick, party)); + bottom.add(Kit.navBar("homeNav", p.admin())); + Component banner = Ads.banner(Ui.adFree()); + if (banner != null) { + bottom.add(banner); + } + front.add(BorderLayout.SOUTH, bottom); + } + + /// Today's quests in a sheet over home, each claimable the moment it is done. + private static void missions(ProgressDto g) { + Dialog d = new Dialog("Missions", BoxLayout.y()); + d.setName("missions"); + d.getContentPane().setScrollableY(true); + if (g == null || g.quests().isEmpty()) { + d.add(new Label("No missions right now.", "Muted")); + } else { + for (QuestDto q : g.quests()) { + d.add(quest(q)); + } + } + Button close = Ui.button("Close", "DialogAction", "closeMissions", e -> d.dispose()); + d.add(close); + d.showPacked(BorderLayout.CENTER, true); + } + + /// The four chest slots. A full one opens on a tap; an empty one says how + /// to fill it. + private static Container chests(ProgressDto g) { + Container c = new Container(new GridLayout(1, 4)); + c.setName("chests"); + int size = CN.convertToPixels(5.5f); + for (int i = 0; i < 4; i++) { + if (i < g.chests()) { + Button b = new Button("Open", Portraits.crate(size, false), "CrateSlot"); + b.setName("chest" + i); + b.setTextPosition(Component.BOTTOM); + b.addActionListener(e -> { + b.setEnabled(false); + Api.progress().openChest(Net.to(r -> Ui.later(() -> Rewards.show(r, HomeForm::show)), + (s, m) -> Ui.later(() -> Ui.failed(s, m)))); + }); + c.add(b); + } else { + Label l = new Label("Win one", "CrateSlotEmpty"); + l.setTextPosition(Component.BOTTOM); + l.setIcon(FontImage.createMaterial(FontImage.MATERIAL_ADD_CIRCLE_OUTLINE, "CrateSlotEmpty", 3.6f)); + c.add(l); + } + } + return c; + } + + private static Container quest(final QuestDto q) { + Container card = new Container(new BorderLayout()); + card.setUIID("QuestCard"); + card.setName("quest-" + q.id()); + Container text = new Container(BoxLayout.y()); + text.add(new Label(q.title(), "QuestTitle")); + Container barRow = new Container(new BorderLayout()); + barRow.add(BorderLayout.CENTER, Ui.bar(q.progress(), q.goal(), 0xffc94a)); + barRow.add(BorderLayout.EAST, new Label(" " + q.progress() + "/" + q.goal(), "XpText")); + text.add(barRow); + text.add(new Label("+" + q.coins() + " coins +" + q.xp() + " XP", "QuestReward")); + card.add(BorderLayout.CENTER, text); + if (q.claimed()) { + Label done = new Label("Done", "Claimed"); + done.setMaterialIcon(FontImage.MATERIAL_CHECK_CIRCLE); + card.add(BorderLayout.EAST, FlowLayout.encloseMiddle(done)); + } else if (q.progress() >= q.goal()) { + Button claim = Ui.button("Claim", "ClaimButton", "claim-" + q.id(), null); + claim.addActionListener(e -> { + claim.setEnabled(false); + Api.progress().claimQuest(new ClaimDto(q.id()), Net.to(r -> Ui.later(() -> Rewards.show(r, + HomeForm::show)), (s, m) -> Ui.later(() -> Ui.failed(s, m)))); + }); + card.add(BorderLayout.EAST, FlowLayout.encloseMiddle(claim)); + } + return card; + } + + // tag::dash-spotlight[] + /// The spotlight: one line of news from the operator, and a tap that goes + /// where it is about -- a Forge pack, or a screen of the game. + private static Component spotlightBanner() { + final SpotlightDto s = spotlight; + if (s == null || s.text() == null || s.text().length() == 0) { + return null; + } + // A SpanButton, so a long line wraps instead of being cut off. + SpanButton b = new SpanButton(s.text()); + b.setUIID("Spotlight"); + b.setTextUIID("SpotlightText"); + b.setIconUIID("SpotlightIcon"); + b.setName("spotlight"); + b.setMaterialIcon(FontImage.MATERIAL_CAMPAIGN, 4); + b.addActionListener(e -> { + if (s.packId() != null && s.packId().length() > 0) { + PackScreen.show(s.packId()); + } else { + Nav.screen(s.screen() == null ? "" : s.screen()); + } + }); + return b; + } + // end::dash-spotlight[] + + /// The league a trophy count has reached. Cosmetic only: a quick race + /// fills with whoever is waiting, not by league. + static String leagueName(int trophies) { + if (trophies < 300) { + return "Rookie Lane"; + } + if (trophies < 800) { + return "Bronze Circuit"; + } + if (trophies < 1500) { + return "Neon League"; + } + return "Skyline Masters"; + } +} +// end::dash-home[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Kit.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Kit.java new file mode 100644 index 00000000000..18cee1aed84 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Kit.java @@ -0,0 +1,713 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; + +/// The game's own widgets, the pieces every menu is built from: the living +/// city behind the menus, the stage a runner stands on, the bursts of light +/// behind a reward, the pills that hold a currency, the icon tiles along the +/// sides of the home screen and the bar along the bottom. +/// +/// They are ordinary components that paint themselves, so they lay out, +/// scroll and test like any other; `theme.css` styles the text around them. +public final class Kit { + private Kit() { + } + + // ---- The city at night, behind the menus ---- + + /// A city skyline at dusk with a track running into it, its lane marks + /// streaming towards the viewer. The still part is painted once per size + /// into an image; each frame adds only the moving marks and the + /// twinkling lights. + public static Component backdrop() { + return new Backdrop(); + } + + static final class Backdrop extends Component { + private Image still; + private long start = System.currentTimeMillis(); + private long last; + + Backdrop() { + setUIID("Container"); + setFocusable(false); + } + + @Override + protected void initComponent() { + super.initComponent(); + getComponentForm().registerAnimated(this); + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + long now = System.currentTimeMillis(); + if (now - last < 33) { + return false; + } + last = now; + return true; + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.getDisplayWidth(), CN.getDisplayHeight()); + } + + @Override + public void paint(Graphics g) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) { + return; + } + if (still == null || still.getWidth() != w || still.getHeight() != h) { + still = paintStill(w, h); + } + g.drawImage(still, getX(), getY()); + float t = (System.currentTimeMillis() - start) / 1000f; + int horizon = h * 44 / 100; + int cx = getX() + w / 2; + // Lane marks: evenly spaced in depth, so they bunch towards the + // horizon and race apart near the bottom. + int marks = 9; + for (int lane = -1; lane <= 1; lane += 2) { + for (int k = 0; k < marks; k++) { + float s0 = ((k + t * 0.9f) % marks) / marks; + float s1 = s0 + 0.035f; + int y0 = getY() + horizon + (int) ((h - horizon) * s0 * s0); + int y1 = getY() + horizon + (int) ((h - horizon) * s1 * s1); + int x0 = cx + (int) (lane * w * 0.11f * s0); + int x1 = cx + (int) (lane * w * 0.11f * s1); + int wd = Math.max(1, (int) (w * 0.012f * s1)); + g.setColor(0x7cf2ff); + g.setAlpha((int) (60 + 160 * s0)); + g.fillPolygon(new int[] {x0 - wd / 2, x0 + wd / 2, x1 + wd, x1 - wd}, + new int[] {y0, y0, y1, y1}, 4); + } + } + // A few lit windows breathe. + for (int i = 0; i < 18; i++) { + float a = (float) Math.sin(t * (0.7f + i * 0.13f) + i); + if (a < 0.4f) { + continue; + } + int x = getX() + (int) (hash(i, 3) * w); + int y = getY() + horizon - (int) (hash(i, 5) * h * 0.18f) - 4; + g.setColor(0xffd98a); + g.setAlpha((int) (a * 200)); + g.fillRect(x, y, Math.max(2, w / 220), Math.max(2, w / 220)); + } + g.setAlpha(255); + } + } + + /// A small lit view down a level's track, tinted for its theme, for the + /// level cards. + public static Image thumb(String theme, int w, int h) { + Image img = paintStill(w, h); + Graphics g = img.getGraphics(); + int tint = "daybreak".equals(theme) ? 0xff9a3c : "ember".equals(theme) ? 0xff3c1e : 0x2ee6ff; + g.setColor(tint); + g.setAlpha(46); + g.fillRect(0, 0, w, h); + g.setAlpha(255); + return img; + } + + private static Image paintStill(int w, int h) { + Image img = Image.createImage(w, h, 0xff0b0e1d); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + int horizon = h * 44 / 100; + // Sky: deep blue overhead into violet and a warm band at the horizon. + g.fillLinearGradient(0x070a1c, 0x3a1460, 0, 0, w, horizon * 3 / 4, false); + g.fillLinearGradient(0x3a1460, 0xb83a7a, 0, horizon * 3 / 4, w, horizon / 4 + 1, false); + // Stars. + for (int i = 0; i < 70; i++) { + g.setColor(0xffffff); + g.setAlpha((int) (40 + hash(i, 11) * 140)); + int s = hash(i, 13) > 0.9f ? 3 : 2; + g.fillRect((int) (hash(i, 7) * w), (int) (hash(i, 9) * horizon * 0.7f), s, s); + } + // The sun, low and soft, with a wide halo. + int sr = w * 17 / 100; + int sx = w / 2; + int sy = horizon - sr / 3; + for (int r = sr * 3; r > sr; r -= Math.max(2, sr / 12)) { + g.setColor(0xff4f9a); + g.setAlpha(Math.max(4, 40 - 36 * (r - sr) / (sr * 2))); + g.fillArc(sx - r, sy - r, r * 2, r * 2, 0, 360); + } + g.setAlpha(255); + for (int y = -sr; y < sr; y++) { + int half = (int) Math.sqrt((double) sr * sr - (double) y * y); + float k = (y + sr) / (2f * sr); + g.setColor(mix(0xffe39a, 0xff4f8a, k)); + g.fillRect(sx - half, sy + y, half * 2, 1); + } + // Two layers of towers: the far one hazed into the sky. + skyline(g, w, horizon, 0x4a2468, 0.20f, 31, false); + skyline(g, w, horizon, 0x160f2e, 0.30f, 17, true); + // The ground, and the track running into the sun. + g.setAlpha(255); + g.fillLinearGradient(0x1a1236, 0x06070f, 0, horizon, w, h - horizon, false); + int cx = w / 2; + int[] xs = {cx - w / 40, cx + w / 40, cx + w * 62 / 100, cx - w * 62 / 100}; + int[] ys = {horizon, horizon, h, h}; + g.setColor(0x141633); + g.fillPolygon(xs, ys, 4); + // Neon edges with a soft glow either side. + for (int side = -1; side <= 1; side += 2) { + int color = side < 0 ? 0xff3d9a : 0x2ee6ff; + for (int glow = 5; glow >= 0; glow--) { + int spread = glow * Math.max(1, w / 260); + g.setColor(color); + g.setAlpha(glow == 0 ? 255 : 26); + int top = cx + side * (w / 40); + int bottom = cx + side * (w * 62 / 100); + g.fillPolygon(new int[] {top - spread / 4, top + spread / 4 + 1, bottom + spread + 2, + bottom - spread - 2}, new int[] {horizon, horizon, h, h}, 4); + } + } + // A glow where the track meets the horizon. + for (int r = w / 3; r >= 5; r -= Math.max(2, w / 60)) { + g.setColor(0xff7ab8); + g.setAlpha(10); + g.fillArc(cx - r, horizon - r / 5, r * 2, r * 2 / 5, 0, 360); + } + g.setAlpha(255); + return img; + } + + private static void skyline(Graphics g, int w, int horizon, int color, float maxH, int salt, boolean windows) { + int x = -w / 30; + int i = 0; + while (x < w) { + int bw = (int) (w * (0.05f + hash(i, salt) * 0.08f)); + int bh = (int) (horizon * maxH * (0.35f + hash(i, salt + 1) * 0.65f)); + // Leave the middle low so the sun shows through. + float mid = Math.abs((x + bw / 2f) - w / 2f) / (w / 2f); + bh = (int) (bh * (0.35f + 0.65f * Math.min(1f, mid * 1.6f))); + int top = horizon - bh; + g.setColor(color); + g.setAlpha(255); + g.fillRect(x, top, bw, bh); + if (hash(i, salt + 2) > 0.5f) { + // A setback on top, and an aviation light. + int sw = bw / 2; + int shh = bh / 6; + g.fillRect(x + bw / 4, top - shh, sw, shh); + g.setColor(0xff3d5a); + g.fillRect(x + bw / 2 - 1, top - shh - 3, 3, 3); + } + if (windows) { + int cell = Math.max(3, w / 120); + for (int wy = top + cell; wy < horizon - cell; wy += cell * 2) { + for (int wx = x + cell; wx < x + bw - cell; wx += cell * 2) { + float lit = hash(wx * 31 + wy, salt); + if (lit > 0.72f) { + g.setColor(lit > 0.9f ? 0x7cf2ff : 0xffcf7a); + g.setAlpha(lit > 0.9f ? 150 : 110); + g.fillRect(wx, wy, cell, cell); + } + } + } + g.setAlpha(255); + } + x += bw + (int) (hash(i, salt + 3) * w * 0.012f); + i++; + } + } + + // ---- The runner on its stage ---- + + /// The runner standing on a ring of light, breathing gently. + public static Component stage(final Image figure) { + Component c = new Component() { + private long start = System.currentTimeMillis(); + + @Override + protected void initComponent() { + super.initComponent(); + getComponentForm().registerAnimated(this); + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + return true; + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(figure.getWidth() * 3 / 2, figure.getHeight() * 23 / 20); + } + + @Override + public void paint(Graphics g) { + float t = (System.currentTimeMillis() - start) / 1000f; + int cx = getX() + getWidth() / 2; + int base = getY() + getHeight() - getHeight() / 12; + int rw = getWidth() * 3 / 4; + int rh = rw / 4; + float pulse = 0.5f + 0.5f * (float) Math.sin(t * 2.2f); + for (int i = 6; i >= 0; i--) { + int ww = rw + i * rw / 12; + int hh = rh + i * rh / 12; + g.setColor(0x2ee6ff); + g.setAlpha(i == 0 ? 120 : (int) (14 + pulse * 10)); + g.fillArc(cx - ww / 2, base - hh / 2, ww, hh, 0, 360); + } + g.setColor(0x0b0e1d); + g.setAlpha(200); + g.fillArc(cx - rw * 4 / 10, base - rh * 4 / 10, rw * 8 / 10, rh * 8 / 10, 0, 360); + g.setAlpha(255); + int bob = (int) (Math.sin(t * 3.1f) * getHeight() / 90); + g.drawImage(figure, cx - figure.getWidth() / 2, base - figure.getHeight() + rh / 4 + bob); + } + }; + c.setUIID("Container"); + return c; + } + + // ---- Light behind a reward ---- + + /// Slowly turning rays of light with `art` in the middle: the reveal + /// behind anything the player has just won. + public static Component burst(final Image art, final int color) { + Component c = new Component() { + private long start = System.currentTimeMillis(); + + @Override + protected void initComponent() { + super.initComponent(); + getComponentForm().registerAnimated(this); + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + return true; + } + + @Override + protected Dimension calcPreferredSize() { + int s = Math.max(art.getWidth(), art.getHeight()) * 9 / 5; + return new Dimension(s, s); + } + + @Override + public void paint(Graphics g) { + float t = (System.currentTimeMillis() - start) / 1000f; + int cx = getX() + getWidth() / 2; + int cy = getY() + getHeight() / 2; + int r = Math.min(getWidth(), getHeight()) / 2; + int rays = 12; + g.setAntiAliased(true); + for (int i = 0; i < rays; i++) { + double a = t * 0.35 + i * Math.PI * 2 / rays; + double b = a + Math.PI / rays * 0.55; + g.setColor(color); + g.setAlpha(46); + g.fillPolygon(new int[] {cx, cx + (int) (Math.cos(a) * r), cx + (int) (Math.cos(b) * r)}, + new int[] {cy, cy + (int) (Math.sin(a) * r), cy + (int) (Math.sin(b) * r)}, 3); + } + for (int i = 5; i > 0; i--) { + int rr = r * i / 7; + g.setAlpha(18); + g.fillArc(cx - rr, cy - rr, rr * 2, rr * 2, 0, 360); + } + g.setAlpha(255); + float pop = Math.min(1f, t * 4f); + float s = pop < 1f ? 0.6f + 0.5f * pop : 1f + 0.03f * (float) Math.sin(t * 3); + int aw = (int) (art.getWidth() * s); + int ah = (int) (art.getHeight() * s); + g.drawImage(art, cx - aw / 2, cy - ah / 2, aw, ah); + } + }; + c.setUIID("Container"); + return c; + } + + /// `of` stars, the first `earned` lit, each popping in after the last. + public static Component stars(int earned, int of) { + return stars(earned, of, 12f); + } + + /// As [#stars(int, int)], each star `mm` millimetres across. + public static Component stars(final int earned, final int of, final float mm) { + Component c = new Component() { + private long start = System.currentTimeMillis(); + + @Override + protected void initComponent() { + super.initComponent(); + getComponentForm().registerAnimated(this); + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + return System.currentTimeMillis() - start < 2500; + } + + @Override + protected Dimension calcPreferredSize() { + int s = CN.convertToPixels(mm); + return new Dimension(s * of + s / 2, s * 6 / 5); + } + + @Override + public void paint(Graphics g) { + float t = (System.currentTimeMillis() - start) / 1000f; + int s = getHeight() * 5 / 6; + int gap = (getWidth() - s * of) / Math.max(1, of + 1); + g.setAntiAliased(true); + for (int i = 0; i < of; i++) { + int x = getX() + gap + i * (s + gap) + s / 2; + // The middle star sits higher and larger, as on a podium. + boolean middle = of == 3 && i == 1; + int size = middle ? s : s * 4 / 5; + int y = getY() + getHeight() / 2 + (middle ? -s / 10 : s / 12); + star(g, x, y, size / 2, 0x2a3060, 255); + if (i < earned) { + float k = Math.max(0f, Math.min(1f, (t - 0.3f - i * 0.35f) * 4f)); + if (k > 0f) { + float over = k < 1f ? k * 1.25f : 1f; + for (int gl = 3; gl > 0; gl--) { + star(g, x, y, (int) (size / 2 * over) + gl * size / 14, 0xffd23a, 40); + } + star(g, x, y, (int) (size / 2 * over), 0xffd23a, 255); + star(g, x, y - size / 14, (int) (size / 2 * over * 0.55f), 0xfff2a8, 200); + } + } + } + g.setAlpha(255); + } + }; + c.setUIID("Container"); + return c; + } + + /// Three stars painted into an image, `earned` of them lit, for rows. + public static Image starsImage(int earned, int height) { + Image img = Image.createImage(height * 3 + height / 2, height, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + for (int i = 0; i < 3; i++) { + int cx = height / 2 + i * (height + height / 4); + star(g, cx, height / 2 + height / 20, height / 2, i < earned ? 0xffd23a : 0x2a3060, 255); + } + return img; + } + + private static void star(Graphics g, int cx, int cy, int r, int color, int alpha) { + int[] xs = new int[10]; + int[] ys = new int[10]; + for (int i = 0; i < 10; i++) { + double a = -Math.PI / 2 + i * Math.PI / 5; + int rr = i % 2 == 0 ? r : r * 45 / 100; + xs[i] = cx + (int) (Math.cos(a) * rr); + ys[i] = cy + (int) (Math.sin(a) * rr); + } + g.setColor(color); + g.setAlpha(alpha); + g.fillPolygon(xs, ys, 10); + } + + /// Counts a label up from 0 to `to` over a second, with `prefix` before. + public static Label countUp(final String prefix, final int to, String uiid) { + final Label l = new Label(prefix + to, uiid); + final long start = System.currentTimeMillis(); + // Sized for the final number, so counting never truncates or jumps. + l.setPreferredSize(l.getPreferredSize()); + l.setText(prefix + "0"); + CN.callSerially(new Runnable() { + public void run() { + if (l.getComponentForm() == null) { + l.setText(prefix + to); + return; + } + float k = Math.min(1f, (System.currentTimeMillis() - start) / 1100f); + k = 1f - (1f - k) * (1f - k); + l.setText(prefix + (int) (to * k)); + l.repaint(); + if (k < 1f) { + com.codename1.ui.util.UITimer.timer(30, false, l.getComponentForm(), this); + } + } + }); + return l; + } + + // ---- Currency, tiles and the bottom bar ---- + + /// A currency pill: icon, amount and a plus that goes to the shop. + public static Container pill(char icon, int amount, String uiid, String name, + ActionListener plus) { + Container c = new Container(new BorderLayout()); + c.setUIID("CurrencyPill"); + Label l = new Label(String.valueOf(amount), uiid); + l.setMaterialIcon(icon, 3f); + l.setName(name); + c.add(BorderLayout.CENTER, l); + if (plus != null) { + Button b = new Button("", "PillPlus"); + b.setMaterialIcon(FontImage.MATERIAL_ADD, 2.4f); + b.addActionListener(plus); + c.add(BorderLayout.EAST, FlowLayout.encloseMiddle(b)); + } + return c; + } + + /// An icon tile for the side rails of the home screen, with a red count + /// in its corner while there is something waiting behind it. + public static Button tile(String text, char icon, String name, final int badge, + ActionListener go) { + Button b = new Button(text, "RailTile") { + @Override + public void paint(Graphics g) { + super.paint(g); + if (badge <= 0) { + return; + } + int d = CN.convertToPixels(3.4f); + int x = getX() + getWidth() - d - getStyle().getPaddingRightNoRTL() / 2; + int y = getY() + getStyle().getPaddingTop() / 2; + g.setAntiAliased(true); + g.setColor(0xff3b5c); + g.fillArc(x, y, d, d, 0, 360); + g.setColor(0xffffff); + g.setFont(com.codename1.ui.Font.createSystemFont(com.codename1.ui.Font.FACE_SYSTEM, + com.codename1.ui.Font.STYLE_BOLD, com.codename1.ui.Font.SIZE_SMALL)); + String s = badge > 9 ? "9+" : String.valueOf(badge); + g.drawString(s, x + (d - g.getFont().stringWidth(s)) / 2, y + (d - g.getFont().getHeight()) / 2); + } + }; + b.setName(name); + b.setMaterialIcon(icon, 3.4f); + b.setTextPosition(Component.BOTTOM); + if (go != null) { + b.addActionListener(go); + } + return b; + } + + /// The bar along the bottom of the main screens, `active` highlighted. + public static Container navBar(String active, boolean admin) { + Container bar = new Container(new GridLayout(1, admin ? 5 : 4)); + bar.setUIID("NavBar"); + bar.setSafeArea(true); + bar.add(navItem("Home", FontImage.MATERIAL_HOME, "homeNav", active, e -> Nav.home())); + bar.add(navItem("Levels", FontImage.MATERIAL_ROUTE, "levelsNav", active, e -> LevelsForm.show())); + bar.add(navItem("Editor", FontImage.MATERIAL_EDIT_ROAD, "forgeButton", active, + e -> com.codenameone.examples.dash.forge.ForgeHome.show())); + bar.add(navItem("Shop", FontImage.MATERIAL_STOREFRONT, "shopButton", active, e -> ShopForm.show())); + if (admin) { + bar.add(navItem("Admin", FontImage.MATERIAL_ADMIN_PANEL_SETTINGS, "adminButton", active, + e -> Nav.adminConsole())); + } + return bar; + } + + private static Button navItem(String text, char icon, String name, String active, + ActionListener go) { + boolean on = name.equals(active); + Button b = new Button(text, on ? "NavItemOn" : "NavItem"); + b.setName(name); + b.setMaterialIcon(icon, 4.4f); + b.setTextPosition(Component.BOTTOM); + if (!on) { + b.addActionListener(go); + } + return b; + } + + /// A round avatar with the progress to the next level drawn around it. + public static Component xpRing(final Image face, final int value, final int max) { + Component c = new Component() { + @Override + protected Dimension calcPreferredSize() { + int s = face.getWidth() + CN.convertToPixels(2.4f); + return new Dimension(s, s); + } + + @Override + public void paint(Graphics g) { + int s = Math.min(getWidth(), getHeight()); + int x = getX() + (getWidth() - s) / 2; + int y = getY() + (getHeight() - s) / 2; + int th = CN.convertToPixels(0.9f); + g.setAntiAliased(true); + g.setColor(0x232a4d); + g.fillArc(x, y, s, s, 0, 360); + g.setColor(0xd4ff3a); + int sweep = max <= 0 ? 0 : 360 * Math.max(0, Math.min(value, max)) / max; + g.fillArc(x, y, s, s, 90, -sweep); + g.setColor(0x0b0e1d); + g.fillArc(x + th, y + th, s - th * 2, s - th * 2, 0, 360); + g.drawImage(face, x + (s - face.getWidth()) / 2, y + (s - face.getHeight()) / 2); + } + }; + c.setUIID("Container"); + return c; + } + + /// A thin column of tiles for one side of the home screen. + public static Container rail(Component... tiles) { + Container c = new Container(BoxLayout.y()); + c.setUIID("Rail"); + for (Component t : tiles) { + c.add(t); + } + return c; + } + + /// The picture on a shop card: the runner's helmet, the trail's streak, + /// or a heap of gems, on a lit rounded tile. + public static Image shopArt(String id, int size) { + if (id.indexOf("pass") >= 0) { + // A pass unlocks the golden trail, so it shows one. + id = "trail-gold"; + } + Image img = Image.createImage(size, size, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + int glow = id.startsWith("trail-") ? trailColor(id) : id.startsWith("gems-") ? 0x2ee6ff : 0xb56cff; + g.setColor(0x161a3a); + g.fillRoundRect(0, 0, size, size, size / 3, size / 3); + for (int r = size / 2; r > size / 8; r -= Math.max(1, size / 24)) { + g.setColor(glow); + g.setAlpha(14); + g.fillArc(size / 2 - r, size / 2 - r, r * 2, r * 2, 0, 360); + } + g.setAlpha(255); + if (id.startsWith("runner-")) { + Image face = Portraits.face(id, size * 3 / 4); + g.drawImage(face, (size - face.getWidth()) / 2, (size - face.getHeight()) / 2); + } else if (id.startsWith("trail-")) { + // A comet: a bright head trailing a fading tail. + for (int i = 0; i < 14; i++) { + int x = size * 3 / 4 - i * size / 22; + int y = size / 4 + i * size / 22; + int rr = Math.max(2, size / 7 - i * size / 120); + g.setColor(glow); + g.setAlpha(Math.max(20, 230 - i * 16)); + g.fillArc(x - rr, y - rr, rr * 2, rr * 2, 0, 360); + } + g.setColor(0xffffff); + g.setAlpha(255); + g.fillArc(size * 3 / 4 - size / 14, size / 4 - size / 14, size / 7, size / 7, 0, 360); + } else { + int n = id.endsWith("500") ? 5 : 2; + for (int i = 0; i < n; i++) { + int cx = size / 2 + (i - (n - 1) / 2) * size / 6 + (i % 2) * size / 20; + int cy = size / 2 + (i % 2 == 0 ? size / 10 : -size / 14); + gem(g, cx, cy, size / 5); + } + } + g.setAlpha(255); + return img; + } + + private static int trailColor(String id) { + if (id.endsWith("gold")) { + return 0xffd23a; + } + if (id.endsWith("comet")) { + return 0x7cf2ff; + } + if (id.endsWith("spark")) { + return 0xff5ab4; + } + return 0xd4ff3a; + } + + private static void gem(Graphics g, int cx, int cy, int r) { + g.setAlpha(255); + g.setColor(0x1aa8d8); + g.fillPolygon(new int[] {cx - r, cx + r, cx}, new int[] {cy - r / 3, cy - r / 3, cy + r}, 3); + g.setColor(0x7cf2ff); + g.fillPolygon(new int[] {cx - r, cx - r / 2, cx + r / 2, cx + r}, + new int[] {cy - r / 3, cy - r, cy - r, cy - r / 3}, 4); + g.setColor(0xd8fdff); + g.fillPolygon(new int[] {cx - r / 2, cx, cx - r / 4}, new int[] {cy - r, cy - r / 3, cy - r / 3}, 3); + } + + static int mix(int a, int b, float k) { + int r = (int) (((a >> 16) & 0xff) * (1 - k) + ((b >> 16) & 0xff) * k); + int g = (int) (((a >> 8) & 0xff) * (1 - k) + ((b >> 8) & 0xff) * k); + int bl = (int) ((a & 0xff) * (1 - k) + (b & 0xff) * k); + return (r << 16) | (g << 8) | bl; + } + + static float hash(int i, int salt) { + int h = i * 73856093 ^ salt * 19349663; + h ^= h >>> 13; + h *= 0x5bd1e995; + h ^= h >>> 15; + return (h & 0xffff) / 65535f; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LeaderboardForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LeaderboardForm.java new file mode 100644 index 00000000000..af3539ca40b --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LeaderboardForm.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.dash.api.LeaderDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; + +/// The top players by trophies. +public final class LeaderboardForm { + private LeaderboardForm() { + } + + public static void show() { + final Form f = Ui.form("Leaders", "leaders"); + Ui.back(f, Nav::home); + Api.game().leaderboard(50, Net.to(list -> Ui.later(() -> { + for (LeaderDto l : list) { + Container row = new Container(new BorderLayout()); + row.setUIID(l.rank() <= 3 ? "LeaderTop" : "LeaderRow"); + row.add(BorderLayout.WEST, new Label("#" + l.rank(), "LeaderRank")); + row.add(BorderLayout.CENTER, new Label(l.displayName(), "LeaderName")); + row.add(BorderLayout.EAST, new Label(String.valueOf(l.trophies()), "Trophies")); + f.add(row); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LevelsForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LevelsForm.java new file mode 100644 index 00000000000..43405375338 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/LevelsForm.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.FontImage; +import com.codename1.ui.CN; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.LevelStarsDto; +import com.codenameone.examples.dash.forge.ForgeHome; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.ViewMode; +import com.codenameone.examples.dash.rules.forge.Samples; +import com.codenameone.examples.dash.run.Levels; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The levels the game ships with, the stars earned on each, and the way to +/// everyone else's: the community browser in the track editor. +/// +/// A run here is on this week's seed for the level, so it counts on the +/// weekly board as well as the all-time one; "Race the best" runs against +/// the week's leader as a ghost. +public final class LevelsForm { + private LevelsForm() { + } + + public static void show() { + final Form f = Ui.form("Levels", "levels"); + Ui.back(f, Nav::home); + final Container list = Ui.column(); + f.add(Ui.centeredColumn(list)); + fill(list, new HashMap()); + f.show(); + Api.forge().stars(Samples.OFFICIAL_ID, Net.to(stars -> Ui.later(() -> { + fill(list, byLevel(stars)); + f.revalidate(); + }), (s, m) -> { })); + } + + private static Map byLevel(List stars) { + Map m = new HashMap(); + for (LevelStarsDto s : stars) { + m.put(s.levelId(), Integer.valueOf(s.stars())); + } + return m; + } + + private static void fill(Container list, Map stars) { + list.removeAll(); + for (final LevelDef l : Content.levels()) { + final Levels.Playable p = Levels.official(l.id); + Integer s = stars.get(l.id); + Container card = card(l, s == null ? 0 : s.intValue(), + e -> RunForm.solo(p, week(l.id), "solo", null, LevelsForm::show), + "Race the best", FontImage.MATERIAL_DIRECTIONS_RUN, e -> ghost(p), + e -> ScoresForm.show(l.id, LevelsForm::show)); + list.add(card); + } + list.add(cityCard()); + // In the list, so it scrolls up with the cards instead of sitting + // under the last one half off the screen. + list.add(Ui.primary("Community levels", "communityButton", e -> ForgeHome.show())); + } + + /// A card per level: a lit view of its track, its name, what kind of run + /// it is and the stars won, a round play button, and the two ways to + /// compete underneath. The community levels of a pack use it too. + public static Container card(LevelDef l, int stars, ActionListener play, String compete, + char competeIcon, ActionListener onCompete, ActionListener onScores) { + Container card = new Container(new BorderLayout()); + card.setUIID("LevelCard"); + card.setName("level-" + l.id); + int th = CN.convertToPixels(17); + card.add(BorderLayout.WEST, new Label(Kit.thumb(l.theme, th * 3 / 5, th), "LevelThumb")); + Container text = new Container(BoxLayout.y()); + // The name wraps rather than truncates; the kind of run is a chip + // and the length a line of its own, since "2D into 3D" and a + // length side by side are wider than a narrow phone's card. + text.add(Ui.text(l.name, "LevelName")); + text.add(FlowLayout.encloseIn(new Label(view(l), "TagChip"))); + text.add(new Label(l.length <= 0 ? "Endless" : l.length / 1000 + " m", "LevelLength")); + text.add(Kit.stars(stars, 3, 4.5f)); + card.add(BorderLayout.CENTER, text); + Button go = Ui.button("", "PlayRound", "play-" + l.id, play); + go.setMaterialIcon(FontImage.MATERIAL_PLAY_ARROW, 3.8f); + card.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(go)); + Button versus = Ui.button(compete, "CardAction", "race-" + l.id, onCompete); + Ui.glyph(versus, competeIcon, 3.2f); + Button scores = Ui.button("Scores", "CardAction", "scores-" + l.id, onScores); + Ui.glyph(scores, FontImage.MATERIAL_LEADERBOARD, 3.2f); + card.add(BorderLayout.SOUTH, GridLayout.encloseIn(2, versus, scores)); + return card; + } + + /// The way into city runs: any two points on a real street map. + private static Container cityCard() { + Container card = new Container(new BorderLayout()); + card.setUIID("LevelCard"); + card.setName("cityCard"); + Label icon = new Label("", "CityIcon"); + icon.setMaterialIcon(FontImage.MATERIAL_MAP, 9f); + card.add(BorderLayout.WEST, icon); + Container text = new Container(BoxLayout.y()); + text.add(Ui.text("Run your city", "LevelName")); + text.add(Ui.text("Pick a start and a finish on a street map. The streets become the track, " + + "every turn a corner.", "LevelLength")); + card.add(BorderLayout.CENTER, text); + Button go = Ui.button("", "PlayRound", "cityButton", e -> CityForm.show()); + go.setMaterialIcon(FontImage.MATERIAL_PLAY_ARROW, 3.8f); + card.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(go)); + return card; + } + + /// The week's leader on `p`, as a ghost on the same seed. + static void ghost(final Levels.Playable p) { + Api.game().ghost(p.level.id, "week", Net.to(g -> Ui.later( + () -> RunForm.solo(p, week(p.level.id), "ghost", g, LevelsForm::show)), + (s, m) -> Ui.later(() -> RunForm.solo(p, week(p.level.id), "ghost", null, LevelsForm::show)))); + } + + /// This week's seed for `levelId`, the one the weekly board counts. + static long week(String levelId) { + int day = (int) (System.currentTimeMillis() / 86400000L); + return Content.weeklySeed(day / 7, levelId); + } + + /// One line on what kind of level it is. + public static String describe(LevelDef l) { + return view(l) + (l.length <= 0 ? " - endless" : " - " + l.length / 1000 + " m"); + } + + /// How the level is seen: "2D", "3D", or "2D into 3D". + static String view(LevelDef l) { + boolean side = l.startView() == ViewMode.SIDE_2D; + boolean turns = false; + for (int i = 0; i < l.cues.size(); i++) { + if (l.cues.get(i).mode != l.startView()) { + turns = true; + } + } + return side && turns ? "2D into 3D" : side ? "2D" : "3D"; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Nav.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Nav.java new file mode 100644 index 00000000000..06cf145b711 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Nav.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.CN; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.admin.AdminConsole; +import com.codenameone.examples.dash.engage.Friends; +import com.codenameone.examples.dash.engage.Notifications; +import com.codenameone.examples.dash.engage.Store; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.forge.ForgeHome; +import com.codenameone.examples.dash.forge.PackScreen; +import com.codenameone.examples.dash.net.Session; + +/// Where the app goes. Every screen change that is not a plain "back" comes +/// through here, so a push notification, a challenge link and a button all +/// land on the same screen the same way. +public final class Nav { + private static String pendingChallenge; + private static String pendingPack; + + private Nav() { + } + + /// The first screen: the stored session decides between home and sign-in. + public static void launch() { + pendingChallenge = AppConfig.challengeCode(); + pendingPack = AppConfig.packId(); + Session.onSignedOut(() -> CN.callSerially(SignInForm::show)); + Session.restore(() -> CN.callSerially(Nav::enter), + () -> CN.callSerially(SignInForm::show), + () -> CN.callSerially(() -> { + SignInForm.show(); + Ui.toast("The server did not answer. Is it running?"); + })); + } + + /// Signed in: a challenge or pack link opened the app wins, then the admin + /// console for an administrator who was last there, then home. + public static void enter() { + Notifications.signedIn(); + Store.resend(); + if (pendingChallenge != null) { + String code = pendingChallenge; + pendingChallenge = null; + challenge(code); + return; + } + if (pendingPack != null) { + String id = pendingPack; + pendingPack = null; + PackScreen.show(id); + return; + } + if (Session.user() != null && Session.user().admin() + && "admin".equals(AppConfig.mode(Session.user().username()))) { + adminConsole(); + return; + } + home(); + } + + public static void signedIn() { + Telemetry.signedIn(); + Friends.claimPending(); + enter(); + } + + public static void home() { + AppConfig.setMode("player", Session.user() == null ? "" : Session.user().username()); + HomeForm.show(); + } + + public static void challenge(String code) { + RaceLobby.join(code); + } + + public static void adminConsole() { + AppConfig.setMode("admin", Session.user().username()); + AdminConsole.show(); + } + + /// A screen named by a push or the admin's broadcast composer; the names + /// are the server's `Promotion.SCREENS`. + public static void screen(String name) { + if ("shop".equals(name)) { + shop(); + } else if ("friends".equals(name)) { + FriendsForm.show(); + } else if ("forge".equals(name)) { + ForgeHome.show(); + } else if ("runner".equals(name)) { + RunnerForm.show(); + } else if ("race".equals(name)) { + RaceLobby.quick(null); + } else if ("daily".equals(name)) { + RunForm.daily(); + } else if ("crews".equals(name)) { + CrewsForm.show(); + } else if (name != null && name.startsWith("level:")) { + // "A friend beat your score": that level's board, to see by how much. + ScoresForm.show(name.substring(6), null); + } else { + home(); + } + } + + public static void shop() { + ShopForm.show(); + } + + public static void signOut() { + Notifications.signingOut(() -> Session.signOut(() -> CN.callSerially(SignInForm::show))); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Portraits.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Portraits.java new file mode 100644 index 00000000000..9982f2eb20b --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Portraits.java @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codenameone.examples.dash.run.Outfit; +import java.util.HashMap; +import java.util.Map; + +/// The runners as the menus show them, and the supply crate the reward +/// screen opens. Everything is painted in code from the runner's [Outfit], +/// the same colours the run dresses its body in, so a new runner needs no +/// art at all. +public final class Portraits { + private static final Map CACHE = new HashMap(); + + private Portraits() { + } + + /// The portrait key of a shop runner id (`runner-dash`, ...). + public static String of(String runnerId) { + return runnerId == null ? "runner-dash" : runnerId; + } + + /// The portrait of the signed-in player's runner. + public static String mine() { + com.codenameone.examples.dash.api.PlayerDto p = com.codenameone.examples.dash.net.Session.user(); + return of(p == null ? null : p.runner()); + } + + /// A runner mid-stride, facing right, `h` pixels tall. + public static Image figure(String runnerId, int h) { + String key = "figure/" + runnerId + "/" + h; + Image img = CACHE.get(key); + if (img != null) { + return img; + } + Outfit o = Outfit.of(runnerId, 0); + int w = h * 4 / 5; + img = Image.createImage(w, h, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + // A soft disc of the trim colour behind the body, and speed lines. + int cx = w / 2; + int cy = h / 2; + for (int r = h / 2; r > 0; r -= Math.max(1, h / 40)) { + g.setColor(o.trim); + g.setAlpha(Math.max(0, 22 - r * 40 / h)); + g.fillArc(cx - r, cy - r, r * 2, r * 2, 0, 360); + } + g.setAlpha(140); + g.setColor(o.trim); + for (int i = 0; i < 4; i++) { + int y = h * (34 + i * 9) / 100; + int len = w * (18 + (i * 7) % 15) / 100; + g.fillRoundRect(w * 2 / 100, y, len, Math.max(2, h / 70), h / 70, h / 70); + } + g.setAlpha(255); + // The same faceted android the run draws: tapered segments whose + // two halves are lit and shaded like flat panels, armour at the + // joints, a chest wider than the waist and a helmet with a face + // plate. Far limbs first, in shadow. + float dim = 0.6f; + leg(g, o, w, h, dim, 40, 53, 29, 67, 15, 76); + arm(g, o, w, h, dim, 45, 31, 36, 41, 28, 49); + // The torso: hips, a narrow waist, the rib cage, shoulders, neck. + panel(g, o.suit, w, h, new float[] {35, 57, 46, 56, 46.5f, 46, 54, 37, 52, 29, 51.5f, 21, 47, 21, + 41, 29, 38.5f, 37, 37, 46}, 0.78f); + // The pack on the back with its light, the chest plate and its + // light, the hip guard and its belt line, the shoulder cap. + panel(g, o.shell, w, h, new float[] {35.5f, 31, 40.5f, 30, 40, 44, 35, 43}, 0.7f); + panel(g, o.trim, w, h, new float[] {35.5f, 33, 36.5f, 33, 36, 41, 35, 41}, 1f); + panel(g, o.shell, w, h, new float[] {48, 32, 53.5f, 34.5f, 52, 41, 47, 40}, 0.7f); + panel(g, o.trim, w, h, new float[] {51, 34.5f, 52, 35, 50.5f, 40.5f, 49.5f, 40.5f}, 1f); + panel(g, o.trim, w, h, new float[] {37.5f, 43.5f, 47, 42.5f, 47, 44, 37.5f, 45}, 1f); + panel(g, o.shell, w, h, new float[] {34.5f, 51, 46.5f, 50, 47, 57, 35, 58}, 0.7f); + panel(g, o.trim, w, h, new float[] {34.5f, 52, 46.5f, 51, 46.5f, 52.3f, 34.5f, 53.3f}, 1f); + // The helmet: an octagon, wider at the temples than the jaw, a + // face plate wrapping round the front and a crest over the crown. + int helmet = blend(o.shell, o.suit, 0.3f); + panel(g, helmet, w, h, new float[] {47, 21, 46, 15, 47.5f, 9, 52, 6.5f, 57, 8, 59.5f, 13, + 58.5f, 19, 53, 22.5f}, 0.72f); + panel(g, shade(o.shell, 0.8f), w, h, new float[] {48.5f, 12, 51.5f, 11.5f, 52, 17, 49, 17.5f}, 1f); + panel(g, o.trim, w, h, new float[] {54.5f, 10.5f, 60, 11.5f, 60, 16, 55, 16.5f}, 1f); + panel(g, o.trim, w, h, new float[] {48.5f, 7.5f, 52, 5.8f, 56.5f, 7, 52, 7.3f}, 1f); + panel(g, shade(o.shell, 1f), w, h, new float[] {41, 27.5f, 46, 26.5f, 47, 32, 42, 33}, 0.7f); + // Near limbs. + leg(g, o, w, h, 1f, 41, 54, 56, 66, 52, 85); + arm(g, o, w, h, 1f, 46, 31, 57, 41, 66, 32); + CACHE.put(key, img); + return img; + } + + /// A round helmet portrait, for the player's avatar and the coach's + /// speech bubble. + public static Image face(String runnerId, int size) { + String key = "face/" + runnerId + "/" + size; + Image img = CACHE.get(key); + if (img != null) { + return img; + } + Outfit o = Outfit.of(runnerId, 0); + img = Image.createImage(size, size, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + g.setColor(o.trim); + g.fillArc(0, 0, size - 1, size - 1, 0, 360); + g.fillRadialGradient(0x2a2f55, 0x0d1022, 2, 2, size - 5, size - 5); + // Shoulders and neck, then the faceted helmet front on: a jaw + // narrower than the temples, the face plate across it, a crest. + panel(g, o.suit, size, size, new float[] {14, 100, 22, 84, 40, 78, 60, 78, 78, 84, 86, 100}, 0.75f); + panel(g, o.shell, size, size, new float[] {16, 92, 24, 82, 38, 80, 34, 92}, 0.7f); + panel(g, o.shell, size, size, new float[] {84, 92, 76, 82, 62, 80, 66, 92}, 0.7f); + panel(g, blend(o.shell, o.suit, 0.3f), size, size, new float[] {38, 80, 26, 66, 24, 44, 33, 26, 50, 20, + 67, 26, 76, 44, 74, 66, 62, 80}, 0.72f); + panel(g, o.trim, size, size, new float[] {27, 46, 50, 42, 73, 46, 72, 58, 50, 62, 28, 58}, 1f); + panel(g, o.trim, size, size, new float[] {47, 21, 53, 21, 52, 34, 48, 34}, 1f); + g.setAlpha(255); + CACHE.put(key, img); + return img; + } + + /// A supply crate, `size` pixels wide, its seams lit; an amber one for + /// the rare kind. + public static Image crate(int size, boolean rare) { + String key = "crate/" + size + "/" + rare; + Image img = CACHE.get(key); + if (img != null) { + return img; + } + int w = size; + int h = size * 4 / 5; + int glow = rare ? 0xffc35a : 0x35e8ff; + img = Image.createImage(w, h, 0); + Graphics g = img.getGraphics(); + g.setAntiAliased(true); + int arc = w / 5; + g.setColor(glow); + g.setAlpha(60); + g.fillRoundRect(0, 0, w, h, arc, arc); + g.setAlpha(255); + g.fillLinearGradient(0x3a4166, 0x161a30, 3, 3, w - 6, h - 6, false); + g.setColor(0x161a30); + g.drawRoundRect(3, 3, w - 7, h - 7, arc, arc); + // The lid seam and the clamp, lit. + g.setColor(glow); + int seam = h * 38 / 100; + g.fillRect(4, seam, w - 8, Math.max(2, h / 22)); + int cw = w / 4; + g.fillRoundRect((w - cw) / 2, seam - h / 12, cw, h / 6, h / 12, h / 12); + // A chevron on the front, pointing the way the runners go. + int cy = h * 68 / 100; + int ch = h / 7; + int[] xs = {w / 2 - ch, w / 2, w / 2 - ch, w / 2 - ch / 2, w / 2 + ch / 2, w / 2 - ch / 2}; + int[] ys = {cy - ch, cy, cy + ch, cy + ch, cy, cy - ch}; + g.setAlpha(200); + g.fillPolygon(xs, ys, xs.length); + g.setAlpha(255); + g.setColor(0xffffff); + g.setAlpha(50); + g.fillRoundRect(6, 6, w - 12, h / 6, arc / 2, arc / 2); + g.setAlpha(255); + CACHE.put(key, img); + return img; + } + + /// A leg from the hip through the knee to the ankle: a tapered thigh + /// with its stripe, a knee guard, a shin, and a trainer with a sole. + private static void leg(Graphics g, Outfit o, int w, int h, float dim, float hx, float hy, float kx, + float ky, float ax, float ay) { + int suit = shade(o.suit, dim); + seg(g, suit, w, h, hx, hy, kx, ky, 9f, 6f); + seg(g, shade(o.trim, dim), w, h, hx + (kx - hx) * 0.15f, hy + (ky - hy) * 0.15f - 1.5f, + hx + (kx - hx) * 0.7f, hy + (ky - hy) * 0.7f - 1.5f, 1.2f, 1f); + seg(g, suit, w, h, kx, ky, ax, ay, 6f, 3.8f); + seg(g, shade(o.shell, dim), w, h, kx + (ax - kx) * 0.15f, ky + (ay - ky) * 0.15f, + kx + (ax - kx) * 0.7f, ky + (ay - ky) * 0.7f, 3.4f, 2.4f); + seg(g, shade(o.shell, dim), w, h, kx - (ax - kx) * 0.08f, ky - (ay - ky) * 0.08f, + kx + (ax - kx) * 0.08f, ky + (ay - ky) * 0.08f, 7f, 7f); + int kicks = shade(blend(o.shell, 0xffffff, 0.8f), dim); + panel(g, kicks, w, h, new float[] {ax - 3, ay - 2.5f, ax + 1.5f, ay - 3, ax + 10, ay + 0.5f, + ax + 11, ay + 3, ax - 3.5f, ay + 3}, 0.8f); + panel(g, shade(o.trim, dim), w, h, new float[] {ax - 3.5f, ay + 3, ax + 11.5f, ay + 3, + ax + 11, ay + 4.6f, ax - 3.5f, ay + 4.6f}, 1f); + } + + /// An arm from the shoulder through the elbow to the fist: a tapered + /// upper arm, an elbow guard, an armoured bracer with a light band. + private static void arm(Graphics g, Outfit o, int w, int h, float dim, float sx, float sy, float ex, + float ey, float fx, float fy) { + int shell = shade(o.shell, dim); + seg(g, shade(o.suit, dim), w, h, sx, sy, ex, ey, 5.5f, 4.2f); + seg(g, shell, w, h, ex, ey, fx, fy, 4.6f, 3.4f); + seg(g, shade(o.trim, dim), w, h, ex + (fx - ex) * 0.4f, ey + (fy - ey) * 0.4f, + ex + (fx - ex) * 0.5f, ey + (fy - ey) * 0.5f, 4.9f, 4.7f); + seg(g, shell, w, h, ex - (fx - ex) * 0.1f, ey - (fy - ey) * 0.1f, ex + (fx - ex) * 0.1f, + ey + (fy - ey) * 0.1f, 5.2f, 5.2f); + seg(g, shell, w, h, fx - (fx - ex) * 0.05f, fy - (fy - ey) * 0.05f, fx + (fx - ex) * 0.3f, + fy + (fy - ey) * 0.3f, 4.4f, 3.6f); + } + + /// A tapered segment from one point to another, `t1` and `t2` percent + /// of the image height thick at either end, with flat ends. + private static void seg(Graphics g, int color, int w, int h, float x1, float y1, float x2, float y2, float t1, + float t2) { + float ax = w * x1 / 100f; + float ay = h * y1 / 100f; + float bx = w * x2 / 100f; + float by = h * y2 / 100f; + float dx = bx - ax; + float dy = by - ay; + float len = (float) Math.sqrt(dx * dx + dy * dy); + if (len < 0.5f) { + len = 0.5f; + } + // Half the thickness per unit of t, in percent of each axis. + float nx = -dy / len * h / (2f * w); + float ny = dx / len / 2f; + float[] p = {x1 + nx * t1, y1 + ny * t1, x2 + nx * t2, y2 + ny * t2, x2 - nx * t2, y2 - ny * t2, + x1 - nx * t1, y1 - ny * t1}; + panel(g, color, w, h, p, 0.72f); + } + + /// A flat-shaded panel: the polygon `pts` (x, y pairs in percent of the + /// image) in `color`, with every edge that faces away from a light at + /// the top right darkened to `k` from the centre out, so a shape reads + /// as cut facets the way the run's flat-shaded meshes do. + private static void panel(Graphics g, int color, int w, int h, float[] pts, float k) { + int n = pts.length / 2; + int[] xs = new int[n]; + int[] ys = new int[n]; + float cx = 0; + float cy = 0; + for (int i = 0; i < n; i++) { + xs[i] = Math.round(w * pts[i * 2] / 100f); + ys[i] = Math.round(h * pts[i * 2 + 1] / 100f); + cx += xs[i]; + cy += ys[i]; + } + cx /= n; + cy /= n; + g.setColor(color); + g.fillPolygon(xs, ys, n); + if (k >= 1f) { + return; + } + g.setColor(shade(color, k)); + for (int i = 0; i < n; i++) { + int j = (i + 1) % n; + float mx = (xs[i] + xs[j]) / 2f - cx; + float my = (ys[i] + ys[j]) / 2f - cy; + // Away from the light: the edge's middle lies left of or below + // the centre. + if (mx * 0.6f - my * 0.8f < 0f) { + g.fillTriangle(Math.round(cx), Math.round(cy), xs[i], ys[i], xs[j], ys[j]); + } + } + } + + private static int blend(int a, int b, float k) { + int r = (int) (((a >> 16) & 0xff) * (1 - k) + ((b >> 16) & 0xff) * k); + int gr = (int) (((a >> 8) & 0xff) * (1 - k) + ((b >> 8) & 0xff) * k); + int bl = (int) ((a & 0xff) * (1 - k) + (b & 0xff) * k); + return (r << 16) | (gr << 8) | bl; + } + + private static int shade(int argb, float k) { + int r = (int) (((argb >> 16) & 0xff) * k); + int gr = (int) (((argb >> 8) & 0xff) * k); + int b = (int) ((argb & 0xff) * k); + return (r << 16) | (gr << 8) | b; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RaceLobby.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RaceLobby.java new file mode 100644 index 00000000000..5c833a3bfee --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RaceLobby.java @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.InfiniteProgress; +import com.codename1.components.ShareButton; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.components.SpanLabel; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.GameLink; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.Json; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.run.Voice; +import java.util.Map; + +/// Getting into a live race. +/// +/// - **Quick race**: whoever is waiting for the same level; bots fill the +/// empty places after a few seconds, so nobody waits long. +/// - **Party**: a code and a link for friends. Everyone who opens it lands +/// here, can talk (voice, if the device has it, and text), and the host +/// starts the race when the crew is in. +/// +/// Every runner in a race runs the same level on the same seed: the track is +/// identical on every screen, and the race is decided by who runs it best. +// tag::dash-lobby[] +public final class RaceLobby implements GameLink.Listener, Voice.Listener { + private final String hello; + private final boolean spectator; + private GameLink link; + private RaceDriver driver; + private boolean cancelled; + private Form form; + private ShareButton share; + private final Container members = new Container(BoxLayout.y()); + private final Container chat = new Container(BoxLayout.y()); + private Container page; + private final Label code = new Label("", "ChallengeCode"); + private final Label url = new Label("", "ChallengeUrl"); + private boolean host; + private String level = Content.CAUSEWAY; + private String[] names = new String[0]; + private String[] players = new String[0]; + private String[] talking = new String[0]; + private final java.util.Set mutedPeers = new java.util.HashSet(); + + private RaceLobby(String hello, boolean spectator) { + this.hello = hello; + this.spectator = spectator; + } + + /// A quick race on `level` (an official id or `pack/level`), or on the + /// game's featured race level when null. + public static void quick(String level) { + String l = level == null ? Content.CAUSEWAY : level; + RaceLobby r = new RaceLobby("{\"type\":\"" + GameProtocol.QUICK + "\",\"level\":" + Json.write(l) + "}", + false); + r.level = l; + r.waiting("Finding runners"); + } + + /// A party of one's own, on `level`. + public static void host(String level) { + host(level, null); + } + + /// A party for a crew: the server pushes the invite to the other members. + public static void host(String level, String crew) { + String l = level == null ? Content.CAUSEWAY : level; + RaceLobby r = new RaceLobby("{\"type\":\"" + GameProtocol.HOST + "\",\"level\":" + Json.write(l) + + (crew == null ? "" : ",\"crew\":" + Json.write(crew)) + "}", false); + r.level = l; + r.host = true; + r.party("Your party"); + } + + /// Someone else's party, from its link or code. + public static void join(String code) { + new RaceLobby("{\"type\":\"" + GameProtocol.JOIN + "\",\"code\":" + Json.write(code) + "}", false) + .party("Joining the party"); + } + + /// The admin console's spectator view of a live race. + public static void spectate(String raceId) { + new RaceLobby("{\"type\":\"" + GameProtocol.SPECTATE + "\",\"race\":" + Json.write(raceId) + "}", true) + .waiting("Connecting"); + } + + /// A "join with a code" prompt, for a friend who was told the code. + public static void enterCode() { + final Form f = Ui.form("Join a party", "joinParty"); + Ui.back(f, Nav::home); + final TextField field = new TextField("", "Party code", 12, TextField.ANY); + field.setName("partyCode"); + f.add(field); + f.add(Ui.primary("Join", "joinButton", e -> { + if (field.getText().trim().length() > 0) { + join(field.getText().trim().toUpperCase()); + } + })); + f.show(); + } + + private void waiting(String title) { + form = Ui.form(title, "lobby"); + Ui.back(form, this::cancel); + form.setLayout(BoxLayout.yCenter()); + form.add(Ui.centered(new InfiniteProgress())); + form.add(Ui.secondary("Cancel", "cancel", e -> cancel())); + form.show(); + link = GameLink.open(this); + } + + private void party(String title) { + form = Ui.form(title, "party"); + form.setLayout(new BorderLayout()); + Ui.back(form, this::cancel); + Container top = Ui.column(); + top.add(Ui.label("Party code", "SectionTitle")); + code.setName("partyCode"); + top.add(Ui.centered(code)); + top.add(url); + share = new ShareButton(); + share.setText("Invite friends"); + share.setUIID("PrimaryButton"); + share.setName("shareParty"); + share.addActionListener(e -> Telemetry.challengeShared()); + // A material icon takes the button's own colour; the stock share + // image is a picture and sits on a light square on a dark button. + share.setMaterialIcon(FontImage.MATERIAL_SHARE); + Button copy = Ui.secondary("Copy the link", "copyParty", e -> { + Display.getInstance().copyToClipboard(url.getText()); + Ui.toast("Copied"); + }); + top.add(share); + top.add(copy); + // The code and the invite stay put; the runners and the chat scroll + // under them, so the conversation is never squeezed into what the + // code card leaves and scrolling to a new line never hides the code. + Container below = Ui.column(); + below.add(Ui.label("Runners", "SectionTitle")); + members.setName("members"); + below.add(members); + chat.setName("chat"); + below.add(chat); + page = BoxLayout.encloseY(Ui.centeredColumn(below)); + page.setScrollableY(true); + form.add(BorderLayout.NORTH, Ui.centeredColumn(top)); + form.add(BorderLayout.CENTER, page); + + final TextField say = new TextField("", "Say something", 40, TextField.ANY); + say.setName("chatField"); + say.setDoneListener(e -> { + String t = say.getText().trim(); + if (t.length() > 0 && link != null) { + link.send("{\"type\":\"" + GameProtocol.CHAT + "\",\"text\":" + Json.write(t) + "}"); + say.setText(""); + } + }); + final Button mic = Ui.button("", "HudButton", "mic", null); + mic.setMaterialIcon(FontImage.MATERIAL_MIC); + mic.setEnabled(Voice.available()); + mic.addActionListener(e -> { + Voice v = Voice.current(); + if (v != null) { + v.setMuted(!v.isMuted()); + mic.setMaterialIcon(v.isMuted() ? FontImage.MATERIAL_MIC_OFF : FontImage.MATERIAL_MIC); + } + }); + // The chat line and the buttons share the page's column, so their + // edges line up with the code card and the bubbles above them. + Container sayRow = new Container(new BorderLayout()); + sayRow.setUIID("LobbySay"); + sayRow.add(BorderLayout.CENTER, say); + sayRow.add(BorderLayout.EAST, mic); + Button leave = Ui.secondary("Leave", "cancel", e -> cancel()); + // Both buttons in one row from the start: a grid built for one + // child grows a second row for the next, not a second column. + Container actions = host + ? Ui.row(leave, Ui.primary("Start", "goButton", e -> link.send("{\"type\":\"" + + GameProtocol.GO + "\",\"level\":" + Json.write(level) + "}"))) + : Ui.row(leave); + Container bottom = Ui.centeredColumn(Ui.column(sayRow, actions)); + bottom.setSafeArea(true); + form.add(BorderLayout.SOUTH, bottom); + form.show(); + link = GameLink.open(this); + } + // end::dash-lobby[] + + private void cancel() { + cancelled = true; + Voice.stop(); + if (link != null) { + link.send("{\"type\":\"" + GameProtocol.CANCEL + "\"}"); + link.close(); + } + Nav.home(); + } + + @Override + public void message(Map m) { + if (cancelled) { + return; + } + String type = GameLink.text(m, "type"); + if (GameProtocol.READY.equals(type)) { + link.send(hello); + } else if (GameProtocol.PARTY.equals(type)) { + party(m); + } else if (GameProtocol.START.equals(type) && driver == null) { + start(m); + } else if (GameProtocol.VOICE.equals(type)) { + Voice v = Voice.current(); + if (v != null) { + v.signal(GameLink.text(m, "from"), GameLink.text(m, "data")); + } + } else if (GameProtocol.MUTE.equals(type)) { + Voice v = Voice.current(); + if (v != null) { + v.mutedBy(GameLink.text(m, "from"), Boolean.TRUE.equals(m.get("on"))); + } + } else if (GameProtocol.CHAT.equals(type) && driver == null) { + // A bubble per message, the newest scrolled into view. + Container bubble = BoxLayout.encloseY(new Label(GameLink.text(m, "from"), "ChatFrom"), + new SpanLabel(GameLink.text(m, "text"), "ChatLine")); + bubble.setUIID("ChatBubble"); + chat.add(bubble); + form.revalidate(); + page.scrollComponentToVisible(bubble); + } else if (GameProtocol.ERROR.equals(type) || GameProtocol.KICKED.equals(type)) { + String why = GameProtocol.KICKED.equals(type) ? "You were removed from the race." + : GameLink.text(m, "message"); + Voice.stop(); + link.close(); + if (driver != null) { + driver.dispose(); + } + Ui.toast(why); + Nav.home(); + } else if (driver != null) { + driver.message(m); + } + } + + private void party(Map m) { + code.setText(GameLink.text(m, "code")); + url.setText(GameLink.text(m, "url")); + level = GameLink.text(m, "level"); + if (share != null) { + share.setTextToShare("Race me in Dash: " + url.getText()); + } + names = toStrings(m.get("names")); + players = toStrings(m.get("players")); + if (Voice.available() && Voice.current() == null && Session.user() != null) { + Voice.start(link, Session.user().username()).setListener(this); + } + if (Voice.current() != null) { + Voice.current().members(players); + } + drawMembers(); + } + + private void drawMembers() { + members.removeAll(); + for (int i = 0; i < names.length; i++) { + String u = i < players.length ? players[i] : ""; + boolean me = Session.user() != null && Session.user().username().equals(u); + String state = me ? "" : contains(talking, u) ? (mutedPeers.contains(u) ? " (muted)" : " (voice)") : ""; + Label l = new Label(names[i] + state, "LobbyMemberName"); + l.setMaterialIcon(FontImage.MATERIAL_DIRECTIONS_RUN); + Container row = BorderLayout.center(l); + row.setUIID("LobbyMember"); + if (i == 0) { + row.add(BorderLayout.EAST, new Label("HOST", "LobbyHostTag")); + } + members.add(row); + } + form.revalidateLater(); + } + + /// The race is on: its level must be on this device first, so a + /// community level's pack is fetched before the run begins. + private void start(final Map m) { + final String ref = GameLink.text(m, "level"); + Levels.Playable p = Levels.resolve(ref); + if (p != null) { + begin(p, m); + return; + } + String pack = Levels.packOf(ref); + if (pack == null) { + Ui.toast("This race's level is not in this version of the game."); + cancel(); + return; + } + Api.forge().pack(pack, Net.to(dto -> Ui.later(() -> { + Levels.remember(dto.content()); + Levels.Playable found = Levels.resolve(ref); + if (found == null) { + Ui.toast("The race's level changed while it started."); + cancel(); + } else { + begin(found, m); + } + }), (s, msg) -> Ui.later(this::cancel))); + } + + private void begin(Levels.Playable p, Map m) { + driver = new RaceDriver(link, p, m, spectator); + if (Voice.current() != null) { + Voice.current().members(toStrings(m.get("players"))); + } + RunForm.race(driver, toStrings(m.get("runners")), () -> { + Voice.stop(); + Nav.home(); + }); + } + + @Override + public void closed(int closeCode) { + if (cancelled) { + return; + } + Voice.stop(); + if (driver == null) { + Ui.toast("Could not reach the game server."); + Nav.home(); + } + } + + @Override + public void peers(String[] connected) { + talking = connected; + if (driver == null && form != null) { + drawMembers(); + } + } + + @Override + public void muted(String username, boolean on) { + if (on) { + mutedPeers.add(username); + } else { + mutedPeers.remove(username); + } + if (driver == null && form != null) { + drawMembers(); + } + } + + private static boolean contains(String[] a, String v) { + for (String s : a) { + if (s.equals(v)) { + return true; + } + } + return false; + } + + private static String[] toStrings(Object list) { + if (!(list instanceof java.util.List)) { + return new String[0]; + } + java.util.List l = (java.util.List) list; + String[] out = new String[l.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = l.get(i) == null ? "" : l.get(i).toString(); + } + return out; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RegisterForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RegisterForm.java new file mode 100644 index 00000000000..a276d165be3 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RegisterForm.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.api.RegisterDto; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; + +/// Creating an account. The player name is the one piece of text a player +/// types that everyone else sees, so its field is styled as a banner -- the +/// `NameField` UIID in `theme.css` -- and previews the name as it will appear +/// over the arena. +// tag::dash-name-field[] +public final class RegisterForm { + private RegisterForm() { + } + + public static void show() { + Form f = Ui.form("New player", "register"); + Ui.back(f, SignInForm::show); + final TextField name = new TextField("", "Your runner name", 16, TextField.ANY); + name.setUIID("NameField"); + name.setName("displayName"); + name.setMaxSize(16); + final TextField email = SignInForm.field("Email", TextField.EMAILADDR, "email"); + final TextField password = SignInForm.field("Password (8+ characters)", TextField.PASSWORD, + "password"); + Container col = Ui.column(); + col.add(SignInForm.heroes()); + col.add(Ui.label("Choose your runner name", "SectionTitle")); + col.add(Ui.panel(name)); + col.add(Ui.panel(email, password, Ui.primary("Start running", "createButton", e -> { + final String mail = email.getText().trim(); + final String pass = password.getText(); + Api.account().register(new RegisterDto(mail, pass, name.getText().trim()), + Net.to(player -> { + Telemetry.registered(); + Session.signIn(mail, pass, () -> Ui.later(Nav::signedIn), Ui::failed); + }, Ui::failed)); + }))); + f.add(Ui.centeredColumn(col)); + f.show(); + } +} +// end::dash-name-field[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ReplayForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ReplayForm.java new file mode 100644 index 00000000000..8ee4f94718e --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ReplayForm.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Slider; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.dash.api.ReplayDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.run.ReplayDriver; +import com.codenameone.examples.dash.run.RunView; + +/// A replay. Nothing was recorded but the level, the seed and the inputs each +/// runner gave: the [Run] is simply run again under the same rules, which is +/// why a replay is a few hundred bytes and why scrubbing is "run from the +/// start up to here". A tap on a runner's name follows that runner. +// tag::dash-replay-form[] +public final class ReplayForm implements RunView.Hud { + private final ReplayDriver driver; + private final Slider scrub = new Slider(); + private final Label clock = new Label("0:00", "RunClock"); + private final Runnable back; + private boolean dragging; + + private ReplayForm(ReplayDriver driver, Runnable back) { + this.driver = driver; + this.back = back; + } + + /// A race (by id) or a leaderboard run (`level|player` ids) from the server. + public static void load(String id) { + Api.game().replay(id, Net.to(r -> Ui.later(() -> open(r, null)), Ui::failed)); + } + + /// Opens `r`, fetching the community pack its level lives in first when + /// this device has not seen it. + public static void open(final ReplayDto r, final Runnable back) { + final Replay replay = Replay.decode(r.replay()); + Levels.Playable level = Levels.resolve(replay.levelId()); + if (level != null) { + show(level, replay, r.names() == null ? null : r.names().toArray(new String[0]), back); + return; + } + String pack = Levels.packOf(replay.levelId()); + if (pack == null) { + Ui.toast("This replay's level is not in this version of the game."); + return; + } + Api.forge().pack(pack, Net.to(p -> Ui.later(() -> { + Levels.remember(p.content()); + Levels.Playable found = Levels.resolve(replay.levelId()); + if (found == null) { + Ui.toast("The level was changed after this run, so it cannot be replayed."); + } else { + show(found, replay, r.names() == null ? null : r.names().toArray(new String[0]), back); + } + }), Ui::failed)); + } + + public static void show(Levels.Playable level, Replay replay, String[] names, Runnable back) { + int me = 0; + if (names != null && Session.user() != null) { + for (int i = 0; i < names.length; i++) { + if (Session.user().displayName().equals(names[i])) { + me = i; + } + } + } + new ReplayForm(new ReplayDriver(level, replay, names, me), back).build(); + } + + private void build() { + final RunView view = new RunView(driver); + view.setName("track"); + view.setHud(this); + Form f = new Form(driver.run().level().name, new BorderLayout()); + f.setName("replay"); + f.setUIID("RunScreen"); + Ui.back(f, () -> { + view.stop(); + if (back != null) { + back.run(); + } else if (Session.user() == null) { + SignInForm.show(); + } else { + HistoryForm.show(); + } + }); + f.add(BorderLayout.CENTER, view); + scrub.setName("scrub"); + scrub.setEditable(true); + scrub.setMinValue(0); + scrub.setMaxValue(Math.max(1, driver.length())); + scrub.addPointerPressedListener(e -> dragging = true); + scrub.addPointerReleasedListener(e -> dragging = false); + scrub.addDataChangedListener((type, index) -> { + if (dragging) { + driver.seek(scrub.getProgress()); + } + }); + final Button pause = Ui.button("", "ReplayButton", "pause", null); + pause.setMaterialIcon(FontImage.MATERIAL_PAUSE); + pause.addActionListener(e -> { + driver.setPaused(!driver.isPaused()); + pause.setMaterialIcon(driver.isPaused() ? FontImage.MATERIAL_PLAY_ARROW : FontImage.MATERIAL_PAUSE); + }); + final Button speed = Ui.button("1x", "ReplayButton", "speed", null); + speed.addActionListener(e -> { + int next = driver.speedPercent() >= 400 ? 100 : driver.speedPercent() * 2; + driver.setSpeedPercent(next); + speed.setText((next / 100) + "x"); + }); + Container controls = new Container(new BorderLayout()); + controls.setUIID("ReplayBar"); + controls.setSafeArea(true); + controls.add(BorderLayout.WEST, BoxLayout.encloseX(pause, speed)); + controls.add(BorderLayout.CENTER, scrub); + controls.add(BorderLayout.EAST, clock); + if (driver.run().runnerCount() > 1) { + final Button follow = Ui.button(driver.name(driver.me()), "ReplayButton", "follow", null); + follow.addActionListener(e -> { + driver.follow((driver.me() + 1) % driver.run().runnerCount()); + follow.setText(driver.name(driver.me())); + }); + controls.add(BorderLayout.NORTH, follow); + } + f.add(BorderLayout.SOUTH, controls); + f.show(); + view.start(); + } + // end::dash-replay-form[] + + @Override + public void ticked(Run r) { + if (!dragging) { + scrub.setProgress(r.tick()); + } + int s = r.tick() / Run.TICKS_PER_SECOND; + clock.setText((s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)); + } + + @Override + public void ended(Run r) { + driver.setPaused(true); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Rewards.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Rewards.java new file mode 100644 index 00000000000..bb29a753a21 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Rewards.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.dash.api.AchievementDto; +import com.codenameone.examples.dash.api.ProgressDto; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import java.util.ArrayList; +import java.util.List; + +/// The player's progress as the client last saw it, and the celebrations when +/// it moves: a reward popup with a hero in it, a level-up, a new achievement. +/// +/// The server decides every reward. This class only remembers the previous +/// [ProgressDto] so that, after a run, it can tell what changed and say so. +// tag::dash-rewards[] +public final class Rewards { + private static ProgressDto last; + + private Rewards() { + } + + /// The progress last fetched, or null before the first fetch. + public static ProgressDto progress() { + return last; + } + + /// Fetches the progress; `done` runs on the EDT either way. + public static void refresh(final Runnable done) { + if (Session.user() == null) { + Ui.later(done); + return; + } + Api.progress().progress(Net.to(p -> Ui.later(() -> { + last = p; + done.run(); + }), (s, m) -> Ui.later(done))); + } + + /// Fetches the progress and celebrates what changed since the last fetch: + /// levels gained and achievements unlocked by the run just played. + public static void afterRun(final Runnable done) { + afterRun(done, null); + } + + /// [#afterRun(Runnable)], celebrating on `host` when it is not null. + public static void afterRun(final Runnable done, final Host host) { + final ProgressDto before = last; + refresh(() -> { + ProgressDto now = last; + if (before == null || now == null) { + done.run(); + return; + } + List unlocked = new ArrayList(); + for (AchievementDto a : now.achievements()) { + if (a.unlocked() && !wasUnlocked(before, a.id())) { + unlocked.add(a.title()); + } + } + int gainedXp = now.xp() - before.xp(); + int chests = Math.max(0, now.chests() - before.chests()); + if (now.level() > before.level() || !unlocked.isEmpty() || chests > 0) { + show(new RewardDto(now.level() > before.level() ? "Level up!" : "Run rewards", 0, 0, + gainedXp, chests, now.level(), now.level() > before.level(), unlocked), done, host); + } else { + done.run(); + } + }); + } + + /// Somewhere other than a dialog to show a card: the run screen lays it + /// over its own scene, because a dialog over the GPU view captures the + /// form behind it and the 3D scene is not in that capture -- the card + /// sat on flat grey. + public interface Host { + /// Shows `card` and answers what takes it away again. + Runnable open(Component card); + } + + /// A reward the server just granted, shown with the hero cheering. + public static void show(RewardDto r, final Runnable done) { + show(r, done, null); + } + + /// [#show(RewardDto, Runnable)] on `host`, or in a dialog when it is null. + public static void show(RewardDto r, final Runnable done, Host host) { + Telemetry.screen(r.levelUp() ? "levelUp" : "reward"); + final Container d = new Container(BoxLayout.y()); + d.setName("rewardDialog"); + d.setUIID(host == null ? "RewardBody" : "RewardCard"); + // Wraps rather than truncating "Training co...". + SpanLabel title = Ui.text(r.title(), r.levelUp() ? "LevelUpTitle" : "RewardTitle"); + title.setTextBlockAlign(Component.CENTER); + d.add(title); + String hero = Portraits.mine(); + Image art = Portraits.figure(hero, CN.convertToPixels(r.levelUp() ? 17 : 16)); + // The prize turns in a burst of light, the way a win should feel. + Image prize = r.chests() > 0 && !r.levelUp() ? Portraits.crate(CN.convertToPixels(16), true) : art; + d.add(Kit.burst(prize, r.levelUp() ? 0xd4ff3a : 0xffd23a)); + if (r.levelUp()) { + d.add(FlowLayout.encloseCenter(new Label("Level " + r.level(), "LevelBadgeBig"))); + } + Container items = new Container(new FlowLayout(Component.CENTER)); + if (r.coins() > 0) { + Label coinsWon = Kit.countUp("+", r.coins(), "RewardCoins"); + coinsWon.setMaterialIcon(FontImage.MATERIAL_MONETIZATION_ON); + // The icon widens the label: size it again for the final amount. + coinsWon.setText("+" + r.coins()); + coinsWon.setPreferredSize(null); + coinsWon.setPreferredSize(coinsWon.getPreferredSize()); + coinsWon.setText("+0"); + items.add(coinsWon); + } + if (r.gems() > 0) { + items.add(chip(FontImage.MATERIAL_DIAMOND, "+" + r.gems(), "RewardGems")); + } + if (r.xp() > 0) { + items.add(chip(FontImage.MATERIAL_STAR, "+" + r.xp() + " XP", "RewardXp")); + } + if (r.chests() > 0) { + items.add(chip(FontImage.MATERIAL_INVENTORY_2, "+" + r.chests() + " chest", "RewardChest")); + } + d.add(items); + if (r.unlocked() != null) { + for (String a : r.unlocked()) { + Label l = new Label("Achievement: " + a, "RewardAchievement"); + l.setMaterialIcon(FontImage.MATERIAL_MILITARY_TECH); + d.add(FlowLayout.encloseCenter(l)); + } + } + final Runnable[] close = new Runnable[1]; + Button collect = Ui.primary("Collect", "collectReward", e -> { + close[0].run(); + Session.refreshUser(() -> Ui.later(done), (s, m) -> Ui.later(done)); + }); + d.add(collect); + // A wrapping title packs to its narrowest, so an empty strut gives the + // card its width. + Label strut = new Label("", "Container"); + strut.setPreferredW(Math.min(CN.convertToPixels(70), CN.getDisplayWidth() - CN.convertToPixels(16))); + strut.setPreferredH(1); + d.add(strut); + if (host != null) { + close[0] = host.open(d); + return; + } + final Dialog dlg = new Dialog(new BorderLayout()); + dlg.setDialogUIID("RewardDialog"); + dlg.getContentPane().setUIID("Container"); + dlg.getTitleComponent().setUIID("Container"); + // A fade, not the pulsate transition: pulsate scales a snapshot of the + // dialog whose rounded corners were captured opaque, so it spun in on + // a white box. + dlg.setTransitionInAnimator(CommonTransitions.createFade(250)); + dlg.setTransitionOutAnimator(CommonTransitions.createFade(200)); + dlg.setDisposeWhenPointerOutOfBounds(false); + // A scrolling body: showPacked clamped the height first, and the last + // child -- the button -- absorbed the shortfall. + dlg.getContentPane().setScrollableY(true); + dlg.add(BorderLayout.CENTER, d); + close[0] = dlg::dispose; + dlg.showPacked(BorderLayout.CENTER, true); + } + + /// A short line under a celebration: how far the player is from the next + /// level. + static SpanLabel nextLevelHint(ProgressDto p) { + return Ui.text((p.levelEnd() - p.xp()) + " XP to level " + (p.level() + 1), "Muted"); + } + + private static Label chip(char icon, String text, String uiid) { + Label l = new Label(text, uiid); + l.setMaterialIcon(icon); + return l; + } + + private static boolean wasUnlocked(ProgressDto p, String id) { + for (AchievementDto a : p.achievements()) { + if (a.id().equals(id)) { + return a.unlocked(); + } + } + return false; + } +} +// end::dash-rewards[] diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunForm.java new file mode 100644 index 00000000000..55004a4d83b --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunForm.java @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.dash.api.GhostDto; +import com.codenameone.examples.dash.api.LevelOutcomeDto; +import com.codenameone.examples.dash.api.LevelResultDto; +import com.codenameone.examples.dash.engage.Ads; +import com.codenameone.examples.dash.engage.Friends; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.rules.DialogLine; +import com.codenameone.examples.dash.rules.Input; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Runner; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.run.CityPath; +import com.codenameone.examples.dash.run.Driver; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.run.SoloDriver; + +/// The run screen: the track, and over it the distance, the score, the +/// coins, a place in a race, a coach's line in the tutorial and the +/// countdown before a race. Built the same way for every kind of run; what +/// differs is the [Driver] underneath. +// tag::dash-run-form[] +public final class RunForm implements RunView.Hud, RaceDriver.Listener { + private final Driver driver; + private final String mode; + private final Runnable back; + private final RunView view; + private final Form form; + private Container layers; + private CityPath.Minimap minimap; + private final Label distance = new Label("0 m", "HudDistance"); + private final Label score = new Label("0", "HudScore"); + private final Label coins = new Label("0", "HudCoins"); + private final Label place = new Label("", "HudPlace"); + private final Label countdown = new Label("", "HudCountdown"); + private final Label power = new Label("", "HudPower"); + private final Label street = new Label("", "HudStreet"); + private CityRoute cityRoute; + private final SpanLabel coach = new SpanLabel(""); + /// Everything drawn over the track; hidden under the result card. + private Container hud; + private final Container chatLog = new Container(BoxLayout.y()); + private int nextLine; + + private RunForm(Driver driver, String mode, String[] runnerIds, Runnable back) { + this.driver = driver; + this.mode = mode; + this.back = back; + view = new RunView(driver); + view.setName("track"); + view.setRunnerIds(runnerIds); + view.setHud(this); + form = new Form(driver.run().level().name, new BorderLayout()); + form.setName("run"); + form.setUIID("RunScreen"); + form.getToolbar().hideToolbar(); + + hud = new Container(new BorderLayout()); + hud.setSafeArea(true); + // Centred on the screen, not between the columns: the race's right + // column is wider than the pause button and pulled the distance left. + Container top = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE)); + top.setUIID("HudBar"); + Button quit = Ui.button("", "HudButton", "quit", e -> quit()); + quit.setMaterialIcon(FontImage.MATERIAL_PAUSE, 4.4f); + // In a box, so a taller right column never stretches the button. + // The pause pill keeps its own width over the wider minimap. + Container left = BoxLayout.encloseY(FlowLayout.encloseIn(quit)); + // A city run shows where on its streets the runner is, under the + // pause button and clear of the distance. + CityPath path = Levels.cityPath(driver.run().level().id); + if (path != null) { + minimap = new CityPath.Minimap(path); + minimap.setName("minimap"); + left.add(minimap); + } + top.add(BorderLayout.WEST, left); + // Distance big in the middle, score under it; coins and place to the + // right, where the eye checks them between obstacles. + Container mid = new Container(BoxLayout.y()); + mid.add(distance); + mid.add(FlowLayout.encloseCenter(score)); + // The street the runner is on, on a city run, under the score. + cityRoute = Levels.cityRoute(driver.run().level().id); + if (cityRoute != null) { + street.setName("street"); + Ui.glyph(street, FontImage.MATERIAL_SIGNPOST, 2.8f); + mid.add(FlowLayout.encloseCenter(street)); + } + top.add(BorderLayout.CENTER, mid); + Ui.glyph(coins, FontImage.MATERIAL_MONETIZATION_ON, 3.6f); + Ui.glyph(power, FontImage.MATERIAL_BOLT, 3.2f); + // Each on the right edge at its own width: a wide place line in a + // race must not stretch the coin pill. + Container right = new Container(BoxLayout.y()); + right.add(FlowLayout.encloseRight(coins)); + right.add(FlowLayout.encloseRight(place)); + right.add(FlowLayout.encloseRight(power)); + top.add(BorderLayout.EAST, right); + distance.setName("distance"); + score.setName("score"); + place.setName("place"); + hud.add(BorderLayout.NORTH, top); + coach.setUIID("CoachBubble"); + coach.setTextUIID("CoachText"); + coach.setName("coach"); + coach.setVisible(false); + countdown.setName("countdown"); + Container middle = new Container(BoxLayout.y()); + middle.add(FlowLayout.encloseCenter(countdown)); + middle.add(coach); + hud.add(BorderLayout.CENTER, middle); + if (driver instanceof RaceDriver) { + Container bottom = new Container(new BorderLayout()); + chatLog.setUIID("ChatLog"); + bottom.add(BorderLayout.CENTER, chatLog); + Container emotes = new Container(new GridLayout(1, 4)); + // Clear of the home indicator, with room under the chips. + emotes.setUIID("EmoteBar"); + emotes.setSafeArea(true); + String[] ids = {"wave", "gg", "wow", "oops"}; + for (final String id : ids) { + emotes.add(Ui.button(emoteText(id), "EmoteButton", "emote-" + id, + e -> ((RaceDriver) driver).emote(id))); + } + bottom.add(BorderLayout.SOUTH, emotes); + hud.add(BorderLayout.SOUTH, bottom); + } + layers = new Container(new LayeredLayout()); + layers.add(view); + layers.add(hud); + form.add(BorderLayout.CENTER, layers); + } + // end::dash-run-form[] + + /// The level a playtest runs, so "Again" can run it again. + private static Levels.Playable playtest; + + /// Plays a level from the editor, unsubmitted. + public static void playtest(Levels.Playable level, Runnable back) { + playtest = level; + solo(level, level.level.contentHash(), "playtest", null, back); + } + + /// Plays `level` alone on `seed`, with an optional ghost to beat. + public static void solo(Levels.Playable level, long seed, String mode, GhostDto ghost, Runnable back) { + SoloDriver d = new SoloDriver(level, seed); + if (ghost != null && ghost.replay() != null && ghost.replay().length() > 0) { + d.ghost(level, Replay.decode(ghost.replay()), ghost.displayName()); + } + Telemetry.runStarted(mode, level.ref); + String runner = Session.user() == null ? null : Session.user().runner(); + new RunForm(d, mode, new String[] {runner}, back).start(); + } + + /// The tutorial level, its coach lines on screen. + public static void tutorial() { + solo(Levels.official(Content.TUTORIAL), 1L, "tutorial", null, LevelsForm::show); + } + + /// Today's seed on the endless level: the same track for everyone, today. + public static void daily() { + Api.game().daily(Net.to(d -> Ui.later(() -> { + Levels.Playable level = Levels.official(d.level()); + if (level == null) { + Ui.toast("Today's level is not in this version of the game."); + return; + } + solo(level, Long.parseLong(d.seed()), "daily", null, Nav::home); + }), Ui::failed)); + } + + /// A live race the lobby has started. + public static void race(RaceDriver driver, String[] runnerIds, Runnable back) { + Telemetry.runStarted("race", driver.run().level().id); + RunForm f = new RunForm(driver, "race", runnerIds, back); + driver.setListener(f); + f.start(); + } + + private void start() { + form.show(); + view.start(); + } + + private void quit() { + if (driver instanceof RaceDriver) { + if (!Dialog.show("Leave the race?", "You will be placed last.", "Leave", "Stay")) { + return; + } + ((RaceDriver) driver).leave(); + } + close(); + } + + private void close() { + view.stop(); + driver.dispose(); + if (back != null) { + back.run(); + } else { + Nav.home(); + } + } + + @Override + public void ticked(Run run) { + Runner me = run.runner(driver.me()); + distance.setText(me.z / Track.UNIT + " m"); + if (minimap != null && run.level().length > 0) { + minimap.setProgress(me.z / (float) Track.UNIT / run.level().length); + } + score.setText(String.valueOf(me.score())); + if (cityRoute != null) { + String on = cityRoute.streetAt(me.z / Track.UNIT); + if (!on.equals(street.getText())) { + street.setText(on); + street.setVisible(on.length() > 0); + form.revalidateLater(); + } + } + coins.setText(String.valueOf(me.coins)); + power.setText(me.shield > 0 ? "Shield" : me.magnet > 0 ? "Magnet" : me.boost > 0 ? "Boost" : ""); + int wait = driver.countdownTicks(); + countdown.setText(wait > 0 ? String.valueOf(1 + wait / Run.TICKS_PER_SECOND) : ""); + if (run.runnerCount() > 1) { + place.setText(HistoryForm.place(placeOf(run, driver.me())) + " / " + run.runnerCount()); + } else if (driver.ghosts().length > 0) { + Runner g = driver.ghosts()[0].runner(0); + place.setText(me.z >= g.z ? "Ahead" : "Behind"); + } + lines(run, me); + form.revalidateLater(); + } + + /// The run is over: the coach stops talking and the HUD steps aside, so + /// neither shows through the dim behind the result card or the rewards. + private void quiet() { + nextLine = Integer.MAX_VALUE; + coach.setVisible(false); + // The result card says it all; the HUD under the dim is just noise. + hud.setVisible(false); + form.revalidateLater(); + } + + /// The coach's line for the stretch the runner is on. + private void lines(Run run, Runner me) { + java.util.List d = run.level().dialog; + while (nextLine < d.size() && d.get(nextLine).at <= me.z) { + DialogLine l = d.get(nextLine++); + coach.setText(l.speaker + ": " + l.text + hint(l.hint)); + coach.setVisible(true); + final int shown = nextLine; + CN.setTimeout(4000, () -> { + if (nextLine == shown) { + coach.setVisible(false); + form.revalidateLater(); + } + }); + } + } + + private static String hint(int bits) { + if ((bits & Input.JUMP) != 0) { + return " (Up, W or Space)"; + } + if ((bits & Input.SLIDE) != 0) { + return " (Down or S)"; + } + if ((bits & (Input.LEFT | Input.RIGHT)) != 0) { + return " (Left/Right or A/D)"; + } + return ""; + } + + /// Runner `i`'s place right now, counted from 0: ahead is farther along. + static int placeOf(Run run, int i) { + Runner r = run.runner(i); + int p = 0; + for (int k = 0; k < run.runnerCount(); k++) { + Runner o = run.runner(k); + if (k != i && (o.z > r.z || o.z == r.z && k < i)) { + p++; + } + } + return p; + } + + @Override + public void ended(Run run) { + Runner me = run.runner(driver.me()); + quiet(); + Telemetry.runEnded(mode, run.level().id, placeOf(run, driver.me()), me.z / Track.UNIT, !me.alive); + if ("playtest".equals(mode)) { + result(me.alive ? "FINISHED" : "CRASHED AT " + me.z / Track.UNIT + " M", me.score(), + "Playtests are not submitted", null); + } else if (driver instanceof SoloDriver) { + submit((SoloDriver) driver); + } + // A race waits for the server's END, which carries the trophies. + } + + /// Sends the run's inputs. The server plays them again before it believes + /// the score, then answers with the stars, the best and the rank. + private void submit(SoloDriver d) { + final Runner me = d.run().runner(0); + if (Session.user() == null) { + result("RUN OVER", me.score(), null, null); + return; + } + // A city run carries its route: the server builds the level from it. + Api.forge().result(new LevelResultDto(d.replay().encode(), Levels.citySpec(d.replay().levelId())), Net.to(o -> Ui.later(() -> { + if ("tutorial".equals(mode)) { + Api.progress().tutorialDone(Net.to(r -> { }, (s, m) -> { })); + } + result(o.score() >= o.best() && o.score() > 0 ? "NEW BEST!" : headline(o.stars()), o.score(), + null, o); + }), (s, m) -> Ui.later(() -> result("RUN OVER", me.score(), "Not saved: " + m, null)))); + } + + private static String headline(int stars) { + switch (stars) { + case 3: + return "PERFECT RUN"; + case 2: + return "GREAT RUN"; + case 1: + return "NICE RUN"; + default: + return "RUN OVER"; + } + } + + /// The end of a run: the stars popping in one by one, the score counting + /// up, the best and the rank beside it, and the next run the biggest + /// button on the card. + private void result(String title, int score, String note, final LevelOutcomeDto o) { + view.stop(); + final Runnable[] close = new Runnable[1]; + Container card = new Container(BoxLayout.y()); + card.setUIID("ResultCard"); + card.setName("result"); + card.add(new Label(title, "ResultTitle")); + if (o != null) { + card.add(Kit.stars(o.stars(), 3, 9f)); + } + card.add(Kit.countUp("", score, "ResultScore")); + card.add(new Label("POINTS", "ResultCaption")); + Container chips = new Container(BoxLayout.x()); + if (o != null) { + Label best = new Label("BEST " + o.best(), "StatChip"); + Ui.glyph(best, FontImage.MATERIAL_EMOJI_EVENTS, 3f); + chips.add(best); + if (o.rank() > 0) { + Label rank = new Label("RANK #" + o.rank(), "StatChip"); + Ui.glyph(rank, FontImage.MATERIAL_LEADERBOARD, 3f); + chips.add(rank); + } + } + if (note != null) { + chips.add(new Label(note, "ResultCaption")); + } + card.add(FlowLayout.encloseCenter(chips)); + Button again = Ui.button("RUN AGAIN", "CardGo", "again", e -> { + close[0].run(); + again(); + }); + again.setMaterialIcon(FontImage.MATERIAL_REPLAY, 4.4f); + card.add(again); + Container buttons = new Container(new GridLayout(1, "playtest".equals(mode) ? 1 : 2)); + if (!"playtest".equals(mode)) { + Button scores = Ui.button("Scores", "CardAction", "scores", e -> { + close[0].run(); + driver.dispose(); + ScoresForm.show(driver.run().level().id, null); + }); + Ui.glyph(scores, FontImage.MATERIAL_LEADERBOARD, 3.4f); + buttons.add(scores); + } + Button done = Ui.button("Home", "CardAction", "done", e -> { + close[0].run(); + close(); + }); + Ui.glyph(done, FontImage.MATERIAL_HOME, 3.4f); + buttons.add(done); + card.add(buttons); + final Runnable after = () -> { + close[0] = overlay(card); + Ads.betweenRuns(Ui.adFree()); + }; + if (o != null && o.reward() != null && (o.reward().coins() > 0 || o.reward().xp() > 0)) { + Rewards.show(o.reward(), () -> Rewards.afterRun(after, this::overlay), this::overlay); + } else { + Rewards.afterRun(after, this::overlay); + } + } + + /// Lays `card` over the scene, which stays where the run ended, and + /// answers what takes it away. Not a dialog: a dialog shows a capture of + /// the form behind it, and the GPU scene is not in that capture. + private Runnable overlay(Component card) { + final Container dim = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER)); + dim.setUIID("RunDim"); + // A short phone scrolls the card rather than cutting its buttons off. + Container scroll = BoxLayout.encloseY(card); + scroll.setScrollableY(true); + scroll.setScrollVisible(false); + dim.add(BorderLayout.CENTER, scroll); + layers.add(dim); + form.revalidate(); + return () -> { + layers.removeComponent(dim); + form.revalidate(); + }; + } + + private void again() { + driver.dispose(); + if ("playtest".equals(mode)) { + solo(playtest, driver.run().seed(), mode, null, back); + return; + } + Levels.Playable p = Levels.resolve(((SoloDriver) driver).replay().levelId()); + if (p != null) { + solo(p, driver.run().seed(), mode, null, back); + } else { + close(); + } + } + + @Override + public void emote(String from, String id) { + chat(from, emoteText(id)); + } + + @Override + public void chat(String from, String text) { + chatLog.add(new Label(from + ": " + text, "ChatLine")); + while (chatLog.getComponentCount() > 3) { + chatLog.removeComponent(chatLog.getComponentAt(0)); + } + form.revalidateLater(); + } + + @Override + public void ended(int[] places, int[] scores, int trophies, int coinsWon, String raceId) { + view.stop(); + quiet(); + int mine = driver.me() < places.length ? places[driver.me()] : 0; + Friends.raceFinished(mine == 0); + StringBuilder sb = new StringBuilder(); + for (int p = 0; p < places.length; p++) { + for (int i = 0; i < places.length; i++) { + if (places[i] == p) { + sb.append(HistoryForm.place(p)).append(" ").append(driver.name(i)).append(" ") + .append(i < scores.length ? scores[i] : 0).append('\n'); + } + } + } + sb.append('\n').append(trophies >= 0 ? "+" : "").append(trophies).append(" trophies +") + .append(coinsWon).append(" coins"); + final Runnable[] close = new Runnable[1]; + final Container d = new Container(BoxLayout.y()); + d.setUIID("ResultCard"); + d.setName("raceResult"); + d.add(new Label(HistoryForm.place(mine) + " place", "ResultTitle")); + d.add(Ui.text(sb.toString(), "ResultLine")); + Container buttons = new Container(new GridLayout(1, 2)); + Button replay = Ui.button("Replay", "CardAction", "replayRace", e -> { + close[0].run(); + driver.dispose(); + ReplayForm.load(raceId); + }); + Ui.glyph(replay, FontImage.MATERIAL_REPLAY, 3.4f); + buttons.add(replay); + Button done = Ui.button("Home", "CardAction", "done", e -> { + close[0].run(); + close(); + }); + Ui.glyph(done, FontImage.MATERIAL_HOME, 3.4f); + buttons.add(done); + d.add(buttons); + Rewards.afterRun(() -> close[0] = overlay(d), this::overlay); + } + + @Override + public void lost() { + Ui.toast("The connection to the race was lost."); + close(); + } + + static String emoteText(String id) { + if ("gg".equals(id)) { + return "GG"; + } + if ("wow".equals(id)) { + return "Wow!"; + } + if ("oops".equals(id)) { + return "Oops"; + } + return "Hi!"; + } + + /// For the component inspector and tests: the view under the HUD. + Component view() { + return view; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunnerForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunnerForm.java new file mode 100644 index 00000000000..016b9a606d9 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/RunnerForm.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.api.RunnerDto; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; + +/// The runner the player races as. One is free; the shop sells the others. +/// Cosmetic only: every runner runs by the same rules. +public final class RunnerForm { + /// Every runner, in the order the screen shows them. + static final String[] RUNNERS = {"runner-dash", "runner-nova", "runner-kite"}; + static final String[] NAMES = {"Dash", "Nova", "Kite"}; + + private RunnerForm() { + } + + public static void show() { + final Form f = Ui.form("Runner", "runner"); + Ui.back(f, Nav::home); + final Container grid = new Container(new GridLayout(1, RUNNERS.length)); + final Container stage = new Container(BoxLayout.y()); + Container col = Ui.column(stage, grid); + col.add(Ui.secondary("More runners and trails in the shop", "runnerShop", e -> ShopForm.show())); + f.add(Ui.centeredColumn(col)); + fill(f, grid, stage); + f.show(); + } + + private static void fill(final Form f, final Container grid, final Container stage) { + grid.removeAll(); + stage.removeAll(); + PlayerDto p = Session.user(); + String current = p == null || p.runner() == null ? RUNNERS[0] : p.runner(); + // The runner in play, large and lit, over the line-up to choose from. + for (int i = 0; i < RUNNERS.length; i++) { + if (RUNNERS[i].equals(current)) { + stage.add(Kit.burst(Portraits.figure(Portraits.of(current), CN.convertToPixels(30)), 0x29e0ff)); + Label name = new Label(NAMES[i], "RunnerStageName"); + stage.add(FlowLayout.encloseCenter(name)); + } + } + for (int i = 0; i < RUNNERS.length; i++) { + final String id = RUNNERS[i]; + boolean owned = i == 0 || p != null && p.owned() != null && p.owned().contains(id); + Container card = new Container(new BorderLayout()); + card.setUIID(id.equals(current) ? "CardSlotSelected" : "CardSlot"); + card.setName(id); + card.add(BorderLayout.CENTER, new Label(Portraits.figure(Portraits.of(id), CN.convertToPixels(22)), + "Container")); + Container under = new Container(BoxLayout.y()); + under.add(FlowLayout.encloseCenter(new Label(NAMES[i], "QuestTitle"))); + // Every card ends in the same place: a chip the size of a button + // on the one in play, so the three line up. + if (id.equals(current)) { + Label on = new Label("In play", "RunnerChip"); + on.setMaterialIcon(FontImage.MATERIAL_CHECK_CIRCLE, 3f); + under.add(on); + } else if (owned) { + under.add(Ui.button("Choose", "CardAction", "choose-" + id, e -> choose(f, grid, stage, id))); + } else { + under.add(Ui.button("Shop", "CardAction", "shop-" + id, e -> ShopForm.show())); + } + card.add(BorderLayout.SOUTH, under); + grid.add(card); + } + f.revalidate(); + } + + private static void choose(final Form f, final Container grid, final Container stage, final String id) { + Api.game().saveRunner(new RunnerDto(id), Net.to(r -> Session.refreshUser(() -> Ui.later(() -> { + Telemetry.runnerChosen(id); + fill(f, grid, stage); + }), Ui::failed), Ui::failed)); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ScoresForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ScoresForm.java new file mode 100644 index 00000000000..511daf30ef7 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ScoresForm.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.MultiButton; +import com.codename1.ui.Button; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.RadioButton; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.dash.api.CrewDto; +import com.codenameone.examples.dash.api.ScoreDto; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; + +/// A level's leaderboards. Every score on them was re-simulated by the server +/// from the inputs alone, so a board shows runs that really happened. +/// +/// `all` takes any seed. `week` counts only runs on the week's seed for the +/// level, so everyone met the same track; `day` is the daily dash. `friends` +/// is the player, the friends they invited and their crew mates; a crew's +/// own board is `crew:`. A row opens the run's replay. +public final class ScoresForm { + private static final String[] BOARDS = {"all", "week", "day", "friends"}; + private static final String[] LABELS = {"All time", "Week", "Today", "Friends"}; + + private ScoresForm() { + } + + /// `level` is an official id or `pack/level`. + public static void show(final String level, final Runnable back) { + final Form f = Ui.form("Scores", "scores"); + f.setLayout(new BorderLayout()); + Ui.back(f, back == null ? Nav::home : back); + final Container rows = new Container(BoxLayout.y()); + rows.setScrollableY(true); + Container tabs = new Container(new GridLayout(1, BOARDS.length)); + ButtonGroup group = new ButtonGroup(); + for (int i = 0; i < BOARDS.length; i++) { + final String board = BOARDS[i]; + RadioButton b = RadioButton.createToggle(LABELS[i], group); + b.setUIID("BoardTab"); + b.setName("board-" + board); + b.addActionListener(e -> load(f, rows, level, board)); + tabs.add(b); + if (i == 0) { + b.setSelected(true); + } + } + Container north = BoxLayout.encloseY(tabs); + Api.game().crews(Net.to(crews -> Ui.later(() -> { + for (final CrewDto c : crews) { + Button b = Ui.button(c.name(), "BoardTab", "board-crew-" + c.code(), + e -> load(f, rows, level, "crew:" + c.code())); + north.add(b); + } + f.revalidate(); + }), (s, m) -> { })); + f.add(BorderLayout.NORTH, north); + f.add(BorderLayout.CENTER, rows); + load(f, rows, level, "all"); + f.show(); + } + + private static void load(final Form f, final Container rows, String level, String board) { + Api.game().scores(level, board, 50, Net.to(list -> Ui.later(() -> { + rows.removeAll(); + if (list.isEmpty()) { + rows.add(Ui.text("No runs on this board yet. Be the first.", "Empty")); + } + for (final ScoreDto s : list) { + MultiButton b = new MultiButton("#" + s.rank() + " " + s.displayName()); + b.setUIID(s.rank() <= 3 ? "LeaderTop" : "LeaderRow"); + b.setTextLine2(s.score() + " pts " + s.distance() + " m " + s.coins() + " coins"); + b.setEmblem(Kit.starsImage(s.stars(), com.codename1.ui.CN.convertToPixels(3.4f))); + b.setName("score-" + s.rank()); + if (s.replayId() != null && s.replayId().length() > 0) { + b.addActionListener(e -> Api.game().replay(s.replayId(), Net.to( + r -> Ui.later(() -> ReplayForm.open(r, () -> f.showBack())), Ui::failed))); + } + rows.add(b); + } + f.revalidate(); + }), Ui::failed)); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SettingsForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SettingsForm.java new file mode 100644 index 00000000000..6eb9ab90060 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SettingsForm.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.Switch; +import com.codename1.io.Preferences; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.dash.AppConfig; +import com.codenameone.examples.dash.api.ProfileDto; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.run.Sfx; +import com.codenameone.examples.dash.engage.Notifications; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; + +/// Settings: the runner name, sound, analytics consent, push, the server. +public final class SettingsForm { + static final String SOUND_KEY = "dash.sound"; + + private SettingsForm() { + } + + /// Applies what was chosen in an earlier run. + public static void apply() { + Sfx.setMuted(!Preferences.get(SOUND_KEY, true)); + } + + public static void show() { + Form f = Ui.form("Settings", "settings"); + Ui.back(f, Nav::home); + final TextField name = new TextField(Session.user().displayName(), "Runner name", 16, TextField.ANY); + name.setUIID("NameField"); + name.setName("displayName"); + f.add(Ui.label("Runner name", "SectionTitle")); + f.add(Ui.panel(name, Ui.secondary("Rename", "rename", e -> Api.account().saveProfile( + new ProfileDto(name.getText().trim()), Net.to(p -> { + Session.setUser(p); + Ui.toast("Renamed"); + }, Ui::failed))))); + f.add(Ui.label("Game", "SectionTitle")); + f.add(Ui.panel( + toggle("Sound", "sound", !Sfx.isMuted(), on -> { + Preferences.set(SOUND_KEY, on); + Sfx.setMuted(!on); + }), + toggle("On-screen buttons", "touchButtons", Preferences.get(RunView.BUTTONS_KEY, true), + on -> Preferences.set(RunView.BUTTONS_KEY, on)), + toggle("Share anonymous usage", "analytics", Telemetry.allowed(), Telemetry::allow), + toggle("Notifications", "push", Notifications.enabled(), Notifications::setEnabled))); + if (AppConfig.serverSwitchable()) { + f.add(Ui.label("Server", "SectionTitle")); + f.add(Ui.secondary("Server: " + AppConfig.serverUrl(), "serverButton", e -> chooseServer())); + } + f.add(Ui.button("Sign out", "DangerButton", "signOut", e -> Nav.signOut())); + f.show(); + } + + interface Flipped { + void flipped(boolean on); + } + + private static Container toggle(String text, String name, boolean value, final Flipped flipped) { + final Switch s = new Switch(); + s.setName(name); + s.setValue(value); + s.addActionListener(e -> flipped.flipped(s.isValue())); + Container row = new Container(new BorderLayout()); + row.setUIID("SettingRow"); + row.add(BorderLayout.CENTER, new Label(text, "SettingLabel")).add(BorderLayout.EAST, s); + return row; + } + + /// Points the app at another server: a phone reaching the developer's + /// laptop, or a test server. + public static void chooseServer() { + TextField url = new TextField(AppConfig.serverUrl(), "http://192.168.1.10:8080", 30, TextField.URL); + url.setName("serverUrl"); + if (Dialog.show("Server", url, new com.codename1.ui.Command("Cancel"), + new com.codename1.ui.Command("Use")).getCommandName().equals("Use")) { + AppConfig.setServerUrl(url.getText().trim()); + Session.start(AppConfig.serverUrl()); + SignInForm.show(); + } + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ShopForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ShopForm.java new file mode 100644 index 00000000000..d1399586287 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/ShopForm.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.CN; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.dash.api.BuyDto; +import com.codenameone.examples.dash.api.EquipDto; +import com.codenameone.examples.dash.api.ShopItemDto; +import com.codenameone.examples.dash.api.WalletDto; +import com.codenameone.examples.dash.engage.Store; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Api; +import com.codenameone.examples.dash.net.Net; +import com.codenameone.examples.dash.net.Session; +import java.util.List; + +/// The shop. Gems and the Royal Pass are real-money purchases through the +/// platform store ([Store]); runners, trails and emotes cost coins or gems and are +/// bought from the game's own server. Either way the server holds the wallet. +public final class ShopForm { + private ShopForm() { + } + + public static void show() { + final Form f = Ui.form("Shop", "shop"); + Ui.back(f, Nav::home); + Store.listen(wallet -> { + Ui.toast("Purchase complete"); + Session.refreshUser(() -> Ui.later(ShopForm::show), Ui::failed); + }); + Api.shop().wallet(Net.to(wallet -> Api.shop().items(Net.to(items -> Ui.later(() -> { + if (Session.user() == null) { + // Signed out while the shop was loading. + return; + } + // The wallet just fetched, not the player cached at sign-in: + // the shop is where the balance has to be right. + f.add(Ui.wallet(Session.user().trophies(), wallet.coins(), wallet.gems())); + fill(f, items, wallet); + f.revalidate(); + }), Ui::failed)), Ui::failed)); + f.show(); + } + + private static void fill(Form f, List items, WalletDto wallet) { + for (final ShopItemDto item : items) { + boolean owned = wallet.owned() != null && wallet.owned().contains(item.id()); + Container row = new Container(new BorderLayout()); + row.setUIID("ShopItem"); + row.add(BorderLayout.WEST, new Label(Kit.shopArt(item.id(), CN.convertToPixels(13)), "ShopArt")); + Container text = new Container(BoxLayout.y()); + text.add(Ui.text(item.name(), "ShopItemName")); + text.add(new Label(kindOf(item), "ShopItemKind")); + row.add(BorderLayout.CENTER, text); + if (item.sku() != null && item.sku().length() > 0) { + if (owned) { + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(new Label("Owned", "Owned"))); + } else { + String price = Store.price(item.sku()); + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(Ui.button(price == null ? "Buy" : price, + "GemButton", "buy-" + item.id(), + e -> Store.buy(item.sku())))); + } + } else if (owned) { + boolean on = item.id().equals(wallet.outfit()); + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(item.id().startsWith("trail-") && !on + ? Ui.button("Use", "GhostButton", "equip-" + item.id(), e -> equip(item.id())) + : new Label(on ? "In use" : "Owned", "Owned"))); + } else { + String price = item.priceGems() > 0 ? item.priceGems() + " gems" : item.priceCoins() + " coins"; + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(Ui.button(price, "PriceButton", "buy-" + item.id(), + e -> buy(item)))); + } + f.add(row); + } + } + + private static String kindOf(ShopItemDto item) { + if (item.id().startsWith("runner-")) { + return "RUNNER"; + } + if (item.id().startsWith("trail-")) { + return "TRAIL"; + } + if ("gems-500".equals(item.id())) { + // The biggest pack is the one that is cheapest per gem. + return "BEST VALUE"; + } + if (item.id().startsWith("gems-")) { + return "GEM PACK"; + } + return item.sku() != null && item.sku().length() > 0 ? "SEASON PASS" : "ITEM"; + } + + private static void buy(ShopItemDto item) { + Api.shop().buy(new BuyDto(item.id()), Net.to(wallet -> { + Telemetry.itemBought(item.id(), item.priceGems() > 0 ? "gems" : "coins"); + Session.refreshUser(() -> Ui.later(ShopForm::show), Ui::failed); + }, Ui::failed)); + } + + private static void equip(String outfit) { + Api.shop().equip(new EquipDto(outfit), Net.to(wallet -> Session.refreshUser(() -> Ui.later(ShopForm::show), + Ui::failed), Ui::failed)); + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SignInForm.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SignInForm.java new file mode 100644 index 00000000000..2d2f1d99c67 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/SignInForm.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.dash.net.Session; + +/// Signing in, and the way to the practice arena without an account. +public final class SignInForm { + private SignInForm() { + } + + public static void show() { + Form f = Ui.form("", "signin"); + f.setLayout(new com.codename1.ui.layouts.LayeredLayout()); + f.setScrollable(false); + f.add(Kit.backdrop()); + f.getToolbar().hideToolbar(); + Container col = Ui.column(); + col.add(heroes()); + col.add(Ui.label("DASH", "Logo")); + col.add(FlowLayout.encloseCenter(Ui.label("Run. Race. Build.", "Tagline"))); + final TextField email = field("Email", TextField.EMAILADDR, "email"); + final TextField password = field("Password", TextField.PASSWORD, "password"); + // Fields and both buttons share one panel, so they share one width. + // The guest run and the server switch sit inside the panel too, so + // nothing reads over the backdrop's track. + Button guest = Ui.button("Try a level without an account", "LinkButton", "practiceButton", + e -> RunForm.solo(com.codenameone.examples.dash.run.Levels.official( + com.codenameone.examples.dash.rules.Content.CAUSEWAY), 1L, "guest", null, SignInForm::show)); + Container panel = Ui.panel(email, password, + Ui.primary("Sign in", "signInButton", e -> { + Session.signIn(email.getText().trim(), password.getText(), + () -> Ui.later(Nav::signedIn), Ui::failed); + }), + Ui.secondary("Create an account", "registerButton", e -> RegisterForm.show()), guest); + // Which server the app talks to is a setting, not something every + // player meets before signing in: it is under Settings, signed in. + col.add(panel); + Container safe = Ui.centeredColumn(col); + safe.setSafeArea(true); + Container front = new Container(BoxLayout.yCenter()); + front.setScrollableY(true); + front.add(safe); + f.add(front); + f.show(); + } + + /// Three runners mid-stride, the middle one in front: the first thing a + /// new player sees is who they will race. + static Container heroes() { + int h = CN.convertToPixels(26); + Label left = new Label(Portraits.figure("runner-nova", h * 4 / 5), "SignInHero"); + Label mid = new Label(Portraits.figure("runner-dash", h), "SignInHero"); + Label right = new Label(Portraits.figure("runner-kite", h * 4 / 5), "SignInHero"); + Container c = new Container(new FlowLayout(Component.CENTER, Component.BOTTOM)); + c.add(left).add(mid).add(right); + c.setName("heroes"); + return c; + } + + static TextField field(String hint, int constraint, String name) { + TextField t = new TextField("", hint, 20, constraint); + t.setUIID("GameField"); + t.setName(name); + return t; + } +} diff --git a/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Ui.java b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Ui.java new file mode 100644 index 00000000000..fe079b39f37 --- /dev/null +++ b/scripts/dash/common/src/main/java/com/codenameone/examples/dash/ui/Ui.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.components.ToastBar; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codenameone.examples.dash.api.PlayerDto; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.net.Session; + +/// The few building blocks every screen is made of. None of them draws +/// anything itself: each one only names a UIID, and `theme.css` decides what a +/// `PrimaryButton`, a `Panel` or a `Banner` looks like. Re-skinning the game is +/// editing that file, not this one. +// tag::dash-ui[] +public final class Ui { + /// The widest a column of fields and buttons grows, in millimetres. + public static final int MAX_COLUMN_MM = 90; + + private Ui() { + } + + /// A screen: a scrolling column on the game's backdrop, its name reported + /// to analytics and given to the form so tests can find it. + public static Form form(String title, String name) { + Form f = new Form(title, BoxLayout.y()); + f.setName(name); + f.setUIID("GameForm"); + f.getToolbar().setUIID("GameToolbar"); + f.getToolbar().getTitleComponent().setUIID("GameTitle"); + f.setScrollableY(true); + Telemetry.screen(name); + return f; + } + + /// Adds a back command that leaves through `leave`. + public static void back(Form f, final Runnable leave) { + Command back = new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + leave.run(); + } + }; + f.getToolbar().setBackCommand(back); + } + + public static Label label(String text, String uiid) { + return new Label(text, uiid); + } + + public static SpanLabel text(String text, String uiid) { + SpanLabel l = new SpanLabel(text); + l.setUIID("Container"); + l.setTextUIID(uiid); + return l; + } + + public static Button button(String text, String uiid, String name, ActionListener onClick) { + Button b = new Button(text, uiid); + b.setName(name); + if (onClick != null) { + b.addActionListener(onClick); + } + return b; + } + + /// The bright, rounded call to action of a screen. + public static Button primary(String text, String name, ActionListener onClick) { + return button(text, "PrimaryButton", name, onClick); + } + + /// The outlined button for everything secondary. + public static Button secondary(String text, String name, ActionListener onClick) { + return button(text, "GhostButton", name, onClick); + } + + /// Sets a material icon drawn on nothing, so a label with a translucent + /// background doesn't get a darker square behind its icon. + public static void glyph(com.codename1.ui.Label l, char icon, float mm) { + com.codename1.ui.plaf.Style st = new com.codename1.ui.plaf.Style(l.getUnselectedStyle()); + st.setBgTransparency(0); + st.setBorder(null); + l.setIcon(com.codename1.ui.FontImage.createMaterial(icon, st, mm)); + } + + public static Container panel(Component... children) { + Container c = BoxLayout.encloseY(children); + c.setUIID("Panel"); + return c; + } + + /// Equal cells side by side, with a gap between them and none at the + /// outer edges, so a row lines up with the full-width buttons above it. + public static Container row(Component... children) { + Container c = new Container(new GridLayout(1, children.length)); + for (int i = 0; i < children.length; i++) { + Style s = children[i].getAllStyles(); + s.setMarginUnitLeft(Style.UNIT_TYPE_DIPS); + s.setMarginUnitRight(Style.UNIT_TYPE_DIPS); + s.setMarginLeft(i == 0 ? 0f : 0.8f); + s.setMarginRight(i == children.length - 1 ? 0f : 0.8f); + c.add(children[i]); + } + return c; + } + + /// A column no wider than a phone held upright, centred on wider screens, + /// with the same gutter on both sides. Fields and buttons inside it all + /// fill its width, so they line up. + public static Container column(Component... children) { + Container col = new Container(BoxLayout.y()) { + @Override + protected Dimension calcPreferredSize() { + Dimension d = super.calcPreferredSize(); + int gutter = CN.convertToPixels(4) * 2; + d.setWidth(Math.min(CN.convertToPixels(MAX_COLUMN_MM), CN.getDisplayWidth() - gutter)); + return d; + } + }; + for (Component child : children) { + col.add(child); + } + return col; + } + + /// `column` centred in the space it is given. + public static Container centeredColumn(Container column) { + return BorderLayout.centerCenter(column); + } + + /// A rounded bar filled to `value` of `max`, for experience and quests. + public static Component bar(final int value, final int max, final int color) { + return new Component() { + @Override + public void paint(Graphics g) { + int h = getHeight(); + int w = getWidth(); + g.setAntiAliased(true); + g.setColor(0x0e1224); + g.fillRoundRect(getX(), getY(), w, h, h, h); + int fill = max <= 0 ? 0 : (int) ((long) Math.max(0, Math.min(value, max)) * (w - 2) / max); + if (fill > 0) { + g.setColor(color); + g.fillRoundRect(getX() + 1, getY() + 1, Math.max(h - 2, fill), h - 2, h - 2, h - 2); + g.setColor(0xffffff); + g.setAlpha(70); + g.fillRoundRect(getX() + 2, getY() + 1, Math.max(h - 4, fill - 2), (h - 2) / 2, h / 2, h / 2); + g.setAlpha(255); + } + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(20), CN.convertToPixels(2.2f)); + } + }; + } + + public static Container centered(Component... children) { + return FlowLayout.encloseCenter(children); + } + + /// The trophies, coins and gems strip at the top of the menus. + public static Container wallet(PlayerDto p) { + return wallet(p.trophies(), p.coins(), p.gems()); + } + + /// The same bar for balances fresher than the signed-in player's. + public static Container wallet(int trophies, int coins, int gems) { + Container c = new Container(new GridLayout(1, 3)); + c.setUIID("WalletBar"); + c.add(stat(FontImage.MATERIAL_EMOJI_EVENTS, trophies, "Trophies")); + c.add(stat(FontImage.MATERIAL_MONETIZATION_ON, coins, "Coins")); + c.add(stat(FontImage.MATERIAL_DIAMOND, gems, "Gems")); + return c; + } + + private static Label stat(char icon, int value, String uiid) { + Label l = new Label(String.valueOf(value), uiid); + l.setName(uiid); + l.setMaterialIcon(icon); + return l; + } + + public static void toast(String text) { + ToastBar.showMessage(text, FontImage.MATERIAL_INFO, 2500); + } + + public static void failed(int status, String message) { + toast(message == null || message.length() == 0 ? "Something went wrong (" + status + ")" : message); + } + + public static PlayerDto me() { + return Session.user(); + } + + public static boolean adFree() { + PlayerDto p = Session.user(); + return p != null && p.owned() != null && p.owned().contains("royal-pass"); + } + + public static void later(Runnable r) { + CN.callSerially(r); + } +} +// end::dash-ui[] diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AccountTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AccountTest.java new file mode 100644 index 00000000000..453eafd6c10 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AccountTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.ui.Nav; + +/// Registering with a runner name, the first level, choosing a runner, the +/// settings and signing out. +public class AccountTest extends E2e { + @Override + protected boolean run() throws Exception { + shot("signin"); + click("registerButton"); + waitForForm("register"); + String stamp = String.valueOf(System.currentTimeMillis() % 100000); + type("displayName", "Ironclad"); + type("email", "new" + stamp + "@dash.example"); + type("password", "a-long-password"); + shot("register-name"); + click("createButton"); + waitForForm("home"); + // A new player is taken straight into the first level. + waitForForm("run"); + final RunView view = track(); + shot("tutorial-first-visit"); + onEdt(() -> { + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + assertEqual("Ironclad", Session.user().displayName()); + assertEqual("runner-dash", Session.user().runner() == null ? "runner-dash" : Session.user().runner(), "a new player runs as Dash"); + shot("home"); + + // Picking another runner saves it on the account. + click("runnerButton"); + waitForForm("runner"); + shot("runner"); + if (has("choose-runner-kite")) { + click("choose-runner-kite"); + until(() -> "runner-kite".equals(Session.user().runner()), 10000, "the runner to be saved"); + } + + onEdt(() -> com.codenameone.examples.dash.ui.SettingsForm.show()); + waitForForm("settings"); + shot("settings"); + click("signOut"); + waitForForm("signin"); + assertNull(Session.user()); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AdminTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AdminTest.java new file mode 100644 index 00000000000..514af6080c1 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/AdminTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.net.Session; + +/// God mode: the overview, the flag queue with the demo's flagged player, +/// and that player's file. +public class AdminTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(ADMIN); + waitForForm("home"); + assertTrue(Session.user().admin()); + click("adminButton"); + waitForForm("admin"); + until(() -> has("stat-Open flags"), 20000, "the numbers"); + shot("admin-overview"); + onEdt(() -> ((com.codename1.ui.Tabs) findByName("adminTabs")).setSelectedIndex(8)); + until(() -> has("promoteTitle"), 20000, "the Promote tab"); + shot("admin-promote"); + onEdt(() -> ((com.codename1.ui.Tabs) findByName("adminTabs")).setSelectedIndex(1)); + until(() -> findFlag() != null, 20000, "a flag in the queue"); + shot("admin-flags"); + touch(findFlag()); + waitForForm("adminPlayer"); + until(() -> has("act-SUSPEND"), 20000, "the actions"); + shot("admin-player"); + return true; + } + + private String findFlag() { + com.codename1.ui.Form f = com.codename1.ui.Display.getInstance().getCurrent(); + return find(f); + } + + private static String find(com.codename1.ui.Container c) { + for (int i = 0; i < c.getComponentCount(); i++) { + com.codename1.ui.Component k = c.getComponentAt(i); + if (k.getName() != null && k.getName().startsWith("flag-")) { + return k.getName(); + } + if (k instanceof com.codename1.ui.Container) { + String inner = find((com.codename1.ui.Container) k); + if (inner != null) { + return inner; + } + } + } + return null; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java new file mode 100644 index 00000000000..8c29e549301 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.maps.LatLng; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.rules.CityRoute; +import com.codenameone.examples.dash.run.Levels; +import com.codenameone.examples.dash.run.Landmarks; +import com.codename1.maps.routing.Route; +import com.codename1.maps.routing.RouteCallback; +import com.codename1.maps.routing.RouteLeg; +import com.codename1.maps.routing.RouteRequest; +import com.codename1.maps.routing.RouteService; +import com.codename1.maps.routing.RouteStep; +import com.codename1.maps.routing.Routing; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codenameone.examples.dash.run.RunView; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// A city run: two taps on the street map, the route between them, and the +/// run down it with the minimap lit as it goes. The route comes from a +/// stand-in service, so the test never leans on the public routing server. +public class CityRunTest extends E2e { + @Override + protected boolean run() throws Exception { + final RouteService real = Routing.getService(); + Routing.setService(new Canned()); + Landmarks.setSource(new CannedPlaces()); + try { + signIn(PLAYER); + waitForForm("home"); + click("runButton"); + waitForForm("levels"); + touch("cityButton"); + waitForForm("city"); + until(() -> findByName("cityMap") != null && findByName("cityMap").getWidth() > 0, 20000, "the map"); + // Give the street tiles a moment to arrive for the screenshot. + waitFor(4000); + Component map = findByName("cityMap"); + int x = map.getAbsoluteX(); + int y = map.getAbsoluteY(); + touch(x + map.getWidth() / 4, y + map.getHeight() * 2 / 3); + waitFor(500); + touch(x + map.getWidth() * 3 / 4, y + map.getHeight() / 4); + until(() -> ((Button) findByName("runCity")).isEnabled(), 20000, "the route"); + waitFor(2500); + shot("city-route"); + click("runCity"); + waitForForm("run"); + final RunView view = track(); + while (view.driver().run().tick() < 60 * 12 && !driveFor(view, 200)) { + // the bot steers for the first stretch + } + assertTrue(has("minimap"), "the route's minimap"); + assertTrue(has("street"), "the street the runner is on"); + shot("city-run", view); + // Just short of the first landmark, with its board in view. + CityRoute route = Levels.cityRoute(view.driver().run().level().id); + assertTrue(route != null && route.landmarks().size() >= 3, "landmarks along the route"); + int mark = -1; + for (CityRoute.Sign sign : route.signs()) { + if (sign.kind >= 0 && sign.metres > 120) { + mark = sign.metres; + break; + } + } + final int ahead = mark - 45; + long give = System.currentTimeMillis() + 60000; + while (view.driver().run().runner(view.driver().me()).z / Track.UNIT < ahead + && System.currentTimeMillis() < give && !driveFor(view, 100)) { + // the bot steers up to the landmark + } + assertTrue(!view.driver().run().over(), "still running at the landmark"); + shot("city-landmark", view); + finish(view, 60 * 900); + awaitResult("the result"); + shot("city-result"); + click("done"); + return true; + } finally { + Routing.setService(real); + Landmarks.setSource(new Landmarks.Overpass("https://overpass-api.de/api/interpreter")); + } + } + + /// Three streets from wherever the start is: east, a left turn north, + /// and a right turn east again to the finish. + static final class Canned implements RouteService { + @Override + public String getId() { + return "canned"; + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public void findRoutes(RouteRequest request, final RouteCallback callback) { + LatLng a = request.getOrigin(); + // East, north, east again, ending where the finish was tapped so + // the line on the map meets both pins. + LatLng d = request.getDestination(); + double share = 520.0 / (520 + 460); + double midLon = a.getLongitude() + (d.getLongitude() - a.getLongitude()) * share; + LatLng b = new LatLng(a.getLatitude(), midLon); + LatLng c = new LatLng(d.getLatitude(), midLon); + List steps = new ArrayList(); + steps.add(new RouteStep("Head east", "Cannon Street", 520, 400, a, Arrays.asList(a, b))); + steps.add(new RouteStep("Turn left", "Moorgate", 380, 300, b, Arrays.asList(b, c))); + steps.add(new RouteStep("Turn right", "London Wall", 460, 350, c, Arrays.asList(c, d))); + steps.add(new RouteStep("Arrive", "London Wall", 0, 0, d, Arrays.asList(d))); + final Route r = new Route(Arrays.asList(a, b, c, d), + Arrays.asList(new RouteLeg("", 1360, 1050, steps)), 1360, 1050, ""); + CN.callSerially(() -> callback.routesFound(Arrays.asList(r))); + } + } + + /// Real places of the City of London, set along the canned route. + static final class CannedPlaces implements Landmarks.Source { + private static final Object[][] PLACES = { + {0.12, CityRoute.WORSHIP, "St Mary-le-Bow"}, + {0.3, CityRoute.MUSEUM, "Bank of England Museum"}, + {0.48, CityRoute.MONUMENT, "Guildhall"}, + {0.62, CityRoute.STATION, "Moorgate Station"}, + {0.8, CityRoute.PARK, "Finsbury Circus Gardens"}, + }; + + @Override + public void find(List path, Landmarks.Callback done) { + List out = new ArrayList(); + for (Object[] p : PLACES) { + double t = (Double) p[0] * (path.size() - 1); + int i = Math.min(path.size() - 2, (int) t); + double f = t - i; + LatLng a = path.get(i); + LatLng b = path.get(i + 1); + LatLng at = new LatLng(a.getLatitude() + (b.getLatitude() - a.getLatitude()) * f, + a.getLongitude() + (b.getLongitude() - a.getLongitude()) * f); + out.add(new Landmarks.Place(at, (Integer) p[1], (String) p[2])); + } + CN.callSerially(() -> done.found(out)); + } + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java new file mode 100644 index 00000000000..b160536b9e8 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.TokenStore; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.TextArea; +import com.codename1.ui.util.ImageIO; +import com.codename1.util.AsyncResource; +import com.codenameone.examples.dash.engage.Telemetry; +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.api.GameProtocol; +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.run.Driver; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.run.Sfx; +import com.codenameone.examples.dash.net.Session; +import com.codenameone.examples.dash.ui.Nav; +import java.io.OutputStream; + +/// What every end-to-end test shares: a fresh session against the test +/// server, ways to press and type by component name, and screenshots. +/// +/// The screenshots are the ones the developer guide's "Make it your own" +/// chapter shows; `run-e2e.sh` writes them to `target/e2e-shots`. +public abstract class E2e extends AbstractTest { + public static final String PASSWORD = "dash-demo"; + public static final String PLAYER = "player@dash.example"; + public static final String RIVAL = "rival@dash.example"; + public static final String ADMIN = "admin@dash.example"; + + protected interface Check { + boolean ok(); + } + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + @Override + public int getTimeoutMillis() { + return 180000; + } + + protected abstract boolean run() throws Exception; + + /// Whether this run of the tests is the one in a desktop window. + /// + /// `run-e2e.sh` runs the tests twice: on the simulator's phone, and then, + /// on Linux, with the simulator as a desktop, where the admin console is + /// a side bar and a page. A test belongs to one of the two -- see + /// [#desktop()] -- and passes without doing anything in the other. + static boolean desktopRun() { + return "true".equals(System.getProperty("dash.e2e.desktop")); + } + + /// Whether this test is one for the desktop window. The phone's are not. + protected boolean desktop() { + return false; + } + + private boolean mine() { + return desktop() == desktopRun(); + } + + @Override + public final boolean runTest() throws Exception { + if (!mine()) { + log("Not a test for " + (desktopRun() ? "the desktop" : "the phone") + "; skipped"); + return true; + } + if (desktopRun()) { + desktopWindow(1440, 900); + } + return run(); + } + + /// The desktop pass shoots a laptop-sized window. The simulator opens a + /// desktop at whatever size the content asks for, which for a phone app + /// is small; the window is sized here, and the form lays itself out again + /// at the new size as it would after a user resized it. + private void desktopWindow(int width, int height) throws Exception { + javax.swing.SwingUtilities.invokeAndWait(() -> { + for (java.awt.Frame f : java.awt.Frame.getFrames()) { + if (f.isShowing()) { + f.setExtendedState(java.awt.Frame.NORMAL); + f.setSize(width, height); + f.validate(); + } + } + }); + waitFor(1200); + } + + @Override + public void prepare() { + if (!mine()) { + return; + } + Session.useStore(new MemoryStore()); + onEdt(() -> { + Telemetry.allow(false); + Sfx.setMuted(true); + AppConfig.setMode("player"); + Session.start(AppConfig.serverUrl()); + Nav.launch(); + }); + waitForForm("signin"); + } + + @Override + public void cleanup() { + if (!mine()) { + return; + } + onEdt(() -> Session.start(AppConfig.serverUrl())); + } + + protected void onEdt(Runnable work) { + CN.callSeriallyAndWait(work); + } + + /// Presses a button, or taps anything else by that name -- a list row is + /// a `MultiButton`, which is a container holding its button. + protected void click(final String name) { + until(() -> findByName(name) != null, 20000, "a button called " + name); + if (!(findByName(name) instanceof Button)) { + touch(name); + return; + } + onEdt(() -> { + Button button = (Button) findByName(name); + button.pressed(); + button.released(); + }); + } + + protected void type(final String name, final String text) { + until(() -> findByName(name) instanceof TextArea, 20000, "a field called " + name); + onEdt(() -> ((TextArea) findByName(name)).setText(text)); + } + + protected boolean has(String name) { + return findByName(name) != null; + } + + protected void until(Check check, int timeoutMillis, String what) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (!check.ok()) { + if (System.currentTimeMillis() > deadline) { + Form current = Display.getInstance().getCurrent(); + shot("timed-out-" + (current == null ? "no-form" : current.getName())); + throw new RuntimeException("Timed out waiting for " + what + " (on " + + (current == null ? "no form" : current.getName()) + ")"); + } + waitFor(100); + } + } + + protected void waitForForm(final String name) { + until(() -> { + Form current = Display.getInstance().getCurrent(); + return current != null && name.equals(current.getName()); + }, 30000, "the " + name + " screen"); + waitFor(150); + } + + protected void signIn(String email) { + type("email", email); + type("password", PASSWORD); + click("signInButton"); + until(() -> Session.user() != null, 30000, "the sign-in"); + } + + protected RunView track() { + until(() -> findByName("track") instanceof RunView, 20000, "the track"); + return (RunView) findByName("track"); + } + + /// Runs the level with the bot's thumbs: before every tick, whatever the + /// bot would press goes through the same `input` a swipe reaches. A test + /// still asserts that the run ended, never that it was won. + protected void drive(final RunView view, int timeoutMillis) { + if (!driveFor(view, timeoutMillis)) { + assertTrue(false, "the run did not end in " + timeoutMillis + " ms"); + } + } + + /// Steers with the bot for `millis` of real time, or until the run ends, + /// so a test waiting on the screen does not leave the runner to crash. + /// Answers whether the run ended. + protected boolean driveFor(final RunView view, int millis) { + // A race applies an input when the server has stamped it, at least + // INPUT_DELAY ticks on, so the bot looks that much further ahead. + final Bot bot = new Bot(7, 90, view.driver() instanceof RaceDriver ? GameProtocol.INPUT_DELAY + 4 : 0); + onEdt(() -> view.setBeforeTick(() -> { + Run run = view.driver().run(); + if (!run.over()) { + view.driver().input(bot.decide(run, view.driver().me())); + } + })); + try { + long end = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < end) { + final boolean[] over = new boolean[1]; + onEdt(() -> over[0] = view.driver().run().over()); + if (over[0]) { + return true; + } + waitFor(16); + } + } finally { + onEdt(() -> view.setBeforeTick(null)); + } + return false; + } + + /// Plays the rest of the run at once, the bot choosing every tick, so a + /// long level costs a test a moment instead of minutes of real time. The + /// view then sees a finished run on its next frame, the way it would at + /// the end of a real one. `maxTicks` stops an endless level. + protected void finish(final RunView view, final int maxTicks) { + onEdt(() -> { + Bot bot = new Bot(7, 90); + Driver d = view.driver(); + Run run = d.run(); + int limit = run.tick() + maxTicks; + while (!run.over() && d.canStep() && run.tick() < limit) { + d.input(bot.decide(run, d.me())); + d.step(); + } + }); + } + + /// Waits for the result screen, collecting the reward that a run which + /// earned something shows first. + protected void awaitResult(String what) { + until(() -> has("result") || has("collectReward"), 20000, what); + // A run can earn more than one card -- the level's reward, then a + // level up -- and each is collected in turn. + boolean first = true; + while (!has("result")) { + if (has("collectReward")) { + if (first) { + shot("run-reward"); + first = false; + } + click("collectReward"); + waitFor(600); + } + until(() -> has("result") || has("collectReward"), 20000, what); + } + } + + /// A real touch: press and release at a point on the screen, the way a + /// finger would, so it reaches the form's pointer listeners. + protected void touch(final int x, final int y) { + onEdt(() -> { + Form f = Display.getInstance().getCurrent(); + f.pointerPressed(x, y); + f.pointerReleased(x, y); + }); + } + + protected void touch(String name) { + until(() -> findByName(name) != null, 20000, name); + // A tap lands only on what is on screen: scroll a row below the fold + // into view first. + onEdt(() -> { + Component k = findByName(name); + if (k.getComponentForm() != null) { + k.getComponentForm().scrollComponentToVisible(k); + } + }); + waitFor(600); + Component c = findByName(name); + touch(c.getAbsoluteX() + c.getWidth() / 2, c.getAbsoluteY() + c.getHeight() / 2); + } + + // -------------------------------------------------------- screenshots + + protected void shot(String name) { + String dir = System.getProperty("dash.shots.dir"); + if (dir == null || dir.length() == 0) { + return; + } + waitFor(1200); + capture(dir, name); + } + + /// Whether the stretch the camera shows next -- 10 to 70 m ahead -- + /// holds at least `items` coins, power-ups and obstacles, so a shot of + /// the track has something on it. + protected static boolean busyAhead(RunView view, int items) { + Run run = view.driver().run(); + int z = run.runner(view.driver().me()).z; + Track t = run.track(); + int n = 0; + for (int i = 0; i < t.size(); i++) { + Track.Entry e = t.get(i); + if (e.z > z + 10 * Track.UNIT && e.z < z + 70 * Track.UNIT) { + n++; + } + } + return n >= items; + } + + /// Lets the bot run, for at most `millis`, until the track ahead is busy. + protected void driveToBusy(RunView view, int items, int millis) { + long give = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < give && !busyAhead(view, items) && !driveFor(view, 100)) { + // the bot steers + } + } + + /// A shot of a run in progress: the bot keeps steering while the frame + /// settles, or the runner would be on the floor by the time it is taken. + protected void shot(String name, RunView view) { + String dir = System.getProperty("dash.shots.dir"); + if (dir == null || dir.length() == 0) { + return; + } + driveFor(view, 1200); + capture(dir, name); + } + + private void capture(String dir, String name) { + String out = dir.startsWith("file:") ? dir : "file://" + dir; + final Image[] captured = new Image[1]; + onEdt(() -> captured[0] = Display.getInstance().captureScreen()); + FileSystemStorage files = FileSystemStorage.getInstance(); + try { + files.mkdir(out); + OutputStream png = files.openOutputStream(out + "/" + name + ".png"); + try { + ImageIO.getImageIO().save(captured[0], png, ImageIO.FORMAT_PNG, 1f); + } finally { + png.close(); + } + } catch (java.io.IOException err) { + log("Could not save " + name + ": " + err); + } + } + + static final class MemoryStore implements TokenStore { + private OidcTokens kept; + + @Override + public AsyncResource load(String key) { + AsyncResource out = new AsyncResource(); + out.complete(kept); + return out; + } + + @Override + public AsyncResource save(String key, OidcTokens tokens) { + kept = tokens; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + + @Override + public AsyncResource clear(String key) { + kept = null; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ForgeTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ForgeTest.java new file mode 100644 index 00000000000..a9a82b83bc8 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ForgeTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.ui.Component; +import com.codenameone.examples.dash.forge.PackEditor; +import com.codenameone.examples.dash.forge.TrackTimeline; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.forge.Samples; +import com.codenameone.examples.dash.run.RunView; + +/// The track editor: the official pack, then a new pack with a level built +/// on the timeline -- a hurdle painted onto a lane by touch -- and a playtest +/// of the draft. +public class ForgeTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("forgeButton"); + waitForForm("forge"); + until(() -> has("pack-" + Samples.OFFICIAL_ID), 20000, "the official pack"); + shot("forge-home"); + click("pack-" + Samples.OFFICIAL_ID); + waitForForm("pack"); + until(() -> has("level-" + Content.CAUSEWAY), 20000, "the official levels"); + shot("forge-pack"); + + onEdt(() -> PackEditor.create("Frost Pass")); + waitForForm("packEditor"); + shot("forge-editor"); + click("addLevel"); + waitForForm("levelEditor"); + click("tool-" + Content.HURDLE); + final TrackTimeline timeline = (TrackTimeline) findByName("timeline"); + onEdt(() -> timeline.getComponentForm().scrollComponentToVisible(timeline)); + waitFor(300); + // The second lane, a little way down the track. + Component t = timeline; + int rows = 2 + 3; + touch(t.getAbsoluteX() + t.getWidth() / 8 - t.getScrollX(), t.getAbsoluteY() + t.getHeight() * 2 / rows + + t.getHeight() / (2 * rows)); + waitFor(300); + shot("forge-level-editor"); + click("playtest"); + waitForForm("run"); + final RunView view = track(); + until(() -> view.driver().run().tick() > 240, 20000, "the playtest"); + shot("forge-playtest"); + finish(view, 60 * 600); + awaitResult("the playtest result"); + click("done"); + waitForForm("levelEditor"); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/FriendsTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/FriendsTest.java new file mode 100644 index 00000000000..065173fb740 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/FriendsTest.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +/// Invite a friend: the player's code, what an invite pays, and who joined. +public class FriendsTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("friendsButton"); + waitForForm("friends"); + until(() -> has("inviteCode") && has("inviteButton"), 20000, "the invite code"); + shot("friends"); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/Guest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/Guest.java new file mode 100644 index 00000000000..e08b6903ec3 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/Guest.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.io.WebSocket; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// A second player for a test that needs company: signs in through the same +/// OAuth steps the app takes, opens its own game socket and says what the +/// test tells it to. The app under test is the other player. +final class Guest { + private final String server; + private String access; + private WebSocket socket; + private final List heard = new ArrayList(); + + private Guest(String server) { + this.server = server; + } + + static Guest signIn(String email, String password) throws Exception { + Guest g = new Guest(System.getProperty("dash.server.url", "http://localhost:18090")); + g.login(email.trim().toLowerCase(java.util.Locale.ROOT), password); + return g; + } + + private void login(String email, String password) throws Exception { + HttpURLConnection c = open("POST", "/login"); + String session = cookie(send(c, "username=" + enc(email) + "&password=" + enc(password))); + byte[] raw = new byte[32]; + new SecureRandom().nextBytes(raw); + String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + HttpURLConnection a = open("GET", "/oauth2/authorize?response_type=code&client_id=" + AppConfig.CLIENT_ID + + "&redirect_uri=" + enc(AppConfig.REDIRECT_URI) + "&scope=" + enc("openid profile") + + "&state=guest&code_challenge=" + challenge + "&code_challenge_method=S256"); + a.setRequestProperty("Cookie", session); + a.getResponseCode(); + Matcher code = Pattern.compile("[?&]code=([^&]+)").matcher(String.valueOf(a.getHeaderField("Location"))); + if (!code.find()) { + throw new IllegalStateException("no authorization code for the guest"); + } + HttpURLConnection t = open("POST", "/oauth2/token"); + String body = read(send(t, "grant_type=authorization_code&code=" + code.group(1) + "&redirect_uri=" + + enc(AppConfig.REDIRECT_URI) + "&code_verifier=" + verifier + "&client_id=" + AppConfig.CLIENT_ID)); + Matcher token = Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\"").matcher(body); + if (!token.find()) { + throw new IllegalStateException("no token for the guest"); + } + access = token.group(1); + } + + /// Opens the game socket and joins the party `code`. + void join(final String code) throws Exception { + HttpURLConnection t = open("POST", "/api/game/ticket"); + t.setRequestProperty("Authorization", "Bearer " + access); + Matcher m = Pattern.compile("\"ticket\"\\s*:\\s*\"([^\"]+)\"").matcher(read(send(t, ""))); + if (!m.find()) { + throw new IllegalStateException("no game ticket for the guest"); + } + socket = WebSocket.build("ws" + server.substring(4) + "/ws/game?ticket=" + m.group(1)) + .onConnect(ws -> ws.send("{\"type\":\"join\",\"code\":\"" + code + "\"}")) + .onTextMessage((ws, text) -> { + synchronized (heard) { + heard.add(text); + } + }) + .connect(10000); + } + + void say(String text) { + socket.send("{\"type\":\"chat\",\"text\":\"" + text.replace("\"", "'") + "\"}"); + } + + void emote(String id) { + socket.send("{\"type\":\"emote\",\"id\":\"" + id + "\"}"); + } + + boolean heard(String fragment) { + synchronized (heard) { + for (String h : heard) { + if (h.indexOf(fragment) >= 0) { + return true; + } + } + } + return false; + } + + void leave() { + if (socket != null) { + socket.close(); + } + } + + private HttpURLConnection open(String method, String path) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(server + path).openConnection(); + c.setRequestMethod(method); + c.setInstanceFollowRedirects(false); + if ("POST".equals(method)) { + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + } + return c; + } + + private static HttpURLConnection send(HttpURLConnection c, String form) throws Exception { + try (OutputStream out = c.getOutputStream()) { + out.write(form.getBytes(StandardCharsets.UTF_8)); + } + c.getResponseCode(); + return c; + } + + private static String cookie(HttpURLConnection c) { + String all = ""; + for (int i = 0; c.getHeaderFieldKey(i) != null || i == 0; i++) { + if ("Set-Cookie".equalsIgnoreCase(c.getHeaderFieldKey(i))) { + String v = c.getHeaderField(i); + int semi = v.indexOf(';'); + all += (all.length() > 0 ? "; " : "") + (semi < 0 ? v : v.substring(0, semi)); + } + } + return all; + } + + private static String read(HttpURLConnection c) throws Exception { + InputStream in = c.getResponseCode() >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + for (int n; in != null && (n = in.read(b)) > 0;) { + out.write(b, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + private static String enc(String s) throws Exception { + return URLEncoder.encode(s, "UTF-8"); + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/OnlineTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/OnlineTest.java new file mode 100644 index 00000000000..92725fdc567 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/OnlineTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.ui.Nav; + +/// A quick race, in lockstep with the server. The test server fills a lone +/// player's race with bots at once, so this needs no second player. +public class OnlineTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(RIVAL); + waitForForm("home"); + click("quickRaceButton"); + waitForForm("run"); + final RunView view = track(); + assertTrue(view.driver() instanceof RaceDriver, "a live race"); + assertTrue(view.driver().run().runnerCount() > 1, "other runners on the track"); + // The clock only moves when the server says it may: reaching tick 100 + // means the ticks are arriving. + until(() -> view.driver().run().tick() > 100, 30000, "server ticks"); + click("emote-gg"); + waitFor(1500); + shot("race"); + onEdt(() -> { + ((RaceDriver) view.driver()).leave(); + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java new file mode 100644 index 00000000000..8ab0f78ab9d --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.ui.Nav; + +/// A party of two: the app hosts, a second player signs in from the test, +/// joins by the party code and talks; then they race each other and trade +/// emotes. The screenshots are the party chat and the race with the guest's +/// emote and chat on screen. +public class PartyTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("partyButton"); + waitForForm("party"); + until(() -> ((Label) findByName("partyCode")).getText().length() > 2, 20000, "the party code"); + String code = ((Label) findByName("partyCode")).getText().trim(); + Guest guest = Guest.signIn(RIVAL, PASSWORD); + try { + guest.join(code); + until(() -> guest.heard("\"party\""), 20000, "the guest in the party"); + waitFor(800); + guest.say("Hey! Ready when you are"); + waitFor(600); + onEdt(() -> { + TextField say = (TextField) findByName("chatField"); + say.setText("Neon Causeway, best of three?"); + say.fireDoneEvent(); + }); + waitFor(600); + guest.say("You're on. Loser buys the next crate"); + until(() -> guest.heard("best of three"), 10000, "the host's message at the guest"); + waitFor(1200); + shot("party-chat"); + click("goButton"); + waitForForm("run"); + final RunView view = track(); + // The bot steers while the race warms up, so the shot is of the + // race and not of a runner who hit the first barrier. + // A race only moves as the server stamps ticks, so a stalled one + // would spin here until the suite's timeout killed the test with + // no word of why; give it half a minute and say where it stood. + long give = System.currentTimeMillis() + 30000; + while (view.driver().run().tick() < 60 * 6 && System.currentTimeMillis() < give + && !driveFor(view, 200)) { + // keep running + } + int tick = view.driver().run().tick(); + assertTrue(tick >= 60 * 6 || view.driver().run().over(), + "the race reached tick " + tick + " of " + 60 * 6 + " in 30 s"); + guest.emote("wow"); + guest.say("Catch me if you can!"); + click("emote-wave"); + driveToBusy(view, 5, 4000); + assertTrue(!view.driver().run().over(), "still racing for the shot"); + shot("party-race", view); + onEdt(() -> { + ((RaceDriver) view.driver()).leave(); + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + } finally { + guest.leave(); + } + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PracticeTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PracticeTest.java new file mode 100644 index 00000000000..afe8292d1b9 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PracticeTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.ViewMode; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.run.SoloDriver; +import com.codenameone.examples.dash.ui.ScoresForm; + +/// A solo run of the causeway from the level list: it starts side on, the +/// camera swings behind the runner at 300 m, and the server's re-simulation +/// of the input log is what puts the score on the board. +public class PracticeTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("runButton"); + waitForForm("levels"); + shot("levels"); + click("play-" + Content.CAUSEWAY); + waitForForm("run"); + final RunView view = track(); + assertTrue(view.driver() instanceof SoloDriver, "a solo run"); + assertEqual(ViewMode.SIDE_2D, view.driver().run().level().startView(), "the causeway starts side on"); + until(() -> view.driver().run().tick() > 240, 20000, "the run"); + shot("run-side"); + new Thread(() -> drive(view, 60000)).start(); + until(() -> view.driver().run().runner(view.driver().me()).z / 1000 > 340 + && busyAhead(view, 8) || view.driver().run().over(), 60000, "the swing to the chase view"); + shot("run-chase"); + finish(view, 60 * 600); + awaitResult("the result"); + shot("run-result"); + click("scores"); + waitForForm("scores"); + // The board for the guide is the day's endless run: a track with no + // finish line, where every run ends somewhere different. A finite + // level's all-time board is a row of finishers at its length. + onEdt(() -> ScoresForm.show(Content.ENDLESS, null)); + until(() -> has("board-day"), 20000, "the endless board"); + touch("board-day"); + until(() -> has("score-3"), 20000, "the day's board"); + shot("scores"); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java new file mode 100644 index 00000000000..73553593276 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.impl.javase.record.RecordingOptions; +import com.codename1.impl.javase.record.SimulatorRecorder; +import com.codename1.impl.javase.record.TouchOverlay; +import com.codename1.maps.routing.RouteService; +import com.codename1.maps.routing.Routing; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.run.Landmarks; +import com.codenameone.examples.dash.run.RaceDriver; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.ui.Nav; +import com.codenameone.examples.dash.forge.PackEditor; +import java.io.File; + +/// The promotional videos: the simulator's own recorder films a tour of the +/// game with captions -- a run, a city route, a live race with chat, the +/// level editor and the boards -- and, when `dash.e2e.clip` names a second +/// file, a short gameplay clip inside the phone's skin first. It is not a +/// test of anything the others do not cover, so it runs only when asked for +/// (`run-e2e.sh --video`, which names the files in `dash.e2e.video` and +/// `dash.e2e.clip`). +public class PromoVideoTest extends E2e { + private SimulatorRecorder recorder; + + /// The clip and the whole tour play in real time, about five minutes. + @Override + public int getTimeoutMillis() { + return 600000; + } + + @Override + protected boolean run() throws Exception { + String out = System.getProperty("dash.e2e.video"); + if (out == null || out.length() == 0) { + log("The promo video is recorded only when dash.e2e.video names the file; skipped"); + return true; + } + RouteService realRoutes = Routing.getService(); + Routing.setService(new CityRunTest.Canned()); + Landmarks.setSource(new CityRunTest.CannedPlaces()); + Guest guest = null; + try { + signIn(PLAYER); + waitForForm("home"); + waitFor(1500); + String clip = System.getProperty("dash.e2e.clip"); + if (clip != null && clip.length() > 0) { + clip(clip); + } + record(out, RecordingOptions.Frame.SCREEN, 240); + caption("Dash -- a runner you can make your own", 2600); + waitFor(2800); + + click("runButton"); + waitForForm("levels"); + waitFor(1200); + click("play-" + Content.CAUSEWAY); + waitForForm("run"); + RunView view = track(); + caption("Starts side on...", 2600); + driveTo(view, 280); + caption("...and swings into 3D mid-run", 3000); + driveTo(view, 400); + caption("Coins, power-ups and traps every few metres", 2800); + driveFor(view, 4000); + finish(view, 60 * 600); + caption("Rewards, chests and level ups", 2400); + collectAll(); + waitFor(1200); + home(); + + click("runButton"); + waitForForm("levels"); + touch("cityButton"); + waitForForm("city"); + until(() -> findByName("cityMap") != null && findByName("cityMap").getWidth() > 0, 20000, "the map"); + caption("Pick two points on a real map", 2600); + waitFor(3000); + Component map = findByName("cityMap"); + int x = map.getAbsoluteX(); + int y = map.getAbsoluteY(); + touch(x + map.getWidth() / 4, y + map.getHeight() * 2 / 3); + waitFor(700); + touch(x + map.getWidth() * 3 / 4, y + map.getHeight() / 4); + until(() -> ((Button) findByName("runCity")).isEnabled(), 20000, "the route"); + caption("The route becomes a level: its streets, turns and landmarks", 3000); + waitFor(3200); + click("runCity"); + waitForForm("run"); + view = track(); + caption("Street names overhead, landmarks along the way", 3200); + driveTo(view, 280); + caption("Every turn on the map is a corner on the track", 3000); + driveTo(view, 560); + finish(view, 60 * 900); + collectAll(); + waitFor(1000); + home(); + + click("partyButton"); + waitForForm("party"); + until(() -> ((Label) findByName("partyCode")).getText().length() > 2, 20000, "the party code"); + String code = ((Label) findByName("partyCode")).getText().trim(); + guest = Guest.signIn(RIVAL, PASSWORD); + final Guest g = guest; + caption("Race friends live: a party code, chat and voice", 3000); + g.join(code); + until(() -> g.heard("\"party\""), 20000, "the guest in the party"); + waitFor(900); + g.say("Hey! Ready when you are"); + waitFor(1100); + onEdt(() -> { + TextField say = (TextField) findByName("chatField"); + say.setText("Neon Causeway, best of three?"); + say.fireDoneEvent(); + }); + waitFor(1000); + g.say("You're on."); + waitFor(1500); + click("goButton"); + waitForForm("run"); + view = track(); + final RunView racing = view; + until(() -> racing.driver().run().tick() > 30, 20000, "the countdown"); + caption("Same seed, side by side, scored by the server", 3000); + driveFor(view, 4000); + g.emote("wow"); + g.say("Catch me if you can!"); + click("emote-wave"); + driveFor(view, 6000); + // A race steps in lockstep with the guest, who sends no inputs + // from here; leave it the way the pause menu does. + final RunView race = view; + onEdt(() -> { + ((RaceDriver) race.driver()).leave(); + race.stop(); + race.driver().dispose(); + }); + g.leave(); + guest = null; + home(); + + click("forgeButton"); + waitForForm("forge"); + caption("Build levels in the app, playtest, publish", 3000); + waitFor(1800); + onEdt(() -> PackEditor.create("Frost Pass")); + waitForForm("packEditor"); + waitFor(1200); + click("addLevel"); + waitForForm("levelEditor"); + waitFor(1200); + click("tool-" + Content.HURDLE); + Component t = findByName("timeline"); + onEdt(() -> t.getComponentForm().scrollComponentToVisible(t)); + waitFor(500); + for (int i = 0; i < 3; i++) { + touch(t.getAbsoluteX() + t.getWidth() * (2 + 2 * i) / 10 - t.getScrollX(), + t.getAbsoluteY() + t.getHeight() * (2 + i % 3) / 5 + t.getHeight() / 10); + waitFor(600); + } + waitFor(1200); + click("playtest"); + waitForForm("run"); + view = track(); + driveFor(view, 4000); + finish(view, 60 * 600); + collectAll(); + click("done"); + waitForForm("levelEditor"); + + caption("Dash -- a Codename One sample. Rename it, reskin it, ship it.", 3200); + waitFor(3400); + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms, " + r.frames + " frames" + + (r.note == null ? "" : " -- " + r.note)); + assertTrue(r.frames > 0, "frames recorded"); + return true; + } finally { + if (recorder != null && recorder.isRecording()) { + recorder.stop(); + } + if (guest != null) { + guest.leave(); + } + Routing.setService(realRoutes); + Landmarks.setSource(new Landmarks.Overpass("https://overpass-api.de/api/interpreter")); + } + } + + /// About forty seconds of play in the phone's skin: one run of the + /// causeway from its side-on start through the swing into 3D. + private void clip(String out) throws Exception { + record(out, RecordingOptions.Frame.SKIN, 90); + caption("Dash", 1800); + click("runButton"); + waitForForm("levels"); + waitFor(900); + click("play-" + Content.CAUSEWAY); + waitForForm("run"); + RunView view = track(); + driveTo(view, 280); + caption("2D to 3D, mid-run", 2600); + driveTo(view, 420); + driveFor(view, 6000); + caption("Built with Codename One", 2600); + driveFor(view, 2800); + finish(view, 60 * 600); + collectAll(); + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms, " + r.frames + " frames" + + (r.note == null ? "" : " -- " + r.note)); + assertTrue(r.frames > 0, "clip frames recorded"); + home(); + } + + private void record(String out, RecordingOptions.Frame frame, int maxSeconds) throws Exception { + recorder = JavaSEPort.instance.getSimulatorRecorder(); + RecordingOptions o = new RecordingOptions(); + o.setPath(new File(out).getAbsolutePath()); + o.setFps(30); + o.setScale(1f); + o.setFrame(frame); + o.setTouches(TouchOverlay.Style.DOT); + o.setAudio(RecordingOptions.Audio.APP); + o.setMaxSeconds(maxSeconds); + recorder.start(o); + } + + private void caption(String text, long ms) { + recorder.caption(text, ms); + } + + /// Lets the bot run until the runner is `metres` down the track. + private void driveTo(RunView view, int metres) { + long give = System.currentTimeMillis() + 90000; + while (view.driver().run().runner(view.driver().me()).z / 1000 < metres + && System.currentTimeMillis() < give && !driveFor(view, 100)) { + // the bot steers + } + } + + /// Back to the home screen, the way the tab bar takes a player there. + private void home() { + onEdt(Nav::home); + waitForForm("home"); + waitFor(800); + } + + /// Collects every reward card until the result shows. + private void collectAll() { + until(() -> has("result") || has("collectReward"), 20000, "the result"); + while (!has("result")) { + if (has("collectReward")) { + waitFor(1400); + click("collectReward"); + } + waitFor(600); + } + waitFor(1800); + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoteTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoteTest.java new file mode 100644 index 00000000000..ffce21ef55d --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoteTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.ui.Component; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Tabs; + +/// The console in a desktop window: the reports and their charts, a query +/// over the players, the engagement pipelines and one opened for editing, +/// then the Promote tab -- a push broadcast counted against its audience, +/// and a home spotlight that every player then sees. +public class PromoteTest extends E2e { + @Override + protected boolean desktop() { + return true; + } + + @Override + protected boolean run() throws Exception { + signIn(ADMIN); + waitForForm("home"); + click("adminButton"); + waitForForm("admin"); + until(() -> has("stat-Open flags") && has("chartPlayers"), 20000, "the numbers and the charts"); + waitFor(800); + shot("desktop-admin-overview"); + reveal("chartGems"); + shot("desktop-admin-reports"); + tab(6); + until(() -> has("queryRun"), 20000, "the query tool"); + click("queryRun"); + until(() -> has("queryCount"), 20000, "the players the query found"); + shot("desktop-admin-query"); + tab(7); + until(() -> has("pipeline-Win back"), 20000, "the pipelines"); + shot("desktop-admin-pipelines"); + click("pipelineEdit-Win back"); + until(() -> has("pipelineSave"), 20000, "the pipeline editor"); + click("pipelineCountButton"); + waitFor(1500); + shot("desktop-admin-pipeline-edit"); + click("pipelineCancel"); + tab(8); + until(() -> has("promoteTitle"), 20000, "the Promote tab"); + shot("desktop-admin-promote"); + type("promoteTitle", "Season two is here"); + type("promoteBody", "A new level is up. Come and race!"); + type("promoteCampaign", "season-two"); + reveal("promoteTitle"); + click("promoteCount"); + waitFor(1500); + shot("desktop-admin-broadcast"); + // Send asks first; the shot is the question, and the answer is no. + click("promoteSend"); + until(() -> Display.getInstance().getCurrent() instanceof Dialog, 20000, "the confirmation"); + shot("desktop-admin-broadcast-confirm"); + onEdt(() -> ((Dialog) Display.getInstance().getCurrent()).dispose()); + waitForForm("admin"); + reveal("spotlightText"); + type("spotlightText", "Season two: the Frost Arena is open"); + click("spotlightSave"); + waitFor(1500); + reveal("linkCampaign"); + click("linkMake"); + waitFor(1500); + shot("desktop-admin-spotlight"); + onEdt(() -> com.codenameone.examples.dash.ui.Nav.home()); + waitForForm("home"); + until(() -> has("spotlight"), 20000, "the spotlight banner"); + shot("desktop-home-spotlight"); + return true; + } + + private void tab(final int index) { + onEdt(() -> ((Tabs) findByName("adminTabs")).setSelectedIndex(index)); + waitFor(600); + } + + private void reveal(final String name) { + onEdt(() -> { + Component c = findByName(name); + c.getComponentForm().scrollComponentToVisible(c); + }); + waitFor(600); + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ShopTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ShopTest.java new file mode 100644 index 00000000000..46c5becce66 --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/ShopTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codenameone.examples.dash.net.Session; + +/// Buying gems through the store and spending them in the game's shop. In +/// the simulator the store purchase completes at once with a simulated +/// receipt, which the test server's receipt validator accepts. +public class ShopTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + final int gems = Session.user().gems(); + click("shopButton"); + waitForForm("shop"); + until(() -> has("buy-gems-80"), 20000, "the shop items"); + shot("shop"); + click("buy-gems-80"); + until(() -> Session.user().gems() == gems + 80, 20000, "the gems to arrive"); + waitForForm("shop"); + until(() -> has("buy-trail-comet"), 20000, "the shop items"); + click("buy-trail-comet"); + until(() -> Session.user().owned().contains("trail-comet"), 20000, "the trail to be owned"); + return true; + } +} diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/TutorialTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/TutorialTest.java new file mode 100644 index 00000000000..8982053707e --- /dev/null +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/TutorialTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash; + +import com.codename1.ui.CN; +import com.codenameone.examples.dash.api.RewardDto; +import com.codenameone.examples.dash.run.RunView; +import com.codenameone.examples.dash.ui.Nav; +import com.codenameone.examples.dash.ui.Rewards; +import com.codenameone.examples.dash.ui.RunForm; +import java.util.Arrays; + +/// The home screen with progress on it, then the first level the way a new +/// player meets it: the countdown, the coach's lines as the hazards arrive, +/// and the result. +public class TutorialTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + until(() -> has("chests") || has("spotlight"), 20000, "the progress on the home screen"); + shot("home"); + onEdt(RunForm::tutorial); + waitForForm("run"); + final RunView view = track(); + until(() -> view.driver().run().tick() > 120, 20000, "the run to start"); + until(() -> has("coach"), 20000, "the coach"); + shot("tutorial-coach"); + finish(view, 60 * 600); + awaitResult("the result"); + shot("tutorial-result"); + click("done"); + // Not through onEdt: the popup is modal and would hold it forever. + CN.callSerially(() -> Rewards.show(new RewardDto("Training complete", 100, 15, 150, 2, 2, true, + Arrays.asList("Sprinter")), Nav::home)); + until(() -> has("collectReward"), 10000, "the reward"); + shot("reward-levelup"); + click("collectReward"); + waitForForm("home"); + return true; + } +} diff --git a/scripts/dash/ios/pom.xml b/scripts/dash/ios/pom.xml new file mode 100644 index 00000000000..2550c0abe33 --- /dev/null +++ b/scripts/dash/ios/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + + com.codenameone.examples.dash + dash-ios + 1.0-SNAPSHOT + + dash-ios + + + UTF-8 + 17 + 17 + ios + ios + ios-device + + + + + src/main/objectivec + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-ios + package + + build + + + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + + + diff --git a/scripts/dash/ios/src/main/objectivec/.gitkeep b/scripts/dash/ios/src/main/objectivec/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/dash/pom.xml b/scripts/dash/pom.xml new file mode 100644 index 00000000000..d75f3846f4f --- /dev/null +++ b/scripts/dash/pom.xml @@ -0,0 +1,321 @@ + + 4.0.0 + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + pom + dash + dash + https://www.codenameone.com + + + MIT No Attribution + https://opensource.org/license/mit-0 + repo + The same terms as the LICENSE file beside this pom + + + + + rules + + shared + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 1.8 + 11 + 1.7.11 + 3.8.0 + 1.8 + 1.8 + 1.8 + dash + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-android-compat + ${cn1.version} + provided + + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + + + cn1-client-tests + + + codename1.platform + !backend + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + javascript + + + codename1.platform + javascript + + + ${basedir}/javascript/pom.xml + + + + javascript + + + + ios + + + codename1.platform + ios + + + ${basedir}/ios/pom.xml + + + + ios + + + + win + + + codename1.platform + win + + + ${basedir}/win/pom.xml + + + + win + + + + linux + + + codename1.platform + linux + + + ${basedir}/linux/pom.xml + + + + linux + + + + + backend + + + codename1.platform + backend + + + ${basedir}/backend/pom.xml + + + + backend + + + + android + + + codename1.platform + android + + + ${basedir}/android/pom.xml + + + + android + + + + javase + + + codename1.platform + javase + + + ${basedir}/javase/pom.xml + + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/dash/rules/pom.xml b/scripts/dash/rules/pom.xml new file mode 100644 index 00000000000..fcd61218b4b --- /dev/null +++ b/scripts/dash/rules/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + + dash-rules + jar + dash-rules + + + + 1.8 + 1.8 + + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + + diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Bot.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Bot.java new file mode 100644 index 00000000000..93017be9e18 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Bot.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// A runner that plays by looking ahead. Used for ghosts when nobody else is +/// online, for the editor's "can this be beaten?" check and for the soak tests. +/// +/// It has its own random stream, seeded apart from the run's, so a bot's lapses +/// never change the track; what it decides is recorded as input like a player's. +public final class Bot { + private final Rng rng; + /// 0 to 100: how often it reacts in time, and how greedily it chases coins. + private final int skill; + /// Ticks between deciding and the input taking effect: 0 when the bot + /// steps the run itself, the race's input delay when its inputs go + /// through a server first. It sees everything that much earlier. + private final int lead; + private int cooldown; + + public Bot(long seed, int skill) { + this(seed, skill, 0); + } + + public Bot(long seed, int skill, int lead) { + this.rng = new Rng(seed * 31 + 7); + this.skill = FixedMath.clamp(skill, 0, 100); + this.lead = Math.max(0, lead); + } + + public int decide(Run run, int who) { + Runner r = run.runner(who); + if (!r.running()) { + return Input.NONE; + } + if (cooldown > 0) { + cooldown--; + return Input.NONE; + } + int perTick = Math.max(1, r.speed / Run.TICKS_PER_SECOND); + int look = perTick * (40 + lead); + Track t = run.track(); + Track.Entry threat = nearestObstacle(t, r, r.lane, look); + if (threat != null) { + int dist = threat.z - (r.z + Runner.HALF_DEPTH); + if (rng.nextInt(100) >= skill + 20) { + // A lapse: it sees the obstacle a few ticks late. + cooldown = 3; + return Input.NONE; + } + int bits = respond(run, r, threat, dist, perTick, look); + if (bits != Input.NONE) { + cooldown = bits == Input.JUMP ? 6 : 2; + } + return bits; + } + if (rng.nextInt(100) < skill / 2) { + return chaseCoins(run, r, look); + } + return Input.NONE; + } + + private int respond(Run run, Runner r, Track.Entry e, int dist, int perTick, int look) { + Clearance c = e.obstacle.clearance; + if (c == Clearance.DODGE || (e.obstacle.lethal && !e.spansAllLanes() && dist > perTick * (12 + lead))) { + int lanes = run.level().lanes; + int best = -1; + for (int d = 1; d < lanes && best < 0; d++) { + for (int s = -1; s <= 1; s += 2) { + int l = r.lane + s * d; + if (l >= 0 && l < lanes && nearestObstacle(run.track(), r, l, look) == null) { + best = l; + break; + } + } + } + if (best >= 0) { + return best < r.lane ? Input.LEFT : Input.RIGHT; + } + if (c == Clearance.DODGE) { + return Input.NONE; + } + } + if (c == Clearance.JUMP && dist <= perTick * (14 + lead) && r.y == 0) { + return Input.JUMP; + } + if (c == Clearance.SLIDE && dist <= perTick * (10 + lead) && !r.sliding()) { + return Input.SLIDE; + } + return Input.NONE; + } + + private static Track.Entry nearestObstacle(Track t, Runner r, int lane, int look) { + for (int i = r.cursor; i < t.size(); i++) { + Track.Entry e = t.get(i); + if (e.obstacle == null || e.z + e.depth < r.z - Runner.HALF_DEPTH) { + continue; + } + if (e.z > r.z + look) { + break; + } + if (e.spansAllLanes() || e.lane == lane) { + return e; + } + } + return null; + } + + private static int chaseCoins(Run run, Runner r, int look) { + Track t = run.track(); + for (int i = r.cursor; i < t.size(); i++) { + Track.Entry e = t.get(i); + if (e.z < r.z || e.pickup == null || e.spansAllLanes()) { + continue; + } + if (e.z > r.z + look) { + break; + } + if (e.lane != r.lane && nearestObstacle(t, r, e.lane, look) == null) { + return e.lane < r.lane ? Input.LEFT : Input.RIGHT; + } + return Input.NONE; + } + return Input.NONE; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Catalog.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Catalog.java new file mode 100644 index 00000000000..9b0d7fa9104 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Catalog.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.HashMap; +import java.util.Map; + +/// Every obstacle and pickup a level may name, by id. [Content#catalog] holds the +/// built-in ones; a community pack adds its own under `u::` so it can +/// never shadow one. +/// +/// Lookup only: the simulation never iterates the maps, whose order is not the +/// same on every runtime. +public final class Catalog { + private final Map obstacles = new HashMap(); + private final Map pickups = new HashMap(); + + public Catalog add(ObstacleDef d) { + obstacles.put(d.id, d); + return this; + } + + public Catalog add(PickupDef d) { + pickups.put(d.id, d); + return this; + } + + public ObstacleDef obstacle(String id) { + return obstacles.get(id); + } + + public PickupDef pickup(String id) { + return pickups.get(id); + } + + public boolean has(String id) { + return obstacles.containsKey(id) || pickups.containsKey(id); + } + + /// A catalog holding this one's entries plus `other`'s. + public Catalog with(Catalog other) { + Catalog c = new Catalog(); + c.obstacles.putAll(obstacles); + c.pickups.putAll(pickups); + c.obstacles.putAll(other.obstacles); + c.pickups.putAll(other.pickups); + return c; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Chunk.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Chunk.java new file mode 100644 index 00000000000..6f804177244 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Chunk.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// A stretch of track the endless part of a level is assembled from. The track +/// picks chunks at random among those whose `minDifficulty` the current distance +/// has reached, and may mirror one across the lanes. +/// +/// Placements are relative to the chunk's start and kept sorted by distance, so +/// the track can append them as they are. +public final class Chunk { + public final String id; + public final int length; + public final int minDifficulty; + private final List placements = new ArrayList(); + + public Chunk(String id, int length, int minDifficulty) { + this.id = id; + this.length = length < Track.UNIT ? Track.UNIT : length; + this.minDifficulty = minDifficulty; + } + + public Chunk add(int z, String defId, int lane) { + int i = placements.size(); + while (i > 0 && placements.get(i - 1).z > z) { + i--; + } + placements.add(i, new Placement(z, defId, lane)); + return this; + } + + public List placements() { + return placements; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/CityRoute.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/CityRoute.java new file mode 100644 index 00000000000..7137b96c786 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/CityRoute.java @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// A run down real streets: the legs of a route a map found between two +/// points, turned into a level. Each leg is a stretch of one street; a turn +/// into the next is a corner the runner has to take on the side it turns to, +/// with the street's name called out before it, and what lies between the +/// corners comes from the game's own chunks. Landmarks the route passes -- +/// named places from the map -- are kept with how far along the route they +/// stand, so the run can show them by the track where they really are and +/// the coach can call them out. +/// +/// Nothing here knows about maps. The app reduces a route to this spec, and +/// the spec is all the server needs: [#level()] is deterministic, so the +/// server builds the very same level from the [#encode()]d spec a result +/// carries, and verifies the run on it as it would any other. +public final class CityRoute { + /// What every city level's id starts with. + public static final String ID_PREFIX = "city-"; + public static final int LEFT = -1; + public static final int STRAIGHT = 0; + public static final int RIGHT = 1; + /// The shortest and longest run a route becomes. A longer route is run + /// to scale, so a 6 km route is a 2.5 km level with its corners in the + /// same proportion. + public static final int MIN_METRES = 300; + public static final int MAX_METRES = 2500; + public static final int MAX_LEGS = 60; + public static final int MAX_NAME = 32; + public static final int MAX_LANDMARKS = 24; + /// What kind of place a landmark is; the renderer picks its marker by it. + public static final int LANDMARK = 0; + public static final int WORSHIP = 1; + public static final int MUSEUM = 2; + public static final int PARK = 3; + public static final int STATION = 4; + public static final int MONUMENT = 5; + private static final int KINDS = 6; + private static final int VERSION = 1; + private static final String[] THEMES = {"daybreak", "neon", "ember"}; + /// Clear track before the first obstacle, and around every corner. + private static final int LEAD_IN = 40; + private static final int CORNER = 24; + + /// One street of the route: how long it is, which way the runner turned + /// into it, and its name (empty when the map has none). + public static final class Leg { + public final int metres; + public final int turn; + public final String street; + + Leg(int metres, int turn, String street) { + this.metres = metres; + this.turn = turn; + this.street = street; + } + } + + /// A named place beside the route, `metres` from its start. + public static final class Landmark { + public final int metres; + public final int kind; + public final String name; + + Landmark(int metres, int kind, String name) { + this.metres = metres; + this.kind = kind; + this.name = name; + } + } + + /// Something the run shows by the track, `metres` into the level (scaled + /// as the level is): a street's name where the runner enters it, or a + /// landmark. `kind` is -1 for a street sign, else the landmark's kind. + public static final class Sign { + public final int metres; + public final int kind; + public final String text; + + Sign(int metres, int kind, String text) { + this.metres = metres; + this.kind = kind; + this.text = text; + } + } + + public final String place; + private final List legs = new ArrayList(); + private final List landmarks = new ArrayList(); + + /// A route through `place`, a name for the area shown on the level. + public CityRoute(String place) { + this.place = clean(place); + } + + /// Adds the next street. `turn` is the turn into it -- [#LEFT], + /// [#RIGHT] or [#STRAIGHT] -- and is ignored for the first. + public CityRoute leg(int metres, int turn, String street) { + if (legs.size() >= MAX_LEGS) { + throw new IllegalArgumentException("a route has at most " + MAX_LEGS + " streets"); + } + if (metres < 0 || metres > 100000) { + throw new IllegalArgumentException("a street is 0 to 100 km long"); + } + if (turn < LEFT || turn > RIGHT) { + throw new IllegalArgumentException("a turn is left, right or straight on"); + } + legs.add(new Leg(metres, legs.isEmpty() ? STRAIGHT : turn, clean(street))); + return this; + } + + public List legs() { + return legs; + } + + /// Adds a landmark `metres` along the route. Ones past the end, past + /// [#MAX_LANDMARKS] or with no name are refused. + public CityRoute landmark(int metres, int kind, String name) { + if (landmarks.size() >= MAX_LANDMARKS) { + throw new IllegalArgumentException("a route has at most " + MAX_LANDMARKS + " landmarks"); + } + if (metres < 0 || metres > 100000 || kind < 0 || kind >= KINDS) { + throw new IllegalArgumentException("not a landmark"); + } + String n = clean(name); + if (n.length() == 0) { + throw new IllegalArgumentException("a landmark has a name"); + } + landmarks.add(new Landmark(metres, kind, n)); + return this; + } + + public List landmarks() { + return landmarks; + } + + /// Where a point `metres` along the real route falls in the level. + private int scaled(int metres, int total, int real) { + return (int) ((long) Math.min(metres, real) * total / real); + } + + /// The signs along the run, in order: each street's name where it + /// starts, and every landmark. + public List signs() { + int real = Math.max(1, metres()); + int total = Math.max(MIN_METRES, Math.min(MAX_METRES, real)); + List out = new ArrayList(); + int at = 0; + for (int i = 0; i < legs.size(); i++) { + Leg l = legs.get(i); + if (l.street.length() > 0) { + out.add(new Sign(Math.max(12, scaled(at, total, real)), -1, l.street)); + } + at += l.metres; + } + for (int i = 0; i < landmarks.size(); i++) { + Landmark m = landmarks.get(i); + out.add(new Sign(scaled(m.metres, total, real), m.kind, m.name)); + } + // Insertion sort: a route has a few dozen signs at most. + for (int i = 1; i < out.size(); i++) { + Sign x = out.get(i); + int j = i - 1; + while (j >= 0 && out.get(j).metres > x.metres) { + out.set(j + 1, out.get(j)); + j--; + } + out.set(j + 1, x); + } + return out; + } + + /// The street a runner `metres` into the level is on, or "" where the + /// map had no name. + public String streetAt(int metres) { + int real = Math.max(1, metres()); + int total = Math.max(MIN_METRES, Math.min(MAX_METRES, real)); + int at = 0; + String street = ""; + for (int i = 0; i < legs.size(); i++) { + if (scaled(at, total, real) > metres) { + break; + } + street = legs.get(i).street; + at += legs.get(i).metres; + } + return street; + } + + /// The real length of the route, in metres. + public int metres() { + int m = 0; + for (int i = 0; i < legs.size(); i++) { + m += legs.get(i).metres; + } + return m; + } + + /// The spec as one line: `1~place~metres,turn,street~...`. Names never + /// hold a separator; [#clean] removed them. + public String encode() { + StringBuilder sb = new StringBuilder(); + sb.append(VERSION).append('~').append(place); + for (int i = 0; i < legs.size(); i++) { + Leg l = legs.get(i); + sb.append('~').append(l.metres).append(',').append(l.turn).append(',').append(l.street); + } + for (int i = 0; i < landmarks.size(); i++) { + Landmark m = landmarks.get(i); + sb.append("~@").append(m.metres).append(',').append(m.kind).append(',').append(m.name); + } + return sb.toString(); + } + + /// Reads [#encode()]'s line back. Throws `IllegalArgumentException` for + /// anything else, since a server decodes what a client sent. + public static CityRoute decode(String s) { + if (s == null || s.length() > 4096) { + throw new IllegalArgumentException("not a city route"); + } + List parts = Text.split(s, '~'); + if (parts.size() < 3 || !String.valueOf(VERSION).equals(parts.get(0))) { + throw new IllegalArgumentException("not a city route"); + } + CityRoute r = new CityRoute(parts.get(1)); + for (int i = 2; i < parts.size(); i++) { + String part = parts.get(i); + boolean mark = part.length() > 0 && part.charAt(0) == '@'; + List f = Text.split(mark ? part.substring(1) : part, ','); + if (f.size() != 3) { + throw new IllegalArgumentException("not a city route"); + } + try { + if (mark) { + r.landmark(Integer.parseInt(f.get(0)), Integer.parseInt(f.get(1)), f.get(2)); + continue; + } + if (!r.landmarks.isEmpty()) { + throw new IllegalArgumentException("not a city route"); + } + r.leg(Integer.parseInt(f.get(0)), Integer.parseInt(f.get(1)), f.get(2)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("not a city route"); + } + } + if (r.metres() < 1) { + throw new IllegalArgumentException("an empty route"); + } + return r; + } + + /// The level's id: the same spec always names the same level, so a + /// route has one board however many players find it. + public String id() { + String h = Long.toString(new Hash().add(encode()).value() >>> 1, 16); + return ID_PREFIX + h; + } + + /// The level this route runs as. Deterministic: the same spec builds the + /// same placements, on the app and on the server. + public LevelDef level() { + int real = Math.max(1, metres()); + int total = Math.max(MIN_METRES, Math.min(MAX_METRES, real)); + LevelDef d = new LevelDef(id(), place.length() > 0 ? place : "City run"); + long h = new Hash().add(encode()).value(); + d.theme = THEMES[(int) ((h >>> 1) % THEMES.length)]; + d.length = total * Track.UNIT; + d.stars = new int[] {total * 6 / 10, total * 9 / 10, total * 11 / 10}; + d.cue(0, ViewMode.CHASE_3D, 0); + Rng rng = new Rng(h); + List pool = Content.chunks(); + int start = 0; + int lastCorner = -1000; + for (int i = 0; i < legs.size(); i++) { + Leg l = legs.get(i); + // To scale, the last leg running to the finish exactly. + int end = i == legs.size() - 1 ? total : (int) ((long) runUpTo(i + 1) * total / real); + String street = l.street.length() > 0 ? l.street : "the next street"; + if (i == 0) { + d.dialog.add(new DialogLine(5 * Track.UNIT, "Vee", + (l.street.length() > 0 ? l.street : "The street") + ". Go!", 0)); + } else if (l.turn != STRAIGHT) { + // A street too short to run down between two turns is passed + // rather than cornered twice in a few metres. + // One right at the line would sit on the finish coins. + if (start - lastCorner >= 2 * CORNER + 20 && start <= total - CORNER) { + corner(d, start, l.turn, street); + lastCorner = start; + } + } else if (start > LEAD_IN && l.street.length() > 0) { + d.dialog.add(new DialogLine((start - 20) * Track.UNIT, "Vee", "Straight on into " + street + ".", 0)); + } + fill(d, pool, rng, Math.max(LEAD_IN, start + CORNER), end - CORNER, total); + start = end; + } + // A row of coins over the line. + for (int m = total - 12; m < total - 2; m += 3) { + d.place(m, Content.COIN, Placement.ALL_LANES); + } + // The coach names each landmark as it comes up, unless it would talk + // over a corner's call. + for (int i = 0; i < landmarks.size(); i++) { + Landmark m = landmarks.get(i); + int at = Math.max(LEAD_IN, scaled(m.metres, total, real) - 25); + if (at < total - 10 && !talking(d, at * Track.UNIT)) { + d.dialog.add(new DialogLine(at * Track.UNIT, "Vee", "Passing " + m.name + ".", 0)); + } + } + sortLines(d.dialog); + return d; + } + + /// Whether a line is already said within 6 seconds' running of `at`. + private static boolean talking(LevelDef d, int at) { + for (int i = 0; i < d.dialog.size(); i++) { + int gap = d.dialog.get(i).at - at; + if (gap > -40 * Track.UNIT && gap < 40 * Track.UNIT) { + return true; + } + } + return false; + } + + /// Orders lines by where they are said; the run reads them in order. + private static void sortLines(List lines) { + for (int i = 1; i < lines.size(); i++) { + DialogLine x = lines.get(i); + int j = i - 1; + while (j >= 0 && lines.get(j).at > x.at) { + lines.set(j + 1, lines.get(j)); + j--; + } + lines.set(j + 1, x); + } + } + + private int runUpTo(int legCount) { + int m = 0; + for (int i = 0; i < legCount; i++) { + m += legs.get(i).metres; + } + return m; + } + + /// A turn at `at`: haulers block every lane but the one on the side the + /// street turns to, with coins drawing the runner across, and the call + /// comes in time to move. + private static void corner(LevelDef d, int at, int turn, String street) { + if (at < LEAD_IN) { + return; + } + d.dialog.add(new DialogLine(Math.max(1, at - 45) * Track.UNIT, "Vee", + (turn == LEFT ? "Left onto " : "Right onto ") + street + "!", turn == LEFT ? Input.LEFT : Input.RIGHT)); + int open = turn == LEFT ? 0 : d.lanes - 1; + for (int lane = 0; lane < d.lanes; lane++) { + if (lane != open) { + d.place(at, Content.HAULER, lane); + } + } + for (int m = at - 8; m <= at + 4; m += 3) { + d.place(m, Content.COIN, open); + } + } + + /// Chunks from the game's pool between `from` and `to`, harder as the + /// run goes on, with a breath between each. + private static void fill(LevelDef d, List pool, Rng rng, int from, int to, int total) { + int m = from; + while (true) { + int difficulty = m * 7 / Math.max(1, total); + List fits = new ArrayList(); + for (int i = 0; i < pool.size(); i++) { + Chunk c = pool.get(i); + if (c.minDifficulty <= difficulty && m + c.length / Track.UNIT <= to) { + fits.add(c); + } + } + if (fits.isEmpty()) { + return; + } + Chunk c = fits.get(rng.nextInt(fits.size())); + for (int j = 0; j < c.placements().size(); j++) { + Placement p = c.placements().get(j); + d.placements.add(new Placement(m * Track.UNIT + p.z, p.defId, p.lane)); + } + m += c.length / Track.UNIT + 4; + } + } + + /// A name made safe for the spec and the screen: no separators or + /// control characters, trimmed, at most [#MAX_NAME] characters. + static String clean(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length() && sb.length() < MAX_NAME; i++) { + char c = s.charAt(i); + if (c >= ' ' && c != '~' && c != ',' && c != '|' && c != 0x7f) { + sb.append(c); + } + } + return sb.toString().trim(); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Clearance.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Clearance.java new file mode 100644 index 00000000000..f7a9d3b8908 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Clearance.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// What a runner has to do to get past an obstacle. +public enum Clearance { + /// Be airborne above the obstacle's height while overlapping it. + JUMP, + /// Be sliding, on the ground, while overlapping it. + SLIDE, + /// Be in another lane: nothing gets past this one. + DODGE +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Content.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Content.java new file mode 100644 index 00000000000..ba55a4ba9ed --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Content.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// The built-in obstacles, pickups, chunks and official levels. A fork changes +/// the game here (or in the editor, which produces the same objects from data). +public final class Content { + public static final String HURDLE = "hurdle"; + public static final String BARRIER = "barrier"; + public static final String HAULER = "hauler"; + public static final String GAP = "gap"; + public static final String COIN = "coin"; + public static final String GEM = "gem"; + public static final String MAGNET = "magnet"; + public static final String SHIELD = "shield"; + public static final String BOOST = "boost"; + + public static final String TUTORIAL = "first-steps"; + public static final String CAUSEWAY = "neon-causeway"; + public static final String ENDLESS = "endless"; + + private static Catalog catalog; + + private Content() { + } + + public static synchronized Catalog catalog() { + if (catalog == null) { + catalog = new Catalog() + .add(new ObstacleDef(HURDLE, Clearance.JUMP, 300, 600, false)) + .add(new ObstacleDef(BARRIER, Clearance.SLIDE, 400, 0, true)) + .add(new ObstacleDef(HAULER, Clearance.DODGE, 9000, 0, true)) + .add(new ObstacleDef(GAP, Clearance.JUMP, 1600, 0, true)) + .add(new PickupDef(COIN, PickupKind.COIN, 10, 0)) + .add(new PickupDef(GEM, PickupKind.GEM, 100, 0)) + .add(new PickupDef(MAGNET, PickupKind.MAGNET, 25, 8 * Run.TICKS_PER_SECOND)) + .add(new PickupDef(SHIELD, PickupKind.SHIELD, 25, 20 * Run.TICKS_PER_SECOND)) + .add(new PickupDef(BOOST, PickupKind.BOOST, 25, 4 * Run.TICKS_PER_SECOND)); + } + return catalog; + } + + /// The chunk pool every official endless section draws from, easiest first. + /// Chunks are short and full: a coin trail runs through nearly every one, + /// so the line a player takes always has something to pick up, and a + /// hazard is never more than a couple of seconds away. + public static List chunks() { + // Spacing rule: a jump carries the runner about 14 m at top speed, and + // a lane change takes a quarter second. So two obstacles a runner has + // to answer one after the other stand at least 16 m apart, nothing + // stands in a chunk's first 6 m or within 12 m of its end (the next + // chunk may open with anything), and a hauler always leaves a lane + // open with room to reach it. ChunkFairnessTest holds every chunk to + // this by sending the bot down hundreds of seeds. + List c = new ArrayList(); + int u = Track.UNIT; + c.add(trail(new Chunk("warmup", 22 * u, 0).add(10 * u, HURDLE, 1), 2, 14, 1)); + c.add(trail(new Chunk("side-step", 34 * u, 0).add(6 * u, HAULER, 0).add(22 * u, HURDLE, 2), 2, 26, 1)); + c.add(trail(new Chunk("duck", 22 * u, 1).add(10 * u, BARRIER, Placement.ALL_LANES), 4, 18, 1) + .add(16 * u, GEM, 0)); + c.add(trail(new Chunk("leap", 24 * u, 1).add(10 * u, GAP, Placement.ALL_LANES), 6, 16, 1)); + c.add(trail(new Chunk("hop-skip", 36 * u, 1).add(6 * u, HURDLE, 0).add(6 * u, HURDLE, 2) + .add(24 * u, BARRIER, 1), 2, 30, 0)); + c.add(trail(new Chunk("corridor", 34 * u, 2).add(6 * u, HAULER, 0).add(6 * u, HAULER, 2) + .add(22 * u, HURDLE, 1), 2, 28, 1).add(28 * u, MAGNET, 0)); + c.add(trail(new Chunk("slalom", 42 * u, 2).add(6 * u, HURDLE, 0).add(18 * u, HURDLE, 1) + .add(30 * u, HURDLE, 2), 2, 36, 2).add(36 * u, GEM, 0)); + c.add(trail(new Chunk("weave", 52 * u, 3).add(6 * u, HAULER, 0).add(6 * u, HAULER, 1) + .add(30 * u, HAULER, 1).add(30 * u, HAULER, 2), 2, 46, 2).add(46 * u, SHIELD, 0)); + c.add(trail(new Chunk("combo", 38 * u, 4).add(6 * u, HURDLE, Placement.ALL_LANES) + .add(26 * u, BARRIER, Placement.ALL_LANES), 2, 32, 1).add(32 * u, BOOST, 2)); + c.add(trail(new Chunk("traffic", 56 * u, 5).add(6 * u, HAULER, 1).add(26 * u, HAULER, 0) + .add(26 * u, HAULER, 2).add(44 * u, BARRIER, Placement.ALL_LANES), 2, 50, 1).add(50 * u, GEM, 2)); + c.add(trail(new Chunk("gauntlet", 42 * u, 6).add(6 * u, HAULER, 1).add(8 * u, BARRIER, 0) + .add(8 * u, HURDLE, 2).add(30 * u, GAP, Placement.ALL_LANES), 18, 38, 1).add(38 * u, GEM, 0)); + return c; + } + + /// Coins every 2 m in `lane` from `from` to `to` metres into the chunk. + private static Chunk trail(Chunk c, int from, int to, int lane) { + for (int m = from; m <= to; m += 2) { + c.add(m * Track.UNIT, COIN, lane); + } + return c; + } + + /// Every official level, in the order the home screen lists them. + public static List levels() { + List l = new ArrayList(); + l.add(tutorial()); + l.add(causeway()); + l.add(endless()); + return l; + } + + public static LevelDef level(String id) { + List l = levels(); + for (int i = 0; i < l.size(); i++) { + if (l.get(i).id.equals(id)) { + return l.get(i); + } + } + return null; + } + + /// The how-to-play level: side-on, one move at a time, nothing lethal. + public static LevelDef tutorial() { + LevelDef d = new LevelDef(TUTORIAL, "First Steps"); + d.theme = "daybreak"; + d.length = 400 * Track.UNIT; + d.accel = 0; + d.stars = new int[] {400, 450, 500}; + d.cue(0, ViewMode.SIDE_2D, 0); + d.dialog.add(new DialogLine(20 * Track.UNIT, "Coach", "Swipe up to jump the hurdle.", Input.JUMP)); + d.place(50, HURDLE, Placement.ALL_LANES); + d.place(90, HURDLE, Placement.ALL_LANES); + d.dialog.add(new DialogLine(130 * Track.UNIT, "Coach", "Swipe down to slide under the barrier.", Input.SLIDE)); + d.place(160, BARRIER, Placement.ALL_LANES); + d.place(210, BARRIER, Placement.ALL_LANES); + for (int i = 0; i < 10; i++) { + d.place(240 + i * 3, COIN, Placement.ALL_LANES); + } + d.dialog.add(new DialogLine(290 * Track.UNIT, "Coach", "Gaps need a jump too. Then you are ready.", Input.JUMP)); + d.place(320, GAP, Placement.ALL_LANES); + d.place(370, GEM, Placement.ALL_LANES); + return d; + } + + // tag::dash-causeway[] + /// Opens side-on and swings into the chase camera at 300 metres, where the + /// lanes appear: the faux-2D-to-3D level. + public static LevelDef causeway() { + LevelDef d = new LevelDef(CAUSEWAY, "Neon Causeway"); + d.theme = "neon"; + d.length = 1500 * Track.UNIT; + d.stars = new int[] {900, 1400, 1800}; + d.cue(0, ViewMode.SIDE_2D, 0); + d.cue(300, ViewMode.CHASE_3D, 40); + for (int m = 60; m < 280; m += 45) { + d.place(m, (m / 45) % 2 == 0 ? HURDLE : BARRIER, Placement.ALL_LANES); + d.place(m + 15, COIN, Placement.ALL_LANES); + } + d.dialog.add(new DialogLine(290 * Track.UNIT, "Vee", "The road opens up. Swipe sideways!", Input.LEFT)); + d.proceduralFrom = 340 * Track.UNIT; + d.difficultyStep = 180 * Track.UNIT; + d.chunks.addAll(chunks()); + return d; + } + // end::dash-causeway[] + + /// The chase-view endless run every leaderboard and the daily challenge use. + public static LevelDef endless() { + LevelDef d = new LevelDef(ENDLESS, "Endless"); + d.theme = "ember"; + d.goal = Goal.ENDLESS; + d.stars = new int[] {500, 1500, 3000}; + d.cue(0, ViewMode.CHASE_3D, 0); + d.proceduralFrom = 40 * Track.UNIT; + d.chunks.addAll(chunks()); + return d; + } + + /// The seed of the daily challenge for a day number (days since the epoch, in + /// UTC, which the server supplies). Everyone plays the same track that day. + public static long dailySeed(int day) { + return new Rng(0xDA11L * 1000003L + day).nextLong(); + } + + /// The seed of a level's weekly board for a week number (days since the + /// epoch divided by seven). A weekly score only counts on this seed. + public static long weeklySeed(int week, String levelId) { + return new Rng(0x3EE7L * 1000003L + week * 31L + new Hash().add(levelId).value()).nextLong(); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/DialogLine.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/DialogLine.java new file mode 100644 index 00000000000..14270f3b7b5 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/DialogLine.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// A line the client shows when the runner reaches distance `at`. A `hint` names +/// the move it teaches (an [Input] bit, or 0) so the tutorial can draw the swipe. +public final class DialogLine { + public final int at; + public final String speaker; + public final String text; + public final int hint; + + public DialogLine(int at, String speaker, String text, int hint) { + this.at = at; + this.speaker = speaker; + this.text = text; + this.hint = hint; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/FixedMath.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/FixedMath.java new file mode 100644 index 00000000000..762db420432 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/FixedMath.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// Integer helpers for the simulation. +/// +/// The rules use no `float` or `double` at all. Floating point is where platforms +/// disagree (fused multiply-add, x87 precision, constant folding), and one ulp of +/// difference in a position is a desync two minutes later. Every length is an `int` +/// in millimetres ([Track#UNIT] per metre) and every product goes through `long`. +public final class FixedMath { + private FixedMath() { + } + + /// Floor of the square root of a non-negative value, by Newton iteration. + public static long isqrt(long v) { + if (v <= 0) { + return 0; + } + long x = v; + long y = (x + 1) >>> 1; + while (y < x) { + x = y; + y = (x + v / x) >>> 1; + } + return x; + } + + public static long distSq(int x1, int y1, int x2, int y2) { + long dx = (long) x2 - x1; + long dy = (long) y2 - y1; + return dx * dx + dy * dy; + } + + /// True when the two points are no further apart than `range`. + public static boolean within(int x1, int y1, int x2, int y2, int range) { + long r = range; + return distSq(x1, y1, x2, y2) <= r * r; + } + + public static int clamp(int v, int lo, int hi) { + return v < lo ? lo : (v > hi ? hi : v); + } + + /// `a * num / den` without overflowing, rounding toward zero. A zero `den` + /// answers 0 explicitly: ParparVM's integer division does not throw on zero, + /// so the rules never divide by a value they have not checked. + public static int muldiv(int a, int num, int den) { + if (den == 0) { + return 0; + } + return (int) ((long) a * num / den); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Goal.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Goal.java new file mode 100644 index 00000000000..1f02f122b33 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Goal.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// What finishes a level. A run that has not finished ends only when the runner +/// falls. +public enum Goal { + /// Reach the level's length. + DISTANCE, + /// Collect `goalValue` coins. + COINS, + /// Stay up for `goalValue` ticks. + SURVIVE, + /// Nothing finishes it: run until you fall, for the score. + ENDLESS +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Hash.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Hash.java new file mode 100644 index 00000000000..73d5c0d2242 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Hash.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// 64-bit FNV-1a, fed integers and strings. Used for state hashes and level +/// content hashes; both must agree byte for byte on every runtime, so strings are +/// hashed by `char`, never through an encoding. +public final class Hash { + private long h = 0xcbf29ce484222325L; + + public Hash add(long v) { + for (int i = 0; i < 8; i++) { + h ^= (v >>> (i * 8)) & 0xff; + h *= 0x100000001b3L; + } + return this; + } + + public Hash add(boolean b) { + return add(b ? 1 : 0); + } + + public Hash add(String s) { + if (s == null) { + return add(-1); + } + add(s.length()); + for (int i = 0; i < s.length(); i++) { + add(s.charAt(i)); + } + return this; + } + + public long value() { + return h; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Input.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Input.java new file mode 100644 index 00000000000..8e7a29bc83b --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Input.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// The bits a runner's input for one tick is made of. Each is an edge -- the +/// moment the player swiped or pressed -- not a held key, so a replay records only +/// the ticks on which something happened. +public final class Input { + public static final int NONE = 0; + public static final int LEFT = 1; + public static final int RIGHT = 2; + public static final int JUMP = 4; + public static final int SLIDE = 8; + + /// Every bit an input may carry; anything else is refused by [Replay#decode] + /// and by the server. + public static final int ALL = LEFT | RIGHT | JUMP | SLIDE; + + private Input() { + } + + public static boolean valid(int bits) { + return (bits & ~ALL) == 0; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Json.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Json.java new file mode 100644 index 00000000000..2930c49ec92 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Json.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The JSON a level is written in, read and written without reflection so +/// the app, the server and the tests share it. It is deliberately small: objects +/// keep their key order, numbers are whole (a level has no use for a fraction and +/// the rules have no floating point), and a document is refused past fixed limits +/// on size and nesting, because levels arrive from strangers. +public final class Json { + public static final int MAX_CHARS = 512 * 1024; + public static final int MAX_DEPTH = 32; + + private final String src; + private final boolean lenient; + private int at; + private int depth; + + private Json(String src, boolean lenient) { + this.src = src; + this.lenient = lenient; + } + + /// Parses a document into maps (`LinkedHashMap`), lists, strings, `Long`s, + /// `Boolean`s and null. + public static Object parse(String text) { + return parse(text, false, MAX_CHARS); + } + + /// Parses JSON written by other tools -- a glTF file's header -- where numbers + /// may have fractions. Those come back as their text, unconverted: nothing here + /// does arithmetic with them, and the rules must never see a float. + public static Object parseLenient(String text, int maxChars) { + return parse(text, true, maxChars); + } + + private static Object parse(String text, boolean lenient, int maxChars) { + if (text == null) { + throw new IllegalArgumentException("empty document"); + } + if (text.length() > maxChars) { + throw new IllegalArgumentException("document too large"); + } + Json p = new Json(text, lenient); + p.ws(); + Object v = p.value(); + p.ws(); + if (p.at != text.length()) { + throw p.error("trailing characters"); + } + return v; + } + + private IllegalArgumentException error(String what) { + return new IllegalArgumentException(what + " at " + at); + } + + private void ws() { + while (at < src.length()) { + char c = src.charAt(at); + if (c != ' ' && c != '\n' && c != '\r' && c != '\t') { + return; + } + at++; + } + } + + private char peek() { + if (at >= src.length()) { + throw error("unexpected end"); + } + return src.charAt(at); + } + + private Object value() { + char c = peek(); + if (c == '{') { + return object(); + } + if (c == '[') { + return array(); + } + if (c == '"') { + return string(); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return number(); + } + if (src.startsWith("true", at)) { + at += 4; + return Boolean.TRUE; + } + if (src.startsWith("false", at)) { + at += 5; + return Boolean.FALSE; + } + if (src.startsWith("null", at)) { + at += 4; + return null; + } + throw error("unexpected character"); + } + + private void enter() { + if (++depth > MAX_DEPTH) { + throw error("nested too deeply"); + } + } + + private Map object() { + enter(); + at++; + Map out = new LinkedHashMap(); + ws(); + if (peek() == '}') { + at++; + depth--; + return out; + } + while (true) { + ws(); + if (peek() != '"') { + throw error("expected a key"); + } + String key = string(); + ws(); + if (peek() != ':') { + throw error("expected ':'"); + } + at++; + ws(); + if (out.containsKey(key)) { + throw error("duplicate key " + key); + } + out.put(key, value()); + ws(); + char c = peek(); + at++; + if (c == '}') { + depth--; + return out; + } + if (c != ',') { + throw error("expected ',' or '}'"); + } + } + } + + private List array() { + enter(); + at++; + List out = new ArrayList(); + ws(); + if (peek() == ']') { + at++; + depth--; + return out; + } + while (true) { + ws(); + out.add(value()); + ws(); + char c = peek(); + at++; + if (c == ']') { + depth--; + return out; + } + if (c != ',') { + throw error("expected ',' or ']'"); + } + } + } + + private String string() { + at++; + StringBuilder sb = new StringBuilder(); + while (true) { + char c = peek(); + at++; + if (c == '"') { + return sb.toString(); + } + if (c < 0x20) { + throw error("control character in string"); + } + if (c != '\\') { + sb.append(c); + continue; + } + char e = peek(); + at++; + switch (e) { + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + case '/': sb.append('/'); break; + case 'n': sb.append('\n'); break; + case 't': sb.append('\t'); break; + case 'r': sb.append('\r'); break; + case 'b': sb.append('\b'); break; + case 'f': sb.append('\f'); break; + case 'u': + if (at + 4 > src.length()) { + throw error("short escape"); + } + sb.append((char) hex4(src.substring(at, at + 4))); + at += 4; + break; + default: + throw error("bad escape"); + } + } + } + + private int hex4(String h) { + int v = 0; + for (int i = 0; i < 4; i++) { + char c = h.charAt(i); + int d; + if (c >= '0' && c <= '9') { + d = c - '0'; + } else if (c >= 'a' && c <= 'f') { + d = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + d = c - 'A' + 10; + } else { + throw error("bad escape"); + } + v = v * 16 + d; + } + return v; + } + + private Object number() { + int start = at; + if (peek() == '-') { + at++; + } + while (at < src.length() && src.charAt(at) >= '0' && src.charAt(at) <= '9') { + at++; + } + if (at < src.length()) { + char c = src.charAt(at); + if (c == '.' || c == 'e' || c == 'E') { + if (!lenient) { + throw error("only whole numbers are allowed"); + } + while (at < src.length() && "0123456789.eE+-".indexOf(src.charAt(at)) >= 0) { + at++; + } + return src.substring(start, at); + } + } + String digits = src.substring(start, at); + if (digits.length() == 0 || "-".equals(digits) || digits.length() > 18) { + throw error("bad number"); + } + return Long.valueOf(Long.parseLong(digits)); + } + + // ------------------------------------------------------------------ writing + + /// Writes `v` compactly, keys in their map's order. The same pack always + /// writes the same text, which is what its content hash is taken over. + public static String write(Object v) { + StringBuilder sb = new StringBuilder(); + write(sb, v); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static void write(StringBuilder sb, Object v) { + if (v == null) { + sb.append("null"); + } else if (v instanceof String) { + quote(sb, (String) v); + } else if (v instanceof Boolean) { + sb.append(((Boolean) v).booleanValue() ? "true" : "false"); + } else if (v instanceof Long || v instanceof Integer) { + sb.append(((Number) v).longValue()); + } else if (v instanceof Map) { + sb.append('{'); + boolean first = true; + for (Map.Entry e : ((Map) v).entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + quote(sb, e.getKey()); + sb.append(':'); + write(sb, e.getValue()); + } + sb.append('}'); + } else if (v instanceof List) { + sb.append('['); + List l = (List) v; + for (int i = 0; i < l.size(); i++) { + if (i > 0) { + sb.append(','); + } + write(sb, l.get(i)); + } + sb.append(']'); + } else { + throw new IllegalArgumentException("cannot write " + v.getClass().getName()); + } + } + + private static void quote(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c < 0x20 || c > 0x7e) { + // Everything outside printable ASCII is escaped, so the text -- + // and the hash taken over it -- is the same on every platform. + String h = Integer.toHexString(c); + sb.append("\\u"); + for (int k = h.length(); k < 4; k++) { + sb.append('0'); + } + sb.append(h); + } else { + sb.append(c); + } + } + sb.append('"'); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelCodec.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelCodec.java new file mode 100644 index 00000000000..a44bb677a21 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelCodec.java @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A level as JSON: what the editor saves, the server stores and a share link +/// carries. A level may bring its own obstacles and pickups; their ids must be +/// namespaced `u::` so a community level can never redefine a +/// built-in one. Each may name `art`, an asset id the client resolves to a sprite +/// or a glTF model. +/// +/// Distances in the document are millimetres, exactly as the rules hold them. +public final class LevelCodec { + /// A decoded level, its own catalog entries and the art they name. + public static final class Decoded { + public final LevelDef level; + public final Catalog custom; + public final Map art; + /// The runner model the level asks for, or null for the player's own. + public final String runnerArt; + + Decoded(LevelDef level, Catalog custom, Map art, String runnerArt) { + this.level = level; + this.custom = custom; + this.art = art; + this.runnerArt = runnerArt; + } + + /// The built-in catalog plus this level's own entries. + public Catalog catalog() { + return Content.catalog().with(custom); + } + } + + public static final String CUSTOM_PREFIX = "u:"; + + private LevelCodec() { + } + + public static String encode(LevelDef d) { + return encode(d, null, null, null); + } + + /// `customObstacles`/`customPickups` may be null; `art` maps a definition id + /// to its asset id. + public static String encode(LevelDef d, List customDefs, Map art, String runnerArt) { + Map m = new LinkedHashMap(); + m.put("format", Long.valueOf(1)); + m.put("id", d.id); + m.put("name", d.name); + m.put("theme", d.theme); + m.put("lanes", num(d.lanes)); + m.put("startSpeed", num(d.startSpeed)); + m.put("maxSpeed", num(d.maxSpeed)); + m.put("accel", num(d.accel)); + m.put("length", num(d.length)); + m.put("goal", d.goal.name()); + m.put("goalValue", num(d.goalValue)); + m.put("proceduralFrom", num(d.proceduralFrom)); + m.put("difficultyStep", num(d.difficultyStep)); + List stars = new ArrayList(); + for (int i = 0; i < d.stars.length; i++) { + stars.add(num(d.stars[i])); + } + m.put("stars", stars); + m.put("placements", placements(d.placements)); + List chunks = new ArrayList(); + for (int i = 0; i < d.chunks.size(); i++) { + Chunk c = d.chunks.get(i); + Map cm = new LinkedHashMap(); + cm.put("id", c.id); + cm.put("length", num(c.length)); + cm.put("minDifficulty", num(c.minDifficulty)); + cm.put("placements", placements(c.placements())); + chunks.add(cm); + } + m.put("chunks", chunks); + List cues = new ArrayList(); + for (int i = 0; i < d.cues.size(); i++) { + ViewCue c = d.cues.get(i); + Map cm = new LinkedHashMap(); + cm.put("at", num(c.at)); + cm.put("mode", c.mode.name()); + cm.put("blend", num(c.blendMillis)); + cues.add(cm); + } + m.put("cues", cues); + List dialog = new ArrayList(); + for (int i = 0; i < d.dialog.size(); i++) { + DialogLine l = d.dialog.get(i); + Map lm = new LinkedHashMap(); + lm.put("at", num(l.at)); + lm.put("speaker", l.speaker); + lm.put("text", l.text); + lm.put("hint", num(l.hint)); + dialog.add(lm); + } + m.put("dialog", dialog); + List defs = new ArrayList(); + if (customDefs != null) { + for (int i = 0; i < customDefs.size(); i++) { + Object o = customDefs.get(i); + Map dm = new LinkedHashMap(); + if (o instanceof ObstacleDef) { + ObstacleDef od = (ObstacleDef) o; + dm.put("kind", "obstacle"); + dm.put("id", od.id); + dm.put("clearance", od.clearance.name()); + dm.put("depth", num(od.depth)); + dm.put("height", num(od.height)); + dm.put("lethal", Boolean.valueOf(od.lethal)); + } else if (o instanceof PickupDef) { + PickupDef pd = (PickupDef) o; + dm.put("kind", "pickup"); + dm.put("id", pd.id); + dm.put("pickup", pd.kind.name()); + dm.put("points", num(pd.points)); + dm.put("ticks", num(pd.ticks)); + } else { + continue; + } + String a = art == null ? null : art.get(dm.get("id")); + if (a != null) { + dm.put("art", a); + } + defs.add(dm); + } + } + m.put("defs", defs); + if (runnerArt != null) { + m.put("runnerArt", runnerArt); + } + return Json.write(m); + } + + private static List placements(List ps) { + List out = new ArrayList(); + for (int i = 0; i < ps.size(); i++) { + Placement p = ps.get(i); + List row = new ArrayList(); + row.add(num(p.z)); + row.add(p.defId); + row.add(num(p.lane)); + out.add(row); + } + return out; + } + + private static Long num(int v) { + return Long.valueOf(v); + } + + /// Parses a level. Throws `IllegalArgumentException` on anything malformed; + /// it does not judge whether the level is any good, which is [Validator]'s job. + public static Decoded decode(String json) { + Object root = Json.parse(json); + if (!(root instanceof Map)) { + throw new IllegalArgumentException("a level is an object"); + } + Map m = (Map) root; + LevelDef d = new LevelDef(str(m, "id", null), str(m, "name", "Untitled")); + d.theme = str(m, "theme", d.theme); + d.lanes = integer(m, "lanes", d.lanes); + d.startSpeed = integer(m, "startSpeed", d.startSpeed); + d.maxSpeed = integer(m, "maxSpeed", d.maxSpeed); + d.accel = integer(m, "accel", d.accel); + d.length = integer(m, "length", d.length); + d.goal = goal(str(m, "goal", d.goal.name())); + d.goalValue = integer(m, "goalValue", 0); + d.proceduralFrom = integer(m, "proceduralFrom", 0); + d.difficultyStep = integer(m, "difficultyStep", d.difficultyStep); + List stars = list(m, "stars"); + if (stars.size() == 3) { + d.stars = new int[] {asInt(stars.get(0)), asInt(stars.get(1)), asInt(stars.get(2))}; + } + readPlacements(list(m, "placements"), d.placements); + List chunks = list(m, "chunks"); + for (int i = 0; i < chunks.size(); i++) { + Map cm = map(chunks.get(i)); + Chunk c = new Chunk(str(cm, "id", "chunk" + i), integer(cm, "length", 20 * Track.UNIT), + integer(cm, "minDifficulty", 0)); + List ps = new ArrayList(); + readPlacements(list(cm, "placements"), ps); + for (int j = 0; j < ps.size(); j++) { + c.add(ps.get(j).z, ps.get(j).defId, ps.get(j).lane); + } + d.chunks.add(c); + } + List cues = list(m, "cues"); + for (int i = 0; i < cues.size(); i++) { + Map cm = map(cues.get(i)); + d.cues.add(new ViewCue(integer(cm, "at", 0), view(str(cm, "mode", "CHASE_3D")), integer(cm, "blend", 0))); + } + List dialog = list(m, "dialog"); + for (int i = 0; i < dialog.size(); i++) { + Map lm = map(dialog.get(i)); + d.dialog.add(new DialogLine(integer(lm, "at", 0), str(lm, "speaker", ""), str(lm, "text", ""), + integer(lm, "hint", 0) & Input.ALL)); + } + Catalog custom = new Catalog(); + Map art = new LinkedHashMap(); + List defs = list(m, "defs"); + for (int i = 0; i < defs.size(); i++) { + Map dm = map(defs.get(i)); + String id = str(dm, "id", ""); + if (!id.startsWith(CUSTOM_PREFIX) || Content.catalog().has(id)) { + throw new IllegalArgumentException("custom ids start with u: (" + id + ")"); + } + if ("obstacle".equals(str(dm, "kind", ""))) { + custom.add(new ObstacleDef(id, clearance(str(dm, "clearance", "JUMP")), integer(dm, "depth", 300), + integer(dm, "height", 600), Boolean.TRUE.equals(dm.get("lethal")))); + } else if ("pickup".equals(str(dm, "kind", ""))) { + custom.add(new PickupDef(id, pickupKind(str(dm, "pickup", "COIN")), integer(dm, "points", 10), + integer(dm, "ticks", 0))); + } else { + throw new IllegalArgumentException("unknown def kind for " + id); + } + Object a = dm.get("art"); + if (a instanceof String) { + art.put(id, (String) a); + } + } + Object runner = m.get("runnerArt"); + return new Decoded(d, custom, art, runner instanceof String ? (String) runner : null); + } + + private static void readPlacements(List rows, List into) { + for (int i = 0; i < rows.size(); i++) { + Object r = rows.get(i); + if (!(r instanceof List) || ((List) r).size() != 3) { + throw new IllegalArgumentException("a placement is [z, id, lane]"); + } + List row = (List) r; + Object id = row.get(1); + if (!(id instanceof String)) { + throw new IllegalArgumentException("a placement names a definition"); + } + into.add(new Placement(asInt(row.get(0)), (String) id, asInt(row.get(2)))); + } + } + + private static Goal goal(String s) { + Goal[] all = Goal.values(); + for (int i = 0; i < all.length; i++) { + if (all[i].name().equals(s)) { + return all[i]; + } + } + throw new IllegalArgumentException("goal " + s); + } + + private static ViewMode view(String s) { + ViewMode[] all = ViewMode.values(); + for (int i = 0; i < all.length; i++) { + if (all[i].name().equals(s)) { + return all[i]; + } + } + throw new IllegalArgumentException("view " + s); + } + + private static Clearance clearance(String s) { + Clearance[] all = Clearance.values(); + for (int i = 0; i < all.length; i++) { + if (all[i].name().equals(s)) { + return all[i]; + } + } + throw new IllegalArgumentException("clearance " + s); + } + + private static PickupKind pickupKind(String s) { + PickupKind[] all = PickupKind.values(); + for (int i = 0; i < all.length; i++) { + if (all[i].name().equals(s)) { + return all[i]; + } + } + throw new IllegalArgumentException("pickup " + s); + } + + private static Map map(Object o) { + if (!(o instanceof Map)) { + throw new IllegalArgumentException("expected an object"); + } + return (Map) o; + } + + private static List list(Map m, String key) { + Object v = m.get(key); + if (v == null) { + return new ArrayList(); + } + if (!(v instanceof List)) { + throw new IllegalArgumentException(key + " is a list"); + } + return (List) v; + } + + private static String str(Map m, String key, String def) { + Object v = m.get(key); + return v instanceof String ? (String) v : def; + } + + private static int integer(Map m, String key, int def) { + Object v = m.get(key); + return v == null ? def : asInt(v); + } + + private static int asInt(Object v) { + if (!(v instanceof Long)) { + throw new IllegalArgumentException("expected a whole number"); + } + long l = ((Long) v).longValue(); + if (l < Integer.MIN_VALUE || l > Integer.MAX_VALUE) { + throw new IllegalArgumentException("number out of range"); + } + return (int) l; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelDef.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelDef.java new file mode 100644 index 00000000000..068568cb62a --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/LevelDef.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// A level: hand-placed track, an optional endless section assembled from +/// chunks, camera cues, a goal and star thresholds. This is what the editor +/// edits, what a player submits and what a leaderboard is keyed by. +/// +/// Distances are millimetres, speeds millimetres per second. +public final class LevelDef { + public String id; + public String name; + public String theme = "ember"; + public int lanes = 3; + public int startSpeed = 9000; + public int maxSpeed = 22000; + /// Millimetres per second gained every second. + public int accel = 120; + /// For [Goal#DISTANCE], the finish line. Ignored otherwise. + public int length; + public Goal goal = Goal.DISTANCE; + public int goalValue; + /// Where the endless section starts; it never starts before the last + /// hand-placed obstacle ends. + public int proceduralFrom; + /// Distance per difficulty step of the endless section. + public int difficultyStep = 150 * Track.UNIT; + /// Scores for one, two and three stars. + public int[] stars = {100, 300, 600}; + public final List placements = new ArrayList(); + public final List chunks = new ArrayList(); + public final List cues = new ArrayList(); + /// Tutorial or story lines, shown by distance. + public final List dialog = new ArrayList(); + + public LevelDef(String id, String name) { + this.id = id; + this.name = name; + } + + public LevelDef place(int metres, String defId, int lane) { + placements.add(new Placement(metres * Track.UNIT, defId, lane)); + return this; + } + + public LevelDef cue(int metres, ViewMode mode, int blendMetres) { + cues.add(new ViewCue(metres * Track.UNIT, mode, blendMetres * Track.UNIT)); + return this; + } + + /// The camera at the start of the run: the first cue at distance 0, or the + /// chase view. + public ViewMode startView() { + for (int i = 0; i < cues.size(); i++) { + if (cues.get(i).at == 0) { + return cues.get(i).mode; + } + } + return ViewMode.CHASE_3D; + } + + /// A hash of everything that changes how the level plays, so a replay or a + /// leaderboard entry can tell that a level was edited under it. Cosmetic + /// fields -- name, theme, cues, dialog -- are left out on purpose: retitling + /// a level must not wipe its scores. + public long contentHash() { + Hash h = new Hash(); + h.add(lanes).add(startSpeed).add(maxSpeed).add(accel).add(length).add(goal.ordinal()).add(goalValue) + .add(proceduralFrom).add(difficultyStep); + for (int i = 0; i < placements.size(); i++) { + Placement p = placements.get(i); + h.add(p.z).add(p.lane).add(p.defId); + } + for (int i = 0; i < chunks.size(); i++) { + Chunk c = chunks.get(i); + h.add(c.id).add(c.length).add(c.minDifficulty); + for (int j = 0; j < c.placements().size(); j++) { + Placement p = c.placements().get(j); + h.add(p.z).add(p.lane).add(p.defId); + } + } + return h.value(); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ObstacleDef.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ObstacleDef.java new file mode 100644 index 00000000000..f1ec559730f --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ObstacleDef.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// A kind of obstacle. Content, not code: the editor creates these from data and +/// a fork re-skins one by giving its id a different model or sprite. +/// +/// A non-lethal obstacle makes the runner stumble -- lose a third of their speed +/// -- and two stumbles within [Runner#STUMBLE_WINDOW] ticks end the run. +public final class ObstacleDef { + public final String id; + public final Clearance clearance; + /// Length along the track, in millimetres. + public final int depth; + /// For [Clearance#JUMP]: how high the runner's feet must be, in millimetres. + public final int height; + public final boolean lethal; + + public ObstacleDef(String id, Clearance clearance, int depth, int height, boolean lethal) { + this.id = id; + this.clearance = clearance; + this.depth = depth < 1 ? 1 : depth; + this.height = height < 0 ? 0 : height; + this.lethal = lethal; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupDef.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupDef.java new file mode 100644 index 00000000000..6355d598dfb --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupDef.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// A kind of pickup. +public final class PickupDef { + public final String id; + public final PickupKind kind; + public final int points; + /// For timed kinds, how long the effect lasts in ticks. + public final int ticks; + + public PickupDef(String id, PickupKind kind, int points, int ticks) { + this.id = id; + this.kind = kind; + this.points = points; + this.ticks = ticks < 0 ? 0 : ticks; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupKind.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupKind.java new file mode 100644 index 00000000000..44190d4fa5a --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/PickupKind.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// What collecting a pickup does. +public enum PickupKind { + /// Counts toward coins and adds its points. + COIN, + /// Adds its points only. + GEM, + /// Pulls every pickup within reach in from any lane, for its duration. + MAGNET, + /// Absorbs the next hit, until it is used or its duration runs out. + SHIELD, + /// Half again as fast and untouchable, for its duration. + BOOST +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Placement.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Placement.java new file mode 100644 index 00000000000..f1ca665a470 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Placement.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// One obstacle or pickup put on the track by a level or a chunk: a distance, a +/// lane and the id of its definition in the [Catalog]. +public final class Placement { + /// Spans every lane. The only kind of placement that reads well in + /// [ViewMode#SIDE_2D], where lanes collapse into one. + public static final int ALL_LANES = -1; + + public final int z; + public final int lane; + public final String defId; + + public Placement(int z, String defId, int lane) { + this.z = z; + this.lane = lane; + this.defId = defId; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Recorder.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Recorder.java new file mode 100644 index 00000000000..1c262f5952f --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Recorder.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// Steps a [Run] and records what it was given, so the run can be submitted as a +/// [Replay] when it ends. +public final class Recorder { + private final Run run; + private final Replay replay; + + public Recorder(Run run) { + this(run, run.level().id); + } + + /// Records under `levelId`, which for a community level is its content + /// reference (`pack@hash#level`) rather than the bare id the level carries. + public Recorder(Run run, String levelId) { + this.run = run; + this.replay = new Replay(levelId, run.level().contentHash(), run.seed(), run.runnerCount()); + } + + public void step(int[] inputs) { + if (run.over()) { + return; + } + if (inputs != null) { + for (int i = 0; i < inputs.length && i < run.runnerCount(); i++) { + replay.record(run.tick(), i, inputs[i] & Input.ALL); + } + } + run.step(inputs); + } + + public Run run() { + return run; + } + + public Replay replay() { + return replay; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Replay.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Replay.java new file mode 100644 index 00000000000..fcf297ccd4c --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Replay.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// A run as data: the level, its content hash, the seed and every non-empty input. +/// Re-simulated, never recorded frames -- which is why a leaderboard can trust a +/// score: the server plays the replay and reads the score off its own run. +/// +/// Text form: `DSH1||||||t,r,b;...` +public final class Replay { + public static final String MAGIC = "DSH1"; + + private final String rulesVersion; + private final String levelId; + private final long levelHash; + private final long seed; + private final int runners; + private final List events = new ArrayList(); + + public Replay(String levelId, long levelHash, long seed, int runners) { + this(RulesVersion.ID, levelId, levelHash, seed, runners); + } + + private Replay(String rulesVersion, String levelId, long levelHash, long seed, int runners) { + this.rulesVersion = rulesVersion; + this.levelId = levelId; + this.levelHash = levelHash; + this.seed = seed; + this.runners = runners; + } + + /// Appends one input. Ticks must not go backwards. + public void record(int tick, int runner, int bits) { + if (bits == 0) { + return; + } + if (!events.isEmpty() && events.get(events.size() - 1)[0] > tick) { + throw new IllegalArgumentException("tick " + tick + " before last"); + } + events.add(new int[] {tick, runner, bits}); + } + + public String rulesVersion() { + return rulesVersion; + } + + public String levelId() { + return levelId; + } + + public long levelHash() { + return levelHash; + } + + public long seed() { + return seed; + } + + public int runners() { + return runners; + } + + public int eventCount() { + return events.size(); + } + + public int[] event(int i) { + return events.get(i); + } + + /// Re-runs the replay to its end on `level`, which must be the level it was + /// recorded on ([#levelHash] says whether it still is). + public Run play(LevelDef level, Catalog catalog) { + return playTo(level, catalog, Integer.MAX_VALUE); + } + + /// Re-runs to `untilTick`, or the end; the replay viewer scrubs with this. + public Run playTo(LevelDef level, Catalog catalog, int untilTick) { + Run run = new Run(level, catalog, seed, runners); + int[] in = new int[runners]; + int next = 0; + while (!run.over() && run.tick() < untilTick) { + for (int i = 0; i < runners; i++) { + in[i] = 0; + } + while (next < events.size() && events.get(next)[0] == run.tick()) { + int[] e = events.get(next++); + if (e[1] >= 0 && e[1] < runners) { + in[e[1]] |= e[2]; + } + } + run.step(in); + } + return run; + } + + public String encode() { + StringBuilder sb = new StringBuilder(); + sb.append(MAGIC).append('|').append(rulesVersion).append('|').append(levelId).append('|') + .append(Long.toHexString(levelHash)).append('|').append(seed).append('|').append(runners).append('|'); + for (int i = 0; i < events.size(); i++) { + int[] e = events.get(i); + if (i > 0) { + sb.append(';'); + } + sb.append(e[0]).append(',').append(e[1]).append(',').append(e[2]); + } + return sb.toString(); + } + + /// Parses [#encode]'s output. Refuses anything malformed -- unknown input + /// bits, ticks out of order, a runner out of range -- because this is what a + /// client submits for a score. + public static Replay decode(String s) { + List f = Text.split(s, '|'); + if (f.size() != 7 || !MAGIC.equals(f.get(0))) { + throw new IllegalArgumentException("not a replay"); + } + int runners = Integer.parseInt(f.get(5)); + if (runners < 1 || runners > 8) { + throw new IllegalArgumentException("runners " + runners); + } + Replay r = new Replay(f.get(1), f.get(2), Text.parseHex(f.get(3)), Long.parseLong(f.get(4)), runners); + String body = f.get(6); + if (body.length() > 0) { + List evs = Text.split(body, ';'); + for (int i = 0; i < evs.size(); i++) { + List p = Text.split(evs.get(i), ','); + if (p.size() != 3) { + throw new IllegalArgumentException("event " + evs.get(i)); + } + int t = Integer.parseInt(p.get(0)); + int who = Integer.parseInt(p.get(1)); + int bits = Integer.parseInt(p.get(2)); + if (t < 0 || t >= Run.MAX_TICKS || who < 0 || who >= runners || bits == 0 || !Input.valid(bits)) { + throw new IllegalArgumentException("event " + evs.get(i)); + } + r.record(t, who, bits); + } + } + return r; + } + + /// A replay holding only runner `who`'s inputs, as runner 0: how a live race + /// is split into one leaderboard entry per player. + public Replay single(int who) { + Replay r = new Replay(rulesVersion, levelId, levelHash, seed, 1); + for (int i = 0; i < events.size(); i++) { + int[] e = events.get(i); + if (e[1] == who) { + r.record(e[0], 0, e[2]); + } + } + return r; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Rng.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Rng.java new file mode 100644 index 00000000000..f4614d1cd13 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Rng.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// The only source of randomness a match may use: SplitMix64, seeded by the server. +/// +/// `java.util.Random` is not used on purpose. Its algorithm is specified, but the +/// rules have to produce the same numbers on the JVM, on ParparVM and in the browser, +/// and owning the eight lines that do it is cheaper than proving every port agrees. +public final class Rng { + private long state; + + public Rng(long seed) { + state = seed; + } + + public long nextLong() { + state += 0x9E3779B97F4A7C15L; + long z = state; + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + /// A value in `[0, bound)`; `bound` must be positive. + public int nextInt(int bound) { + if (bound <= 0) { + throw new IllegalArgumentException("bound " + bound); + } + long r = nextLong() >>> 1; + return (int) (r % bound); + } + + public long state() { + return state; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/RulesVersion.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/RulesVersion.java new file mode 100644 index 00000000000..d684bea22e8 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/RulesVersion.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// Identifies the rules a replay was recorded under. Bump it whenever a change to +/// [Run], [Runner] or [Content] would make an old replay play out differently; +/// the server refuses a score whose replay names another version, and the replay +/// viewer warns when it re-runs one under newer rules. +public final class RulesVersion { + public static final String ID = "2"; + + private RulesVersion() { + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Run.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Run.java new file mode 100644 index 00000000000..57db1fd1e65 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Run.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// One run of a level by one or more runners on the same seed -- a solo run, +/// a ghost race or a live race. [#step] is the only thing that changes it. +/// +/// Runners never touch each other: a race is several independent runs on one +/// shared track, which is what lets the server re-simulate every runner of a +/// live race from their inputs alone. +public final class Run { + public static final int TICKS_PER_SECOND = 60; + /// No run lasts longer than this, whatever its goal: thirty minutes. + public static final int MAX_TICKS = 30 * 60 * TICKS_PER_SECOND; + + private final LevelDef level; + private final Track track; + private final Runner[] runners; + private final long seed; + private int tick; + + public Run(LevelDef level, Catalog catalog, long seed, int runnerCount) { + if (runnerCount < 1) { + throw new IllegalArgumentException("runners " + runnerCount); + } + this.level = level; + this.seed = seed; + this.track = new Track(level, catalog, seed); + this.runners = new Runner[runnerCount]; + int lanes = level.lanes < 1 ? 1 : level.lanes; + for (int i = 0; i < runnerCount; i++) { + runners[i] = new Runner(i, lanes / 2, level.startSpeed); + } + track.ensure(Track.HORIZON); + } + + public int tick() { + return tick; + } + + public long seed() { + return seed; + } + + public LevelDef level() { + return level; + } + + public Track track() { + return track; + } + + public int runnerCount() { + return runners.length; + } + + public Runner runner(int i) { + return runners[i]; + } + + public boolean over() { + for (int i = 0; i < runners.length; i++) { + if (runners[i].running()) { + return false; + } + } + return true; + } + + /// Advances one tick. `inputs[i]` is runner `i`'s [Input] bits for this tick; + /// a shorter array, or null, means no input. + public void step(int[] inputs) { + if (over()) { + return; + } + int lead = 0; + for (int i = 0; i < runners.length; i++) { + Runner r = runners[i]; + if (r.running()) { + int bits = inputs != null && i < inputs.length ? inputs[i] & Input.ALL : 0; + advance(r, bits); + } + lead = Math.max(lead, r.z); + } + track.ensure(lead + Track.HORIZON); + tick++; + if (tick >= MAX_TICKS) { + for (int i = 0; i < runners.length; i++) { + end(runners[i], runners[i].alive); + } + } + } + + private void advance(Runner r, int bits) { + int lanes = level.lanes < 1 ? 1 : level.lanes; + if ((bits & Input.LEFT) != 0 && r.lane > 0) { + r.lane--; + } + if ((bits & Input.RIGHT) != 0 && r.lane < lanes - 1) { + r.lane++; + } + if ((bits & Input.JUMP) != 0 && r.y == 0) { + r.vy = Runner.JUMP_VELOCITY; + r.slideTicks = 0; + } + if ((bits & Input.SLIDE) != 0) { + if (r.y > 0) { + // A slide in the air is a dive: come straight down, then slide. + r.vy = -Runner.JUMP_VELOCITY; + } + r.slideTicks = Runner.SLIDE_TICKS; + } + + int target = r.lane * 1000; + if (r.laneX < target) { + r.laneX = Math.min(target, r.laneX + Runner.LANE_SPEED); + } else if (r.laneX > target) { + r.laneX = Math.max(target, r.laneX - Runner.LANE_SPEED); + } + if (r.y > 0 || r.vy > 0) { + r.y += r.vy; + r.vy -= Runner.GRAVITY; + if (r.y <= 0) { + r.y = 0; + r.vy = 0; + } + } + if (r.slideTicks > 0 && r.y == 0) { + r.slideTicks--; + } + + r.accelCarry += level.accel; + r.speed += r.accelCarry / TICKS_PER_SECOND; + r.accelCarry %= TICKS_PER_SECOND; + if (r.speed > level.maxSpeed) { + r.speed = level.maxSpeed; + } + int speed = r.boost > 0 ? r.speed + r.speed / 2 : r.speed; + r.speedCarry += speed; + r.z += r.speedCarry / TICKS_PER_SECOND; + r.speedCarry %= TICKS_PER_SECOND; + + if (r.invulnerable > 0) { + r.invulnerable--; + } + if (r.shield > 0) { + r.shield--; + } + if (r.magnet > 0) { + r.magnet--; + } + if (r.boost > 0) { + r.boost--; + } + + collide(r); + if (!r.alive) { + return; + } + boolean done; + switch (level.goal) { + case DISTANCE: + done = level.length > 0 && r.z >= level.length; + break; + case COINS: + done = r.coins >= level.goalValue; + break; + case SURVIVE: + done = tick + 1 >= level.goalValue; + break; + default: + done = false; + break; + } + if (done) { + end(r, true); + } + } + + private void collide(Runner r) { + while (r.cursor < track.size()) { + Track.Entry e = track.get(r.cursor); + if (e.z + e.depth + 4 * Track.UNIT < r.z) { + r.cursor++; + } else { + break; + } + } + int reach = r.magnet > 0 ? 4 * Track.UNIT : 0; + for (int i = r.cursor; i < track.size(); i++) { + Track.Entry e = track.get(i); + if (e.z > r.z + Runner.HALF_DEPTH + reach) { + break; + } + if (r.took(e.index)) { + continue; + } + if (e.pickup != null) { + boolean magnetised = r.magnet > 0 && e.pickup.kind != PickupKind.MAGNET; + int window = magnetised ? reach : 0; + if (overlapsZ(r, e, window) && (magnetised || inLane(r, e, 500))) { + r.take(e.index); + collect(r, e.pickup); + } + } else if (overlapsZ(r, e, 0) && inLane(r, e, 600)) { + if (!cleared(r, e.obstacle)) { + r.take(e.index); + hit(r, e.obstacle); + if (!r.alive) { + return; + } + } + } + } + } + + private static boolean overlapsZ(Runner r, Track.Entry e, int extra) { + return r.z + Runner.HALF_DEPTH + extra >= e.z && r.z - Runner.HALF_DEPTH - extra <= e.z + e.depth; + } + + private static boolean inLane(Runner r, Track.Entry e, int tolerance) { + if (e.spansAllLanes()) { + return true; + } + int d = r.laneX - e.lane * 1000; + return (d < 0 ? -d : d) < tolerance; + } + + private static boolean cleared(Runner r, ObstacleDef o) { + switch (o.clearance) { + case JUMP: + return r.y > o.height; + case SLIDE: + return r.sliding(); + default: + return false; + } + } + + private void hit(Runner r, ObstacleDef o) { + if (r.boost > 0 || r.invulnerable > 0) { + return; + } + if (r.shield > 0) { + r.shield = 0; + r.invulnerable = 30; + return; + } + if (o.lethal || tick - r.lastStumble <= Runner.STUMBLE_WINDOW) { + end(r, false); + return; + } + r.stumbles++; + r.lastStumble = tick; + r.invulnerable = 60; + r.speed = Math.max(level.startSpeed / 2, r.speed - r.speed / 3); + } + + private static void collect(Runner r, PickupDef p) { + r.points += p.points; + switch (p.kind) { + case COIN: + r.coins++; + break; + case MAGNET: + r.magnet = p.ticks; + break; + case SHIELD: + r.shield = p.ticks; + break; + case BOOST: + r.boost = p.ticks; + break; + default: + break; + } + } + + private void end(Runner r, boolean finished) { + if (!r.running()) { + return; + } + if (finished) { + r.finished = true; + } else { + r.alive = false; + } + r.endTick = tick; + } + + /// Stars earned by runner `i`: 0 to 3. A finite goal not reached earns none. + public int stars(int i) { + Runner r = runners[i]; + if (level.goal != Goal.ENDLESS && !r.finished) { + return 0; + } + int s = 0; + for (int k = 0; k < level.stars.length && k < 3; k++) { + if (r.score() >= level.stars[k]) { + s = k + 1; + } + } + return s; + } + + /// FNV hash of everything that determines the rest of the run. Two runtimes + /// that disagree here have desynchronised. + public long stateHash() { + Hash h = new Hash(); + h.add(tick).add(seed); + for (int i = 0; i < runners.length; i++) { + runners[i].hash(h); + } + return track.hashInto(h); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Runner.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Runner.java new file mode 100644 index 00000000000..11ed1633736 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Runner.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// One runner's state. Plain fields, mutated only by [Run#step]; the renderer +/// reads them and interpolates between ticks. +public final class Runner { + /// Milli-lanes moved per tick while changing lane: eight ticks per lane. + public static final int LANE_SPEED = 125; + /// Upward speed at take-off, millimetres per tick. + public static final int JUMP_VELOCITY = 190; + /// Millimetres per tick lost every tick while airborne. + public static final int GRAVITY = 9; + public static final int SLIDE_TICKS = 36; + /// Two stumbles this close together, in ticks, end the run. + public static final int STUMBLE_WINDOW = 300; + /// Half the runner's length along the track, for overlap tests. + public static final int HALF_DEPTH = 250; + + public final int index; + public int lane; + /// Lateral position in milli-lanes: lane 1 is 1000. + public int laneX; + public int y; + public int vy; + public int slideTicks; + public int z; + /// Millimetres per second. + public int speed; + int speedCarry; + int accelCarry; + public int coins; + public int points; + public int invulnerable; + public int shield; + public int magnet; + public int boost; + public int stumbles; + int lastStumble = -STUMBLE_WINDOW - 1; + public boolean alive = true; + public boolean finished; + /// The tick the runner fell or finished; -1 while running. + public int endTick = -1; + int cursor; + private int[] taken = new int[8]; + + Runner(int index, int lane, int speed) { + this.index = index; + this.lane = lane; + this.laneX = lane * 1000; + this.speed = speed; + } + + public boolean running() { + return alive && !finished; + } + + public boolean airborne() { + return y > 0; + } + + public boolean sliding() { + return slideTicks > 0 && y == 0; + } + + /// Distance in whole metres plus pickup points. + public int score() { + return z / Track.UNIT + points; + } + + /// Whether this runner already collected, or crashed through, track entry + /// `entry`. The renderer hides what the player took. + public boolean took(int entry) { + int w = entry >>> 5; + return w < taken.length && (taken[w] & (1 << (entry & 31))) != 0; + } + + void take(int entry) { + int w = entry >>> 5; + if (w >= taken.length) { + int[] n = new int[Math.max(taken.length * 2, w + 1)]; + System.arraycopy(taken, 0, n, 0, taken.length); + taken = n; + } + taken[w] |= 1 << (entry & 31); + } + + void hash(Hash h) { + h.add(lane).add(laneX).add(y).add(vy).add(slideTicks).add(z).add(speed).add(speedCarry).add(accelCarry) + .add(coins).add(points).add(invulnerable).add(shield).add(magnet).add(boost).add(stumbles) + .add(lastStumble).add(alive).add(finished).add(endTick).add(cursor); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Text.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Text.java new file mode 100644 index 00000000000..a3b96552832 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Text.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// String helpers every runtime agrees on. `String.split` takes a regular +/// expression, which the device class library does not carry. +public final class Text { + private Text() { + } + + public static List split(String s, char sep) { + List out = new ArrayList(); + int start = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) { + out.add(s.substring(start, i)); + start = i + 1; + } + } + out.add(s.substring(start)); + return out; + } + + /// An unsigned 64-bit hex value, as `Long.toHexString` writes it. + public static long parseHex(String s) { + if (s.length() == 0 || s.length() > 16) { + throw new IllegalArgumentException("hex " + s); + } + long v = 0; + for (int i = 0; i < s.length(); i++) { + int d = Character.digit(s.charAt(i), 16); + if (d < 0) { + throw new IllegalArgumentException("hex " + s); + } + v = (v << 4) | d; + } + return v; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Track.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Track.java new file mode 100644 index 00000000000..b1d05855b72 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Track.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// The track one run is played on: the level's hand-placed entries, then the +/// endless section, generated ahead of the leading runner from the run's seed. +/// Every runner in a race shares it, so "same seed" means "same track". +/// +/// Entries are only ever appended, in distance order, which lets each runner keep +/// a cursor instead of searching. +public final class Track { + /// Millimetres per metre. + public static final int UNIT = 1000; + /// How far ahead of the leader the track is generated. + public static final int HORIZON = 120 * UNIT; + + /// One placement with its definition resolved and its lane mirrored. + public static final class Entry { + public final int index; + public final int z; + public final int lane; + public final int depth; + public final ObstacleDef obstacle; + public final PickupDef pickup; + + Entry(int index, int z, int lane, ObstacleDef obstacle, PickupDef pickup) { + this.index = index; + this.z = z; + this.lane = lane; + this.obstacle = obstacle; + this.pickup = pickup; + this.depth = obstacle != null ? obstacle.depth : 300; + } + + public boolean spansAllLanes() { + return lane == Placement.ALL_LANES; + } + } + + private final LevelDef level; + private final Catalog catalog; + private final Rng rng; + private final List entries = new ArrayList(); + private int genZ; + private final boolean procedural; + + Track(LevelDef level, Catalog catalog, long seed) { + this.level = level; + this.catalog = catalog; + this.rng = new Rng(seed ^ 0x5DA57E5ADL); + List fixed = new ArrayList(level.placements); + // A stable insertion sort: the editor may hand placements in any order, + // and two at the same distance keep the order the author gave them. + for (int i = 1; i < fixed.size(); i++) { + Placement p = fixed.get(i); + int j = i; + while (j > 0 && fixed.get(j - 1).z > p.z) { + fixed.set(j, fixed.get(j - 1)); + j--; + } + fixed.set(j, p); + } + int end = 0; + for (int i = 0; i < fixed.size(); i++) { + Placement p = fixed.get(i); + Entry e = resolve(p.z, p.lane, p.defId); + if (e != null) { + entries.add(e); + end = Math.max(end, e.z + e.depth); + } + } + genZ = Math.max(level.proceduralFrom, end + 4 * UNIT); + procedural = !level.chunks.isEmpty(); + } + + private Entry resolve(int z, int lane, String id) { + ObstacleDef o = catalog.obstacle(id); + PickupDef p = o == null ? catalog.pickup(id) : null; + if (o == null && p == null) { + // Unknown ids are the validator's to report; the run skips them so a + // level edited against a newer catalog still loads. + return null; + } + return new Entry(entries.size(), z, lane, o, p); + } + + public int difficultyAt(int z) { + int step = level.difficultyStep < UNIT ? UNIT : level.difficultyStep; + int d = z / step; + return d > 10 ? 10 : d; + } + + /// Generates the endless section up to `z`. Called by the run before every + /// tick with the leader's distance plus [#HORIZON]. + void ensure(int z) { + if (!procedural) { + return; + } + int limit = level.goal == Goal.DISTANCE && level.length > 0 ? level.length : Integer.MAX_VALUE; + while (genZ < z && genZ < limit) { + int diff = difficultyAt(genZ); + int eligible = 0; + for (int i = 0; i < level.chunks.size(); i++) { + if (level.chunks.get(i).minDifficulty <= diff) { + eligible++; + } + } + if (eligible == 0) { + // Nothing easy enough yet: leave an empty stretch and look again. + genZ += 20 * UNIT; + continue; + } + int pick = rng.nextInt(eligible); + Chunk chunk = null; + for (int i = 0; i < level.chunks.size(); i++) { + if (level.chunks.get(i).minDifficulty <= diff && pick-- == 0) { + chunk = level.chunks.get(i); + break; + } + } + boolean mirror = level.lanes > 1 && rng.nextInt(2) == 1; + List ps = chunk.placements(); + for (int i = 0; i < ps.size(); i++) { + Placement p = ps.get(i); + int lane = p.lane; + if (mirror && lane != Placement.ALL_LANES) { + lane = level.lanes - 1 - lane; + } + Entry e = resolve(genZ + p.z, lane, p.defId); + if (e != null) { + entries.add(e); + } + } + genZ += chunk.length; + } + } + + public int size() { + return entries.size(); + } + + public Entry get(int i) { + return entries.get(i); + } + + public LevelDef level() { + return level; + } + + /// The camera mode in force at `z`, and how far through its blend the runner + /// is, from 0 to 1000. For the renderer; the simulation never asks. + public ViewMode viewAt(int z) { + ViewMode m = ViewMode.CHASE_3D; + int best = -1; + for (int i = 0; i < level.cues.size(); i++) { + ViewCue c = level.cues.get(i); + if (c.at <= z && c.at >= best) { + best = c.at; + m = c.mode; + } + } + return m; + } + + /// The mode in force before the cue that `z` is blending into, for the + /// renderer to interpolate from. + public ViewMode previousViewAt(int z) { + ViewCue cur = cueAt(z); + if (cur == null) { + return ViewMode.CHASE_3D; + } + return cur.at == 0 ? cur.mode : viewAt(cur.at - 1); + } + + /// 0..1000: how far the camera has blended into the current cue's mode. + public int blendAt(int z) { + ViewCue c = cueAt(z); + if (c == null || c.blendMillis == 0) { + return 1000; + } + int into = z - c.at; + return into >= c.blendMillis ? 1000 : FixedMath.muldiv(into, 1000, c.blendMillis); + } + + private ViewCue cueAt(int z) { + ViewCue found = null; + for (int i = 0; i < level.cues.size(); i++) { + ViewCue c = level.cues.get(i); + if (c.at <= z && (found == null || c.at >= found.at)) { + found = c; + } + } + return found; + } + + long hashInto(Hash h) { + h.add(entries.size()).add(genZ).add(rng.state()); + return h.value(); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Validator.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Validator.java new file mode 100644 index 00000000000..1a73605f8aa --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/Validator.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import java.util.ArrayList; +import java.util.List; + +/// Checks a level before it is played, saved or submitted. The editor shows the +/// problems as the author works; the server runs the same checks and refuses a +/// submission with any error. +public final class Validator { + /// One finding. Warnings do not block a submission. + public static final class Problem { + public final boolean error; + public final String message; + + Problem(boolean error, String message) { + this.error = error; + this.message = message; + } + + @Override + public String toString() { + return (error ? "error: " : "warning: ") + message; + } + } + + private Validator() { + } + + public static List check(LevelDef d, Catalog c) { + List out = new ArrayList(); + if (d.id == null || d.id.length() == 0 || d.id.indexOf('|') >= 0) { + out.add(new Problem(true, "the level needs an id without '|'")); + } + if (d.lanes < 1 || d.lanes > 5) { + out.add(new Problem(true, "lanes must be 1 to 5")); + } + if (d.startSpeed < 2000 || d.maxSpeed < d.startSpeed || d.maxSpeed > 40000) { + out.add(new Problem(true, "speeds must be 2..40 m/s, max at least start")); + } + if (d.goal == Goal.DISTANCE && d.length < 50 * Track.UNIT) { + out.add(new Problem(true, "a distance goal needs a length of 50 m or more")); + } + if ((d.goal == Goal.COINS || d.goal == Goal.SURVIVE) && d.goalValue <= 0) { + out.add(new Problem(true, "the goal needs a target value")); + } + if (d.goal == Goal.ENDLESS && d.chunks.isEmpty()) { + out.add(new Problem(true, "an endless level needs at least one chunk")); + } + for (int i = 1; i < d.stars.length; i++) { + if (d.stars[i] < d.stars[i - 1]) { + out.add(new Problem(true, "star scores must not decrease")); + } + } + for (int i = 0; i < d.placements.size(); i++) { + checkPlacement(out, d, c, d.placements.get(i), "at " + d.placements.get(i).z / Track.UNIT + " m"); + Placement p = d.placements.get(i); + if (p.lane != Placement.ALL_LANES && viewAt(d, p.z) == ViewMode.SIDE_2D) { + out.add(new Problem(false, p.defId + " at " + p.z / Track.UNIT + + " m is in one lane, but the camera is side-on there")); + } + } + for (int i = 0; i < d.chunks.size(); i++) { + Chunk ch = d.chunks.get(i); + for (int j = 0; j < ch.placements().size(); j++) { + Placement p = ch.placements().get(j); + checkPlacement(out, d, c, p, "in chunk " + ch.id); + if (p.z >= ch.length) { + out.add(new Problem(true, p.defId + " lies past the end of chunk " + ch.id)); + } + } + } + return out; + } + + private static void checkPlacement(List out, LevelDef d, Catalog c, Placement p, String where) { + if (!c.has(p.defId)) { + out.add(new Problem(true, "unknown " + p.defId + " " + where)); + } + if (p.lane != Placement.ALL_LANES && (p.lane < 0 || p.lane >= d.lanes)) { + out.add(new Problem(true, p.defId + " " + where + " is in lane " + p.lane + " of " + d.lanes)); + } + if (p.z < 0) { + out.add(new Problem(true, p.defId + " " + where + " is before the start")); + } + } + + private static ViewMode viewAt(LevelDef d, int z) { + ViewMode m = ViewMode.CHASE_3D; + int best = -1; + for (int i = 0; i < d.cues.size(); i++) { + ViewCue cue = d.cues.get(i); + if (cue.at <= z && cue.at >= best) { + best = cue.at; + m = cue.mode; + } + } + return m; + } + + public static boolean hasErrors(List ps) { + for (int i = 0; i < ps.size(); i++) { + if (ps.get(i).error) { + return true; + } + } + return false; + } + + /// True when a strong bot finishes a finite-goal level on at least one of + /// three seeds, or survives 500 m of an endless one. The server requires it + /// before a community level is published; the editor offers it as a button. + public static boolean beatable(LevelDef d, Catalog c) { + for (long seed = 1; seed <= 3; seed++) { + Run run = new Run(d, c, seed, 1); + Bot bot = new Bot(seed, 100); + int[] in = new int[1]; + while (!run.over()) { + in[0] = bot.decide(run, 0); + run.step(in); + if (d.goal == Goal.ENDLESS && run.runner(0).z >= 500 * Track.UNIT) { + return true; + } + } + if (run.runner(0).finished) { + return true; + } + } + return false; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewCue.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewCue.java new file mode 100644 index 00000000000..870a8479cad --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewCue.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// From distance `at` onwards the camera moves to `mode`, blending over +/// `blendMillis` of distance travelled. Blending by distance rather than by time +/// keeps the transition at the same spot on the track at every speed. +public final class ViewCue { + public final int at; + public final ViewMode mode; + public final int blendMillis; + + public ViewCue(int at, ViewMode mode, int blendMillis) { + this.at = at; + this.mode = mode; + this.blendMillis = blendMillis < 0 ? 0 : blendMillis; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewMode.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewMode.java new file mode 100644 index 00000000000..9ecfc973960 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/ViewMode.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +/// How the client draws a stretch of track. The simulation never reads it: the +/// same run plays out identically whatever the camera does. +public enum ViewMode { + /// A flat side-on view, the "faux 2D" a level can open with. Lanes collapse, + /// so a stretch drawn this way should only hold placements that span every + /// lane ([Validator] warns otherwise). + SIDE_2D, + /// Behind and above the runner, in perspective. + CHASE_3D, + /// Straight down on the lanes. + TOP_DOWN +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/AssetRef.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/AssetRef.java new file mode 100644 index 00000000000..7394f132f21 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/AssetRef.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +/// A file a pack uses that is not part of the app: an uploaded glTF character or +/// a portrait. It is named by the SHA-256 of its bytes, so a pack's hash covers the +/// exact files it was published with, and the same file uploaded twice is stored once. +public final class AssetRef { + public static final String MODEL = "model"; + public static final String IMAGE = "image"; + + /// Lower-case hex SHA-256 of the bytes. + public String sha256 = ""; + public String kind = MODEL; + public String name = ""; + public int bytes; + + /// How a level's definitions refer to it, in their `art` field. + public String ref() { + return "asset:" + sha256; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentPack.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentPack.java new file mode 100644 index 00000000000..2bb9cd45fa1 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentPack.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.PickupDef; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// What a player builds in the Forge and publishes: one or more levels, the +/// obstacles and pickups they invent, and the art those use. Official content is +/// a pack too ([Samples#official]), so the game's own levels go through the same +/// path as anyone's. +public final class ContentPack { + /// The version of the pack format [PackCodec] writes. + public static final int FORMAT = 1; + + public String id = ""; + public String name = ""; + public String author = ""; + public String description = ""; + /// Set by the server on publishing; 0 for a draft. + public int version; + /// [ObstacleDef] and [PickupDef] entries, ids namespaced `u:`. + public List defs = new ArrayList(); + /// Definition id (or [#RUNNER]) to asset reference ([AssetRef#ref]). + public Map art = new LinkedHashMap(); + public List levels = new ArrayList(); + public List assets = new ArrayList(); + + /// The key under [#art] for a runner model the pack replaces the player's with. + public static final String RUNNER = "runner"; + + /// The built-in catalog plus this pack's definitions. + public Catalog catalog() { + Catalog own = new Catalog(); + for (int i = 0; i < defs.size(); i++) { + Object d = defs.get(i); + if (d instanceof ObstacleDef) { + own.add((ObstacleDef) d); + } else if (d instanceof PickupDef) { + own.add((PickupDef) d); + } + } + return Content.catalog().with(own); + } + + public LevelDef level(String levelId) { + for (int i = 0; i < levels.size(); i++) { + if (levels.get(i).id.equals(levelId)) { + return levels.get(i); + } + } + return null; + } + + public AssetRef asset(String sha256) { + for (int i = 0; i < assets.size(); i++) { + if (assets.get(i).sha256.equals(sha256)) { + return assets.get(i); + } + } + return null; + } + + /// A 64-bit FNV-1a hash of the pack's canonical text, in 16 hex digits. + public String hash() { + return hashOf(PackCodec.write(this)); + } + + public static String hashOf(String canonical) { + long h = 0xcbf29ce484222325L; + for (int i = 0; i < canonical.length(); i++) { + char c = canonical.charAt(i); + h ^= c & 0xff; + h *= 0x100000001b3L; + h ^= (c >>> 8) & 0xff; + h *= 0x100000001b3L; + } + String hex = Long.toHexString(h); + StringBuilder sb = new StringBuilder(); + for (int i = hex.length(); i < 16; i++) { + sb.append('0'); + } + return sb.append(hex).toString(); + } + + /// A copy that shares nothing mutable with this one. + public ContentPack copy() { + return PackCodec.read(PackCodec.write(this)); + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentRef.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentRef.java new file mode 100644 index 00000000000..2e35cbf697c --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/ContentRef.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Perlevel is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +/// The name of a level in a published pack version: `pack@hash#level`. A +/// replay and a level result carry it, so the server and a replay viewer load the +/// exact content the run was played on. +public final class ContentRef { + public final String pack; + public final String hash; + public final String level; + + public ContentRef(String pack, String hash, String level) { + this.pack = pack; + this.hash = hash; + this.level = level; + } + + public static ContentRef parse(String s) { + int at = s == null ? -1 : s.indexOf('@'); + int hashMark = s == null ? -1 : s.indexOf('#', at + 1); + if (at <= 0 || hashMark <= at + 1 || hashMark == s.length() - 1) { + throw new IllegalArgumentException("not a content reference"); + } + return new ContentRef(s.substring(0, at), s.substring(at + 1, hashMark), s.substring(hashMark + 1)); + } + + @Override + public String toString() { + return pack + "@" + hash + "#" + level; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/GlbInspector.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/GlbInspector.java new file mode 100644 index 00000000000..89aae85a0db --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/GlbInspector.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Json; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Checks an uploaded binary glTF (`.glb`) before anyone loads it. +/// +/// The app renders these on the GPU and the server stores them, so a file is held +/// to the structure a character needs and to fixed limits, and nothing in it is +/// trusted: the header's lengths are checked against the bytes actually present, +/// and the JSON chunk is parsed with the same bounded parser as a pack. What the +/// file *draws* is a moderator's question; whether it is safe to open is this one. +public final class GlbInspector { + public static final int MAX_JSON_CHARS = 256 * 1024; + public static final int MAX_NODES = 256; + public static final int MAX_MESHES = 32; + public static final int MAX_ACCESSORS = 1024; + public static final int MAX_JOINTS = 128; + public static final int MAX_ANIMATIONS = 48; + /// Vertices across all meshes; a phone draws dozens of these at once. + public static final int MAX_VERTICES = 60000; + + private GlbInspector() { + } + + /// What was found: the animation names when the file is usable, or the reason not. + public static final class Report { + public final String problem; + public final List animations; + public final boolean skinned; + public final int vertices; + + Report(String problem, List animations, boolean skinned, int vertices) { + this.problem = problem; + this.animations = animations; + this.skinned = skinned; + this.vertices = vertices; + } + + public boolean ok() { + return problem == null; + } + } + + private static Report bad(String why) { + return new Report(why, new ArrayList(), false, 0); + } + + private static int u32(byte[] b, int at) { + return (b[at] & 0xff) | (b[at + 1] & 0xff) << 8 | (b[at + 2] & 0xff) << 16 | (b[at + 3] & 0xff) << 24; + } + + public static Report inspect(byte[] glb) { + if (glb == null || glb.length < 20) { + return bad("not a glTF binary"); + } + if (glb.length > Validator.MAX_MODEL_BYTES) { + return bad("larger than " + (Validator.MAX_MODEL_BYTES / (1024 * 1024)) + " MB"); + } + // "glTF", version 2, total length, then the JSON chunk. + if (u32(glb, 0) != 0x46546c67 || u32(glb, 4) != 2) { + return bad("not a glTF 2 binary"); + } + if (u32(glb, 8) != glb.length) { + return bad("the file is truncated or padded"); + } + int jsonLength = u32(glb, 12); + if (u32(glb, 16) != 0x4e4f534a || jsonLength <= 0 || jsonLength > glb.length - 20 + || jsonLength > MAX_JSON_CHARS) { + return bad("the description chunk is missing or too large"); + } + int binAt = 20 + jsonLength; + if (binAt + 8 <= glb.length) { + int binLength = u32(glb, binAt); + if (binLength < 0 || binLength > glb.length - binAt - 8 || u32(glb, binAt + 4) != 0x004e4942) { + return bad("the data chunk is malformed"); + } + } + StringBuilder sb = new StringBuilder(jsonLength); + for (int i = 0; i < jsonLength; i++) { + int c = glb[20 + i] & 0xff; + if (c >= 0x80) { + // Names may be UTF-8; the structure is ASCII. Keep a placeholder. + c = '?'; + } + sb.append((char) c); + } + Object doc; + try { + doc = Json.parseLenient(sb.toString().trim(), MAX_JSON_CHARS); + } catch (IllegalArgumentException e) { + return bad("the description is not valid JSON"); + } + if (!(doc instanceof Map)) { + return bad("the description is not an object"); + } + Map root = (Map) doc; + if (root.containsKey("extensionsRequired")) { + return bad("the model needs glTF extensions the game does not support"); + } + if (root.containsKey("buffers") && hasExternalUri(root.get("buffers"))) { + return bad("the model refers to files outside itself"); + } + if (root.containsKey("images") && hasExternalUri(root.get("images"))) { + return bad("the model refers to images outside itself"); + } + if (count(root, "nodes") > MAX_NODES || count(root, "meshes") > MAX_MESHES + || count(root, "accessors") > MAX_ACCESSORS || count(root, "animations") > MAX_ANIMATIONS) { + return bad("the model is too complex"); + } + if (count(root, "meshes") == 0) { + return bad("the model has nothing to draw"); + } + boolean skinned = false; + Object skins = root.get("skins"); + if (skins instanceof List) { + List l = (List) skins; + for (int i = 0; i < l.size(); i++) { + Object s = l.get(i); + if (s instanceof Map && ((Map) s).get("joints") instanceof List) { + if (((List) ((Map) s).get("joints")).size() > MAX_JOINTS) { + return bad("the skeleton has too many bones"); + } + skinned = true; + } + } + } + int vertices = vertices(root); + if (vertices > MAX_VERTICES) { + return bad("the model has too many vertices (" + vertices + ")"); + } + List names = new ArrayList(); + Object anims = root.get("animations"); + if (anims instanceof List) { + List l = (List) anims; + for (int i = 0; i < l.size(); i++) { + Object a = l.get(i); + Object n = a instanceof Map ? ((Map) a).get("name") : null; + names.add(n instanceof String ? (String) n : "Animation " + (i + 1)); + } + } + return new Report(null, names, skinned, vertices); + } + + private static int count(Map root, String key) { + Object v = root.get(key); + return v instanceof List ? ((List) v).size() : 0; + } + + private static boolean hasExternalUri(Object list) { + if (!(list instanceof List)) { + return false; + } + List l = (List) list; + for (int i = 0; i < l.size(); i++) { + Object o = l.get(i); + if (o instanceof Map && ((Map) o).get("uri") != null) { + Object uri = ((Map) o).get("uri"); + // An embedded data: URI is self-contained; anything else is a fetch. + if (!(uri instanceof String) || !((String) uri).startsWith("data:")) { + return true; + } + } + } + return false; + } + + /// The vertex count, read from each primitive's POSITION accessor. + private static int vertices(Map root) { + Object meshes = root.get("meshes"); + Object accessors = root.get("accessors"); + if (!(meshes instanceof List) || !(accessors instanceof List)) { + return 0; + } + List acc = (List) accessors; + long total = 0; + List ml = (List) meshes; + for (int i = 0; i < ml.size(); i++) { + Object m = ml.get(i); + Object prims = m instanceof Map ? ((Map) m).get("primitives") : null; + if (!(prims instanceof List)) { + continue; + } + List pl = (List) prims; + for (int k = 0; k < pl.size(); k++) { + Object p = pl.get(k); + Object attrs = p instanceof Map ? ((Map) p).get("attributes") : null; + Object pos = attrs instanceof Map ? ((Map) attrs).get("POSITION") : null; + if (pos instanceof Long) { + long idx = ((Long) pos).longValue(); + if (idx >= 0 && idx < acc.size() && acc.get((int) idx) instanceof Map) { + Object c = ((Map) acc.get((int) idx)).get("count"); + if (c instanceof Long) { + total += ((Long) c).longValue(); + } + } + } + } + } + return (int) Math.min(Integer.MAX_VALUE, total); + } + + /// The clip of `part` ("idle", "run", "jump", "slide", "fall") in a + /// runner mapping, or null. + public static String clip(String clips, String part) { + if (clips == null) { + return null; + } + String key = part + "="; + int at = 0; + while (at < clips.length()) { + int end = clips.indexOf(';', at); + if (end < 0) { + end = clips.length(); + } + if (clips.startsWith(key, at)) { + return clips.substring(at + key.length(), end); + } + at = end + 1; + } + return null; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/LevelRun.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/LevelRun.java new file mode 100644 index 00000000000..a08fb3d4c69 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/LevelRun.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import com.codenameone.examples.dash.rules.RulesVersion; + +/// Verifies a submitted run. A client sends only its [Replay]; the server finds +/// the level the replay names, checks it is the same content, and plays the +/// inputs itself. The score on a leaderboard is the score of the server's run, +/// never a number the client reported. +public final class LevelRun { + private LevelRun() { + } + + /// The level id a replay of `levelId` in `pack` carries: the bare id for an + /// official level, a [ContentRef] for anything else. + public static String replayLevelId(ContentPack pack, String levelId) { + if (Samples.OFFICIAL_ID.equals(pack.id)) { + return levelId; + } + return new ContentRef(pack.id, pack.hash(), levelId).toString(); + } + + /// Plays `claimed` on `pack`'s level. Throws `IllegalArgumentException` when + /// the replay was recorded under other rules, on other content or with more + /// than one runner. + public static Run verify(ContentPack pack, String levelId, Replay claimed) { + if (!RulesVersion.ID.equals(claimed.rulesVersion())) { + throw new IllegalArgumentException("recorded under other rules"); + } + LevelDef level = pack.level(levelId); + if (level == null) { + throw new IllegalArgumentException("no level " + levelId); + } + Catalog catalog = Samples.OFFICIAL_ID.equals(pack.id) ? Content.catalog() : pack.catalog(); + return verify(level, catalog, claimed); + } + + /// Plays `claimed` on `level`, a level the caller built itself -- a city + /// route, rebuilt from its spec. The same checks as for a pack's level; + /// that the replay names this level is the caller's to check, since a + /// pack's level is named by a [ContentRef]. + public static Run verify(LevelDef level, Catalog catalog, Replay claimed) { + if (!RulesVersion.ID.equals(claimed.rulesVersion())) { + throw new IllegalArgumentException("recorded under other rules"); + } + if (level.contentHash() != claimed.levelHash()) { + throw new IllegalArgumentException("the level changed since this run"); + } + if (claimed.runners() != 1) { + throw new IllegalArgumentException("a submitted run has one runner"); + } + Run run = claimed.play(level, catalog); + if (!run.over()) { + throw new IllegalArgumentException("the run did not end"); + } + return run; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/PackCodec.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/PackCodec.java new file mode 100644 index 00000000000..0c7a82f70c3 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/PackCodec.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Json; +import com.codenameone.examples.dash.rules.LevelCodec; +import com.codenameone.examples.dash.rules.LevelDef; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A [ContentPack] as JSON. Each level is written by [LevelCodec], so a single +/// level exported from the editor and the same level inside a pack are one +/// format; the pack's own definitions and art are written once, at the top. +/// +/// The output is canonical -- keys in a fixed order, no whitespace -- because +/// the pack's hash is taken over it. +public final class PackCodec { + private PackCodec() { + } + + public static String write(ContentPack p) { + return Json.write(toMap(p)); + } + + public static Map toMap(ContentPack p) { + Map m = new LinkedHashMap(); + m.put("format", Long.valueOf(ContentPack.FORMAT)); + m.put("id", p.id); + m.put("name", p.name); + m.put("author", p.author); + m.put("description", p.description); + m.put("version", Long.valueOf(p.version)); + // The definitions travel inside a carrier level so LevelCodec writes them; + // only its "defs" member is kept. + LevelDef carrier = new LevelDef("defs", "defs"); + Map defs = (Map) Json.parse(LevelCodec.encode(carrier, p.defs, p.art, p.art.get(ContentPack.RUNNER))); + m.put("defs", defs.get("defs")); + if (p.art.containsKey(ContentPack.RUNNER)) { + m.put("runnerArt", p.art.get(ContentPack.RUNNER)); + } + List levels = new ArrayList(); + for (int i = 0; i < p.levels.size(); i++) { + levels.add(Json.parse(LevelCodec.encode(p.levels.get(i)))); + } + m.put("levels", levels); + List assets = new ArrayList(); + for (int i = 0; i < p.assets.size(); i++) { + AssetRef a = p.assets.get(i); + Map am = new LinkedHashMap(); + am.put("sha256", a.sha256); + am.put("kind", a.kind); + am.put("name", a.name); + am.put("bytes", Long.valueOf(a.bytes)); + assets.add(am); + } + m.put("assets", assets); + return m; + } + + /// Parses a pack, refusing anything malformed. Whether it is any good is + /// [Validator]'s question. + public static ContentPack read(String json) { + Object root = Json.parse(json); + if (!(root instanceof Map)) { + throw new IllegalArgumentException("a pack is an object"); + } + Map m = (Map) root; + ContentPack p = new ContentPack(); + p.id = str(m, "id"); + p.name = str(m, "name"); + p.author = str(m, "author"); + p.description = str(m, "description"); + Object v = m.get("version"); + p.version = v instanceof Long ? (int) ((Long) v).longValue() : 0; + Map carrier = new LinkedHashMap(); + carrier.put("id", "defs"); + carrier.put("defs", m.get("defs") == null ? new ArrayList() : m.get("defs")); + if (m.get("runnerArt") != null) { + carrier.put("runnerArt", m.get("runnerArt")); + } + LevelCodec.Decoded d = LevelCodec.decode(Json.write(carrier)); + p.art.putAll(d.art); + if (d.runnerArt != null) { + p.art.put(ContentPack.RUNNER, d.runnerArt); + } + Object defs = m.get("defs"); + if (defs instanceof List) { + for (int i = 0; i < ((List) defs).size(); i++) { + Object id = ((Map) ((List) defs).get(i)).get("id"); + Object def = d.custom.obstacle((String) id); + p.defs.add(def != null ? def : d.custom.pickup((String) id)); + } + } + Object levels = m.get("levels"); + if (levels instanceof List) { + for (int i = 0; i < ((List) levels).size(); i++) { + p.levels.add(LevelCodec.decode(Json.write(((List) levels).get(i))).level); + } + } + Object assets = m.get("assets"); + if (assets instanceof List) { + for (int i = 0; i < ((List) assets).size(); i++) { + Object o = ((List) assets).get(i); + if (!(o instanceof Map)) { + throw new IllegalArgumentException("an asset is an object"); + } + Map am = (Map) o; + AssetRef a = new AssetRef(); + a.sha256 = str(am, "sha256"); + a.kind = str(am, "kind"); + a.name = str(am, "name"); + Object b = am.get("bytes"); + a.bytes = b instanceof Long ? (int) ((Long) b).longValue() : 0; + p.assets.add(a); + } + } + return p; + } + + private static String str(Map m, String key) { + Object v = m.get(key); + return v instanceof String ? (String) v : ""; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Samples.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Samples.java new file mode 100644 index 00000000000..8776a23f83b --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Samples.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Content; +import com.codenameone.examples.dash.rules.LevelDef; +import com.codenameone.examples.dash.rules.Placement; +import com.codenameone.examples.dash.rules.Track; +import com.codenameone.examples.dash.rules.ViewMode; + +/// The packs the game ships with: the official levels, and the blank pack the +/// editor starts a new one from. +public final class Samples { + public static final String OFFICIAL_ID = "official"; + + private Samples() { + } + + public static ContentPack official() { + ContentPack p = new ContentPack(); + p.id = OFFICIAL_ID; + p.name = "Dash"; + p.author = "Dash team"; + p.description = "The levels the game ships with."; + p.levels.addAll(Content.levels()); + return p; + } + + /// A one-level pack that already plays: flat for a stretch, then a hurdle, a + /// barrier and a coin line, with the camera starting side-on and swinging + /// into the chase view so the author sees both at once. + public static ContentPack blank(String id, String author) { + ContentPack p = new ContentPack(); + p.id = id; + p.name = "My levels"; + p.author = author; + LevelDef d = new LevelDef("level-1", "Level 1"); + d.length = 300 * Track.UNIT; + d.stars = new int[] {300, 330, 360}; + d.cue(0, ViewMode.SIDE_2D, 0); + d.cue(150, ViewMode.CHASE_3D, 30); + d.place(60, Content.HURDLE, Placement.ALL_LANES); + d.place(110, Content.BARRIER, Placement.ALL_LANES); + for (int i = 0; i < 6; i++) { + d.place(200 + 3 * i, Content.COIN, 1); + } + d.place(240, Content.HAULER, 1); + p.levels.add(d); + return p; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Validator.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Validator.java new file mode 100644 index 00000000000..cda662d503e --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/Validator.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import com.codenameone.examples.dash.rules.Catalog; +import com.codenameone.examples.dash.rules.LevelDef; +import java.util.ArrayList; +import java.util.List; + +/// The checks a pack passes before it is published: shape and limits here, each +/// level through the rules' own [com.codenameone.examples.dash.rules.Validator], +/// and every level proven beatable by a bot. The editor runs the same checks as +/// the author types; the server runs them again and trusts nothing else. +public final class Validator { + public static final int MAX_LEVELS = 30; + public static final int MAX_DEFS = 40; + public static final int MAX_ASSETS = 40; + public static final int MAX_PLACEMENTS = 2000; + public static final int MAX_TEXT = 280; + public static final int MAX_LONG_TEXT = 1200; + public static final int MAX_MODEL_BYTES = 5 * 1024 * 1024; + public static final int MAX_IMAGE_BYTES = 1024 * 1024; + + private Validator() { + } + + /// Every problem with the pack, errors first; empty when it may be published. + /// Warnings from the level checks are included with a `warning:` prefix and + /// do not block publishing ([#isValid]). + public static List check(ContentPack p) { + List errors = new ArrayList(); + List warnings = new ArrayList(); + if (!isSlug(p.id, 3, 40)) { + errors.add("The pack id is 3 to 40 lower-case letters, digits or hyphens"); + } + if (p.name.trim().length() == 0 || p.name.length() > 60) { + errors.add("The pack needs a name of up to 60 characters"); + } + if (p.description.length() > MAX_LONG_TEXT) { + errors.add("The description is too long"); + } + if (p.levels.isEmpty()) { + errors.add("A pack needs at least one level"); + } + if (p.levels.size() > MAX_LEVELS || p.defs.size() > MAX_DEFS || p.assets.size() > MAX_ASSETS) { + errors.add("A pack holds at most " + MAX_LEVELS + " levels, " + MAX_DEFS + " definitions and " + + MAX_ASSETS + " files"); + } + for (String ref : p.art.values()) { + if (!ref.startsWith("asset:") || p.asset(ref.substring(6)) == null) { + errors.add("Art " + ref + " is not one of the pack's files"); + } + } + Catalog catalog = p.catalog(); + List seen = new ArrayList(); + for (int i = 0; i < p.levels.size(); i++) { + LevelDef d = p.levels.get(i); + String name = "Level " + (d.name == null ? d.id : d.name) + ": "; + if (!isSlug(d.id, 1, 32) || seen.contains(d.id)) { + errors.add(name + "needs a unique id of lower-case letters, digits or hyphens"); + } + seen.add(d.id); + int placements = d.placements.size(); + for (int c = 0; c < d.chunks.size(); c++) { + placements += d.chunks.get(c).placements().size(); + } + if (placements > MAX_PLACEMENTS) { + errors.add(name + "has more than " + MAX_PLACEMENTS + " placements"); + continue; + } + List ps = + com.codenameone.examples.dash.rules.Validator.check(d, catalog); + for (int k = 0; k < ps.size(); k++) { + if (ps.get(k).error) { + errors.add(name + ps.get(k).message); + } else { + warnings.add("warning: " + name + ps.get(k).message); + } + } + if (!com.codenameone.examples.dash.rules.Validator.hasErrors(ps) + && !com.codenameone.examples.dash.rules.Validator.beatable(d, catalog)) { + errors.add(name + "the bot could not finish it; leave a way through"); + } + } + errors.addAll(warnings); + return errors; + } + + public static boolean isValid(ContentPack p) { + return !hasErrors(check(p)); + } + + public static boolean hasErrors(List problems) { + for (int i = 0; i < problems.size(); i++) { + if (!problems.get(i).startsWith("warning: ")) { + return true; + } + } + return false; + } + + public static boolean isSlug(String s, int min, int max) { + if (s == null || s.length() < min || s.length() > max) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) { + return false; + } + } + return true; + } +} diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/package-info.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/package-info.java new file mode 100644 index 00000000000..1de0dfb7916 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/forge/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/// The Forge's content model: a pack of levels with their own obstacles, +/// pickups and uploaded art, how it is written as JSON, the checks every pack +/// passes before it is published, and how a submitted run is verified. +package com.codenameone.examples.dash.rules.forge; diff --git a/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/package-info.java b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/package-info.java new file mode 100644 index 00000000000..5209c95b5f0 --- /dev/null +++ b/scripts/dash/rules/src/main/java/com/codenameone/examples/dash/rules/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/// The game's rules: a deterministic runner simulation shared by the app, the +/// server, the level editor and the replay viewer. Nothing here may touch a clock, +/// a float, the UI or the network. +/// +/// The world is one abstract space -- lane, height and distance -- and the 2D and +/// 3D views are two renderings of it. A level that swings its camera from a side +/// view into a chase view mid-run therefore changes nothing about how it plays. +package com.codenameone.examples.dash.rules; diff --git a/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/CityRouteTest.java b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/CityRouteTest.java new file mode 100644 index 00000000000..faf6d12ddb0 --- /dev/null +++ b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/CityRouteTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/// A route becomes the same level wherever it is built, and a strong runner +/// can finish it. +class CityRouteTest { + static CityRoute harbour() { + return new CityRoute("Old Harbour") + .leg(420, CityRoute.STRAIGHT, "Quay Street") + .leg(310, CityRoute.LEFT, "Rope Walk") + .leg(30, CityRoute.RIGHT, "Tide Lane") + .leg(650, CityRoute.RIGHT, "Lantern Avenue") + .leg(280, CityRoute.STRAIGHT, "") + .leg(510, CityRoute.LEFT, "Market Square") + .leg(900, CityRoute.RIGHT, "Ferry Road"); + } + + @Test + void theSpecRoundTripsToTheSameLevel() { + CityRoute r = harbour(); + CityRoute back = CityRoute.decode(r.encode()); + assertEquals(r.encode(), back.encode()); + assertEquals(r.id(), back.id()); + LevelDef a = r.level(); + LevelDef b = back.level(); + assertEquals(a.id, b.id); + assertEquals(a.contentHash(), b.contentHash()); + assertEquals(a.dialog.size(), b.dialog.size()); + assertTrue(a.id.startsWith(CityRoute.ID_PREFIX)); + } + + @Test + void aLongRouteRunsToScaleAndAShortOneIsStretched() { + assertEquals(CityRoute.MAX_METRES * Track.UNIT, harbour().level().length); + LevelDef shortRun = new CityRoute("Corner").leg(120, CityRoute.STRAIGHT, "A").level(); + assertEquals(CityRoute.MIN_METRES * Track.UNIT, shortRun.length); + } + + @Test + void cornersAreCalledByStreetAndBlockTheOtherLanes() { + LevelDef l = harbour().level(); + boolean left = false; + boolean right = false; + for (DialogLine line : l.dialog) { + left |= line.text.equals("Left onto Rope Walk!") && line.hint == Input.LEFT; + right |= line.text.startsWith("Right onto ") && line.hint == Input.RIGHT; + } + assertTrue(left, "the left turn is called"); + assertTrue(right, "a right turn is called"); + // Tide Lane is 30 m between two turns: the turn out of it is passed, + // not cornered a few metres after the turn into it. + for (DialogLine line : l.dialog) { + assertTrue(!line.text.contains("Lantern Avenue"), "a corner every few metres"); + } + // Dialog runs in order, so the client can walk it front to back. + for (int i = 1; i < l.dialog.size(); i++) { + assertTrue(l.dialog.get(i).at >= l.dialog.get(i - 1).at, "dialog out of order"); + } + } + + @Test + void aStrongRunnerFinishesIt() { + assertTrue(Validator.beatable(harbour().level(), Content.catalog())); + assertTrue(!Validator.hasErrors(Validator.check(harbour().level(), Content.catalog()))); + } + + @Test + void anotherRouteIsAnotherLevel() { + CityRoute other = new CityRoute("Old Harbour").leg(420, CityRoute.STRAIGHT, "Quay Street") + .leg(310, CityRoute.RIGHT, "Rope Walk"); + assertNotEquals(harbour().id(), other.id()); + } + + @Test + void namesLoseTheSeparatorsAndJunkIsRefused() { + CityRoute r = new CityRoute("A~B|C").leg(500, CityRoute.STRAIGHT, "One, Two"); + assertEquals("ABC", r.place); + assertEquals("One Two", r.legs().get(0).street); + assertThrows(IllegalArgumentException.class, () -> CityRoute.decode("nonsense")); + assertThrows(IllegalArgumentException.class, () -> CityRoute.decode("1~x~ten,0,a")); + assertThrows(IllegalArgumentException.class, () -> CityRoute.decode("1~x~10,7,a")); + assertThrows(IllegalArgumentException.class, () -> CityRoute.decode(null)); + } + + /// The developer guide's two examples, compiled and run here. + @Test + void theGuidesRoutesBuildLevels() { + // tag::dash-city-route[] + CityRoute route = new CityRoute("Old Harbour") + .leg(420, CityRoute.STRAIGHT, "Quay Street") + .leg(310, CityRoute.LEFT, "Rope Walk"); + LevelDef level = route.level(); + // end::dash-city-route[] + assertEquals(level.id, route.level().id); + // tag::dash-city-landmark[] + CityRoute withPlace = new CityRoute("Old Harbour") + .leg(420, CityRoute.STRAIGHT, "Quay Street") + .landmark(180, CityRoute.MUSEUM, "Maritime Museum"); + // end::dash-city-landmark[] + assertTrue(!withPlace.id().equals(new CityRoute("Old Harbour") + .leg(420, CityRoute.STRAIGHT, "Quay Street").id())); + } + + @Test + void landmarksRoundTripAndShowWhereTheyStand() { + CityRoute r = new CityRoute("City of London").leg(500, CityRoute.STRAIGHT, "Cannon Street") + .leg(400, CityRoute.LEFT, "Moorgate").leg(300, CityRoute.RIGHT, "London Wall") + .landmark(250, CityRoute.WORSHIP, "St Paul's Cathedral") + .landmark(700, CityRoute.MUSEUM, "Bank of England Museum"); + CityRoute back = CityRoute.decode(r.encode()); + assertEquals(r.encode(), back.encode()); + assertEquals(2, back.landmarks().size()); + assertEquals("Moorgate", back.streetAt(600)); + assertEquals("Cannon Street", back.streetAt(10)); + int streets = 0; + int marks = 0; + int last = -1; + for (CityRoute.Sign s : back.signs()) { + assertTrue(s.metres >= last, "signs in order"); + last = s.metres; + if (s.kind < 0) { + streets++; + } else { + marks++; + } + } + assertEquals(3, streets); + assertEquals(2, marks); + boolean called = false; + for (DialogLine l : back.level().dialog) { + called |= l.text.indexOf("St Paul's Cathedral") >= 0; + } + assertTrue(called, "the coach names a landmark"); + // A landmark changes the route, so it names another level. + assertTrue(!r.id().equals(new CityRoute("City of London").leg(500, CityRoute.STRAIGHT, "Cannon Street") + .leg(400, CityRoute.LEFT, "Moorgate").leg(300, CityRoute.RIGHT, "London Wall").id())); + assertThrows(IllegalArgumentException.class, + () -> CityRoute.decode("1~x~@10,0,Tower~100,0,Street")); + assertThrows(IllegalArgumentException.class, () -> CityRoute.decode("1~x~100,0,Street~@10,9,Tower")); + } +} diff --git a/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/GoldenReplayTest.java b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/GoldenReplayTest.java new file mode 100644 index 00000000000..7b30309dbef --- /dev/null +++ b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/GoldenReplayTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/// Pins the outcome of a fixed set of replays over every official level. +/// +/// The same file is the corpus the cross-platform determinism check plays on +/// ParparVM and in the browser: every runtime must reach these exact hashes. +/// +/// A deliberate rules change makes this fail. Bump [RulesVersion#ID] and rewrite +/// the file with `mvn test -Ddash.regolden=true`. +class GoldenReplayTest { + static final String FILE = "src/test/resources/golden-replays.txt"; + + @Test + void goldenReplaysStillPlayOutTheSame() throws Exception { + if (Boolean.getBoolean("dash.regolden")) { + regenerate(); + } + List lines = read(); + assertTrue(lines.size() >= 9, "corpus"); + for (String line : lines) { + int bar = line.indexOf(' '); + long expected = Text.parseHex(line.substring(0, bar)); + Replay r = Replay.decode(line.substring(bar + 1)); + assertEquals(RulesVersion.ID, r.rulesVersion(), "replay recorded under other rules"); + LevelDef level = Content.level(r.levelId()); + assertEquals(Long.toHexString(level.contentHash()), Long.toHexString(r.levelHash()), r.levelId()); + assertEquals(Long.toHexString(expected), Long.toHexString(r.play(level, Content.catalog()).stateHash()), + r.levelId() + " seed " + r.seed()); + } + } + + private static List read() throws Exception { + List out = new ArrayList(); + try (BufferedReader in = new BufferedReader(new InputStreamReader( + GoldenReplayTest.class.getResourceAsStream("/golden-replays.txt"), StandardCharsets.UTF_8))) { + String l; + while ((l = in.readLine()) != null) { + if (l.length() > 0 && l.charAt(0) != '#') { + out.add(l); + } + } + } + return out; + } + + private static void regenerate() throws Exception { + try (Writer w = new OutputStreamWriter(new FileOutputStream(new File(FILE)), StandardCharsets.UTF_8)) { + w.write("# ; regenerate with -Ddash.regolden=true\n"); + for (LevelDef level : Content.levels()) { + for (long seed = 1000; seed < 1003; seed++) { + Replay r = RunTest.botRace(level, seed, (int) (seed - 998)); + w.write(Long.toHexString(r.play(level, Content.catalog()).stateHash()) + " " + r.encode() + "\n"); + } + } + } + throw new IllegalStateException("golden file rewritten; re-run without -Ddash.regolden"); + } +} diff --git a/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/RunTest.java b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/RunTest.java new file mode 100644 index 00000000000..f065114048d --- /dev/null +++ b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/RunTest.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RunTest { + /// A race of bots, recorded: the shape every golden replay has. + static Replay botRace(LevelDef level, long seed, int runners) { + Run run = new Run(level, Content.catalog(), seed, runners); + Recorder rec = new Recorder(run); + Bot[] bots = new Bot[runners]; + for (int i = 0; i < runners; i++) { + bots[i] = new Bot(seed + i, 60 + 15 * i); + } + int[] in = new int[runners]; + while (!run.over()) { + for (int i = 0; i < runners; i++) { + in[i] = bots[i].decide(run, i); + } + rec.step(in); + } + return rec.replay(); + } + + private static LevelDef flat(String obstacle, int lane) { + LevelDef d = new LevelDef("t", "t"); + d.length = 100 * Track.UNIT; + d.accel = 0; + d.place(30, obstacle, lane); + return d; + } + + private static Run runWith(LevelDef d, int actTick, int bits) { + Run run = new Run(d, Content.catalog(), 1, 1); + while (!run.over()) { + run.step(new int[] {run.tick() == actTick ? bits : 0}); + } + return run; + } + + /// The tick on which a runner at start speed is `metres` short of 30 m. + private static int ticksBefore(int metres) { + return (30 - metres) * Track.UNIT * Run.TICKS_PER_SECOND / 9000; + } + + @Test + void sameSeedAndInputsGiveTheSameRun() { + Replay a = botRace(Content.causeway(), 42, 3); + Replay b = botRace(Content.causeway(), 42, 3); + assertEquals(a.encode(), b.encode()); + assertEquals(a.play(Content.causeway(), Content.catalog()).stateHash(), + b.play(Content.causeway(), Content.catalog()).stateHash()); + assertNotEquals(botRace(Content.causeway(), 43, 3).encode(), a.encode()); + } + + @Test + void replayRoundTripsThroughText() { + Replay r = botRace(Content.endless(), 7, 2); + Replay back = Replay.decode(r.encode()); + assertEquals(r.encode(), back.encode()); + assertEquals(r.play(Content.endless(), Content.catalog()).stateHash(), + back.play(Content.endless(), Content.catalog()).stateHash()); + } + + @Test + void tamperedReplaysAreRefused() { + String ok = botRace(Content.tutorial(), 3, 1).encode(); + assertThrows(IllegalArgumentException.class, () -> Replay.decode(ok.replace("DSH1", "XXX1"))); + assertThrows(IllegalArgumentException.class, () -> Replay.decode(ok + ";5,0,64")); + assertThrows(IllegalArgumentException.class, () -> Replay.decode(ok + ";1,0,4")); + assertThrows(IllegalArgumentException.class, () -> Replay.decode(ok + ";999999,3,4")); + } + + @Test + void jumpingClearsAHurdleAndStandingStumbles() { + LevelDef d = flat(Content.HURDLE, Placement.ALL_LANES); + Run cleared = runWith(d, ticksBefore(3), Input.JUMP); + assertTrue(cleared.runner(0).finished); + assertEquals(0, cleared.runner(0).stumbles); + Run hit = runWith(d, -1, 0); + assertTrue(hit.runner(0).finished, "a hurdle only trips you"); + assertEquals(1, hit.runner(0).stumbles); + } + + @Test + void slidingPassesUnderABarrierAndStandingFalls() { + LevelDef d = flat(Content.BARRIER, Placement.ALL_LANES); + assertTrue(runWith(d, ticksBefore(2), Input.SLIDE).runner(0).finished); + Run hit = runWith(d, -1, 0); + assertFalse(hit.runner(0).alive); + } + + @Test + void aHaulerIsDodgedByChangingLane() { + LevelDef d = flat(Content.HAULER, 1); + assertTrue(runWith(d, 10, Input.LEFT).runner(0).finished); + assertFalse(runWith(d, -1, 0).runner(0).alive); + assertFalse(runWith(d, 10, Input.JUMP).runner(0).alive, "no jumping over a hauler"); + } + + @Test + void aShieldAbsorbsOneHit() { + LevelDef d = flat(Content.BARRIER, Placement.ALL_LANES); + d.place(10, Content.SHIELD, Placement.ALL_LANES); + Run run = runWith(d, -1, 0); + assertTrue(run.runner(0).finished); + assertEquals(0, run.runner(0).shield); + } + + @Test + void coinsAreCollectedOncePerRunnerInARace() { + LevelDef d = new LevelDef("c", "c"); + d.length = 60 * Track.UNIT; + d.place(20, Content.COIN, 1); + d.place(25, Content.COIN, 0); + Run run = new Run(d, Content.catalog(), 1, 2); + while (!run.over()) { + run.step(new int[] {0, 0}); + } + assertEquals(1, run.runner(0).coins); + assertEquals(1, run.runner(1).coins, "each runner has their own copy of the track"); + } + + @Test + void everyRunnerInARaceSeesTheSameTrack() { + Run a = new Run(Content.endless(), Content.catalog(), 99, 1); + Run b = new Run(Content.endless(), Content.catalog(), 99, 4); + assertEquals(a.track().size(), b.track().size()); + for (int i = 0; i < a.track().size(); i++) { + assertEquals(a.track().get(i).z, b.track().get(i).z); + assertEquals(a.track().get(i).lane, b.track().get(i).lane); + } + } + + @Test + void theOfficialLevelsValidateAndCanBeBeaten() { + for (LevelDef d : Content.levels()) { + List ps = Validator.check(d, Content.catalog()); + assertFalse(Validator.hasErrors(ps), d.id + " " + ps); + assertTrue(Validator.beatable(d, Content.catalog()), d.id); + } + } + + /// The chunks are fair: a sharp bot finishes the causeway on every one of + /// 200 weekly seeds. One obstacle placed inside another's jump or lane + /// change shows up here as a run that ends early on some seeds. + @Test + void aSharpBotFinishesTheCausewayOnEveryWeeklySeed() { + LevelDef level = Content.causeway(); + for (int week = 2900; week < 3100; week++) { + long seed = Content.weeklySeed(week, level.id); + Run run = new Run(level, Content.catalog(), seed, 1); + Bot bot = new Bot(seed * 31, 97); + int[] in = new int[1]; + while (!run.over() && run.tick() < 60 * 600) { + in[0] = bot.decide(run, 0); + run.step(in); + } + assertTrue(run.runner(0).finished, "week " + week + " ended at " + + run.runner(0).z / Track.UNIT + " m"); + } + } + + @Test + void theValidatorCatchesBrokenLevels() { + LevelDef d = new LevelDef("bad", "bad"); + d.length = 10; + d.place(5, "nonsense", 0); + d.place(6, Content.HURDLE, 7); + d.stars = new int[] {5, 2, 9}; + List ps = Validator.check(d, Content.catalog()); + assertTrue(ps.size() >= 4, ps.toString()); + LevelDef wall = flat(Content.HAULER, Placement.ALL_LANES); + assertFalse(Validator.beatable(wall, Content.catalog())); + } + + @Test + void theCameraSwingsFromSideOnToChaseOnTheCauseway() { + Track t = new Run(Content.causeway(), Content.catalog(), 1, 1).track(); + assertEquals(ViewMode.SIDE_2D, t.viewAt(100 * Track.UNIT)); + assertEquals(ViewMode.CHASE_3D, t.viewAt(320 * Track.UNIT)); + assertEquals(ViewMode.SIDE_2D, t.previousViewAt(320 * Track.UNIT)); + assertEquals(500, t.blendAt(320 * Track.UNIT)); + assertEquals(1000, t.blendAt(400 * Track.UNIT)); + } + + @Test + void editingALevelChangesItsHashButRetitlingDoesNot() { + LevelDef a = Content.causeway(); + LevelDef b = Content.causeway(); + b.name = "Renamed"; + b.theme = "other"; + assertEquals(a.contentHash(), b.contentHash()); + b.place(500, Content.HURDLE, 1); + assertNotEquals(a.contentHash(), b.contentHash()); + } + + @Test + void botsSoakTheEndlessLevelWithoutBreakingInvariants() { + for (long seed = 1; seed <= 20; seed++) { + Run run = new Run(Content.endless(), Content.catalog(), seed, 1); + Bot bot = new Bot(seed, 90); + int lastZ = 0; + while (!run.over()) { + run.step(new int[] {bot.decide(run, 0)}); + Runner r = run.runner(0); + assertTrue(r.z >= lastZ); + assertTrue(r.y >= 0 && r.laneX >= 0 && r.laneX <= 2000); + assertTrue(r.speed <= Content.endless().maxSpeed); + lastZ = r.z; + } + assertTrue(run.runner(0).z > 200 * Track.UNIT, "seed " + seed + " fell at " + run.runner(0).z); + } + } + + @Test + void levelsRoundTripThroughJsonWithTheirOwnDefinitions() { + for (LevelDef d : Content.levels()) { + LevelCodec.Decoded back = LevelCodec.decode(LevelCodec.encode(d)); + assertEquals(d.contentHash(), back.level.contentHash(), d.id); + assertEquals(LevelCodec.encode(d), LevelCodec.encode(back.level), d.id); + } + LevelDef d = flat("u:me:spikes", 1); + java.util.List defs = new java.util.ArrayList(); + defs.add(new ObstacleDef("u:me:spikes", Clearance.JUMP, 500, 300, true)); + java.util.Map art = new java.util.HashMap(); + art.put("u:me:spikes", "asset-1"); + LevelCodec.Decoded back = LevelCodec.decode(LevelCodec.encode(d, defs, art, "asset-2")); + assertEquals("asset-1", back.art.get("u:me:spikes")); + assertEquals("asset-2", back.runnerArt); + assertFalse(Validator.hasErrors(Validator.check(back.level, back.catalog()))); + assertTrue(runWith(back.level, ticksBefore(3), Input.JUMP).runner(0).finished); + defs.set(0, new ObstacleDef(Content.HURDLE, Clearance.JUMP, 500, 300, true)); + assertThrows(IllegalArgumentException.class, () -> LevelCodec.decode(LevelCodec.encode(d, defs, null, null))); + assertThrows(IllegalArgumentException.class, () -> LevelCodec.decode("{\"placements\":[[1.5,\"coin\",0]]}")); + } +} diff --git a/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/forge/PackTest.java b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/forge/PackTest.java new file mode 100644 index 00000000000..c60b013f401 --- /dev/null +++ b/scripts/dash/rules/src/test/java/com/codenameone/examples/dash/rules/forge/PackTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.rules.forge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codenameone.examples.dash.rules.Bot; +import com.codenameone.examples.dash.rules.Clearance; +import com.codenameone.examples.dash.rules.ObstacleDef; +import com.codenameone.examples.dash.rules.Placement; +import com.codenameone.examples.dash.rules.Recorder; +import com.codenameone.examples.dash.rules.Replay; +import com.codenameone.examples.dash.rules.Run; +import org.junit.jupiter.api.Test; + +class PackTest { + @Test + void theOfficialAndBlankPacksAreValidAndRoundTrip() { + for (ContentPack p : new ContentPack[] {Samples.official(), Samples.blank("my-pack", "me")}) { + assertFalse(Validator.hasErrors(Validator.check(p)), Validator.check(p).toString()); + String text = PackCodec.write(p); + assertEquals(text, PackCodec.write(PackCodec.read(text))); + assertEquals(p.hash(), p.copy().hash()); + } + } + + @Test + void customDefinitionsAndArtSurviveAndAreChecked() { + ContentPack p = Samples.blank("art-pack", "me"); + p.defs.add(new ObstacleDef("u:me:log", Clearance.JUMP, 800, 500, false)); + p.art.put("u:me:log", "asset:abc"); + p.levels.get(0).place(150, "u:me:log", Placement.ALL_LANES); + assertTrue(Validator.hasErrors(Validator.check(p)), "art without its file"); + AssetRef a = new AssetRef(); + a.sha256 = "abc"; + a.name = "log.glb"; + p.assets.add(a); + assertFalse(Validator.hasErrors(Validator.check(p)), Validator.check(p).toString()); + ContentPack back = PackCodec.read(PackCodec.write(p)); + assertEquals("asset:abc", back.art.get("u:me:log")); + assertEquals(p.hash(), back.hash()); + } + + @Test + void aSubmittedRunIsReplayedOnTheServersOwnCopy() { + ContentPack p = Samples.blank("run-pack", "me"); + Run run = new Run(p.level("level-1"), p.catalog(), 5, 1); + Recorder rec = new Recorder(run); + Bot bot = new Bot(5, 100); + while (!run.over()) { + rec.step(new int[] {bot.decide(run, 0)}); + } + Replay replay = Replay.decode(rec.replay().encode()); + Run verified = LevelRun.verify(p, "level-1", replay); + assertEquals(run.stateHash(), verified.stateHash()); + assertEquals(run.runner(0).score(), verified.runner(0).score()); + p.levels.get(0).place(250, "hurdle", 0); + assertThrows(IllegalArgumentException.class, () -> LevelRun.verify(p, "level-1", replay)); + } +} diff --git a/scripts/dash/rules/src/test/resources/golden-replays.txt b/scripts/dash/rules/src/test/resources/golden-replays.txt new file mode 100644 index 00000000000..76af4de97cb --- /dev/null +++ b/scripts/dash/rules/src/test/resources/golden-replays.txt @@ -0,0 +1,10 @@ +# ; regenerate with -Ddash.regolden=true +b3b00b7931ae8d25 DSH1|2|first-steps|1e78c073ff00f7f0|1000|2|318,1,4;321,0,4;585,0,4;585,1,4;1055,0,8;1055,1,8;1390,1,8;1391,0,8;2118,1,4;2122,0,4 +6e8b669871799df3 DSH1|2|first-steps|1e78c073ff00f7f0|1001|3|318,0,4;318,1,4;318,2,4;585,2,4;589,0,4;589,1,4;1055,1,8;1055,2,8;1057,0,8;1389,0,8;1389,2,8;1392,1,8;2118,1,4;2118,2,4;2122,0,4 +e1d636d6ec7a6655 DSH1|2|first-steps|1e78c073ff00f7f0|1002|4|318,1,4;318,2,4;318,3,4;319,0,4;585,2,4;585,3,4;586,0,4;589,1,4;1055,1,8;1055,2,8;1055,3,8;1059,0,8;1389,0,8;1389,1,8;1389,2,8;1389,3,8;2118,0,4;2118,1,4;2118,2,4;2118,3,4 +7303320904c0ad64 DSH1|2|neon-causeway|70451f9a1f42ffef|1000|2|373,0,8;373,1,8;638,1,4;641,0,4;897,0,8;897,1,8;1138,0,4;1138,1,4;1376,1,8;1380,0,8;1912,1,8;1919,0,8;2104,1,1;2105,0,1;2254,0,4;2258,1,4;2295,0,2;2298,1,2;2350,1,4;2354,0,4;2446,0,4;2446,1,4;2599,1,4;2602,0,4;2640,0,2;2642,1,2;2675,0,4;2675,1,4;2799,0,1;2800,1,1;2840,0,4;2840,1,4;2985,0,4;2985,1,4;3073,1,4;3080,0,4;3104,1,1;3105,0,1;3212,0,2;3212,1,2;3215,0,2;3215,1,2;3289,1,1;3290,0,1;3290,1,1;3298,0,1;3315,0,2;3320,0,2;3320,1,2;3323,1,2;3411,0,1;3414,0,1;3415,1,1;3418,1,1;3487,0,2;3489,1,2;3490,1,2;3497,0,2;3511,0,1;3511,1,1;3560,1,8;3568,0,8;3681,1,4;3683,0,4;3805,0,4;3805,1,4;3884,0,4;3885,1,4;3966,0,8;3966,1,8;4000,0,1;4000,1,1;4084,0,2;4084,1,2;4087,0,2;4087,1,2;4157,0,1;4157,1,1;4159,1,1;4162,0,1;4180,0,2;4181,1,2;4220,1,4;4226,0,4;4295,1,4;4299,0,4;4411,1,4;4413,0,4;4597,0,4;4598,1,4;4625,1,2;4656,1,4;4672,0,2;4779,1,4;4785,0,4;4819,0,1;4850,0,4;4850,1,4;4870,1,1;4882,0,2;4882,1,2;4958,0,1;4958,1,1;4961,1,1;4965,0,1;5027,1,2;5033,0,2;5034,0,2;5037,1,2;5115,0,1;5115,1,1;5197,0,8;5198,1,8;5220,0,1;5220,1,1;5251,0,4;5251,1,4;5289,1,2;5291,0,2;5320,1,4;5324,0,4;5389,0,8;5389,1,8;5425,0,2;5426,1,2;5512,1,4;5513,0,4;5541,0,1;5541,1,1;5741,0,1;5741,1,1;5772,0,4;5773,1,4;5808,0,2;5808,1,2;5837,0,4;5840,1,4;5863,0,2;5863,1,2;5932,1,1;5935,1,1;5936,0,1;5939,0,1;5998,0,2;5998,1,2 +3c4fe4a85212f119 DSH1|2|neon-causeway|70451f9a1f42ffef|1001|3|373,1,8;373,2,8;377,0,8;638,1,4;638,2,4;641,0,4;897,1,8;897,2,8;902,0,8;1138,0,4;1138,1,4;1138,2,4;1376,1,8;1376,2,8;1378,0,8;1908,0,4;1908,1,4;1908,2,4;2014,0,8;2014,1,8;2014,2,8;2266,1,8;2266,2,8;2268,0,8;2315,2,2;2316,1,2;2324,0,2;2446,0,4;2446,1,4;2446,2,4;2488,1,1;2488,2,1;2490,0,1;2591,1,4;2591,2,4;2594,0,4;2687,2,8;2689,0,8;2692,1,8;2774,0,4;2774,1,4;2774,2,4;2921,0,4;2921,1,4;2921,2,4;2960,0,2;2960,2,2;2962,1,2;3089,2,4;3090,1,4;3093,0,4;3127,0,1;3127,2,1;3130,1,1;3175,0,4;3175,1,4;3175,2,4;3260,0,4;3260,1,4;3260,2,4;3390,0,4;3390,1,4;3390,2,4;3428,2,1;3429,1,1;3432,0,1;3458,0,4;3458,1,4;3458,2,4;3681,1,4;3681,2,4;3683,0,4;3718,0,2;3718,1,2;3718,2,2;3747,1,4;3747,2,4;3751,0,4;3823,0,8;3823,2,8;3826,1,8;3941,1,4;3941,2,4;3942,0,4;4018,1,4;4018,2,4;4021,0,4;4095,2,4;4096,1,4;4098,0,4;4133,0,1;4133,1,1;4133,2,1;4205,1,2;4205,2,2;4208,0,2;4292,0,8;4292,2,8;4296,1,8;4363,0,4;4363,1,4;4363,2,4;4405,0,1;4405,2,1;4409,1,1;4476,1,2;4476,2,2;4479,0,2;4561,0,8;4561,1,8;4561,2,8;4740,1,4;4740,2,4;4746,0,4;4774,1,2;4774,2,2;4777,0,2;4882,0,4;4882,1,4;4882,2,4;4939,0,4;4939,1,4;4939,2,4;4957,2,1;4959,1,1;4961,1,1;4961,2,1;4964,0,1;4968,0,1;5030,2,2;5031,0,2;5031,1,2;5065,0,4;5065,1,4;5065,2,4;5171,0,4;5171,1,4;5171,2,4;5331,0,4;5331,1,4;5331,2,4;5396,0,8;5396,2,8;5400,1,8;5462,0,2;5462,1,2;5462,2,2;5554,1,4;5554,2,4;5555,0,4;5570,1,1;5571,2,1;5573,0,1;5617,0,8;5617,1,8;5617,2,8;5678,1,4;5678,2,4;5679,0,4;5753,0,8;5753,1,8;5753,2,8;5801,1,4;5801,2,4;5804,0,4;5863,0,8;5863,2,8;5866,1,8;5886,0,1;5886,1,1;5886,2,1;5911,1,8;5911,2,8;5915,0,8;5950,0,4;5950,1,4;5950,2,4 +57eaf2a6ee540604 DSH1|2|neon-causeway|70451f9a1f42ffef|1002|4|373,1,8;373,2,8;373,3,8;376,0,8;638,0,4;638,1,4;638,2,4;638,3,4;897,1,8;897,2,8;897,3,8;901,0,8;1138,0,4;1138,1,4;1138,2,4;1138,3,4;1376,1,8;1376,2,8;1376,3,8;1377,0,8;1908,1,4;1908,2,4;1908,3,4;1909,0,4;2177,1,8;2177,2,8;2177,3,8;2185,0,8;2224,1,2;2224,3,2;2225,0,2;2225,2,2;2359,1,4;2359,2,4;2359,3,4;2362,0,4;2401,0,1;2401,3,1;2402,2,1;2403,1,1;2506,0,4;2506,1,4;2506,2,4;2506,3,4;2547,1,2;2547,3,2;2548,2,2;2554,0,2;2683,0,4;2683,1,4;2683,2,4;2683,3,4;2723,1,1;2723,3,1;2724,0,1;2724,2,1;2913,0,4;2913,1,4;2913,2,4;2913,3,4;3005,1,8;3005,2,8;3005,3,8;3093,1,8;3093,2,8;3093,3,8;3134,1,2;3134,2,2;3134,3,2;3228,1,1;3228,2,1;3228,3,1;3231,1,1;3231,2,1;3231,3,1;3303,3,2;3304,3,2;3305,1,2;3305,2,2;3306,2,2;3310,1,2;3329,3,1;3333,1,1;3334,2,1;3334,3,1;3335,2,1;3339,1,1;3426,2,2;3426,3,2;3429,2,2;3429,3,2;3430,1,2;3433,1,2;3501,1,1;3501,2,1;3501,3,1;3502,2,1;3505,1,1;3505,3,1;3526,1,2;3527,2,2;3529,3,2;3556,1,4;3556,2,4;3556,3,4;3634,2,8;3634,3,8;3636,1,8;3668,2,2;3669,1,2;3670,3,2;3696,1,4;3696,2,4;3696,3,4;3887,2,1;3887,3,1;3890,2,1;3890,3,1;3891,1,1;3894,1,1;3960,3,2;3962,1,2;3963,1,2;3963,2,2;3963,3,2;3966,2,2;4011,1,4;4011,2,4;4011,3,4;4110,2,1;4110,3,1;4111,2,1;4118,3,1;4137,1,4;4154,1,1;4182,1,4;4220,2,4;4220,3,4;4238,1,1;4255,1,2;4256,2,2;4256,3,2;4336,1,4;4336,2,4;4336,3,4;4371,1,1;4371,2,1;4372,3,1;4452,1,2;4452,2,2;4452,3,2;4455,1,2;4455,2,2;4455,3,2;4524,1,1;4524,2,1;4525,3,1;4526,1,1;4526,3,1;4529,2,1;4545,1,2;4545,2,2;4545,3,2;4583,1,4;4583,2,4;4583,3,4;4619,2,1;4620,1,1;4623,3,1;4727,1,4;4727,2,4;4727,3,4;4761,2,2;4762,1,2;4763,3,2;4865,2,1;4865,3,1;4866,1,1;4946,1,2;4946,2,2;4946,3,2;4949,1,2;4949,2,2;4949,3,2;5015,2,1;5016,1,1;5016,2,1;5017,3,1;5018,1,1;5022,3,1;5034,1,2;5034,2,2;5034,3,2;5177,1,4;5177,2,4;5177,3,4;5211,1,2;5211,3,2;5213,2,2;5312,1,4;5312,2,4;5312,3,4;5346,1,1;5347,2,1;5347,3,1;5383,1,8;5383,2,8;5383,3,8;5510,2,1;5511,1,1;5511,3,1;5582,1,2;5582,2,2;5582,3,2;5585,1,2;5585,2,2;5585,3,2;5649,1,1;5649,2,1;5650,2,1;5651,3,1;5652,1,1;5652,3,1;5732,1,2;5732,2,2;5732,3,2;5805,1,8;5805,2,8;5805,3,8;5828,1,1;5828,2,1;5828,3,1;5863,1,8;5863,2,8;5863,3,8;5896,1,2;5922,2,4;5922,3,4;5926,1,4;5942,2,2;5946,3,2;5986,1,4;5986,2,4;5986,3,4 +35183201d2dfc47 DSH1|2|endless|e5b05497c8551d12|1000|2|514,0,4;514,1,4;838,0,4;838,1,4;892,1,1;894,0,1;938,0,4;938,1,4;1088,1,2;1089,0,2;1503,0,4;1511,1,4;1625,0,8;1625,1,8;1785,0,4;1785,1,4;2101,1,4;2102,0,4;2200,1,4;2203,0,4;2311,1,8;2313,0,8;2358,0,2;2360,1,2;2464,1,1;2467,1,1;2468,0,1;2471,0,1;2543,0,2;2545,0,2;2548,1,2;2551,1,2;2572,0,1;2573,0,1;2577,1,1;2578,1,1;2683,1,2;2686,1,2;2687,0,2;2694,0,2;2763,1,1;2766,1,1;2770,0,1;2774,0,1;2824,0,4;2824,1,4;3065,1,4;3067,0,4;3104,0,2;3105,1,2;3136,0,4;3139,1,4;3218,0,8;3218,1,8;3303,0,8;3303,1,8;3345,1,2;3346,0,2;3458,0,4;3458,1,4;3496,0,1;3497,1,1;3498,0,1;3503,1,1;3615,1,4;3618,0,4;3652,0,2;3654,1,2;3696,1,4;3707,0,4;3726,1,2;3747,0,2;3764,1,4;3793,0,4;3951,1,1;3954,1,1;4023,1,2;4024,1,2;4083,0,1;4086,0,1;4158,1,4;4170,0,2;4174,0,2;4266,1,1;4353,1,8;4367,0,4;4385,1,1;4411,0,1;4417,1,4;4433,0,1;4456,1,2;4490,1,4;4516,0,2;4567,1,8;4607,1,2;4638,0,8;4693,0,1;4704,1,4;4727,0,4;4735,1,1;4776,0,2;4829,0,4;4931,0,8;4952,1,1;4979,0,2;4986,1,4;5053,1,4;5073,1,2;5084,1,2;5117,0,4;5155,0,1;5158,1,1;5161,1,1;5227,1,2;5229,1,2;5346,1,4;5376,1,1;5410,1,4;5453,0,1;5484,1,4;5486,0,4;5531,0,2;5554,1,4;5583,0,4;5628,0,2;5687,1,2;5720,0,1;5723,0,1;5758,1,1;5765,1,1;5797,0,2;5799,0,2;5825,1,2;5827,1,2;5845,1,1;5880,1,8;5954,0,4;6001,0,1;6025,1,4;6046,0,4;6086,1,8;6126,0,4;6133,1,4;6193,1,8;6214,1,1;6217,0,4;6249,1,8;6280,1,2;6306,1,4;6335,1,1;6366,1,8;6382,0,2;6396,1,2;6422,1,4;6480,0,1;6486,1,8;6487,0,1;6506,1,1;6554,0,2;6555,0,2;6571,1,2;6583,0,1;6624,0,8;6639,1,8;6668,1,1;6750,1,4;6803,0,4;6828,1,2;6877,0,8;6935,0,4;6962,1,4;7008,0,8;7032,1,8;7040,0,1;7065,1,1;7076,0,8;7111,0,2;7142,1,4;7149,0,4;7173,1,2;7181,0,1;7204,1,4;7221,0,8;7256,0,2;7286,0,4;7319,1,1;7384,1,2;7452,1,8;7513,1,1;7578,1,2;7606,1,4;7670,1,8;7731,1,2;7788,1,1;7861,1,4;7906,1,4;7965,1,8;8010,1,4;8068,1,8;8181,1,1;8215,1,8;8274,1,4;8296,1,2;8331,1,4;8386,1,4;8446,1,8;8533,1,4;8650,1,2;8715,1,1;8718,1,1;8781,1,2;8785,1,2;8800,1,1;8887,1,1;8975,1,4;9093,1,4;9121,1,2;9150,1,4;9210,1,4;9259,1,4;9317,1,8;9406,1,4;9472,1,1;9593,1,2;9665,1,8;9681,1,1;9746,1,2;9814,1,8;9873,1,4;9997,1,2;10082,1,4;10106,1,1;10175,1,4;10243,1,8;10300,1,1;10388,1,4;10448,1,4;10468,1,2;10488,1,1;10562,1,4;10594,1,2;10622,1,4;10646,1,1;10711,1,2;10779,1,8;10928,1,4;10993,1,8;11053,1,4;11077,1,1;11142,1,2;11210,1,8;11266,1,4;11331,1,4;11397,1,4;11459,1,4;11486,1,2;11552,1,1;11555,1,1;11621,1,2;11622,1,2;11637,1,1;11668,1,8;11729,1,2;11821,1,8;11851,1,1;11877,1,4;11941,1,8;11961,1,1;11991,1,4;12051,1,4;12071,1,2;12111,1,4;12177,1,4;12237,1,4;12428,1,4;12477,1,4;12535,1,8;12566,1,1;12646,1,4;12677,1,2;12707,1,4;12891,1,4;12941,1,4;12999,1,8;13044,1,4;13106,1,8;13284,1,4;13315,1,2;13399,1,4;13430,1,1;13467,1,8;13519,1,4;13583,1,8;13639,1,4;13668,1,1;13733,1,2;13804,1,8;13821,1,1;13886,1,2;13954,1,8;14102,1,4;14126,1,1;14191,1,2;14259,1,8;14304,1,4;14363,1,8;14408,1,4;14466,1,8;14490,1,1;14521,1,8;14551,1,2;14577,1,4;14637,1,4;14697,1,4;14750,1,8;14795,1,4;14854,1,8;14971,1,1;15059,1,4;15150,1,2;15153,1,2;15212,1,1;15215,1,1;15288,1,2;15355,1,8;15375,1,2;15441,1,1;15444,1,1;15506,1,2;15507,1,2;15524,1,1;15553,1,4;15577,1,1;15612,1,8;15642,1,2;15668,1,4;15761,1,4;15917,1,8;15980,1,8;16033,1,4;16063,1,1;16129,1,2;16195,1,8;16215,1,1;16282,1,2;16348,1,8;16404,1,4;16453,1,4;16512,1,8;16557,1,4;16615,1,8;16635,1,1;16685,1,2;16688,1,2;16727,1,1;16728,1,1;16749,1,4;16798,1,2;16801,1,2;16845,1,1;16848,1,1;16888,1,2;16889,1,2;16897,1,1;16900,1,1;16963,1,2;16970,1,2;17029,1,1;17034,1,1;17070,1,4;17130,1,4;17149,1,2;17157,1,1;17185,1,4;17244,1,4;17264,1,2;17281,1,1;17359,1,4;17408,1,4;17435,1,2;17466,1,8;17495,1,1;17577,1,4;17601,1,2;17670,1,4;17693,1,1;17728,1,8;17784,1,4;17807,1,2;17848,1,8;17893,1,4;17952,1,8;18008,1,4;18057,1,4;18115,1,8;18171,1,4;18231,1,4;18255,1,1;18290,1,8;18346,1,4;18368,1,2;18370,1,1;18435,1,2;18504,1,8;18531,1,2;18613,1,4;18645,1,1;18677,1,8;18826,1,4;18899,1,1;19019,1,2;19022,1,2;19084,1,1;19085,1,1;19095,1,2;19188,1,1;19256,1,2;19321,1,8;19341,1,1;19371,1,4;19434,1,4;19456,1,2;19468,1,1;19546,1,4;19579,1,2;19663,1,1;19728,1,2;19731,1,2;19796,1,1;19799,1,1;19812,1,2;19841,1,4;19933,1,4;20000,1,1;20081,1,4;20194,1,8;20207,1,2;20213,1,1;20279,1,2;20346,1,8;20402,1,4;20495,1,4;20648,1,4;20672,1,1;20702,1,4;20762,1,4;20782,1,2;20786,1,1;20819,1,4;20877,1,4;20897,1,2;20926,1,4;20985,1,8;21004,1,1;21050,1,2;21053,1,2;21097,1,1;21125,1,4;21197,1,8;21210,1,1;21255,1,2;21258,1,2;21320,1,1;21321,1,1;21357,1,4;21374,1,2;21415,1,8;21528,1,1;21593,1,2;21661,1,8;21721,1,8;21766,1,4;21827,1,8;21844,1,1;21870,1,8;21907,1,4;21924,1,2;21951,1,4;22019,1,8;22059,1,8;22089,1,4;22146,1,8;22208,1,1;22264,1,2;22382,1,4;22441,1,8;22502,1,1;22624,1,2;22696,1,8;22723,1,2;22802,1,4;22834,1,1;22862,1,4;22928,1,4;22957,1,1;22988,1,4;23048,1,4;23106,1,8;23162,1,4;23182,1,2;23226,1,8;23285,1,4;23381,1,4;23430,1,4;23488,1,8;23551,1,8;23637,1,4;23673,1,2;23751,1,4;23840,1,1;23908,1,8;23964,1,4;23998,1,1;24088,1,4;24175,1,2;24241,1,8;24261,1,2;24326,1,1;24329,1,1;24391,1,2;24392,1,2;24437,1,8;24493,1,4;24524,1,1;24553,1,4;24577,1,1;24608,1,4;24668,1,4;24690,1,2;24853,1,4;24877,1,1;24942,1,2;25010,1,8;25068,1,4;25121,1,4;25179,1,8;25268,1,4;25333,1,1;25481,1,4;25504,1,2;25573,1,4;25634,1,4;25695,1,4;25812,1,1;25901,1,4;25990,1,2;26057,1,8;26113,1,4;26173,1,4;26239,1,4;26304,1,4;26370,1,4;26393,1,1;26428,1,8;26484,1,4;26504,1,2;26508,1,1;26573,1,2;26643,1,8;26686,1,4;26745,1,8;26772,1,2;26855,1,4;26904,1,4;26920,1,1;26921,1,1;27012,1,8;27042,1,2;27068,1,4;27128,1,4;27152,1,1;27217,1,2;27285,1,8;27347,1,1;27439,1,4;27459,1,2;27504,1,4;27556,1,4;27614,1,8;27632,1,2;27678,1,1;27681,1,1;27721,1,2;27722,1,2;27752,1,8;27789,1,4;27803,1,1;27805,1,1;27848,1,2;27901,1,8;27964,1,1;28084,1,2;28152,1,8;28216,1,2;28305,1,4;28362,1,4;28381,1,1;28384,1,1;28415,1,4;28475,1,4;28496,1,2;28499,1,1;28564,1,2;28567,1,2;28630,1,1;28633,1,1;28731,1,4;28791,1,4;28814,1,2;28864,1,1;28944,1,4;28960,1,2;28961,1,2;29042,1,4;29062,1,1;29101,1,8;29125,1,2;29186,1,1;29189,1,1;29255,1,2;29260,1,2;29263,1,1;29331,1,4;29391,1,4;29451,1,4;29550,1,4;29573,1,1;29639,1,2;29706,1,8;29763,1,4;29827,1,8;29975,1,4;30004,1,1;30070,1,2;30137,1,8;30182,1,4;30241,1,8;30261,1,1;30286,1,8;30306,1,2;30323,1,4;30395,1,8;30425,1,4;30465,1,8;30484,1,2;30553,1,4;30577,1,1;30670,1,1;30735,1,2;30803,1,8;30863,1,8;30919,1,4;30951,1,2;31039,1,4;31088,1,4;31104,1,1;31132,1,4;31141,1,1;31176,1,4;31208,1,2;31397,1,8;31480,1,4;31577,1,8;31658,1,4;31740,1,4;31993,1,4;32077,1,8;32140,1,4;32213,1,8;32273,1,4;32348,1,8;32381,1,1;32465,1,2;32468,1,2;32539,1,1;32540,1,1;32586,1,4;32788,1,4;32848,1,4;32865,1,2;32892,1,4;32943,1,2;33035,1,1;33113,1,8;33291,1,4;33323,1,2;33400,1,1;33403,1,1;33477,1,2;33479,1,2;33512,1,4;33531,1,1;33578,1,8;33677,1,4;33744,1,4;33773,1,1;33840,1,2;33915,1,8;33976,1,4;34008,1,1;34105,1,4;34173,1,8;34185,1,2;34365,1,4;34510,1,4;34570,1,8;34660,1,4;34724,1,8;34782,1,4;34815,1,2;34901,1,4;35015,1,4;35046,1,1;35075,1,4;35141,1,4;35208,1,4;35255,1,4;35314,1,8;35333,1,1;35381,1,2;35428,1,8;35438,1,1;35481,1,2;35526,1,8;35566,1,4;35584,1,2;35670,1,4;35759,1,1;35826,1,8;35915,1,4;35977,1,4;36030,1,4;36088,1,8;36116,1,1;36199,1,4;36230,1,2;36351,1,4;36415,1,8;36478,1,2;36543,1,1;36626,1,1;36657,1,4;36720,1,4;36737,1,2;36753,1,1;36831,1,4;36862,1,2;36895,1,8;36915,1,1;36984,1,2;37052,1,8;37112,1,2;37166,1,1;37235,1,4;37259,1,1;37324,1,2;37392,1,8;37420,1,1;37502,1,4;37526,1,2;37595,1,4;37659,1,8;37721,1,1;37809,1,4;37875,1,2;37968,1,2;38033,1,1;38036,1,1;38101,1,2;38103,1,2;38117,1,1;38203,1,1;38268,1,2;38335,1,8;38424,1,4;38484,1,4;38508,1,1;38543,1,8;38601,1,4;38618,1,2;38659,1,4;38683,1,1;38748,1,2;38751,1,2;38814,1,1;38821,1,1;38824,1,2;38953,1,4;39013,1,4;39072,1,4;39126,1,8;39182,1,4;39212,1,1;39277,1,2;39280,1,2;39343,1,1;39356,1,1;39408,1,2;39415,1,1;39504,1,4;39528,1,2;39531,1,2;39593,1,1;39596,1,1;39659,1,2;39666,1,2;39677,1,1;39831,1,4;39897,1,2;39953,1,1;40022,1,4;40086,1,8;40148,1,2;40235,1,4;40295,1,4;40315,1,1;40332,1,2;40410,1,4;40433,1,1;40436,1,1;40499,1,2;40502,1,2;40569,1,1;40572,1,1;40582,1,2;40611,1,4;40635,1,1;40700,1,2;40772,1,8;40824,1,4;40855,1,2;40944,1,4;41033,1,1;41036,1,1;41101,1,2;41103,1,2;41117,1,1;41149,1,4;41170,1,1;41235,1,2;41303,1,8;41364,1,4;41383,1,1;41448,1,2;41451,1,2;41514,1,1;41515,1,1;41533,1,2;41565,1,8;41625,1,8;41644,1,1;41680,1,8;41735,1,4;41796,1,8;41862,1,4;41906,1,4;41964,1,4;41987,1,2;41988,1,1;42053,1,2;42056,1,2;42121,1,1;42123,1,1;42157,1,4;42173,1,2;42175,1,2;42253,1,4;42270,1,1;42312,1,8;42372,1,8;42461,1,4;42526,1,2;42673,1,4;42762,1,1;42830,1,8;42886,1,4;42910,1,1;42975,1,2;43043,1,8;43099,1,4;43241,1,4;43299,1,8;43388,1,4;43412,1,1;43442,1,4;43502,1,4;43522,1,2;43595,1,4;43619,1,2;43684,1,1;43687,1,1;43756,1,2;43758,1,2;43816,1,1;43821,1,1;43886,1,2;43954,1,8;44014,1,4;44103,1,4;44130,1,1;44159,1,4;44217,1,4;44237,1,2;44241,1,2;44306,1,1;44309,1,1;44377,1,2;44379,1,2;44390,1,1;44501,1,4;44559,1,8;44615,1,4;44642,1,1;44730,1,4;44761,1,2;44794,1,8;44813,1,1;44882,1,2;44946,1,8;44966,1,2;45032,1,1;45035,1,1;45097,1,2;45103,1,2;45117,1,1;45148,1,8;45237,1,4;45261,1,1;45326,1,2;45394,1,8;45451,1,4;45479,1,2;45544,1,1;45547,1,1;45611,1,2;45612,1,2;45646,1,4;45662,1,1;45713,1,8;45842,1,4;45901,1,8;45990,1,4;46022,1,2;46104,1,4;46128,1,1;46131,1,1;46193,1,2;46200,1,2;46263,1,1;46265,1,1;46278,1,2;46306,1,4;46370,1,8;46430,1,8;46450,1,1;46515,1,2;46518,1,2;46583,1,1;46585,1,1;46622,1,4;46682,1,4;46731,1,4;46752,1,2;46790,1,8;46850,1,4;46876,1,2;46964,1,4;46993,1,1;47077,1,1;47143,1,2;47146,1,2;47210,1,1;47211,1,1;47250,1,4;47310,1,4;47329,1,2;47333,1,1;47399,1,2;47466,1,8;47486,1,1;47524,1,8;47581,1,4;47597,1,2;47670,1,4;47736,1,1;47826,1,8;47882,1,4;47902,1,2;47942,1,4;48195,1,4;48242,1,4;48301,1,8;48357,1,4;48381,1,1;48446,1,2;48514,1,8;48663,1,4;48726,1,8;48746,1,1;48777,1,4;48837,1,4;48858,1,2;48897,1,4;48926,1,1;48959,1,4;48991,1,2;49017,1,4;49082,1,1;49139,1,2;49142,1,2;49204,1,1;49207,1,1;49270,1,2;49273,1,2;49290,1,1;49317,1,4;49371,1,4;49430,1,8;49457,1,1;49541,1,4;49599,1,8;49655,1,4;49676,1,2;49715,1,4;49785,1,8;49873,1,4;49897,1,1;49962,1,2;50033,1,8;50050,1,1;50081,1,4;50141,1,4;50166,1,2;50201,1,4;50261,1,4;50330,1,8;50350,1,1;50381,1,4;50441,1,4;50460,1,2;50502,1,4;50566,1,4;50590,1,1;50655,1,2;50723,1,8;50785,1,1;50841,1,2;50910,1,4;50959,1,4;51017,1,8;51045,1,2;51128,1,4;51177,1,4;51193,1,1;51235,1,8;51293,1,4;51482,1,4;51506,1,2;51572,1,1;51575,1,1;51642,1,2;51647,1,2;51673,1,4;51690,1,1;51732,1,8;51821,1,4;51844,1,1;51910,1,2;51977,1,8;52039,1,2;52124,1,4;52138,1,1;52168,1,4;52176,1,1;52212,1,4;52242,1,2;52295,1,8;52338,1,1;52416,1,2;52517,1,8;52557,1,1;52590,1,4;52633,1,2;52675,1,4;52834,1,2;52874,1,4;52979,1,1;53018,1,4;53107,1,4;53136,1,2;53256,1,4;53293,1,1;53295,1,1;53368,1,2;53457,1,8;53532,1,4;53566,1,1;53638,1,2;53724,1,8;53794,1,4;53907,1,4;53979,1,4;54096,1,4;54167,1,4;54200,1,1;54269,1,2;54349,1,8;54422,1,8;54471,1,4;54536,1,8;54696,1,4;54756,1,8;54792,1,1;54878,1,4;54911,1,2;54944,1,4;55013,1,8;55073,1,4;55107,1,1;55138,1,4;55174,1,2;55203,1,4;55239,1,1;55325,1,4;55348,1,2;55419,1,4;55514,1,4;55704,1,4;55728,1,1;55793,1,2;55796,1,2;55860,1,1;55861,1,1;55877,1,2;55988,1,4;56046,1,8;56109,1,2;56229,1,1;56299,1,8;56344,1,4;56401,1,8;56421,1,1;56466,1,2;56512,1,8;56531,1,2;56585,1,4;56732,1,1;56735,1,1;56802,1,2;56809,1,2;56899,1,4;57013,1,4;57044,1,1;57077,1,8;57097,1,1;57162,1,2;57230,1,8;57343,1,1;57410,1,2;57475,1,8;57538,1,1;57624,1,4;57684,1,4;57704,1,2;57748,1,8;57808,1,8;57864,1,4;57930,1,4;57953,1,1;57988,1,8;58044,1,4;58064,1,2;58068,1,1;58133,1,2;58136,1,2;58199,1,1;58204,1,1;58235,1,4;58373,1,2;58441,1,8;58530,1,4;58598,1,8;58682,1,4;58706,1,1;58771,1,2;58839,1,8;58895,1,4;58927,1,2;59015,1,4;59070,1,4;59130,1,4;59155,1,1;59190,1,4;59355,1,2;59419,1,1;59446,1,4;59470,1,1;59505,1,8;59561,1,4;59581,1,2;59588,1,1;59650,1,2;59653,1,2;59716,1,1;59717,1,1;59733,1,2;59762,1,4;59830,1,1;59884,1,2;60002,1,4;60061,1,8;60173,1,1;60204,1,4;60264,1,4;60285,1,2;60324,1,4;60356,1,1;60444,1,4;60503,1,8;60559,1,4;60580,1,2;60619,1,4;60643,1,2;60708,1,1;60711,1,1;60774,1,2;60776,1,2;60792,1,1;60821,1,4;60891,1,2;60973,1,4;60989,1,1;61017,1,4;61024,1,1;61063,1,4;61096,1,2;61102,1,2;61223,1,4;61262,1,1;61263,1,1;61295,1,4;61447,1,8;61494,1,2;61527,1,4;61571,1,1;61607,1,4;61649,1,2;61686,1,4;61771,1,4;61853,1,8;61933,1,8;62013,1,8;62088,1,4;62127,1,1;62164,1,8;62202,1,2;62236,1,4;62385,1,2;62416,1,4;62515,1,1;62557,1,8;62631,1,8;62700,1,4;62736,1,1;62766,1,4;62859,1,2;62936,1,4;63046,1,4;63121,1,1;63217,1,4;63236,1,2;63284,1,8;63415,1,1;63447,1,4;63487,1,2;63514,1,4;63544,1,1;63611,1,2;63687,1,8;63854,1,8;63878,1,1;63944,1,2;64017,1,8;64040,1,1;64109,1,2;64112,1,2;64179,1,1;64184,1,1;64215,1,4;64291,1,2;64362,1,4;64387,1,1;64422,1,8;64479,1,4;64499,1,2;64516,1,2;64595,1,4;64626,1,1;64655,1,4;64681,1,1;64774,1,4;64793,1,2;64862,1,4;64922,1,4;64952,1,1;65017,1,2;65020,1,2;65087,1,1;65095,1,1;65102,1,2;65130,1,4;65194,1,8;65213,1,1;65248,1,8;65304,1,4;65325,1,2;65364,1,4;65431,1,4;65528,1,4;65588,1,4;65617,1,2;65682,1,1;65685,1,1;65748,1,2;65749,1,2;65759,1,1;65828,1,4;65852,1,1;65917,1,2;65920,1,2;65984,1,1;65985,1,1;66084,1,4;66139,1,4;66201,1,4;66218,1,2;66248,1,4;66309,1,8;66326,1,1;66361,1,8;66417,1,4;66438,1,2;66574,1,4;66668,1,4;66760,1,4;66810,1,4;66868,1,8;66924,1,4;66973,1,4;67032,1,8;67052,1,1;67082,1,4;67142,1,4;67162,1,2;67206,1,8;67226,1,2;67292,1,1;67295,1,1;67357,1,2;67358,1,2;67433,1,1;67436,1,1;67506,1,2;67519,1,1;67600,1,4;67632,1,2;67879,1,4;67910,1,1;67993,1,4;68042,1,4;68060,1,2;68064,1,2;68206,1,4;68230,1,1;68233,1,1;68295,1,2;68298,1,2;68361,1,1;68364,1,1;68462,1,4;68486,1,2;68555,1,4;68579,1,2;68644,1,1;68647,1,1;68713,1,2;68717,1,2;68721,1,1;68724,1,1;68786,1,2;68789,1,2;68857,1,1;68858,1,1;68928,1,2;68931,1,2;68993,1,1;68998,1,1;69012,1,2;69097,1,1;69162,1,2;69230,1,8;69275,1,4;69335,1,8;69353,1,1;69379,1,8;69416,1,4;69431,1,2;69459,1,8;69482,1,1;69533,1,4;69552,1,2;69615,1,1;69650,1,8;69706,1,4;69726,1,2;69770,1,8;69790,1,1;69855,1,2;69858,1,2;69929,1,1;69930,1,1;69939,1,2;69968,1,4;69992,1,1;70057,1,2;70124,1,8;70186,1,2;70279,1,4;70298,1,1;70344,1,4;70373,1,2;70439,1,1;70442,1,1;70505,1,2;70512,1,2;70580,1,1;70648,1,8;70708,1,8;70728,1,1;70793,1,2;70861,1,8;70917,1,4;70941,1,1;71006,1,2;71074,1,8;71137,1,1;71257,1,2;71328,1,8;71344,1,1;71412,1,2;71477,1,8;71539,1,2;71604,1,1;71631,1,4;71681,1,4;71739,1,8;71888,1,4;71980,1,4;72008,1,1;72035,1,4;72095,1,4;72115,1,2;72144,1,4;72203,1,8;72291,1,4;72315,1,1;72380,1,2;72448,1,8;72630,1,4;72690,1,4;72760,1,2;72843,1,4;72860,1,1;72887,1,4;72895,1,1;72931,1,4;72965,1,2;72968,1,2;72996,1,4;73154,1,4;73192,1,1;73235,1,4;73495,1,4;73563,1,4;73641,1,8;73679,1,1;73752,1,2;73846,1,8;73882,1,1;73992,1,4;74030,1,2;74056,1,4;74130,1,8;74166,1,1;74272,1,4;74317,1,2;74347,1,4;74432,1,8;74506,1,1;74609,1,4;74627,1,2;74679,1,8;74747,1,4;74822,1,8;74861,1,1;74953,1,4;74988,1,2;75117,1,4;75188,1,8;75357,1,8;75485,1,1;75553,1,2;75628,1,8;75652,1,1;75687,1,4;75748,1,4;75770,1,2;75813,1,4;75887,1,2;75969,1,4;75987,1,1;76031,1,8;76094,1,8;76157,1,1;76282,1,2;76352,1,8;76416,1,8;76473,1,8;76493,1,1;76559,1,2;76562,1,2;76625,1,1;76626,1,1;76666,1,4;76700,1,2;76729,1,4;76762,1,2;76843,1,4;76871,1,1;77026,1,4;77050,1,1;77080,1,4;77140,1,4;77161,1,2;77164,1,1;77230,1,2;77233,1,2;77295,1,1;77296,1,1;77371,1,2;77439,1,8;77459,1,1;77524,1,2;77592,1,8;77746,1,2;77811,1,1;77839,1,4;77908,1,8;77976,1,2;78057,1,4;78117,1,4;78137,1,1;78276,1,2;78332,1,1;78335,1,1;78397,1,2;78400,1,2;78469,1,1;78472,1,1;78473,1,2;78635,1,4;78699,1,8;78719,1,1;78784,1,2;78852,1,8;78884,1,1;78962,1,4;79051,1,2;79119,1,8;79232,1,1;79297,1,2;79364,1,8;79384,1,1;79449,1,2;79518,1,8;79537,1,1;79602,1,2;79670,1,8;79728,1,4;79790,1,8;79974,1,4;80124,1,4;80241,1,1;80271,1,4;80333,1,4;80353,1,2;80368,1,2;80447,1,4;80499,1,4;80568,1,1;80571,1,1;80633,1,2;80636,1,2;80700,1,1;80705,1,1;80714,1,2;80780,1,4;80839,1,4;80870,1,2;80959,1,4;81008,1,4;81025,1,1;81026,1,1;81119,1,8;81172,1,4;81194,1,2;81239,1,2;81319,1,4;81335,1,1;81377,1,8;81437,1,8;81526,1,4;81550,1,1;81615,1,2;81683,1,8;81711,1,1;81793,1,4;81882,1,2;81950,1,8;82099,1,4;82168,1,8;82235,1,2;82293,1,1;82326,1,8;82346,1,1;82377,1,4;82411,1,2;82437,1,4;82461,1,1;82528,1,2;82594,1,8;82654,1,8;82710,1,4;82777,1,4;82833,1,4;82888,1,8;82916,1,1;83001,1,4;83048,1,4;83067,1,2;83106,1,8;83126,1,1;83149,1,4;83197,1,4;83207,1,2;83232,1,8;83235,1,8;83238,1,8;83273,1,1;83329,1,4;83341,1,2;83386,1,2;83393,1,1;83396,1,1;83459,1,2;83462,1,2;83530,1,1;83535,1,1;83566,1,4;83600,1,2;83626,1,4;83784,1,2;83931,1,4;83962,1,1;83991,1,4;84015,1,2;84081,1,1;84084,1,1;84146,1,2;84149,1,2;84164,1,1;84193,1,4;84217,1,1;84248,1,4;84308,1,4;84328,1,2;84362,1,4;84415,1,8;84471,1,4;84537,1,1;84602,1,2;84630,1,4;84659,1,1;84724,1,2;84792,1,8;84906,1,1;84995,1,4;85084,1,2;85152,1,8;85212,1,8;85232,1,1;85297,1,2;85364,1,8;85384,1,1;85449,1,2;85517,1,8;85573,1,4;85602,1,1;85668,1,2;85735,1,8;85780,1,4;85840,1,8;85859,1,1;85928,1,2;85931,1,2;85992,1,1;85993,1,1;86026,1,4;86043,1,2;86084,1,8;86144,1,4;86260,1,2;86348,1,4;86372,1,1;86375,1,1;86437,1,2;86440,1,2;86502,1,1;86506,1,1;86522,1,2;86645,1,4;86837,1,4;86903,1,1;86986,1,4;87049,1,4;87069,1,2;87111,1,1;87233,1,2;87236,1,2;87304,1,1;87356,1,1;87415,1,2;87444,1,4;87504,1,4;87597,1,4;87625,1,1;87687,1,2;87754,1,8;87773,1,1;87839,1,2;87908,1,8;87962,1,4;88084,1,1;88149,1,2;88219,1,8;88237,1,1;88302,1,2;88370,1,8;88459,1,4;88551,1,4;88575,1,2;88641,1,1;88644,1,1;88709,1,2;88715,1,2;88727,1,1;88757,1,8;88777,1,1;88812,1,4;88868,1,4;88888,1,2;89027,1,1;89173,1,4;89197,1,2;89273,1,4;89330,1,8;89393,1,4;89455,1,8;89515,1,8;89575,1,8;89635,1,8;89695,1,8;89715,1,1;89780,1,2;89850,1,8;89868,1,1;89903,1,8;89961,1,4;89978,1,2;89996,1,1;90077,1,4;90125,1,4;90142,1,2;90145,1,2;90205,1,1;90235,1,4;90262,1,1;90346,1,4;90370,1,2;90466,1,2;90528,1,1;90531,1,1;90595,1,2;90599,1,2;90604,1,1;90733,1,4;90805,1,1;90870,1,2;90901,1,8;90921,1,2;90986,1,1;90989,1,1;91052,1,2;91055,1,2;91072,1,1;91099,1,4;91159,1,4;91323,1,1;91386,1,2;91415,1,4;91446,1,1;91535,1,4;91569,1,2;91595,1,4;91619,1,1;91684,1,2;91752,1,8;91808,1,4;91873,1,4;91897,1,1;91928,1,4;91988,1,4;92007,1,2;92104,1,1;92169,1,2;92237,1,8;92293,1,4;92359,1,4;92423,1,8;92560,1,4;92619,1,8;92668,1,4;92723,1,8;92784,1,1;92866,1,4;92993,1,2;93128,1,4;93154,1,1;93242,1,4;93332,1,2;93339,1,2;93398,1,1;93400,1,1;93416,1,2;93447,1,4;93504,1,4;93574,1,8;93621,1,4;93677,1,8;93790,1,2;93859,1,1;93862,1,1;93920,1,2;93925,1,2;93932,1,1;93935,1,1;93997,1,2;94000,1,2;94062,1,1;94064,1,1;94083,1,2;94166,1,1;94236,1,2;94239,1,2;94297,1,1;94298,1,1;94373,1,2;94442,1,8;94470,1,1;94551,1,4;94600,1,4;94617,1,2;94659,1,8;94679,1,1;94724,1,2;94770,1,8;94829,1,4;94871,1,8;94902,1,4;94952,1,8;95062,1,2;95151,1,4;95210,1,8;95266,1,4;95288,1,1;95326,1,4;95424,1,4;95448,1,1;95513,1,2;95584,1,8;95693,1,1;95759,1,2;95826,1,8;95888,1,1;95970,1,4;96073,1,4;96133,1,4;96154,1,2;96199,1,8;96253,1,4;96277,1,1;96308,1,4;96368,1,4;96388,1,2;96392,1,1;96422,1,4;96482,1,4;96504,1,2;96520,1,1;96598,1,4;96646,1,4;96665,1,2;96704,1,8;96724,1,1;96749,1,4;96793,1,4;96800,1,2;96803,1,1;96826,1,8;96829,1,8;96832,1,8;96866,1,4;96878,1,2;96925,1,4;96969,1,4;97060,1,4;97084,1,1;97122,1,8;97175,1,4;97198,1,2;97420,1,4;97444,1,1;97509,1,2;97577,1,8;97666,1,4;97726,1,4;97888,1,4;97917,1,1;97999,1,4;98022,1,2;98091,1,4;98155,1,8;98304,1,4;98370,1,4;98441,1,8;98459,1,1;98524,1,2;98592,1,8;98612,1,2;98677,1,1;98684,1,1;98742,1,2;98745,1,2;98819,1,1;98886,1,8;98946,1,8;99095,1,4;99144,1,4;99203,1,8;99230,1,2;99313,1,4;99347,1,1;99376,1,4;99397,1,1;99462,1,2;99530,1,8;99550,1,2;99615,1,1;99618,1,1;99682,1,2;99684,1,2;99699,1,1;99728,1,4;99850,1,1;99884,1,8;99940,1,4;99999,1,8;100062,1,4;100079,1,2;100119,1,8;100168,1,4;100223,1,8;100279,1,4;100344,1,4;100437,1,4;100461,1,1;100526,1,2;100529,1,2;100595,1,1;100597,1,1;100610,1,2;100639,1,4;100709,1,1;100791,1,4;100929,1,2;100997,1,8;101053,1,4;101123,1,8;101188,1,1;101250,1,2;101335,1,2;101424,1,4;101477,1,4;101492,1,1;101532,1,8;101600,1,2;101657,1,1;101690,1,8;101722,1,1;101800,1,4;101893,1,2;101957,1,8;102018,1,2;102142,1,1;102208,1,8;102269,1,2;102355,1,4;102373,1,1;102374,1,1;102450,1,4;102467,1,2;102508,1,8;102528,1,1;102593,1,2;102596,1,2;102660,1,1;102664,1,1;102677,1,2;102708,1,4;102737,1,2;102826,1,4;102859,1,1;102886,1,4;103048,1,8;103068,1,1;103133,1,2;103204,1,8;103246,1,4;103304,1,8;103324,1,1;103370,1,2;103417,1,8;103445,1,4;103484,1,8;103487,1,8;103525,1,2;103563,1,1;103784,1,8;103873,1,4;103897,1,1;103962,1,2;103965,1,2;104030,1,1;104033,1,1;104039,1,2;104132,1,2;104197,1,1;104200,1,1;104262,1,2;104265,1,2;104282,1,1;104310,1,4;104339,1,1;104404,1,2;104472,1,8;104517,1,4;104575,1,8;104595,1,1;104621,1,8;104658,1,4;104672,1,2;104674,1,1;104697,1,8;104717,1,2;104734,1,4;104812,1,1;104833,1,4;104888,1,4;104907,1,2;104912,1,1;104977,1,2;104980,1,2;105042,1,1;105044,1,1;105088,1,8;105144,1,4;105164,1,2;105193,1,4;105252,1,8;105279,1,1;105362,1,4;105411,1,4;105492,1,2;105613,1,4;105662,1,4;105721,1,8;105766,1,4;105824,1,8;105844,1,1;105890,1,2;105893,1,2;105934,1,1;105935,1,1;105958,1,4;106011,1,2;106097,1,8;106142,1,4;106204,1,8;106221,1,1;106243,1,4;106287,1,4;106300,1,2;106326,1,8;106329,1,8;106385,1,4;106401,1,1;106429,1,4 +1b65e15bb0a521b5 DSH1|2|endless|e5b05497c8551d12|1001|3|713,0,4;713,1,4;713,2,4;1025,0,4;1025,2,4;1029,1,4;1324,0,8;1324,1,8;1324,2,8;1602,0,4;1602,1,4;1602,2,4;1645,1,1;1645,2,1;1648,0,1;1689,0,4;1689,1,4;1689,2,4;1827,2,2;1828,1,2;1832,0,2;1884,1,8;1884,2,8;1886,0,8;2037,0,4;2037,1,4;2037,2,4;2080,1,1;2081,0,1;2081,2,1;2119,1,4;2119,2,4;2127,0,4;2242,1,2;2242,2,2;2245,0,2;2641,0,4;2641,1,4;2641,2,4;2733,1,4;2733,2,4;2736,0,4;2828,1,8;2828,2,8;2829,0,8;2871,0,1;2873,2,1;2874,1,1;2993,1,4;2993,2,4;3002,0,4;3032,0,2;3034,1,2;3034,2,2;3214,0,4;3214,1,4;3214,2,4;3244,0,1;3244,2,1;3247,1,1;3350,0,2;3350,1,2;3350,2,2;3353,0,2;3353,1,2;3353,2,2;3425,0,1;3425,1,1;3426,0,1;3426,2,1;3427,1,1;3427,2,1;3451,1,2;3451,2,2;3455,0,2;3622,1,4;3622,2,4;3626,0,4;3703,0,4;3703,1,4;3703,2,4;3740,0,2;3740,1,2;3741,2,2;3837,2,1;3840,2,1;3841,1,1;3844,1,1;3845,0,1;3848,0,1;3910,1,2;3911,2,2;3912,1,2;3913,0,2;3914,2,2;3918,0,2;3937,0,1;3939,2,1;3940,1,1;3976,0,4;3976,1,4;3976,2,4;4021,0,1;4021,1,1;4021,2,1;4094,1,2;4094,2,2;4097,0,2;4183,1,8;4183,2,8;4186,0,8;4356,1,4;4356,2,4;4428,1,8;4428,2,8;4458,1,2;4458,2,2;4461,0,8;4484,1,4;4484,2,4;4503,0,2;4546,0,4;4603,1,4;4603,2,4;4621,2,1;4625,1,1;4673,1,8;4673,2,8;4680,0,1;4722,0,4;4740,1,4;4740,2,4;4822,2,8;4826,0,8;4829,1,8;4875,1,4;4875,2,4;4920,0,4;4943,1,8;4943,2,8;4971,1,1;4971,2,1;5006,1,8;5006,2,8;5032,0,8;5041,2,2;5043,1,2;5071,1,4;5071,2,4;5114,0,4;5204,0,8;5208,2,1;5212,1,1;5239,1,4;5239,2,4;5258,0,1;5276,1,2;5276,2,2;5292,0,8;5306,1,4;5306,2,4;5334,0,2;5336,1,1;5336,2,1;5383,0,4;5404,1,2;5404,2,2;5480,1,8;5480,2,8;5542,1,4;5542,2,4;5579,0,1;5607,1,4;5607,2,4;5613,0,4;5641,1,1;5641,2,1;5658,0,2;5676,1,8;5676,2,8;5704,0,4;5709,2,2;5737,1,4;5737,2,4;5753,0,1;5762,1,2;5764,1,1;5764,2,1;5831,2,2;5835,0,2;5835,1,2;5903,2,8;5906,1,8;5936,0,8;5963,2,4;5966,1,4;5989,1,1;5989,2,1;6020,0,4;6055,1,2;6055,2,2;6106,0,4;6126,1,8;6126,2,8;6159,0,1;6188,1,8;6188,2,8;6199,0,8;6209,1,2;6209,2,2;6238,0,2;6276,0,4;6276,1,1;6276,2,1;6279,1,1;6279,2,1;6320,0,1;6341,2,2;6342,2,2;6343,1,2;6345,1,2;6378,1,4;6378,2,4;6394,1,1;6394,2,1;6395,0,2;6437,1,8;6437,2,8;6465,2,2;6467,1,2;6492,0,8;6548,1,4;6548,2,4;6575,0,4;6597,2,4;6600,1,4;6611,0,1;6613,2,1;6617,1,1;6655,2,8;6657,1,8;6675,1,1;6675,2,1;6684,0,2;6741,1,2;6741,2,2;6744,1,2;6744,2,2;6778,0,8;6806,1,1;6808,1,1;6810,2,1;6811,2,1;6842,1,4;6842,2,4;6859,1,2;6862,0,8;6862,2,2;6896,0,2;6901,1,8;6901,2,8;6946,2,4;6948,1,4;6980,0,1;6983,0,1;7005,2,8;7009,1,8;7032,2,1;7039,1,1;7049,0,2;7052,0,2;7099,0,4;7115,0,1;7115,1,4;7115,2,4;7139,1,2;7139,2,2;7142,1,2;7142,2,2;7172,0,8;7204,1,1;7204,2,1;7207,0,2;7207,1,1;7207,2,1;7270,2,2;7271,1,2;7271,2,2;7272,1,2;7281,1,1;7281,2,1;7311,0,4;7372,0,4;7394,0,1;7414,1,8;7414,2,8;7433,2,1;7437,1,1;7443,0,8;7464,1,4;7464,2,4;7473,0,1;7524,1,4;7524,2,4;7544,1,2;7545,2,2;7548,1,1;7548,2,1;7564,0,2;7571,0,2;7583,1,8;7583,2,8;7623,0,1;7626,0,1;7639,1,4;7639,2,4;7658,2,2;7659,1,2;7675,2,2;7677,1,2;7680,0,4;7690,0,2;7753,1,4;7753,2,4;7770,0,8;7843,1,1;7843,2,1;7846,1,1;7846,2,1;7860,0,4;7908,1,2;7908,2,2;7910,2,2;7911,1,2;7944,1,4;7944,2,4;7950,0,8;7961,2,1;7965,1,1;7988,1,4;7988,2,4;7994,0,1;7995,1,1;7996,2,1;8032,1,4;8032,2,4;8067,1,2;8069,1,2;8069,2,2;8071,2,2;8140,0,4;8184,0,2;8185,0,2;8194,1,4;8194,2,4;8302,0,1;8305,0,1;8335,1,1;8335,2,1;8338,1,1;8338,2,1;8387,0,2;8389,0,2;8410,2,2;8411,2,2;8412,0,1;8412,1,2;8415,1,2;8559,1,4;8559,2,4;8597,1,1;8597,2,1;8615,0,8;8662,0,1;8695,0,4;8741,0,2;8772,1,8;8772,2,8;8787,0,4;8840,0,1;8852,1,2;8852,2,2;8873,0,8;8914,0,2;8958,0,4;8965,1,4;8965,2,4;9006,0,2;9125,0,4;9154,1,1;9154,2,1;9157,2,1;9161,1,1;9229,1,2;9230,2,2;9231,1,2;9231,2,2;9264,0,1;9267,0,1;9277,1,4;9277,2,4;9341,0,2;9345,0,2;9366,0,1;9367,0,1;9408,1,4;9408,2,4;9448,2,1;9452,1,1;9483,1,4;9483,2,4;9489,0,4;9559,0,4;9572,0,2;9573,0,2;9598,1,4;9598,2,4;9644,0,4;9671,1,4;9671,2,4;9737,1,4;9737,2,4;9773,0,1;9776,0,1;9807,1,8;9807,2,8;9837,2,2;9838,1,2;9846,0,2;9847,0,2;9939,1,4;9939,2,4;9972,1,1;9972,2,1;9987,0,4;10020,0,1;10049,1,4;10049,2,4;10118,1,4;10118,2,4;10156,1,1;10156,2,1;10184,0,8;10188,1,4;10188,2,4;10225,2,2;10257,2,4;10258,1,4;10259,0,2;10280,1,2;10329,2,1;10330,1,1;10367,0,4;10434,1,8;10434,2,8;10467,2,2;10497,1,4;10497,2,4;10519,1,2;10527,1,2;10527,2,2;10545,0,1;10552,0,1;10599,1,1;10599,2,1;10602,1,1;10602,2,1;10612,0,2;10624,0,2;10663,0,4;10667,2,2;10668,1,2;10668,2,2;10671,1,2;10750,2,1;10753,1,1;10783,0,4;10823,0,1;10825,1,8;10825,2,8;10848,1,1;10848,2,1;10854,0,4;10919,1,2;10919,2,2;10922,1,2;10922,2,2;10963,0,4;10987,1,1;10987,2,1;10989,1,1;10990,2,1;11007,2,2;11008,1,2;11032,0,4;11037,1,4;11037,2,4;11069,2,1;11070,1,1;11095,0,4;11161,0,8;11163,1,4;11163,2,4;11195,0,2;11214,1,4;11214,2,4;11287,0,4;11317,0,1;11358,1,2;11358,2,2;11361,1,2;11361,2,2;11391,0,4;11423,1,1;11424,1,1;11424,2,1;11428,2,1;11457,0,4;11492,0,1;11502,1,2;11502,2,2;11505,1,2;11505,2,2;11527,0,4;11563,0,2;11567,1,1;11567,2,1;11568,2,1;11571,1,1;11586,2,2;11587,1,2;11593,0,4;11615,1,4;11615,2,4;11644,1,1;11644,2,1;11662,0,1;11710,1,2;11710,2,2;11761,0,8;11777,1,8;11777,2,8;11797,1,2;11797,2,2;11824,0,4;11841,0,2;11850,0,2;11863,1,1;11863,2,1;11866,1,1;11866,2,1;11916,0,1;11919,0,1;11928,2,2;11929,1,2;11929,2,2;11931,1,2;11983,0,2;11991,0,2;12004,1,1;12004,2,1;12065,0,1;12072,1,8;12072,2,8;12133,1,2;12133,2,2;12135,0,8;12155,0,1;12225,1,8;12225,2,8;12226,0,2;12229,0,2;12281,1,4;12281,2,4;12290,0,1;12296,0,1;12301,2,1;12304,1,1;12307,0,2;12336,0,4;12345,1,8;12345,2,8;12364,1,1;12364,2,1;12377,0,1;12430,1,2;12430,2,2;12433,1,2;12433,2,2;12458,0,4;12496,1,1;12499,1,1;12499,2,1;12500,2,1;12511,0,4;12531,1,4;12531,2,4;12612,1,2;12613,2,2;12641,2,4;12642,1,4;12645,0,2;12648,0,2;12711,2,2;12712,1,2;12718,0,1;12721,0,1;12787,0,2;12790,0,2;12803,1,8;12803,2,8;12853,0,1;12855,0,1;12859,2,4;12860,1,4;12872,0,2;12879,1,1;12879,2,1;12895,2,1;12900,0,4;12902,1,1;12929,0,1;12973,1,4;12973,2,4;12998,0,2;13063,1,2;13063,2,2;13064,0,8;13066,1,2;13066,2,2;13082,0,2;13129,2,1;13130,1,1;13130,2,1;13136,1,1;13155,0,1;13162,0,1;13212,0,2;13214,0,2;13230,2,4;13232,1,4;13295,0,1;13344,1,4;13344,2,4;13360,0,8;13399,2,4;13400,1,4;13417,0,2;13459,1,4;13459,2,4;13478,1,2;13478,2,2;13511,0,8;13524,1,2;13524,2,2;13565,0,4;13585,0,1;13588,2,1;13591,1,1;13617,1,4;13617,2,4;13631,0,8;13649,0,1;13689,1,1;13689,2,1;13714,0,2;13717,0,2;13744,1,2;13744,2,2;13780,0,1;13782,0,1;13819,0,4;13862,1,4;13862,2,4;13897,0,2;13921,1,8;13921,2,8;13925,0,4;13981,1,8;13981,2,8;13998,0,2;14013,1,2;14014,2,2;14089,0,8;14091,1,4;14091,2,4;14123,2,1;14124,1,1;14143,0,4;14155,1,8;14155,2,8;14163,0,1;14175,1,1;14175,2,1;14180,0,1;14241,1,2;14241,2,2;14244,1,2;14244,2,2;14258,0,4;14306,1,1;14307,1,1;14307,2,1;14311,2,1;14317,1,2;14317,2,2;14347,0,2;14350,0,2;14386,1,4;14386,2,4;14414,0,1;14417,0,1;14503,2,2;14507,1,2;14515,0,4;14568,1,1;14568,2,1;14571,1,1;14571,2,1;14633,1,2;14633,2,2;14635,1,2;14635,2,2;14639,0,4;14681,1,4;14681,2,4;14684,0,4;14701,1,1;14701,2,1;14710,1,1;14710,2,1;14743,0,4;14745,1,8;14745,2,8;14792,0,4;14801,1,4;14801,2,4;14809,0,2;14815,0,2;14820,1,2;14821,2,2;14865,1,8;14865,2,8;14901,0,4;14921,0,1;14953,1,4;14953,2,4;14973,0,1;14977,1,1;14977,2,1;15012,1,8;15012,2,8;15029,0,2;15042,1,2;15068,1,4;15068,2,4;15089,2,2;15128,2,4;15132,1,4;15147,0,4;15152,1,1;15152,2,1;15182,2,4;15185,1,4;15209,0,8;15242,1,4;15242,2,4;15264,2,2;15265,0,8;15266,1,2;15299,0,2;15302,1,4;15302,2,4;15326,1,1;15326,2,1;15380,0,4;15391,1,2;15391,2,2;15409,0,1;15440,0,8;15459,1,8;15459,2,8;15460,0,1;15504,1,4;15504,2,4;15525,0,2;15532,0,2;15563,1,8;15563,2,8;15583,1,1;15583,2,1;15590,0,1;15594,0,1;15602,0,2;15605,2,4;15609,1,4;15649,2,4;15653,1,4;15658,2,2;15670,0,4;15670,2,2;15690,1,2;15700,1,4;15722,2,4;15734,1,2;15738,1,1;15738,2,1;15784,1,4;15784,2,4;15787,0,2;15852,0,1;15855,0,1;15868,1,8;15868,2,8;15923,0,2;15924,0,2;15929,2,1;15931,1,1;15939,0,1;15965,0,4;15994,0,1;16017,1,4;16017,2,4;16031,0,8;16051,1,2;16077,1,4;16077,2,4;16087,0,4;16101,1,2;16101,2,2;16104,2,2;16105,0,2;16150,0,8;16166,1,1;16166,2,1;16169,1,1;16169,2,1;16231,2,2;16233,1,2;16236,2,2;16239,1,2;16243,0,4;16266,0,1;16296,0,8;16308,1,1;16308,2,1;16311,1,1;16311,2,1;16352,0,4;16373,1,2;16374,1,2;16376,0,2;16376,2,2;16377,2,2;16394,2,1;16413,0,4;16421,2,4;16436,0,1;16437,1,1;16467,0,4;16485,2,8;16486,1,8;16501,0,2;16530,0,4;16545,1,8;16545,2,8;16587,0,4;16590,1,4;16590,2,4;16611,0,1;16648,1,8;16648,2,8;16668,2,1;16676,0,2;16676,1,1;16714,2,2;16733,1,2;16744,0,8;16759,2,8;16789,0,4;16789,2,4;16801,1,8;16828,2,8;16831,2,8;16846,1,4;16848,0,8;16858,2,4;16867,0,1;16890,0,4;16897,2,8;16900,2,8;16905,1,8;16936,0,4;16945,0,2;16950,1,4;16954,0,2;17006,0,4;17008,1,8;17022,0,1;17041,2,8;17068,0,4;17097,2,4;17155,0,8;17161,1,8;17162,2,4;17192,2,2;17217,0,1;17217,1,4;17257,2,1;17260,2,1;17282,1,4;17301,0,4;17312,1,2;17322,2,2;17323,2,2;17361,0,4;17364,2,4;17377,1,1;17380,1,1;17382,0,2;17389,0,2;17424,2,4;17442,1,2;17445,1,2;17451,0,1;17454,0,1;17473,2,4;17484,1,4;17489,2,1;17519,0,2;17521,0,2;17532,2,8;17544,1,4;17564,1,1;17577,2,4;17592,0,1;17593,1,4;17595,0,1;17635,2,8;17652,1,8;17655,2,2;17659,0,2;17666,0,2;17678,0,1;17700,1,4;17707,0,4;17721,2,1;17724,2,1;17755,1,8;17769,0,8;17775,1,2;17786,2,2;17789,2,2;17831,0,8;17841,1,1;17844,1,1;17862,2,1;17874,0,4;17906,1,2;17907,1,2;17930,2,8;17937,0,8;17950,2,1;17952,0,1;17981,2,4;17982,1,1;18005,0,2;18041,2,4;18046,0,8;18050,1,8;18060,2,2;18064,2,1;18070,1,1;18079,0,4;18099,2,8;18105,1,4;18113,0,8;18143,0,4;18155,2,4;18161,1,4;18176,2,2;18180,1,2;18184,1,1;18188,0,8;18215,2,4;18219,1,8;18275,1,4;18296,1,2;18335,1,4;18379,2,1;18435,2,2;18504,1,1;18504,2,4;18555,1,2;18570,2,2;18624,1,4;18626,2,1;18689,1,2;18695,2,4;18746,1,1;18815,1,4;18852,2,8;18941,2,4;18972,1,8;18973,2,2;19055,2,4;19061,1,4;19088,2,1;19092,1,2;19115,2,4;19139,2,1;19170,2,4;19175,1,4;19206,1,1;19230,2,4;19237,1,4;19249,2,2;19259,1,1;19290,1,4;19290,2,4;19313,2,1;19350,1,4;19370,1,2;19379,2,2;19412,1,4;19433,1,1;19446,2,8;19502,1,2;19506,2,8;19566,1,8;19630,1,8;19660,2,2;19742,2,4;19760,2,1;19782,1,2;19801,2,8;19857,2,4;19862,1,4;19881,1,1;19886,2,1;19922,1,8;19952,2,2;19955,2,2;19977,1,4;20006,1,1;20017,2,1;20019,2,1;20028,2,2;20072,1,2;20075,1,2;20097,2,4;20138,1,1;20142,1,1;20148,1,2;20164,2,2;20221,1,4;20282,1,2;20310,2,4;20333,2,1;20402,2,4;20434,1,4;20457,1,1;20468,2,1;20522,1,4;20550,2,4;20566,2,2;20569,2,2;20592,1,1;20623,2,1;20670,1,4;20691,1,2;20691,2,4;20693,1,1;20696,1,2;20697,1,1;20701,1,2;20702,1,1;20708,1,2;20709,1,1;20712,1,2;20713,1,1;20715,2,1;20717,1,2;20718,1,1;20721,1,2;20723,1,8;20740,1,2;20743,1,1;20780,2,2;20811,1,4;20835,1,1;20848,2,8;20868,2,1;20900,1,2;20903,2,8;20959,2,4;20968,1,8;20980,2,2;20983,2,1;20992,1,1;21023,1,8;21048,2,2;21051,2,2;21079,1,4;21101,1,2;21103,1,1;21113,2,1;21115,2,1;21124,2,2;21168,1,2;21171,1,2;21193,2,4;21225,2,1;21233,1,1;21243,1,1;21244,1,2;21308,2,4;21313,1,4;21332,2,2;21335,2,2;21347,1,1;21397,2,1;21400,2,1;21429,1,4;21452,1,2;21455,1,2;21465,2,2;21467,2,2;21499,2,4;21517,1,1;21520,1,1;21579,2,1;21582,1,2;21583,1,2;21620,1,4;21664,2,2;21699,1,1;21730,2,1;21733,2,1;21784,1,2;21798,2,2;21799,2,2;21837,2,4;21850,1,1;21853,1,1;21871,2,1;21897,2,4;21915,1,2;21926,1,1;21929,1,2;21930,1,1;21957,2,4;21961,1,8;21986,2,1;22020,1,4;22042,1,2;22051,2,2;22077,1,4;22106,1,1;22119,2,8;22139,2,2;22171,1,2;22204,2,1;22207,2,1;22247,1,8;22259,1,2;22270,2,2;22271,2,2;22311,2,4;22324,1,1;22327,1,1;22371,2,4;22391,1,2;22397,1,2;22421,2,4;22431,1,4;22439,2,1;22479,2,8;22491,1,4;22511,1,1;22542,1,4;22543,2,1;22599,1,8;22660,1,1;22688,2,4;22777,2,2;22808,1,4;22845,2,8;22864,2,2;22897,1,2;22930,2,1;22933,2,1;22965,1,8;22984,1,2;22995,2,2;22998,2,2;23006,2,1;23050,1,1;23053,1,1;23075,2,4;23115,1,2;23118,1,2;23126,1,1;23140,2,1;23195,1,4;23197,2,2;23261,1,1;23266,2,4;23290,2,1;23317,1,2;23355,2,2;23386,1,4;23410,1,1;23423,2,8;23443,2,1;23475,1,2;23508,2,2;23543,1,8;23563,1,1;23575,2,8;23628,1,2;23695,1,8;23713,2,4;23772,2,8;23792,2,1;23814,2,4;23833,1,4;23837,2,2;23858,2,4;23892,1,8;23912,1,1;23926,2,8;23934,1,4;23944,2,2;23979,1,4;24000,2,4;24018,1,2;24044,2,4;24048,1,8;24051,2,1;24065,1,2;24115,2,8;24120,1,4;24164,1,4;24171,1,1;24199,2,4;24235,1,8;24319,1,4;24334,2,4;24379,2,1;24454,1,4;24457,2,2;24499,1,1;24555,2,8;24576,1,2;24594,2,1;24669,2,2;24675,1,8;24714,1,1;24766,2,8;24790,1,2;24843,2,4;24875,2,2;24886,1,8;24963,1,4;24971,2,1;24974,2,1;24995,1,2;25049,2,2;25051,2,2;25091,1,1;25094,1,1;25097,2,4;25115,2,1;25166,1,2;25168,1,2;25172,2,8;25189,1,1;25206,2,1;25217,1,4;25292,1,8;25316,2,4;25331,1,1;25352,2,2;25378,2,4;25436,1,4;25451,2,8;25471,1,2;25502,1,4;25526,2,8;25571,1,8;25600,2,8;25632,2,1;25646,1,8;25703,2,2;25720,1,8;25752,1,1;25787,2,8;25825,1,2;25856,2,4;25911,1,8;25927,2,4;25976,1,4;26005,2,4;26038,2,1;26047,1,4;26073,2,8;26108,2,2;26127,1,4;26139,2,4;26158,1,1;26181,2,2;26193,1,8;26230,1,2;26259,1,4;26272,2,4;26300,1,2;26309,2,1;26345,2,8;26371,2,1;26392,1,4;26425,1,1;26440,2,2;26466,1,8;26491,1,1;26517,2,8;26564,1,2;26580,2,4;26610,2,1;26637,1,8;26677,2,2;26700,1,4;26734,1,1;26753,2,8;26797,1,2;26873,1,8;27002,2,4;27064,2,8;27122,1,4;27184,1,8;27185,2,1;27251,2,2;27305,1,1;27322,2,8;27371,1,2;27380,2,4;27410,2,1;27442,1,8;27477,2,2;27480,2,2;27500,1,4;27530,1,1;27543,2,1;27544,2,1;27562,2,2;27591,2,4;27597,1,2;27600,1,2;27662,2,2;27664,1,1;27666,1,1;27681,1,2;27711,1,4;27750,2,4;27784,1,2;27810,2,4;27829,2,1;27870,1,4;27875,2,2;27930,1,4;27950,1,1;27957,2,4;27973,2,1;27996,1,2;28015,2,8;28071,2,4;28077,1,4;28093,1,1;28095,2,1;28136,1,8;28161,2,2;28164,2,2;28191,1,4;28215,1,1;28226,2,1;28228,2,1;28262,2,4;28280,2,2;28281,1,2;28284,1,2;28321,2,8;28347,1,1;28352,1,1;28366,2,4;28382,1,4;28400,1,2;28425,2,8;28441,1,8;28485,2,8;28486,1,4;28504,2,2;28545,1,8;28570,2,1;28573,2,1;28605,1,8;28624,1,2;28637,2,2;28638,2,2;28671,2,4;28689,2,1;28690,1,1;28690,2,1;28693,1,1;28756,1,2;28760,1,2;28791,1,4;28810,1,1;28811,1,1;28835,2,4;28859,2,2;28928,2,4;28955,1,4;28979,1,2;28988,2,4;29037,2,4;29048,1,4;29095,2,8;29108,1,4;29115,2,1;29157,1,4;29161,2,2;29206,2,8;29215,1,8;29219,2,1;29235,1,1;29263,2,2;29281,1,2;29308,2,8;29321,2,1;29327,1,8;29339,1,1;29344,2,8;29383,1,2;29391,2,4;29411,2,2;29415,2,1;29430,1,8;29441,1,1;29446,2,4;29466,1,8;29480,2,2;29506,2,4;29514,1,4;29531,1,2;29535,1,1;29555,2,4;29566,1,4;29600,1,2;29614,2,8;29626,1,4;29641,2,2;29675,1,4;29724,2,4;29734,1,8;29761,1,2;29773,2,4;29796,2,1;29832,2,8;29844,1,4;29893,1,4;29914,1,1;29921,2,4;29952,1,8;29954,2,1;30035,2,4;30041,1,4;30059,2,2;30074,1,1;30128,2,4;30159,1,4;30179,1,2;30194,2,1;30248,1,4;30314,1,1;30341,2,4;30373,2,2;30401,2,4;30461,1,4;30471,2,2;30493,1,2;30521,1,4;30535,2,1;30594,1,2;30646,2,4;30657,1,1;30705,2,8;30750,2,4;30766,1,4;30808,2,8;30825,1,8;30853,2,4;30870,1,4;30912,2,8;30928,1,8;30932,2,1;30954,2,4;30973,1,4;30998,2,4;31009,2,2;31032,1,8;31052,1,1;31056,2,4;31072,2,1;31074,1,4;31097,1,2;31116,2,2;31119,2,2;31121,1,4;31159,2,1;31161,2,1;31176,1,4;31192,1,1;31213,2,2;31236,1,2;31239,1,2;31246,2,4;31279,1,1;31281,1,1;31305,2,8;31324,2,1;31333,1,2;31350,2,8;31366,1,4;31387,2,4;31400,2,2;31425,1,8;31444,1,1;31459,2,8;31470,1,8;31472,2,1;31507,1,4;31516,2,2;31520,1,2;31561,2,8;31579,1,8;31592,1,1;31636,1,2;31644,2,4;31681,1,8;31710,2,1;31764,1,4;31774,2,2;31802,2,4;31832,1,1;31832,2,1;31896,1,2;31897,2,2;31922,1,4;31952,1,1;31965,2,8;31984,2,1;32017,1,2;32049,2,2;32085,1,8;32104,1,1;32117,2,8;32169,1,2;32173,2,4;32237,1,8;32239,2,4;32293,1,4;32308,2,8;32359,1,4;32372,2,1;32426,2,2;32429,2,2;32432,1,8;32491,1,1;32492,2,1;32495,2,1;32546,1,2;32549,1,2;32557,2,2;32558,2,2;32593,2,4;32609,2,1;32612,1,1;32619,1,1;32652,2,8;32679,1,2;32679,2,2;32681,1,2;32713,1,4;32729,1,1;32762,2,4;32774,1,8;32786,2,1;32801,1,2;32855,2,4;32879,2,1;32882,1,4;32906,1,1;32910,2,4;32970,2,4;32975,1,4;32989,2,2;32999,1,1;33030,1,4;33090,1,4;33109,1,2;33128,2,1;33192,2,2;33225,2,8;33249,1,1;33313,1,2;33313,2,4;33344,2,1;33345,1,8;33428,2,4;33433,1,4;33452,2,2;33465,1,1;33548,1,4;33570,2,4;33572,1,2;33628,2,8;33648,2,1;33694,1,4;33713,2,2;33748,1,8;33768,1,1;33781,2,8;33801,2,1;33833,1,2;33866,2,2;33908,1,8;33921,1,1;33934,2,8;33986,1,2;33998,2,1;34054,1,8;34077,2,4;34095,2,2;34120,1,1;34135,2,8;34155,2,1;34178,2,4;34197,1,4;34213,1,2;34222,2,4;34231,2,2;34255,1,8;34266,2,4;34275,1,1;34278,2,1;34298,1,4;34338,2,4;34342,1,4;34351,1,2;34351,2,2;34362,2,2;34386,1,4;34398,1,1;34421,2,4;34461,1,4;34504,1,2;34509,2,1;34513,1,4;34559,1,2;34577,2,8;34597,2,1;34629,1,1;34662,2,2;34697,1,8;34717,1,1;34730,2,8;34782,1,2;34850,1,8;34971,2,4;35037,2,2;35091,1,4;35124,2,4;35157,1,2;35184,2,4;35206,2,1;35233,2,4;35244,1,4;35292,2,8;35306,1,4;35324,1,1;35348,2,4;35353,1,4;35414,1,8;35417,2,8;35437,2,1;35468,1,4;35502,2,2;35537,1,8;35557,1,1;35570,2,8;35622,1,2;35626,2,4;35686,2,4;35694,1,8;35715,2,1;35746,1,4;35746,2,4;35806,2,4;35813,1,4;35829,2,2;35835,1,1;35855,2,4;35870,1,4;35914,2,8;35927,1,4;35933,2,1;35946,1,2;35956,2,4;35975,1,4;36000,2,4;36010,2,2;36037,1,8;36042,2,2;36053,1,1;36076,1,4;36094,2,4;36106,2,1;36120,1,4;36131,1,2;36133,2,8;36136,2,8;36160,1,2;36199,2,4;36214,1,4;36226,1,1;36253,1,8;36256,1,8;36319,1,4;36355,2,8;36375,2,1;36440,2,2;36482,1,8;36495,1,1;36508,2,8;36528,2,1;36560,1,2;36593,2,2;36628,1,8;36648,1,1;36661,2,8;36713,1,2;36750,2,4;36783,1,8;36870,1,4;36891,2,4;36950,2,8;36970,2,1;36993,2,4;37011,1,4;37015,2,2;37037,2,4;37070,1,8;37081,2,4;37090,1,1;37113,1,4;37135,1,2;37157,1,4;37201,1,4;37255,2,8;37317,2,2;37375,1,8;37438,1,2;37439,2,1;37506,2,8;37533,2,2;37563,1,1;37617,2,4;37626,1,8;37655,1,2;37666,2,4;37682,2,1;37710,2,4;37718,2,1;37739,1,4;37754,2,4;37786,1,4;37802,1,1;37820,2,4;37830,1,4;37838,1,1;37874,1,4;37936,2,2;37940,1,4;38026,2,4;38052,1,2;38097,2,4;38146,1,4;38178,2,8;38217,1,4;38258,2,4;38302,1,8;38349,2,4;38378,1,4;38386,2,1;38469,1,4;38508,1,1;38512,2,4;38551,2,2;38632,1,4;38669,1,2;38702,2,4;38777,2,8;38822,1,4;38894,2,4;38897,1,8;38975,2,8;39014,1,4;39015,2,1;39095,1,8;39116,2,4;39136,1,1;39184,2,4;39224,2,2;39236,1,4;39259,2,4;39304,1,4;39333,2,4;39344,1,2;39361,2,1;39383,1,4;39393,2,4;39453,1,4;39481,1,1;39513,1,4;39590,2,4;39648,2,4;39710,1,4;39741,2,2;39768,1,4;39777,2,4;39861,1,2;39884,2,4;39899,1,4;39916,2,2;39991,2,1;39994,2,1;40004,1,4;40036,1,2;40062,2,2;40063,2,2;40111,1,1;40114,1,1;40146,2,1;40182,1,2;40183,1,2;40223,2,8;40248,2,1;40266,1,1;40321,2,2;40324,2,2;40343,1,8;40368,1,1;40389,2,1;40390,2,1;40410,2,2;40441,1,2;40442,2,4;40444,1,2;40510,1,1;40511,1,1;40513,2,4;40530,1,2;40547,2,1;40564,1,4;40582,2,8;40614,2,2;40633,1,4;40642,2,4;40667,1,1;40702,1,8;40734,1,2;40762,1,4;40902,2,4;40968,2,8;40990,2,2;41022,1,4;41057,2,1;41060,2,1;41090,1,8;41110,1,2;41124,2,2;41125,2,2;41177,1,1;41180,1,1;41201,2,1;41246,1,2;41251,1,2;41269,2,8;41290,2,1;41321,1,1;41324,2,8;41380,2,4;41389,1,8;41403,2,2;41404,2,1;41410,1,1;41444,1,8;41469,2,2;41500,1,4;41522,1,2;41524,1,1;41537,2,8;41589,1,2;41593,2,4;41617,2,1;41657,1,8;41682,2,2;41685,2,2;41713,1,4;41737,1,1;41749,2,1;41750,2,1;41784,2,4;41802,1,2;41805,1,2;41871,1,1;41876,1,1;41888,2,4;41904,1,4;41948,2,4;41967,2,2;42008,1,4;42008,2,4;42032,2,2;42068,1,4;42092,1,2;42097,2,1;42100,2,1;42131,1,4;42156,1,2;42164,2,2;42165,2,2;42204,2,4;42217,1,1;42220,1,1;42264,2,4;42283,1,2;42284,1,2;42287,2,1;42288,2,1;42324,1,4;42353,2,2;42384,1,4;42406,1,1;42408,1,1;42421,2,8;42466,2,4;42473,1,2;42524,2,8;42545,1,8;42556,2,1;42586,1,4;42635,2,4;42644,1,8;42676,1,1;42684,2,4;42758,1,4;42807,1,4;42822,2,2;42890,2,8;42910,2,2;42942,1,2;42975,2,1;42978,2,1;43010,1,8;43030,1,2;43046,2,2;43047,2,2;43095,1,1;43098,1,1;43117,2,1;43160,1,2;43164,1,2;43184,2,8;43237,1,1;43249,2,2;43302,2,1;43305,2,1;43308,1,8;43367,1,2;43368,2,2;43371,2,2;43422,1,1;43425,1,1;43433,2,1;43436,2,1;43444,2,2;43488,1,2;43491,1,2;43513,2,4;43553,1,1;43556,1,1;43564,1,2;43573,2,4;43633,1,4;43693,1,4;43782,2,1;43817,2,8;43873,2,4;43902,1,1;43937,1,8;43962,2,2;43993,1,4;44016,1,2;44017,1,1;44030,2,8;44082,1,2;44142,2,1;44150,1,8;44208,2,2;44211,2,2;44262,1,1;44273,2,1;44274,2,1;44284,2,2;44287,2,2;44328,1,2;44331,1,2;44350,2,1;44353,2,1;44393,1,1;44398,1,1;44404,1,2;44407,1,2;44417,2,2;44418,2,2;44466,2,8;44470,1,1;44473,1,1;44479,2,1;44486,2,1;44535,1,2;44536,1,2;44551,2,2;44553,1,1;44586,1,8;44606,1,1;44619,2,8;44639,2,1;44671,1,2;44704,2,2;44739,1,8;44759,1,1;44772,2,8;44824,1,2;44828,2,4;44892,1,8;44920,2,4;44944,2,1;44948,1,4;44975,2,4;45009,2,2;45035,2,4;45040,1,4;45059,2,1;45064,1,1;45099,1,4;45124,2,2;45155,1,4;45178,1,2;45179,1,1;45192,2,8;45244,1,2;45252,2,8;45312,1,8;45372,1,8;45400,2,4;45466,2,4;45495,2,1;45520,1,4;45560,2,2;45586,1,4;45615,1,1;45628,2,8;45680,1,2;45684,2,4;45708,2,1;45739,2,4;45748,1,8;45799,2,4;45804,1,4;45820,2,2;45828,1,1;45837,2,2;45859,1,4;45913,2,4;45919,1,4;45938,1,2;45946,2,1;45960,1,2;46030,2,1;46037,1,4;46064,2,8;46066,1,1;46120,2,4;46144,2,2;46150,1,1;46184,1,8;46213,2,4;46240,1,4;46260,1,2;46273,2,4;46333,1,4;46333,2,4;46393,1,4;46399,2,4;46428,2,1;46454,1,4;46493,2,2;46519,1,4;46548,1,1;46561,2,8;46615,1,2;46650,2,4;46673,2,1;46681,1,8;46739,2,2;46770,1,4;46793,1,1;46806,2,8;46826,2,1;46861,2,8;46865,1,2;46917,2,4;46926,1,8;46939,2,2;46946,1,1;46953,2,2;46981,1,8;47031,2,4;47037,1,4;47086,1,4;47105,1,2;47120,2,1;47134,1,4;47187,1,2;47188,2,8;47209,1,1;47215,1,1;47277,2,4;47295,1,2;47309,2,1;47391,2,4;47395,1,8;47423,2,2;47451,2,4;47525,1,4;47564,1,1;47573,2,1;47639,2,2;47685,1,4;47706,2,8;47729,1,2;47733,2,2;47769,1,4;47817,2,4;47841,2,1;47910,2,4;47944,1,1;47970,2,4;48018,1,2;48030,2,4;48095,2,2;48111,1,8;48144,1,2;48152,2,1;48220,2,4;48244,2,1;48257,1,4;48294,1,1;48309,2,2;48377,1,4;48377,2,8;48405,2,1;48453,1,4;48488,2,4;48519,2,2;48529,1,4;48548,2,4;48561,1,2;48572,2,1;48591,1,4;48637,2,2;48687,1,1;48704,2,8;48764,2,8;48766,1,4;48784,2,1;48805,1,1;48850,2,2;48853,2,2;48871,1,2;48917,2,1;48918,2,1;48955,1,8;48986,1,1;48991,2,2;49059,2,8;49088,1,4;49088,2,2;49122,1,2;49160,1,4;49170,2,4;49191,1,1;49219,2,4;49235,2,1;49260,1,2;49277,2,8;49297,2,1;49320,2,4;49340,1,8;49364,2,4;49374,2,2;49408,2,4;49409,1,8;49416,2,1;49436,1,1;49476,2,4;49491,2,2;49510,1,2;49513,1,2;49550,2,8;49577,2,2;49583,1,1;49584,1,1;49660,2,4;49664,1,2;49740,1,8;49749,2,1;49773,1,2;49817,2,8;49837,2,2;49862,1,4;49902,2,1;49905,2,1;49915,1,4;49934,1,1;49968,2,2;49969,2,2;49978,1,8;50002,1,1;50024,1,4;50044,2,1;50068,1,4;50082,1,2;50112,1,4;50112,2,8;50128,1,1;50156,1,4;50200,1,4;50208,1,2;50224,2,1;50276,1,8;50289,2,2;50308,1,2;50357,2,8;50377,2,2;50392,1,4;50442,2,1;50445,2,1;50483,1,1;50511,2,2;50513,2,2;50544,2,4;50560,1,8;50561,2,1;50574,1,2;50603,2,8;50640,1,1;50643,1,1;50663,2,8;50708,2,4;50716,1,2;50717,1,1;50721,1,2;50722,1,1;50766,2,8;50782,1,2;50786,2,1;50850,1,8;50852,2,2;50855,2,2;50918,2,1;50919,2,1;50963,1,1;50993,2,2;51029,1,2;51061,2,8;51090,2,2;51095,1,8;51115,1,2;51171,2,4;51181,1,1;51184,1,1;51202,2,1;51231,2,4;51253,1,2;51255,2,1;51282,1,4;51286,2,4;51320,2,2;51341,1,8;51346,2,4;51385,2,1;51402,1,8;51448,1,4;51460,2,4;51493,2,2;51505,1,8;51520,2,4;51524,1,1;51544,2,1;51590,1,2;51593,1,2;51609,2,2;51658,1,1;51662,1,1;51677,2,8;51731,1,2;51739,2,2;51799,1,8;51804,2,1;51829,1,2;51835,2,8;51897,2,2;51911,1,4;51941,1,1;51953,2,1;51970,1,4;51993,1,1;52022,2,4;52027,1,4;52084,1,4;52086,2,8;52105,1,2;52125,1,1;52149,2,1;52199,1,4;52214,2,2;52231,1,2;52240,2,4;52259,1,4;52283,1,1;52348,1,2;52393,2,4;52415,1,8;52420,2,2;52481,1,2;52508,2,4;52572,2,8;52577,1,8;52584,2,1;52588,1,1;52634,2,1;52635,1,2;52690,2,2;52692,1,1;52759,2,4;52761,1,4;52823,2,8;52825,1,8;52842,2,1;52873,2,4;52886,1,1;52933,2,4;52950,1,2;52956,2,2;52957,2,1;52979,1,4;53022,2,2;53090,2,8;53131,1,4;53155,2,1;53158,1,2;53233,2,4;53246,1,4;53250,2,2;53277,2,4;53278,1,1;53284,2,2;53310,1,8;53321,2,4;53356,2,1;53372,1,1;53407,2,8;53428,1,2;53497,1,4;53561,1,8;53581,1,1;53611,1,4;53629,2,8;53671,1,4;53713,2,2;53760,1,2;53802,2,1;53828,1,8;53889,1,1;53975,1,4;53976,2,4;53996,1,2;54019,1,4;54027,1,2;54063,1,4;54094,1,1;54109,2,4;54146,1,8;54150,2,1;54223,2,2;54316,2,8;54367,1,8;54390,2,4;54419,2,2;54451,1,2;54538,2,4;54543,1,1;54647,2,1;54714,1,4;54735,2,8;54810,2,8;54847,1,4;54888,1,1;54920,2,4;54962,1,2;54993,2,4;55022,2,1;55053,2,4;55054,1,8;55129,1,4;55146,2,2;55159,1,2;55250,2,2;55276,1,4;55280,2,4;55373,2,1;55386,1,1;55407,2,4;55473,1,8;55483,2,4;55518,2,2;55548,1,8;55620,2,4;55651,2,1;55658,1,4;55731,1,4;55753,2,2;55759,1,1;55780,2,4;55791,1,4;55884,1,2;55932,2,1;55991,1,2;56007,2,8;56018,1,4;56031,2,1;56066,2,8;56099,2,2;56115,1,1;56127,2,4;56146,1,4;56225,1,4;56254,2,1;56266,1,2;56285,2,4;56321,2,2;56349,2,4;56359,1,4;56375,2,1;56389,1,1;56406,2,4;56442,2,2;56469,2,4;56495,2,1;56537,1,2;56561,2,2;56631,2,8;56670,1,1;56693,2,8;56739,2,4;56745,1,8;56770,1,1;56798,2,8;56806,1,8;56855,2,4;56866,1,4;56886,1,2;56904,2,4;56963,2,8;56992,1,1;57023,1,4;57024,2,1;57061,1,2;57087,1,4;57113,1,1;57146,1,4;57146,2,2;57149,2,2;57180,1,2;57207,1,4;57211,2,1;57212,2,1;57222,2,2;57233,1,1;57302,1,2;57369,1,8;57384,2,4;57433,1,8;57433,2,4;57477,1,4;57492,2,8;57536,1,8;57593,1,4;57604,2,1;57642,1,4;57670,2,2;57673,2,2;57705,1,8;57738,2,1;57740,2,1;57762,1,1;57771,2,4;57870,2,4;57884,1,2;57887,1,2;57887,2,2;57914,2,4;57922,2,2;57949,1,1;57950,1,1;57958,2,4;57961,1,2;57992,2,1;58040,2,4;58071,2,1;58122,1,4;58175,1,4;58182,2,2;58185,2,2;58234,1,8;58264,2,1;58267,2,1;58284,2,2;58285,2,2;58343,1,1;58408,1,2;58408,2,4;58411,1,2;58476,1,1;58477,1,1;58488,2,8;58513,1,4;58527,2,1;58567,2,4;58610,1,4;58610,2,1;58639,1,2;58642,2,4;58654,1,4;58661,1,2;58684,2,2;58698,1,4;58723,2,4;58730,1,1;58778,1,4;58810,1,1;58848,2,4;58884,2,1;58920,1,2;58923,1,2;58973,2,2;58976,2,2;58999,1,1;59000,1,1;59028,1,2;59030,1,2;59046,2,1;59050,2,1;59070,2,2;59073,2,2;59147,1,4;59181,2,4;59217,2,1;59227,1,8;59266,1,1;59306,1,4;59348,1,1;59361,2,4;59381,1,4;59422,1,2;59433,2,8;59462,1,4;59503,2,4;59587,2,8;59588,1,4;59626,1,1;59643,2,4;59712,1,2;59713,2,8;59715,1,2;59785,2,1;59789,1,1;59792,1,1;59809,1,2;59810,1,2;59860,2,2;59897,2,4;59920,1,4;59955,1,1;59972,2,8;60042,2,8;60069,2,1;60099,1,4;60105,2,8;60139,2,2;60170,2,4;60171,1,8;60238,2,4;60241,1,4;60275,2,1;60325,1,8;60343,2,2;60381,1,4;60420,2,8;60452,1,8;60487,2,8;60522,2,1;60523,1,1;60599,1,2;60610,2,4;60635,1,4;60638,2,2;60710,1,8;60739,2,1;60770,2,4;60780,1,8;60807,2,2;60808,1,1;60834,2,4;60843,1,8;60879,1,2;60908,1,4;60933,2,4;60976,1,4;60996,2,4;61013,1,1;61069,2,8;61082,1,2;61132,2,8;61161,1,8;61196,2,2;61226,1,8;61260,1,1;61280,2,4;61298,2,1;61340,2,8;61348,1,4;61376,1,2;61477,1,1;61495,2,8;61508,1,4;61544,1,2;61551,2,4;61572,1,4;61611,2,4;61666,2,4;61671,1,4;61724,2,8;61736,1,4;61808,1,8;61870,1,8;61873,2,4;61897,2,1;61935,1,2;61962,2,2;62019,1,4;62030,2,8;62037,1,1;62078,1,8;62086,2,4;62157,2,2;62233,1,8;62244,2,4;62290,1,4;62304,2,4;62326,2,1;62350,1,4;62404,1,4;62421,2,1;62455,2,8;62463,1,8;62511,2,4;62533,2,2;62552,2,2;62611,1,4;62626,2,4;62635,1,1;62684,2,8;62700,1,2;62740,2,4;62760,2,1;62768,1,8;62824,1,4;62833,2,4;62857,2,1;62888,2,4;62899,1,2;62948,2,4;62968,2,2;62972,2,1;62982,1,4;63006,2,8;63045,1,4;63062,1,1;63062,2,4;63151,2,2;63159,1,1;63194,1,8;63219,2,8;63248,2,2;63250,1,4;63270,1,2;63286,1,2;63330,2,4;63362,2,1;63364,1,4;63423,1,8;63446,2,1;63479,1,4;63502,1,1;63511,2,2;63571,1,4;63579,2,8;63599,1,1;63626,1,4;63668,2,4;63686,1,4;63701,2,1;63744,1,8;63782,2,4;63800,1,4;63816,2,2;63823,1,2;63824,1,1;63846,2,8;63889,1,2;63906,2,8;63957,1,8;63966,2,8;63984,1,2;63999,2,2;64071,1,4;64077,2,4;64100,1,1;64126,2,4;64142,2,1;64145,2,1;64184,1,1;64230,2,4;64249,1,2;64290,2,4;64311,2,2;64317,1,8;64355,2,2;64406,1,4;64437,1,1;64442,2,4;64502,2,4;64520,1,4;64522,2,1;64526,2,1;64552,1,2;64584,1,8;64592,2,2;64595,2,2;64644,1,8;64658,2,1;64659,2,1;64678,2,2;64704,1,8;64708,2,8;64737,1,2;64764,2,4;64815,1,4;64824,2,4;64851,2,1;64864,1,4;64881,1,1;64883,1,1;64939,2,4;64972,2,2;64975,1,4;65028,1,4;65077,1,4;65097,2,1;65098,1,2;65101,1,1;65106,1,2;65107,1,1;65112,1,2;65113,1,1;65120,1,2;65121,1,1;65124,1,2;65125,1,1;65128,1,2;65130,1,8;65143,1,2;65153,2,2;65180,1,4;65222,2,4;65240,1,4;65246,2,2;65260,1,1;65264,1,1;65312,2,1;65315,2,1;65330,1,2;65333,1,2;65377,2,2;65378,2,2;65395,1,1;65395,2,1;65397,1,1;65415,1,2;65449,1,8;65502,1,4;65562,1,4;65573,2,1;65588,1,1;65639,2,2;65677,1,4;65706,2,8;65714,1,2;65751,2,4;65810,2,8;65830,2,1;65837,1,1;65892,1,2;65895,2,2;65898,2,2;65960,1,4;65962,2,1;65963,2,1;65972,2,2;65975,2,2;65984,1,2;66037,2,1;66040,2,1;66050,1,1;66053,1,1;66106,2,2;66107,2,2;66116,1,2;66117,1,2;66134,1,1;66139,2,4;66156,2,1;66197,2,8;66253,2,4;66316,1,1;66319,2,1;66377,1,2;66400,2,4;66417,2,2;66444,1,8;66444,2,4;66452,2,2;66488,2,4;66490,1,4;66532,2,1;66548,1,8;66568,1,1;66571,2,4;66626,2,1;66633,1,2;66640,1,2;66699,1,1;66700,1,1;66703,2,2;66710,1,2;66713,1,2;66775,1,1;66778,1,1;66804,2,8;66839,2,1;66840,1,2;66841,1,2;66877,1,4;66895,1,1;66935,1,8;66962,2,4;66991,1,4;67031,2,4;67059,1,1;67139,1,4;67155,1,2;67166,2,4;67183,1,4;67190,1,2;67227,1,4;67271,1,1;67309,1,4;67361,2,2;67364,1,1;67364,2,2;67435,2,1;67436,2,1;67441,1,2;67542,1,8;67572,2,4;67578,1,1;67635,2,4;67700,1,4;67773,1,4;67844,2,4;67880,2,2;67904,1,4;67960,2,4;67996,2,1;68099,1,2;68101,2,4;68102,1,2;68135,2,2;68174,1,1;68175,1,1;68213,2,4;68310,1,4;68358,2,1;68374,1,4;68427,2,2;68509,2,8;68582,1,4;68617,1,2;68698,1,4;68733,1,1;68750,2,1;68781,2,4;68822,2,2;68839,1,4;68849,2,4;68874,1,2;68880,2,1;68952,1,4;68953,2,2;68956,2,2;69021,2,1;69022,2,1;69062,2,4;69096,1,1;69150,2,2;69166,1,2;69182,2,4;69247,1,8;69253,2,4;69312,2,4;69375,2,8;69423,2,4;69485,2,8;69488,1,1;69520,1,4;69549,2,8;69557,1,2;69588,1,4;69613,2,1;69618,1,1;69691,1,2;69694,1,2;69699,2,4;69715,2,2;69759,1,1;69759,2,8;69761,1,1;69790,2,1;69801,1,4;69873,2,4;69888,1,2;69920,1,4;69963,2,2;69992,1,4;70031,2,8;70051,1,4;70052,2,1;70082,2,4;70113,1,8;70142,2,4;70162,2,2;70166,1,4;70166,2,1;70223,1,8;70231,2,2;70291,1,8;70299,2,8;70352,1,1;70437,1,4;70452,2,8;70453,1,2;70497,1,8;70525,1,1;70540,2,4;70611,1,4;70701,1,2;70770,1,8;70786,2,4;70790,1,1;70820,1,4;70851,2,4;70880,1,4;70881,2,2;70902,1,2;70904,1,1;70946,2,1;70949,2,1;70969,1,2;71011,2,2;71014,2,2;71022,2,1;71025,2,1;71038,1,8;71088,2,2;71091,2,2;71154,2,1;71155,2,1;71193,1,8;71218,2,2;71281,1,4;71293,2,4;71357,2,8;71392,2,2;71468,2,4;71517,2,4;71524,1,4;71534,2,1;71575,2,8;71590,1,4;71619,1,2;71631,2,4;71684,1,1;71687,1,1;71702,2,2;71750,1,2;71751,1,2;71761,1,1;71764,1,1;71824,2,1;71826,1,2;71827,2,1;71829,1,2;71889,2,2;71890,2,2;71891,1,1;71892,1,1;71901,2,1;71904,2,1;71956,1,2;71966,2,2;71969,2,2;72031,1,4;72033,2,1;72035,2,1;72077,2,8;72097,1,8;72129,1,2;72133,2,4;72155,2,2;72157,2,2;72206,1,4;72222,2,1;72225,2,1;72255,1,4;72272,1,1;72288,2,2;72290,2,2;72314,1,8;72370,1,4;72390,2,4;72421,2,1;72444,1,2;72542,2,4;72566,1,1;72569,1,1;72591,2,4;72630,1,2;72636,1,2;72643,1,1;72646,1,1;72650,2,8;72704,1,2;72706,2,4;72711,1,2;72733,2,2;72770,1,1;72771,1,1;72815,1,8;72820,2,4;72854,2,1;72871,1,4;72893,1,2;72895,1,2;72940,2,2;72961,1,1;72964,1,1;73028,2,4;73033,1,2;73035,1,2;73117,2,1;73128,1,4;73159,1,1;73184,2,8;73273,2,4;73280,1,4;73297,2,1;73330,1,4;73362,2,2;73388,1,8;73430,2,8;73444,1,4;73475,1,2;73559,1,4;73583,2,8;73591,1,1;73602,2,1;73668,2,2;73671,2,2;73678,1,2;73733,2,1;73735,2,1;73744,2,2;73766,1,4;73855,1,1;73873,2,4;73902,2,2;73923,1,8;73968,2,1;73971,2,1;74011,1,4;74033,2,2;74034,2,2;74035,1,1;74044,2,1;74100,1,2;74113,2,4;74162,2,4;74168,1,8;74221,2,8;74241,2,2;74306,2,1;74309,2,1;74321,1,8;74341,1,1;74374,2,2;74381,2,2;74382,2,1;74406,1,2;74409,1,2;74451,2,4;74472,1,1;74473,1,1;74482,1,2;74520,2,1;74611,1,4;74639,2,2;74641,1,2;74642,2,2;74704,2,1;74705,2,1;74706,1,1;74709,1,1;74740,2,4;74759,2,2;74771,1,2;74775,1,2;74782,1,1;74799,2,8;74844,2,4;74854,1,4;74902,1,4;74903,2,8;74959,1,8;74979,1,2;74991,2,4;75022,2,2;75048,1,1;75051,1,1;75106,2,4;75109,1,2;75110,1,2;75121,1,1;75190,1,4;75195,2,1;75198,2,1;75256,1,1;75262,2,2;75265,2,2;75297,2,4;75314,2,1;75341,2,4;75348,2,1;75377,1,2;75380,1,2;75385,2,4;75437,2,2;75445,1,1;75446,1,1;75467,2,4;75479,1,4;75496,1,2;75509,2,2;75537,1,8;75547,2,4;75582,1,4;75641,1,8;75657,2,1;75658,2,1;75730,1,4;75757,2,2;75760,2,2;75761,1,2;75835,2,1;75836,2,1;75844,1,4;75858,2,2;75908,2,8;75933,1,1;75936,1,1;75987,2,4;75999,1,2;76001,1,2;76035,1,4;76052,1,1;76055,2,4;76079,1,4;76087,1,1;76123,1,4;76133,2,8;76166,1,2;76205,1,4;76209,2,4;76244,1,2;76257,2,1;76285,1,4;76289,2,4;76330,2,2;76369,2,4;76395,1,1;76402,1,1;76408,2,1;76480,2,2;76495,1,2;76498,1,2;76570,1,1;76571,2,8;76573,1,1;76596,1,2;76604,2,2;76649,1,8;76688,2,1;76691,2,1;76728,1,4;76760,2,2;76762,2,2;76783,2,1;76792,1,4;76871,1,8;76937,2,4;76947,1,4;76995,1,1;77027,1,4;77050,2,4;77069,1,2;77107,1,4;77110,2,4;77146,1,1;77179,2,8;77218,1,2;77250,2,2;77309,1,8;77326,2,1;77341,1,2;77363,2,4;77397,2,2;77426,1,1;77429,1,1;77478,2,1;77481,2,1;77499,1,2;77501,1,2;77521,1,1;77547,2,2;77548,2,2;77634,2,1;77676,1,4;77713,2,8;77780,2,8;77790,1,4;77848,1,4;77848,2,1;77916,2,2;77920,1,8;77989,2,4;77990,1,2;78059,2,8;78066,1,1;78101,1,4;78135,1,2;78156,2,4;78183,2,1;78216,1,1;78219,1,1;78254,2,2;78257,2,2;78290,1,2;78292,1,2;78323,2,1;78325,2,1;78372,1,1;78402,2,2;78451,1,8;78474,2,8;78496,2,2;78519,1,8;78564,2,1;78567,2,1;78587,1,1;78631,2,2;78633,2,2;78654,1,2;78729,1,4;78736,2,4;78760,2,1;78797,1,8;78855,2,1;78894,1,4;78921,2,2;78922,1,1;78924,2,2;78988,2,1;78989,2,1;78992,1,2;78995,1,2;79022,2,4;79039,2,2;79061,1,1;79064,1,1;79066,2,4;79075,2,2;79110,2,4;79143,1,2;79145,2,1;79196,2,8;79212,1,8;79234,1,2;79302,1,1;79305,1,1;79368,1,2;79369,1,2;79418,2,8;79474,1,4;79499,1,1;79499,2,4;79591,2,4;79593,1,1;79659,1,2;79662,1,2;79675,2,4;79726,1,1;79729,1,1;79761,1,4;79769,2,8;79780,1,2;79805,1,4;79807,2,1;79843,2,8;79859,1,2;79884,2,2;79885,1,1;79920,2,4;79937,1,8;79961,2,1;80034,2,2;80126,2,8;80156,1,8;80205,2,8;80239,1,4;80278,2,4;80307,2,2;80330,1,4;80341,2,4;80413,1,4;80439,2,1;80483,2,8;80507,1,8;80545,1,1;80554,2,4;80581,1,8;80620,1,2;80629,2,4;80658,1,4;80702,2,4;80703,1,1;80737,2,1;80772,1,2;80823,2,2;80826,2,2;80865,1,8;80893,2,1;80895,2,1;80943,1,8;80985,2,2;81020,1,4;81047,1,2;81067,2,8;81079,1,4;81138,2,1;81178,1,1;81221,1,8;81278,2,2;81296,1,4;81358,2,8;81367,1,4;81429,2,2;81441,1,4;81475,1,1;81498,2,1;81536,2,8;81561,1,2;81564,1,2;81604,2,8;81633,1,1;81635,1,1;81670,2,8;81723,1,2;81768,2,4;81811,1,8;81838,2,8;81876,1,1;81934,2,4;82018,1,2;82096,1,8;82097,2,4;82174,1,2;82236,1,1;82273,2,1;82275,1,8;82337,2,2;82343,1,8;82366,2,4;82408,1,8;82438,2,1;82497,2,2;82500,2,2;82508,1,4;82564,2,1;82567,2,1;82576,1,8;82631,2,2;82633,2,2;82671,2,4;82672,1,4;82731,2,4;82751,2,1;82791,2,4;82827,2,2;82835,1,4;82911,2,4;82943,2,1;82975,2,8;83007,1,1;83038,2,1;83078,1,2;83102,2,2;83106,1,4;83129,2,4;83178,1,1;83179,2,4;83235,1,2;83237,2,8;83238,1,2;83282,2,4;83306,1,1;83309,1,1;83341,2,8;83361,2,2;83368,1,2;83369,1,2;83407,2,1;83409,1,4;83410,2,1;83451,2,2;83453,2,2;83462,2,1;83474,1,4;83482,2,4;83490,1,1;83526,2,4;83530,1,4;83561,1,2;83570,2,4;83651,1,4;83661,2,2;83681,1,1;83714,1,8;83726,2,1;83729,2,1;83776,1,1;83791,2,2;83792,2,2;83828,2,4;83841,1,2;83868,1,4;83909,2,1;83917,1,4;83937,2,4;83975,1,8;84020,1,4;84035,2,4;84059,2,2;84079,1,8;84099,1,2;84124,2,1;84127,2,1;84145,1,1;84148,1,1;84188,1,2;84190,1,2;84192,2,2;84193,2,2;84200,1,1;84209,2,1;84220,1,4;84241,2,8;84261,2,1;84264,1,4;84308,1,4;84326,2,2;84329,2,2;84391,2,1;84392,2,1;84399,1,2;84402,2,2;84464,1,1;84467,1,1;84531,1,2;84533,1,2;84564,2,4;84566,1,4;84632,2,1;84646,1,1;84675,1,4;84751,2,2;84754,2,2;84773,1,4;84797,1,2;84821,2,1;84822,2,1;84853,2,4;84862,1,1;84865,1,1;84870,2,2;84912,2,8;84930,1,2;84931,2,1;84932,1,2;84979,1,8;84991,1,1;84997,2,2;84999,1,1;85064,1,2;85064,2,8;85067,1,2;85129,1,1;85132,1,1;85141,1,2;85153,2,4;85217,2,8;85262,2,4;85302,1,4;85321,2,8;85366,2,4;85369,1,1;85424,2,8;85490,1,2;85493,1,2;85541,2,1;85555,1,1;85556,1,1;85591,1,4;85607,1,2;85628,2,4;85650,1,8;85670,1,1;85686,2,8;85737,1,2;85742,2,4;85803,1,8;85831,2,2;85834,2,2;85891,1,4;85899,2,1;85900,2,1;85933,2,4;85949,2,2;85955,1,8;85992,2,8;86004,1,4;86037,2,4;86059,1,8;86095,2,8;86104,1,4;86163,1,8;86184,2,4;86215,2,1;86279,1,1;86299,2,4;86322,2,2;86325,2,2;86366,1,4;86388,2,1;86391,2,1;86424,1,8;86453,2,2;86458,2,2;86473,2,1;86480,1,4;86502,1,2;86504,1,1;86504,2,8;86524,2,1;86559,2,8;86570,1,2;86573,1,2;86615,2,4;86635,1,1;86637,2,2;86638,1,1;86675,1,4;86687,1,2;86708,2,4;86730,1,8;86772,2,8;86775,1,4;86834,1,8;86923,1,4;86926,2,1;86953,1,1;87041,1,4;87048,2,2;87061,1,2;87064,1,2;87115,2,8;87126,1,1;87129,1,1;87191,1,2;87199,1,2;87204,2,4;87210,1,1;87247,1,8;87262,1,1;87264,2,4;87293,2,1;87297,1,8;87324,2,4;87353,1,4;87358,2,2;87375,1,2;87384,2,4;87408,2,1;87446,1,4;87473,2,2;87476,2,2;87510,1,8;87538,2,1;87542,2,1;87575,2,4;87593,2,2;87597,2,2;87671,1,1;87713,2,1;87781,2,8;87786,1,2;87808,2,1;87854,1,8;87891,2,4;87915,2,2;87942,1,4;87984,2,4;88002,1,4;88032,1,1;88033,2,4;88062,1,4;88092,2,8;88122,1,4;88143,1,2;88146,1,1;88212,1,2;88219,1,2;88244,2,8;88277,1,1;88278,1,1;88308,2,1;88317,1,4;88329,1,2;88332,1,2;88403,2,8;88415,2,2;88454,1,1;88459,2,4;88519,1,8;88551,1,1;88557,2,4;88630,1,4;88649,2,4;88653,1,2;88683,2,2;88722,1,4;88764,2,4;88775,1,4;88823,2,8;88830,1,8;88879,2,4;88900,2,1;88939,2,4;88968,2,1;88983,1,8;88999,2,4;89046,1,1;89059,2,4;89108,2,4;89110,1,2;89127,2,2;89141,1,8;89152,2,4;89163,2,2;89196,2,4;89198,1,4;89230,2,1;89295,1,4;89357,2,1;89388,1,4;89398,2,4;89422,1,2;89502,1,4;89561,1,8;89617,1,4;89631,2,4;89636,1,1;89670,2,2;89677,1,4;89706,1,1;89715,2,4;89737,1,4;89771,1,2;89797,1,4;89837,1,2;89843,2,4;89911,1,4;89929,2,8;89935,1,1;89991,2,4;90068,2,8;90071,1,1;90101,2,1;90129,2,4;90217,1,4;90233,2,2;90272,2,4;90294,1,2;90361,2,8;90370,1,4;90433,2,4;90434,1,8;90479,1,4;90517,2,4;90537,1,8;90553,2,1;90585,2,4;90598,1,1;90665,1,2;90691,1,4;90707,2,4;90725,2,2;90764,1,8;90778,2,8;90817,1,4;90848,2,4;90876,2,1;90882,1,4;90907,2,4;90955,1,1;91001,2,2;91036,1,4;91038,2,4;91052,1,2;91094,1,8;91116,2,4;91150,1,4;91187,2,4;91215,1,1;91250,2,4;91308,1,4;91318,2,8;91332,1,2;91376,1,4;91420,2,4;91433,1,4;91488,1,4;91489,2,4;91546,1,8;91557,2,4;91628,2,8;91635,1,4;91695,1,4;91695,2,1;91757,1,4;91763,2,2;91819,1,8;91836,2,4;91880,1,1;91889,2,4;91937,1,2;91953,2,8;92006,1,4;92018,2,8;92055,1,4;92066,2,4;92117,1,8;92129,2,8;92174,1,8;92188,2,4;92219,1,4;92262,2,8;92277,1,8;92333,1,4;92403,1,8;92406,2,4;92466,2,8;92513,2,4;92541,1,4;92573,2,8;92600,2,1;92602,1,8;92644,1,4;92685,2,4;92703,1,8;92730,1,1;92735,2,4;92751,2,2;92753,2,2;92813,1,4;92833,2,4;92850,2,1;92862,1,4;92882,1,2;92884,1,2;92892,2,8;92948,2,4;92960,1,4;92971,2,1;92978,1,1;93006,2,8;93019,1,8;93062,2,4;93075,1,4;93086,2,2;93099,1,1;93138,1,8;93191,1,4;93209,1,2;93215,2,4;93240,2,1;93329,2,4;93342,1,4;93368,1,1;93384,2,4;93444,2,4;93457,1,4;93464,2,2;93468,2,1;93499,2,4;93511,1,4;93559,2,4;93571,1,4;93608,2,4;93624,2,2;93633,1,4;93652,2,4;93659,2,2;93686,1,4;93696,2,4;93706,1,2;93726,1,2;93770,2,4;93804,1,4;93816,2,1;93855,1,4;93858,2,4;93904,2,1;93915,1,4;93937,1,1;93939,1,1;93982,2,2;94008,1,2;94072,1,8;94081,2,8;94132,1,8;94166,2,8;94220,1,4;94291,2,4;94313,1,4;94409,1,4;94417,2,4;94466,1,4;94495,1,2;94542,2,4;94561,1,1;94564,1,1;94622,2,4;94626,1,2;94627,1,2;94645,1,1;94658,2,2;94675,1,4;94733,1,4;94754,2,1;94757,2,1;94761,1,1;94788,1,4;94831,2,2;94833,2,2;94848,1,4;94853,2,1;94870,1,2;94892,2,4;94916,1,8;94932,1,1;94968,2,4;94997,1,2;95000,1,2;95005,2,1;95037,2,4;95062,1,1;95068,1,1;95078,2,2;95112,2,4;95141,1,2;95191,2,8;95207,1,8;95223,2,1;95303,2,2;95306,2,2;95374,2,1;95378,2,1;95392,1,4;95452,1,8;95466,2,2;95540,1,4;95549,2,8;95564,1,1;95631,1,2;95697,1,8;95758,1,1;95763,2,4;95815,1,2;95836,2,8;95884,1,4;95939,2,4;95944,1,4;95970,2,1;96004,1,4;96028,1,1;96038,2,2;96059,1,4;96116,2,8;96119,1,4;96138,1,2;96168,1,4;96183,2,1;96226,1,8;96251,2,2;96288,1,1;96324,2,4;96344,1,2;96389,2,4;96417,1,4;96455,2,4;96483,2,1;96506,1,4;96514,2,4;96550,2,2;96566,1,4;96578,2,4;96630,2,4;96664,1,4;96692,2,8;96762,2,1;96818,2,2;96889,2,4;96985,2,4;97046,2,4;97146,2,4;97171,2,1;97206,2,8;97236,2,2;97262,2,4;97355,2,4;97448,2,4;97497,2,4;97555,2,8;97618,2,1;97738,2,2;97806,2,8;97862,2,4;97928,2,4;97982,2,4;98041,2,8;98097,2,4;98128,2,1;98217,2,4;98248,2,2;98277,2,4;98301,2,1;98331,2,4;98391,2,4;98412,2,2;98462,2,1;98522,2,2;98549,2,4;98599,2,4;98657,2,8;98746,2,4;98839,2,4;98862,2,1;98928,2,2;98995,2,8;99040,2,4;99099,2,8;99117,2,1;99161,2,2;99206,2,8;99281,2,1;99325,2,2;99328,2,2;99377,2,1;99380,2,1;99477,2,4;99535,2,8;99591,2,4;99612,2,2;99651,2,4;99677,2,2;99766,2,4;99797,2,1;99826,2,4;99860,2,1;99946,2,4;99977,2,2;100006,2,4;100055,2,4;100113,2,8;100169,2,4;100268,2,4;100291,2,1;100357,2,2;100424,2,8;100444,2,1;100475,2,4;100535,2,4;100555,2,2;100599,2,8;100688,2,4;100737,2,4;100795,2,8;100884,2,4;100908,2,1;100973,2,2;101041,2,8;101156,2,2;101244,2,4;101275,2,1;101453,2,1;101518,2,2;101586,2,8;101631,2,4;101690,2,8;101839,2,4;101911,2,2;101974,2,1;102006,2,8;102026,2,2;102091,2,1;102094,2,1;102158,2,2;102159,2,2;102193,2,4;102209,2,1;102213,2,1;102266,2,2;102335,2,4;102368,2,1;102449,2,4;102499,2,4;102515,2,2;102557,2,8;102577,2,2;102642,2,1;102645,2,1;102709,2,2;102710,2,2;102719,2,1;102788,2,4;102880,2,4;102904,2,1;102935,2,4;102995,2,4;103015,2,2;103111,2,2;103177,2,1;103180,2,1;103242,2,2;103243,2,2;103279,2,4;103295,2,1;103337,2,8;103357,2,1;103422,2,2;103425,2,2;103490,2,1;103495,2,1;103524,2,4;103542,2,2;103583,2,8;103610,2,2;103693,2,4;103757,2,8;103771,2,1;103802,2,4;103861,2,8;103881,2,1;103946,2,2;103949,2,2;104014,2,1;104015,2,1;104031,2,2;104063,2,8;104126,2,1;104271,2,4;104295,2,2;104298,2,2;104361,2,1;104364,2,1;104428,2,2;104429,2,2;104444,2,1;104477,2,8;104537,2,8;104626,2,4;104675,2,4;104733,2,8;104753,2,1;104799,2,2;104844,2,8;104886,2,1;104983,2,4;105007,2,2;105035,2,4;105064,2,1;105099,2,8;105155,2,4;105176,2,2;105312,2,8;105346,2,2;105422,2,4;105537,2,4;105561,2,1;105629,2,4;105653,2,2;105719,2,1;105722,2,1;105785,2,2;105786,2,2;105802,2,1;105835,2,8;105891,2,4;105957,2,4;106055,2,4;106079,2,2;106144,2,1;106147,2,1;106209,2,2;106210,2,2;106221,2,1;106289,2,4;106339,2,4;106397,2,8;106433,2,4;106472,2,8;106475,2,8;106512,2,8;106571,2,4;106643,2,8;106656,2,1;106699,2,2;106744,2,8;106804,2,4;106875,2,8;107015,2,8;107035,2,1;107069,2,8;107125,2,4;107146,2,2;107149,2,2;107215,2,1;107218,2,1;107280,2,2;107281,2,2;107316,2,4;107415,2,4;107431,2,1;107473,2,8;107493,2,1;107518,2,8;107556,2,4;107569,2,2;107617,2,4;107689,2,8;107726,2,4;107790,2,1;107842,2,2;107935,2,1 +5b0e35b6a9d50d4a DSH1|2|endless|e5b05497c8551d12|1002|4|307,0,4;307,1,4;307,2,4;307,3,4;442,0,4;442,1,4;442,2,4;442,3,4;573,1,4;573,2,4;573,3,4;575,0,4;701,0,4;701,2,4;701,3,4;703,1,4;1018,0,8;1018,1,8;1018,2,8;1018,3,8;1132,1,4;1132,2,4;1132,3,4;1136,0,4;1248,1,4;1248,2,4;1248,3,4;1254,0,4;1372,1,4;1372,2,4;1372,3,4;1380,0,4;1664,1,8;1664,2,8;1664,3,8;1668,0,8;1770,1,8;1770,2,8;1770,3,8;1776,0,8;1819,0,2;1819,2,2;1820,1,2;1820,3,2;1964,0,4;1964,1,4;1964,2,4;1964,3,4;2046,1,4;2046,2,4;2046,3,4;2052,0,4;2184,3,1;2185,1,1;2186,2,1;2188,0,1;2227,1,4;2227,2,4;2227,3,4;2230,0,4;2273,0,1;2273,1,1;2274,2,1;2274,3,1;2420,0,4;2420,1,4;2420,2,4;2420,3,4;2465,0,2;2465,3,2;2466,1,2;2468,2,2;2519,2,8;2519,3,8;2523,1,8;2525,0,8;2608,0,4;2608,1,4;2608,2,4;2608,3,4;2708,0,4;2708,1,4;2708,2,4;2708,3,4;2807,1,4;2807,2,4;2807,3,4;2814,0,4;2905,1,4;2905,2,4;2905,3,4;2909,0,4;2938,1,2;2939,3,2;2942,0,2;2942,2,2;2977,1,4;2977,2,4;2977,3,4;2979,0,4;3088,1,1;3089,2,1;3090,0,1;3091,3,1;3136,1,4;3136,2,4;3136,3,4;3139,0,4;3166,1,2;3166,2,2;3166,3,2;3168,0,2;3274,0,1;3274,1,1;3274,2,1;3274,3,1;3277,0,1;3277,1,1;3277,2,1;3277,3,1;3349,1,2;3349,3,2;3350,1,2;3351,2,2;3352,0,2;3353,0,2;3353,3,2;3355,2,2;3375,0,1;3375,1,1;3375,3,1;3377,3,1;3378,2,1;3380,1,1;3381,0,1;3388,2,1;3496,2,4;3496,3,4;3498,1,4;3500,0,4;3533,0,2;3533,1,2;3534,2,2;3535,3,2;3622,1,4;3622,2,4;3622,3,4;3630,0,4;3703,1,4;3703,2,4;3703,3,4;3706,0,4;3783,0,4;3783,1,4;3783,2,4;3783,3,4;3812,0,1;3813,1,1;3813,3,1;3816,2,1;3933,1,4;3933,2,4;3933,3,4;3940,0,4;3970,1,2;3970,2,2;3970,3,2;3972,0,2;4082,3,1;4083,0,1;4085,2,1;4088,1,1;4116,1,4;4116,2,4;4116,3,4;4119,0,4;4215,0,2;4215,2,2;4215,3,2;4217,1,2;4254,0,4;4254,1,4;4254,2,4;4254,3,4;4376,1,4;4376,2,4;4376,3,4;4377,0,4;4742,0,1;4742,2,1;4742,3,1;4743,1,1;4773,2,4;4773,3,4;4777,1,4;4780,0,4;4863,0,2;4863,2,2;4863,3,2;4866,0,2;4866,2,2;4866,3,2;4867,1,2;4870,1,2;4939,0,1;4939,1,1;4939,2,1;4939,3,1;4942,0,1;4942,1,1;4942,2,1;4942,3,1;5008,0,2;5008,1,2;5009,0,2;5009,1,2;5010,3,2;5011,2,2;5014,2,2;5014,3,2;5096,1,1;5096,2,1;5096,3,1;5099,0,1;5175,1,8;5175,2,8;5175,3,8;5182,0,8;5303,2,2;5304,3,2;5305,0,2;5306,1,2;5379,0,1;5379,1,1;5379,2,1;5379,3,1;5382,0,1;5382,1,1;5382,2,1;5382,3,1;5446,1,2;5446,3,2;5447,1,2;5447,3,2;5450,0,2;5453,2,2;5455,2,2;5456,0,2;5535,0,1;5535,1,1;5535,2,1;5535,3,1;5538,0,1;5538,1,1;5538,2,1;5538,3,1;5602,0,2;5602,1,2;5603,3,2;5604,1,2;5604,3,2;5606,0,2;5606,2,2;5607,2,2;5622,3,1;5623,2,1;5625,1,1;5655,1,4;5655,2,4;5655,3,4;5663,0,4;5679,0,1;5787,0,1;5787,3,1;5813,0,4;5813,3,4;5829,2,1;5831,1,1;5960,0,2;5960,1,2;5960,2,2;5960,3,2;5963,0,2;5963,1,2;5963,2,2;5963,3,2;6028,1,1;6028,2,1;6028,3,1;6029,2,1;6029,3,1;6034,1,1;6037,0,1;6038,0,1;6106,1,2;6106,2,2;6106,3,2;6109,0,2;6176,0,8;6176,2,8;6176,3,8;6180,1,8;6198,0,1;6198,1,1;6198,2,1;6198,3,1;6265,0,2;6265,1,2;6265,2,2;6265,3,2;6268,0,2;6268,1,2;6268,2,2;6268,3,2;6330,3,1;6332,0,1;6332,1,1;6332,2,1;6333,0,1;6334,1,1;6334,2,1;6340,3,1;6349,0,2;6350,2,2;6350,3,2;6382,2,8;6382,3,8;6383,0,8;6385,1,8;6397,1,2;6402,0,1;6402,1,1;6402,2,1;6402,3,1;6468,0,2;6468,2,2;6468,3,2;6470,1,2;6535,0,8;6535,1,8;6535,2,8;6535,3,8;6555,0,2;6555,1,2;6555,2,2;6555,3,2;6621,0,1;6621,1,1;6621,2,1;6621,3,1;6624,1,1;6624,2,1;6624,3,1;6628,0,1;6686,1,2;6687,3,2;6688,0,2;6688,2,2;6688,3,2;6689,0,2;6689,2,2;6691,1,2;6722,1,4;6722,2,4;6722,3,4;6730,0,4;6739,1,1;6739,2,1;6739,3,1;6740,0,1;6781,0,8;6781,1,8;6781,2,8;6781,3,8;6826,1,4;6826,2,4;6826,3,4;6827,0,4;6885,0,8;6885,1,8;6885,2,8;6885,3,8;6930,1,4;6930,2,4;6930,3,4;6934,0,4;6988,0,8;6988,1,8;6988,2,8;6988,3,8;7008,0,1;7008,1,1;7008,2,1;7008,3,1;7073,1,2;7073,2,2;7073,3,2;7075,0,2;7141,2,8;7141,3,8;7143,0,8;7143,1,8;7201,0,8;7201,1,8;7201,2,8;7201,3,8;7257,1,4;7257,2,4;7257,3,4;7260,0,4;7410,1,4;7410,2,4;7410,3,4;7413,0,4;7435,0,1;7435,3,1;7436,1,1;7437,2,1;7524,0,4;7524,1,4;7524,2,4;7524,3,4;7548,0,2;7548,1,2;7548,2,2;7548,3,2;7677,1,4;7677,2,4;7677,3,4;7688,0,4;7701,1,1;7701,2,1;7701,3,1;7725,0,1;7735,1,8;7735,2,8;7735,3,8;7765,0,8;7791,1,4;7791,2,4;7791,3,4;7805,0,2;7811,2,2;7812,3,2;7813,1,2;7828,1,1;7828,3,1;7830,2,1;7849,0,4;7894,0,1;7906,1,4;7906,2,4;7906,3,4;7930,2,2;7930,3,2;7933,2,2;7933,3,2;7934,1,2;7937,1,2;7995,1,1;7995,2,1;7995,3,1;7998,1,1;7998,2,1;7998,3,1;8013,0,4;8052,0,2;8055,0,2;8060,1,2;8060,3,2;8061,1,2;8061,2,2;8061,3,2;8068,2,2;8079,1,1;8079,2,1;8082,3,1;8108,1,4;8108,2,4;8108,3,4;8150,0,1;8153,0,1;8180,1,1;8180,2,1;8182,3,1;8228,0,2;8231,0,2;8258,0,1;8270,2,8;8270,3,8;8273,1,8;8297,0,4;8300,3,2;8326,1,4;8326,2,4;8326,3,4;8343,0,1;8346,2,2;8348,1,2;8373,0,4;8386,1,4;8386,2,4;8386,3,4;8441,1,4;8441,2,4;8441,3,4;8499,1,8;8499,2,8;8499,3,8;8518,0,8;8527,2,2;8532,3,2;8547,1,2;8556,0,2;8593,0,4;8610,1,4;8610,2,4;8610,3,4;8659,1,4;8659,2,4;8659,3,4;8685,0,4;8744,0,4;8757,2,4;8757,3,4;8758,1,4;8773,3,1;8774,1,1;8775,2,1;8815,2,8;8815,3,8;8817,1,8;8821,0,8;8856,0,2;8861,1,4;8861,2,4;8861,3,4;8919,1,8;8919,2,8;8919,3,8;8973,0,4;8975,1,4;8975,2,4;8975,3,4;9027,0,4;9041,1,4;9041,2,4;9041,3,4;9064,1,1;9064,2,1;9064,3,1;9123,0,1;9130,1,2;9130,2,2;9130,3,2;9133,1,2;9133,2,2;9133,3,2;9152,0,4;9195,1,1;9195,2,1;9195,3,1;9197,1,1;9197,2,1;9200,3,1;9224,0,8;9272,1,2;9272,2,2;9272,3,2;9275,1,2;9275,2,2;9275,3,2;9277,0,4;9337,1,1;9337,2,1;9337,3,1;9339,1,1;9340,3,1;9341,2,1;9349,0,8;9356,3,2;9357,2,2;9358,1,2;9420,0,4;9441,1,1;9441,2,1;9441,3,1;9475,2,8;9475,3,8;9476,1,8;9498,0,4;9526,0,1;9531,1,4;9531,2,4;9531,3,4;9551,1,2;9551,3,2;9552,2,2;9595,1,8;9595,2,8;9595,3,8;9608,0,2;9611,0,2;9651,1,4;9651,2,4;9651,3,4;9677,1,2;9677,2,2;9679,3,2;9680,0,1;9682,0,1;9766,1,4;9766,2,4;9766,3,4;9782,0,2;9785,0,2;9797,3,1;9800,1,1;9830,2,8;9830,3,8;9834,1,8;9842,2,1;9843,0,1;9844,0,1;9865,0,2;9886,1,4;9886,2,4;9886,3,4;9951,2,1;9951,3,1;9956,1,1;9969,0,1;10004,0,8;10015,1,2;10015,2,2;10015,3,2;10038,0,2;10044,1,4;10044,2,4;10044,3,4;10068,0,4;10143,0,8;10206,0,4;10230,0,2;10230,1,4;10230,2,4;10230,3,4;10290,1,4;10290,2,4;10290,3,4;10332,0,4;10339,1,4;10339,2,4;10339,3,4;10365,0,1;10397,1,8;10397,2,8;10397,3,8;10403,0,8;10453,2,4;10453,3,4;10454,1,4;10468,0,4;10488,0,1;10515,0,4;10601,0,2;10617,1,2;10617,2,2;10617,3,2;10632,0,4;10704,2,4;10704,3,4;10705,1,4;10764,1,4;10764,2,4;10764,3,4;10785,2,1;10785,3,1;10813,1,4;10829,0,4;10829,1,1;10829,3,1;10830,1,1;10832,2,1;10893,1,2;10894,2,2;10899,0,4;10922,1,4;10922,2,4;10939,3,2;10950,0,4;10988,2,2;10991,3,2;10993,1,2;11007,0,8;11044,1,1;11044,2,1;11044,3,1;11047,1,1;11047,2,1;11047,3,1;11061,0,4;11110,1,2;11110,2,2;11110,3,2;11113,1,2;11113,2,2;11113,3,2;11176,2,1;11177,3,1;11178,2,1;11179,3,1;11180,1,1;11181,1,1;11186,1,2;11186,2,2;11186,3,2;11227,0,2;11255,1,4;11255,2,4;11255,3,4;11279,1,1;11279,2,1;11279,3,1;11310,1,4;11310,2,4;11310,3,4;11315,0,4;11370,1,4;11370,2,4;11370,3,4;11378,0,4;11389,2,2;11389,3,2;11392,1,2;11393,1,1;11393,2,1;11393,3,1;11397,0,1;11441,0,1;11459,2,2;11459,3,2;11462,1,2;11506,0,2;11526,1,8;11526,2,8;11526,3,8;11534,0,4;11553,2,2;11554,3,2;11556,1,2;11600,0,2;11637,1,4;11637,2,4;11637,3,4;11655,0,1;11670,0,1;11725,0,2;11728,0,2;11751,1,4;11751,2,4;11751,3,4;11775,1,1;11775,2,1;11775,3,1;11778,1,1;11778,2,1;11778,3,1;11789,0,1;11791,0,1;11797,0,2;11841,1,2;11841,2,2;11841,3,2;11844,1,2;11844,2,2;11844,3,2;11866,0,4;11890,0,1;11907,3,1;11909,3,1;11910,1,1;11910,2,1;11911,1,1;11911,2,1;11928,0,4;11983,1,2;11983,2,2;11983,3,2;11986,1,2;11986,2,2;11986,3,2;11990,0,4;12002,0,2;12008,0,1;12048,1,1;12049,2,1;12050,3,1;12051,1,1;12051,2,1;12051,3,1;12066,1,2;12067,3,2;12070,0,2;12095,2,4;12095,3,4;12099,1,4;12115,2,2;12137,0,8;12167,0,2;12193,1,4;12193,2,4;12193,3,4;12248,0,4;12253,1,4;12253,2,4;12253,3,4;12302,1,4;12302,2,4;12302,3,4;12361,1,8;12361,2,8;12361,3,8;12362,0,4;12386,0,1;12389,0,1;12450,1,4;12450,2,4;12450,3,4;12452,0,2;12459,0,2;12517,0,1;12521,0,1;12542,1,4;12542,2,4;12542,3,4;12597,0,2;12600,0,2;12608,3,1;12609,1,1;12610,2,1;12660,0,1;12662,0,1;12664,1,2;12664,2,2;12664,3,2;12710,0,4;12726,0,2;12793,1,4;12793,2,4;12793,3,4;12804,0,4;12853,1,4;12853,2,4;12853,3,4;12864,0,4;12916,0,4;12924,2,2;12925,3,2;12927,1,2;12973,0,8;13015,1,8;13015,2,8;13015,3,8;13065,0,4;13071,1,4;13071,2,4;13071,3,4;13091,1,1;13091,3,1;13093,2,1;13135,1,8;13135,2,8;13135,3,8;13154,0,4;13191,2,4;13191,3,4;13195,1,4;13219,0,1;13241,1,4;13241,2,4;13241,3,4;13279,0,2;13299,1,8;13299,2,8;13299,3,8;13360,1,2;13360,2,2;13361,3,2;13404,0,4;13464,0,4;13483,2,1;13483,3,1;13486,2,1;13486,3,1;13487,1,1;13490,1,1;13537,0,2;13548,1,2;13548,2,2;13548,3,2;13549,2,2;13549,3,2;13550,1,2;13559,1,1;13559,2,1;13559,3,1;13628,0,8;13677,1,4;13677,2,4;13677,3,4;13684,0,4;13704,0,1;13735,1,8;13735,2,8;13735,3,8;13746,0,8;13791,1,4;13791,2,4;13791,3,4;13803,0,4;13846,1,4;13846,2,4;13846,3,4;13851,0,4;13905,1,8;13905,2,8;13905,3,8;13918,0,8;13950,1,4;13950,2,4;13950,3,4;13978,0,2;14008,1,8;14008,2,8;14008,3,8;14035,1,2;14035,3,2;14036,2,2;14093,0,1;14096,0,1;14119,1,4;14119,2,4;14119,3,4;14165,0,2;14166,0,2;14170,0,1;14208,1,1;14208,2,1;14208,3,1;14211,1,1;14211,2,1;14211,3,1;14273,2,2;14273,3,2;14274,1,2;14276,2,2;14276,3,2;14277,1,2;14290,0,4;14292,1,1;14292,3,1;14294,2,1;14321,1,4;14321,2,4;14321,3,4;14348,0,8;14402,0,4;14459,0,4;14473,2,4;14473,3,4;14475,1,4;14503,1,1;14503,2,1;14503,3,1;14515,0,8;14537,1,8;14537,2,8;14537,3,8;14565,0,4;14593,2,4;14593,3,4;14595,1,4;14613,3,2;14614,1,2;14617,2,2;14617,3,2;14619,0,8;14620,2,2;14621,1,2;14646,0,2;14683,1,1;14683,2,1;14683,3,1;14686,1,1;14686,2,1;14686,3,1;14742,0,4;14749,2,2;14750,2,2;14752,1,2;14752,3,2;14753,3,2;14758,1,2;14824,1,1;14824,2,1;14824,3,1;14878,0,1;14881,0,1;14892,2,8;14892,3,8;14894,1,8;14937,1,4;14937,2,4;14937,3,4;14954,0,2;14955,0,2;14978,0,1;14995,1,8;14995,2,8;14995,3,8;15028,0,4;15057,2,1;15057,3,1;15060,1,1;15179,1,2;15179,2,2;15179,3,2;15246,1,8;15246,2,8;15246,3,8;15247,0,4;15294,0,1;15331,0,8;15359,1,1;15359,2,1;15359,3,1;15373,0,2;15390,2,4;15390,3,4;15394,1,4;15410,0,4;15450,1,4;15450,2,4;15450,3,4;15452,0,2;15469,2,2;15469,3,2;15471,1,2;15510,1,4;15510,2,4;15510,3,4;15540,0,1;15543,0,1;15575,1,4;15575,2,4;15575,3,4;15599,1,1;15599,2,1;15599,3,1;15615,0,2;15618,0,2;15634,2,8;15634,3,8;15637,1,8;15639,0,1;15650,0,1;15690,1,4;15690,2,4;15690,3,4;15711,2,2;15711,3,2;15712,1,2;15713,1,2;15713,2,2;15713,3,2;15716,0,2;15779,1,1;15779,2,1;15779,3,1;15782,1,1;15782,2,1;15782,3,1;15808,0,8;15844,2,2;15845,1,2;15845,2,2;15845,3,2;15846,3,2;15848,1,2;15872,0,4;15890,2,8;15890,3,8;15894,1,8;15944,0,8;15946,1,4;15946,2,4;15946,3,4;15966,1,1;15966,3,1;15968,2,1;15976,0,1;16003,0,4;16039,2,4;16039,3,4;16043,1,4;16099,1,4;16099,2,4;16099,3,4;16124,2,2;16124,3,2;16127,1,2;16175,0,2;16213,1,4;16213,2,4;16213,3,4;16237,1,1;16237,2,1;16237,3,1;16240,1,1;16240,2,1;16240,3,1;16263,0,8;16303,1,2;16303,2,2;16303,3,2;16306,1,2;16306,2,2;16306,3,2;16368,2,1;16368,3,1;16369,1,1;16369,2,1;16370,3,1;16372,1,1;16386,3,2;16387,1,2;16388,2,2;16405,0,1;16415,1,4;16415,2,4;16415,3,4;16437,0,4;16439,1,1;16439,2,1;16439,3,1;16470,1,4;16470,2,4;16470,3,4;16480,0,2;16513,0,4;16530,1,4;16530,2,4;16530,3,4;16549,2,2;16549,3,2;16553,1,2;16589,0,4;16660,0,4;16693,0,1;16734,0,8;16739,1,1;16739,2,1;16739,3,1;16763,0,2;16774,1,8;16774,2,8;16774,3,8;16795,0,4;16827,0,2;16830,1,4;16830,2,4;16830,3,4;16849,1,2;16849,2,2;16850,3,2;16879,1,4;16879,2,4;16879,3,4;16903,0,1;16906,0,1;16937,1,8;16937,2,8;16937,3,8;16981,0,2;16983,0,2;16997,1,8;16997,2,8;16997,3,8;17027,0,8;17042,2,4;17042,3,4;17045,1,4;17063,0,1;17095,0,4;17101,1,8;17101,2,8;17101,3,8;17162,1,1;17163,2,1;17163,3,1;17195,0,4;17226,3,2;17227,1,2;17227,2,2;17259,1,8;17259,2,8;17259,3,8;17266,0,4;17290,0,2;17319,1,8;17319,2,8;17319,3,8;17390,0,4;17418,0,1;17421,0,1;17432,1,1;17432,2,1;17432,3,1;17466,1,8;17466,2,8;17466,3,8;17489,0,2;17492,0,2;17522,1,4;17522,2,4;17522,3,4;17542,2,2;17542,3,2;17546,1,2;17557,0,1;17560,0,1;17576,0,2;17613,0,4;17615,1,4;17615,2,4;17615,3,4;17636,0,1;17639,1,1;17639,2,1;17639,3,1;17671,0,4;17704,0,2;17704,2,2;17704,3,2;17705,1,2;17735,0,4;17772,1,8;17772,2,8;17772,3,8;17792,1,2;17792,2,2;17792,3,2;17857,1,1;17857,2,1;17857,3,1;17860,1,1;17860,2,1;17860,3,1;17922,2,2;17922,3,2;17923,2,2;17923,3,2;17924,1,2;17925,1,2;17933,1,1;17933,2,1;17933,3,1;17953,0,1;18002,1,4;18002,2,4;18002,3,4;18026,1,1;18026,2,1;18026,3,1;18091,1,2;18091,2,2;18091,3,2;18159,1,8;18159,2,8;18159,3,8;18179,1,1;18179,2,1;18179,3,1;18214,1,8;18214,2,8;18214,3,8;18270,1,4;18270,2,4;18270,3,4;18289,1,2;18289,3,2;18292,2,2;18293,1,2;18293,2,2;18293,3,2;18359,1,1;18359,2,1;18359,3,1;18362,1,1;18362,2,1;18362,3,1;18424,3,2;18425,2,2;18426,1,2;18426,3,2;18428,2,2;18430,1,2;18461,1,4;18461,2,4;18461,3,4;18477,3,1;18481,1,1;18483,2,1;18519,1,8;18519,2,8;18519,3,8;18580,2,1;18580,3,1;18583,1,1;18668,1,4;18668,2,4;18668,3,4;18702,1,2;18702,2,2;18702,3,2;18728,1,4;18728,2,4;18728,3,4;18788,1,4;18788,2,4;18788,3,4;18812,1,1;18812,2,1;18812,3,1;18842,1,4;18842,2,4;18842,3,4;18902,1,4;18902,2,4;18902,3,4;18922,1,2;18922,3,2;18923,2,2;18962,1,4;18962,2,4;18962,3,4;19017,2,4;19017,3,4;19020,1,4;19075,1,8;19075,2,8;19075,3,8;19131,2,4;19131,3,4;19136,1,4;19295,3,2;19296,1,2;19298,2,2;19352,1,1;19352,2,1;19352,3,1;19421,1,4;19421,2,4;19421,3,4;19470,1,4;19470,2,4;19470,3,4;19528,1,8;19528,2,8;19528,3,8;19617,1,4;19617,2,4;19617,3,4;19733,1,1;19733,2,1;19733,3,1;19799,1,2;19799,2,2;19799,3,2;19802,1,2;19802,2,2;19802,3,2;19864,1,1;19864,2,1;19864,3,1;19865,1,1;19868,3,1;19869,2,1;19882,3,2;19883,2,2;19884,1,2;19915,1,8;19915,2,8;19915,3,8;19977,1,1;19977,3,1;19978,2,1;20041,3,2;20042,1,2;20074,1,8;20074,2,8;20074,3,8;20089,2,2;20134,1,8;20134,2,8;20134,3,8;20282,1,4;20282,2,4;20282,3,4;20331,1,4;20331,2,4;20331,3,4;20390,1,8;20390,2,8;20390,3,8;20417,2,1;20418,1,1;20418,3,1;20501,1,4;20501,2,4;20501,3,4;20532,3,2;20534,1,2;20534,2,2;20561,1,4;20561,2,4;20561,3,4;20625,1,8;20625,2,8;20625,3,8;20686,2,2;20686,3,2;20687,1,2;20750,1,1;20750,3,1;20751,2,1;20779,1,4;20779,2,4;20779,3,4;20808,1,2;20808,2,2;20808,3,2;20873,1,1;20873,2,1;20873,3,1;20876,1,1;20876,2,1;20876,3,1;20940,1,2;20940,2,2;20940,3,2;20941,1,2;20942,2,2;20944,3,2;20975,2,4;20975,3,4;20978,1,4;20991,2,1;20992,2,1;20994,1,1;20994,3,1;20997,1,1;20997,3,1;21083,1,8;21083,2,8;21083,3,8;21139,1,4;21139,2,4;21139,3,4;21158,1,2;21159,2,2;21159,3,2;21175,2,2;21175,3,2;21177,1,2;21253,1,4;21253,2,4;21253,3,4;21302,1,4;21302,2,4;21302,3,4;21319,1,1;21319,3,1;21320,2,1;21361,1,8;21361,2,8;21361,3,8;21417,1,4;21417,2,4;21417,3,4;21442,1,2;21443,2,2;21443,3,2;21531,2,4;21531,3,4;21533,1,4;21646,1,4;21646,2,4;21646,3,4;21677,1,1;21677,3,1;21678,2,1;21706,1,4;21706,2,4;21706,3,4;21732,2,2;21732,3,2;21733,1,2;21821,2,4;21821,3,4;21824,1,4;21870,1,4;21870,2,4;21870,3,4;21886,1,1;21886,2,1;21888,3,1;21914,2,4;21914,3,4;21918,1,4;21921,2,1;21922,3,1;21958,2,4;21958,3,4;21969,1,1;21999,3,2;22002,1,2;22003,2,2;22040,1,4;22040,2,4;22040,3,4;22095,1,1;22095,2,1;22095,3,1;22132,1,8;22132,2,8;22132,3,8;22172,2,2;22173,3,2;22176,1,2;22215,1,4;22215,2,4;22215,3,4;22302,3,2;22303,2,2;22304,1,2;22393,2,1;22393,3,1;22400,1,1;22443,1,8;22443,2,8;22443,3,8;22526,1,8;22526,2,8;22526,3,8;22650,1,4;22650,2,4;22650,3,4;22687,1,2;22687,2,2;22688,3,2;22716,1,4;22716,2,4;22716,3,4;22848,1,4;22848,2,4;22848,3,4;22949,1,1;22950,2,1;22952,1,1;22952,2,1;23025,1,2;23025,2,2;23025,3,1;23116,1,8;23116,2,8;23116,3,8;23189,2,4;23189,3,4;23190,1,4;23374,2,4;23374,3,4;23377,1,4;23446,1,8;23446,2,8;23446,3,8;23503,1,4;23503,2,4;23503,3,4;23574,1,8;23574,2,8;23574,3,8;23605,1,2;23605,3,2;23611,2,2;23709,1,4;23709,2,4;23709,3,4;23846,1,4;23846,2,4;23846,3,4;23880,2,1;23881,3,1;23882,1,1;23916,1,4;23916,2,4;23916,3,4;23949,1,1;23949,2,1;23949,3,1;24018,1,2;24018,2,2;24018,3,2;24098,1,8;24098,2,8;24098,3,8;24201,2,4;24201,3,4;24204,1,4;24269,1,4;24269,2,4;24269,3,4;24401,1,1;24401,3,1;24403,2,1;24502,1,4;24502,2,4;24502,3,4;24556,1,4;24556,2,4;24556,3,4;24572,2,2;24575,2,2;24575,3,2;24576,3,2;24577,1,2;24578,1,2;24705,1,1;24705,2,1;24705,3,1;24780,1,8;24780,2,8;24780,3,8;24803,1,1;24803,2,1;24803,3,1;24873,1,2;24873,2,2;24873,3,2;24876,1,2;24876,2,2;24876,3,2;24940,2,1;24940,3,1;24941,1,1;24942,2,1;24942,3,1;24943,1,1;24979,1,4;24979,2,4;24979,3,4;24996,1,2;24997,3,2;24998,1,2;24999,3,2;25001,2,2;25004,2,2;25083,1,4;25083,2,4;25083,3,4;25099,2,1;25099,3,1;25100,1,1;25143,1,8;25143,2,8;25143,3,8;25331,1,4;25331,2,4;25331,3,4;25392,1,4;25392,2,4;25392,3,4;25453,1,4;25453,2,4;25453,3,4;25477,1,1;25477,2,1;25477,3,1;25512,1,8;25512,2,8;25512,3,8;25568,1,4;25568,2,4;25568,3,4;25588,1,2;25588,2,2;25588,3,2;25617,1,4;25617,2,4;25617,3,4;25675,1,8;25675,2,8;25675,3,8;25764,1,4;25764,2,4;25764,3,4;25788,1,1;25788,2,1;25788,3,1;25853,2,2;25853,3,2;25855,1,2;25921,1,8;25921,2,8;25921,3,8;25977,2,4;25977,3,4;25978,1,4;26006,1,1;26006,2,1;26006,3,1;26041,1,8;26041,2,8;26041,3,8;26097,1,4;26097,2,4;26097,3,4;26117,3,2;26118,1,2;26121,2,2;26190,1,4;26190,2,4;26190,3,4;26213,1,2;26213,2,2;26213,3,2;26279,1,1;26279,2,1;26279,3,1;26282,1,1;26282,2,1;26282,3,1;26344,1,2;26344,3,2;26345,2,2;26347,1,2;26347,2,2;26347,3,2;26362,3,1;26363,2,1;26364,1,1;26395,1,8;26395,2,8;26395,3,8;26441,1,4;26441,2,4;26441,3,4;26499,1,8;26499,2,8;26499,3,8;26604,1,4;26604,2,4;26604,3,4;26623,1,1;26623,2,1;26623,3,1;26667,1,2;26667,2,2;26667,3,2;26670,1,2;26670,2,2;26670,3,2;26710,3,1;26712,1,1;26713,2,1;26714,2,1;26714,3,1;26715,1,1;26724,3,2;26742,1,4;26742,2,4;26742,3,4;26755,1,2;26755,2,2;26806,1,2;26806,3,2;26809,2,2;26870,1,1;26870,2,1;26870,3,1;26955,1,1;26955,2,1;26955,3,1;27020,2,2;27020,3,2;27023,1,2;27088,1,8;27088,2,8;27088,3,8;27148,1,8;27148,2,8;27148,3,8;27193,1,4;27193,2,4;27193,3,4;27252,1,8;27252,2,8;27252,3,8;27313,2,2;27313,3,2;27318,1,2;27370,1,1;27370,2,1;27370,3,1;27439,1,4;27439,2,4;27439,3,4;27503,1,8;27503,2,8;27503,3,8;27559,1,4;27559,2,4;27559,3,4;27608,1,4;27608,2,4;27608,3,4;27666,1,8;27666,2,8;27666,3,8;27686,1,2;27686,2,2;27686,3,2;27752,1,1;27752,2,1;27752,3,1;27755,1,1;27755,2,1;27755,3,1;27817,1,2;27817,3,2;27818,1,2;27820,2,2;27821,3,2;27824,2,2;27893,1,1;27893,2,1;27893,3,1;27896,1,1;27896,2,1;27896,3,1;27959,1,2;27959,2,2;27959,3,2;27960,1,2;27961,3,2;27962,2,2;28035,1,1;28035,2,1;28035,3,1;28103,2,8;28103,3,8;28107,1,8;28164,2,2;28164,3,2;28169,1,2;28286,1,1;28286,2,1;28286,3,1;28289,1,1;28289,2,1;28289,3,1;28351,3,2;28352,2,2;28352,3,2;28353,2,2;28354,1,2;28355,1,2;28388,1,4;28388,2,4;28388,3,4;28404,3,1;28405,1,1;28406,2,1;28446,1,8;28446,2,8;28446,3,8;28508,3,2;28509,1,2;28509,2,2;28573,1,1;28573,2,1;28574,3,1;28657,1,1;28657,2,1;28657,3,1;28722,1,2;28722,2,2;28722,3,2;28790,1,8;28790,2,8;28790,3,8;28846,2,4;28846,3,4;28850,1,4;28873,2,1;28873,3,1;28874,1,1;28961,2,4;28961,3,4;28962,1,4;28992,2,2;28992,3,2;28994,1,2;29025,1,8;29025,2,8;29025,3,8;29086,3,1;29087,1,1;29090,2,1;29168,1,4;29168,2,4;29168,3,4;29184,3,2;29186,1,2;29186,2,2;29226,1,8;29226,2,8;29226,3,8;29246,1,2;29246,2,2;29246,3,2;29312,1,1;29312,2,1;29312,3,1;29315,1,1;29315,2,1;29315,3,1;29377,2,2;29378,2,2;29378,3,2;29381,1,2;29381,3,2;29383,1,2;29388,1,1;29388,2,1;29388,3,1;29391,1,1;29391,2,1;29391,3,1;29453,1,2;29453,2,2;29453,3,2;29456,1,2;29456,2,2;29456,3,2;29519,2,1;29519,3,1;29520,1,1;29520,2,1;29520,3,1;29522,1,1;29530,1,2;29530,2,2;29530,3,2;29599,1,4;29599,2,4;29599,3,4;29648,1,4;29648,2,4;29648,3,4;29706,1,8;29706,2,8;29706,3,8;29766,1,8;29766,2,8;29766,3,8;29855,1,4;29855,2,4;29855,3,4;29919,2,8;29919,3,8;29921,1,8;29952,1,2;29952,3,2;29959,2,2;30030,1,4;30030,2,4;30030,3,4;30119,1,1;30119,2,1;30119,3,1;30186,1,8;30186,2,8;30186,3,8;30248,2,1;30249,3,1;30253,1,1;30330,1,4;30330,2,4;30330,3,4;30428,1,4;30428,2,4;30428,3,4;30535,1,8;30535,2,8;30535,3,8;30591,1,4;30591,2,4;30591,3,4;30611,2,2;30611,3,2;30615,1,2;30615,2,2;30615,3,2;30618,1,2;30681,1,1;30681,2,1;30681,3,1;30684,1,1;30684,2,1;30684,3,1;30746,3,2;30747,3,2;30748,2,2;30749,1,2;30750,2,2;30752,1,2;30757,1,1;30757,2,1;30757,3,1;30826,1,4;30826,2,4;30826,3,4;30850,1,2;30850,2,2;30850,3,2;30915,1,1;30915,2,1;30915,3,1;30918,1,1;30918,2,1;30918,3,1;30980,1,2;30980,3,2;30981,2,2;30983,1,2;30983,2,2;30983,3,2;30999,3,1;31000,1,1;31000,2,1;31028,1,4;31028,2,4;31028,3,4;31052,1,1;31052,2,1;31052,3,1;31117,1,2;31117,2,2;31117,3,2;31185,1,8;31185,2,8;31185,3,8;31297,1,1;31297,2,1;31297,3,1;31328,1,4;31328,2,4;31328,3,4;31388,1,4;31388,2,4;31388,3,4;31410,3,2;31411,1,2;31412,1,1;31412,3,1;31442,1,4;31442,2,4;31442,3,4;31502,1,4;31502,2,4;31502,3,4;31522,2,2;31523,3,2;31525,1,2;31526,1,1;31526,2,1;31526,3,1;31557,2,4;31557,3,4;31559,1,4;31591,3,2;31617,1,4;31617,2,4;31617,3,4;31637,2,2;31638,1,2;31710,1,4;31710,2,4;31710,3,4;31802,1,4;31802,2,4;31802,3,4;31866,1,8;31866,2,8;31866,3,8;31886,1,2;31886,2,2;31886,3,2;31952,1,1;31952,2,1;31952,3,1;31955,1,1;31955,2,1;31955,3,1;32017,2,2;32017,3,2;32018,2,2;32018,3,2;32020,1,2;32021,1,2;32053,2,4;32053,3,4;32054,1,4;32069,1,1;32070,2,1;32072,3,1;32112,1,8;32112,2,8;32112,3,8;32132,1,1;32132,2,1;32132,3,1;32197,1,2;32197,2,2;32197,3,2;32265,1,8;32265,2,8;32265,3,8;32326,1,2;32326,3,2;32328,2,2;32413,1,4;32413,2,4;32413,3,4;32473,1,4;32473,2,4;32473,3,4;32493,1,1;32493,3,1;32495,2,1;32533,1,4;32533,2,4;32533,3,4;32603,1,8;32603,2,8;32603,3,8;32664,2,1;32664,3,1;32666,1,1;32729,3,2;32730,2,2;32761,1,8;32761,2,8;32761,3,8;32775,1,2;32850,1,4;32850,2,4;32850,3,4;32910,1,4;32910,2,4;32910,3,4;32975,1,4;32975,2,4;32975,3,4;32999,2,1;32999,3,1;33007,1,1;33064,1,2;33064,2,2;33064,3,2;33132,1,8;33132,2,8;33132,3,8;33188,2,4;33188,3,4;33190,1,4;33248,1,4;33248,2,4;33248,3,4;33346,1,4;33346,2,4;33346,3,4;33406,1,4;33406,2,4;33406,3,4;33438,3,2;33440,1,2;33440,2,2;33526,1,4;33526,2,4;33526,3,4;33557,3,1;33559,1,1;33559,2,1;33586,2,4;33586,3,4;33589,1,4;33739,1,4;33739,2,4;33739,3,4;33768,1,2;33768,2,2;33768,3,2;33833,1,1;33833,2,1;33833,3,1;33836,1,1;33836,2,1;33836,3,1;33900,1,2;33900,3,2;33901,2,2;33902,1,2;33906,3,2;33908,2,2;33935,1,4;33935,2,4;33935,3,4;33951,3,1;33952,1,1;33955,2,1;33979,1,4;33979,2,4;33979,3,4;33986,2,1;33987,1,1;33987,3,1;34023,1,4;34023,2,4;34023,3,4;34065,1,2;34065,2,2;34065,3,2;34105,2,4;34105,3,4;34107,1,4;34146,2,2;34146,3,2;34153,1,2;34255,1,1;34255,2,1;34255,3,1;34258,1,1;34258,2,1;34258,3,1;34331,3,2;34332,2,2;34333,1,2;34333,2,2;34333,3,2;34335,1,2;34364,3,1;34366,2,1;34372,1,1;34404,1,4;34404,2,4;34404,3,4;34497,2,4;34497,3,4;34500,1,4;34527,1,1;34527,2,1;34527,3,1;34565,2,4;34565,3,4;34568,1,4;34707,1,4;34707,2,4;34707,3,4;34749,1,2;34749,3,2;34750,2,2;34789,1,4;34789,2,4;34789,3,4;34869,1,4;34869,2,4;34869,3,4;34906,1,1;34906,3,1;34907,2,1;35028,1,4;35028,2,4;35028,3,4;35064,1,2;35064,3,2;35066,2,2;35106,1,4;35106,2,4;35106,3,4;35135,2,1;35135,3,1;35138,1,1;35170,1,4;35170,2,4;35170,3,4;35295,1,4;35295,2,4;35295,3,4;35313,3,2;35314,1,2;35315,2,2;35316,1,2;35316,2,2;35317,3,2;35393,1,1;35393,3,1;35394,2,1;35474,1,4;35474,2,4;35474,3,4;35552,2,8;35552,3,8;35553,1,8;35626,1,8;35626,2,8;35626,3,8;35657,1,2;35657,2,2;35657,3,2;35736,2,1;35736,3,1;35739,2,1;35739,3,1;35740,1,1;35743,1,1;35806,2,2;35806,3,2;35807,2,2;35808,3,2;35809,1,2;35811,1,2;35828,1,1;35828,2,1;35828,3,1;35870,1,8;35870,2,8;35870,3,8;35937,1,4;35937,2,4;35937,3,4;36014,1,4;36014,2,4;36014,3,4;36071,1,4;36071,2,4;36071,3,4;36139,1,8;36139,2,8;36139,3,8;36166,1,1;36166,2,1;36166,3,1;36241,1,2;36241,2,2;36241,3,2;36244,1,2;36244,2,2;36244,3,2;36310,1,1;36310,3,1;36311,1,1;36311,2,1;36311,3,1;36313,2,1;36353,1,4;36353,2,4;36353,3,4;36370,3,2;36371,1,2;36372,1,2;36372,2,2;36373,2,2;36374,3,2;36464,1,4;36464,2,4;36464,3,4;36481,1,1;36482,3,1;36485,2,1;36508,1,4;36508,2,4;36508,3,4;36553,1,1;36553,3,1;36554,2,1;36591,2,4;36591,3,4;36597,1,4;36699,1,4;36699,2,4;36699,3,4;36715,1,2;36716,2,2;36717,1,2;36717,3,2;36719,2,2;36719,3,2;36785,1,1;36785,3,1;36786,2,1;36821,1,8;36821,2,8;36821,3,8;36857,1,1;36857,2,1;36857,3,1;36940,1,4;36940,2,4;36940,3,4;36968,1,2;36968,2,2;36968,3,2;37039,2,4;37039,3,4;37041,1,4;37102,1,4;37102,2,4;37102,3,4;37199,1,4;37199,2,4;37199,3,4;37225,1,1;37225,2,1;37225,3,1;37260,2,8;37260,3,8;37263,1,8;37291,1,2;37291,2,2;37318,1,4;37318,2,4;37318,3,4;37339,3,2;37474,2,4;37474,3,4;37477,1,4;37498,1,1;37498,2,1;37498,3,1;37564,2,2;37564,3,2;37568,1,2;37632,1,8;37632,2,8;37632,3,8;37688,1,4;37688,2,4;37688,3,4;37712,1,1;37712,2,1;37712,3,1;37777,1,2;37777,2,2;37777,3,2;37845,1,8;37845,2,8;37845,3,8;37933,1,4;37933,2,4;37933,3,4;37982,1,4;37982,2,4;37982,3,4;38041,1,8;38041,2,8;38041,3,8;38097,1,4;38097,2,4;38097,3,4;38162,1,4;38162,2,4;38162,3,4;38192,1,1;38192,2,1;38192,3,1;38257,1,2;38257,2,2;38257,3,2;38325,1,8;38325,2,8;38325,3,8;38344,1,1;38344,2,1;38344,3,1;38409,1,2;38409,2,2;38409,3,2;38477,2,8;38477,3,8;38480,1,8;38497,1,1;38497,2,1;38497,3,1;38562,1,2;38562,2,2;38562,3,2;38630,1,8;38630,2,8;38630,3,8;38650,1,1;38650,2,1;38650,3,1;38681,1,4;38681,2,4;38681,3,4;38741,1,4;38741,2,4;38741,3,4;38761,1,2;38761,2,2;38761,3,2;38764,1,1;38764,2,1;38764,3,1;38829,1,2;38829,2,2;38829,3,2;38897,2,8;38897,3,8;38901,1,8;38942,1,4;38942,2,4;38942,3,4;39001,2,8;39001,3,8;39005,1,8;39021,1,2;39021,2,2;39021,3,2;39067,1,1;39067,2,1;39067,3,1;39070,1,1;39070,2,1;39070,3,1;39110,2,2;39111,1,2;39111,2,2;39111,3,2;39112,1,2;39112,3,2;39134,1,4;39134,2,4;39134,3,4;39146,1,1;39146,2,1;39146,3,1;39189,1,1;39191,2,1;39191,3,1;39199,2,2;39200,1,2;39218,1,4;39218,2,4;39229,3,2;39235,1,2;39235,3,2;39236,2,2;39308,2,4;39308,3,4;39310,1,4;39357,1,4;39357,2,4;39357,3,4;39373,2,1;39373,3,1;39374,1,1;39415,1,8;39415,2,8;39415,3,8;39471,1,4;39471,2,4;39471,3,4;39501,1,1;39501,2,1;39501,3,1;39566,1,2;39566,2,2;39566,3,2;39634,1,8;39634,2,8;39634,3,8;39690,1,4;39690,2,4;39690,3,4;39755,2,2;39755,3,2;39758,1,2;39820,1,1;39820,2,1;39820,3,1;39904,1,1;39904,2,1;39904,3,1;39969,1,2;39969,2,2;39969,3,2;40037,1,8;40037,2,8;40037,3,8;40099,1,1;40099,2,1;40099,3,1;40220,2,2;40220,3,2;40222,1,2;40288,1,8;40288,2,8;40288,3,8;40308,1,2;40308,2,2;40308,3,2;40373,1,1;40373,2,1;40373,3,1;40376,1,1;40376,2,1;40376,3,1;40439,3,2;40440,1,2;40440,3,2;40441,1,2;40443,2,2;40444,2,2;40475,1,4;40475,2,4;40475,3,4;40493,3,1;40494,1,1;40494,2,1;40534,1,8;40534,2,8;40534,3,8;40590,1,4;40590,2,4;40590,3,4;40613,1,1;40613,2,1;40613,3,1;40679,1,2;40679,2,2;40679,3,2;40682,1,2;40682,2,2;40682,3,2;40744,2,1;40745,2,1;40745,3,1;40746,3,1;40748,1,1;40749,1,1;40762,1,2;40764,2,2;40765,3,2;40791,1,4;40791,2,4;40791,3,4;40821,1,2;40821,2,2;40821,3,2;40886,1,1;40886,2,1;40886,3,1;40889,1,1;40889,2,1;40889,3,1;40951,2,2;40952,1,2;40952,3,2;40953,2,2;40955,1,2;40955,3,2;40970,1,1;40970,2,1;40971,3,1;40999,2,4;40999,3,4;41003,1,4;41063,1,8;41063,2,8;41063,3,8;41083,1,2;41083,2,2;41083,3,2;41148,1,1;41148,2,1;41148,3,1;41151,1,1;41151,2,1;41151,3,1;41213,1,2;41214,1,2;41214,2,2;41216,2,2;41216,3,2;41217,3,2;41290,1,1;41290,2,1;41290,3,1;41293,1,1;41293,2,1;41293,3,1;41356,1,2;41356,2,2;41357,1,2;41357,2,2;41359,3,2;41361,3,2;41391,1,4;41391,2,4;41391,3,4;41490,1,4;41490,2,4;41490,3,4;41507,2,1;41509,3,1;41518,1,1;41548,1,8;41548,2,8;41548,3,8;41604,1,4;41604,2,4;41604,3,4;41762,1,4;41762,2,4;41762,3,4;41826,1,8;41826,2,8;41826,3,8;41889,3,1;41892,1,1;41895,2,1;42010,1,2;42010,2,2;42010,3,2;42013,1,2;42013,2,2;42013,3,2;42076,3,1;42077,1,1;42077,2,1;42078,1,1;42078,3,1;42079,2,1;42093,1,2;42094,3,2;42096,2,2;42122,1,4;42122,2,4;42122,3,4;42153,3,1;42157,2,1;42158,1,1;42242,1,4;42242,2,4;42242,3,4;42273,1,2;42273,3,2;42275,2,2;42302,1,4;42302,2,4;42302,3,4;42401,1,4;42401,2,4;42401,3,4;42432,1,1;42432,2,1;42434,3,1;42515,1,4;42515,2,4;42515,3,4;42564,1,4;42564,2,4;42564,3,4;42580,1,2;42580,3,2;42581,2,2;42623,1,8;42623,2,8;42623,3,8;42643,1,1;42643,2,1;42643,3,1;42708,1,2;42708,2,2;42708,3,2;42775,1,8;42775,2,8;42775,3,8;42864,1,4;42864,2,4;42864,3,4;42924,1,4;42924,2,4;42924,3,4;42948,1,1;42948,2,1;42948,3,1;43013,1,2;43013,2,2;43013,3,2;43081,2,8;43081,3,8;43085,1,8;43101,1,1;43101,2,1;43101,3,1;43166,1,2;43166,2,2;43166,3,2;43234,1,8;43234,2,8;43234,3,8;43253,1,2;43253,2,2;43253,3,2;43319,1,1;43319,2,1;43319,3,1;43322,1,1;43322,2,1;43322,3,1;43385,2,2;43386,3,2;43387,2,2;43387,3,2;43388,1,2;43390,1,2;43395,1,1;43395,2,1;43395,3,1;43488,1,1;43488,2,1;43488,3,1;43523,1,8;43523,2,8;43523,3,8;43579,1,4;43579,2,4;43579,3,4;43600,1,2;43601,2,2;43603,3,2;43639,1,4;43639,2,4;43639,3,4;43668,1,1;43668,2,1;43668,3,1;43733,1,2;43733,2,2;43733,3,2;43801,1,8;43801,2,8;43801,3,8;43857,1,4;43857,2,4;43857,3,4;43886,1,1;43886,2,1;43886,3,1;43921,2,8;43921,3,8;43924,1,8;43977,1,4;43977,2,4;43977,3,4;43997,2,2;43998,3,2;44002,1,2;44037,1,4;44037,2,4;44037,3,4;44106,2,8;44106,3,8;44113,1,8;44126,1,1;44126,2,1;44126,3,1;44157,1,4;44157,2,4;44157,3,4;44191,2,2;44217,1,4;44217,2,4;44217,3,4;44237,1,2;44240,3,2;44277,1,4;44277,2,4;44277,3,4;44301,1,1;44301,2,1;44301,3,1;44366,1,2;44366,2,2;44366,3,2;44434,1,8;44434,2,8;44434,3,8;44496,2,1;44497,3,1;44500,1,1;44642,1,4;44642,2,4;44642,3,4;44666,1,2;44666,2,2;44666,3,2;44735,2,4;44735,3,4;44737,1,4;44800,3,1;44801,1,1;44802,2,1;44882,1,4;44882,2,4;44882,3,4;44900,2,2;44902,1,2;44902,3,2;44926,1,4;44926,2,4;44926,3,4;44933,1,2;44934,2,2;44937,3,2;44970,1,4;44970,2,4;44970,3,4;45003,3,1;45004,2,1;45006,1,1;45012,1,1;45012,2,1;45012,3,1;45089,2,2;45089,3,2;45092,1,2;45192,1,8;45192,2,8;45192,3,8;45259,2,4;45259,3,4;45262,1,4;45340,1,8;45340,2,8;45340,3,8;45425,1,8;45425,2,8;45425,3,8;45550,1,4;45550,2,4;45550,3,4;45590,2,1;45590,3,1;45593,1,1;45707,2,4;45707,3,4;45711,1,4;45744,1,2;45744,2,2;45744,3,2;45773,1,4;45773,2,4;45773,3,4;45850,1,8;45850,2,8;45850,3,8;45882,3,2;45883,1,2;45884,2,2;45911,1,4;45911,2,4;45911,3,4;46015,3,1;46016,2,1;46018,1,1;46054,1,4;46054,2,4;46054,3,4;46099,1,1;46099,2,1;46099,3,1;46135,1,8;46135,2,8;46135,3,8;46171,3,2;46173,1,2;46173,2,2;46208,1,4;46208,2,4;46208,3,4;46270,1,4;46270,2,4;46270,3,4;46343,1,8;46343,2,8;46343,3,8;46375,1,1;46375,2,1;46375,3,1;46401,1,4;46401,2,4;46401,3,4;46522,1,4;46522,2,4;46522,3,4;46617,1,2;46617,2,2;46617,3,2;46695,1,4;46695,2,4;46695,3,4;46754,1,4;46754,2,4;46754,3,4;46823,2,8;46823,3,8;46827,1,8;46878,1,4;46878,2,4;46878,3,4;46946,1,8;46946,2,8;46946,3,8;47107,1,4;47107,2,4;47107,3,4;47174,1,8;47174,2,8;47174,3,8;47203,2,1;47203,3,1;47205,1,1;47300,1,4;47300,2,4;47300,3,4;47331,1,2;47331,2,2;47331,3,2;47334,1,2;47334,2,2;47334,3,2;47404,2,1;47404,3,1;47407,2,1;47407,3,1;47408,1,1;47411,1,1;47473,1,2;47473,3,2;47474,2,2;47475,2,2;47476,3,2;47478,1,2;47489,1,1;47489,2,1;47489,3,1;47590,3,1;47616,3,4;47636,1,1;47636,2,1;47698,1,2;47698,2,2;47698,3,2;47839,1,8;47839,2,8;47839,3,8;47904,3,1;47905,2,1;47906,1,1;47992,1,4;47992,2,4;47992,3,4;48009,2,2;48009,3,2;48013,1,2;48053,2,8;48053,3,8;48054,1,8;48076,1,1;48076,2,1;48076,3,1;48123,1,2;48123,2,2;48123,3,2;48126,1,2;48126,2,2;48126,3,2;48168,1,1;48168,2,1;48168,3,1;48171,2,1;48171,3,1;48173,1,1;48180,1,2;48180,3,2;48181,2,2;48200,1,4;48200,2,4;48200,3,4;48264,1,4;48264,2,4;48264,3,4;48309,3,1;48310,2,1;48311,1,1;48427,1,2;48427,2,2;48427,3,2;48496,2,8;48496,3,8;48500,1,8;48552,1,4;48552,2,4;48552,3,4;48622,1,8;48622,2,8;48622,3,8;48678,1,4;48678,2,4;48678,3,4;48707,1,1;48707,2,1;48707,3,1;48772,1,2;48772,2,2;48772,3,2;48840,1,8;48840,2,8;48840,3,8;48896,1,4;48896,2,4;48896,3,4;48920,1,1;48920,2,1;48920,3,1;48985,1,2;48985,2,2;48985,3,2;49052,2,8;49052,3,8;49053,1,8;49072,2,1;49072,3,1;49076,1,1;49137,1,2;49137,2,2;49137,3,2;49205,1,8;49205,2,8;49205,3,8;49225,1,1;49225,2,1;49225,3,1;49290,1,2;49290,2,2;49290,3,2;49293,1,2;49293,2,2;49293,3,2;49356,2,1;49357,1,1;49357,3,1;49361,2,1;49361,3,1;49367,2,2;49367,3,2;49552,1,1;49552,2,1;49552,3,1;49587,1,8;49587,2,8;49587,3,8;49617,3,2;49643,1,4;49643,2,4;49643,3,4;49663,1,2;49664,2,2;49736,1,4;49736,2,4;49736,3,4;49894,2,1;49895,3,1;49896,1,1;49976,1,4;49976,2,4;49976,3,4;49992,3,2;49996,2,2;50000,1,2;50034,1,8;50034,2,8;50034,3,8;50090,1,4;50090,2,4;50090,3,4;50254,1,1;50254,3,1;50257,2,1;50318,1,2;50318,2,2;50318,3,2;50347,1,4;50347,2,4;50347,3,4;50411,2,8;50411,3,8;50415,1,8;50430,1,1;50430,2,1;50430,3,1;50496,1,2;50496,2,2;50496,3,2;50499,1,2;50499,2,2;50499,3,2;50562,1,1;50562,2,1;50562,3,1;50563,1,1;50563,2,1;50565,3,1;50572,1,2;50572,2,2;50572,3,2;50575,1,2;50575,2,2;50575,3,2;50638,1,1;50638,2,1;50638,3,1;50641,1,1;50641,2,1;50641,3,1;50703,2,2;50703,3,2;50704,1,2;50704,2,2;50704,3,2;50705,1,2;50749,1,8;50749,2,8;50749,3,8;50779,1,1;50779,2,1;50779,3,1;50805,1,4;50805,2,4;50805,3,4;50964,3,1;50968,1,1;50969,2,1;51020,1,2;51020,2,2;51020,3,2;51088,1,4;51088,2,4;51088,3,4;51112,1,2;51112,2,2;51112,3,2;51178,1,1;51178,2,1;51178,3,1;51181,1,1;51181,2,1;51181,3,1;51243,2,2;51245,3,2;51246,3,2;51248,2,2;51250,1,2;51253,1,2;51285,1,4;51285,2,4;51285,3,4;51345,1,4;51345,2,4;51345,3,4;51364,1,1;51364,2,1;51364,3,1;51381,1,1;51381,2,1;51386,3,1;51459,1,4;51459,2,4;51459,3,4;51508,1,4;51508,2,4;51508,3,4;51525,3,2;51526,2,2;51529,1,2;51552,2,4;51552,3,4;51553,1,4;51590,2,2;51591,1,2;51591,3,2;51623,1,4;51623,2,4;51623,3,4;51640,2,1;51640,3,1;51646,1,1;51682,1,8;51682,2,8;51682,3,8;51727,1,4;51727,2,4;51727,3,4;51785,1,8;51785,2,8;51785,3,8;51805,1,2;51805,2,2;51805,3,2;51870,1,1;51870,2,1;51870,3,1;51873,1,1;51873,2,1;51873,3,1;51936,3,2;51937,2,2;51937,3,2;51938,2,2;51942,1,2;51972,2,4;51972,3,4;51988,3,1;51989,1,1;51989,2,1;51989,3,1;51993,2,1;52052,2,2;52053,1,2;52055,3,2;52081,1,4;52081,2,4;52081,3,4;52112,2,2;52112,3,2;52113,1,2;52201,2,4;52201,3,4;52205,1,4;52232,1,1;52234,2,1;52236,3,1;52343,1,4;52343,2,4;52343,3,4;52402,1,8;52402,2,8;52402,3,8;52447,2,4;52447,3,4;52450,1,4;52505,1,8;52505,2,8;52505,3,8;52532,2,2;52532,3,2;52533,1,2;52616,1,4;52616,2,4;52616,3,4;52705,1,1;52705,2,1;52705,3,1;52772,1,8;52772,2,8;52772,3,8;52861,1,4;52861,2,4;52861,3,4;52885,1,1;52885,2,1;52885,3,1;52916,2,4;52916,3,4;52917,1,4;52976,1,4;52976,2,4;52976,3,4;52995,2,2;52996,3,2;53000,1,2;53000,2,2;53000,3,2;53003,1,2;53065,1,1;53065,2,1;53065,3,1;53068,1,1;53068,2,1;53068,3,1;53130,1,2;53130,3,2;53131,1,2;53132,3,2;53140,2,2;53141,2,1;53146,2,2;53147,2,1;53206,1,1;53206,2,2;53206,3,1;53274,1,8;53274,2,8;53274,3,8;53294,1,1;53294,2,1;53294,3,1;53360,1,2;53360,2,2;53360,3,2;53363,1,2;53363,2,2;53363,3,2;53425,3,1;53426,2,1;53426,3,1;53427,2,1;53429,1,1;53430,1,1;53436,1,2;53436,2,2;53436,3,2;53439,1,2;53439,2,2;53439,3,2;53501,1,1;53501,2,1;53501,3,1;53504,1,1;53504,2,1;53504,3,1;53566,1,2;53568,3,2;53569,3,2;53571,1,2;53571,2,2;53572,2,2;53585,2,1;53587,1,1;53588,3,1;53614,1,4;53614,2,4;53614,3,4;53639,2,1;53639,3,1;53640,1,1;53728,1,4;53728,2,4;53728,3,4;53759,2,2;53760,3,2;53788,2,4;53788,3,4;53792,1,4;53810,1,2;53854,1,4;53854,2,4;53854,3,4;53879,1,1;53880,2,1;53881,3,1;53968,1,4;53968,2,4;53968,3,4;54027,1,8;54027,2,8;54027,3,8;54083,1,4;54083,2,4;54083,3,4;54103,3,2;54104,2,2;54105,1,2;54328,1,4;54328,2,4;54328,3,4;54378,1,4;54378,2,4;54378,3,4;54436,1,8;54436,2,8;54436,3,8;54464,2,1;54465,3,1;54470,1,1;54547,1,4;54547,2,4;54547,3,4;54605,1,8;54605,2,8;54605,3,8;54661,1,4;54661,2,4;54661,3,4;54681,1,2;54681,2,2;54681,3,2;54721,1,4;54721,2,4;54721,3,4;54781,1,4;54781,2,4;54781,3,4;54939,1,4;54939,2,4;54939,3,4;54963,1,1;54963,2,1;54963,3,1;54994,2,4;54994,3,4;54996,1,4;55028,1,2;55028,3,2;55054,1,4;55054,2,4;55054,3,4;55074,2,2;55090,1,2;55090,3,2;55091,2,2;55168,1,4;55168,2,4;55168,3,4;55199,1,1;55199,3,1;55228,1,4;55228,3,4;55247,2,1;55321,1,4;55321,2,4;55321,3,4;55370,2,4;55370,3,4;55373,1,4;55429,1,8;55429,2,8;55429,3,8;55449,1,1;55449,2,1;55449,3,1;55479,1,4;55479,2,4;55479,3,4;55539,1,4;55539,2,4;55539,3,4;55559,1,2;55559,2,2;55559,3,2;55563,1,1;55563,2,1;55563,3,1;55628,1,2;55628,2,2;55628,3,2;55696,2,8;55696,3,8;55697,1,8;55785,1,4;55785,2,4;55785,3,4;55845,1,4;55845,2,4;55845,3,4;55869,1,1;55869,2,1;55869,3,1;55934,1,2;55934,2,2;55934,3,2;55937,1,2;55937,2,2;55937,3,2;55999,3,1;56000,3,1;56001,2,1;56002,2,1;56003,1,1;56005,1,1;56041,1,4;56041,2,4;56041,3,4;56101,1,4;56101,2,4;56101,3,4;56122,1,2;56122,2,2;56122,3,2;56125,1,1;56125,2,1;56125,3,1;56160,1,8;56160,2,8;56160,3,8;56216,1,4;56216,2,4;56216,3,4;56235,2,2;56235,3,2;56236,1,2;56265,1,4;56265,2,4;56265,3,4;56323,1,8;56323,2,8;56323,3,8;56343,1,1;56343,2,1;56343,3,1;56366,1,4;56366,2,4;56366,3,4;56410,1,4;56410,2,4;56410,3,4;56419,2,2;56419,3,2;56450,3,2;56451,1,2;56454,2,2;56455,1,1;56460,1,2;56465,1,1;56469,1,2;56470,1,1;56474,1,2;56475,1,8;56486,1,2;56487,1,1;56487,2,1;56487,3,1;56549,1,1;56549,2,1;56549,3,1;56570,1,4;56570,2,4;56570,3,4;56619,1,4;56619,2,4;56619,3,4;56640,1,2;56640,3,2;56641,2,2;56657,1,2;56658,3,2;56660,2,2;56734,1,4;56734,2,4;56734,3,4;56765,3,1;56767,1,1;56767,2,1;56794,2,4;56794,3,4;56795,1,4;56863,1,8;56863,2,8;56863,3,8;56919,1,4;56919,2,4;56919,3,4;56985,2,4;56985,3,4;56989,1,4;57045,1,4;57045,2,4;57045,3,4;57069,1,1;57069,2,1;57069,3,1;57134,1,2;57134,2,2;57134,3,2;57202,1,8;57202,2,8;57202,3,8;57263,3,1;57266,1,1;57266,2,1;57327,3,2;57328,1,2;57328,2,2;57356,1,4;57356,2,4;57356,3,4;57421,1,4;57421,2,4;57421,3,4;57491,1,8;57491,2,8;57491,3,8;57523,2,1;57523,3,1;57524,1,1;57601,1,4;57601,2,4;57601,3,4;57625,1,2;57625,2,2;57625,3,2;57694,1,4;57694,2,4;57694,3,4;57758,2,8;57758,3,8;57761,1,8;57803,1,4;57803,2,4;57803,3,4;57862,2,8;57862,3,8;57865,1,8;57923,2,1;57923,3,1;57929,1,1;57980,1,2;57980,2,2;57980,3,2;57983,1,2;57983,2,2;57983,3,2;58045,1,1;58045,2,1;58045,3,1;58048,1,1;58048,2,1;58048,3,1;58110,3,2;58111,2,2;58112,1,2;58112,2,2;58113,1,2;58114,3,2;58130,1,1;58130,2,1;58130,3,1;58158,2,4;58158,3,4;58161,1,4;58187,2,1;58187,3,1;58191,1,1;58252,1,2;58252,2,2;58252,3,2;58320,1,8;58320,2,8;58320,3,8;58340,1,1;58340,2,1;58340,3,1;58374,2,8;58374,3,8;58377,1,8;58430,1,4;58430,2,4;58430,3,4;58450,1,2;58450,2,2;58450,3,2;58454,1,1;58454,2,1;58454,3,1;58485,1,4;58485,2,4;58485,3,4;58519,1,2;58545,1,4;58545,2,4;58545,3,4;58564,2,2;58565,3,2;58569,1,1;58569,2,1;58569,3,1;58634,1,2;58634,2,2;58634,3,2;58702,1,8;58702,2,8;58702,3,8;58762,1,8;58762,2,8;58762,3,8;58822,1,8;58822,2,8;58822,3,8;58841,1,1;58841,2,1;58841,3,1;58906,1,2;58906,2,2;58906,3,2;58974,1,8;58974,2,8;58974,3,8;59030,1,4;59030,2,4;59030,3,4;59060,1,1;59060,2,1;59060,3,1;59090,1,4;59090,2,4;59090,3,4;59150,1,4;59150,2,4;59150,3,4;59170,1,2;59170,2,2;59171,3,2;59174,1,1;59174,2,1;59174,3,1;59240,1,2;59240,2,2;59240,3,2;59243,1,2;59243,2,2;59243,3,2;59305,1,1;59305,2,1;59305,3,1;59308,1,1;59308,3,1;59309,2,1;59323,2,2;59323,3,2;59324,1,2;59356,1,8;59356,2,8;59356,3,8;59401,2,4;59401,3,4;59403,1,4;59460,1,8;59460,2,8;59460,3,8;59520,1,8;59520,2,8;59520,3,8;59540,1,1;59540,2,1;59540,3,1;59605,1,2;59605,2,2;59605,3,2;59672,2,8;59672,3,8;59680,1,8;59699,1,1;59700,3,1;59701,2,1;59783,2,4;59783,3,4;59784,1,4;59807,1,2;59807,2,2;59807,3,2;59968,2,4;59968,3,4;59972,1,4;60028,1,4;60028,2,4;60028,3,4;60059,1,1;60059,2,1;60059,3,1;60148,2,4;60148,3,4;60152,1,4;60237,1,2;60237,2,2;60237,3,2;60305,1,8;60305,2,8;60305,3,8;60361,1,4;60361,2,4;60361,3,4;60416,1,4;60416,2,4;60416,3,4;60474,2,8;60474,3,8;60478,1,8;60494,1,1;60494,2,1;60494,3,1;60559,1,2;60559,2,2;60559,3,2;60627,1,8;60627,2,8;60627,3,8;60832,1,1;60832,2,1;60832,3,1;60897,1,2;60897,2,2;60897,3,2;60965,2,8;60965,3,8;60967,1,8;60992,2,2;60993,1,2;60993,3,2;61076,1,4;61076,2,4;61076,3,4;61100,1,1;61100,2,1;61100,3,1;61168,1,4;61168,2,4;61168,3,4;61228,1,4;61228,2,4;61228,3,4;61327,1,4;61327,2,4;61327,3,4;61387,1,4;61387,2,4;61387,3,4;61479,1,4;61479,2,4;61479,3,4;61503,1,1;61503,2,1;61503,3,1;61538,1,8;61538,2,8;61538,3,8;61568,2,2;61594,1,4;61594,2,4;61594,3,4;61614,1,2;61614,3,2;61618,1,1;61618,2,1;61618,3,1;61683,2,2;61683,3,2;61684,1,2;61751,1,8;61751,2,8;61751,3,8;61992,1,4;61992,2,4;61992,3,4;62018,1,1;62018,2,1;62018,3,1;62107,2,4;62107,3,4;62111,1,4;62196,1,2;62196,2,2;62196,3,2;62263,1,8;62263,2,8;62263,3,8;62323,1,8;62323,2,8;62323,3,8;62379,2,4;62379,3,4;62380,1,4;62439,2,4;62439,3,4;62442,1,4;62510,1,2;62510,2,2;62513,3,2;62598,1,4;62598,2,4;62598,3,4;62658,1,4;62658,2,4;62658,3,4;62679,2,1;62679,3,1;62680,1,1;62774,2,1;62774,3,1;62778,1,1;62805,1,4;62805,2,4;62805,3,4;62839,1,2;62839,2,2;62865,1,4;62865,2,4;62865,3,4;62914,3,4;62930,1,1;62931,2,1;62994,3,2;62995,1,2;62997,2,2;63023,2,4;63023,3,4;63025,1,4;63140,1,1;63140,2,1;63140,3,1;63205,1,2;63205,2,2;63205,3,2;63272,1,8;63272,2,8;63272,3,8;63328,1,4;63328,2,4;63328,3,4;63394,1,4;63394,2,4;63394,3,4;63465,1,1;63466,2,1;63466,3,1;63521,1,2;63521,2,2;63521,3,2;63590,1,4;63590,2,4;63590,3,4;63654,1,8;63654,2,8;63654,3,8;63710,1,4;63710,2,4;63710,3,4;63780,1,8;63780,2,8;63780,3,8;63840,1,8;63840,2,8;63840,3,8;63885,1,4;63885,2,4;63885,3,4;63943,1,8;63943,2,8;63943,3,8;63963,1,2;63963,2,2;63963,3,2;64009,1,1;64009,2,1;64009,3,1;64012,2,1;64012,3,1;64016,1,1;64054,3,2;64055,1,2;64055,2,2;64055,3,2;64056,1,2;64061,2,2;64065,2,1;64066,3,1;64084,1,4;64084,2,4;64084,3,4;64102,1,1;64128,1,4;64128,2,4;64128,3,4;64200,1,8;64200,2,8;64200,3,8;64214,2,1;64214,3,1;64218,1,1;64237,2,4;64237,3,4;64246,1,4;64281,2,4;64281,3,4;64290,2,2;64290,3,2;64305,1,4;64325,1,2;64339,2,4;64339,3,4;64398,1,4;64411,2,8;64411,3,8;64448,1,4;64448,2,4;64448,3,4;64501,2,4;64501,3,4;64505,1,8;64561,1,4;64567,2,4;64567,3,4;64621,1,4;64639,2,1;64639,3,1;64687,1,4;64694,2,2;64694,3,2;64757,1,1;64763,2,4;64763,3,4;64812,2,4;64812,3,4;64814,1,2;64871,2,8;64871,3,8;64885,1,4;64932,1,4;64991,1,8;65026,2,2;65027,3,2;65147,1,2;65172,2,4;65172,3,4;65227,2,4;65227,3,4;65287,2,4;65287,3,4;65292,1,4;65306,2,1;65306,3,1;65347,1,4;65347,2,4;65347,3,4;65396,2,4;65396,3,4;65407,1,4;65426,1,1;65454,2,8;65454,3,8;65467,1,4;65510,2,4;65510,3,4;65516,1,4;65576,1,8;65603,2,4;65603,3,4;65630,1,4;65652,2,4;65652,3,4;65711,2,8;65711,3,8;65723,1,4;65730,2,1;65730,3,1;65753,2,4;65753,3,4;65772,1,4;65797,2,4;65797,3,4;65807,3,2;65809,2,2;65831,1,8;65854,1,1;65855,2,4;65855,3,4;65871,2,1;65871,3,1;65881,1,4;65899,2,4;65899,3,4;65941,1,4;65943,2,4;65943,3,4;65950,2,2;65950,3,2;65953,2,2;65953,3,2;65962,1,2;65998,2,1;65998,3,1;66001,2,1;66001,3,1;66034,1,4;66058,1,1;66063,2,2;66064,2,2;66064,3,2;66065,3,2;66088,1,4;66140,2,1;66140,3,1;66143,2,1;66143,3,1;66149,1,4;66169,1,2;66176,1,2;66205,2,2;66205,3,2;66207,2,2;66211,3,2;66238,1,1;66241,1,1;66303,1,2;66304,1,2;66307,2,4;66307,3,4;66380,1,1;66383,1,1;66396,2,1;66396,3,1;66399,2,1;66399,3,1;66448,1,2;66454,1,2;66462,2,2;66463,3,2;66464,2,2;66465,3,2;66547,1,4;66563,2,4;66563,3,4;66595,3,1;66597,2,1;66636,1,1;66639,1,1;66705,1,2;66707,1,2;66803,1,4;66808,2,4;66808,3,4;66839,1,1;67048,1,4;67141,2,4;67141,3,4;67200,2,8;67200,3,8;67256,2,4;67256,3,4;67381,1,4;67446,1,8;67496,1,4;67512,2,2;67512,3,2;67634,2,1;67634,3,1;67637,2,1;67637,3,1;67702,2,2;67702,3,2;67703,2,2;67704,3,2;67753,1,2;67776,2,1;67776,3,1;67843,2,8;67843,3,8;67863,2,1;67863,3,1;67878,1,1;67881,1,1;67894,2,4;67894,3,4;67928,3,2;67942,1,2;67945,1,2;67954,2,4;67954,3,4;67975,2,2;68016,1,1;68018,2,8;68018,3,8;68038,2,1;68038,3,1;68083,1,8;68103,2,2;68103,3,2;68107,1,1;68134,1,4;68168,1,2;68171,2,8;68171,3,8;68194,1,4;68216,2,4;68216,3,4;68258,1,8;68274,2,8;68274,3,8;68278,1,1;68344,1,2;68363,2,4;68363,3,4;68387,2,1;68387,3,1;68411,1,8;68418,2,4;68418,3,4;68457,1,4;68478,2,4;68478,3,4;68498,2,2;68499,3,2;68514,1,8;68570,2,4;68570,3,4;68594,2,1;68594,3,1;68608,1,4;68627,1,1;68629,2,8;68629,3,8;68658,1,4;68659,3,2;68685,2,4;68685,3,4;68709,3,1;68718,1,4;68738,1,2;68743,2,8;68743,3,8;68799,2,4;68799,3,4;68810,1,4;68820,3,2;68821,2,2;68823,2,1;68823,3,1;68834,1,1;68858,2,8;68858,3,8;68875,1,8;68914,2,4;68914,3,4;68925,1,4;68934,3,2;68936,2,2;68938,2,2;68938,3,2;68944,1,2;68949,1,1;68983,1,8;69003,2,1;69003,3,1;69006,2,1;69006,3,1;69039,1,4;69061,1,2;69063,1,1;69068,2,2;69069,3,2;69071,3,2;69072,2,2;69080,2,1;69080,3,1;69098,1,8;69128,1,2;69148,2,4;69148,3,4;69154,1,4;69172,2,1;69172,3,1;69178,1,2;69237,2,2;69237,3,2;69243,1,1;69246,1,1;69305,2,8;69305,3,8;69310,1,2;69311,1,2;69320,1,1;69361,2,4;69361,3,4;69388,1,4;69412,1,1;69427,2,1;69428,3,1;69477,1,2;69483,2,2;69483,3,2;69545,1,8;69601,1,4;69619,2,1;69619,3,1;69667,1,1;69723,1,2;69740,2,2;69740,3,2;69743,2,2;69743,3,2;69805,2,1;69806,2,1;69808,3,1;69810,3,1;69841,2,4;69841,3,4;69858,2,2;69858,3,2;69861,1,1;69900,2,8;69900,3,8;69956,2,4;69956,3,4;69980,1,2;69983,1,2;70020,2,8;70020,3,8;70045,1,1;70046,1,1;70081,2,1;70081,3,1;70084,1,4;70101,1,2;70140,1,8;70196,1,4;70203,2,2;70203,3,2;70206,2,2;70206,3,2;70260,1,8;70269,2,1;70269,3,1;70270,2,1;70271,3,1;70325,1,1;70370,2,4;70370,3,4;70401,2,2;70401,3,2;70430,2,4;70430,3,4;70443,1,2;70446,1,2;70509,1,1;70512,1,1;70594,2,1;70596,3,1;70613,1,4;70642,1,2;70658,3,2;70660,2,2;70670,1,4;70691,2,8;70691,3,8;70779,2,4;70779,3,4;70803,2,2;70803,3,2;70834,1,1;70869,2,1;70869,3,1;70872,2,1;70872,3,1;70900,1,2;70931,1,8;70937,2,2;70937,3,2;70938,3,2;70942,2,2;70952,3,1;70981,2,4;70981,3,4;71003,2,1;71023,1,4;71043,1,2;71109,1,1;71112,1,1;71139,2,4;71139,3,4;71179,1,2;71180,1,2;71210,2,2;71210,3,2;71226,1,4;71241,1,1;71274,3,1;71275,2,1;71379,1,4;71400,2,8;71400,3,8;71432,3,1;71433,2,1;71451,1,2;71510,2,4;71510,3,4;71515,1,1;71559,2,4;71559,3,4;71575,3,2;71577,3,2;71580,2,2;71582,2,2;71632,2,1;71632,3,1;71644,1,8;71672,1,1;71751,1,4;71794,2,4;71794,3,4;71799,1,4;71816,1,2;71823,1,1;71827,1,2;71828,1,1;71831,1,2;71832,1,1;71836,1,2;71837,1,1;71840,1,2;71841,1,1;71845,1,2;71846,1,1;71849,1,2;71855,1,8;71858,2,8;71858,3,8;71870,1,2;71872,1,1;71918,2,8;71918,3,8;71974,2,4;71974,3,4;72003,2,2;72003,3,2;72034,1,4;72069,2,1;72069,3,1;72072,2,1;72072,3,1;72101,1,8;72134,3,2;72136,2,2;72137,2,2;72138,3,2;72158,1,8;72170,2,4;72170,3,4;72186,2,1;72188,3,1;72214,1,4;72229,2,8;72229,3,8;72243,1,2;72289,2,8;72289,3,8;72309,1,1;72312,1,1;72375,1,2;72376,1,2;72412,1,4;72428,1,1;72470,1,8;72470,2,4;72470,3,4;72529,1,8;72530,2,4;72530,3,4;72560,2,1;72560,3,1;72590,2,4;72590,3,4;72650,2,4;72650,3,4;72670,2,2;72670,3,2;72710,1,4;72743,2,4;72743,3,4;72770,1,4;72774,2,1;72774,3,1;72800,1,1;72830,1,4;72858,2,4;72858,3,4;72881,2,2;72881,3,2;72890,1,4;72914,1,2;72950,2,4;72950,3,4;72983,1,4;73014,1,1;73015,2,2;73015,3,2;73079,2,1;73079,3,1;73098,1,4;73108,2,4;73108,3,4;73121,1,2;73168,2,4;73168,3,4;73191,1,4;73238,2,8;73238,3,8;73255,1,2;73319,1,1;73348,1,4;73352,2,1;73354,3,1;73409,1,4;73441,2,4;73441,3,4;73478,1,8;73530,2,2;73530,3,2;73533,2,2;73533,3,2;73592,1,1;73596,2,1;73597,2,1;73597,3,1;73598,3,1;73614,2,2;73615,3,2;73643,2,4;73643,3,4;73681,1,4;73741,2,4;73741,3,4;73770,1,2;73773,1,2;73847,1,1;73850,1,1;73854,1,2;73862,3,2;73866,2,2;73883,1,4;73948,2,4;73948,3,4;73972,2,1;73972,3,1;73981,1,4;74041,2,4;74041,3,4;74101,1,2;74101,2,4;74101,3,4;74167,2,4;74167,3,4;74188,1,4;74212,1,1;74216,2,4;74216,3,4;74274,2,8;74274,3,8;74281,1,4;74319,2,4;74319,3,4;74341,1,4;74378,2,8;74378,3,8;74405,2,2;74405,3,2;74407,1,4;74456,1,4;74488,2,4;74488,3,4;74514,1,8;74559,1,4;74603,2,4;74603,3,4;74618,1,8;74645,1,2;74658,2,4;74658,3,4;74718,2,4;74718,3,4;74728,1,4;74737,3,1;74738,2,1;74756,2,1;74756,3,1;74832,2,4;74832,3,4;74846,1,4;74881,2,4;74881,3,4;74897,2,2;74897,3,2;74898,1,4;74898,3,2;74899,2,2;74961,1,4;74961,2,1;74961,3,1;74977,1,1;74994,2,8;74994,3,8;74997,1,1;75055,2,1;75055,3,1;75076,1,4;75121,1,4;75137,1,2;75138,2,4;75138,3,4;75139,1,2;75155,2,2;75157,3,2;75182,2,4;75182,3,4;75189,2,2;75192,3,2;75201,1,1;75226,2,4;75226,3,4;75234,1,8;75258,3,1;75259,2,1;75296,1,1;75308,2,4;75308,3,4;75355,2,1;75355,3,1;75378,1,4;75388,2,4;75388,3,4;75402,1,2;75422,1,4;75432,3,2;75433,2,2;75475,2,4;75475,3,4;75476,1,2;75498,1,1;75548,1,4;75563,3,2;75564,2,2;75595,1,1;75628,1,4;75654,3,1;75655,2,1;75659,2,1;75659,3,1;75672,1,2;75715,1,4;75735,2,2;75735,3,2;75803,1,2;75832,2,8;75832,3,8;75865,3,2;75866,2,2;75893,1,1;75895,2,4;75895,3,4;75899,1,1;75975,1,2;76000,3,1;76002,2,1;76072,1,8;76086,2,4;76086,3,4;76105,1,2;76135,1,4;76152,2,4;76152,3,4;76227,2,8;76227,3,8;76239,1,1;76260,2,2;76260,3,2;76288,2,4;76288,3,4;76326,1,4;76387,2,1;76387,3,1;76390,2,1;76390,3,1;76392,1,4;76462,2,2;76462,3,2;76469,1,8;76502,1,2;76528,1,4;76550,2,8;76550,3,8;76583,2,1;76583,3,1;76628,1,1;76630,1,1;76665,2,2;76665,3,2;76668,2,2;76668,3,2;76702,1,2;76736,2,1;76736,3,1;76738,2,1;76739,3,1;76790,1,8;76823,1,1;76830,2,2;76830,3,2;76905,1,2;76908,1,2;76915,2,8;76915,3,8;76945,2,1;76945,3,1;76977,1,1;76979,1,1;77024,2,2;77024,3,2;77027,2,2;77027,3,2;77070,1,2;77093,2,1;77094,2,1;77095,3,1;77096,3,1;77115,3,2;77116,2,2;77155,1,8;77156,2,8;77156,3,8;77185,1,1;77264,1,2;77267,1,2;77331,2,4;77331,3,4;77337,1,1;77338,1,1;77355,1,2;77396,1,8;77410,2,8;77410,3,8;77571,1,4;77584,2,8;77584,3,8;77650,1,8;77751,2,4;77751,3,4;77778,2,2;77778,3,2;77824,1,8;77878,2,4;77878,3,4;77906,2,1;77906,3,1;77909,2,1;77909,3,1;77977,2,2;77977,3,2;77980,2,2;77980,3,2;77991,1,4;78021,1,2;78046,3,1;78049,2,1;78050,3,1;78053,2,1;78118,1,4;78126,2,2;78126,3,2;78146,1,1;78149,1,1;78199,2,8;78199,3,8;78217,1,2;78220,1,2;78222,2,1;78222,3,1;78288,2,2;78288,3,2;78289,1,1;78290,1,1;78360,2,8;78360,3,8;78366,1,2;78407,2,4;78407,3,4;78442,1,8;78462,1,1;78468,2,8;78468,3,8;78526,2,4;78526,3,4;78528,1,2;78576,2,4;78576,3,4;78600,1,8;78636,2,8;78636,3,8;78647,1,4;78682,2,4;78682,3,4;78708,1,8;78741,2,8;78741,3,8;78766,1,4;78797,2,4;78797,3,4;78816,1,4;78829,2,1;78829,3,1;78876,1,8;78917,2,4;78917,3,4;78922,1,4;78981,1,8;79006,2,2;79006,3,2;79038,1,4;79070,1,1;79074,2,8;79074,3,8;79094,2,2;79094,3,2;79157,1,4;79160,2,1;79160,3,1;79163,2,1;79163,3,1;79225,3,2;79226,2,2;79226,3,2;79232,2,2;79246,1,2;79301,2,1;79301,3,1;79304,2,1;79304,3,1;79314,1,8;79334,1,2;79369,2,2;79369,3,2;79370,2,2;79372,3,2;79378,2,1;79378,3,1;79381,2,1;79381,3,1;79400,1,1;79403,1,1;79443,2,2;79443,3,2;79446,2,2;79446,3,2;79468,1,2;79470,1,2;79509,3,1;79511,2,1;79512,2,1;79515,3,1;79541,1,1;79544,1,1;79554,2,8;79554,3,8;79610,2,4;79610,3,4;79611,1,2;79613,1,2;79618,1,1;79621,1,1;79630,2,2;79634,2,1;79683,1,2;79686,1,2;79699,2,2;79699,3,2;79751,1,1;79752,1,1;79767,2,8;79767,3,8;79794,1,8;79794,3,2;79800,2,2;79850,1,4;79870,1,2;79874,1,1;79877,2,4;79877,3,4;79910,2,1;79910,3,1;79939,1,2;80007,1,8;80035,3,1;80037,1,2;80038,2,1;80099,2,2;80101,3,2;80117,1,4;80132,2,8;80132,3,8;80152,1,1;80188,2,4;80188,3,4;80237,2,4;80237,3,4;80275,1,1;80296,2,8;80296,3,8;80316,2,1;80316,3,1;80362,2,2;80362,3,2;80372,1,8;80386,1,2;80407,2,8;80407,3,8;80428,1,4;80444,2,4;80444,3,4;80477,1,4;80523,2,1;80523,3,1;80536,1,8;80556,1,1;80592,2,4;80592,3,4;80602,1,2;80647,1,8;80651,2,8;80651,3,8;80684,1,4;80707,2,4;80707,3,4;80726,2,2;80731,3,2;80763,1,1;80832,1,4;80891,1,8;80916,2,1;80916,3,1;80947,1,4;80947,2,4;80947,3,4;80966,1,2;81007,2,4;81007,3,4;81027,3,2;81028,2,2;81030,2,1;81030,3,1;81061,2,4;81061,3,4;81121,2,4;81121,3,4;81142,2,2;81143,3,2;81156,1,1;81181,2,4;81181,3,4;81188,1,4;81246,3,2;81247,1,4;81247,2,2;81301,1,4;81310,3,1;81313,2,1;81339,2,4;81339,3,4;81363,1,4;81369,2,2;81369,3,2;81385,1,2;81421,1,4;81434,2,1;81434,3,1;81437,2,1;81437,3,1;81489,1,2;81500,3,2;81501,2,2;81501,3,2;81502,2,2;81536,2,4;81536,3,4;81551,1,1;81552,2,1;81552,3,1;81579,1,4;81594,2,8;81594,3,8;81609,1,2;81614,2,2;81614,3,2;81674,1,1;81677,1,1;81680,2,1;81680,3,1;81683,2,1;81683,3,1;81740,1,2;81742,1,2;81746,3,2;81747,2,2;81748,3,2;81751,2,2;81764,2,1;81767,3,1;81779,1,4;81792,1,1;81834,1,8;81854,1,2;81874,2,4;81874,3,4;81920,1,1;81923,1,1;81932,2,8;81932,3,8;81952,2,1;81952,3,1;81985,1,2;81987,1,2;81998,2,2;81998,3,2;82001,2,2;82001,3,2;82003,1,1;82042,2,1;82043,2,1;82045,3,1;82046,3,1;82049,2,2;82049,3,2;82052,2,2;82052,3,2;82093,2,1;82093,3,1;82096,2,1;82096,3,1;82114,1,4;82136,3,2;82138,2,2;82138,3,2;82146,2,2;82171,2,8;82171,3,8;82172,1,8;82179,2,1;82179,3,1;82192,1,1;82238,1,2;82241,1,2;82245,2,4;82245,3,4;82269,2,1;82269,3,1;82282,1,1;82283,1,1;82289,1,2;82292,1,2;82299,2,4;82299,3,4;82333,1,1;82336,1,1;82359,2,4;82359,3,4;82376,1,2;82377,1,2;82379,2,2;82380,3,2;82389,1,1;82408,2,4;82408,3,4;82411,1,8;82467,2,8;82467,3,8;82485,1,4;82509,1,1;82513,3,2;82515,2,2;82541,1,4;82568,2,4;82568,3,4;82599,1,4;82620,1,2;82648,1,4;82660,2,1;82660,3,1;82705,2,8;82705,3,8;82707,1,8;82718,2,2;82718,3,2;82753,1,2;82781,2,1;82781,3,1;82784,2,1;82784,3,1;82808,1,4;82847,3,2;82848,2,2;82849,2,2;82849,3,2;82883,2,4;82883,3,4;82900,1,1;82900,3,1;82902,2,1;82927,2,4;82927,3,4;82934,2,1;82936,3,1;82945,1,8;82958,1,2;82971,2,4;82971,3,4;83021,1,1;83028,1,1;83037,2,4;83037,3,4;83086,1,2;83089,1,2;83123,1,4;83142,1,1;83167,1,4;83175,1,1;83188,2,4;83188,3,4;83211,1,4;83232,2,2;83232,3,2;83275,2,4;83275,3,4;83277,1,4;83320,2,1;83320,3,1;83396,2,2;83397,3,2;83428,1,4;83472,1,2;83494,2,8;83494,3,8;83515,1,4;83560,1,1;83618,2,4;83618,3,4;83636,1,2;83704,2,8;83704,3,8;83734,1,8;83786,2,2;83786,3,2;83858,1,4;83944,1,8;83983,2,4;83983,3,4;84020,3,1;84021,2,1;84022,2,1;84022,3,1;84028,1,2;84095,2,2;84095,3,2;84185,2,8;84185,3,8;84223,1,4;84257,2,4;84257,3,4;84319,2,4;84319,3,4;84335,1,1;84392,2,8;84392,3,8;84428,1,8;84462,2,4;84462,3,4;84498,2,1;84498,3,1;84501,1,4;84563,1,4;84610,2,4;84610,3,4;84632,1,8;84680,2,8;84680,3,8;84702,1,4;84716,3,2;84717,2,2;84738,1,1;84748,2,4;84748,3,4;84820,2,4;84820,3,4;84851,1,4;84897,2,4;84897,3,4;84920,1,8;84958,1,2;84978,2,8;84978,3,8;84988,1,4;85060,1,4;85082,2,4;85082,3,4;85116,2,1;85118,3,1;85137,1,4;85214,2,4;85214,3,4;85222,1,8;85270,2,4;85270,3,4;85287,3,2;85293,2,2;85322,1,4;85336,2,8;85336,3,8;85356,1,1;85362,2,1;85362,3,1;85393,2,4;85393,3,4;85430,2,2;85432,3,2;85457,1,4;85460,2,4;85460,3,4;85490,2,1;85490,3,1;85510,1,4;85528,1,2;85562,2,2;85562,3,2;85565,2,2;85565,3,2;85576,1,8;85602,1,1;85629,2,1;85630,3,1;85631,2,1;85632,3,1;85633,1,4;85671,1,2;85681,2,8;85681,3,8;85700,1,4;85714,3,2;85715,2,2;85730,1,1;85742,2,4;85742,3,4;85802,1,2;85805,1,2;85886,1,1;85887,1,1;85906,2,4;85906,3,4;85921,1,8;85933,2,1;85933,3,1;85954,1,2;85982,1,4;86002,2,2;86002,3,2;86005,2,2;86005,3,2;86068,2,1;86069,3,1;86070,2,1;86071,3,1;86146,1,4;86150,2,2;86150,3,2;86153,2,2;86153,3,2;86172,1,1;86216,2,1;86216,3,1;86218,3,1;86220,2,1;86242,1,2;86245,1,2;86309,1,1;86310,1,1;86322,2,4;86322,3,4;86390,1,2;86393,1,2;86412,2,2;86412,3,2;86457,1,1;86461,1,1;86481,2,8;86481,3,8;86562,1,4;86570,2,4;86570,3,4;86594,2,1;86594,3,1;86629,2,8;86629,3,8;86655,1,2;86685,2,4;86685,3,4;86704,2,2;86708,3,2;86709,2,1;86709,3,1;86721,1,8;86774,2,2;86774,3,2;86810,1,4;86838,1,1;86841,2,8;86841,3,8;86861,2,1;86861,3,1;86877,1,8;86926,2,2;86926,3,2;86929,1,4;86948,1,2;86953,1,1;86994,2,8;86994,3,8;87014,1,2;87014,2,1;87014,3,1;87079,2,2;87079,3,2;87081,1,8;87082,2,2;87082,3,2;87101,1,1;87146,3,1;87147,3,1;87149,2,1;87155,2,1;87163,2,2;87163,3,2;87166,1,2;87234,1,8;87249,2,1;87249,3,1;87254,1,1;87279,2,4;87279,3,4;87319,1,2;87322,1,2;87339,2,4;87339,3,4;87359,3,2;87360,2,2;87363,2,1;87363,3,1;87385,1,1;87386,1,1;87404,1,2;87429,2,2;87429,3,2;87432,2,2;87432,3,2;87489,1,1;87494,2,1;87495,2,1;87497,3,1;87500,3,1;87519,1,4;87530,2,4;87530,3,4;87546,3,2;87547,2,2;87580,1,4;87589,2,8;87589,3,8;87600,1,2;87603,1,1;87619,3,1;87620,2,1;87669,1,2;87672,1,2;87699,2,4;87699,3,4;87731,2,2;87731,3,2;87737,1,1;87738,1,1;87763,2,8;87763,3,8;87770,1,4;87788,1,2;87829,1,8;87852,2,4;87852,3,4;87856,1,1;87912,2,4;87912,3,4;87939,1,4;87970,1,2;87981,2,8;87981,3,8;88001,2,1;88001,3,1;88003,1,8;88066,2,2;88066,3,2;88092,1,4;88134,2,8;88134,3,8;88153,1,4;88154,2,2;88154,3,2;88219,2,1;88219,3,1;88221,1,8;88222,2,1;88222,3,1;88241,1,1;88285,3,2;88288,3,2;88292,2,2;88295,2,2;88308,1,2;88321,2,4;88321,3,4;88338,3,1;88339,2,1;88339,3,1;88341,2,1;88374,1,8;88394,1,2;88402,2,2;88403,3,2;88459,1,1;88462,1,1;88487,2,1;88487,3,1;88528,1,2;88529,1,2;88552,2,2;88552,3,2;88564,1,4;88578,1,1;88582,1,1;88620,2,8;88620,3,8;88639,2,1;88639,3,1;88644,1,2;88705,2,2;88705,3,2;88727,1,1;88772,2,8;88772,3,8;88792,1,2;88860,1,8;88861,2,4;88861,3,4;88879,1,1;88927,2,1;88927,3,1;88945,1,2;89012,1,8;89049,2,2;89049,3,2;89052,2,2;89052,3,2;89101,1,4;89114,2,1;89115,3,1;89116,2,1;89117,3,1;89168,1,1;89216,2,4;89216,3,4;89239,2,2;89239,3,2;89289,1,2;89292,1,2;89308,2,4;89308,3,4;89332,2,1;89332,3,1;89355,1,1;89356,1,1;89397,2,2;89397,3,2;89456,1,4;89465,2,8;89465,3,8;89479,1,2;89510,2,4;89510,3,4;89548,1,4;89569,2,8;89569,3,8;89572,1,1;89589,2,1;89589,3,1;89637,1,2;89654,2,2;89654,3,2;89657,2,2;89657,3,2;89705,1,8;89719,2,1;89720,2,1;89724,3,1;89725,3,1;89750,1,4;89756,2,4;89756,3,4;89772,2,2;89773,2,2;89774,3,2;89775,3,2;89809,1,8;89829,1,1;89894,1,2;89901,1,2;89919,2,4;89919,3,4;89943,2,1;89943,3,1;89946,2,1;89946,3,1;89962,1,1;89963,1,1;89996,1,4;90009,2,2;90009,3,2;90012,1,2;90012,2,2;90012,3,2;90019,1,1;90023,1,2;90024,1,1;90028,1,2;90029,1,1;90038,1,2;90039,1,1;90044,1,2;90045,1,1;90051,1,2;90052,1,8;90063,1,2;90074,2,1;90075,2,1;90075,3,1;90076,3,1;90085,2,2;90085,3,2;90154,2,4;90154,3,4;90159,1,4;90187,1,1;90190,1,1;90218,2,8;90218,3,8;90238,2,1;90238,3,1;90249,1,2;90252,1,2;90303,2,2;90303,3,2;90316,1,1;90317,1,1;90325,1,2;90371,2,8;90371,3,8;90394,1,4;90431,2,8;90431,3,8;90458,1,8;90464,2,1;90467,3,1;90478,1,1;90541,2,4;90541,3,4;90544,1,2;90574,2,2;90574,3,2;90611,1,8;90658,2,1;90658,3,1;90671,1,8;90688,2,4;90688,3,4;90704,1,1;90748,2,4;90748,3,4;90769,3,2;90773,2,2;90781,1,4;90808,2,4;90808,3,4;90812,1,2;90863,2,4;90863,3,4;90898,1,1;90921,2,8;90921,3,8;90928,1,4;90941,2,2;90941,3,2;90989,1,4;91007,2,1;91007,3,1;91009,1,2;91010,2,1;91010,3,1;91048,1,4;91073,2,2;91073,3,2;91075,2,2;91075,3,2;91106,1,4;91148,2,1;91148,3,1;91161,1,8;91181,1,2;91216,2,8;91216,3,8;91247,1,1;91250,1,1;91276,2,8;91276,3,8;91296,2,2;91296,3,2;91315,1,2;91318,1,2;91361,2,1;91361,3,1;91364,2,1;91364,3,1;91391,1,1;91426,2,2;91426,3,2;91427,2,2;91428,3,2;91456,1,8;91472,2,8;91472,3,8;91516,1,8;91528,2,4;91528,3,4;91536,1,2;91548,3,1;91549,2,1;91601,1,1;91604,1,1;91645,2,1;91645,3,1;91666,1,2;91667,1,2;91710,2,2;91710,3,2;91712,1,8;91768,1,4;91778,2,8;91778,3,8;91788,1,1;91834,2,4;91834,3,4;91883,2,4;91883,3,4;91885,1,1;91941,2,8;91941,3,8;91950,1,2;92018,1,8;92054,2,1;92054,3,1;92074,1,4;92119,2,2;92119,3,2;92122,2,2;92122,3,2;92123,1,4;92181,1,8;92185,3,1;92186,2,1;92189,2,1;92189,3,1;92196,2,2;92196,3,2;92294,1,1;92325,2,4;92325,3,4;92359,1,2;92362,1,2;92385,2,4;92385,3,4;92414,2,2;92414,3,2;92429,1,1;92430,1,1;92436,1,2;92479,2,1;92479,3,1;92482,2,1;92482,3,1;92545,2,2;92546,2,2;92546,3,2;92549,3,2;92556,2,1;92556,3,1;92559,2,1;92559,3,1;92565,1,4;92621,2,2;92621,3,2;92624,2,2;92624,3,2;92625,1,4;92654,1,2;92686,2,1;92686,3,1;92687,3,1;92689,2,1;92706,2,2;92706,3,2;92719,1,1;92722,1,1;92734,2,4;92734,3,4;92783,2,4;92783,3,4;92787,1,2;92788,1,2;92796,1,1;92799,1,1;92841,2,8;92841,3,8;92861,1,2;92861,2,1;92861,3,1;92864,1,2;92926,2,2;92926,3,2;92927,1,1;92930,1,1;92946,1,2;92974,1,4;92994,2,8;92994,3,8;93014,2,1;93014,3,1;93023,1,4;93045,2,4;93045,3,4;93081,1,8;93101,1,1;93105,2,4;93105,3,4;93125,2,2;93125,3,2;93166,1,2;93169,2,8;93169,3,8;93189,2,1;93189,3,1;93219,2,4;93219,3,4;93234,1,8;93254,1,1;93279,2,4;93279,3,4;93285,1,4;93301,3,2;93302,2,2;93303,2,1;93303,3,1;93345,1,4;93368,2,2;93368,3,2;93373,1,2;93409,1,8;93429,1,1;93436,2,8;93436,3,8;93456,2,2;93456,3,2;93462,1,4;93519,1,4;93521,2,1;93521,3,1;93524,2,1;93524,3,1;93539,1,2;93543,1,1;93586,3,2;93588,2,2;93588,3,2;93590,2,2;93605,3,1;93607,2,1;93608,1,2;93634,2,4;93634,3,4;93677,1,8;93694,2,4;93694,3,4;93696,1,2;93758,2,8;93758,3,8;93761,1,1;93764,1,1;93778,2,1;93778,3,1;93826,1,2;93832,1,2;93843,2,2;93843,3,2;93846,1,1;93846,2,2;93846,3,2;93877,1,4;93908,3,1;93911,3,1;93913,2,1;93914,2,1;93934,1,4;93945,2,4;93945,3,4;93962,3,2;93963,3,2;93965,2,2;93968,2,2;93998,1,8;94018,1,1;94018,2,1;94018,3,1;94083,1,2;94086,1,2;94087,2,4;94087,3,4;94118,2,2;94123,3,2;94152,1,1;94154,1,1;94186,1,4;94201,2,4;94201,3,4;94202,1,2;94203,1,2;94258,1,1;94260,2,8;94260,3,8;94316,2,4;94316,3,4;94327,1,4;94336,3,1;94337,2,1;94362,1,2;94376,2,4;94376,3,4;94443,1,4;94500,1,8;94556,1,4;94577,1,1;94616,1,4;94714,2,4;94714,3,4;94779,2,4;94779,3,4;94877,2,4;94877,3,4;94901,2,2;94901,3,2;94954,1,4;94967,2,1;94967,3,1;94970,2,1;94970,3,1;95019,1,4;95032,3,2;95033,2,2;95034,2,2;95035,3,2;95043,2,1;95043,3,1;95112,2,4;95112,3,4;95117,1,4;95141,1,2;95177,2,1;95177,3,1;95207,1,1;95210,1,1;95265,2,4;95265,3,4;95273,1,2;95278,1,2;95283,1,1;95325,2,4;95325,3,4;95344,3,2;95345,2,2;95352,1,4;95385,2,4;95385,3,4;95409,2,1;95409,3,1;95418,1,1;95474,2,2;95474,3,2;95507,1,4;95541,2,8;95541,3,8;95561,2,1;95561,3,1;95565,1,4;95587,1,2;95625,1,4;95627,2,2;95627,3,2;95630,2,2;95630,3,2;95649,1,1;95692,2,1;95694,3,1;95695,3,1;95699,2,1;95714,1,2;95738,2,8;95738,3,8;95768,3,2;95781,1,8;95794,2,4;95794,3,4;95801,1,1;95816,2,2;95867,1,2;95870,1,2;95887,2,4;95887,3,4;95910,2,2;95910,3,2;95932,1,1;95934,1,1;95976,2,1;95976,3,1;95979,2,1;95979,3,1;95980,1,8;96008,1,2;96034,1,4;96041,2,2;96043,3,2;96045,3,2;96048,2,2;96052,2,1;96052,3,1;96121,2,4;96121,3,4;96127,1,4;96150,1,2;96214,2,4;96214,3,4;96216,1,1;96219,1,1;96278,2,8;96278,3,8;96282,1,2;96283,1,2;96292,1,1;96339,2,2;96339,3,2;96361,1,4;96404,3,1;96405,2,1;96432,2,4;96432,3,4;96455,1,4;96497,2,4;96497,3,4;96518,1,8;96557,2,4;96557,3,4;96580,1,2;96645,1,1;96650,2,4;96650,3,4;96672,1,4;96683,3,2;96684,2,2;96737,1,4;96765,2,4;96765,3,4;96789,2,1;96789,3,1;96797,1,4;96857,2,4;96857,3,4;96890,1,4;96917,2,4;96917,3,4;96922,1,2;96941,2,1;96941,3,1;96972,2,4;96972,3,4;97005,1,4;97029,1,1;97032,2,4;97032,3,4;97052,2,2;97055,3,2;97069,2,2;97069,3,2;97099,1,4;97147,2,4;97147,3,4;97157,1,4;97170,2,1;97170,3,1;97181,1,1;97212,1,4;97239,2,4;97239,3,4;97272,1,4;97293,1,2;97312,1,2;97359,2,2;97361,3,2;97387,1,4;97410,1,1;97447,2,4;97447,3,4;97483,1,4;97496,2,4;97496,3,4;97569,2,1;97569,3,1;97599,1,2;97637,2,4;97637,3,4;97687,1,4;97730,2,4;97730,3,4;97736,1,4;97754,2,2;97754,3,2;97809,1,1;97819,2,1;97819,3,1;97822,2,1;97822,3,1;97879,1,4;97885,2,2;97885,3,2;97886,2,2;97886,3,2;97921,2,4;97921,3,4;97938,2,1;97940,3,1;97970,1,4;97980,2,8;97980,3,8;97994,1,2;98003,2,2;98003,3,2;98059,2,4;98059,3,4;98063,1,1;98066,1,1;98118,2,1;98118,3,1;98125,1,2;98127,1,2;98161,1,4;98163,2,8;98163,3,8;98184,1,1;98200,2,4;98200,3,4;98220,1,8;98220,2,1;98220,3,1;98245,1,2;98277,2,2;98277,3,2;98299,1,4;98345,2,8;98345,3,8;98358,1,1;98403,1,8;98405,2,8;98405,3,8;98440,1,4;98450,2,4;98450,3,4;98460,1,1;98509,2,8;98509,3,8;98517,1,2;98529,2,1;98529,3,1;98574,2,2;98574,3,2;98588,1,8;98619,2,8;98619,3,8;98639,3,1;98640,2,1;98649,1,8;98690,1,4;98693,2,4;98693,3,4;98732,2,8;98732,3,8;98735,2,8;98735,3,8;98750,1,8;98769,1,1;98770,2,4;98770,3,4;98786,3,2;98787,2,2;98818,1,2;98831,2,8;98831,3,8;98859,1,8;98881,1,1;98933,1,4;98972,1,8;98975,1,8;98979,2,4;98979,3,4;99010,1,4;99027,1,2;99045,2,2;99045,3,2;99072,1,8;99192,2,4;99192,3,4;99219,1,4;99224,2,1;99224,3,1;99252,2,4;99252,3,4;99276,2,1;99276,3,1;99285,1,2;99341,2,2;99341,3,2;99409,2,8;99409,3,8;99432,1,4;99463,1,1;99472,2,1;99472,3,1;99492,1,4;99516,1,1;99581,1,2;99592,2,2;99592,3,2;99653,1,8;99660,2,8;99660,3,8;99710,1,1;99748,2,4;99748,3,4;99772,2,2;99772,3,2;99832,1,2;99838,2,1;99838,3,1;99841,2,1;99841,3,1;99900,1,8;99903,2,2;99904,3,2;99905,2,2;99905,3,2;99939,2,4;99939,3,4;99955,3,1;99956,2,1;99956,3,1;99959,2,1;99988,1,4;100016,1,2;100037,2,4;100037,3,4;100055,2,2;100055,3,2;100078,1,1;100081,1,1;100096,2,8;100096,3,8;100143,1,2;100147,1,2;100152,2,4;100152,3,4;100179,1,4;100199,1,1;100201,1,1;100212,2,4;100212,3,4;100281,1,4;100297,1,2;100334,2,1;100334,3,1;100336,1,8;100392,1,4;100399,2,2;100399,3,2;100452,1,4;100467,2,8;100467,3,8;100523,2,4;100523,3,4;100552,2,1;100552,3,1;100574,1,1;100617,2,2;100617,3,2;100639,1,2;100685,2,8;100685,3,8;100705,2,1;100705,3,1;100707,1,8;100740,2,8;100740,3,8;100763,1,4;100792,1,1;100796,2,4;100796,3,4;100815,3,2;100817,2,2;100857,1,2;100888,2,4;100888,3,4;100925,1,8;100948,2,4;100948,3,4;100949,1,1;100982,1,8;101012,2,8;101012,3,8;101032,2,1;101032,3,1;101040,1,4;101057,1,2;101063,2,4;101063,3,4;101097,3,2;101123,2,4;101123,3,4;101128,1,4;101143,2,2;101188,1,4;101188,2,2;101188,3,2;101255,1,8;101270,2,4;101270,3,4;101272,1,1;101286,3,1;101289,2,1;101306,1,4;101329,2,8;101329,3,8;101363,1,4;101384,1,2;101390,2,1;101390,3,1;101428,1,2;101447,2,2;101447,3,2;101512,1,4;101516,2,4;101516,3,4;101533,1,1;101569,1,8;101608,2,4;101608,3,4;101633,1,1;101672,2,8;101672,3,8;101687,1,2;101705,2,1;101705,3,1;101756,1,4;101783,2,4;101783,3,4;101807,2,2;101807,3,2;101848,1,4;101876,2,4;101876,3,4;101899,2,2;101899,3,2;101912,1,8;101947,1,1;101965,2,1;101965,3,1;101968,2,1;101968,3,1;102027,1,4;102030,2,2;102031,3,2;102033,3,2;102034,2,2;102041,2,1;102041,3,1;102044,2,1;102044,3,1;102047,1,2;102107,2,2;102107,3,2;102110,2,2;102110,3,2;102116,1,4;102139,1,2;102172,3,1;102173,2,1;102174,2,1;102174,3,1;102205,1,1;102208,1,1;102214,2,4;102214,3,4;102248,2,2;102248,3,2;102270,1,2;102273,1,2;102274,2,4;102274,3,4;102281,1,1;102284,1,1;102323,2,4;102323,3,4;102347,1,2;102350,1,2;102381,2,8;102381,3,8;102401,2,1;102401,3,1;102413,1,1;102418,1,1;102432,2,4;102432,3,4;102454,1,4;102466,2,2;102466,3,2;102488,1,2;102492,2,4;102492,3,4;102516,2,1;102516,3,1;102517,1,4;102563,1,4;102581,2,2;102581,3,2;102584,2,2;102584,3,2;102621,1,8;102641,1,1;102647,3,1;102648,2,1;102649,2,1;102649,3,1;102672,1,4;102683,2,4;102683,3,4;102699,3,2;102702,2,2;102706,1,2;102732,1,4;102741,2,8;102741,3,8;102756,1,1;102797,2,4;102797,3,4;102823,3,1;102825,1,2;102826,2,1;102828,1,2;102888,1,1;102889,1,1;102912,2,4;102912,3,4;102923,1,4;102941,1,2;102961,2,4;102961,3,4;102981,1,8;103042,1,4;103063,1,1;103070,2,4;103070,3,4;103090,2,2;103096,3,2;103125,2,4;103125,3,4;103152,1,4;103183,2,8;103183,3,8;103204,1,4;103243,2,8;103243,3,8;103276,2,2;103277,3,2;103283,1,2;103310,1,4;103354,2,4;103354,3,4;103365,1,4;103412,2,8;103412,3,8;103423,1,8;103468,2,4;103468,3,4;103486,1,8;103488,3,1;103489,2,1;103516,1,2;103528,2,4;103528,3,4;103592,2,8;103592,3,8;103594,1,4;103648,2,4;103648,3,4;103652,1,8;103708,1,4;103708,2,4;103708,3,4;103728,1,1;103738,2,1;103738,3,1;103768,1,4;103768,2,4;103768,3,4;103828,2,4;103828,3,4;103832,1,8;103848,2,2;103849,3,2;103888,1,4;103892,2,8;103892,3,8;103948,1,4;103978,1,1;104008,1,4;104046,2,1;104046,3,1;104068,1,4;104095,1,2;104103,2,2;104103,3,2;104135,1,8;104172,2,4;104172,3,4;104236,2,8;104236,3,8;104269,2,1;104270,3,1;104286,1,1;104343,1,2;104347,2,4;104347,3,4;104370,2,2;104370,3,2;104412,1,4;104439,2,4;104439,3,4;104463,2,2;104463,3,2;104479,1,8;104515,1,1;104529,2,1;104529,3,1;104532,2,1;104532,3,1;104587,1,4;104594,2,2;104595,3,2;104599,2,2;104599,3,2;104610,1,2;104612,3,1;104613,2,1;104641,2,4;104641,3,4;104682,1,4;104707,1,2;104734,2,4;104734,3,4;104758,2,1;104758,3,1;104769,1,1;104772,1,1;104823,2,2;104823,3,2;104834,1,2;104836,1,2;104852,1,1;104881,1,4;104891,2,8;104891,3,8;104952,3,2;104953,2,2;104974,1,4;104998,1,1;105017,2,1;105018,3,1;105045,2,4;105045,3,4;105065,1,2;105074,2,1;105074,3,1;105105,2,4;105105,3,4;105131,1,8;105165,2,4;105165,3,4;105184,3,2;105186,2,2;105192,1,2;105257,1,1;105257,2,4;105257,3,4;105285,1,4;105290,3,1;105292,2,1;105314,1,1;105347,1,4;105372,2,4;105372,3,4;105405,1,4;105421,2,4;105421,3,4;105429,1,2;105494,2,2;105494,3,2;105497,1,4;105529,1,1;105563,2,4;105563,3,4;105587,2,1;105587,3,1;105615,1,4;105617,2,4;105617,3,4;105661,1,4;105677,2,4;105677,3,4;105697,3,2;105698,2,2;105727,2,4;105727,3,4;105734,1,2;105785,2,8;105785,3,8;105803,1,4;105827,1,1;105828,2,4;105828,3,4;105857,1,4;105893,2,4;105893,3,4;105915,2,1;105916,3,1;105917,1,4;105937,1,2;105970,1,4;105970,2,4;105970,3,4;105986,2,2;105986,3,2;106025,1,8;106032,2,4;106032,3,4;106068,1,4;106133,1,4;106143,2,1;106143,3,1;106157,1,1;106208,2,2;106208,3,2;106210,1,4;106226,1,2;106272,1,4;106276,2,8;106276,3,8;106296,2,1;106296,3,1;106361,2,2;106361,3,2;106364,2,2;106364,3,2;106383,1,1;106426,3,1;106427,2,1;106429,3,1;106433,2,1;106445,2,2;106446,3,2;106448,1,2;106474,2,4;106474,3,4;106516,1,8;106536,1,1;106539,2,1;106539,3,1;106601,1,2;106603,3,2;106604,2,2;106608,1,2;106632,2,4;106632,3,4;106661,2,1;106661,3,1;106666,1,1;106668,1,1;106685,1,2;106714,1,4;106727,2,2;106727,3,2;106730,2,2;106730,3,2;106781,1,1;106794,3,1;106795,2,1;106795,3,1;106796,2,1;106812,3,2;106839,2,4;106839,3,4;106844,1,2;106859,2,2;106872,1,4;106901,1,1;106937,2,4;106937,3,4;106967,1,2;106970,1,2;106997,2,4;106997,3,4;107033,1,1;107035,1,1;107051,1,2;107057,2,4;107057,3,4;107079,1,4;107087,2,1;107087,3,1;107121,2,8;107121,3,8;107177,1,4;107177,2,4;107177,3,4;107197,3,2;107201,3,1;107237,1,4;107266,2,2;107266,3,2;107297,1,4;107327,1,1;107334,2,8;107334,3,8;107361,1,8;107361,3,1;107362,2,1;107417,1,4;107439,1,2;107441,1,1;107445,2,4;107445,3,4;107477,2,2;107477,3,2;107506,1,2;107574,1,8;107587,2,4;107587,3,4;107602,1,1;107645,2,8;107645,3,8;107687,1,4;107701,2,4;107701,3,4;107716,1,2;107730,2,1;107730,3,1;107796,2,2;107796,3,2;107799,2,2;107799,3,2;107827,1,4;107861,2,1;107862,3,1;107863,2,1;107863,3,1;107879,2,2;107880,3,2;107885,1,8;107908,2,4;107908,3,4;107934,3,2;107935,2,2;107941,1,4;107970,1,1 diff --git a/scripts/dash/run-e2e.sh b/scripts/dash/run-e2e.sh new file mode 100755 index 00000000000..22dd8421505 --- /dev/null +++ b/scripts/dash/run-e2e.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# +# Runs the client's end-to-end tests against a real server. +# +# Builds the server, starts it with the test profile (an in-memory database, +# simulated store receipts, the bot matched at once), then runs the +# simulator tests in common/src/test against it. The screenshots they take -- +# the ones the developer guide shows -- land in target/e2e-shots. +# +# run-e2e.sh [--prebuilt] [--video] +# +# --video also films the promotional tour (PromoVideoTest) with the +# simulator's recorder into target/dash-promo.mp4, and a short gameplay clip +# in the phone skin into target/dash-gameplay.mp4; it needs ffmpeg. +# +# On Linux it wraps the simulator in xvfb-run when that is installed. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${MVN:-mvn}" +PORT="${DASH_PORT:-18090}" +LOG="${DASH_SERVER_LOG:-$HERE/target/e2e-server.log}" +SHOTS="$HERE/target/e2e-shots" +BUILD=1 +VIDEO= +for arg in "$@"; do + case "$arg" in + --prebuilt) BUILD=0 ;; + --video) VIDEO="-Ddash.e2e.video=$HERE/target/dash-promo.mp4 -Ddash.e2e.clip=$HERE/target/dash-gameplay.mp4" ;; + *) echo "[dash-e2e] unknown option: $arg" >&2; exit 2 ;; + esac +done + +mkdir -p "$HERE/target" +rm -rf "$SHOTS" +mkdir -p "$SHOTS" + +if [ "$BUILD" = 1 ]; then + "$HERE/backend/server.sh" build +fi + +echo "[dash-e2e] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[dash-e2e] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +RUNNER=() +if command -v xvfb-run >/dev/null 2>&1; then + RUNNER=(xvfb-run -a -s "-screen 0 1280x1024x24") +fi + +# Every test runs in one simulator, and the Forge's turntable previews and +# 3D battles hold enough textures that 768m ran out in the test after them. +export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS:-} -Xmx1536m -Ddash.server.url=http://localhost:$PORT -Ddash.shots.dir=$SHOTS $VIDEO" + +set +e +${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify +STATUS=$? +set -e + +# The second pass: the same tests with the simulator as a desktop window and +# not a phone, which is where the admin console is a side bar and a page and +# the Promote tab is shown (PromoteTest). Each test knows which pass it is for +# and passes untouched in the other (E2e.desktop()). +# +# The simulator is a desktop when its "desktopSkin" preference says so; there +# is no switch for it on the command line. Java keeps preferences in files only +# on Linux, where java.util.prefs.userRoot can point them at a directory of +# this run's own -- so that is where this pass runs, which covers CI and a +# container. Elsewhere it would mean rewriting the developer's own simulator +# preferences, and it is skipped with a word. +if [ "$STATUS" -eq 0 ] && [ "$(uname -s)" = "Linux" ]; then + PREFS="$HERE/target/e2e-desktop-prefs" + NODE="$PREFS/.java/.userPrefs/com/codename1/impl/javase" + rm -rf "$PREFS" + mkdir -p "$NODE" + cat >"$NODE/prefs.xml" <<'XML' + + + + + +XML + echo "[dash-e2e] the desktop pass" + export JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -Ddash.e2e.desktop=true -Djava.util.prefs.userRoot=$PREFS -Dcodename1.arg.desktop.titleBar=native" + set +e + ${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify + STATUS=$? + set -e +elif [ "$STATUS" -eq 0 ]; then + echo "[dash-e2e] not on Linux: the desktop pass was skipped" +fi + +if [ "$STATUS" -ne 0 ]; then + echo "[dash-e2e] the tests failed; the end of the server's log:" >&2 + tail -n 80 "$LOG" >&2 || true + exit "$STATUS" +fi diff --git a/scripts/dash/shared/pom.xml b/scripts/dash/shared/pom.xml new file mode 100644 index 00000000000..391cd2b21f3 --- /dev/null +++ b/scripts/dash/shared/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.codenameone.examples.dash + dash + 1.0-SNAPSHOT + + com.codenameone.examples.dash + dash-shared + 1.0-SNAPSHOT + jar + dash-shared + + + + 17 + 17 + + + + + com.codenameone + codenameone-core + provided + + + diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AccountApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AccountApi.java new file mode 100644 index 00000000000..2055b168636 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AccountApi.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Accounts: registration, the signed-in player, push devices and the game socket's ticket. Signing in is OAuth (see the app's Session). +@RestClient +public interface AccountApi { + @POST("/api/account/register") + void register(@Body RegisterDto request, OnComplete> callback); + + @GET("/api/me") + void me(OnComplete> callback); + + @POST("/api/me/profile") + void saveProfile(@Body ProfileDto profile, OnComplete> callback); + + @POST("/api/game/ticket") + void gameTicket(OnComplete> callback); + + @POST("/api/account/push") + void registerPush(@Body PushDeviceDto device, OnComplete> callback); + + @POST("/api/account/push/remove") + void unregisterPush(@Body PushDeviceDto device, OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AchievementDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AchievementDto.java new file mode 100644 index 00000000000..5b763631f9c --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AchievementDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A lifetime goal. Unlocking one pays gems once. +@Mapped +public record AchievementDto(String id, String title, String detail, int progress, int goal, boolean unlocked) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdRewardDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdRewardDto.java new file mode 100644 index 00000000000..12fb9b70838 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdRewardDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Claiming the reward for a watched rewarded ad. +@Mapped +public record AdRewardDto(String nonce) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminActionDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminActionDto.java new file mode 100644 index 00000000000..45c67a8ae30 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminActionDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// What an admin does to a player: WARN, MUTE, UNMUTE, SUSPEND (for `days`), BAN, UNBAN, RESET_TROPHIES or REVOKE_ITEMS. +@Mapped +public record AdminActionDto(String action, String reason, int days) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminApi.java new file mode 100644 index 00000000000..a3c2a96e210 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminApi.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import java.util.List; + +/// God mode: everything the admin console reads and does. Every route requires the ADMIN role, and every action is written to the audit log. +@RestClient +public interface AdminApi { + @GET("/api/admin/stats") + void stats(OnComplete> callback); + + @GET("/api/admin/players") + void players(@Query("q") String query, OnComplete>> callback); + + @GET("/api/admin/players/{username}") + void player(@Path("username") String username, OnComplete> callback); + + @POST("/api/admin/players/{username}/action") + void act(@Path("username") String username, @Body AdminActionDto action, + OnComplete> callback); + + @GET("/api/admin/flags") + void flags(@Query("open") boolean open, OnComplete>> callback); + + @POST("/api/admin/flags/{id}/resolve") + void resolve(@Path("id") String id, OnComplete> callback); + + @GET("/api/admin/races") + void liveRaces(OnComplete>> callback); + + @GET("/api/admin/audit") + void audit(OnComplete>> callback); + + /// Packs and files waiting for a moderator: reported or hidden packs, and + /// uploaded files nobody has looked at yet. + @GET("/api/admin/forge/packs") + void forgeQueue(OnComplete>> callback); + + @GET("/api/admin/forge/assets") + void forgeAssets(OnComplete>> callback); + + /// `hide`, `restore`, `official` or `unofficial` a pack. + @POST("/api/admin/forge/packs/action") + void forgePackAction(@Body ForgeActionDto action, OnComplete> callback); + + /// `approve` or `reject` an uploaded file. + @POST("/api/admin/forge/assets/action") + void forgeAssetAction(@Body ForgeActionDto action, OnComplete> callback); + + /// The Promote tab: players, credited invites, top inviters, pushes sent + /// and the home banner. + @GET("/api/admin/promote") + void promote(OnComplete> callback); + + /// How many players a broadcast would reach, without sending it. + @POST("/api/admin/promote/audience") + void audience(@Body BroadcastDto broadcast, OnComplete> callback); + + /// Sends a push to a segment, through Codename One push. + @POST("/api/admin/promote/broadcast") + void broadcast(@Body BroadcastDto broadcast, OnComplete> callback); + + @POST("/api/admin/promote/spotlight") + void spotlight(@Body SpotlightDto spotlight, OnComplete> callback); + + /// Daily players, new accounts, races and sales over the last `days`, + /// with the funnel, retention and trophy bands. + @GET("/api/admin/reports") + void reports(@Query("days") int days, OnComplete> callback); + + /// The players matching every condition, counted, with the first of them. + @POST("/api/admin/query") + void query(@Body QueryDto query, OnComplete> callback); + + @GET("/api/admin/pipelines") + void pipelines(OnComplete>> callback); + + /// Creates a pipeline (no id) or replaces one. + @POST("/api/admin/pipelines") + void savePipeline(@Body PipelineDto pipeline, OnComplete> callback); + + @POST("/api/admin/pipelines/{id}/delete") + void deletePipeline(@Path("id") String id, OnComplete> callback); + + /// Runs a pipeline now instead of waiting for the hour. + @POST("/api/admin/pipelines/{id}/run") + void runPipeline(@Path("id") String id, OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminStatsDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminStatsDto.java new file mode 100644 index 00000000000..46cc905f509 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AdminStatsDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The numbers at the top of the admin console. +@Mapped +public record AdminStatsDto(int players, int online, int liveRaces, int racesToday, int openFlags, int suspended, int purchasesToday, int gemsSoldToday) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetDto.java new file mode 100644 index 00000000000..7b2d7eb5705 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// An uploaded file. `status` is pending until a moderator approves it, and +/// `data` is only filled when the file itself was asked for. +@Mapped +public record AssetDto(String sha256, String name, String kind, int bytes, String status, List animations, String data) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetUploadDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetUploadDto.java new file mode 100644 index 00000000000..de443a34db6 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AssetUploadDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A model or image for a pack, base64 encoded. +@Mapped +public record AssetUploadDto(String name, String kind, String data) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AuditDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AuditDto.java new file mode 100644 index 00000000000..629d3dc5aad --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/AuditDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One entry of the admin audit log. +@Mapped +public record AuditDto(String id, String admin, String username, String action, String reason, long createdAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BroadcastDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BroadcastDto.java new file mode 100644 index 00000000000..b2ca3d47a8c --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BroadcastDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A push notification sent to a segment of players. `segment` is `all`, +/// `lapsed` (not seen for `days`), `new` (joined in the last `days`) or +/// `invited` (joined through a friend). `screen` and `campaign` travel in +/// the push's data, so the app opens the right screen and reports the open +/// under the campaign. `targets` is how many players it reached. +@Mapped +public record BroadcastDto(String id, String segment, int days, String title, String body, String screen, + String campaign, int targets, String sentBy, long sentAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BuyDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BuyDto.java new file mode 100644 index 00000000000..743b5b28687 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/BuyDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Buying a shop item with coins or gems. +@Mapped +public record BuyDto(String itemId) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ChallengeDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ChallengeDto.java new file mode 100644 index 00000000000..50ed4508192 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ChallengeDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// An invitation to a party. Whoever opens `url` (it opens the web build) joins +/// the party, and races with it when the host starts. +@Mapped +public record ChallengeDto(String code, String url, int expiresInSeconds) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ClaimDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ClaimDto.java new file mode 100644 index 00000000000..edec97e9f98 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ClaimDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The quest whose reward is being collected. +@Mapped +public record ClaimDto(String questId) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ConditionDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ConditionDto.java new file mode 100644 index 00000000000..2d9bc19494f --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ConditionDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One test a player must pass to be in a query or a pipeline's audience, +/// such as `daysAway >= 7`. The fields and operators are listed in the +/// server's `Audience` class; anything else is refused. +@Mapped +public record ConditionDto(String field, String op, int value) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewCreateDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewCreateDto.java new file mode 100644 index 00000000000..8e1bdf2963c --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewCreateDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Creates a crew with the caller as its owner. +@Mapped +public record CrewCreateDto(String name) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewDto.java new file mode 100644 index 00000000000..5c56bf77ed3 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/CrewDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A crew: a standing group of friends that races together and has its own +/// leaderboards. +@Mapped +public record CrewDto(String code, String name, String owner, List members, List names) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/DailyDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/DailyDto.java new file mode 100644 index 00000000000..f651e9837fd --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/DailyDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The daily challenge: one level on one seed for everyone, for `day` (days +/// since the epoch, UTC). The seed travels as text: a 64-bit number does not +/// survive a JavaScript JSON parser. +@Mapped +public record DailyDto(int day, String level, String seed, int secondsLeft) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/EquipDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/EquipDto.java new file mode 100644 index 00000000000..53126f6b889 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/EquipDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Equips an owned outfit; the shop sells them. +@Mapped +public record EquipDto(String outfit) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FlagDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FlagDto.java new file mode 100644 index 00000000000..c5d2db6b68d --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FlagDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A reason to look at a player: a report from an opponent, or something the server noticed itself (`kind` AUTO). +@Mapped +public record FlagDto(String id, String username, String displayName, String kind, String reason, String raceId, String reporter, long createdAt, boolean open) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeActionDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeActionDto.java new file mode 100644 index 00000000000..a49601661b9 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeActionDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A moderator's decision about a pack (`hide`, `restore`, `official`, +/// `unofficial`) or a file (`approve`, `reject`). +@Mapped +public record ForgeActionDto(String target, String action, String reason) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeApi.java new file mode 100644 index 00000000000..6cf4375ee18 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForgeApi.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import java.util.List; + +/// The Forge: packs of levels, obstacles, pickups and characters that players +/// build, share and play. A pack's content travels as the JSON +/// `com.codenameone.examples.dash.rules.forge.PackCodec` writes, and the +/// server validates it with the same rules module the app runs. +@RestClient +public interface ForgeApi { + /// `scope` is official, popular, new or mine. + @GET("/api/forge/packs") + void packs(@Query("scope") String scope, OnComplete>> callback); + + /// The latest published version; a share link opens this. + @GET("/api/forge/packs/{id}") + void pack(@Path("id") String id, OnComplete> callback); + + /// One exact version, as a replay or a run result names it. + @GET("/api/forge/packs/{id}/versions/{version}") + void version(@Path("id") String id, @Path("version") int version, OnComplete> callback); + + /// The caller's working copy of their own pack. + @GET("/api/forge/drafts/{id}") + void draft(@Path("id") String id, OnComplete> callback); + + @POST("/api/forge/drafts") + void saveDraft(@Body PackSaveDto draft, OnComplete> callback); + + /// Publishes the draft as a new version, or answers 422 with what is wrong. + @POST("/api/forge/drafts/{id}/publish") + void publish(@Path("id") String id, OnComplete> callback); + + @POST("/api/forge/drafts/{id}/delete") + void delete(@Path("id") String id, OnComplete> callback); + + /// Remixes a published pack into a draft of the caller's own. + @POST("/api/forge/packs/{id}/fork") + void fork(@Path("id") String id, @Body ForkDto fork, OnComplete> callback); + + @POST("/api/forge/rate") + void rate(@Body RateDto rating, OnComplete> callback); + + @POST("/api/forge/report") + void report(@Body PackReportDto report, OnComplete> callback); + + @POST("/api/forge/assets") + void upload(@Body AssetUploadDto file, OnComplete> callback); + + @GET("/api/forge/assets/{sha256}") + void asset(@Path("sha256") String sha256, OnComplete> callback); + + /// Records a finished solo run, pays its first-time stars and enters its + /// score on the level's leaderboards. + @POST("/api/forge/results") + void result(@Body LevelResultDto result, OnComplete> callback); + + @GET("/api/forge/packs/{id}/stars") + void stars(@Path("id") String id, OnComplete>> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForkDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForkDto.java new file mode 100644 index 00000000000..37b13e432bd --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ForkDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Copies a published pack into a new draft named `newId`. +@Mapped +public record ForkDto(String newId) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FriendDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FriendDto.java new file mode 100644 index 00000000000..f34cd397266 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/FriendDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Someone who joined through the player's invite. `state` is `joined` +/// until their first race, then `played`; `rewarded` says whether that +/// race paid the inviter. +@Mapped +public record FriendDto(String displayName, int trophies, String state, boolean rewarded, long joinedAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameApi.java new file mode 100644 index 00000000000..ba194df9130 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameApi.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import java.util.List; + +/// The runner, race history, replays, leaderboards and ghosts, party +/// invitations, crews and reports. +@RestClient +public interface GameApi { + @GET("/api/runner") + void runner(OnComplete> callback); + + @POST("/api/runner") + void saveRunner(@Body RunnerDto runner, OnComplete> callback); + + @GET("/api/races") + void history(OnComplete>> callback); + + @GET("/api/races/{id}") + void replay(@Path("id") String id, OnComplete> callback); + + /// The trophy ladder: who wins races. + @GET("/api/leaderboard") + void leaderboard(@Query("limit") int limit, OnComplete>> callback); + + /// A level's best runs. `level` is an official level id or `pack/level`; + /// `board` is `all`, `week`, `day` (the daily challenge), `friends` or + /// `crew:`. + @GET("/api/scores") + void scores(@Query("level") String level, @Query("board") String board, @Query("limit") int limit, + OnComplete>> callback); + + /// The run to race against: the best on `board` just above the caller's own. + @GET("/api/ghost") + void ghost(@Query("level") String level, @Query("board") String board, OnComplete> callback); + + /// Today's daily challenge: the level and the seed everyone runs. + @GET("/api/daily") + void daily(OnComplete> callback); + + @POST("/api/challenges") + void challenge(OnComplete> callback); + + @GET("/api/crews") + void crews(OnComplete>> callback); + + @POST("/api/crews") + void createCrew(@Body CrewCreateDto crew, OnComplete> callback); + + @POST("/api/crews/{code}/join") + void joinCrew(@Path("code") String code, OnComplete> callback); + + @POST("/api/crews/{code}/leave") + void leaveCrew(@Path("code") String code, OnComplete> callback); + + @POST("/api/reports") + void report(@Body ReportDto report, OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameProtocol.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameProtocol.java new file mode 100644 index 00000000000..fa4e959f2f8 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GameProtocol.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +/// The game socket's message types, shared so both ends spell them the same. +/// +/// Every message is a small JSON object with a `type`. The socket carries only +/// inputs one way and accepted inputs the other; every runner's run is computed +/// by the shared rules on every machine, in lockstep. +/// +/// Parties, client to server: [#HOST] (`level`, an official id or a +/// `pack@hash#level` reference), [#JOIN] (`code`), [#QUICK] (race whoever is +/// waiting, bots fill the rest), [#GO] (the host starts), [#CANCEL]. +/// +/// In a race, client to server: [#INPUT] (`b`: [com.codenameone.examples.dash.rules.Input] +/// bits), [#SYNC] (`t`, `h`), [#RESUME], [#LEAVE], [#SPECTATE] (admins, `race`), +/// [#PING]. Anywhere: [#CHAT] (`text`), [#EMOTE] (`id`), [#VOICE] (`to`: a seat, +/// `data`: an opaque WebRTC offer, answer or candidate the server only relays), +/// [#MUTE] (`on`). +/// +/// Server to client: [#READY], [#PARTY] (`code`, `host`, `level`, `names`, +/// `you`), [#QUEUED], [#START] (`race`, `level`, `seed`, `you`, `names`, +/// `rules`, `startsIn`, `tick`, `log`), [#TICKS] (`t`: the last tick the client +/// may simulate, `i`: inputs executed since the previous message, as +/// `tick,seat,bits;...`), [#HASH] (`t`, `h`), [#CHAT] (`from`, `text`), +/// [#EMOTE] (`from`, `id`), [#VOICE] (`from`, `data`), [#MUTE] (`from`, `on`), +/// [#END] (`places`, `scores`, `trophies`, `coins`, `race`), [#FLAG] (admins), +/// [#ERROR] (`message`), [#PONG], [#KICKED]. +public final class GameProtocol { + public static final String HOST = "host"; + public static final String JOIN = "join"; + public static final String QUICK = "quick"; + public static final String GO = "go"; + public static final String CANCEL = "cancel"; + public static final String INPUT = "input"; + public static final String SYNC = "sync"; + public static final String RESUME = "resume"; + public static final String LEAVE = "leave"; + public static final String SPECTATE = "spectate"; + public static final String CHAT = "chat"; + public static final String EMOTE = "emote"; + public static final String VOICE = "voice"; + public static final String MUTE = "mute"; + public static final String PING = "ping"; + + public static final String READY = "ready"; + public static final String PARTY = "party"; + public static final String QUEUED = "queued"; + public static final String START = "start"; + public static final String TICKS = "ticks"; + public static final String HASH = "hash"; + public static final String END = "end"; + public static final String FLAG = "flag"; + public static final String ERROR = "error"; + public static final String PONG = "pong"; + public static final String KICKED = "kicked"; + + /// Ticks between the server receiving an input and executing it. Every + /// machine applies it on the same tick; 6 ticks is 100 ms, which hides most + /// round trips. The client shows its own runner reacting at once and + /// reconciles when the input lands. + public static final int INPUT_DELAY = 6; + + /// The server sends a [#HASH] this often, in ticks. + public static final int HASH_EVERY = 60; + + /// Milliseconds of countdown between [#START] and tick 0. + public static final int COUNTDOWN_MILLIS = 3000; + + /// Most runners in one race. + public static final int MAX_RUNNERS = 6; + + /// Longest message a client may send. A WebRTC offer with its candidates is + /// a few kilobytes; nothing else comes near. + public static final int MAX_MESSAGE = 16 * 1024; + + private GameProtocol() { + } +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GhostDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GhostDto.java new file mode 100644 index 00000000000..41461094a69 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/GhostDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A leaderboard run to race against: its replay, re-simulated by the app. +@Mapped +public record GhostDto(String displayName, int score, String replay) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InsightsDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InsightsDto.java new file mode 100644 index 00000000000..d050d9e0ac8 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InsightsDto.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// The console's reports, read from the game's own records: accounts, +/// races and purchases. The daily series have one entry per day, oldest +/// first, starting at `firstDay`. +/// +/// @param firstDay the start of the first day, in milliseconds +/// @param active players who ran at least one race that day +/// @param joined accounts created that day +/// @param races races finished that day +/// @param gems gems sold that day +/// @param funnel joined, played, won, paid and invited a friend +/// @param retention the share of players, in percent, seen again at least 1, 7 and 30 days after joining +/// @param trophies how many players are in each trophy band +@Mapped +public record InsightsDto(long firstDay, List active, List joined, List races, + List gems, List funnel, List retention, List trophies) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteClaimDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteClaimDto.java new file mode 100644 index 00000000000..c380128a742 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteClaimDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// An invite the app recovered through `com.codename1.analytics.invite.Invites`: +/// the code the inviter's link carried in its payload, and how it was matched. +@Mapped +public record InviteClaimDto(String code, String campaign, String channel, String match) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteDto.java new file mode 100644 index 00000000000..926999a86c8 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviteDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The player's own invite code and how far it has got them: friends who +/// joined through it, how many of those have played and been paid for, and +/// what each side earns. +@Mapped +public record InviteDto(String code, int joined, int rewarded, int maxRewarded, int inviterCoins, + int friendCoins) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviterDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviterDto.java new file mode 100644 index 00000000000..48febae7988 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/InviterDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A player whose invites brought others in. +@Mapped +public record InviterDto(String displayName, int joined, int played) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LeaderDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LeaderDto.java new file mode 100644 index 00000000000..e1b52eff021 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LeaderDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One row of the leaderboard. +@Mapped +public record LeaderDto(int rank, String displayName, int trophies, int wins) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelOutcomeDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelOutcomeDto.java new file mode 100644 index 00000000000..4864b731045 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelOutcomeDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// What a verified run earned: stars, the personal best, the leaderboard rank +/// and the reward. +@Mapped +public record LevelOutcomeDto(String packId, String levelId, int stars, int score, int best, int rank, RewardDto reward) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelResultDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelResultDto.java new file mode 100644 index 00000000000..486b12ba3d9 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelResultDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A finished solo run: the replay the app recorded. The server plays it again +/// before it believes the score. `city` is the encoded route of a city run +/// (`CityRoute.encode`), from which the server builds the level itself; null +/// for any other level. +@Mapped +public record LevelResultDto(String replay, String city) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelStarsDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelStarsDto.java new file mode 100644 index 00000000000..0b7041a3cdc --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LevelStarsDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A player's best on one level. +@Mapped +public record LevelStarsDto(String packId, String levelId, int stars, int best) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LiveRaceDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LiveRaceDto.java new file mode 100644 index 00000000000..cd7c094c39e --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/LiveRaceDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A race in progress, for the admin console. +@Mapped +public record LiveRaceDto(String id, String level, List players, List names, int tick, String leader, int bots) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/NameCountDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/NameCountDto.java new file mode 100644 index 00000000000..f6e934343e0 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/NameCountDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A named number: a step of a funnel, a retention day, a trophy band. +@Mapped +public record NameCountDto(String name, int count) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackDto.java new file mode 100644 index 00000000000..6e2f1c257d8 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A pack's content, as the Forge's JSON, with what a browse list shows about it. +@Mapped +public record PackDto(PackSummaryDto summary, String content) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackReportDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackReportDto.java new file mode 100644 index 00000000000..720aea8fc36 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackReportDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A player telling the moderators about a pack. +@Mapped +public record PackReportDto(String packId, String reason) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSaveDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSaveDto.java new file mode 100644 index 00000000000..6cd4293cf7c --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSaveDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A draft to keep: the whole pack, as JSON. Drafts are not validated. +@Mapped +public record PackSaveDto(String content) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSummaryDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSummaryDto.java new file mode 100644 index 00000000000..a28674856cd --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PackSummaryDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A pack as a browse list shows it. `rating` is the average of the stars +/// players gave it, times ten; `status` is draft, published or hidden. +@Mapped +public record PackSummaryDto(String id, String name, String author, String owner, String description, int version, String hash, boolean official, String status, int plays, int rating, int ratings, int levels, int defs, boolean pendingAssets, long updatedAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PipelineDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PipelineDto.java new file mode 100644 index 00000000000..63b2862d6d0 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PipelineDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// An engagement pipeline: when a player matches the conditions, do the +/// action, at most once per `cooldownDays` for each player. The server runs +/// every enabled pipeline once an hour. +/// +/// @param action `push` (sends title and body, opening screen) or `gems` (gives amount gems, with a push saying so) +/// @param reached players acted on, ever +/// @param lastRun when the pipeline last ran, in milliseconds, or 0 +@Mapped +public record PipelineDto(String id, String name, boolean enabled, List conditions, String action, + String title, String body, String screen, int amount, int cooldownDays, int reached, long lastRun) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDetailDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDetailDto.java new file mode 100644 index 00000000000..f002dc18ef0 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDetailDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// Everything the admin console shows about one player. +@Mapped +public record PlayerDetailDto(PlayerDto player, List races, List flags, List actions, List purchases) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDto.java new file mode 100644 index 00000000000..a0c94db437a --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PlayerDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A player as they see themselves, and as the admin console sees them. `owned` lists cosmetic item ids; `outfit` is the equipped outfit and `runner` the chosen character. +@Mapped +public record PlayerDto(String username, String displayName, int trophies, int wins, int losses, int draws, int coins, int gems, boolean admin, boolean suspended, boolean muted, boolean flagged, String runner, List owned, String outfit) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProfileDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProfileDto.java new file mode 100644 index 00000000000..86a69dced99 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProfileDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The parts of a profile a player may change. +@Mapped +public record ProfileDto(String displayName) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressApi.java new file mode 100644 index 00000000000..a1f9f09e877 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressApi.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Levels, daily quests, chests, achievements and the tutorial. The server +/// keeps the score: every reward is decided and granted there. +@RestClient +public interface ProgressApi { + @GET("/api/progress") + void progress(OnComplete> callback); + + @POST("/api/progress/quest") + void claimQuest(@Body ClaimDto quest, OnComplete> callback); + + @POST("/api/progress/chest") + void openChest(OnComplete> callback); + + @POST("/api/progress/tutorial") + void tutorialDone(OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressDto.java new file mode 100644 index 00000000000..16c932b410b --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ProgressDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// Everything the home screen shows about a player's progress: the level and +/// how far into it they are, chests waiting, today's quests and the +/// achievements, unlocked or not. +@Mapped +public record ProgressDto(int level, int xp, int levelStart, int levelEnd, boolean tutorialDone, int chests, + int coins, int gems, int streak, int wins, List quests, List achievements) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PromoteDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PromoteDto.java new file mode 100644 index 00000000000..2c6cf06b159 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PromoteDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// What the console's Promote tab shows from the game's own records: players, +/// the invites the server credited, the players who bring others in, the +/// pushes sent and the home banner. Engagement, retention and revenue are not +/// here: they are in Codename One analytics, where the app reports them. +@Mapped +public record PromoteDto(int players, int joinedThisWeek, int invited, int invitedPlayed, + List inviters, List broadcasts, SpotlightDto spotlight) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PushDeviceDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PushDeviceDto.java new file mode 100644 index 00000000000..969b8a606b9 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/PushDeviceDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A device the server may send push notifications to. +@Mapped +public record PushDeviceDto(String provider, String token, String platform) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryDto.java new file mode 100644 index 00000000000..7a9ad94c769 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A query over the players: every condition must hold. +@Mapped +public record QueryDto(List conditions) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryResultDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryResultDto.java new file mode 100644 index 00000000000..baa09b7f64a --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QueryResultDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// How many players a query matched, and the first of them. +@Mapped +public record QueryResultDto(int count, List players) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QuestDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QuestDto.java new file mode 100644 index 00000000000..555b919c464 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/QuestDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One of today's quests: what it asks, how far the player is and what it pays. +@Mapped +public record QuestDto(String id, String title, int progress, int goal, int coins, int xp, boolean claimed) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RaceSummaryDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RaceSummaryDto.java new file mode 100644 index 00000000000..14bc870825f --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RaceSummaryDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One finished race from one player's side. +@Mapped +public record RaceSummaryDto(String id, String level, int place, int runners, int score, int trophyChange, long endedAt, int ticks) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RateDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RateDto.java new file mode 100644 index 00000000000..c4c8260d0b9 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RateDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One to five stars for a pack; rating again replaces the earlier stars. +@Mapped +public record RateDto(String packId, int stars) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReceiptDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReceiptDto.java new file mode 100644 index 00000000000..61c5621a7f4 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReceiptDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A store purchase for the server to verify before it grants anything. `receipt` is the store's proof: the App Store receipt or the Play purchase token. +@Mapped +public record ReceiptDto(String store, String sku, String transactionId, String receipt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RegisterDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RegisterDto.java new file mode 100644 index 00000000000..099da917cf7 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RegisterDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// What a new player sends to open an account. +@Mapped +public record RegisterDto(String email, String password, String displayName) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReplayDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReplayDto.java new file mode 100644 index 00000000000..e6b4a707efc --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReplayDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A recorded race. `replay` is the shared rules' own encoding: the level, a seed +/// and every runner's inputs, nothing else. +@Mapped +public record ReplayDto(String id, String level, List names, List scores, String replay, String rulesVersion, long endedAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReportDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReportDto.java new file mode 100644 index 00000000000..0842c0ce275 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ReportDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A player reporting their fellow racer after a race. +@Mapped +public record ReportDto(String raceId, String player, String reason) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RewardDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RewardDto.java new file mode 100644 index 00000000000..68fcf620187 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RewardDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// What the player just got: the client turns it into a reward popup, and a +/// level-up or a new achievement into a celebration of its own. +@Mapped +public record RewardDto(String title, int coins, int gems, int xp, int chests, int level, boolean levelUp, + List unlocked) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RunnerDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RunnerDto.java new file mode 100644 index 00000000000..a9e1db97815 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/RunnerDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The runner a player has chosen: a built-in character id, or `asset:` +/// for a glTF model they uploaded. +@Mapped +public record RunnerDto(String runner) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ScoreDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ScoreDto.java new file mode 100644 index 00000000000..97de2be6ac3 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ScoreDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// One row of a level leaderboard. `replayId` fetches the run that set it, to +/// watch or to race as a ghost. +@Mapped +public record ScoreDto(int rank, String displayName, int score, int distance, int coins, int stars, String replayId) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopApi.java new file mode 100644 index 00000000000..5574fb2cdca --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopApi.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import java.util.List; + +/// The wallet, the shop, store purchases (verified on the server) and rewarded ads. +@RestClient +public interface ShopApi { + @GET("/api/shop") + void items(OnComplete>> callback); + + @GET("/api/wallet") + void wallet(OnComplete> callback); + + @POST("/api/shop/buy") + void buy(@Body BuyDto item, OnComplete> callback); + + @POST("/api/shop/equip") + void equip(@Body EquipDto arena, OnComplete> callback); + + @POST("/api/shop/receipt") + void receipt(@Body ReceiptDto receipt, OnComplete> callback); + + @POST("/api/shop/ad-reward") + void adReward(@Body AdRewardDto reward, OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopItemDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopItemDto.java new file mode 100644 index 00000000000..b5e45765941 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/ShopItemDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// Something the shop sells. `kind` is gems, arena or emotes; `sku` is set for real-money items. +@Mapped +public record ShopItemDto(String id, String name, String kind, int priceCoins, int priceGems, String sku) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SocialApi.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SocialApi.java new file mode 100644 index 00000000000..f5427b83774 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SocialApi.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import java.util.List; + +/// Inviting friends and the home banner. The invite link itself is minted and +/// tracked by `com.codename1.analytics.invite.Invites`; the server only knows +/// whose code a link carried, so it can pay both players once the friend has +/// played. +@RestClient +public interface SocialApi { + @GET("/api/social/invite") + void invite(OnComplete> callback); + + /// Credits an invite to the player who just joined. Accounts older than a + /// week, a player's own code and a second claim are refused. + @POST("/api/social/claim") + void claim(@Body InviteClaimDto claim, OnComplete> callback); + + @GET("/api/social/friends") + void friends(OnComplete>> callback); + + @GET("/api/social/spotlight") + void spotlight(OnComplete> callback); +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SpotlightDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SpotlightDto.java new file mode 100644 index 00000000000..be346344e74 --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/SpotlightDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// The banner every home screen shows: a line of text and, optionally, where a +/// tap on it goes -- a Forge pack, or a screen such as the shop. An empty +/// text hides it. +@Mapped +public record SpotlightDto(String text, String packId, String screen, long updatedAt) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/TicketDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/TicketDto.java new file mode 100644 index 00000000000..8e0e3ae491a --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/TicketDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; + +/// A one-shot ticket that opens the game socket; a socket cannot carry a bearer token on every platform. +@Mapped +public record TicketDto(String ticket, int expiresInSeconds) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/WalletDto.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/WalletDto.java new file mode 100644 index 00000000000..3ac2041ba5a --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/WalletDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.dash.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// What a player holds. +@Mapped +public record WalletDto(int coins, int gems, List owned, String outfit, int adRewardsLeft) { +} diff --git a/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/package-info.java b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/package-info.java new file mode 100644 index 00000000000..1da29e9b0aa --- /dev/null +++ b/scripts/dash/shared/src/main/java/com/codenameone/examples/dash/api/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/// The REST contract between the app and the server. The app's build generates +/// a client from each interface, the server's build a dispatcher it implements. +package com.codenameone.examples.dash.api; diff --git a/scripts/developer-guide/check-guide-api-names.py b/scripts/developer-guide/check-guide-api-names.py index 66eb48379bc..bf076c62664 100755 --- a/scripts/developer-guide/check-guide-api-names.py +++ b/scripts/developer-guide/check-guide-api-names.py @@ -72,11 +72,15 @@ # # scripts/bastion is the same for the Bastion implementor's guide: its EthRpc # wraps the framework's EvmRpc, one letter away. +# +# scripts/dash is the same for the Dash implementor's guide: its Outfit, the +# runner's colours, is two letters from Output. IDENTIFIER_ROOTS = [os.path.join(ROOT, d) for d in ('CodenameOne', 'Ports', 'vm', 'maven', os.path.join('docs', 'demos'), os.path.join('scripts', 'wayline'), - os.path.join('scripts', 'bastion'))] + os.path.join('scripts', 'bastion'), + os.path.join('scripts', 'dash'))] IDENTIFIER_SUFFIXES = ('.java', '.m', '.h', '.mm', '.kt', '.js', '.c', '.cpp', '.xml', '.gradle', '.swift', '.cs') diff --git a/scripts/developer-guide/validate-guide-snippets.py b/scripts/developer-guide/validate-guide-snippets.py index 9f6be7b7d55..7783409e155 100755 --- a/scripts/developer-guide/validate-guide-snippets.py +++ b/scripts/developer-guide/validate-guide-snippets.py @@ -15,7 +15,7 @@ LISTING_DELIMITER_RE = re.compile(r"^-{4,}\s*$") TAG_BLOCK_RE = re.compile(r"// tag::([^\[]+)\[\]\n(.*?)\n// end::\1\[\]", re.S) JAVA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*\.java$") -INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/parley/", "../../scripts/reel/", "../../scripts/hellocodenameone/") +INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/dash/", "../../scripts/parley/", "../../scripts/reel/", "../../scripts/hellocodenameone/") def parse_args(): @@ -135,8 +135,8 @@ def validate_includes(includes): cache = {} java_snippets = [] for item in includes: - # The Wayline, Savor, Skirmish and Parley chapters show the sample applications themselves, - # which are built and tested by wayline.yml, savor.yml, skirmish.yml and parley.yml, so they include its sources directly + # The Wayline, Savor, Skirmish, Dash and Parley chapters show the sample applications themselves, + # which are built and tested by wayline.yml, savor.yml, skirmish.yml, dash.yml and parley.yml, so they include its sources directly # rather than through copies under docs/demos that could drift from it. if not item["target"].startswith(INCLUDE_ROOTS): errors.append( @@ -177,7 +177,7 @@ def validate_includes(includes): # compiled by its project's CSS goal, and the sample's workflow # builds the sample. relative_css_path = None - for css_dir in ("docs/demos/common/src/main/css", "scripts/skirmish/common/src/main/css"): + for css_dir in ("docs/demos/common/src/main/css", "scripts/skirmish/common/src/main/css", "scripts/dash/common/src/main/css"): try: relative_css_path = target_path.relative_to(Path(css_dir).resolve()) break diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 7eaab8d3d93..8a6575ce25e 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -467,7 +467,7 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o if (!GeneratorModel.isTemplateOffered(Template.WAYLINE)) { return empty; } - Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, + Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.DASH, Template.PARLEY, Template.BASTION, Template.REEL, Template.PULSE}; int offered = 0; for (Template app : apps) { @@ -510,6 +510,7 @@ private void addTemplateChoice(Container parent, ButtonGroup group, final Templa /// What a full-stack template is, in a few words. static String templateTitle(Template template) { return template == Template.SAVOR ? "Food delivery" : template == Template.SKIRMISH ? "Card battle game" + : template == Template.DASH ? "Endless runner" : template == Template.PARLEY ? "Messaging" : template == Template.WAYLINE ? "Ride hailing" : template == Template.BASTION ? "Banking" : template == Template.REEL ? "Video streaming" diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index bbfcce06e57..1d647228f86 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -118,6 +118,9 @@ static String templateSince(Template template) { /// The card-battle template's accent, used for its icon: it has no `--brand`. public static final int SKIRMISH_BRAND = 0xc8862f; + /// The runner template's accent, used for its icon: it has no `--brand`. + public static final int DASH_BRAND = 0xff4d5e; + /// The messaging template's `--brand`, held to its theme.css the same way. public static final int PARLEY_BRAND = 0x0a7d6b; @@ -143,7 +146,8 @@ public static int brandOf(Template template) { return BASTION_BRAND; } return template == Template.SAVOR ? SAVOR_BRAND - : template == Template.SKIRMISH ? SKIRMISH_BRAND : FULL_STACK_BRAND; + : template == Template.SKIRMISH ? SKIRMISH_BRAND + : template == Template.DASH ? DASH_BRAND : FULL_STACK_BRAND; } /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers @@ -1138,7 +1142,7 @@ private void validateSharedModule(Map entries, String rootPom, S "dependency on the shared module"); requirePomFragment("backend/pom.xml", normalizedPom(entries, "backend/pom.xml"), dependency, "dependency on the shared module"); - if (template == Template.SKIRMISH) { + if (template == Template.SKIRMISH || template == Template.DASH) { // The rules pom takes its group and version from the parent, so its // coordinates are the parent's and its artifact id. requirePomFragment("rules/pom.xml", normalizedPom(entries, "rules/pom.xml"), @@ -1624,7 +1628,7 @@ private byte[] applyDataReplacements(String targetPath, byte[] sourceData) throw content = replaceTagValue(content, "cn1.plugin.version", pluginVersion); content = replaceTagValue(content, "cn1.version", pluginVersion); if (template.isFullStack() && !options.isGradle() && !isMavenBackendOnly()) { - content = addSharedModule(content, template == Template.SKIRMISH); + content = addSharedModule(content, template == Template.SKIRMISH || template == Template.DASH); } if (mavenLayoutsEnabled() && !isMavenBackendOnly()) { content = guardModuleProfiles(content, !includesPlatformModules()); @@ -2056,7 +2060,23 @@ private String buildReadmeMarkdown() { .append("Package it as a native binary with ") .append("`./mvnw -pl backend -am -Dcodename1.platform=backend package cn1:backend-package`.\n\n"); } - if (template == Template.SKIRMISH) { + if (template == Template.DASH) { + out.append("## The game and its server\n\n") + .append("This project is a working endless runner: a 2D-to-3D camera swing, a track editor, ") + .append("leaderboards the server re-simulates, live races with voice, crews and an admin console, ") + .append("and the server they talk to.\n\n") + .append("- `rules/` is the game: a deterministic simulation with no dependencies, which the app ") + .append("runs, the server re-simulates to verify scores and the replay viewer re-runs.\n") + .append("- `shared/` is the contract: the REST interfaces and the objects they carry.\n") + .append("- `backend/` is the server.\n") + .append("- `common/` is the app.\n\n") + .append("Start the server, then, in a second terminal, the app:\n\n```\n") + .append("./run-server.sh\n") + .append("./run.sh\n```\n\n") + .append("On Windows: `run-server.bat`, then `run.bat`. Your IDE's run menu has both: ") + .append("\"Run Server\" and the simulator.\n\n") + .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n"); + } else if (template == Template.SKIRMISH) { out.append("## The game and its server\n\n") .append("This project is a working real-time card battler: a 3D arena, matchmaking, replays, ") .append("progression and an admin console, and the server they talk to.\n\n") diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 2e680aeeee6..d5f53736e86 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -71,6 +71,16 @@ public enum Template { "The card-battle template is an app, a server and the modules they share; " + "an Initializr Gradle project is a single project.", new String[0], "/skirmish-modules.zip", "/skirmish-settings.properties"), + // An endless runner: deterministic rules shared by the app and the server, a + // track editor, leaderboards the server re-simulates and live races. It is the + // project in scripts/dash; its resources come from + // `scripts/sync-initializr-template.py dash`. Its look is painted art, so it + // has no colour scheme to choose. + DASH(false, "/dash-src.zip", "/dash-css.zip", "/dash-pom.xml", + "com.codenameone.examples.dash", "Dash", "/dash-preview.jpg", + "The runner template is an app, a server and the modules they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/dash-modules.zip", "/dash-settings.properties"), // The same kind of template as WAYLINE: an end-to-end encrypted messaging app // (chats, groups, media, calls) and its server. It is the project in // scripts/parley, and its resources come from @@ -160,7 +170,7 @@ public boolean isFullStack() { /// Whether the developer can choose a colour scheme for the template: false for /// one whose look is art and not a palette its stylesheet declares. public boolean hasColorScheme() { - return this != SKIRMISH; + return this != SKIRMISH && this != DASH; } public boolean supportsGradle() { diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md index c8e96e5c397..f738602627f 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel and Skirmish demos). +description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel, Skirmish and Dash demos). metadata: type: skill --- diff --git a/scripts/initializr/common/src/main/resources/dash-css.zip b/scripts/initializr/common/src/main/resources/dash-css.zip new file mode 100644 index 00000000000..c2a3e01f91a Binary files /dev/null and b/scripts/initializr/common/src/main/resources/dash-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/dash-modules.zip b/scripts/initializr/common/src/main/resources/dash-modules.zip new file mode 100644 index 00000000000..422b0179088 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/dash-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/dash-pom.xml b/scripts/initializr/common/src/main/resources/dash-pom.xml new file mode 100644 index 00000000000..e1462c1e296 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/dash-pom.xml @@ -0,0 +1,412 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + + ${project.groupId} + myappname-rules + ${project.version} + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + xalan + serializer + 2.7.3 + + + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/dash-preview.jpg b/scripts/initializr/common/src/main/resources/dash-preview.jpg new file mode 100644 index 00000000000..f9615d0a2c8 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/dash-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/dash-settings.properties b/scripts/initializr/common/src/main/resources/dash-settings.properties new file mode 100644 index 00000000000..d4afdb6dcbd --- /dev/null +++ b/scripts/initializr/common/src/main/resources/dash-settings.properties @@ -0,0 +1,21 @@ +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the game talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# Push notifications: challenges, chest reminders, and flags for an admin. +codename1.arg.ios.includePush=true +# myappname://tour starts the self-driving walkthrough (see Tour.java). +codename1.arg.ios.urlScheme=myappname +codename1.arg.android.xintent_filter= +# Android push is off until the game has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The shop sells through Google Play, and an Android build that uses in-app +# purchase needs the app's licensing key. This is a placeholder that builds +# but verifies nothing: paste the Base64 RSA public key from the Play +# Console's "Monetization setup" before release. +codename1.arg.android.licenseKey=CN1TESTPLACEHOLDERKEYNOTFORPRODUCTIONxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB +# Runs are played in portrait. +codename1.arg.android.screenOrientation=portrait +codename1.arg.ios.interface_orientation=UIInterfaceOrientationPortrait diff --git a/scripts/initializr/common/src/main/resources/dash-src.zip b/scripts/initializr/common/src/main/resources/dash-src.zip new file mode 100644 index 00000000000..5b2e7da9070 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/dash-src.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 6ba214dc493..a983bb56987 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel and Skirmish demos). +description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel, Skirmish and Dash demos). metadata: type: skill --- @@ -31,7 +31,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. - `references/android-to-cn1.md` — Porting Android (XML + Kotlin/Java) screens to Codename One. - `references/react-to-cn1.md` — Bringing a **React / JSX + CSS design** (e.g. a Claude-generated bundle: `*.jsx` + `tokens.css` + `styles.css`) into Codename One: the component/hook/CSS mapping, the `tokens.css` -> `theme.css` token workflow, dark-mode re-skinning, and the **render-on-desktop-then-inspect** loop. Read this when the user hands you a React/HTML design to rebuild in CN1. -- `references/demos.md` — The six full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Bastion secure bank and exchange, Reel video streaming, Skirmish 3D card game): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. +- `references/demos.md` — The seven full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Bastion secure bank and exchange, Reel video streaming, Skirmish 3D card game, Dash endless runner): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. - `references/compatibility.md` — The build-time compatibility tools: run an existing **Android** app (`src/main/android`), **Swing** or **JavaFX** app (`src/main/desktop`), **Flutter** code (`src/main/flutter`) or **Unity 2D** project (`src/main/unity`) on every Codename One target without rewriting it, the `import-*-project` goals, how to mix ported and new code, limits, and sample inputs. Read this first whenever the user has an existing app in one of those frameworks. - `references/flutter-to-cn1.md` — Bringing **Dart / Flutter widget source** into Codename One. Unlike the other porting guides this is mostly not a rewrite: the `transcode-flutter` Maven goal compiles Dart widgets to Java at build time (no Dart SDK involved), and `FlutterUI.wrap` / `FlutterUI.runApp` mount the result. Read this when the user has Dart or Flutter code, asks to embed a Flutter screen in a CN1 app, or asks whether a Flutter app can be ported. - `references/testing-and-screenshots.md` — `AbstractTest`, `TestUtils`, `screenshotTest`, the `cn1:test` Maven goal, the screenshot tolerance algorithm. diff --git a/scripts/initializr/common/src/main/resources/skill/references/demos.md b/scripts/initializr/common/src/main/resources/skill/references/demos.md index 2eb637db1cd..be973bc1aad 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/demos.md +++ b/scripts/initializr/common/src/main/resources/skill/references/demos.md @@ -21,7 +21,7 @@ pattern.** Copy the shape, not the domain. It explains *why* the code is laid out the way it is -- read it before making a structural change modeled on a demo. -All six share this layout, which is itself the first best practice (Skirmish adds a +All seven share this layout, which is itself the first best practice (Skirmish and Dash add a `rules` module, the deterministic game simulation the app, server and tests all compile): | Module | Holds | @@ -45,12 +45,13 @@ README of each lists the demo accounts. | `scripts/bastion` | Security-hardened bank and crypto exchange: customer, staff console | Security end to end: sealed key storage, biometric/PIN app lock, certificate pinning, secure windows (`security/`), device-key request signing, passkeys, TOTP and step-up (`security/RequestSigner.java`, server `money/RequestSigning.java`, `account/`), a Security Center that reports each protection's live state (`ui/SecurityCenterForm.java`); a double-entry ledger with idempotency and conditional updates (server `ledger/`), a hash-chained audit log (`audit/`), accounting reports and PDF printing (`books/`, `ui/Printout.java`), an order-matching exchange (`exchange/`), and real Ether custody on an Ethereum node (server `chain/`). Its implementor's guide ends with a security checklist | | `scripts/pulse` | Gym and studio: member, trainer, front desk, owner | Health and workouts (`com.codename1.health`), a watch companion (`wearable`), rotating QR and NFC door passes (`codescan`, `nfc`), class booking with waitlists and device calendar, households and kids' bookings, an owner dashboard with charts and a desktop master-detail layout, server-side billing with dunning and engagement nudges over push | | `scripts/skirmish` | Real-time 3D card battler | Games: `GameView`/3D rendering (`battle/`), a deterministic `rules` module shared by app, server and tests, replays, in-app purchases and rewarded ads (`engage/`), matchmaking over sockets, in-app editors (`forge/`) | +| `scripts/dash` | Endless runner with a 2D-to-3D camera swing: player, track editor, admin console | Games: a deterministic `rules` module shared by app, server and tests, with the server re-simulating every submitted run to verify leaderboard scores (`game/`, `progress/`), live races with voice over sockets (`net/`), crews, friends and level packs (`social/`), a track editor (`forge/`), in-app purchases and rewarded ads (`engage/`, `shop/`) | ## Patterns the app demos share (copy these) Wayline, Savor, Parley and Reel follow all of these, except that Reel has no live -channel: playback reports progress over REST heartbeats instead. Skirmish is a game and differs: -its socket is `net/GameLink.java` rather than `live/LiveChannel.java`, and it has +channel: playback reports progress over REST heartbeats instead. Skirmish and Dash are games and differ: +their socket is `net/GameLink.java` rather than `live/LiveChannel.java`, and they have no `Prefs`, `Telemetry` or l10n bundles. Bastion has no live channel or `Telemetry` (a bank sends no analytics and pulls its data on demand), and its `net/Guard.java` adds the pinning and host allow-list every request goes through. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index a2968dc5770..b604447d2e2 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -376,7 +376,8 @@ private void assertFullStackProject(Template template, Map entri String packageName, String mainClassName, String label) throws IOException { boolean rides = template == Template.WAYLINE; boolean bank = template == Template.BASTION; - boolean game = template == Template.SKIRMISH; + boolean game = template == Template.SKIRMISH || template == Template.DASH; + boolean runner = template == Template.DASH; boolean video = template == Template.REEL; boolean studio = template == Template.PULSE; String name = template.SOURCE_MAIN_CLASS; @@ -393,7 +394,7 @@ private void assertFullStackProject(Template template, Map entri // project once got the ride-hailing accounts and chapters. String readme = getText(entries, "README.md"); boolean chat = template == Template.PARLEY; - assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : studio ? "fitness studio app" : video ? "video streaming service" : "food-delivery app", + assertContains(readme, game ? (runner ? "endless runner" : "card battler") : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : studio ? "fitness studio app" : video ? "video streaming service" : "food-delivery app", label + "README names the app"); if (!game) { assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : studio ? "`member@" : video ? "`viewer@" : "`customer@") + artifact + ".example`", diff --git a/scripts/initializr/e2e-shipped.sh b/scripts/initializr/e2e-shipped.sh index a94e4ac7e41..37624830126 100755 --- a/scripts/initializr/e2e-shipped.sh +++ b/scripts/initializr/e2e-shipped.sh @@ -12,7 +12,7 @@ # # e2e-shipped.sh [--require-latest] [--no-ui] [TEMPLATE ...] # -# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL or PULSE; all of them +# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, PARLEY, BASTION, REEL or PULSE; all of them # by default. Every other check of the templates builds them against this # checkout's 8.0-SNAPSHOT, which no developer has: the generated pom names the # release the Initializr pins, and that is the only thing this script builds @@ -63,7 +63,7 @@ for arg in "$@"; do *) TEMPLATES+=("$arg") ;; esac done -[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION REEL PULSE) +[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH DASH PARLEY BASTION REEL PULSE) log() { echo "[e2e-shipped] $*" >&2; } fail() { echo "[e2e-shipped] FAIL: $*" >&2; exit 1; } diff --git a/scripts/ios/screenshots-metal/AnimateHierarchyScreenshotTest.png b/scripts/ios/screenshots-metal/AnimateHierarchyScreenshotTest.png index e1a6e5c8056..41ed820c176 100644 Binary files a/scripts/ios/screenshots-metal/AnimateHierarchyScreenshotTest.png and b/scripts/ios/screenshots-metal/AnimateHierarchyScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/AnimateLayoutScreenshotTest.png b/scripts/ios/screenshots-metal/AnimateLayoutScreenshotTest.png index 5e2f0a584b6..5c80aadd5f0 100644 Binary files a/scripts/ios/screenshots-metal/AnimateLayoutScreenshotTest.png and b/scripts/ios/screenshots-metal/AnimateLayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/AnimateUnlayoutScreenshotTest.png b/scripts/ios/screenshots-metal/AnimateUnlayoutScreenshotTest.png index eed0fafcaa6..5f06cfa4985 100644 Binary files a/scripts/ios/screenshots-metal/AnimateUnlayoutScreenshotTest.png and b/scripts/ios/screenshots-metal/AnimateUnlayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png b/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png index c4218a1b0c5..f353af1bc6c 100644 Binary files a/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png and b/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png b/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png index 933046c7a2a..95a820f15fa 100644 Binary files a/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png and b/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/SheetSlideUpAnimationScreenshotTest.png b/scripts/ios/screenshots-metal/SheetSlideUpAnimationScreenshotTest.png index 7c9e961b884..afe4128cddd 100644 Binary files a/scripts/ios/screenshots-metal/SheetSlideUpAnimationScreenshotTest.png and b/scripts/ios/screenshots-metal/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/SmoothScrollScreenshotTest.png b/scripts/ios/screenshots-metal/SmoothScrollScreenshotTest.png index f406694504e..3ea172e4539 100644 Binary files a/scripts/ios/screenshots-metal/SmoothScrollScreenshotTest.png and b/scripts/ios/screenshots-metal/SmoothScrollScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/StatusBarTapDiagnosticScreenshotTest.png b/scripts/ios/screenshots-metal/StatusBarTapDiagnosticScreenshotTest.png index bd877cb653c..ec02e416838 100644 Binary files a/scripts/ios/screenshots-metal/StatusBarTapDiagnosticScreenshotTest.png and b/scripts/ios/screenshots-metal/StatusBarTapDiagnosticScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots-metal/StickyHeaderFadeTransitionScreenshotTest.png index 4d4f89bd112..8a850000024 100644 Binary files a/scripts/ios/screenshots-metal/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/ios/screenshots-metal/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/StickyHeaderScreenshotTest.png b/scripts/ios/screenshots-metal/StickyHeaderScreenshotTest.png index 7c64025c998..1faa0dd2ae8 100644 Binary files a/scripts/ios/screenshots-metal/StickyHeaderScreenshotTest.png and b/scripts/ios/screenshots-metal/StickyHeaderScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/ios/screenshots-metal/StickyHeaderSlideTransitionScreenshotTest.png index 92aeaba0f20..7eae4b67743 100644 Binary files a/scripts/ios/screenshots-metal/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/ios/screenshots-metal/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png index d4635a8a5da..bae53c607c4 100644 Binary files a/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png and b/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/TensileBounceScreenshotTest.png b/scripts/ios/screenshots-metal/TensileBounceScreenshotTest.png index 7372b38dac1..969fc93affc 100644 Binary files a/scripts/ios/screenshots-metal/TensileBounceScreenshotTest.png and b/scripts/ios/screenshots-metal/TensileBounceScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/AnimateHierarchyScreenshotTest.png b/scripts/ios/screenshots-tv/AnimateHierarchyScreenshotTest.png index 67dee24470c..7dabece7f16 100644 Binary files a/scripts/ios/screenshots-tv/AnimateHierarchyScreenshotTest.png and b/scripts/ios/screenshots-tv/AnimateHierarchyScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/AnimateLayoutScreenshotTest.png b/scripts/ios/screenshots-tv/AnimateLayoutScreenshotTest.png index b42e314f988..6440932ce59 100644 Binary files a/scripts/ios/screenshots-tv/AnimateLayoutScreenshotTest.png and b/scripts/ios/screenshots-tv/AnimateLayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/AnimateUnlayoutScreenshotTest.png b/scripts/ios/screenshots-tv/AnimateUnlayoutScreenshotTest.png index f5748f2371f..fab98807bb5 100644 Binary files a/scripts/ios/screenshots-tv/AnimateUnlayoutScreenshotTest.png and b/scripts/ios/screenshots-tv/AnimateUnlayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/MorphTransitionScrolledSourceTest.png b/scripts/ios/screenshots-tv/MorphTransitionScrolledSourceTest.png index ce2588ab38f..b882648617e 100644 Binary files a/scripts/ios/screenshots-tv/MorphTransitionScrolledSourceTest.png and b/scripts/ios/screenshots-tv/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/ios/screenshots-tv/PullToRefreshSpinnerScreenshotTest.png b/scripts/ios/screenshots-tv/PullToRefreshSpinnerScreenshotTest.png index 2f6a8bf5d79..8a95d58180f 100644 Binary files a/scripts/ios/screenshots-tv/PullToRefreshSpinnerScreenshotTest.png and b/scripts/ios/screenshots-tv/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/SheetSlideUpAnimationScreenshotTest.png b/scripts/ios/screenshots-tv/SheetSlideUpAnimationScreenshotTest.png index 77964e3049b..9c84b83036c 100644 Binary files a/scripts/ios/screenshots-tv/SheetSlideUpAnimationScreenshotTest.png and b/scripts/ios/screenshots-tv/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/SmoothScrollScreenshotTest.png b/scripts/ios/screenshots-tv/SmoothScrollScreenshotTest.png index d336e558a98..b08d2f8844d 100644 Binary files a/scripts/ios/screenshots-tv/SmoothScrollScreenshotTest.png and b/scripts/ios/screenshots-tv/SmoothScrollScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/StatusBarTapDiagnosticScreenshotTest.png b/scripts/ios/screenshots-tv/StatusBarTapDiagnosticScreenshotTest.png index 2821ffdffd2..df9799d4bdb 100644 Binary files a/scripts/ios/screenshots-tv/StatusBarTapDiagnosticScreenshotTest.png and b/scripts/ios/screenshots-tv/StatusBarTapDiagnosticScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots-tv/StickyHeaderFadeTransitionScreenshotTest.png index 3e0862be6ed..68aedc1d2ba 100644 Binary files a/scripts/ios/screenshots-tv/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/ios/screenshots-tv/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/StickyHeaderScreenshotTest.png b/scripts/ios/screenshots-tv/StickyHeaderScreenshotTest.png index 161ff05b50f..1581aaf1f91 100644 Binary files a/scripts/ios/screenshots-tv/StickyHeaderScreenshotTest.png and b/scripts/ios/screenshots-tv/StickyHeaderScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/ios/screenshots-tv/StickyHeaderSlideTransitionScreenshotTest.png index e41533d96e1..d786c6460a6 100644 Binary files a/scripts/ios/screenshots-tv/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/ios/screenshots-tv/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots-tv/TabsAnimatedIndicatorScreenshotTest.png index b2eeb524341..cab123066db 100644 Binary files a/scripts/ios/screenshots-tv/TabsAnimatedIndicatorScreenshotTest.png and b/scripts/ios/screenshots-tv/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/ios/screenshots-tv/TensileBounceScreenshotTest.png b/scripts/ios/screenshots-tv/TensileBounceScreenshotTest.png index f5564504b10..a598d0346e0 100644 Binary files a/scripts/ios/screenshots-tv/TensileBounceScreenshotTest.png and b/scripts/ios/screenshots-tv/TensileBounceScreenshotTest.png differ diff --git a/scripts/ios/screenshots-watch/Sheet.png b/scripts/ios/screenshots-watch/Sheet.png index f68f9d3436d..ac0cab64185 100644 Binary files a/scripts/ios/screenshots-watch/Sheet.png and b/scripts/ios/screenshots-watch/Sheet.png differ diff --git a/scripts/javase/lib/SimulatorWindowModeVerifier.java b/scripts/javase/lib/SimulatorWindowModeVerifier.java index 39e1b60905f..952988b26c7 100644 --- a/scripts/javase/lib/SimulatorWindowModeVerifier.java +++ b/scripts/javase/lib/SimulatorWindowModeVerifier.java @@ -352,7 +352,12 @@ private static boolean isSingleWindowDeviceMissing(Args args, BufferedImage imag } private static int countSingleWindowDevicePixels(Args args, BufferedImage image) { - if (!"single".equals(args.mode)) { + // Multi-window mode puts the device in a window of its own, which can + // map before it paints: a capture then shows the two menu strips over + // an empty frame, which is not flat, so only this count catches it. + // Measured: the stored multi references hold about 60k (portrait) and + // 101k (landscape) dark device pixels here, the empty frame none. + if (!"single".equals(args.mode) && !"multi".equals(args.mode)) { return -1; } int xMax = Math.min(image.getWidth(), 560); diff --git a/scripts/sync-initializr-template.py b/scripts/sync-initializr-template.py index 957c0b7c5e4..2adacce06c0 100644 --- a/scripts/sync-initializr-template.py +++ b/scripts/sync-initializr-template.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Keep an Initializr full-stack template aligned with its sample (--write to update). - python3 scripts/sync-initializr-template.py wayline|savor|skirmish|parley|bastion|reel|pulse [--write] + python3 scripts/sync-initializr-template.py wayline|savor|skirmish|dash|parley|bastion|reel|pulse [--write] scripts/ is the project CI builds and tests. The Initializr offers the same project as a template, from five resources this script derives from it: @@ -48,7 +48,7 @@ root = Path(__file__).resolve().parents[1] # The samples the Initializr offers as full-stack templates. Each is a project # under scripts/ whose Java package is com.codenameone.examples.. -SAMPLES = ('wayline', 'savor', 'skirmish', 'parley', 'bastion', 'reel', 'pulse') +SAMPLES = ('wayline', 'savor', 'skirmish', 'dash', 'parley', 'bastion', 'reel', 'pulse') args = [a for a in sys.argv[1:] if not a.startswith('--')] if len(args) != 1 or args[0] not in SAMPLES: raise SystemExit('usage: sync-initializr-template.py ' + '|'.join(SAMPLES) + ' [--write]') diff --git a/scripts/tests/GenerateInitializr.java b/scripts/tests/GenerateInitializr.java index 5d572bf0511..1212ada5a6c 100644 --- a/scripts/tests/GenerateInitializr.java +++ b/scripts/tests/GenerateInitializr.java @@ -90,7 +90,7 @@ public static void main(String[] args) throws Exception { } // The full-stack templates, each in both of its layouts and in a colour scheme of // its own with an icon: an app, its server and the module they share. - for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, Template.BASTION, Template.REEL, Template.PULSE}) { + for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.DASH, Template.PARLEY, Template.BASTION, Template.REEL, Template.PULSE}) { for (boolean full : new boolean[] {false, true}) { ProjectOptions options = ProjectOptions.defaults().forFullStack().withPlatformModules(full) .withScheme(0x0f766e, false).withIcon(new byte[] {(byte)0x89, 'P', 'N', 'G'}); diff --git a/scripts/tests/generate-initializr-fixtures.py b/scripts/tests/generate-initializr-fixtures.py index 7938b4ed517..fa3a4c29310 100644 --- a/scripts/tests/generate-initializr-fixtures.py +++ b/scripts/tests/generate-initializr-fixtures.py @@ -86,6 +86,7 @@ def check_maven_layout_fixture(archive, z): 'WAYLINE': ('api/RiderApi.java', 'rider/RiderForm.java'), 'SAVOR': ('api/OrderApi.java', 'customer/CustomerForm.java'), 'SKIRMISH': ('api/AccountApi.java', 'ui/HomeForm.java'), + 'DASH': ('api/AccountApi.java', 'ui/HomeForm.java'), 'PARLEY': ('api/ConversationApi.java', 'ui/CallScreen.java'), 'REEL': ('api/CatalogApi.java', 'ui/WelcomeForm.java'), 'BASTION': ('api/BankingApi.java', 'ui/HomeForm.java'), @@ -98,6 +99,7 @@ def check_maven_layout_fixture(archive, z): 'WAYLINE': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', 'SAVOR': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', 'SKIRMISH': None, + 'DASH': None, 'PARLEY': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', 'BASTION': 'codename1.arg.ios.detectJailbreak=true', 'REEL': None, @@ -129,8 +131,8 @@ def check_full_stack_fixture(archive, z, template): full = archive.name.split('-')[1] == 'FULL' root_pom = ''.join(z.read('pom.xml').decode().split()) assert 'sharedcommon' in root_pom, archive.name - # SKIRMISH also has the game's rules as a module of its own, ahead of the contract. - rules = template == 'SKIRMISH' + # SKIRMISH and DASH also have the game's rules as a module of its own, ahead of the contract. + rules = template in ('SKIRMISH', 'DASH') assert (not rules) or 'rulesshared' in root_pom, archive.name for module in ('shared', 'common', 'backend') + (('rules',) if rules else ()): assert module + '/pom.xml' in names, (archive.name, module)