Minecraft mining session stat tracker
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: initial commit

+2536
+107
.checkstyle/checkstyle.xml
··· 1 + <?xml version="1.0"?> 2 + <!DOCTYPE module PUBLIC 3 + "-//Puppy Crawl//DTD Check Configuration 1.3//EN" 4 + "https://checkstyle.org/dtds/configuration_1_3.dtd"> 5 + 6 + <module name="Checker"> 7 + 8 + <property name="charset" value="UTF-8"/> 9 + <property name="fileExtensions" value="java, properties, xml"/> 10 + <property name="severity" value="error"/> 11 + 12 + <!-- https://checkstyle.org/filefilters/beforeexecutionexclusionfilefilter.html --> 13 + <module name="BeforeExecutionExclusionFileFilter"> 14 + <property name="fileNamePattern" value="module\-info\.java$"/> 15 + </module> 16 + 17 + <!-- https://checkstyle.org/checks/whitespace/filetabcharacter.html --> 18 + <module name="FileTabCharacter"> 19 + <property name="eachLine" value="true"/> 20 + </module> 21 + 22 + <!-- https://checkstyle.org/checks/misc/newlineatendoffile.html --> 23 + <module name="NewlineAtEndOfFile"/> 24 + 25 + <!-- https://checkstyle.org/filters/suppressionfilter.html --> 26 + <module name="SuppressionFilter"> 27 + <property name="file" value="${configDirectory}/suppressions.xml"/> 28 + </module> 29 + 30 + <!-- https://checkstyle.org/filters/suppresswarningsfilter.html --> 31 + <module name="SuppressWarningsFilter"/> 32 + 33 + <module name="TreeWalker"> 34 + 35 + <!-- https://checkstyle.org/checks/misc/arraytypestyle.html --> 36 + <module name="ArrayTypeStyle"/> 37 + 38 + <!-- https://checkstyle.org/checks/imports/avoidstarimport.html --> 39 + <module name="AvoidStarImport"/> 40 + 41 + <!-- https://checkstyle.org/checks/design/finalclass.html --> 42 + <module name="FinalClass"/> 43 + 44 + <!-- https://checkstyle.org/checks/coding/finallocalvariable.html --> 45 + <module name="FinalLocalVariable"> 46 + <property name="tokens" value="PARAMETER_DEF, VARIABLE_DEF"/> 47 + <property name="validateEnhancedForLoopVariable" value="true"/> 48 + </module> 49 + 50 + <!-- https://checkstyle.org/checks/imports/illegalimport.html --> 51 + <module name="IllegalImport"> 52 + <property name="illegalPkgs" 53 + value="sun, jdk, com.sun, org.jetbrains.annotations.Nullable, org.jetbrains.annotations.NotNull"/> 54 + </module> 55 + 56 + <!-- https://checkstyle.org/checks/javadoc/invalidjavadocposition.html --> 57 + <module name="InvalidJavadocPosition"/> 58 + 59 + <!-- https://checkstyle.org/checks/javadoc/javadoccontentlocation.html --> 60 + <module name="JavadocContentLocation"/> 61 + 62 + <!-- https://checkstyle.org/checks/javadoc/javadocmethod.html --> 63 + <module name="JavadocMethod"/> 64 + 65 + <!-- https://checkstyle.org/checks/javadoc/javadocmissingwhitespaceafterasterisk.html --> 66 + <module name="JavadocMissingWhitespaceAfterAsterisk"/> 67 + 68 + <!-- https://checkstyle.org/checks/javadoc/javadocparagraph.html --> 69 + <module name="JavadocParagraph"/> 70 + 71 + <!-- https://checkstyle.org/checks/javadoc/javadoctagcontinuationindentation.html --> 72 + <module name="JavadocTagContinuationIndentation"/> 73 + 74 + <!-- https://checkstyle.org/checks/blocks/leftcurly.html --> 75 + <module name="LeftCurly"/> 76 + 77 + <!-- https://checkstyle.org/checks/coding/matchxpath.html --> 78 + <module name="MatchXpath"> 79 + <property name="query" value="//ANNOTATION[./IDENT[@text='NotNull']]"/> 80 + <message key="matchxpath.match" 81 + value="Avoid using @NotNull annotation. Use @NonNull instead."/> 82 + </module> 83 + 84 + <!-- https://checkstyle.org/checks/naming/methodname.html --> 85 + <module name="MethodName"> 86 + <property name="format" 87 + value="^(?:(?:.{1,3})|(?:[gs]et[^A-Z].*)|(?:(?:[^gsA-Z]..|.[^e].|..[^t]).+))$"/> 88 + </module> 89 + 90 + <!-- https://tangled.org/nayrid.com/checks/blob/main/src/main/java/com/nayrid/checks/NoGenericExceptionCheck.java --> 91 + <module name="com.nayrid.checks.NoGenericExceptionCheck"/> 92 + 93 + <!-- https://tangled.org/nayrid.com/checks/blob/main/src/main/java/com/nayrid/checks/RequireSinceCheck.java --> 94 + <module name="com.nayrid.checks.RequireSinceCheck"/> 95 + 96 + <!-- https://checkstyle.org/checks/coding/requirethis.html --> 97 + <module name="RequireThis"/> 98 + 99 + <!-- https://checkstyle.org/filters/suppresswarningsfilter.html --> 100 + <module name="SuppressWarningsHolder"/> 101 + 102 + <!-- https://checkstyle.org/filters/suppressioncommentfilter.html --> 103 + <module name="SuppressionCommentFilter"/> 104 + 105 + </module> 106 + 107 + </module>
+6
.checkstyle/suppressions.xml
··· 1 + <?xml version="1.0"?> 2 + <!DOCTYPE suppressions PUBLIC 3 + "-//Puppy Crawl//DTD Suppressions 1.2//EN" 4 + "https://checkstyle.org/dtds/suppressions_1_2.dtd"> 5 + 6 + <suppressions/>
+12
.gitattributes
··· 1 + # 2 + # https://help.github.com/articles/dealing-with-line-endings/ 3 + # 4 + # Linux start script should use lf 5 + /gradlew text eol=lf 6 + 7 + # These are Windows script files and should use crlf 8 + *.bat text eol=crlf 9 + 10 + # Binary files should be left untouched 11 + *.jar binary 12 +
+5
.gitignore
··· 1 + .gradle/ 2 + build/ 3 + .kotlin/ 4 + run/ 5 + .idea
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 kokiriglade 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+7
README.md
··· 1 + # mining 2 + 3 + A mining stat tracker, for fabric 26.1.2. 4 + 5 + ## Disclaimer 6 + 7 + This is my first fabric mod, so some stuff might be a bit crap.
+78
build.gradle.kts
··· 1 + plugins { 2 + alias(libs.plugins.fabric.loom) 3 + `maven-publish` 4 + java 5 + checkstyle 6 + } 7 + 8 + val modVersion: String by project 9 + val mavenGroup: String by project 10 + val archivesBaseName: String by project 11 + 12 + version = modVersion 13 + group = mavenGroup 14 + 15 + base { 16 + archivesName = archivesBaseName 17 + } 18 + 19 + repositories { 20 + maven("https://maven.terraformersmc.com/releases") { name = "TerraformersMC (ModMenu)" } 21 + maven { 22 + name = "nayridSnapshots" 23 + url = uri("https://repo.nayrid.com/snapshots") 24 + } 25 + mavenCentral() 26 + } 27 + 28 + dependencies { 29 + minecraft("com.mojang:minecraft:${libs.versions.minecraft.get()}") 30 + implementation(libs.fabric.loader) 31 + implementation(libs.fabric.api) 32 + implementation(libs.modmenu) 33 + include(implementation(libs.sqlite.jdbc.get())!!) 34 + implementation(libs.jspecify) 35 + checkstyle(libs.checks) 36 + } 37 + 38 + checkstyle { 39 + toolVersion = libs.versions.checkstyle.get() 40 + configDirectory = file(".checkstyle") 41 + configFile = file(".checkstyle/checkstyle.xml") 42 + } 43 + 44 + tasks.withType<Checkstyle>().configureEach { 45 + configProperties = mapOf("configDirectory" to configDirectory.get().asFile.absolutePath) 46 + } 47 + 48 + java { 49 + withSourcesJar() 50 + toolchain { 51 + languageVersion = JavaLanguageVersion.of(25) 52 + } 53 + } 54 + 55 + tasks.withType<JavaCompile>().configureEach { 56 + options.encoding = "UTF-8" 57 + options.release = 25 58 + } 59 + 60 + tasks.processResources { 61 + inputs.property("version", project.version) 62 + filesMatching("fabric.mod.json") { 63 + expand("version" to project.version) 64 + } 65 + } 66 + 67 + tasks.jar { 68 + from("LICENSE") 69 + dependsOn(tasks.check) 70 + } 71 + 72 + publishing { 73 + publications { 74 + create<MavenPublication>("mavenJava") { 75 + from(components["java"]) 76 + } 77 + } 78 + }
+5
gradle.properties
··· 1 + org.gradle.jvmargs=-Xmx1G 2 + org.gradle.parallel=true 3 + modVersion=1.0.0+26.1.2 4 + mavenGroup=de.kokirigla.mining 5 + archivesBaseName=mining
+23
gradle/libs.versions.toml
··· 1 + [versions] 2 + minecraft = "26.1.2" 3 + fabric-loader = "0.19.2" 4 + fabric-loom = "1.17.0-alpha.7" 5 + fabric-api = "0.146.1+26.1.2" 6 + modmenu = "18.0.0-alpha.8" 7 + sqlite-jdbc = "3.47.1.0" 8 + jspecify = "1.0.0" 9 + checkstyle = "13.2.0" 10 + checks = "1.1.0-SNAPSHOT" 11 + 12 + [libraries] 13 + minecraft = { module = "com.mojang:minecraft", version.ref = "minecraft" } 14 + fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric-loader" } 15 + fabric-api = { module = "net.fabricmc.fabric-api:fabric-api", version.ref = "fabric-api" } 16 + modmenu = { module = "com.terraformersmc:modmenu", version.ref = "modmenu" } 17 + sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } 18 + jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } 19 + checkstyle = { group = "com.puppycrawl.tools", name = "checkstyle", version.ref = "checkstyle" } 20 + checks = { group = "com.nayrid", name = "checks", version.ref = "checks" } 21 + 22 + [plugins] 23 + fabric-loom = { id = "net.fabricmc.fabric-loom", version.ref = "fabric-loom" }
gradle/wrapper/gradle-wrapper.jar

This is a binary file and will not be displayed.

