()
+ for (i in 0 until puzzles.length()) {
+ val p = puzzles.getJSONObject(i)
+ val id = p.getInt("id")
+ assertTrue("duplicate id $id", ids.add(id))
+ var board = Board.fromFen(p.getString("fen"))
+ val solution = p.getJSONArray("solution")
+ assertEquals("puzzle $id must end with the solver's move", 1, solution.length() % 2)
+ for (j in 0 until solution.length()) {
+ val wanted = Move.fromUci(solution.getString(j))
+ val move = ChessEngine.legalMoves(board).firstOrNull {
+ it.from == wanted.from && it.to == wanted.to && it.promotion == wanted.promotion
+ }
+ assertNotNull("puzzle $id, move ${j + 1} ${solution.getString(j)} is illegal", move)
+ board = ChessEngine.applyMove(board, move!!)
+ }
+ if (p.optString("theme").startsWith("mate_in_")) {
+ assertEquals("puzzle $id", GameStatus.CHECKMATE, ChessEngine.gameStatus(board))
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/cc/skysparkle/matewave/SessionAuthTest.kt b/app/src/test/java/cc/skysparkle/matewave/SessionAuthTest.kt
new file mode 100644
index 0000000..4bfe0e0
--- /dev/null
+++ b/app/src/test/java/cc/skysparkle/matewave/SessionAuthTest.kt
@@ -0,0 +1,49 @@
+package cc.skysparkle.matewave
+
+import cc.skysparkle.matewave.security.SessionAuth
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SessionAuthTest {
+ private fun auth() = SessionAuth(ByteArray(32) { it.toByte() })
+
+ @Test
+ fun acceptsInOrder() {
+ val a = auth()
+ assertTrue(a.acceptSeq(1))
+ assertTrue(a.acceptSeq(2))
+ assertTrue(a.acceptSeq(3))
+ }
+
+ @Test
+ fun rejectsReplay() {
+ val a = auth()
+ assertTrue(a.acceptSeq(5))
+ assertFalse(a.acceptSeq(5))
+ }
+
+ @Test
+ fun acceptsLateButUnseen() {
+ val a = auth()
+ assertTrue(a.acceptSeq(1))
+ assertTrue(a.acceptSeq(3))
+ assertTrue(a.acceptSeq(2))
+ assertFalse(a.acceptSeq(2))
+ }
+
+ @Test
+ fun rejectsOlderThanWindow() {
+ val a = auth()
+ assertTrue(a.acceptSeq(1))
+ assertTrue(a.acceptSeq(1_000))
+ assertFalse(a.acceptSeq(2))
+ }
+
+ @Test
+ fun rejectsNonPositive() {
+ val a = auth()
+ assertFalse(a.acceptSeq(0))
+ assertFalse(a.acceptSeq(-1))
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..19bda35
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "9.2.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt
new file mode 100644
index 0000000..aa035e8
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/1.txt
@@ -0,0 +1 @@
+First release: play against the AI, puzzles, opening training with spaced repetition and nearby play over Bluetooth or Wi-Fi.
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..cb29bf1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,38 @@
+Matewave is an offline-first chess app. Play against the AI, solve puzzles, learn openings, or play a friend nearby over Bluetooth or Wi-Fi. No accounts, no servers, no ads and no tracking.
+
+Play against the AI
+
+- Four difficulty levels, from beginner to strong club player.
+- Time controls from bullet 1+0 to classical 30+20, or no time limit.
+- An unfinished game is saved with its whole move list and clock and can be continued later.
+
+
+Puzzles
+
+- 1,207 puzzles from the Lichess puzzle database, picked to match your rating.
+- Hints, the solution on request, and progress through multi-move puzzles.
+
+
+Learn openings with spaced repetition
+
+- 24 main lines for White and Black.
+- New moves are learned with the piece highlighted; reviews ask only the moves that are due.
+- Earn up to three stars per opening with flawless scheduled reviews.
+
+
+Play with friends nearby
+
+- Over Bluetooth LE (no pairing) or the same Wi-Fi network, without internet.
+- Chat with images, draw offers, resignation and rematch.
+- Games are encrypted end to end, and each device has its own key, so a friend cannot be impersonated.
+
+
+Also
+
+- The opening name and whether the game is still in theory, shown during games.
+- Rating with a history graph, captured pieces and material balance.
+- Four board color themes.
+- Optional Radio Swiss Jazz background stream (the only feature that uses the internet).
+- Share the app to another phone with a QR code over Wi-Fi.
+- Available in English, Russian, German, Lithuanian, Japanese and Chinese.
+
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000..5138bb5
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
new file mode 100644
index 0000000..5b33913
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
new file mode 100644
index 0000000..95c93f1
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
new file mode 100644
index 0000000..312ba01
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
new file mode 100644
index 0000000..aa2085e
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
new file mode 100644
index 0000000..0a02860
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..62e31a7
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Offline chess: AI, puzzles, opening training and play with friends nearby
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..28fb0b1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+Matewave
diff --git a/fastlane/metadata/android/ru-RU/changelogs/1.txt b/fastlane/metadata/android/ru-RU/changelogs/1.txt
new file mode 100644
index 0000000..0136192
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/1.txt
@@ -0,0 +1 @@
+Первый выпуск: игра с ИИ, задачи, изучение дебютов с интервальными повторениями и игра рядом по Bluetooth или Wi-Fi.
diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt
new file mode 100644
index 0000000..a55610f
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/full_description.txt
@@ -0,0 +1,38 @@
+Matewave — шахматы, которым не нужен интернет. Играйте с ИИ, решайте задачи, учите дебюты или играйте с другом рядом по Bluetooth или Wi-Fi. Без аккаунтов, серверов, рекламы и слежки.
+
+Игра с ИИ
+
+- Четыре уровня сложности — от новичка до сильного любителя.
+- Контроль времени от пули 1+0 до классики 30+20 или без ограничения.
+- Незаконченная партия сохраняется вместе со всеми ходами и часами, её можно продолжить позже.
+
+
+Задачи
+
+- 1207 задач из базы Lichess, подобранных под ваш рейтинг.
+- Подсказки, решение по запросу и прогресс в многоходовых задачах.
+
+
+Изучение дебютов с интервальными повторениями
+
+- 24 основных варианта за белых и за чёрных.
+- Новые ходы учатся с подсвеченной фигурой, а на повторении спрашиваются только те ходы, которым пора.
+- До трёх звёзд за дебют за безошибочные повторения по расписанию.
+
+
+Игра с друзьями рядом
+
+- По Bluetooth LE (без сопряжения) или в одной сети Wi-Fi, без интернета.
+- Чат с картинками, предложение ничьей, сдача и реванш.
+- Партии зашифрованы, у каждого устройства свой ключ, поэтому выдать себя за друга нельзя.
+
+
+А ещё
+
+- Название дебюта во время партии и отметка, идёт ли игра ещё по теории.
+- Рейтинг с графиком, взятые фигуры и материальный перевес.
+- Четыре цветовые темы доски.
+- Фоновое радио Radio Swiss Jazz по желанию — единственная функция, которой нужен интернет.
+- Передача приложения на другой телефон через QR-код по Wi-Fi.
+- Языки: английский, русский, немецкий, литовский, японский и китайский.
+
diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt
new file mode 100644
index 0000000..c570c83
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/short_description.txt
@@ -0,0 +1 @@
+Шахматы без интернета: ИИ, задачи, изучение дебютов и игра с друзьями рядом
diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt
new file mode 100644
index 0000000..28fb0b1
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/title.txt
@@ -0,0 +1 @@
+Matewave
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..def95d4
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,8 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+kotlin.code.style=official
+
+org.gradle.parallel=true
+org.gradle.caching=true
+
+org.gradle.configuration-cache=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c56b367
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..0eda5ae
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,242 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# "${var}" itself is not word-split.
+#
+# Under Cygwin or MSYS, only functions that are currently useful are
+# defined; those functions are only called then, so they check for
+# Cygwin/MSYS in advance.
+#
+# There are tests in "gradle/wrapper/wrapper-functions-test.sh" that
+# exercise the murkier or more esoteric bash features and workarounds.
+#
+###############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it
+ # appeared originally.
+ set -- "$arg" "$@"
+ shift
+ done
+fi
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${JAVA_OPTS} to expand to embedded shell code, as it is an unquoted string.
+# * Distributions from the shell run the wrapper as normal.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..25da30d
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..035321d
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Matewave"
+include(":app")
diff --git a/tools/sfx/generate_sfx.py b/tools/sfx/generate_sfx.py
new file mode 100644
index 0000000..b7fc7cf
--- /dev/null
+++ b/tools/sfx/generate_sfx.py
@@ -0,0 +1,121 @@
+"""Synthesizes the board sound effects (CC0 1.0).
+
+Usage:
+ python3 generate_sfx.py OUT_DIR
+ for s in move capture select; do
+ ffmpeg -y -fflags +bitexact -i OUT_DIR/sfx_$s.wav -map_metadata -1 \
+ -c:a libvorbis -q:a 5 ../../app/src/main/res/raw/sfx_$s.ogg
+ done
+"""
+
+import sys
+import wave
+from pathlib import Path
+
+import numpy as np
+
+SR = 44100
+
+
+def modes(t, rng, specs):
+ out = np.zeros_like(t)
+ for freq, amp, tau in specs:
+ out += amp * np.sin(2 * np.pi * freq * t + rng.uniform(0, 2 * np.pi)) * np.exp(-t / tau)
+ return out
+
+
+def band_noise(n, lo, hi, rng):
+ spectrum = np.fft.rfft(rng.standard_normal(n))
+ freqs = np.fft.rfftfreq(n, 1 / SR)
+ spectrum[(freqs < lo) | (freqs > hi)] = 0
+ x = np.fft.irfft(spectrum, n)
+ return x / np.max(np.abs(x))
+
+
+def hit(dur, specs, noise_band, noise_tau, noise_amp, rng):
+ n = int(SR * dur)
+ t = np.arange(n) / SR
+ x = modes(t, rng, specs)
+ x += noise_amp * band_noise(n, *noise_band, rng) * np.exp(-t / noise_tau)
+ return x * np.minimum(1.0, t / 0.0004)
+
+
+def board_knock(dur, rng, scale=1.0):
+ return hit(
+ dur,
+ [
+ (125 * scale, 0.45, 0.018),
+ (410 * scale, 1.00, 0.042),
+ (1130 * scale, 0.55, 0.024),
+ (2210 * scale, 0.30, 0.013),
+ (3050 * scale, 0.35, 0.007),
+ (4700 * scale, 0.15, 0.004),
+ ],
+ (1200, 8000), 0.0025, 0.55, rng,
+ )
+
+
+def piece_clack(dur, rng):
+ return hit(
+ dur,
+ [
+ (1650, 0.40, 0.016),
+ (2450, 1.00, 0.012),
+ (3900, 0.60, 0.008),
+ (6100, 0.30, 0.004),
+ ],
+ (1800, 10000), 0.0018, 0.70, rng,
+ )
+
+
+def finish(x, rms_db, fade=0.012):
+ n = int(SR * fade)
+ x = x.copy()
+ x[-n:] *= np.linspace(1, 0, n)
+ x *= 10 ** (rms_db / 20) / np.sqrt(np.mean(x ** 2))
+ peak = np.max(np.abs(x))
+ return x if peak <= 0.94 else x * 0.94 / peak
+
+
+def move(rng):
+ return finish(board_knock(0.20, rng), -24.0)
+
+
+def capture(rng):
+ total = int(SR * 0.30)
+ x = np.zeros(total)
+ clack = piece_clack(0.10, rng) * 0.85
+ x[: len(clack)] += clack
+ start = int(SR * 0.030)
+ knock = board_knock(0.30 - 0.030, rng, scale=0.94)
+ x[start:start + len(knock)] += knock
+ return finish(x, -25.0)
+
+
+def select(rng):
+ x = hit(
+ 0.10,
+ [(1850, 0.60, 0.007), (3300, 1.00, 0.005), (5200, 0.35, 0.003)],
+ (2500, 9000), 0.0012, 0.45, rng,
+ )
+ return finish(x, -27.0, fade=0.008)
+
+
+def write_wav(path, x):
+ pcm = np.clip(np.round(x * 32767), -32768, 32767).astype("