+7
gradle/wrapper/gradle-wrapper.properties
··· 1 + distributionBase=GRADLE_USER_HOME 2 + distributionPath=wrapper/dists 3 + distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip 4 + networkTimeout=10000 5 + validateDistributionUrl=true 6 + zipStoreBase=GRADLE_USER_HOME 7 + zipStorePath=wrapper/dists
+248
gradlew
··· 1 + #!/bin/sh 2 + 3 + # 4 + # Copyright © 2015 the original authors. 5 + # 6 + # Licensed under the Apache License, Version 2.0 (the "License"); 7 + # you may not use this file except in compliance with the License. 8 + # You may obtain a copy of the License at 9 + # 10 + # https://www.apache.org/licenses/LICENSE-2.0 11 + # 12 + # Unless required by applicable law or agreed to in writing, software 13 + # distributed under the License is distributed on an "AS IS" BASIS, 14 + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 + # See the License for the specific language governing permissions and 16 + # limitations under the License. 17 + # 18 + # SPDX-License-Identifier: Apache-2.0 19 + # 20 + 21 + ############################################################################## 22 + # 23 + # Gradle start up script for POSIX generated by Gradle. 24 + # 25 + # Important for running: 26 + # 27 + # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 + # noncompliant, but you have some other compliant shell such as ksh or 29 + # bash, then to run this script, type that shell name before the whole 30 + # command line, like: 31 + # 32 + # ksh Gradle 33 + # 34 + # Busybox and similar reduced shells will NOT work, because this script 35 + # requires all of these POSIX shell features: 36 + # * functions; 37 + # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 + # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 + # * compound commands having a testable exit status, especially «case»; 40 + # * various built-in commands including «command», «set», and «ulimit». 41 + # 42 + # Important for patching: 43 + # 44 + # (2) This script targets any POSIX shell, so it avoids extensions provided 45 + # by Bash, Ksh, etc; in particular arrays are avoided. 46 + # 47 + # The "traditional" practice of packing multiple parameters into a 48 + # space-separated string is a well documented source of bugs and security 49 + # problems, so this is (mostly) avoided, by progressively accumulating 50 + # options in "$@", and eventually passing that to Java. 51 + # 52 + # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 + # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 + # see the in-line comments for details. 55 + # 56 + # There are tweaks for specific operating systems such as AIX, CygWin, 57 + # Darwin, MinGW, and NonStop. 58 + # 59 + # (3) This script is generated from the Groovy template 60 + # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 + # within the Gradle project. 62 + # 63 + # You can find Gradle at https://github.com/gradle/gradle/. 64 + # 65 + ############################################################################## 66 + 67 + # Attempt to set APP_HOME 68 + 69 + # Resolve links: $0 may be a link 70 + app_path=$0 71 + 72 + # Need this for daisy-chained symlinks. 73 + while 74 + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 + [ -h "$app_path" ] 76 + do 77 + ls=$( ls -ld "$app_path" ) 78 + link=${ls#*' -> '} 79 + case $link in #( 80 + /*) app_path=$link ;; #( 81 + *) app_path=$APP_HOME$link ;; 82 + esac 83 + done 84 + 85 + # This is normally unused 86 + # shellcheck disable=SC2034 87 + APP_BASE_NAME=${0##*/} 88 + # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 + APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 + 91 + # Use the maximum available, or set MAX_FD != -1 to use that value. 92 + MAX_FD=maximum 93 + 94 + warn () { 95 + echo "$*" 96 + } >&2 97 + 98 + die () { 99 + echo 100 + echo "$*" 101 + echo 102 + exit 1 103 + } >&2 104 + 105 + # OS specific support (must be 'true' or 'false'). 106 + cygwin=false 107 + msys=false 108 + darwin=false 109 + nonstop=false 110 + case "$( uname )" in #( 111 + CYGWIN* ) cygwin=true ;; #( 112 + Darwin* ) darwin=true ;; #( 113 + MSYS* | MINGW* ) msys=true ;; #( 114 + NONSTOP* ) nonstop=true ;; 115 + esac 116 + 117 + 118 + 119 + # Determine the Java command to use to start the JVM. 120 + if [ -n "$JAVA_HOME" ] ; then 121 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 122 + # IBM's JDK on AIX uses strange locations for the executables 123 + JAVACMD=$JAVA_HOME/jre/sh/java 124 + else 125 + JAVACMD=$JAVA_HOME/bin/java 126 + fi 127 + if [ ! -x "$JAVACMD" ] ; then 128 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 129 + 130 + Please set the JAVA_HOME variable in your environment to match the 131 + location of your Java installation." 132 + fi 133 + else 134 + JAVACMD=java 135 + if ! command -v java >/dev/null 2>&1 136 + then 137 + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 138 + 139 + Please set the JAVA_HOME variable in your environment to match the 140 + location of your Java installation." 141 + fi 142 + fi 143 + 144 + # Increase the maximum file descriptors if we can. 145 + if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 146 + case $MAX_FD in #( 147 + max*) 148 + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 149 + # shellcheck disable=SC2039,SC3045 150 + MAX_FD=$( ulimit -H -n ) || 151 + warn "Could not query maximum file descriptor limit" 152 + esac 153 + case $MAX_FD in #( 154 + '' | soft) :;; #( 155 + *) 156 + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 157 + # shellcheck disable=SC2039,SC3045 158 + ulimit -n "$MAX_FD" || 159 + warn "Could not set maximum file descriptor limit to $MAX_FD" 160 + esac 161 + fi 162 + 163 + # Collect all arguments for the java command, stacking in reverse order: 164 + # * args from the command line 165 + # * the main class name 166 + # * -classpath 167 + # * -D...appname settings 168 + # * --module-path (only if needed) 169 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 170 + 171 + # For Cygwin or MSYS, switch paths to Windows format before running java 172 + if "$cygwin" || "$msys" ; then 173 + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 174 + 175 + JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 + 177 + # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 + for arg do 179 + if 180 + case $arg in #( 181 + -*) false ;; # don't mess with options #( 182 + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 + [ -e "$t" ] ;; #( 184 + *) false ;; 185 + esac 186 + then 187 + arg=$( cygpath --path --ignore --mixed "$arg" ) 188 + fi 189 + # Roll the args list around exactly as many times as the number of 190 + # args, so each arg winds up back in the position where it started, but 191 + # possibly modified. 192 + # 193 + # NB: a `for` loop captures its iteration list before it begins, so 194 + # changing the positional parameters here affects neither the number of 195 + # iterations, nor the values presented in `arg`. 196 + shift # remove old arg 197 + set -- "$@" "$arg" # push replacement arg 198 + done 199 + fi 200 + 201 + 202 + # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 + DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 + 205 + # Collect all arguments for the java command: 206 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 + # and any embedded shellness will be escaped. 208 + # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 + # treated as '${Hostname}' itself on the command line. 210 + 211 + set -- \ 212 + "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 214 + "$@" 215 + 216 + # Stop when "xargs" is not available. 217 + if ! command -v xargs >/dev/null 2>&1 218 + then 219 + die "xargs is not available" 220 + fi 221 + 222 + # Use "xargs" to parse quoted args. 223 + # 224 + # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 + # 226 + # In Bash we could simply go: 227 + # 228 + # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 + # set -- "${ARGS[@]}" "$@" 230 + # 231 + # but POSIX shell has neither arrays nor command substitution, so instead we 232 + # post-process each arg (as a line of input to sed) to backslash-escape any 233 + # character that might be a shell metacharacter, then use eval to reverse 234 + # that process (while maintaining the separation between arguments), and wrap 235 + # the whole thing up as a single "set" statement. 236 + # 237 + # This will of course break if any of these variables contains a newline or 238 + # an unmatched quote. 239 + # 240 + 241 + eval "set -- $( 242 + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 + xargs -n1 | 244 + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 + tr '\n' ' ' 246 + )" '"$@"' 247 + 248 + exec "$JAVACMD" "$@"
+93
gradlew.bat
··· 1 + @rem 2 + @rem Copyright 2015 the original author or authors. 3 + @rem 4 + @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 + @rem you may not use this file except in compliance with the License. 6 + @rem You may obtain a copy of the License at 7 + @rem 8 + @rem https://www.apache.org/licenses/LICENSE-2.0 9 + @rem 10 + @rem Unless required by applicable law or agreed to in writing, software 11 + @rem distributed under the License is distributed on an "AS IS" BASIS, 12 + @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 + @rem See the License for the specific language governing permissions and 14 + @rem limitations under the License. 15 + @rem 16 + @rem SPDX-License-Identifier: Apache-2.0 17 + @rem 18 + 19 + @if "%DEBUG%"=="" @echo off 20 + @rem ########################################################################## 21 + @rem 22 + @rem Gradle startup script for Windows 23 + @rem 24 + @rem ########################################################################## 25 + 26 + @rem Set local scope for the variables with windows NT shell 27 + if "%OS%"=="Windows_NT" setlocal 28 + 29 + set DIRNAME=%~dp0 30 + if "%DIRNAME%"=="" set DIRNAME=. 31 + @rem This is normally unused 32 + set APP_BASE_NAME=%~n0 33 + set APP_HOME=%DIRNAME% 34 + 35 + @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 + for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 + 38 + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 + set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 + 41 + @rem Find java.exe 42 + if defined JAVA_HOME goto findJavaFromJavaHome 43 + 44 + set JAVA_EXE=java.exe 45 + %JAVA_EXE% -version >NUL 2>&1 46 + if %ERRORLEVEL% equ 0 goto execute 47 + 48 + echo. 1>&2 49 + echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 + echo. 1>&2 51 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 + echo location of your Java installation. 1>&2 53 + 54 + goto fail 55 + 56 + :findJavaFromJavaHome 57 + set JAVA_HOME=%JAVA_HOME:"=% 58 + set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 + 60 + if exist "%JAVA_EXE%" goto execute 61 + 62 + echo. 1>&2 63 + echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 + echo. 1>&2 65 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 + echo location of your Java installation. 1>&2 67 + 68 + goto fail 69 + 70 + :execute 71 + @rem Setup the command line 72 + 73 + 74 + 75 + @rem Execute Gradle 76 + "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 + 78 + :end 79 + @rem End local scope for the variables with windows NT shell 80 + if %ERRORLEVEL% equ 0 goto mainEnd 81 + 82 + :fail 83 + rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 + rem the _cmd.exe /c_ return code! 85 + set EXIT_CODE=%ERRORLEVEL% 86 + if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 + if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 + exit /b %EXIT_CODE% 89 + 90 + :mainEnd 91 + if "%OS%"=="Windows_NT" endlocal 92 + 93 + :omega
+12
settings.gradle.kts
··· 1 + pluginManagement { 2 + repositories { 3 + maven("https://maven.fabricmc.net/") { name = "Fabric" } 4 + maven { 5 + name = "nayridSnapshots" 6 + url = uri("https://repo.nayrid.com/snapshots") 7 + } 8 + gradlePluginPortal() 9 + } 10 + } 11 + 12 + rootProject.name = "mining"
+74
src/main/java/de/kokirigla/mining/MiningClient.java
··· 1 + package de.kokirigla.mining; 2 + 3 + import com.mojang.blaze3d.platform.InputConstants; 4 + import de.kokirigla.mining.command.MiningModCommand; 5 + import de.kokirigla.mining.config.MiningConfig; 6 + import de.kokirigla.mining.hud.MiningHud; 7 + import de.kokirigla.mining.screen.MineStatsScreen; 8 + import de.kokirigla.mining.session.MiningSession; 9 + import de.kokirigla.mining.session.SessionManager; 10 + import net.fabricmc.api.ClientModInitializer; 11 + import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; 12 + import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 13 + import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; 14 + import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; 15 + import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; 16 + import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements; 17 + import net.minecraft.client.KeyMapping; 18 + import net.minecraft.network.chat.Component; 19 + import net.minecraft.resources.Identifier; 20 + 21 + public class MiningClient implements ClientModInitializer { 22 + @Override 23 + public void onInitializeClient() { 24 + MiningConfig.load(); 25 + HudElementRegistry.attachElementAfter( 26 + VanillaHudElements.PLAYER_LIST, 27 + Identifier.fromNamespaceAndPath(MiningMod.MOD_ID, "hud"), 28 + (extractor, deltaTracker) -> MiningHud.extractRenderState(extractor) 29 + ); 30 + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> 31 + MiningModCommand.register(dispatcher)); 32 + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> 33 + SessionManager.get().stopSession()); 34 + 35 + final KeyMapping.Category category = KeyMapping.Category.register( 36 + Identifier.fromNamespaceAndPath(MiningMod.MOD_ID, MiningMod.MOD_ID)); 37 + final KeyMapping startKey = KeyMappingHelper.registerKeyMapping( 38 + new KeyMapping("key.mining.start", InputConstants.UNKNOWN.getValue(), category)); 39 + final KeyMapping stopKey = KeyMappingHelper.registerKeyMapping( 40 + new KeyMapping("key.mining.stop", InputConstants.UNKNOWN.getValue(), category)); 41 + final KeyMapping statsKey = KeyMappingHelper.registerKeyMapping( 42 + new KeyMapping("key.mining.stats", InputConstants.UNKNOWN.getValue(), category)); 43 + 44 + ClientTickEvents.END_CLIENT_TICK.register(mc -> { 45 + if (startKey.consumeClick()) { 46 + final SessionManager sm = SessionManager.get(); 47 + final MiningSession active = sm.activeSession(); 48 + if (active != null) { 49 + mc.gui.setOverlayMessage( 50 + Component.translatable("mining.command.alreadyRunning", active.id()), false); 51 + } else if (mc.level != null) { 52 + final String worldName = mc.level.dimension().identifier().getPath(); 53 + final MiningSession session = sm.startSession(worldName); 54 + if (session != null) { 55 + mc.gui.setOverlayMessage( 56 + Component.translatable("mining.command.started", session.id()), false); 57 + } 58 + } 59 + } 60 + if (stopKey.consumeClick()) { 61 + final MiningSession stopped = SessionManager.get().stopSession(); 62 + if (stopped != null) { 63 + mc.gui.setOverlayMessage(Component.translatable("mining.command.stopped", 64 + stopped.id(), stopped.oreCount(), stopped.formattedDuration()), false); 65 + } else { 66 + mc.gui.setOverlayMessage(Component.translatable("mining.command.noSession"), false); 67 + } 68 + } 69 + if (statsKey.consumeClick()) { 70 + mc.setScreen(new MineStatsScreen()); 71 + } 72 + }); 73 + } 74 + }
+16
src/main/java/de/kokirigla/mining/MiningMod.java
··· 1 + package de.kokirigla.mining; 2 + 3 + import de.kokirigla.mining.database.MiningDatabase; 4 + import net.fabricmc.api.ModInitializer; 5 + import org.slf4j.Logger; 6 + import org.slf4j.LoggerFactory; 7 + 8 + public class MiningMod implements ModInitializer { 9 + public static final String MOD_ID = "mining"; 10 + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); 11 + 12 + @Override 13 + public void onInitialize() { 14 + MiningDatabase.get().initialize(); 15 + } 16 + }
+12
src/main/java/de/kokirigla/mining/ModMenuIntegration.java
··· 1 + package de.kokirigla.mining; 2 + 3 + import com.terraformersmc.modmenu.api.ConfigScreenFactory; 4 + import com.terraformersmc.modmenu.api.ModMenuApi; 5 + import de.kokirigla.mining.config.MiningConfig; 6 + 7 + public class ModMenuIntegration implements ModMenuApi { 8 + @Override 9 + public ConfigScreenFactory<?> getModConfigScreenFactory() { 10 + return parent -> MiningConfig.get().buildScreen(parent); 11 + } 12 + }
+113
src/main/java/de/kokirigla/mining/command/MiningModCommand.java
··· 1 + package de.kokirigla.mining.command; 2 + 3 + import com.mojang.brigadier.CommandDispatcher; 4 + import com.mojang.brigadier.arguments.StringArgumentType; 5 + import com.mojang.brigadier.context.CommandContext; 6 + import de.kokirigla.mining.config.MiningConfig; 7 + import de.kokirigla.mining.database.MiningDatabase; 8 + import de.kokirigla.mining.screen.MineStatsScreen; 9 + import de.kokirigla.mining.session.MiningSession; 10 + import de.kokirigla.mining.session.SessionManager; 11 + import net.fabricmc.fabric.api.client.command.v2.ClientCommands; 12 + import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; 13 + import net.fabricmc.loader.api.FabricLoader; 14 + import net.minecraft.client.Minecraft; 15 + import net.minecraft.network.chat.Component; 16 + import org.jspecify.annotations.Nullable; 17 + 18 + import java.io.IOException; 19 + import java.nio.file.Files; 20 + import java.nio.file.Path; 21 + import java.time.LocalDateTime; 22 + import java.time.format.DateTimeFormatter; 23 + 24 + public class MiningModCommand { 25 + private static final DateTimeFormatter FILE_DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); 26 + 27 + public static void register(final CommandDispatcher<FabricClientCommandSource> dispatcher) { 28 + dispatcher.register(ClientCommands.literal("miningmod") 29 + .then(ClientCommands.literal("start").executes(MiningModCommand::start)) 30 + .then(ClientCommands.literal("stop").executes(MiningModCommand::stop)) 31 + .then(ClientCommands.literal("status").executes(MiningModCommand::status)) 32 + .then(ClientCommands.literal("stats").executes(MiningModCommand::openStats)) 33 + .then(ClientCommands.literal("export") 34 + .executes(MiningModCommand::exportAll) 35 + .then(ClientCommands.argument("filename", StringArgumentType.greedyString()) 36 + .executes(MiningModCommand::exportNamed))) 37 + ); 38 + } 39 + 40 + private static int start(final CommandContext<FabricClientCommandSource> ctx) { 41 + final SessionManager sm = SessionManager.get(); 42 + final MiningSession existing = sm.activeSession(); 43 + if (existing != null) { 44 + ctx.getSource().sendError(Component.translatable("mining.command.alreadyRunning", existing.id())); 45 + return 0; 46 + } 47 + final String worldName = ctx.getSource().getLevel().dimension().identifier().getPath(); 48 + final MiningSession session = sm.startSession(worldName); 49 + if (session == null) { 50 + ctx.getSource().sendError(Component.translatable("mining.command.startFailed")); 51 + return 0; 52 + } 53 + ctx.getSource().sendFeedback(Component.translatable("mining.command.started", session.id())); 54 + return 1; 55 + } 56 + 57 + private static int stop(final CommandContext<FabricClientCommandSource> ctx) { 58 + final MiningSession stopped = SessionManager.get().stopSession(); 59 + if (stopped == null) { 60 + ctx.getSource().sendError(Component.translatable("mining.command.noSession")); 61 + return 0; 62 + } 63 + ctx.getSource().sendFeedback(Component.translatable("mining.command.stopped", 64 + stopped.id(), stopped.oreCount(), stopped.formattedDuration())); 65 + return 1; 66 + } 67 + 68 + private static int status(final CommandContext<FabricClientCommandSource> ctx) { 69 + final MiningSession session = SessionManager.get().activeSession(); 70 + if (session == null) { 71 + ctx.getSource().sendError(Component.translatable("mining.command.noSession")); 72 + return 0; 73 + } 74 + ctx.getSource().sendFeedback(Component.translatable("mining.command.status", 75 + session.id(), session.formattedDuration(), session.oreCount())); 76 + return 1; 77 + } 78 + 79 + private static int openStats(final CommandContext<FabricClientCommandSource> ctx) { 80 + final Minecraft mc = Minecraft.getInstance(); 81 + mc.execute(() -> mc.setScreen(new MineStatsScreen())); 82 + return 1; 83 + } 84 + 85 + private static int exportAll(final CommandContext<FabricClientCommandSource> ctx) { 86 + return doExport(ctx, null); 87 + } 88 + 89 + private static int exportNamed(final CommandContext<FabricClientCommandSource> ctx) { 90 + return doExport(ctx, StringArgumentType.getString(ctx, "filename")); 91 + } 92 + 93 + private static int doExport(final CommandContext<FabricClientCommandSource> ctx, final @Nullable String nameArg) { 94 + try { 95 + final Path gameDir = FabricLoader.getInstance().getGameDir(); 96 + final String exportFolder = MiningConfig.get().exportPath; 97 + final Path dir = gameDir.resolve(exportFolder); 98 + Files.createDirectories(dir); 99 + 100 + final String filename = (nameArg != null && !nameArg.isBlank()) 101 + ? (nameArg.endsWith(".csv") ? nameArg : nameArg + ".csv") 102 + : "mining_" + FILE_DATE_FMT.format(LocalDateTime.now()) + ".csv"; 103 + 104 + final Path outPath = dir.resolve(filename); 105 + MiningDatabase.get().exportAllToCsv(outPath); 106 + ctx.getSource().sendFeedback(Component.translatable("mining.command.exported", outPath.toAbsolutePath())); 107 + return 1; 108 + } catch (final IOException e) { 109 + ctx.getSource().sendError(Component.translatable("mining.command.exportFailed", e.getMessage())); 110 + return 0; 111 + } 112 + } 113 + }
+4
src/main/java/de/kokirigla/mining/command/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.command; 3 + 4 + import org.jspecify.annotations.NullMarked;
+75
src/main/java/de/kokirigla/mining/config/MiningConfig.java
··· 1 + package de.kokirigla.mining.config; 2 + 3 + import com.google.gson.Gson; 4 + import com.google.gson.GsonBuilder; 5 + import de.kokirigla.mining.MiningMod; 6 + import net.fabricmc.loader.api.FabricLoader; 7 + import net.minecraft.client.gui.screens.Screen; 8 + import net.minecraft.network.chat.Component; 9 + import org.jspecify.annotations.Nullable; 10 + 11 + import java.io.IOException; 12 + import java.io.Reader; 13 + import java.io.Writer; 14 + import java.nio.file.Files; 15 + import java.nio.file.Path; 16 + 17 + public class MiningConfig { 18 + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); 19 + private static final Path CONFIG_FILE = FabricLoader.getInstance() 20 + .getConfigDir().resolve("mining/config.json"); 21 + private static @Nullable MiningConfig instance; 22 + public boolean hudEnabled = true; 23 + public int hudX = 5; 24 + public int hudY = 5; 25 + public int hudTextColorArgb = 0xFFFFFFFF; 26 + public int hudBackgroundColorArgb = 0xA0000000; 27 + public boolean hudShadow = true; 28 + public float hudScale = 0.75f; 29 + public HudAnchor hudAnchor = HudAnchor.TOP_LEFT; 30 + public boolean hudShowSession = true; 31 + public boolean hudShowStopwatch = true; 32 + public boolean hudShowOreCount = true; 33 + public boolean hudShowLastOre = false; 34 + public String exportPath = "mining_exports"; 35 + 36 + public static MiningConfig get() { 37 + if (instance == null) load(); 38 + return instance; 39 + } 40 + 41 + public static void load() { 42 + if (Files.exists(CONFIG_FILE)) { 43 + try (Reader r = Files.newBufferedReader(CONFIG_FILE)) { 44 + instance = GSON.fromJson(r, MiningConfig.class); 45 + return; 46 + } catch (final IOException e) { 47 + MiningMod.LOGGER.error("Failed to load config, using defaults", e); 48 + } 49 + } 50 + instance = new MiningConfig(); 51 + } 52 + 53 + public void save() { 54 + try { 55 + Files.createDirectories(CONFIG_FILE.getParent()); 56 + try (Writer w = Files.newBufferedWriter(CONFIG_FILE)) { 57 + GSON.toJson(this, w); 58 + } 59 + } catch (final IOException e) { 60 + MiningMod.LOGGER.error("Failed to save config", e); 61 + } 62 + } 63 + 64 + public Screen buildScreen(final Screen parent) { 65 + return new MiningConfigScreen(parent, this); 66 + } 67 + 68 + public enum HudAnchor { 69 + TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT; 70 + 71 + public Component displayText() { 72 + return Component.translatable("mining.config.hud.anchor." + name().toLowerCase()); 73 + } 74 + } 75 + }
+280
src/main/java/de/kokirigla/mining/config/MiningConfigScreen.java
··· 1 + package de.kokirigla.mining.config; 2 + 3 + import net.minecraft.client.Minecraft; 4 + import net.minecraft.client.gui.GuiGraphicsExtractor; 5 + import net.minecraft.client.gui.components.AbstractSelectionList; 6 + import net.minecraft.client.gui.components.AbstractWidget; 7 + import net.minecraft.client.gui.components.Button; 8 + import net.minecraft.client.gui.components.CycleButton; 9 + import net.minecraft.client.gui.components.EditBox; 10 + import net.minecraft.client.gui.narration.NarrationElementOutput; 11 + import net.minecraft.client.gui.screens.Screen; 12 + import net.minecraft.client.input.MouseButtonEvent; 13 + import net.minecraft.network.chat.Component; 14 + import org.jspecify.annotations.Nullable; 15 + 16 + import java.util.function.Consumer; 17 + import java.util.function.Function; 18 + import java.util.function.Supplier; 19 + 20 + public class MiningConfigScreen extends Screen { 21 + private static final int ROW_H = 22; 22 + private static final int LABEL_W = 165; 23 + private static final int CTRL_W = 150; 24 + private static final int PAD = 4; 25 + 26 + private final @Nullable Screen parent; 27 + private final MiningConfig working; 28 + 29 + public MiningConfigScreen(final @Nullable Screen parent, final MiningConfig source) { 30 + super(Component.translatable("mining.config.title")); 31 + this.parent = parent; 32 + this.working = copy(source); 33 + } 34 + 35 + private static MiningConfig copy(final MiningConfig s) { 36 + final MiningConfig c = new MiningConfig(); 37 + copyFields(c, s); 38 + return c; 39 + } 40 + 41 + private static void copyFields(final MiningConfig live, final MiningConfig working) { 42 + live.hudEnabled = working.hudEnabled; 43 + live.hudX = working.hudX; 44 + live.hudY = working.hudY; 45 + live.hudTextColorArgb = working.hudTextColorArgb; 46 + live.hudBackgroundColorArgb = working.hudBackgroundColorArgb; 47 + live.hudShadow = working.hudShadow; 48 + live.hudScale = working.hudScale; 49 + live.hudAnchor = working.hudAnchor; 50 + live.hudShowSession = working.hudShowSession; 51 + live.hudShowStopwatch = working.hudShowStopwatch; 52 + live.hudShowOreCount = working.hudShowOreCount; 53 + live.hudShowLastOre = working.hudShowLastOre; 54 + live.exportPath = working.exportPath; 55 + } 56 + 57 + @Override 58 + protected void init() { 59 + final OptionRowList rowList = new OptionRowList(minecraft, width, height - 32 - 36, 24, ROW_H); 60 + addRenderableWidget(rowList); 61 + 62 + rowList.addHeader(Component.translatable("mining.config.category.hud")); 63 + rowList.addToggle(Component.translatable("mining.config.hud.enabled"), 64 + () -> working.hudEnabled, v -> working.hudEnabled = v); 65 + rowList.addEnum(Component.translatable("mining.config.hud.anchor"), 66 + MiningConfig.HudAnchor.values(), 67 + () -> working.hudAnchor, v -> working.hudAnchor = v, 68 + MiningConfig.HudAnchor::displayText); 69 + rowList.addIntField(Component.translatable("mining.config.hud.x"), () -> working.hudX, v -> working.hudX = v); 70 + rowList.addIntField(Component.translatable("mining.config.hud.y"), () -> working.hudY, v -> working.hudY = v); 71 + rowList.addFloatCycle(Component.translatable("mining.config.hud.scale"), 72 + new float[]{0.25f, 0.50f, 0.75f, 1.00f, 1.25f, 1.50f, 1.75f, 2.00f}, 73 + () -> working.hudScale, v -> working.hudScale = v); 74 + rowList.addHexColor(Component.translatable("mining.config.hud.textColor"), 75 + () -> working.hudTextColorArgb, v -> working.hudTextColorArgb = v); 76 + rowList.addHexColor(Component.translatable("mining.config.hud.backgroundColor"), 77 + () -> working.hudBackgroundColorArgb, v -> working.hudBackgroundColorArgb = v); 78 + rowList.addToggle(Component.translatable("mining.config.hud.shadow"), 79 + () -> working.hudShadow, v -> working.hudShadow = v); 80 + rowList.addToggle(Component.translatable("mining.config.hud.showSession"), 81 + () -> working.hudShowSession, v -> working.hudShowSession = v); 82 + rowList.addToggle(Component.translatable("mining.config.hud.showStopwatch"), 83 + () -> working.hudShowStopwatch, v -> working.hudShowStopwatch = v); 84 + rowList.addToggle(Component.translatable("mining.config.hud.showOreCount"), 85 + () -> working.hudShowOreCount, v -> working.hudShowOreCount = v); 86 + rowList.addToggle(Component.translatable("mining.config.hud.showLastOre"), 87 + () -> working.hudShowLastOre, v -> working.hudShowLastOre = v); 88 + 89 + rowList.addHeader(Component.translatable("mining.config.category.session")); 90 + rowList.addTextField(Component.translatable("mining.config.session.exportPath"), 91 + () -> working.exportPath, v -> working.exportPath = v); 92 + 93 + final int mid = width / 2; 94 + final int btnY = height - 28; 95 + 96 + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> { 97 + applyAndSave(); 98 + minecraft.setScreen(parent); 99 + }).bounds(mid - 82, btnY, 80, 20).build()); 100 + 101 + addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), 102 + b -> minecraft.setScreen(parent) 103 + ).bounds(mid + 2, btnY, 80, 20).build()); 104 + } 105 + 106 + private void applyAndSave() { 107 + final MiningConfig live = MiningConfig.get(); 108 + copyFields(live, working); 109 + live.save(); 110 + } 111 + 112 + @Override 113 + public void extractRenderState(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final float delta) { 114 + extractMenuBackground(g); 115 + g.centeredText(font, title, width / 2, 8, 0xFFFFFFFF); 116 + super.extractRenderState(g, mouseX, mouseY, delta); 117 + } 118 + 119 + @Override 120 + public void onClose() { 121 + minecraft.setScreen(parent); 122 + } 123 + 124 + class OptionRowList extends AbstractSelectionList<OptionRowList.Row> { 125 + 126 + OptionRowList(final Minecraft mc, final int w, final int h, final int top, final int itemH) { 127 + super(mc, w, h, top, itemH); 128 + } 129 + 130 + @Override 131 + protected void extractListBackground(final GuiGraphicsExtractor g) { 132 + // suppress default list background 133 + } 134 + 135 + @Override 136 + public void updateWidgetNarration(final NarrationElementOutput noe) { 137 + } 138 + 139 + void addHeader(final Component title) { 140 + addEntry(new HeaderRow(title)); 141 + } 142 + 143 + void addToggle(final Component label, final Supplier<Boolean> getter, final Consumer<Boolean> setter) { 144 + final CycleButton<Boolean> btn = CycleButton.onOffBuilder(getter.get()) 145 + .create(0, 0, CTRL_W, ROW_H - 2, label, 146 + (b, v) -> setter.accept(v)); 147 + addEntry(new ControlRow(label, btn)); 148 + } 149 + 150 + <E extends Enum<E>> void addEnum(final Component label, final E[] values, 151 + final Supplier<E> getter, final Consumer<E> setter, 152 + final Function<E, Component> namer) { 153 + final CycleButton<E> btn = CycleButton.builder(namer, getter.get()) 154 + .withValues(values) 155 + .create(0, 0, CTRL_W, ROW_H - 2, label, 156 + (b, v) -> setter.accept(v)); 157 + addEntry(new ControlRow(label, btn)); 158 + } 159 + 160 + void addIntField(final Component label, final Supplier<Integer> getter, final Consumer<Integer> setter) { 161 + final EditBox tf = new EditBox(font, 0, 0, CTRL_W, ROW_H - 2, Component.empty()); 162 + tf.setMaxLength(5); 163 + tf.setValue(String.valueOf(getter.get())); 164 + tf.setResponder(text -> { 165 + try { 166 + setter.accept(Integer.parseInt(text)); 167 + tf.setTextColor(0xFFFFFF); 168 + } catch (final NumberFormatException e) { 169 + tf.setTextColor(0xFF4444); 170 + } 171 + }); 172 + addEntry(new ControlRow(label, tf)); 173 + } 174 + 175 + void addFloatCycle(final Component label, final float[] presets, final Supplier<Float> getter, final Consumer<Float> setter) { 176 + final float current = getter.get(); 177 + float nearest = presets[0]; 178 + for (final float p : presets) { 179 + if (Math.abs(p - current) < Math.abs(nearest - current)) nearest = p; 180 + } 181 + final Float[] boxed = new Float[presets.length]; 182 + for (int i = 0; i < presets.length; i++) boxed[i] = presets[i]; 183 + final float snapped = nearest; 184 + setter.accept(snapped); 185 + final CycleButton<Float> btn = CycleButton.builder( 186 + v -> Component.literal(String.format("%.2fx", v)), snapped) 187 + .withValues(boxed) 188 + .create(0, 0, CTRL_W, ROW_H - 2, label, (b, v) -> setter.accept(v)); 189 + addEntry(new ControlRow(label, btn)); 190 + } 191 + 192 + void addHexColor(final Component label, final Supplier<Integer> getter, final Consumer<Integer> setter) { 193 + final EditBox tf = new EditBox(font, 0, 0, CTRL_W, ROW_H - 2, Component.empty()); 194 + tf.setMaxLength(10); 195 + tf.setValue(String.format("#%08X", getter.get())); 196 + tf.setResponder(text -> { 197 + try { 198 + final String s = text.startsWith("#") ? text.substring(1) : text; 199 + setter.accept((int) Long.parseLong(s, 16)); 200 + tf.setTextColor(0xFFFFFF); 201 + } catch (final NumberFormatException e) { 202 + tf.setTextColor(0xFF4444); 203 + } 204 + }); 205 + addEntry(new ControlRow(label, tf)); 206 + } 207 + 208 + void addTextField(final Component label, final Supplier<String> getter, final Consumer<String> setter) { 209 + final EditBox tf = new EditBox(font, 0, 0, CTRL_W, ROW_H - 2, Component.empty()); 210 + tf.setMaxLength(128); 211 + tf.setValue(getter.get()); 212 + tf.setResponder(setter); 213 + addEntry(new ControlRow(label, tf)); 214 + } 215 + 216 + @Override 217 + public int getRowWidth() { 218 + return LABEL_W + CTRL_W + PAD * 3; 219 + } 220 + 221 + @Override 222 + protected int scrollBarX() { 223 + return MiningConfigScreen.this.width - 6; 224 + } 225 + 226 + abstract static class Row extends AbstractSelectionList.Entry<Row> { 227 + } 228 + 229 + class HeaderRow extends Row { 230 + private final Component title; 231 + 232 + HeaderRow(final Component t) { 233 + this.title = t; 234 + } 235 + 236 + @Override 237 + public void extractContent(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final boolean hover, final float dt) { 238 + final int x = getContentX(), y = getContentY(), w = getContentWidth(), h = getContentHeight(); 239 + g.fill(x - 2, y, x + w + 2, y + h, 0x60000000); 240 + g.text(font, Component.literal("- ").append(title).append(" -"), x + PAD, y + (h - 8) / 2, 0xFFFFD700); 241 + } 242 + 243 + @Override 244 + public boolean mouseClicked(final MouseButtonEvent event, final boolean consumed) { 245 + return false; 246 + } 247 + } 248 + 249 + class ControlRow extends Row { 250 + private final Component label; 251 + private final AbstractWidget widget; 252 + 253 + ControlRow(final Component label, final AbstractWidget widget) { 254 + this.label = label; 255 + this.widget = widget; 256 + } 257 + 258 + @Override 259 + public void extractContent(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final boolean hover, final float dt) { 260 + final int x = getContentX(), y = getContentY(), w = getContentWidth(), h = getContentHeight(); 261 + if (hover) g.fill(x - 2, y, x + w + 2, y + h, 0x20FFFFFF); 262 + g.text(font, label, x + PAD, y + (h - 8) / 2, 0xFFFFFFFF); 263 + widget.setX(x + LABEL_W + PAD); 264 + widget.setY(y + 1); 265 + widget.extractRenderState(g, mouseX, mouseY, dt); 266 + } 267 + 268 + @Override 269 + public boolean mouseClicked(final MouseButtonEvent event, final boolean consumed) { 270 + return widget.mouseClicked(event, consumed); 271 + } 272 + 273 + @Override 274 + public void visitWidgets(final java.util.function.Consumer<AbstractWidget> visitor) { 275 + visitor.accept(widget); 276 + } 277 + } 278 + } 279 + 280 + }
+4
src/main/java/de/kokirigla/mining/config/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.config; 3 + 4 + import org.jspecify.annotations.NullMarked;
+342
src/main/java/de/kokirigla/mining/database/MiningDatabase.java
··· 1 + package de.kokirigla.mining.database; 2 + 3 + import de.kokirigla.mining.MiningMod; 4 + import de.kokirigla.mining.session.OreRecord; 5 + import de.kokirigla.mining.session.OreStats; 6 + import de.kokirigla.mining.session.SessionSummary; 7 + import net.fabricmc.loader.api.FabricLoader; 8 + import org.jspecify.annotations.Nullable; 9 + 10 + import java.io.IOException; 11 + import java.io.PrintWriter; 12 + import java.nio.file.Files; 13 + import java.nio.file.Path; 14 + import java.sql.Connection; 15 + import java.sql.DriverManager; 16 + import java.sql.PreparedStatement; 17 + import java.sql.ResultSet; 18 + import java.sql.SQLException; 19 + import java.sql.Statement; 20 + import java.util.ArrayList; 21 + import java.util.HashMap; 22 + import java.util.LinkedHashMap; 23 + import java.util.List; 24 + import java.util.Map; 25 + 26 + public final class MiningDatabase { 27 + private static final MiningDatabase INSTANCE = new MiningDatabase(); 28 + 29 + private @Nullable Connection connection; 30 + 31 + private MiningDatabase() { 32 + } 33 + 34 + public static MiningDatabase get() { 35 + return INSTANCE; 36 + } 37 + 38 + private static String normalizeOreType(final String oreType) { 39 + return oreType.startsWith("deepslate_") ? oreType.substring("deepslate_".length()) : oreType; 40 + } 41 + 42 + public synchronized void initialize() { 43 + try { 44 + final Path dir = FabricLoader.getInstance().getConfigDir().resolve("mining"); 45 + Files.createDirectories(dir); 46 + final Path dbPath = dir.resolve("data.db"); 47 + 48 + connection = DriverManager.getConnection("jdbc:sqlite:" + dbPath.toAbsolutePath()); 49 + try (Statement stmt = connection.createStatement()) { 50 + stmt.execute("PRAGMA journal_mode=WAL"); 51 + stmt.execute("PRAGMA foreign_keys=ON"); 52 + stmt.execute(""" 53 + CREATE TABLE IF NOT EXISTS sessions ( 54 + id INTEGER PRIMARY KEY AUTOINCREMENT, 55 + start_time INTEGER NOT NULL, 56 + end_time INTEGER, 57 + world_name TEXT 58 + )"""); 59 + stmt.execute(""" 60 + CREATE TABLE IF NOT EXISTS ore_records ( 61 + id INTEGER PRIMARY KEY AUTOINCREMENT, 62 + session_id INTEGER NOT NULL, 63 + ore_type TEXT NOT NULL, 64 + display_name TEXT NOT NULL, 65 + y_level INTEGER NOT NULL, 66 + timestamp INTEGER NOT NULL, 67 + x INTEGER NOT NULL, 68 + z INTEGER NOT NULL, 69 + world_name TEXT, 70 + FOREIGN KEY (session_id) REFERENCES sessions(id) 71 + )"""); 72 + stmt.execute("CREATE INDEX IF NOT EXISTS idx_ore_session ON ore_records(session_id)"); 73 + } 74 + MiningMod.LOGGER.info("Database initialized at {}", dbPath); 75 + } catch (final Exception e) { 76 + MiningMod.LOGGER.error("Failed to initialize database", e); 77 + } 78 + } 79 + 80 + public synchronized long createSession(final String worldName) { 81 + try { 82 + assert connection != null; 83 + try (PreparedStatement ps = connection.prepareStatement( 84 + "INSERT INTO sessions (start_time, world_name) VALUES (?, ?)", 85 + Statement.RETURN_GENERATED_KEYS)) { 86 + ps.setLong(1, System.currentTimeMillis()); 87 + ps.setString(2, worldName); 88 + ps.executeUpdate(); 89 + try (ResultSet keys = ps.getGeneratedKeys()) { 90 + if (keys.next()) return keys.getLong(1); 91 + } 92 + } 93 + } catch (final SQLException e) { 94 + MiningMod.LOGGER.error("Failed to create session", e); 95 + } 96 + return -1; 97 + } 98 + 99 + public synchronized void resumeSession(final long sessionId) { 100 + try { 101 + assert connection != null; 102 + try (PreparedStatement ps = connection.prepareStatement( 103 + "UPDATE sessions SET end_time = NULL WHERE id = ?")) { 104 + ps.setLong(1, sessionId); 105 + ps.executeUpdate(); 106 + } 107 + } catch (final SQLException e) { 108 + MiningMod.LOGGER.error("Failed to resume session", e); 109 + } 110 + } 111 + 112 + public synchronized void deleteSession(final long sessionId) { 113 + try { 114 + assert connection != null; 115 + try (PreparedStatement ps = connection.prepareStatement( 116 + "DELETE FROM ore_records WHERE session_id = ?")) { 117 + ps.setLong(1, sessionId); 118 + ps.executeUpdate(); 119 + } 120 + } catch (final SQLException e) { 121 + MiningMod.LOGGER.error("Failed to delete ore records for session", e); 122 + } 123 + try (PreparedStatement ps = connection.prepareStatement( 124 + "DELETE FROM sessions WHERE id = ?")) { 125 + ps.setLong(1, sessionId); 126 + ps.executeUpdate(); 127 + } catch (final SQLException e) { 128 + MiningMod.LOGGER.error("Failed to delete session", e); 129 + } 130 + } 131 + 132 + public synchronized void endSession(final long sessionId) { 133 + try { 134 + assert connection != null; 135 + try (PreparedStatement ps = connection.prepareStatement( 136 + "UPDATE sessions SET end_time = ? WHERE id = ?")) { 137 + ps.setLong(1, System.currentTimeMillis()); 138 + ps.setLong(2, sessionId); 139 + ps.executeUpdate(); 140 + } 141 + } catch (final SQLException e) { 142 + MiningMod.LOGGER.error("Failed to end session", e); 143 + } 144 + } 145 + 146 + public synchronized void insertOreRecord(final OreRecord record) { 147 + try { 148 + assert connection != null; 149 + try (PreparedStatement ps = connection.prepareStatement( 150 + "INSERT INTO ore_records (session_id, ore_type, display_name, y_level, timestamp, x, z, world_name) VALUES (?,?,?,?,?,?,?,?)")) { 151 + ps.setLong(1, record.sessionId()); 152 + ps.setString(2, record.oreType()); 153 + ps.setString(3, record.displayName()); 154 + ps.setInt(4, record.yLevel()); 155 + ps.setLong(5, record.timestamp()); 156 + ps.setInt(6, record.x()); 157 + ps.setInt(7, record.z()); 158 + ps.setString(8, record.worldName()); 159 + ps.executeUpdate(); 160 + } 161 + } catch (final SQLException e) { 162 + MiningMod.LOGGER.error("Failed to insert ore record", e); 163 + } 164 + } 165 + 166 + public synchronized List<OreRecord> recordsForSession(final long sessionId) { 167 + final List<OreRecord> records = new ArrayList<>(); 168 + try { 169 + assert connection != null; 170 + try (PreparedStatement ps = connection.prepareStatement( 171 + "SELECT * FROM ore_records WHERE session_id = ? ORDER BY timestamp")) { 172 + ps.setLong(1, sessionId); 173 + try (ResultSet rs = ps.executeQuery()) { 174 + while (rs.next()) { 175 + records.add(new OreRecord( 176 + rs.getLong("session_id"), 177 + rs.getString("ore_type"), 178 + rs.getString("display_name"), 179 + rs.getInt("y_level"), 180 + rs.getLong("timestamp"), 181 + rs.getInt("x"), 182 + rs.getInt("z"), 183 + rs.getString("world_name") 184 + )); 185 + } 186 + } 187 + } 188 + } catch (final SQLException e) { 189 + MiningMod.LOGGER.error("Failed to query ore records", e); 190 + } 191 + return records; 192 + } 193 + 194 + public synchronized List<SessionSummary> allSessions() { 195 + final List<SessionSummary> sessions = new ArrayList<>(); 196 + try { 197 + assert connection != null; 198 + try (Statement stmt = connection.createStatement(); 199 + ResultSet rs = stmt.executeQuery(""" 200 + SELECT s.id, s.start_time, s.end_time, s.world_name, 201 + COUNT(o.id) AS ore_count 202 + FROM sessions s 203 + LEFT JOIN ore_records o ON o.session_id = s.id 204 + GROUP BY s.id 205 + ORDER BY s.start_time""")) { 206 + while (rs.next()) { 207 + sessions.add(new SessionSummary( 208 + rs.getLong("id"), 209 + rs.getLong("start_time"), 210 + rs.getLong("end_time"), 211 + rs.getString("world_name"), 212 + rs.getInt("ore_count") 213 + )); 214 + } 215 + } 216 + } catch (final SQLException e) { 217 + MiningMod.LOGGER.error("Failed to query sessions", e); 218 + } 219 + return sessions; 220 + } 221 + 222 + /** 223 + * Aggregate per-ore stats for a single session. 224 + * 225 + * @param sessionId the session to aggregate stats for 226 + * @return map of ore type to stats for the given session 227 + * @since 1.0.0 228 + */ 229 + public synchronized Map<String, OreStats> statsByOreForSession(final long sessionId) { 230 + return buildStats(recordsForSession(sessionId)); 231 + } 232 + 233 + /** 234 + * Aggregate per-ore stats across all sessions. 235 + * 236 + * @return map of ore type to aggregated stats across all sessions 237 + * @since 1.0.0 238 + */ 239 + public synchronized Map<String, OreStats> aggregatedStats() { 240 + final List<OreRecord> all = new ArrayList<>(); 241 + try { 242 + assert connection != null; 243 + try (Statement stmt = connection.createStatement(); 244 + ResultSet rs = stmt.executeQuery("SELECT * FROM ore_records ORDER BY ore_type")) { 245 + while (rs.next()) { 246 + all.add(new OreRecord( 247 + rs.getLong("session_id"), rs.getString("ore_type"), rs.getString("display_name"), 248 + rs.getInt("y_level"), rs.getLong("timestamp"), 249 + rs.getInt("x"), rs.getInt("z"), rs.getString("world_name"))); 250 + } 251 + } 252 + } catch (final SQLException e) { 253 + MiningMod.LOGGER.error("Failed to query all ore records", e); 254 + } 255 + return buildStats(all); 256 + } 257 + 258 + private Map<String, OreStats> buildStats(final List<OreRecord> records) { 259 + final Map<String, List<Integer>> byOre = new LinkedHashMap<>(); 260 + final Map<String, String> displayNames = new LinkedHashMap<>(); 261 + for (final OreRecord r : records) { 262 + final String type = normalizeOreType(r.oreType()); 263 + final String name = r.displayName().startsWith("Deepslate ") 264 + ? r.displayName().substring("Deepslate ".length()) : r.displayName(); 265 + byOre.computeIfAbsent(type, _ -> new ArrayList<>()).add(r.yLevel()); 266 + displayNames.put(type, name); 267 + } 268 + final Map<String, OreStats> stats = new LinkedHashMap<>(); 269 + for (final Map.Entry<String, List<Integer>> entry : byOre.entrySet()) { 270 + final String oreType = entry.getKey(); 271 + final List<Integer> ys = entry.getValue(); 272 + final int min = ys.stream().mapToInt(Integer::intValue).min().orElse(0); 273 + final int max = ys.stream().mapToInt(Integer::intValue).max().orElse(0); 274 + final double avg = ys.stream().mapToInt(Integer::intValue).average().orElse(0); 275 + final Map<Integer, Integer> dist = new HashMap<>(); 276 + for (final int y : ys) dist.merge(y, 1, Integer::sum); 277 + final int mode = OreStats.computeMode(dist); 278 + stats.put(oreType, new OreStats(oreType, displayNames.get(oreType), ys.size(), min, max, avg, mode, dist)); 279 + } 280 + return stats; 281 + } 282 + 283 + /** 284 + * Export one session's records to CSV. 285 + * 286 + * @param sessionId the session whose records to export 287 + * @param outputPath the file path to write the CSV to 288 + * @throws IOException if writing the file fails 289 + * @since 1.0.0 290 + */ 291 + public synchronized void exportSessionToCsv(final long sessionId, final Path outputPath) throws IOException { 292 + final List<OreRecord> records = recordsForSession(sessionId); 293 + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(outputPath))) { 294 + w.println("session_id,ore_type,display_name,y_level,x,z,world_name,timestamp"); 295 + for (final OreRecord r : records) { 296 + w.printf("%d,%s,%s,%d,%d,%d,%s,%d%n", 297 + r.sessionId(), r.oreType(), r.displayName(), 298 + r.yLevel(), r.x(), r.z(), r.worldName(), r.timestamp()); 299 + } 300 + } 301 + } 302 + 303 + /** 304 + * Export aggregate stats to CSV. 305 + * 306 + * @param outputPath the file path to write the CSV to 307 + * @throws IOException if writing the file fails 308 + * @since 1.0.0 309 + */ 310 + public synchronized void exportAggregateToCsv(final Path outputPath) throws IOException { 311 + final Map<String, OreStats> stats = aggregatedStats(); 312 + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(outputPath))) { 313 + w.println("ore_type,display_name,count,min_y,max_y,avg_y,mode_y"); 314 + for (final OreStats s : stats.values()) { 315 + w.printf("%s,%s,%d,%d,%d,%.1f,%d%n", 316 + s.oreType(), s.displayName(), s.count(), s.minY(), s.maxY(), s.avgY(), s.modeY()); 317 + } 318 + } 319 + } 320 + 321 + /** 322 + * Export all sessions to CSV. 323 + * 324 + * @param outputPath the file path to write the CSV to 325 + * @throws IOException if writing the file fails 326 + * @since 1.0.0 327 + */ 328 + public synchronized void exportAllToCsv(final Path outputPath) throws IOException { 329 + final List<SessionSummary> sessions = allSessions(); 330 + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(outputPath))) { 331 + w.println("session_id,ore_type,display_name,y_level,x,z,world_name,timestamp"); 332 + for (final SessionSummary s : sessions) { 333 + for (final OreRecord r : recordsForSession(s.id())) { 334 + w.printf("%d,%s,%s,%d,%d,%d,%s,%d%n", 335 + r.sessionId(), r.oreType(), r.displayName(), 336 + r.yLevel(), r.x(), r.z(), r.worldName(), r.timestamp()); 337 + } 338 + } 339 + } 340 + } 341 + 342 + }
+4
src/main/java/de/kokirigla/mining/database/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.database; 3 + 4 + import org.jspecify.annotations.NullMarked;
+122
src/main/java/de/kokirigla/mining/hud/MiningHud.java
··· 1 + package de.kokirigla.mining.hud; 2 + 3 + import de.kokirigla.mining.config.MiningConfig; 4 + import de.kokirigla.mining.config.MiningConfig.HudAnchor; 5 + import de.kokirigla.mining.session.MiningSession; 6 + import de.kokirigla.mining.session.SessionManager; 7 + import net.minecraft.client.Minecraft; 8 + import net.minecraft.client.gui.Font; 9 + import net.minecraft.client.gui.GuiGraphicsExtractor; 10 + import net.minecraft.core.registries.BuiltInRegistries; 11 + import net.minecraft.resources.Identifier; 12 + import net.minecraft.world.item.ItemStack; 13 + 14 + import java.util.ArrayList; 15 + import java.util.List; 16 + import java.util.Map; 17 + import java.util.concurrent.ConcurrentHashMap; 18 + 19 + public class MiningHud { 20 + private static final int PADDING = 4; 21 + private static final int LINE_HEIGHT = 10; 22 + private static final int ICON_SIZE = 16; 23 + private static final int ICON_GAP = 3; 24 + private static final int ORE_ROW_H = ICON_SIZE + 2; 25 + 26 + private static final Map<String, ItemStack> ITEM_CACHE = new ConcurrentHashMap<>(); 27 + 28 + public static void extractRenderState(final GuiGraphicsExtractor extractor) { 29 + final MiningConfig cfg = MiningConfig.get(); 30 + if (!cfg.hudEnabled) return; 31 + 32 + final MiningSession session = SessionManager.get().activeSession(); 33 + if (session == null) return; 34 + 35 + final Minecraft mc = Minecraft.getInstance(); 36 + 37 + final Font font = mc.font; 38 + 39 + final List<String> textLines = buildTextLines(cfg, session); 40 + final Map<String, Integer> oreCounts = cfg.hudShowOreCount ? session.oreCountsSorted() : Map.of(); 41 + 42 + if (textLines.isEmpty() && oreCounts.isEmpty()) return; 43 + 44 + final int textW = textLines.stream().mapToInt(font::width).max().orElse(0); 45 + final int oreW = oreCounts.entrySet().stream() 46 + .mapToInt(e -> ICON_SIZE + ICON_GAP + font.width(session.displayName(e.getKey()) + ": " + e.getValue())) 47 + .max().orElse(0); 48 + final int contentW = Math.max(textW, oreW); 49 + final int panelW = contentW + PADDING * 2; 50 + final int panelH = textLines.size() * LINE_HEIGHT 51 + + oreCounts.size() * ORE_ROW_H 52 + + PADDING * 2 53 + + (textLines.isEmpty() || oreCounts.isEmpty() ? 0 : 2); 54 + 55 + final int screenW = extractor.guiWidth(); 56 + final int screenH = extractor.guiHeight(); 57 + final int originX = resolveX(cfg, screenW, panelW); 58 + final int originY = resolveY(cfg, screenH, panelH); 59 + 60 + extractor.pose().pushMatrix(); 61 + extractor.pose().scale(cfg.hudScale, cfg.hudScale); 62 + 63 + final float inv = 1.0f / cfg.hudScale; 64 + final int sx = (int) (originX * inv); 65 + final int sy = (int) (originY * inv); 66 + 67 + extractor.fill(sx, sy, sx + panelW, sy + panelH, cfg.hudBackgroundColorArgb); 68 + 69 + final int textColor = cfg.hudTextColorArgb | 0xFF000000; 70 + int cy = sy + PADDING; 71 + 72 + for (final String line : textLines) { 73 + extractor.text(font, line, sx + PADDING, cy, textColor, cfg.hudShadow); 74 + cy += LINE_HEIGHT; 75 + } 76 + if (!textLines.isEmpty() && !oreCounts.isEmpty()) cy += 2; 77 + 78 + for (final Map.Entry<String, Integer> entry : oreCounts.entrySet()) { 79 + final String oreType = entry.getKey(); 80 + final int count = entry.getValue(); 81 + final ItemStack stack = oreItem(oreType); 82 + 83 + final int ix = sx + PADDING; 84 + final int iy = cy; 85 + if (!stack.isEmpty()) { 86 + extractor.item(stack, ix, iy); 87 + } 88 + final String label = session.displayName(oreType) + ": " + count; 89 + final int ty = iy + (ICON_SIZE - 8) / 2; 90 + extractor.text(font, label, ix + ICON_SIZE + ICON_GAP, ty, textColor, cfg.hudShadow); 91 + cy += ORE_ROW_H; 92 + } 93 + 94 + extractor.pose().popMatrix(); 95 + } 96 + 97 + private static ItemStack oreItem(final String oreType) { 98 + return ITEM_CACHE.computeIfAbsent(oreType, k -> 99 + BuiltInRegistries.BLOCK.get(Identifier.withDefaultNamespace(k)) 100 + .map(h -> new ItemStack(h.value().asItem())) 101 + .orElse(ItemStack.EMPTY)); 102 + } 103 + 104 + private static List<String> buildTextLines(final MiningConfig cfg, final MiningSession session) { 105 + final List<String> lines = new ArrayList<>(); 106 + if (cfg.hudShowSession) lines.add("Session #" + session.id()); 107 + if (cfg.hudShowStopwatch) lines.add(session.formattedDuration()); 108 + if (cfg.hudShowLastOre && session.lastOreName() != null) 109 + lines.add("Last: " + session.lastOreName()); 110 + return lines; 111 + } 112 + 113 + private static int resolveX(final MiningConfig cfg, final int screenW, final int panelW) { 114 + final boolean right = cfg.hudAnchor == HudAnchor.TOP_RIGHT || cfg.hudAnchor == HudAnchor.BOTTOM_RIGHT; 115 + return right ? screenW - panelW - cfg.hudX : cfg.hudX; 116 + } 117 + 118 + private static int resolveY(final MiningConfig cfg, final int screenH, final int panelH) { 119 + final boolean bottom = cfg.hudAnchor == HudAnchor.BOTTOM_LEFT || cfg.hudAnchor == HudAnchor.BOTTOM_RIGHT; 120 + return bottom ? screenH - panelH - cfg.hudY : cfg.hudY; 121 + } 122 + }
+4
src/main/java/de/kokirigla/mining/hud/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.hud; 3 + 4 + import org.jspecify.annotations.NullMarked;
+22
src/main/java/de/kokirigla/mining/mixin/MultiPlayerGameModeMixin.java
··· 1 + package de.kokirigla.mining.mixin; 2 + 3 + import de.kokirigla.mining.session.SessionManager; 4 + import net.minecraft.client.Minecraft; 5 + import net.minecraft.client.multiplayer.MultiPlayerGameMode; 6 + import net.minecraft.core.BlockPos; 7 + import net.minecraft.world.level.block.state.BlockState; 8 + import org.spongepowered.asm.mixin.Mixin; 9 + import org.spongepowered.asm.mixin.injection.At; 10 + import org.spongepowered.asm.mixin.injection.Inject; 11 + import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 12 + 13 + @Mixin(MultiPlayerGameMode.class) 14 + public class MultiPlayerGameModeMixin { 15 + @Inject(method = "destroyBlock", at = @At("HEAD")) 16 + private void onDestroyBlock(final BlockPos pos, final CallbackInfoReturnable<Boolean> cir) { 17 + final Minecraft mc = Minecraft.getInstance(); 18 + if (mc.level == null) return; 19 + final BlockState state = mc.level.getBlockState(pos); 20 + SessionManager.get().onBlockBroken(mc.level, pos, state); 21 + } 22 + }
+4
src/main/java/de/kokirigla/mining/mixin/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.mixin; 3 + 4 + import org.jspecify.annotations.NullMarked;
+4
src/main/java/de/kokirigla/mining/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining; 3 + 4 + import org.jspecify.annotations.NullMarked;
+444
src/main/java/de/kokirigla/mining/screen/MineStatsScreen.java
··· 1 + package de.kokirigla.mining.screen; 2 + 3 + import de.kokirigla.mining.config.MiningConfig; 4 + import de.kokirigla.mining.database.MiningDatabase; 5 + import de.kokirigla.mining.session.MiningSession; 6 + import de.kokirigla.mining.session.OreStats; 7 + import de.kokirigla.mining.session.SessionManager; 8 + import de.kokirigla.mining.session.SessionSummary; 9 + import net.fabricmc.loader.api.FabricLoader; 10 + import net.minecraft.client.Minecraft; 11 + import net.minecraft.client.gui.GuiGraphicsExtractor; 12 + import net.minecraft.client.gui.components.AbstractSelectionList; 13 + import net.minecraft.client.gui.components.Button; 14 + import net.minecraft.client.gui.components.Tooltip; 15 + import net.minecraft.client.gui.narration.NarrationElementOutput; 16 + import net.minecraft.client.gui.screens.Screen; 17 + import net.minecraft.client.input.MouseButtonEvent; 18 + import net.minecraft.network.chat.Component; 19 + 20 + import java.io.IOException; 21 + import java.nio.file.Files; 22 + import java.nio.file.Path; 23 + import java.time.Instant; 24 + import java.time.LocalDateTime; 25 + import java.time.ZoneId; 26 + import java.time.format.DateTimeFormatter; 27 + import java.util.ArrayList; 28 + import java.util.Comparator; 29 + import java.util.List; 30 + import java.util.Map; 31 + import java.util.TreeMap; 32 + 33 + public class MineStatsScreen extends Screen { 34 + private static final int HEADER_H = 64; 35 + private static final int FOOTER_H = 36; 36 + private static final int COL_ORE = 120; 37 + private static final int COL_COUNT = 48; 38 + private static final int COL_MIN_Y = 48; 39 + private static final int COL_MAX_Y = 48; 40 + private static final int COL_AVG_Y = 52; 41 + private static final int COL_MODE_Y = 52; 42 + private static final int TABLE_W = COL_ORE + COL_COUNT + COL_MIN_Y + COL_MAX_Y + COL_AVG_Y + COL_MODE_Y; 43 + 44 + private static final int COL_Y_RANGE = 80; 45 + private static final int COL_RATE = 72; 46 + private static final int TABLE_W_RATE = COL_ORE + COL_Y_RANGE + COL_COUNT + COL_RATE; 47 + 48 + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); 49 + private final List<OreStats> stats = new ArrayList<>(); 50 + private List<SessionSummary> sessions = new ArrayList<>(); 51 + private int sessionIndex = 0; 52 + private boolean showRates = false; 53 + private StatsListWidget listWidget; 54 + private Button prevBtn, nextBtn, exportBtn, resumeBtn, deleteBtn, rateToggleBtn; 55 + 56 + public MineStatsScreen() { 57 + super(Component.translatable("mining.screen.title")); 58 + } 59 + 60 + private boolean isTotalPage() { 61 + return !sessions.isEmpty() && sessionIndex == sessions.size(); 62 + } 63 + 64 + @Override 65 + protected void init() { 66 + sessions = MiningDatabase.get().allSessions(); 67 + 68 + final MiningSession active = SessionManager.get().activeSession(); 69 + if (active != null) { 70 + for (int i = 0; i < sessions.size(); i++) { 71 + if (sessions.get(i).id() == active.id()) { 72 + sessionIndex = i; 73 + break; 74 + } 75 + } 76 + } else if (!sessions.isEmpty()) { 77 + sessionIndex = sessions.size() - 1; 78 + } 79 + 80 + listWidget = new StatsListWidget(minecraft, width, height - HEADER_H - FOOTER_H, HEADER_H, 18); 81 + addRenderableWidget(listWidget); 82 + 83 + final int mid = width / 2; 84 + 85 + prevBtn = Button.builder(Component.literal("<"), _ -> { 86 + if (sessionIndex > 0) { 87 + sessionIndex--; 88 + refreshStats(); 89 + } 90 + }).bounds(mid - 112, 18, 20, 14).build(); 91 + 92 + nextBtn = Button.builder(Component.literal(">"), _ -> { 93 + if (sessionIndex < sessions.size()) { 94 + sessionIndex++; 95 + refreshStats(); 96 + } 97 + }).bounds(mid + 92, 18, 20, 14).build(); 98 + 99 + rateToggleBtn = Button.builder(Component.translatable(showRates ? "mining.screen.button.showStats" : "mining.screen.button.showRates"), b -> { 100 + showRates = !showRates; 101 + b.setMessage(Component.translatable(showRates ? "mining.screen.button.showStats" : "mining.screen.button.showRates")); 102 + refreshStats(); 103 + }).bounds(width - 88, 18, 84, 14).build(); 104 + 105 + final int btnY = height - FOOTER_H + 8; 106 + exportBtn = Button.builder(Component.translatable("mining.screen.export"), _ -> exportCurrent()) 107 + .bounds(mid - 131, btnY, 64, 20) 108 + .tooltip(Tooltip.create(Component.translatable("mining.screen.tooltip.export"))) 109 + .build(); 110 + 111 + resumeBtn = Button.builder(Component.translatable("mining.screen.button.resume"), _ -> resumeCurrentSession()) 112 + .bounds(mid - 65, btnY, 64, 20) 113 + .tooltip(Tooltip.create(Component.translatable("mining.screen.tooltip.resume"))) 114 + .build(); 115 + 116 + deleteBtn = Button.builder(Component.translatable("mining.screen.button.delete"), _ -> deleteCurrentSession()) 117 + .bounds(mid + 1, btnY, 64, 20) 118 + .tooltip(Tooltip.create(Component.translatable("mining.screen.tooltip.delete"))) 119 + .build(); 120 + 121 + addRenderableWidget(prevBtn); 122 + addRenderableWidget(nextBtn); 123 + addRenderableWidget(rateToggleBtn); 124 + addRenderableWidget(exportBtn); 125 + addRenderableWidget(resumeBtn); 126 + addRenderableWidget(deleteBtn); 127 + addRenderableWidget(Button.builder(Component.translatable("gui.back"), 128 + _ -> onClose() 129 + ).bounds(mid + 67, btnY, 64, 20).build()); 130 + 131 + refreshStats(); 132 + } 133 + 134 + private void refreshStats() { 135 + stats.clear(); 136 + if (isTotalPage()) { 137 + stats.addAll(MiningDatabase.get().aggregatedStats().values()); 138 + } else if (!sessions.isEmpty()) { 139 + stats.addAll(MiningDatabase.get() 140 + .statsByOreForSession(sessions.get(sessionIndex).id()).values()); 141 + } 142 + stats.sort(Comparator.comparingInt(OreStats::count).reversed()); 143 + 144 + if (showRates) { 145 + listWidget.refreshRate(buildRateRows(durationMs())); 146 + } else { 147 + listWidget.refreshOre(stats); 148 + } 149 + updateNavButtons(); 150 + } 151 + 152 + private long durationMs() { 153 + final long now = System.currentTimeMillis(); 154 + if (isTotalPage()) { 155 + return sessions.stream() 156 + .mapToLong(s -> (s.endTime() > 0 ? s.endTime() : now) - s.startTime()) 157 + .sum(); 158 + } else if (!sessions.isEmpty()) { 159 + final SessionSummary s = sessions.get(sessionIndex); 160 + return (s.endTime() > 0 ? s.endTime() : now) - s.startTime(); 161 + } 162 + return 0; 163 + } 164 + 165 + private List<RateData> buildRateRows(final long durationMs) { 166 + if (durationMs <= 0 || stats.isEmpty()) return List.of(); 167 + final double hours = durationMs / 3_600_000.0; 168 + final List<RateData> rows = new ArrayList<>(); 169 + for (final OreStats ore : stats) { 170 + final TreeMap<Integer, Integer> buckets = new TreeMap<>(); 171 + for (final Map.Entry<Integer, Integer> e : ore.yDistribution().entrySet()) { 172 + final int bucket = Math.floorDiv(e.getKey() - (-64), 10); 173 + buckets.merge(bucket, e.getValue(), Integer::sum); 174 + } 175 + for (final Map.Entry<Integer, Integer> e : buckets.entrySet()) { 176 + final int yMin = -64 + e.getKey() * 10; 177 + rows.add(new RateData(ore.displayName(), yMin, yMin + 10, e.getValue(), e.getValue() / hours)); 178 + } 179 + } 180 + rows.sort(Comparator.comparing(RateData::displayName).thenComparingInt(RateData::yMin)); 181 + return rows; 182 + } 183 + 184 + private void updateNavButtons() { 185 + prevBtn.active = sessionIndex > 0; 186 + nextBtn.active = sessionIndex < sessions.size(); 187 + exportBtn.active = !sessions.isEmpty(); 188 + 189 + final boolean isSession = !isTotalPage() && !sessions.isEmpty(); 190 + final MiningSession active = SessionManager.get().activeSession(); 191 + final boolean isActiveSession = isSession && active != null 192 + && active.id() == sessions.get(sessionIndex).id(); 193 + 194 + resumeBtn.active = isSession && !isActiveSession; 195 + resumeBtn.setMessage(Component.translatable(isActiveSession ? "mining.screen.button.active" : "mining.screen.button.resume")); 196 + deleteBtn.active = isSession; 197 + rateToggleBtn.active = !sessions.isEmpty(); 198 + } 199 + 200 + private void resumeCurrentSession() { 201 + if (isTotalPage() || sessions.isEmpty()) return; 202 + final SessionSummary s = sessions.get(sessionIndex); 203 + SessionManager.get().resumeSession(s.id(), s.worldName(), s.startTime()); 204 + updateNavButtons(); 205 + } 206 + 207 + private void deleteCurrentSession() { 208 + if (isTotalPage() || sessions.isEmpty()) return; 209 + final SessionSummary s = sessions.get(sessionIndex); 210 + minecraft.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen( 211 + confirmed -> { 212 + minecraft.setScreen(this); 213 + if (!confirmed) return; 214 + final MiningSession active = SessionManager.get().activeSession(); 215 + if (active != null && active.id() == s.id()) { 216 + SessionManager.get().stopSession(); 217 + } 218 + MiningDatabase.get().deleteSession(s.id()); 219 + sessions = MiningDatabase.get().allSessions(); 220 + sessionIndex = sessions.isEmpty() ? 0 : Math.min(sessionIndex, sessions.size() - 1); 221 + refreshStats(); 222 + }, 223 + Component.translatable("mining.screen.delete.title", s.id()), 224 + Component.translatable("mining.screen.delete.confirm") 225 + )); 226 + } 227 + 228 + private void exportCurrent() { 229 + if (sessions.isEmpty()) return; 230 + try { 231 + final Path dir = FabricLoader.getInstance().getGameDir() 232 + .resolve(MiningConfig.get().exportPath); 233 + Files.createDirectories(dir); 234 + final String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); 235 + if (isTotalPage()) { 236 + final Path file = dir.resolve("totals_" + ts + ".csv"); 237 + MiningDatabase.get().exportAggregateToCsv(file); 238 + } else { 239 + final SessionSummary s = sessions.get(sessionIndex); 240 + final Path file = dir.resolve("session_" + s.id() + "_" + ts + ".csv"); 241 + MiningDatabase.get().exportSessionToCsv(s.id(), file); 242 + } 243 + exportBtn.setMessage(Component.translatable("mining.screen.export.saved")); 244 + } catch (final IOException e) { 245 + exportBtn.setMessage(Component.translatable("mining.screen.export.error")); 246 + } 247 + } 248 + 249 + @Override 250 + public void extractRenderState(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final float delta) { 251 + extractMenuBackground(g); 252 + g.centeredText(font, title, width / 2, 8, 0xFFFFD700); 253 + 254 + if (sessions.isEmpty()) { 255 + g.centeredText(font, Component.translatable("mining.screen.noSessions"), 256 + width / 2, HEADER_H + 10, 0xFFAAAAAA); 257 + } else if (isTotalPage()) { 258 + g.centeredText(font, "All Sessions", width / 2, 21, 0xFFFFFFFF); 259 + final int totalOres = sessions.stream().mapToInt(SessionSummary::oreCount).sum(); 260 + final String info = String.format("%d sessions | %d total ores", sessions.size(), totalOres); 261 + g.centeredText(font, info, width / 2, 36, 0xFFAAAAAA); 262 + renderTableHeader(g); 263 + } else { 264 + final SessionSummary s = sessions.get(sessionIndex); 265 + final String sessionLabel = String.format("Session #%d (%d of %d)", 266 + s.id(), sessionIndex + 1, sessions.size()); 267 + g.centeredText(font, sessionLabel, width / 2, 21, 0xFFFFFFFF); 268 + 269 + final String start = DATE_FMT.format(LocalDateTime.ofInstant( 270 + Instant.ofEpochMilli(s.startTime()), ZoneId.systemDefault())); 271 + final long durMs = s.endTime() > 0 ? s.endTime() - s.startTime() 272 + : System.currentTimeMillis() - s.startTime(); 273 + final String info = String.format("World: %s | Started: %s | Duration: %s | Ores: %d", 274 + s.worldName() != null ? s.worldName() : "?", 275 + start, MiningSession.formatDuration(durMs), s.oreCount()); 276 + g.centeredText(font, info, width / 2, 36, 0xFFAAAAAA); 277 + 278 + renderTableHeader(g); 279 + } 280 + 281 + super.extractRenderState(g, mouseX, mouseY, delta); 282 + } 283 + 284 + private void renderTableHeader(final GuiGraphicsExtractor g) { 285 + if (showRates) { 286 + final int left = (width - TABLE_W_RATE) / 2; 287 + final int y = HEADER_H - 14; 288 + int x = left; 289 + g.fill(left - 2, y - 2, left + TABLE_W_RATE + 2, y + 10, 0x80000000); 290 + final int c = 0xFFFFD700; 291 + g.text(font, "Ore", x, y, c); 292 + x += COL_ORE; 293 + g.text(font, "Y Range", x, y, c); 294 + x += COL_Y_RANGE; 295 + g.text(font, "Count", x, y, c); 296 + x += COL_COUNT; 297 + g.text(font, "/Hour", x, y, c); 298 + } else { 299 + final int left = (width - TABLE_W) / 2; 300 + final int y = HEADER_H - 14; 301 + int x = left; 302 + g.fill(left - 2, y - 2, left + TABLE_W + 2, y + 10, 0x80000000); 303 + final int c = 0xFFFFD700; 304 + g.text(font, "Ore", x, y, c); 305 + x += COL_ORE; 306 + g.text(font, "Count", x, y, c); 307 + x += COL_COUNT; 308 + g.text(font, "Min Y", x, y, c); 309 + x += COL_MIN_Y; 310 + g.text(font, "Max Y", x, y, c); 311 + x += COL_MAX_Y; 312 + g.text(font, "Avg Y", x, y, c); 313 + x += COL_AVG_Y; 314 + g.text(font, "Best Y", x, y, c); 315 + } 316 + } 317 + 318 + @Override 319 + public void onClose() { 320 + minecraft.setScreen(null); 321 + } 322 + 323 + @Override 324 + public boolean isPauseScreen() { 325 + return false; 326 + } 327 + 328 + private record RateData(String displayName, int yMin, int yMax, int count, double ratePerHour) { 329 + } 330 + 331 + class StatsListWidget extends AbstractSelectionList<StatsListWidget.Row> { 332 + private boolean rateMode = false; 333 + 334 + StatsListWidget(final Minecraft mc, final int w, final int h, final int top, final int itemH) { 335 + super(mc, w, h, top, itemH); 336 + } 337 + 338 + @Override 339 + protected void extractListBackground(final GuiGraphicsExtractor g) { 340 + } 341 + 342 + @Override 343 + public void updateWidgetNarration(final NarrationElementOutput noe) { 344 + } 345 + 346 + void refreshOre(final List<OreStats> newStats) { 347 + rateMode = false; 348 + clearEntries(); 349 + for (int i = 0; i < newStats.size(); i++) { 350 + addEntry(new OreRow(newStats.get(i), i % 2 == 1)); 351 + } 352 + } 353 + 354 + void refreshRate(final List<RateData> rows) { 355 + rateMode = true; 356 + clearEntries(); 357 + for (int i = 0; i < rows.size(); i++) { 358 + addEntry(new RateRow(rows.get(i), i % 2 == 1)); 359 + } 360 + } 361 + 362 + @Override 363 + public int getRowWidth() { 364 + return (rateMode ? TABLE_W_RATE : TABLE_W) + 4; 365 + } 366 + 367 + @Override 368 + protected int scrollBarX() { 369 + return MineStatsScreen.this.width - 6; 370 + } 371 + 372 + abstract static class Row extends AbstractSelectionList.Entry<Row> { 373 + } 374 + 375 + class OreRow extends Row { 376 + private final OreStats ore; 377 + private final boolean shaded; 378 + 379 + OreRow(final OreStats ore, final boolean shaded) { 380 + this.ore = ore; 381 + this.shaded = shaded; 382 + } 383 + 384 + @Override 385 + public void extractContent(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final boolean hover, final float dt) { 386 + final int tableLeft = (MineStatsScreen.this.width - TABLE_W) / 2; 387 + final int h = getContentHeight(); 388 + g.fill(tableLeft - 2, getContentY(), tableLeft + TABLE_W + 2, getContentY() + h, 389 + hover ? 0x40FFFFFF : (shaded ? 0x18FFFFFF : 0)); 390 + int x = tableLeft; 391 + final int ty = getContentY() + (h - 8) / 2; 392 + g.text(font, ore.displayName(), x, ty, 0xFFFFFFFF); 393 + x += COL_ORE; 394 + g.text(font, String.valueOf(ore.count()), x, ty, 0xFFAAFFAA); 395 + x += COL_COUNT; 396 + g.text(font, String.valueOf(ore.minY()), x, ty, 0xFF88BBFF); 397 + x += COL_MIN_Y; 398 + g.text(font, String.valueOf(ore.maxY()), x, ty, 0xFFFF8888); 399 + x += COL_MAX_Y; 400 + g.text(font, String.format("%.1f", ore.avgY()), x, ty, 0xFFFFDD88); 401 + x += COL_AVG_Y; 402 + g.text(font, String.valueOf(ore.modeY()), x, ty, 0xFFAAFFFF); 403 + } 404 + 405 + @Override 406 + public boolean mouseClicked(final MouseButtonEvent event, final boolean consumed) { 407 + return false; 408 + } 409 + } 410 + 411 + class RateRow extends Row { 412 + private final RateData data; 413 + private final boolean shaded; 414 + 415 + RateRow(final RateData data, final boolean shaded) { 416 + this.data = data; 417 + this.shaded = shaded; 418 + } 419 + 420 + @Override 421 + public void extractContent(final GuiGraphicsExtractor g, final int mouseX, final int mouseY, final boolean hover, final float dt) { 422 + final int tableLeft = (MineStatsScreen.this.width - TABLE_W_RATE) / 2; 423 + final int h = getContentHeight(); 424 + g.fill(tableLeft - 2, getContentY(), tableLeft + TABLE_W_RATE + 2, getContentY() + h, 425 + hover ? 0x40FFFFFF : (shaded ? 0x18FFFFFF : 0)); 426 + int x = tableLeft; 427 + final int ty = getContentY() + (h - 8) / 2; 428 + final String range = data.yMin() + " to " + data.yMax(); 429 + g.text(font, data.displayName(), x, ty, 0xFFFFFFFF); 430 + x += COL_ORE; 431 + g.text(font, range, x, ty, 0xFFAAAAAA); 432 + x += COL_Y_RANGE; 433 + g.text(font, String.valueOf(data.count()), x, ty, 0xFFAAFFAA); 434 + x += COL_COUNT; 435 + g.text(font, String.format("%.2f", data.ratePerHour()), x, ty, 0xFFFFD700); 436 + } 437 + 438 + @Override 439 + public boolean mouseClicked(final MouseButtonEvent event, final boolean consumed) { 440 + return false; 441 + } 442 + } 443 + } 444 + }
+4
src/main/java/de/kokirigla/mining/screen/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.screen; 3 + 4 + import org.jspecify.annotations.NullMarked;
+104
src/main/java/de/kokirigla/mining/session/MiningSession.java
··· 1 + package de.kokirigla.mining.session; 2 + 3 + import org.jspecify.annotations.Nullable; 4 + 5 + import java.util.LinkedHashMap; 6 + import java.util.Map; 7 + import java.util.concurrent.ConcurrentHashMap; 8 + import java.util.concurrent.atomic.AtomicInteger; 9 + import java.util.stream.Collectors; 10 + 11 + public class MiningSession { 12 + private final long id; 13 + private final long startTime; 14 + private final @Nullable String worldName; 15 + private final AtomicInteger oreCount = new AtomicInteger(0); 16 + private final ConcurrentHashMap<String, AtomicInteger> countsByType = new ConcurrentHashMap<>(); 17 + private final ConcurrentHashMap<String, String> displayNameByType = new ConcurrentHashMap<>(); 18 + private volatile @Nullable String lastOreName = null; 19 + 20 + public MiningSession(final long id, final @Nullable String worldName) { 21 + this(id, worldName, System.currentTimeMillis()); 22 + } 23 + 24 + public MiningSession(final long id, final @Nullable String worldName, final long startTime) { 25 + this.id = id; 26 + this.startTime = startTime; 27 + this.worldName = worldName; 28 + } 29 + 30 + public static String formatDuration(final long millis) { 31 + final long totalSeconds = millis / 1000; 32 + final long hours = totalSeconds / 3600; 33 + final long minutes = (totalSeconds % 3600) / 60; 34 + final long seconds = totalSeconds % 60; 35 + return String.format("%02d:%02d:%02d", hours, minutes, seconds); 36 + } 37 + 38 + public long id() { 39 + return id; 40 + } 41 + 42 + public long startTime() { 43 + return startTime; 44 + } 45 + 46 + public @Nullable String worldName() { 47 + return worldName; 48 + } 49 + 50 + public int oreCount() { 51 + return oreCount.get(); 52 + } 53 + 54 + public @Nullable String lastOreName() { 55 + return lastOreName; 56 + } 57 + 58 + public void incrementOreCount(final String oreType, final String displayName) { 59 + oreCount.incrementAndGet(); 60 + lastOreName = displayName; 61 + countsByType.computeIfAbsent(oreType, _ -> new AtomicInteger()).incrementAndGet(); 62 + displayNameByType.putIfAbsent(oreType, displayName); 63 + } 64 + 65 + /** 66 + * Returns ore types sorted by count descending. 67 + * 68 + * @return map of ore types to counts, sorted by count descending 69 + * @since 1.0.0 70 + */ 71 + public Map<String, Integer> oreCountsSorted() { 72 + return countsByType.entrySet().stream() 73 + .sorted((a, b) -> b.getValue().get() - a.getValue().get()) 74 + .collect(Collectors.toMap( 75 + Map.Entry::getKey, 76 + e -> e.getValue().get(), 77 + (e1, _) -> e1, 78 + LinkedHashMap::new)); 79 + } 80 + 81 + public String displayName(final String oreType) { 82 + return displayNameByType.getOrDefault(oreType, oreType); 83 + } 84 + 85 + /** 86 + * Elapsed milliseconds since session start. 87 + * 88 + * @return milliseconds elapsed since this session started 89 + * @since 1.0.0 90 + */ 91 + public long elapsedMillis() { 92 + return System.currentTimeMillis() - startTime; 93 + } 94 + 95 + /** 96 + * Format elapsed time as HH:MM:SS. 97 + * 98 + * @return formatted duration string in HH:MM:SS 99 + * @since 1.0.0 100 + */ 101 + public String formattedDuration() { 102 + return formatDuration(elapsedMillis()); 103 + } 104 + }
+15
src/main/java/de/kokirigla/mining/session/OreRecord.java
··· 1 + package de.kokirigla.mining.session; 2 + 3 + import org.jspecify.annotations.Nullable; 4 + 5 + public record OreRecord( 6 + long sessionId, 7 + String oreType, 8 + String displayName, 9 + int yLevel, 10 + long timestamp, 11 + int x, 12 + int z, 13 + @Nullable String worldName 14 + ) { 15 + }
+28
src/main/java/de/kokirigla/mining/session/OreStats.java
··· 1 + package de.kokirigla.mining.session; 2 + 3 + import java.util.Map; 4 + 5 + public record OreStats( 6 + String oreType, 7 + String displayName, 8 + int count, 9 + int minY, 10 + int maxY, 11 + double avgY, 12 + int modeY, 13 + Map<Integer, Integer> yDistribution 14 + ) { 15 + /** 16 + * Y level with the highest frequency across all records for this ore type. 17 + * 18 + * @param dist the Y level frequency distribution 19 + * @return the most frequent Y level 20 + * @since 1.0.0 21 + */ 22 + public static int computeMode(final Map<Integer, Integer> dist) { 23 + return dist.entrySet().stream() 24 + .max(Map.Entry.comparingByValue()) 25 + .map(Map.Entry::getKey) 26 + .orElse(0); 27 + } 28 + }
+101
src/main/java/de/kokirigla/mining/session/SessionManager.java
··· 1 + package de.kokirigla.mining.session; 2 + 3 + import de.kokirigla.mining.MiningMod; 4 + import de.kokirigla.mining.database.MiningDatabase; 5 + import net.minecraft.core.BlockPos; 6 + import net.minecraft.core.registries.BuiltInRegistries; 7 + import net.minecraft.tags.BlockTags; 8 + import net.minecraft.world.level.Level; 9 + import net.minecraft.world.level.block.state.BlockState; 10 + 11 + import org.jspecify.annotations.Nullable; 12 + 13 + import java.util.Arrays; 14 + import java.util.stream.Collectors; 15 + 16 + public final class SessionManager { 17 + private static final SessionManager INSTANCE = new SessionManager(); 18 + 19 + private volatile @Nullable MiningSession activeSession = null; 20 + 21 + private SessionManager() { 22 + } 23 + 24 + public static SessionManager get() { 25 + return INSTANCE; 26 + } 27 + 28 + private static String normalizeOreType(final String oreType) { 29 + return oreType.startsWith("deepslate_") ? oreType.substring("deepslate_".length()) : oreType; 30 + } 31 + 32 + private static boolean isOre(final BlockState state) { 33 + return state.is(BlockTags.COAL_ORES) 34 + || state.is(BlockTags.IRON_ORES) 35 + || state.is(BlockTags.GOLD_ORES) 36 + || state.is(BlockTags.DIAMOND_ORES) 37 + || state.is(BlockTags.EMERALD_ORES) 38 + || state.is(BlockTags.LAPIS_ORES) 39 + || state.is(BlockTags.REDSTONE_ORES) 40 + || state.is(BlockTags.COPPER_ORES); 41 + } 42 + 43 + private static String formatOreName(final String registryPath) { 44 + return Arrays.stream(registryPath.split("_")) 45 + .map(w -> Character.toUpperCase(w.charAt(0)) + w.substring(1)) 46 + .collect(Collectors.joining(" ")); 47 + } 48 + 49 + public synchronized @Nullable MiningSession startSession(final String worldName) { 50 + if (activeSession != null) return null; 51 + final long sessionId = MiningDatabase.get().createSession(worldName); 52 + activeSession = new MiningSession(sessionId, worldName); 53 + MiningMod.LOGGER.info("Session #{} started in world '{}'", sessionId, worldName); 54 + return activeSession; 55 + } 56 + 57 + public synchronized void resumeSession(final long sessionId, final @Nullable String worldName, final long originalStartTime) { 58 + if (activeSession != null) stopSession(); 59 + MiningDatabase.get().resumeSession(sessionId); 60 + final MiningSession session = new MiningSession(sessionId, worldName, originalStartTime); 61 + for (final OreRecord r : MiningDatabase.get().recordsForSession(sessionId)) { 62 + session.incrementOreCount(r.oreType(), r.displayName()); 63 + } 64 + activeSession = session; 65 + MiningMod.LOGGER.info("Session #{} resumed with {} existing ores", sessionId, session.oreCount()); 66 + } 67 + 68 + public synchronized @Nullable MiningSession stopSession() { 69 + if (activeSession == null) return null; 70 + final MiningSession stopped = activeSession; 71 + activeSession = null; 72 + assert stopped != null; 73 + MiningDatabase.get().endSession(stopped.id()); 74 + MiningMod.LOGGER.info("Session #{} ended - {} ores mined", stopped.id(), stopped.oreCount()); 75 + return stopped; 76 + } 77 + 78 + public @Nullable MiningSession activeSession() { 79 + return activeSession; 80 + } 81 + 82 + public void onBlockBroken(final Level level, final BlockPos pos, final BlockState state) { 83 + final MiningSession session = activeSession; 84 + if (session == null) return; 85 + if (!isOre(state)) return; 86 + 87 + final String rawType = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath(); 88 + final String oreType = normalizeOreType(rawType); 89 + final String displayName = formatOreName(oreType); 90 + final String worldName = level.dimension().identifier().getPath(); 91 + 92 + final OreRecord record = new OreRecord( 93 + session.id(), oreType, displayName, 94 + pos.getY(), System.currentTimeMillis(), 95 + pos.getX(), pos.getZ(), worldName 96 + ); 97 + 98 + session.incrementOreCount(oreType, displayName); 99 + MiningDatabase.get().insertOreRecord(record); 100 + } 101 + }
+12
src/main/java/de/kokirigla/mining/session/SessionSummary.java
··· 1 + package de.kokirigla.mining.session; 2 + 3 + import org.jspecify.annotations.Nullable; 4 + 5 + public record SessionSummary( 6 + long id, 7 + long startTime, 8 + long endTime, 9 + @Nullable String worldName, 10 + int oreCount 11 + ) { 12 + }
+4
src/main/java/de/kokirigla/mining/session/package-info.java
··· 1 + @NullMarked 2 + package de.kokirigla.mining.session; 3 + 4 + import org.jspecify.annotations.NullMarked;
+73
src/main/resources/assets/mining/lang/en_us.json
··· 1 + { 2 + "mining.config.title": "Mining Settings", 3 + "mining.config.category.hud": "HUD", 4 + "mining.config.category.session": "Session", 5 + "mining.config.hud.enabled": "Enable HUD", 6 + "mining.config.hud.enabled.desc": "Show the stopwatch and ore counter overlay during an active session.", 7 + "mining.config.hud.x": "HUD X Position", 8 + "mining.config.hud.x.desc": "Horizontal position of the HUD (pixels from the anchor edge).", 9 + "mining.config.hud.y": "HUD Y Position", 10 + "mining.config.hud.y.desc": "Vertical position of the HUD (pixels from the anchor edge).", 11 + "mining.config.hud.anchor": "HUD Anchor", 12 + "mining.config.hud.anchor.desc": "Corner of the screen the HUD position is relative to.", 13 + "mining.config.hud.anchor.top_left": "Top Left", 14 + "mining.config.hud.anchor.top_right": "Top Right", 15 + "mining.config.hud.anchor.bottom_left": "Bottom Left", 16 + "mining.config.hud.anchor.bottom_right": "Bottom Right", 17 + "mining.config.hud.textColor": "Text Color", 18 + "mining.config.hud.textColor.desc": "Color of HUD text.", 19 + "mining.config.hud.backgroundColor": "Background Color", 20 + "mining.config.hud.backgroundColor.desc": "Color of the HUD background panel (supports alpha).", 21 + "mining.config.hud.shadow": "Text Shadow", 22 + "mining.config.hud.shadow.desc": "Draw a drop shadow behind HUD text.", 23 + "mining.config.hud.scale": "HUD Scale", 24 + "mining.config.hud.scale.desc": "Scale multiplier for the entire HUD element.", 25 + "mining.config.hud.showSession": "Show Session Number", 26 + "mining.config.hud.showStopwatch": "Show Stopwatch", 27 + "mining.config.hud.showOreCount": "Show Total Ore Count", 28 + "mining.config.hud.showLastOre": "Show Last Mined Ore", 29 + "mining.config.session.exportPath": "Default Export Folder", 30 + "mining.config.session.exportPath.desc": "Folder (relative to .minecraft) where CSV exports are saved.", 31 + "mining.command.started": "Mining session #%d started. Good luck!", 32 + "mining.command.alreadyRunning": "A session is already active (session #%d). Stop it first.", 33 + "mining.command.startFailed": "Failed to start session.", 34 + "mining.command.stopped": "Session #%d ended — %d ores mined in %s.", 35 + "mining.command.noSession": "No active mining session. Use /miningmod start.", 36 + "mining.command.status": "Session #%d active for %s — %d ores mined.", 37 + "mining.command.exported": "Exported to: %s", 38 + "mining.command.exportFailed": "Export failed: %s", 39 + "mining.command.noData": "No sessions to export.", 40 + "mining.screen.title": "Mining Statistics", 41 + "mining.screen.noSessions": "No sessions recorded yet. Start one with /miningmod start.", 42 + "mining.screen.session": "Session #%d", 43 + "mining.screen.sessionInfo": "Duration: %s | Total: %d ores", 44 + "mining.screen.export": "Export CSV", 45 + "mining.screen.export.saved": "Saved!", 46 + "mining.screen.export.error": "Error!", 47 + "mining.screen.allSessions": "All Sessions", 48 + "mining.screen.button.showRates": "Show Rates", 49 + "mining.screen.button.showStats": "Show Stats", 50 + "mining.screen.button.resume": "Resume", 51 + "mining.screen.button.active": "Active", 52 + "mining.screen.button.delete": "Delete", 53 + "mining.screen.tooltip.export": "Export current view to CSV", 54 + "mining.screen.tooltip.resume": "Resume this session", 55 + "mining.screen.tooltip.delete": "Delete this session permanently", 56 + "mining.screen.delete.title": "Delete Session #%s?", 57 + "mining.screen.delete.confirm": "This will permanently delete the session and all its ore records.", 58 + "mining.screen.header.ore": "Ore", 59 + "mining.screen.header.count": "Count", 60 + "mining.screen.header.minY": "Min Y", 61 + "mining.screen.header.maxY": "Max Y", 62 + "mining.screen.header.avgY": "Avg Y", 63 + "mining.screen.header.modeY": "Best Y", 64 + "mining.screen.header.yrange": "Y Range", 65 + "mining.screen.header.rate": "/Hour", 66 + "mining.hud.session": "Session #%d", 67 + "mining.hud.ores": "Ores: %d", 68 + "mining.hud.last": "Last: %s", 69 + "key.category.mining.mining": "Mining", 70 + "key.mining.start": "Start Session", 71 + "key.mining.stop": "Stop Session", 72 + "key.mining.stats": "Open Stats" 73 + }
+35
src/main/resources/fabric.mod.json
··· 1 + { 2 + "schemaVersion": 1, 3 + "id": "mining", 4 + "version": "${version}", 5 + "name": "Mining", 6 + "description": "Track mining sessions", 7 + "authors": [ 8 + "kokiriglade" 9 + ], 10 + "contact": {}, 11 + "license": "MIT", 12 + "icon": "assets/mining/icon.png", 13 + "environment": "*", 14 + "entrypoints": { 15 + "main": [ 16 + "de.kokirigla.mining.MiningMod" 17 + ], 18 + "client": [ 19 + "de.kokirigla.mining.MiningClient" 20 + ], 21 + "modmenu": [ 22 + "de.kokirigla.mining.ModMenuIntegration" 23 + ] 24 + }, 25 + "mixins": [ 26 + "mining.mixins.json" 27 + ], 28 + "depends": { 29 + "fabricloader": ">=0.15.0", 30 + "fabric-api": "*", 31 + "minecraft": "~26.1.2", 32 + "java": ">=21", 33 + "modmenu": "*" 34 + } 35 + }
+12
src/main/resources/mining.mixins.json
··· 1 + { 2 + "required": true, 3 + "package": "de.kokirigla.mining.mixin", 4 + "compatibilityLevel": "JAVA_21", 5 + "mixins": [], 6 + "client": [ 7 + "MultiPlayerGameModeMixin" 8 + ], 9 + "injectors": { 10 + "defaultRequire": 1 11 + } 12 + }