diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8327acb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,127 @@ +## https://github.com/gitattributes/gitattributes/blob/master/Common.gitattributes +# Common settings that generally should always be used with your language specific settings + +# Auto detect text files and perform LF normalization +* text=auto + +# +# The above will handle all files NOT found below +# + +# Documents +*.bibtex text diff=bibtex +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.md text diff=markdown +*.mdx text diff=markdown +*.tex text diff=tex +*.adoc text +*.textile text +*.mustache text +*.csv text eol=crlf +*.tab text +*.tsv text +*.txt text +*.sql text +*.epub diff=astextplain + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as text by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.eps binary + +# Scripts +*.bash text eol=lf +*.fish text eol=lf +*.sh text eol=lf +*.zsh text eol=lf +# These are explicitly windows files and should use crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Serialisation +*.json text +*.toml text +*.xml text +*.yaml text +*.yml text + +# Archives +*.7z binary +*.gz binary +*.tar binary +*.tgz binary +*.zip binary + +# Text files where line endings should be preserved +*.patch -text + +# +# Exclude files from exporting +# + +.gitattributes export-ignore +.gitignore export-ignore +.gitkeep export-ignore + + +## https://github.com/gitattributes/gitattributes/blob/master/C%2B%2B.gitattributes +# Sources +*.c text diff=cpp +*.cc text diff=cpp +*.cxx text diff=cpp +*.cpp text diff=cpp +*.cpi text diff=cpp +*.c++ text diff=cpp +*.hpp text diff=cpp +*.h text diff=cpp +*.h++ text diff=cpp +*.hh text diff=cpp + +# Compiled Object files +*.slo binary +*.lo binary +*.o binary +*.obj binary + +# Precompiled Headers +*.gch binary +*.pch binary + +# Compiled Dynamic libraries +*.so binary +*.dylib binary +*.dll binary + +# Compiled Static libraries +*.lai binary +*.la binary +*.a binary +*.lib binary + +# Executables +*.exe binary +*.out binary +*.app binary + + +## Project specific diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19a3609..a5865c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,11 @@ jobs: - run: | xmake -v -y + - name: Build and run planner tests + run: | + xmake build -y LaminaSortTests + xmake run LaminaSortTests + - uses: actions/upload-artifact@v7 with: name: ${{ github.event.repository.name }}-${{ github.sha }}-client-windows-x64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ada051 --- /dev/null +++ b/.gitignore @@ -0,0 +1,551 @@ +# https://github.com/github/gitignore/blob/main/C++.gitignore +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + + +# https://github.com/github/gitignore/blob/main/CMake.gitignore +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Testing +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps + + +# https://github.com/github/gitignore/blob/main/Global/Ninja.gitignore +.ninja_deps +.ninja_log + + +# https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + + +# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + + +# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + + +# XMake +/.xmake/ +/build/ +/CMakeLists.txt + + +# Project specific ignores +/.idea/ +/bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bddf3..b0eecf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Added + +- Sort key (default `R`, remappable in the game's keyboard settings) that + sorts the main 27 player inventory slots from any inventory or container + screen. Hotbar, armour, offhand and crafting slots are never touched. +- Sorting an opened ordinary storage container (chest, barrel, shulker box, + ...) when one of its slots is hovered; interfaces with role-specific slots + are never sorted. +- Consolidation of partial stacks that vanilla allows to stack + (`42 + 10 -> 52`, `42 + 30 -> 64 + 8`), decided by the game's own + `ItemStackBase::isStackable` check. +- Zero-config default order: Shulker Boxes (filled before empty, named + first, then by contents, then colour), then gear, items, building blocks, + natural blocks, unknown items, empty slots. Inside a section the client's + Creative registry order is kept; food always counts as an item. +- Shulker Box content signature built from the inner stacks' full ordering + keys (custom name, enchantments, damage, ...) merged per kind and sorted, + independent of the internal slot layout and bounded to one level. + Shulker Boxes are recognised by item class (`ShulkerBoxBlockItem`). +- Vanilla item locks: `lock_in_slot` stacks stay in place and are never + merged into or out of; the rest of the region is sorted around them. + `lock_in_inventory` stacks may still move inside the player inventory and + are left alone inside containers. +- The Sort key is ignored while a text box (Creative search, anvil name, + ...) is being edited, so typing the letter never triggers a sort. +- Variants of one item are ordered by custom name, then enchantments + (registry order, higher level first), then damage (better condition first); + the NBT hash is only a last-resort tie-breaker. +- Execution through the vanilla container transfer path + (`ContainerManagerController::handlePlaceAmount` / `handleSwap`), verified + step by step against the planned client-side state; any disagreement + aborts the sort. Server rejections arrive asynchronously, are reverted by + vanilla and are only logged, never fed back into the sort. +- Configuration file (`sortKeyCode`, `sortContainers`) and a `--trace=y` + diagnostic build. +- Unit tests for the pure sort planner (`LaminaSortTests`). + +### Changed + +- Target LeviLamina Client 26.51.3 (built against its SDK; `--trace=y` uses + the `RotatePolicy`-based `ll::io::FileSink` introduced in 26.51.2). diff --git a/README.md b/README.md index 2bc1b59..e7e8b6b 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,188 @@ # LaminaSort -Client-side inventory sorting and management mod for Minecraft Bedrock Edition, -built on [LeviLamina Client](https://github.com/LiteLDev/LeviLamina). +A client-side inventory sorting mod for Minecraft Bedrock Edition, built on +[LeviLamina Client](https://github.com/LiteLDev/LeviLamina). + +LaminaSort automates the ordinary inventory organisation a player performs by +hand: it merges compatible partial stacks and arranges the result into +sensible sections (Shulker Boxes, gear, items, blocks) that follow the game's +own Creative order, using the same client inventory machinery that the mouse +does. It is not an inventory editor, needs no server component and never +touches server-authoritative state directly. ## Target -- LeviLamina **v26.51.1** (client) -- Minecraft Bedrock Edition **1.26.51.x** (Windows x64) +* LeviLamina **v26.51.3** (client) +* Minecraft Bedrock Edition **1.26.51.x** (Windows x64) + +Requires only the Minecraft client with LeviLamina: no server mod, operator +permissions, cheats, commands or experiments. + +## Current scope (MVP) + +* Press the **Sort** key (default `R`, remappable under *Settings → Keyboard & + Mouse* as `LaminaSort.sort`) while an inventory or container screen is open. +* The **main 27 player inventory slots** are sorted. The hotbar, armour, + offhand, crafting and every other special slot are never touched. +* When an ordinary storage container (chest, trapped chest, ender chest, + barrel, shulker box, chest minecart/boat) is open and the pointer hovers one + of **its** slots, the container is sorted instead. Furnaces, brewing stands, + crafting, smithing, enchanting and other interfaces with role-specific slots + are never sorted. +* Compatible partial stacks are consolidated first (`42 + 10 → 52`, + `42 + 30 → 64 + 8`, `42 + 10 + 25 → 64 + 13`), respecting each item's own + max stack size (16 for eggs, 1 for tools). Existing stacks are topped up + before a new partial stack is left over. +* Sorting an already sorted region is a no-op; repeated presses are stable. +* Vanilla item locks are respected: a stack locked **in its slot** + (`minecraft:item_lock` = `lock_in_slot`) stays exactly where it is and the + rest of the region is sorted around it; nothing is ever merged into or out + of it. A stack locked **in the inventory** (`lock_in_inventory`) may still + change slots inside the player inventory, as vanilla allows; inside a + container it is left alone as well. +* The Sort key is ignored while a text box is being edited (Creative + search, anvil name, ...), so typing an `r` never triggers a sort. The key + also does nothing while an item is held on the cursor. + +Sorting is explicit only. Nothing happens in the background, and there is +nothing to configure: the default order is the product. + +## What the result looks like + +One press turns a mixed chest into these sections, in this order: + +1. **Shulker Boxes** – every box, whatever its colour, comes first. Boxes with + something inside come before empty ones. Named boxes lead, then boxes with + the same contents sit next to each other regardless of how the items are + arranged inside them, then colour. "Same contents" is judged the way the + inventory itself is ordered: an inner item's custom name, enchantments and + remaining durability count, so a box holding an *Efficiency V* pickaxe is + not the same as one holding a plain or a worn pickaxe. Only the box's own + items are inspected (vanilla never nests container items). +2. **Gear** – tools, weapons, armour, bows, shields, elytra and the like, in + the game's own Creative order. +3. **Items** – food, materials, potions, utility and miscellaneous items, in + the game's own Creative order. +4. **Blocks** – building blocks, then natural blocks, in Creative order. +5. Anything the game does not list in its Creative inventory (add-on or + custom items) goes last, in a stable identifier order. +6. Empty slots. + +Variants of one item are kept together and ordered the same way every time: +custom-named ones first (by name), then enchanted ones (grouped by +enchantment, higher level first), then plain ones; among damageable items the +one in better condition comes first. Stacks of one kind are placed biggest +first, so a merged run reads `64, 64, 12`. + +## How it works + +Three concerns are kept apart: + +* `src/mod/sort/` is the pure planner and ordering vocabulary. It works on + synthetic slot states (count, max stack size, an opaque *mergeability + group* and an ordering key) and produces a list of *Move* / *Swap* + operations plus the expected final layout. It has no Minecraft dependency + and is covered by unit tests. +* `src/mod/game/StackClassifier` turns real item stacks into planner input. + **Mergeability is decided by vanilla**: two stacks share a group only if + `ItemStackBase::isStackable(other)` — the check the game itself uses when + stacks are combined in the UI — says so. Damage, enchantments, names, lore, + components, container contents, `can_place_on` restrictions and every other + vanilla-relevant difference therefore keep stacks apart, exactly as the + inventory screen would. LaminaSort never defines its own equality. + The ordering key is a thin semantic layer over the game's own data: the + section comes from the Creative category of the item's registry entry + (Shulker Boxes and food are the only overrides), the position inside a + section from the Creative group and entry, and variant order from the + item's custom name, enchantment list (registry id order, level descending), + damage value and, for Shulker Boxes, a signature of the contents. That + signature is built from the inner stacks' own ordering keys (name, + enchantments, damage, ... but not nested contents), merged per kind and + sorted, so it does not depend on the internal slot layout. A Shulker Box is + recognised by the item's class (`ShulkerBoxBlockItem`), not by its name. + Item locks are read with the game's `ItemLockHelper`. A hash of the + remaining item data is used only as a last-resort tie-breaker. +* `src/mod/game/SortSession` executes the plan through the screen's own + `ContainerManagerController`: a Move is `handlePlaceAmount` (the transfer + behind shift-click / drag placement) and a Swap is `handleSwap` (the + transfer behind the hotbar hotkey swap). Both go through the vanilla + `ItemStackRequest` path: the client predicts the change immediately and + the server validates the request afterwards. Before and after every step + the touched slots are compared with the planner's simulation of the + client-side prediction; any disagreement, a refused transfer or an item + held on the cursor aborts the remaining steps. + + The server's verdict arrives asynchronously, after the whole sequence of + steps has already been issued, and is **not** fed back into the sort: if + the server rejects a request, vanilla itself discards the client's + prediction for that step and restores the server's slot contents, exactly + as it does for a rejected manual drag. LaminaSort only observes the + `ItemStackResponse` and logs a warning for each rejection (visible in the + log; a `--trace=y` build shows the accepted count too). It never retries, + re-plans or "fixes up" a rejected step. Since every step is an ordinary + validated transfer, the inventory is consistent whichever steps the server + accepted. + +## Configuration + +On first start LaminaSort writes `mods/LaminaSort/config/config.json`: + +```json +{ + "version": 1, + "sortKeyCode": 82, + "sortContainers": true +} +``` + +* `sortKeyCode` – default Windows virtual-key code for the Sort key (`82` is + `R`). It only seeds the game's keyboard settings; remap it in game. +* `sortContainers` – allow sorting an opened storage container when its slots + are hovered. When `false`, only the player inventory is ever sorted. + +There are no sorting profiles or ordering options; the default order described +above is the only one. The file is read once at mod load; restart the game +after editing it. ## Building -Requirements: xmake, Visual Studio 2022 build tools, and LLVM/clang-cl. +Requirements: [xmake](https://xmake.io), Visual Studio 2022 build tools, and a +clang-cl toolchain (LLVM). ```shell xmake f -y -p windows -a x64 -m release --target_type=client xmake ``` +The packaged mod (`LaminaSort.dll` + `manifest.json`) is written to +`bin/LaminaSort/`. Copy that folder into the `mods/` directory of a LeviLamina +client installation (for a LeviLauncher instance: +`%APPDATA%\levilauncher.exe\versions\\mods\LaminaSort\`). + +Unit tests for the game-independent planner: + +```shell +xmake build LaminaSortTests +xmake run LaminaSortTests +``` + +For runtime diagnostics, configure with `--trace=y`; the mod then logs the +selected region, the planned layout, every operation, the server's responses +and text-box focus changes at debug level and mirrors them, flushed +immediately, to `mods/LaminaSort/trace.log` (appended across runs). + +Building against the LeviLamina SDK needs an LLVM that the SDK package can be +compiled with; the 26.51.x SDK was verified with LLVM 22.1.8 (`clang-cl`). +If the `levilamina` package fails to compile with the Visual Studio-bundled +LLVM, put a matching LLVM `bin` directory first on `PATH` for `xmake f`. + +## Not in scope + +Move matching / move all, LaminaSort's own slot locking (vanilla item locks +are honoured, see above), auto-restock, background sorting, inventory search +or highlighting, custom sort profiles and any server-side component are +deliberately left out. + ## License [MIT](LICENSE) © amatouhake diff --git a/src/mod/Config.h b/src/mod/Config.h new file mode 100644 index 0000000..ff882c3 --- /dev/null +++ b/src/mod/Config.h @@ -0,0 +1,21 @@ +#pragma once + +namespace lamina_sort { + +/// Persistent settings, stored as JSON in the mod's config directory. +/// Bump `version` when the layout changes incompatibly. +struct Config { + int version = 1; + + /// Default key code for the Sort action (Windows virtual-key code; 0x52 is + /// 'R'). The binding is registered with the game's keyboard settings and + /// can be remapped there; this only seeds the default. + int sortKeyCode = 0x52; + + /// Allow sorting an ordinary storage container (chest, barrel, shulker + /// box, ...) when the pointer hovers one of its slots. The player's main + /// inventory is always sortable. + bool sortContainers = true; +}; + +} // namespace lamina_sort diff --git a/src/mod/LaminaSort.cpp b/src/mod/LaminaSort.cpp index db6f561..1468a0e 100644 --- a/src/mod/LaminaSort.cpp +++ b/src/mod/LaminaSort.cpp @@ -1,29 +1,132 @@ #include "mod/LaminaSort.h" +#include "mod/game/ResponseLogger.h" +#include "mod/game/ScreenTracker.h" +#include "mod/game/SortSession.h" +#include "mod/game/TextInputTracker.h" + +#include "ll/api/Config.h" +#include "ll/api/input/KeyHandle.h" +#include "ll/api/input/KeyRegistry.h" +#include "ll/api/io/FileSink.h" +#include "ll/api/io/LogLevel.h" +#include "ll/api/io/PatternFormatter.h" +#include "ll/api/io/RotatePolicy.h" #include "ll/api/mod/RegisterHelper.h" +#include "mc/client/game/IClientInstance.h" +#include "mc/client/multiplayer/ClientLevel.h" +#include "mc/world/item/registry/CreativeItemRegistry.h" +#include "mc/world/item/registry/ItemRegistryRef.h" + namespace lamina_sort { +namespace { +constexpr std::string_view kSortKeyName = "sort"; +} // namespace + LaminaSort& LaminaSort::getInstance() { static LaminaSort instance; return instance; } bool LaminaSort::load() { +#ifdef LAMINASORT_TRACE + // Trace builds mirror every line, flushed immediately, into the mod + // directory so the diagnostics can be followed while the game runs + // (LeviLamina's shared log file is only flushed at exit). Rotation is + // disabled so the file is simply appended to across runs. + getSelf().getLogger().setLevel(ll::io::LogLevel::Debug); + auto sink = std::make_shared( + getSelf().getModDir() / "trace.log", + ll::makePolymorphic("[{3:.3%F %T.} {2}][{1}] {0}", false), + ll::io::RotatePolicy::disabled() + ); + sink->setFlushLevel(ll::io::LogLevel::Debug); + getSelf().getLogger().addSink(std::move(sink)); + getSelf().getLogger().info("Trace build: debug logging enabled, mirrored to trace.log"); +#endif getSelf().getLogger().debug("Loading..."); + + // Missing file -> written with defaults; unknown/old version -> merged + // with defaults and rewritten, so the file always reflects the schema. + auto const configPath = getSelf().getConfigDir() / "config.json"; + try { + if (!ll::config::loadConfig(mConfig, configPath)) { + ll::config::saveConfig(mConfig, configPath); + } + } catch (std::exception const& e) { + getSelf().getLogger().error("Failed to load {}: {}; using defaults", configPath.string(), e.what()); + mConfig = Config{}; + } + getSelf().getLogger().debug( + "Config: sortKeyCode=0x{:X} sortContainers={}", + mConfig.sortKeyCode, + mConfig.sortContainers + ); + + // The key is registered with the game's own keyboard settings (remappable + // there as "LaminaSort.sort"). Registration must happen at load time so + // the binding exists before the input mappings are built. + auto& key = ll::input::KeyRegistry::getInstance().getOrCreateKey(kSortKeyName, {mConfig.sortKeyCode}); + key.registerButtonDownHandler([this](::FocusImpact, ::IClientInstance& client) { onSortRequested(client); }); return true; } bool LaminaSort::enable() { getSelf().getLogger().debug("Enabling..."); + game::ScreenTracker::getInstance().install(); + game::TextInputTracker::getInstance().install(); + game::ResponseLogger::install(); return true; } bool LaminaSort::disable() { getSelf().getLogger().debug("Disabling..."); + game::ResponseLogger::uninstall(); + game::TextInputTracker::getInstance().uninstall(); + game::ScreenTracker::getInstance().uninstall(); return true; } +void LaminaSort::onSortRequested(IClientInstance& client) { + auto& logger = getSelf().getLogger(); + + auto& tracker = game::ScreenTracker::getInstance(); + auto controller = tracker.current(); + if (!controller) { + logger.debug("Sort key pressed with no container screen open; ignored"); + return; + } + // The key is also an ordinary letter: while a text box (Creative search, + // anvil name, ...) is being edited it belongs to the text, not to us. + if (game::TextInputTracker::getInstance().isEditing(tracker.currentView())) { + logger.debug("Sort key pressed while a text box is being edited; ignored"); + return; + } + auto const region = game::SortSession::selectRegion(*controller, mConfig); + if (!region) { + logger.info( + "Sort key pressed but this screen has no sortable region; ignored ({})", + game::SortSession::describeScreen(*controller) + ); + return; + } + + // Creative order comes from the client's own registry, filled from the + // server's creative content. It may be absent (e.g. not yet received). + CreativeItemRegistry const* creativeRegistry = nullptr; + if (auto* level = client.getLevel()) { + creativeRegistry = level->getItemRegistry().getCreativeItemRegistry().get(); + if (creativeRegistry && creativeRegistry->mCreativeItems->empty()) { + creativeRegistry = nullptr; + } + } + + logger.debug("Screen: {}", game::SortSession::describeScreen(*controller)); + game::SortSession::run(*controller, *region, creativeRegistry, logger); +} + } // namespace lamina_sort LL_REGISTER_MOD(lamina_sort::LaminaSort, lamina_sort::LaminaSort::getInstance()); diff --git a/src/mod/LaminaSort.h b/src/mod/LaminaSort.h index 0380a6e..718c4e1 100644 --- a/src/mod/LaminaSort.h +++ b/src/mod/LaminaSort.h @@ -2,6 +2,10 @@ #include "ll/api/mod/NativeMod.h" +#include "mod/Config.h" + +class IClientInstance; + namespace lamina_sort { class LaminaSort { @@ -13,6 +17,8 @@ class LaminaSort { [[nodiscard]] ll::mod::NativeMod& getSelf() const { return mSelf; } + [[nodiscard]] Config const& getConfig() const { return mConfig; } + /// @return True if the mod is loaded successfully. bool load(); @@ -23,7 +29,12 @@ class LaminaSort { bool disable(); private: + /// Explicit Sort trigger (key press). Sorts the region selected for the + /// currently open container screen, if any. + void onSortRequested(IClientInstance& client); + ll::mod::NativeMod& mSelf; + Config mConfig; }; } // namespace lamina_sort diff --git a/src/mod/game/ResponseLogger.cpp b/src/mod/game/ResponseLogger.cpp new file mode 100644 index 0000000..4ffd50b --- /dev/null +++ b/src/mod/game/ResponseLogger.cpp @@ -0,0 +1,68 @@ +#include "mod/game/ResponseLogger.h" + +#include "mod/LaminaSort.h" + +#include "ll/api/memory/Hook.h" + +#include "mc/client/network/ClientNetworkHandler.h" +#include "mc/network/packet/ItemStackResponseContainerInfo.h" +#include "mc/network/packet/ItemStackResponseInfo.h" +#include "mc/network/packet/ItemStackResponsePacket.h" +#include "mc/network/packet/ItemStackResponseSlotInfo.h" +#include "mc/world/inventory/network/ItemStackNetResult.h" + +namespace lamina_sort::game { + +namespace { + +// Observes the server's verdict on each item stack request before the game +// applies it. Every sort step is such a request; a rejection means vanilla +// discards the client's prediction for that step and restores the server's +// slot contents. +LL_TYPE_INSTANCE_HOOK( + ItemStackResponseHook, + ll::memory::HookPriority::Normal, + ClientNetworkHandler, + &ClientNetworkHandler::$handle, + void, + ::NetworkIdentifier const& source, + ::ItemStackResponsePacket const& packet +) { + auto& logger = LaminaSort::getInstance().getSelf().getLogger(); + size_t accepted = 0; + for (auto const& response : packet.mResponses.get()) { + if (response.mResult == ItemStackNetResult::Success) { + ++accepted; + continue; + } + logger.warn( + "Server rejected item stack request #{} (result {}); the predicted slots are reverted by vanilla", + response.mClientRequestId->mRawId, + static_cast(response.mResult) + ); + } + if (accepted > 0) { + logger.debug("Server accepted {} item stack request(s)", accepted); + } + origin(source, packet); +} + +using Hooks = ll::memory::HookRegistrar; + +bool gInstalled = false; + +} // namespace + +void ResponseLogger::install() { + if (gInstalled) return; + Hooks::hook(); + gInstalled = true; +} + +void ResponseLogger::uninstall() { + if (!gInstalled) return; + Hooks::unhook(); + gInstalled = false; +} + +} // namespace lamina_sort::game diff --git a/src/mod/game/ResponseLogger.h b/src/mod/game/ResponseLogger.h new file mode 100644 index 0000000..5751827 --- /dev/null +++ b/src/mod/game/ResponseLogger.h @@ -0,0 +1,14 @@ +#pragma once + +namespace lamina_sort::game { + +/// Diagnostic only: logs every item stack request the server rejects, so a +/// sort step that the client predicted but the server refused (and vanilla +/// then rolled back) is visible in the log. It changes no behaviour. +class ResponseLogger { +public: + static void install(); + static void uninstall(); +}; + +} // namespace lamina_sort::game diff --git a/src/mod/game/ScreenTracker.cpp b/src/mod/game/ScreenTracker.cpp new file mode 100644 index 0000000..8b6eb55 --- /dev/null +++ b/src/mod/game/ScreenTracker.cpp @@ -0,0 +1,93 @@ +#include "mod/game/ScreenTracker.h" + +#include "mod/game/TextInputTracker.h" + +#include "ll/api/event/EventBus.h" +#include "ll/api/event/render/UIRenderEvent.h" +#include "ll/api/memory/Hook.h" + +#include "mc/client/gui/screens/ScreenController.h" +#include "mc/client/gui/screens/ScreenView.h" +#include "mc/client/gui/screens/controllers/ContainerScreenController.h" + +namespace lamina_sort::game { + +namespace { + +// Closing a screen does not always render a final frame we could observe, so +// forget the controller as soon as the game tells it to leave. The base +// implementation is reached by every container screen subclass. +LL_TYPE_INSTANCE_HOOK( + ContainerScreenLeaveHook, + ll::memory::HookPriority::Normal, + ContainerScreenController, + &ContainerScreenController::$onLeave, + void +) { + ScreenTracker::getInstance().onControllerLeft(*this); + origin(); +} + +using Hooks = ll::memory::HookRegistrar; + +} // namespace + +ScreenTracker& ScreenTracker::getInstance() { + static ScreenTracker instance; + return instance; +} + +void ScreenTracker::install() { + if (mInstalled) return; + Hooks::hook(); + mRenderListener = ll::event::EventBus::getInstance().emplaceListener( + [this](ll::event::AfterUIRenderEvent& event) { onAfterUIRender(event); } + ); + mInstalled = true; +} + +void ScreenTracker::uninstall() { + if (!mInstalled) return; + if (mRenderListener) { + ll::event::EventBus::getInstance().removeListener(mRenderListener); + mRenderListener.reset(); + } + Hooks::unhook(); + mCurrent.reset(); + mCurrentView = nullptr; + mInstalled = false; +} + +std::shared_ptr ScreenTracker::current() const { + auto controller = mCurrent.lock(); + if (!controller) return nullptr; + // Only controllers that reported _isContainerScreen() are ever stored. + return std::static_pointer_cast(controller); +} + +void ScreenTracker::onControllerLeft(ContainerScreenController& controller) { + auto current = mCurrent.lock(); + if (current && current.get() == static_cast(&controller)) { + mCurrent.reset(); + TextInputTracker::getInstance().forget(mCurrentView); + mCurrentView = nullptr; + } +} + +void ScreenTracker::onAfterUIRender(ll::event::AfterUIRenderEvent& event) { + // Every ScreenView on the stack renders each frame; keep the most recent + // container screen. The HUD and other overlays are not container screens. + auto const& controller = event.screenView().mController; + if (!controller || !controller->_isContainerScreen()) { + return; + } + if (mCurrent.lock() != controller) { + mCurrent = controller; + mCurrentView = &event.screenView(); + // A freshly shown screen starts with no text box selected; drop any + // stale knowledge a previous screen at the same address left behind. + TextInputTracker::getInstance().forget(mCurrentView); + } +} + +} // namespace lamina_sort::game diff --git a/src/mod/game/ScreenTracker.h b/src/mod/game/ScreenTracker.h new file mode 100644 index 0000000..a59877f --- /dev/null +++ b/src/mod/game/ScreenTracker.h @@ -0,0 +1,53 @@ +#pragma once + +#include "ll/api/event/ListenerBase.h" + +#include + +class ContainerScreenController; +class ScreenController; +class ScreenView; + +namespace ll::event::inline render { +class AfterUIRenderEvent; +} + +namespace lamina_sort::game { + +/// Remembers which container screen (survival inventory, chest, ...) is +/// currently shown, so an explicit Sort trigger can act on it. +/// +/// The controller is observed from the game's own UI render callback and held +/// through a weak pointer to the ScreenView's shared controller, so a screen +/// that has been torn down can never be dereferenced. Leaving the screen also +/// drops it eagerly via the `onLeave` hook. +/// +/// All members run on the client's main (render/input) thread. +class ScreenTracker { +public: + static ScreenTracker& getInstance(); + + void install(); + void uninstall(); + + /// The active container screen controller, or null when no container + /// screen is open (or it has already been destroyed). + [[nodiscard]] std::shared_ptr current() const; + + /// The ScreenView that owns `current()`; only meaningful while + /// `current()` is non-null (the view outlives its controller's onLeave). + [[nodiscard]] ScreenView const* currentView() const { return mCurrentView; } + + /// Called from the onLeave hook. + void onControllerLeft(ContainerScreenController& controller); + +private: + void onAfterUIRender(ll::event::AfterUIRenderEvent& event); + + std::weak_ptr mCurrent; + ScreenView const* mCurrentView{nullptr}; + ll::event::ListenerPtr mRenderListener; + bool mInstalled{false}; +}; + +} // namespace lamina_sort::game diff --git a/src/mod/game/SortSession.cpp b/src/mod/game/SortSession.cpp new file mode 100644 index 0000000..70d37bb --- /dev/null +++ b/src/mod/game/SortSession.cpp @@ -0,0 +1,261 @@ +#include "mod/game/SortSession.h" + +#include "mod/Config.h" +#include "mod/game/StackClassifier.h" +#include "mod/sort/SortPlanner.h" + +#include "ll/api/io/Logger.h" + +#include "mc/client/gui/screens/controllers/ContainerScreenController.h" +#include "mc/deps/shared_types/legacy/ContainerType.h" +#include "mc/world/containers/SlotData.h" +#include "mc/world/containers/managers/controllers/ContainerManagerController.h" +#include "mc/world/item/ItemStack.h" + +#include +#include +#include +#include + +namespace lamina_sort::game { + +namespace { + +using SharedTypes::Legacy::ContainerType; + +// Item collection names as the game's own UI definitions address them. They +// are only ever used after the screen confirms it has such a collection. +constexpr std::string_view kInventoryCollection = "inventory_items"; +constexpr std::array kStorageCollections{"container_items", "barrel_items", "shulker_box_items"}; + +// Ordinary storage: a plain grid of general-purpose slots with no +// role-specific slot. Furnaces, brewing stands, crafting tables and every +// other interface with semantic slots are deliberately not sortable. +bool isOrdinaryStorage(ContainerType type) { + switch (type) { + case ContainerType::Container: // chest, trapped chest, ender chest, barrel, shulker box + case ContainerType::MinecartChest: + case ContainerType::ChestBoat: + return true; + default: + return false; + } +} + +std::string describeStack(ItemStack const& stack) { + if (stack.isNull() || stack.mCount <= 0) return "-"; + return stack.getTypeName() + " x" + std::to_string(stack.mCount); +} + +// "[S] minecraft:red_shulker_box x1" style label for a planned stack. +std::string describeStack(sort::SlotStack const& s) { + if (s.empty()) return "-"; + std::string out(1, '['); + out += sort::sectionLabel(s.key.section); + out += "] "; + out += s.key.tail.empty() ? s.key.typeName : s.key.tail; + if (!s.key.name.empty()) out += " \"" + s.key.name + "\""; + if (!s.key.enchantments.empty()) out += " ench" + std::to_string(s.key.enchantments.size()); + if (s.key.damage > 0) out += " dmg" + std::to_string(s.key.damage); + out += " x" + std::to_string(s.count); + if (s.fixed()) out += " (locked)"; + return out; +} + +// Runtime view of a slot, compared against the planner's simulated state. +struct SlotVerifier { + ContainerManagerController const& manager; + std::string const& collection; + std::vector const& representatives; + + [[nodiscard]] ItemStack const& actual(int index) const { return manager.getItemStack(collection, index); } + + // A slot agrees with the simulation when it is empty exactly when the + // simulation says so, and otherwise holds the same count of an item that + // vanilla considers the same item as the group's representative. + [[nodiscard]] bool matches(int index, sort::SlotStack const& expected) const { + auto const& item = actual(index); + bool const actualEmpty = item.isNull() || item.mCount <= 0; + if (expected.empty() || actualEmpty) return expected.empty() && actualEmpty; + if (item.mCount != expected.count) return false; + if (expected.group < 0 || expected.group >= static_cast(representatives.size())) return false; + return item.matchesItem(representatives[static_cast(expected.group)]); + } + + [[nodiscard]] std::string mismatch(int index, sort::SlotStack const& expected) const { + return "#" + std::to_string(index) + " expected " + describeStack(expected) + ", found " + + describeStack(actual(index)); + } +}; + +} // namespace + +std::optional SortSession::selectRegion(ContainerScreenController& controller, Config const& config) { + auto const& manager = controller.mContainerManagerController; + if (!manager) return std::nullopt; + + if (config.sortContainers && isOrdinaryStorage(manager->getContainerType())) { + // The pointer decides: hovering a slot of the opened container sorts + // the container, anything else sorts the player's inventory. + auto const& hovered = controller.mHoveredSlotData->mCollectionName.get(); + for (auto name : kStorageCollections) { + std::string const collection(name); + if (collection != hovered || !manager->hasContainerController(collection)) continue; + int const size = manager->getContainerSize(collection); + if (size <= 0) continue; + return SortRegion{collection, size, "container"}; + } + } + + std::string const inventory(kInventoryCollection); + if (!manager->hasContainerController(inventory)) return std::nullopt; + int const size = manager->getContainerSize(inventory); + if (size <= 0) return std::nullopt; + return SortRegion{inventory, size, "player inventory"}; +} + +std::string SortSession::describeScreen(ContainerScreenController& controller) { + auto const& manager = controller.mContainerManagerController; + if (!manager) return "no container manager"; + std::string out = "type=" + std::to_string(static_cast(manager->getContainerType())); + out += " hovered=\"" + controller.mHoveredSlotData->mCollectionName.get() + "\""; + out += " containers=["; + bool first = true; + for (auto const& [name, ctrl] : manager->mContainers.get()) { + if (!first) out += ", "; + first = false; + out += name + ":" + std::to_string(manager->getContainerSize(name)); + } + out += "]"; + return out; +} + +bool SortSession::run( + ContainerScreenController& controller, + SortRegion const& region, + CreativeItemRegistry const* creativeRegistry, + ll::io::Logger& logger +) { + auto const manager = controller.mContainerManagerController; + if (!manager) { + logger.warn("Sort aborted: screen has no container manager"); + return false; + } + if (controller._isCursorSelectedActive()) { + logger.info("Sort skipped: an item is being held on the cursor"); + return false; + } + + // 1. Snapshot. Copies keep the item kinds around for verification even + // after the live slots have changed. + std::vector snapshot; + snapshot.reserve(static_cast(region.size)); + for (int i = 0; i < region.size; ++i) { + snapshot.emplace_back(manager->getItemStack(region.collectionName, i)); + } + + // 2. Classify with vanilla's stackability, 3. plan. + StackClassifier classifier(creativeRegistry); + bool const isPlayerInventory = region.collectionName == kInventoryCollection; + auto const slots = classifier.classify(snapshot, isPlayerInventory); + auto const plan = sort::planSort(slots); + + int fixedSlots = 0; + for (auto const& s : slots) fixedSlots += s.fixed() ? 1 : 0; + if (fixedSlots > 0) { + logger.info("{} item-locked slot(s) stay in place; the rest is sorted around them", fixedSlots); + } + + int occupied = 0; + for (auto const& s : slots) occupied += s.empty() ? 0 : 1; + logger.info( + "Sort start: {} \"{}\" ({} slots, {} occupied, {} kinds, order={}) -> {} operation(s)", + region.label, + region.collectionName, + region.size, + occupied, + classifier.representatives().size(), + classifier.usedCreativeOrder() ? "creative" : "identifier", + plan.ops.size() + ); + // Planned layout, for checking the order and consolidation by eye. + { + std::string layout; + for (auto const& s : plan.expected) { + if (s.empty()) continue; + if (!layout.empty()) layout += ", "; + layout += describeStack(s); + } + logger.debug("Planned layout: [{}]", layout); + } + if (plan.ops.empty()) { + logger.info("Sort complete: already sorted"); + return true; + } + + // 4. Execute through the same controller the inventory UI uses, checking + // the touched slots against the simulation before and after each step. + SlotVerifier verify{*manager, region.collectionName, classifier.representatives()}; + std::vector state = slots; + size_t done = 0; + for (auto const& op : plan.ops) { + for (int index : {op.from, op.to}) { + if (!verify.matches(index, state[static_cast(index)])) { + logger.warn( + "Sort aborted before {} ({}/{}): slot changed under us: {}", + sort::describe(op), + done, + plan.ops.size(), + verify.mismatch(index, state[static_cast(index)]) + ); + return false; + } + } + if (!sort::applyOperation(state, op)) { + logger.error("Sort aborted: planner produced an invalid step {}", sort::describe(op)); + return false; + } + + SlotData const src(region.collectionName, op.from); + SlotData const dst(region.collectionName, op.to); + bool ok = false; + if (op.kind == sort::OpKind::Move) { + // Same path as shift-clicking / dragging part of a stack onto a + // compatible or empty slot: a Place transfer of `count` items. + ok = manager->handlePlaceAmount(src, op.count, dst); + } else { + // Same path as the hotbar hotkey swap: exchange two slots. + ok = manager->handleSwap(src, dst); + } + logger.debug("{} -> {}", sort::describe(op), ok ? "ok" : "refused"); + + for (int index : {op.from, op.to}) { + if (ok && verify.matches(index, state[static_cast(index)])) continue; + logger.warn( + "Sort aborted after {} ({}/{}): {}{}", + sort::describe(op), + done + 1, + plan.ops.size(), + ok ? "" : "transfer refused; ", + verify.mismatch(index, state[static_cast(index)]) + ); + return false; + } + ++done; + } + + // 5. Verify the whole region against the planned layout. + for (int i = 0; i < region.size; ++i) { + if (!verify.matches(i, plan.expected[static_cast(i)])) { + logger.warn( + "Sort finished but region differs from plan: {}", + verify.mismatch(i, plan.expected[static_cast(i)]) + ); + return false; + } + } + logger.info("Sort complete: {} operation(s) applied", done); + return true; +} + +} // namespace lamina_sort::game diff --git a/src/mod/game/SortSession.h b/src/mod/game/SortSession.h new file mode 100644 index 0000000..108bce9 --- /dev/null +++ b/src/mod/game/SortSession.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +class ContainerScreenController; +class CreativeItemRegistry; + +namespace ll::io { +class Logger; +} + +namespace lamina_sort { +struct Config; +} + +namespace lamina_sort::game { + +/// A contiguous run of slots in one of the screen's item collections that +/// LaminaSort is allowed to reorder. Nothing outside it is ever addressed. +struct SortRegion { + std::string collectionName; ///< The game's collection name, e.g. "inventory_items". + int size{0}; + std::string label; ///< Human readable, for logs. +}; + +/// One explicit Sort request: picks the region, snapshots it, plans, executes +/// the plan through the game's own container transfer machinery and verifies +/// the outcome after every step. Any disagreement aborts the remaining steps; +/// the operations already issued are ordinary, server-validated transfers. +class SortSession { +public: + /// Chooses what to sort for the given screen: the storage container under + /// the pointer when it is an ordinary one and container sorting is + /// enabled, otherwise the player's main inventory. Empty when the screen + /// offers no sortable region. + static std::optional selectRegion(ContainerScreenController& controller, Config const& config); + + /// Diagnostic summary of the screen's container collections, for logs. + static std::string describeScreen(ContainerScreenController& controller); + + /// Runs the sort. Returns true when the region ended in the planned + /// state (including "nothing to do"). + static bool + run(ContainerScreenController& controller, + SortRegion const& region, + CreativeItemRegistry const* creativeRegistry, + ll::io::Logger& logger); +}; + +} // namespace lamina_sort::game diff --git a/src/mod/game/StackClassifier.cpp b/src/mod/game/StackClassifier.cpp new file mode 100644 index 0000000..959f357 --- /dev/null +++ b/src/mod/game/StackClassifier.cpp @@ -0,0 +1,284 @@ +#include "mod/game/StackClassifier.h" + +#include "mc/deps/nbt/CompoundTag.h" +#include "mc/deps/nbt/ListTag.h" +#include "mc/deps/nbt/Tag.h" +#include "mc/deps/shared_types/item/CreativeItemCategory.h" +#include "mc/world/item/Item.h" +#include "mc/world/item/ItemInstance.h" +#include "mc/world/item/ItemLockHelper.h" +#include "mc/world/item/ItemLockMode.h" +#include "mc/world/item/ItemStack.h" +#include "mc/world/item/ShulkerBoxBlockItem.h" +#include "mc/world/item/registry/CreativeGroupInfo.h" +#include "mc/world/item/registry/CreativeItemEntry.h" +#include "mc/world/item/registry/CreativeItemRegistry.h" + +#include +#include +#include + +namespace lamina_sort::game { + +namespace { + +// NBT keys as the game writes them. +constexpr std::string_view kEnchantsKey = "ench"; // ItemStackBase::TAG_ENCHANTS +constexpr std::string_view kEnchantIdKey = "id"; +constexpr std::string_view kEnchantLevelKey = "lvl"; +constexpr std::string_view kItemsKey = "Items"; // container item contents +// Generic identifier used as the Shulker Box key's `typeName`; the real +// identifier (colour) goes to `tail`. +constexpr std::string_view kShulkerBoxKind = "shulker_box"; + +// Maps the game's Creative categories onto LaminaSort's sections. Only these +// four are visible tabs; anything else is treated as unknown. +sort::Section sectionOf(SharedTypes::CreativeItemCategory category) { + switch (category) { + case SharedTypes::CreativeItemCategory::Equipment: + return sort::Section::Equipment; + case SharedTypes::CreativeItemCategory::Items: + return sort::Section::Items; + case SharedTypes::CreativeItemCategory::Construction: + return sort::Section::Construction; + case SharedTypes::CreativeItemCategory::Nature: + return sort::Section::Nature; + default: + return sort::Section::Unknown; + } +} + +// A vanilla Shulker Box is an instance of the game's ShulkerBoxBlockItem +// class, whatever its colour. Comparing the item's vtable with the class's +// exported vtable is exact and cheap; an add-on item that merely names itself +// "*_shulker_box" is a different (data-driven) class and does not match. +bool isShulkerBox(ItemStackBase const& stack) { + auto const item = stack.mItem; + if (!item) return false; + return *reinterpret_cast(item.get()) == ShulkerBoxBlockItem::$vftable(); +} + +// Vanilla item lock: LockInSlot pins the item to its slot. LockInInventory +// only forbids taking the item out of the player's inventory, so it may still +// be moved between inventory slots; inside a container region such an item +// is left alone as well, since vanilla never lets a player put it there. +bool isFixedInPlace(ItemStackBase const& stack, bool inventoryLockMovable) { + switch (ItemLockHelper::getItemLockMode(stack)) { + case ItemLockMode::LockInSlot: + return true; + case ItemLockMode::LockInInventory: + return !inventoryLockMovable; + default: + return false; + } +} + +// The game's own food flag (Item::isFood). Bedrock's Creative screen files +// most food under Equipment (and some under Nature); a chest reads better +// with food among the items, so this is the one category override. +bool isFood(ItemStackBase const& stack) { + auto const item = stack.mItem; + return item && item->isFood(); +} + +int readInt(CompoundTag const& tag, std::string_view key, int fallback) { + auto it = tag.mTags.find(key); + if (it == tag.mTags.end() || !it->second.is_number_integer()) return fallback; + return static_cast(it->second); +} + +ListTag const* findList(CompoundTag const* tag, std::string_view key) { + if (!tag) return nullptr; + auto it = tag->mTags.find(key); + if (it == tag->mTags.end() || !it->second.is_array()) return nullptr; + return &it->second.get(); +} + +// Enchantments as stored on the item ("ench" list of {id, lvl}); enchanted +// books use the same list for their stored enchantment. +std::vector readEnchantments(ItemStackBase const& stack) { + std::vector list; + auto const* ench = findList(stack.mUserData.get(), kEnchantsKey); + if (!ench) return list; + for (auto const& entryPtr : *ench) { + if (!entryPtr || entryPtr->getId() != Tag::Type::Compound) continue; + auto const& entry = entryPtr->as(); + int const id = readInt(entry, kEnchantIdKey, -1); + if (id < 0) continue; + list.push_back(sort::Enchantment{id, readInt(entry, kEnchantLevelKey, 1)}); + } + return sort::canonicalEnchantments(std::move(list)); +} + +} // namespace + +std::vector StackClassifier::classify(std::vector const& slots, bool inventoryLockMovable) { + mRepresentatives.clear(); + mKeyCache.clear(); + mUsedCreativeOrder = mCreativeRegistry != nullptr; + + std::vector result; + result.reserve(slots.size()); + for (auto const& stack : slots) { + if (stack.isNull() || stack.mCount <= 0) { + result.push_back(sort::SlotStack::emptySlot()); + continue; + } + sort::SlotStack s; + s.count = stack.mCount; + s.maxStackSize = stack.getMaxStackSize(); + s.group = groupOf(stack); + // Every member of a group is, by vanilla's verdict, the same kind of + // item; keying off the group's representative guarantees they share + // one key even if their user data is encoded slightly differently + // (e.g. an empty compound versus none). + s.key = keyOf(s.group); + s.locked = isFixedInPlace(stack, inventoryLockMovable); + result.push_back(std::move(s)); + } + return result; +} + +int StackClassifier::groupOf(ItemStack const& stack) { + // isStackable(other) is the game's own verdict on whether two stacks may + // be combined: same item, aux value, user data (enchantments, names, + // damage, container contents, ...) and block, and both stackable at all. + // Unstackable items therefore never join a group, not even with an + // identical copy of themselves. + for (size_t i = 0; i < mRepresentatives.size(); ++i) { + if (stack.isStackable(mRepresentatives[i])) { + return static_cast(i); + } + } + mRepresentatives.push_back(stack); + return static_cast(mRepresentatives.size() - 1); +} + +sort::SortKey StackClassifier::keyOf(int group) { + if (auto it = mKeyCache.find(group); it != mKeyCache.end()) return it->second; + auto const key = buildKey(mRepresentatives[static_cast(group)], true); + mKeyCache.emplace(group, key); + return key; +} + +sort::SortKey StackClassifier::buildKey(ItemStackBase const& stack, bool describeContents) { + sort::SortKey key; + + auto const placement = placementOf(stack); + key.section = placement.section; + key.creativeIndex = placement.creativeIndex; + key.typeName = stack.getTypeName(); + key.aux = stack.getAuxValue(); + + // A custom name is explicit player intent: named variants lead and are + // ordered by their visible name. + key.name = sort::normalizeName(stack.getCustomName()); + key.nameRank = key.name.empty() ? 1 : 0; + + // Enchanted variants lead the plain ones and group by enchantment + // identity (id order), then level; damage (worse condition) comes last. + key.enchantments = readEnchantments(stack); + key.variantRank = key.enchantments.empty() ? 1 : 0; + key.damage = stack.getDamageValue(); + + if (isShulkerBox(stack)) { + describeShulkerBox(stack, key, describeContents); + } + + // Everything else that keeps stacks apart (lore, other components, the + // adventure-mode restrictions that live outside the NBT compound) still + // needs a deterministic order; these hashes are the last resort only. + if (stack.mUserData && !stack.mUserData->mTags.empty()) { + key.detail = std::to_string(stack.mUserData->hash()); + } + if (stack.mCanPlaceOnHash != 0 || stack.mCanDestroyHash != 0) { + key.detail += "|p" + std::to_string(stack.mCanPlaceOnHash) + "|d" + std::to_string(stack.mCanDestroyHash); + } + return key; +} + +// Shulker Boxes form the leading section: filled boxes before empty ones, +// then custom name, then a signature of the contents (independent of the +// internal slot layout), then colour. The contents are read-only input. +// +// Inner items get the same key an inventory item would get, so a box with +// an enchanted, named or worn tool differs from one with a plain tool. The +// description is bounded to one level: vanilla never nests container items, +// so an inner item's own contents are not expanded. +void StackClassifier::describeShulkerBox(ItemStackBase const& stack, sort::SortKey& key, bool describeContents) { + key.section = sort::Section::ShulkerBox; + key.tail = key.typeName; // colour / variant decides late + key.typeName = std::string(kShulkerBoxKind); + key.aux = 0; + key.variantRank = 0; + key.enchantments.clear(); + key.damage = 0; + + std::vector entries; + auto const* items = describeContents ? findList(stack.mUserData.get(), kItemsKey) : nullptr; + if (items) { + for (auto const& entryPtr : *items) { + if (!entryPtr || entryPtr->getId() != Tag::Type::Compound) continue; + // fromTag resolves the item through the client's registry; a + // malformed entry must not take the whole signature down. + try { + ItemStack inner = ItemStack::fromTag(entryPtr->as()); + if (inner.isNull() || inner.mCount <= 0) continue; + entries.push_back(sort::ContentEntry{buildKey(inner, false), static_cast(inner.mCount)}); + } catch (...) { + sort::SortKey unknown; + unknown.typeName = "?"; + entries.push_back(sort::ContentEntry{unknown, 1}); + } + } + } + key.contents = sort::contentSignature(std::move(entries)); + key.creativeIndex = key.contents.empty() ? 1 : 0; // filled boxes first +} + +StackClassifier::Placement StackClassifier::placementOf(ItemStackBase const& stack) { + if (!mCreativeRegistry) return {}; + + auto const id = static_cast(stack.getId()); + auto const aux = static_cast(stack.getAuxValue()); + auto const cacheKey = std::make_pair(id, aux); + if (auto it = mPlacementCache.find(cacheKey); it != mPlacementCache.end()) { + return it->second; + } + + // The ordinal preserves the Creative screen's visual order inside a + // section: group by group, then entry by entry. Prefer the entry with the + // same aux value (potion variants and the like), otherwise the item's + // first entry; stacks that differ only in user data then share a + // placement and are ordered by their own state. + constexpr int kGroupSpan = 10000; // entries per group, far above reality + auto const& groups = mCreativeRegistry->mCreativeGroups.get(); + Placement found; + Placement idOnly; + bool haveIdOnly = false; + for (auto const& entry : mCreativeRegistry->mCreativeItems.get()) { + auto const& item = entry.mItemInstance.get(); + if (item.getId() != id) continue; + Placement p; + if (entry.mGroupIndex < groups.size()) { + p.section = sectionOf(groups[entry.mGroupIndex].mCategory); + } + p.creativeIndex = static_cast(entry.mGroupIndex) * kGroupSpan + static_cast(entry.mIndex); + if (item.getAuxValue() == aux) { + found = p; + break; + } + if (!haveIdOnly) { + idOnly = p; + haveIdOnly = true; + } + } + if (found.creativeIndex == INT_MAX && haveIdOnly) found = idOnly; + if (found.section != sort::Section::Unknown && isFood(stack)) { + found.section = sort::Section::Items; + } + mPlacementCache.emplace(cacheKey, found); + return found; +} + +} // namespace lamina_sort::game diff --git a/src/mod/game/StackClassifier.h b/src/mod/game/StackClassifier.h new file mode 100644 index 0000000..b76c161 --- /dev/null +++ b/src/mod/game/StackClassifier.h @@ -0,0 +1,65 @@ +#pragma once + +#include "mod/sort/SortPlanner.h" + +#include "mc/world/item/ItemStack.h" + +#include +#include +#include +#include + +class CreativeItemRegistry; +class ItemStackBase; + +namespace lamina_sort::game { + +/// Turns real item stacks into planner input. +/// +/// Mergeability is never decided here: two stacks share a planner group only +/// if vanilla's own `ItemStackBase::isStackable(other)` says the player could +/// stack them through the inventory UI. +/// +/// Ordering: a small semantic layer (Shulker Boxes first, then Equipment, +/// Items, Construction, Nature, unknown) on top of the client's Creative +/// registry order, with the item's own state (custom name, enchantments, +/// damage, container contents) deciding the order of variants of one item. +class StackClassifier { +public: + /// `creativeRegistry` may be null; keys then fall back to identifier order. + explicit StackClassifier(CreativeItemRegistry const* creativeRegistry) : mCreativeRegistry(creativeRegistry) {} + + /// Classifies `slots` (null stacks become empty slots). Every returned + /// group id indexes `representatives()`. Item-locked stacks are marked + /// `locked`; `inventoryLockMovable` says whether a "lock in inventory" + /// item may still change slots (true for the player's own inventory). + [[nodiscard]] std::vector classify(std::vector const& slots, bool inventoryLockMovable); + + /// One stack per group, in group id order. Used to verify that a slot + /// still holds the item kind the plan expects. + [[nodiscard]] std::vector const& representatives() const { return mRepresentatives; } + + /// Whether the last classify() could consult the Creative registry. + [[nodiscard]] bool usedCreativeOrder() const { return mUsedCreativeOrder; } + +private: + /// Creative placement of an item: section and ordinal inside it. + struct Placement { + sort::Section section{sort::Section::Unknown}; + int creativeIndex{INT_MAX}; + }; + + int groupOf(ItemStack const& stack); + sort::SortKey keyOf(int group); + sort::SortKey buildKey(ItemStackBase const& stack, bool describeContents); + Placement placementOf(ItemStackBase const& stack); + void describeShulkerBox(ItemStackBase const& stack, sort::SortKey& key, bool describeContents); + + CreativeItemRegistry const* mCreativeRegistry; + std::vector mRepresentatives; + std::map mKeyCache; ///< group -> key + std::map, Placement> mPlacementCache; ///< (id, aux) -> placement + bool mUsedCreativeOrder{false}; +}; + +} // namespace lamina_sort::game diff --git a/src/mod/game/TextInputTracker.cpp b/src/mod/game/TextInputTracker.cpp new file mode 100644 index 0000000..d4ada35 --- /dev/null +++ b/src/mod/game/TextInputTracker.cpp @@ -0,0 +1,77 @@ +#include "mod/game/TextInputTracker.h" + +#include "mod/LaminaSort.h" + +#include "ll/api/memory/Hook.h" + +#include "mc/client/gui/screens/ScreenView.h" + +namespace lamina_sort::game { + +namespace { + +// The game raises this for every text box when it gains (true) or loses +// (false) its selected/editing state. +LL_TYPE_INSTANCE_HOOK( + TextEditSelectedHook, + ll::memory::HookPriority::Normal, + ScreenView, + &ScreenView::_fireSelectedStateChangeEvent, + void, + ::TextEditComponent const& textEditComponent, + bool state +) { + TextInputTracker::getInstance().onSelectedStateChanged(*this, textEditComponent, state); + origin(textEditComponent, state); +} + +using Hooks = ll::memory::HookRegistrar; + +} // namespace + +TextInputTracker& TextInputTracker::getInstance() { + static TextInputTracker instance; + return instance; +} + +void TextInputTracker::install() { + if (mInstalled) return; + Hooks::hook(); + mInstalled = true; +} + +void TextInputTracker::uninstall() { + if (!mInstalled) return; + Hooks::unhook(); + mSelected.clear(); + mInstalled = false; +} + +bool TextInputTracker::isEditing(ScreenView const* view) const { + auto it = mSelected.find(view); + return it != mSelected.end() && !it->second.empty(); +} + +void TextInputTracker::onSelectedStateChanged( + ScreenView const& view, + TextEditComponent const& component, + bool selected +) { + auto& set = mSelected[&view]; + if (selected) { + set.insert(&component); + } else { + set.erase(&component); + if (set.empty()) mSelected.erase(&view); + } + LaminaSort::getInstance().getSelf().getLogger().debug( + "Text box {} on screen {:p} ({} selected)", + selected ? "selected" : "deselected", + static_cast(&view), + isEditing(&view) ? "still" : "none" + ); +} + +void TextInputTracker::forget(ScreenView const* view) { mSelected.erase(view); } + +} // namespace lamina_sort::game diff --git a/src/mod/game/TextInputTracker.h b/src/mod/game/TextInputTracker.h new file mode 100644 index 0000000..be31627 --- /dev/null +++ b/src/mod/game/TextInputTracker.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +class ScreenView; +class TextEditComponent; + +namespace lamina_sort::game { + +/// Knows whether a screen currently has a text box selected for typing, so +/// the Sort key can stay quiet while the player types an `r` into the +/// Creative search field or an anvil name. +/// +/// The information comes from the game's own text-edit selection event +/// (`ScreenView::_fireSelectedStateChangeEvent`), tracked per ScreenView. +/// All members run on the client's main thread. +class TextInputTracker { +public: + static TextInputTracker& getInstance(); + + void install(); + void uninstall(); + + /// True while at least one text box of `view` is selected for editing. + [[nodiscard]] bool isEditing(ScreenView const* view) const; + + /// Called from the hook. + void onSelectedStateChanged(ScreenView const& view, TextEditComponent const& component, bool selected); + + /// Forgets everything known about `view` (it was closed or just opened). + void forget(ScreenView const* view); + +private: + std::map> mSelected; + bool mInstalled{false}; +}; + +} // namespace lamina_sort::game diff --git a/src/mod/sort/SortKey.cpp b/src/mod/sort/SortKey.cpp new file mode 100644 index 0000000..29824a5 --- /dev/null +++ b/src/mod/sort/SortKey.cpp @@ -0,0 +1,148 @@ +#include "mod/sort/SortKey.h" + +#include +#include +#include +#include + +namespace lamina_sort::sort { + +namespace { + +template +int cmp(T const& a, T const& b) { + if (a < b) return -1; + if (b < a) return 1; + return 0; +} + +int compareEnchantmentLists(std::vector const& a, std::vector const& b) { + size_t const n = std::min(a.size(), b.size()); + for (size_t i = 0; i < n; ++i) { + if (a[i].id != b[i].id) return cmp(a[i].id, b[i].id); + if (a[i].level != b[i].level) return cmp(b[i].level, a[i].level); // higher level first + } + // Same prefix: the item with more enchantments leads. + return cmp(b.size(), a.size()); +} + +// Fixed-width decimal so that string comparison equals numeric comparison. +std::string padded(int value, int width) { + char buf[32]; + std::snprintf(buf, sizeof buf, "%0*d", width, value < 0 ? 0 : value); + return buf; +} + +} // namespace + +int compareKeys(SortKey const& a, SortKey const& b) { + if (int c = cmp(static_cast(a.section), static_cast(b.section))) return c; + if (int c = cmp(a.creativeIndex, b.creativeIndex)) return c; + if (int c = cmp(a.typeName, b.typeName)) return c; + if (int c = cmp(a.aux, b.aux)) return c; + if (int c = cmp(a.nameRank, b.nameRank)) return c; + if (int c = cmp(a.name, b.name)) return c; + if (int c = cmp(a.variantRank, b.variantRank)) return c; + if (int c = compareEnchantmentLists(a.enchantments, b.enchantments)) return c; + if (int c = cmp(a.contents, b.contents)) return c; + if (int c = cmp(a.damage, b.damage)) return c; + if (int c = cmp(a.tail, b.tail)) return c; + return cmp(a.detail, b.detail); +} + +std::string normalizeName(std::string const& name) { + std::string out; + out.reserve(name.size()); + for (size_t i = 0; i < name.size(); ++i) { + // "§" is the two-byte UTF-8 sequence C2 A7; it and the following + // code character are formatting only. + if (static_cast(name[i]) == 0xC2 && i + 1 < name.size() + && static_cast(name[i + 1]) == 0xA7) { + i += 2; // skip the code character too + continue; + } + out.push_back(static_cast(std::tolower(static_cast(name[i])))); + } + auto const first = out.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + auto const last = out.find_last_not_of(" \t\r\n"); + return out.substr(first, last - first + 1); +} + +std::vector canonicalEnchantments(std::vector list) { + std::sort(list.begin(), list.end(), [](Enchantment const& x, Enchantment const& y) { + if (x.id != y.id) return x.id < y.id; + return x.level > y.level; + }); + list.erase(std::unique(list.begin(), list.end()), list.end()); + return list; +} + +std::string keySignature(SortKey const& key) { + std::string out; + out += padded(static_cast(key.section), 1); + out += padded(key.creativeIndex == INT_MAX ? 999999999 : key.creativeIndex, 9); + out += key.typeName; + out += ':'; + out += padded(key.aux, 5); + out += ':'; + out += padded(key.nameRank, 1); + out += key.name; + out += ':'; + out += padded(key.variantRank, 1); + for (auto const& e : key.enchantments) { + out += padded(e.id, 3); + out += '-'; + out += padded(99 - std::min(e.level, 99), 2); // higher level first + out += ','; + } + out += ':'; + out += key.contents; + out += ':'; + out += padded(key.damage, 6); + out += ':'; + out += key.tail; + out += ':'; + out += key.detail; + return out; +} + +std::string contentSignature(std::vector entries) { + // Merge equal kinds regardless of slot layout, then order them like an + // inventory would be ordered. + struct KeyLess { + bool operator()(SortKey const& a, SortKey const& b) const { return compareKeys(a, b) < 0; } + }; + std::map totals; + for (auto const& e : entries) { + if (e.count <= 0 || e.key.typeName.empty()) continue; + totals[e.key] += e.count; + } + std::string out; + for (auto const& [key, total] : totals) { + out += keySignature(key); + out += 'x'; + out += padded(99999 - std::min(total, 99999), 5); // bigger totals first + out += ';'; + } + return out; +} + +char sectionLabel(Section section) { + switch (section) { + case Section::ShulkerBox: + return 'S'; + case Section::Equipment: + return 'E'; + case Section::Items: + return 'I'; + case Section::Construction: + return 'C'; + case Section::Nature: + return 'N'; + default: + return '?'; + } +} + +} // namespace lamina_sort::sort diff --git a/src/mod/sort/SortKey.h b/src/mod/sort/SortKey.h new file mode 100644 index 0000000..6648e93 --- /dev/null +++ b/src/mod/sort/SortKey.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include + +// Pure ordering vocabulary. No Minecraft or LeviLamina dependency: the game +// integration fills these structures in, the planner only compares them. +namespace lamina_sort::sort { + +/// Top-level regions of a sorted inventory, in the order they appear. This +/// is the only classification LaminaSort maintains itself; everything inside +/// a section follows the game's own Creative registry order. +enum class Section : int { + ShulkerBox = 0, ///< Every Shulker Box, filled ones first. + Equipment = 1, ///< Tools, weapons, armour, bows, shields, elytra, ... + Items = 2, ///< Materials, food, potions, utility and misc items. + Construction = 3, ///< Building blocks. + Nature = 4, ///< Natural blocks. + Unknown = 5, ///< Not in the Creative registry (add-on / custom). +}; + +/// One enchantment as stored on an item: registry id and level. +struct Enchantment { + int id{0}; + int level{0}; + + friend bool operator==(Enchantment const& a, Enchantment const& b) { return a.id == b.id && a.level == b.level; } +}; + +/// Ordering key for one kind of item. Compared field by field, in +/// declaration order, by `compareKeys`. +struct SortKey { + Section section{Section::Unknown}; + /// Position inside the section: the Creative registry ordinal (group, + /// then entry) for ordinary items; for Shulker Boxes 0 = filled, 1 = + /// empty. INT_MAX when unknown. + int creativeIndex{INT_MAX}; + /// Namespaced item identifier ("minecraft:cobblestone"). Shulker Boxes + /// use the generic "shulker_box" here and keep their colour in `tail`, so + /// that name and contents are compared before colour. + std::string typeName; + int aux{0}; + /// 0 when the item carries a custom name (named items lead), else 1. + int nameRank{1}; + /// Normalised custom name (see normalizeName), empty when unnamed. + std::string name; + /// 0 when enchanted (enchanted variants lead), else 1. + int variantRank{1}; + /// Enchantments in canonical order (see canonicalEnchantments). + std::vector enchantments; + /// Shulker Box content signature (see ContentSignature); empty otherwise. + std::string contents; + /// Damage value: lower (better condition) first. + int damage{0}; + /// Late tie-breaker with a human meaning, e.g. a Shulker Box's colour id. + std::string tail; + /// Stable last-resort text (user data hash, restriction hashes) so that + /// stacks LaminaSort cannot tell apart semantically still order + /// deterministically. Never the primary rule for common cases. + std::string detail; +}; + +/// Lexicographic comparison in field order; enchantment lists compare +/// entry by entry (id ascending, level descending) and a list that extends +/// another sorts first. Returns <0, 0 or >0. +[[nodiscard]] int compareKeys(SortKey const& a, SortKey const& b); + +inline bool operator<(SortKey const& a, SortKey const& b) { return compareKeys(a, b) < 0; } +inline bool operator==(SortKey const& a, SortKey const& b) { return compareKeys(a, b) == 0; } + +/// Stable human-readable form of a custom name: formatting codes ("§x") +/// removed, ASCII letters lower-cased, surrounding whitespace trimmed. +[[nodiscard]] std::string normalizeName(std::string const& name); + +/// Canonical enchantment order: by id ascending, then level descending, with +/// exact duplicates removed. The id order is the game's enchantment registry +/// order, which is also how it lists them. +[[nodiscard]] std::vector canonicalEnchantments(std::vector list); + +/// One stack found inside a container item, for content signatures. +struct ContentEntry { + /// The inner item's full ordering key (built like any inventory item's, + /// minus nested contents), so custom names, enchantments, damage and the + /// other variant state keep inner items apart. + SortKey key; + int count{0}; +}; + +/// Builds a deterministic signature of a container's contents that does not +/// depend on which internal slots the items occupy: entries with equal keys +/// are merged (counts summed), the kinds are sorted the same way the +/// inventory itself would be, and the result is serialised so that plain +/// string comparison orders signatures sensibly (first kind, then its total, +/// then the next kind, ...). Empty when there are no items. +[[nodiscard]] std::string contentSignature(std::vector entries); + +/// Canonical text form of a key: every field, fixed-width numbers, in +/// comparison order. Used by contentSignature; equal keys give equal text. +[[nodiscard]] std::string keySignature(SortKey const& key); + +/// Short label for logs, e.g. "S", "E", "I", "C", "N", "?". +[[nodiscard]] char sectionLabel(Section section); + +} // namespace lamina_sort::sort diff --git a/src/mod/sort/SortPlanner.cpp b/src/mod/sort/SortPlanner.cpp new file mode 100644 index 0000000..c3c2349 --- /dev/null +++ b/src/mod/sort/SortPlanner.cpp @@ -0,0 +1,177 @@ +#include "mod/sort/SortPlanner.h" + +#include +#include +#include + +namespace lamina_sort::sort { + +namespace { + +bool mergeable(SlotStack const& a, SlotStack const& b) { + return !a.empty() && !b.empty() && a.group >= 0 && a.group == b.group && a.maxStackSize > 1 && b.maxStackSize > 1; +} + +// Phase 1: fill the lowest-index stacks of every group from the highest-index +// ones, so each group ends with at most one partial stack and the earlier +// slots are full. This mirrors what a player does by hand: top up existing +// stacks before leaving new partial ones around. +void planConsolidation(std::vector& state, std::vector& ops) { + std::map> groups; + for (int i = 0; i < static_cast(state.size()); ++i) { + auto const& s = state[static_cast(i)]; + if (s.empty() || s.group < 0 || s.maxStackSize <= 1) continue; + groups[s.group].push_back(i); + } + for (auto& [group, indices] : groups) { + size_t lo = 0; + size_t hi = indices.size() - 1; + while (lo < hi) { + auto& receiver = state[static_cast(indices[lo])]; + auto& donor = state[static_cast(indices[hi])]; + int const space = receiver.maxStackSize - receiver.count; + if (space <= 0) { + ++lo; + continue; + } + if (donor.empty()) { + --hi; + continue; + } + Operation op{OpKind::Move, indices[hi], indices[lo], std::min(space, donor.count)}; + // Cannot fail: both slots were just checked. + (void)applyOperation(state, op); + ops.push_back(op); + } + } +} + +// Phase 2: bring the region into sorted order. Stacks are identified by +// (group, count): two full stacks of one group are interchangeable, so no +// operation is spent on swapping them. +// +// Order: key, then group (so the stacks of one kind stay together even when +// two vanilla-distinct kinds happen to share a key; group ids follow first +// appearance, which keeps a sorted region stable), then count descending. +void planArrangement(std::vector& state, std::vector& ops) { + std::vector order; + for (int i = 0; i < static_cast(state.size()); ++i) { + if (!state[static_cast(i)].empty()) order.push_back(i); + } + std::stable_sort(order.begin(), order.end(), [&](int a, int b) { + auto const& sa = state[static_cast(a)]; + auto const& sb = state[static_cast(b)]; + if (sa.key < sb.key) return true; + if (sb.key < sa.key) return false; + if (sa.group != sb.group) return sa.group < sb.group; + return sa.count > sb.count; + }); + std::vector desired; + desired.reserve(order.size()); + for (int i : order) desired.push_back(state[static_cast(i)]); + + for (size_t t = 0; t < desired.size(); ++t) { + auto const& want = desired[t]; + if (sameStack(state[t], want)) continue; + + // Positions before t are final, so the wanted stack sits after t. + size_t j = t + 1; + while (j < state.size() && !sameStack(state[j], want)) ++j; + if (j >= state.size()) return; // Unreachable: the multiset is preserved. + + auto const& cur = state[t]; + Operation op; + if (cur.empty()) { + op = Operation{OpKind::Move, static_cast(j), static_cast(t), want.count}; + } else if (mergeable(cur, want)) { + // Same group: `cur` is the group's partial stack (fulls sort + // first and are already placed), so top it up from j. Slot j is + // left holding the partial amount, keeping the multiset intact. + op = Operation{OpKind::Move, static_cast(j), static_cast(t), want.count - cur.count}; + } else { + op = Operation{OpKind::Swap, static_cast(t), static_cast(j), 0}; + } + if (!applyOperation(state, op)) return; // Unreachable for a consistent state. + ops.push_back(op); + } +} + +} // namespace + +bool sameStack(SlotStack const& a, SlotStack const& b) { + if (a.empty() || b.empty()) return a.empty() && b.empty(); + return a.group == b.group && a.count == b.count && a.key == b.key; +} + +bool applyOperation(std::vector& slots, Operation const& op) { + auto const n = static_cast(slots.size()); + if (op.from < 0 || op.from >= n || op.to < 0 || op.to >= n || op.from == op.to) return false; + auto& src = slots[static_cast(op.from)]; + auto& dst = slots[static_cast(op.to)]; + if (src.fixed() || dst.fixed()) return false; + + switch (op.kind) { + case OpKind::Move: { + if (src.empty() || op.count <= 0 || op.count > src.count) return false; + if (dst.empty()) { + dst = src; + dst.count = op.count; + } else { + if (!mergeable(src, dst) || dst.count + op.count > dst.maxStackSize) return false; + dst.count += op.count; + } + src.count -= op.count; + if (src.empty()) src = SlotStack::emptySlot(); + return true; + } + case OpKind::Swap: { + if (src.empty() || dst.empty()) return false; + std::swap(src, dst); + return true; + } + } + return false; +} + +Plan planSort(std::vector const& slots) { + Plan plan; + plan.expected = slots; + for (auto& s : plan.expected) { + if (s.empty()) s = SlotStack::emptySlot(); + } + + // Fixed slots are cut out: the planner works on the movable slots as if + // they were a contiguous region and the operations are mapped back, so + // no step can ever address a fixed slot. + std::vector movable; + for (int i = 0; i < static_cast(plan.expected.size()); ++i) { + if (!plan.expected[static_cast(i)].fixed()) movable.push_back(i); + } + std::vector view; + view.reserve(movable.size()); + for (int i : movable) view.push_back(plan.expected[static_cast(i)]); + + std::vector ops; + planConsolidation(view, ops); + planArrangement(view, ops); + + for (size_t k = 0; k < movable.size(); ++k) { + plan.expected[static_cast(movable[k])] = view[k]; + } + plan.ops.reserve(ops.size()); + for (auto op : ops) { + op.from = movable[static_cast(op.from)]; + op.to = movable[static_cast(op.to)]; + plan.ops.push_back(op); + } + return plan; +} + +std::string describe(Operation const& op) { + if (op.kind == OpKind::Swap) { + return "swap #" + std::to_string(op.from) + " <-> #" + std::to_string(op.to); + } + return "move " + std::to_string(op.count) + " #" + std::to_string(op.from) + " -> #" + std::to_string(op.to); +} + +} // namespace lamina_sort::sort diff --git a/src/mod/sort/SortPlanner.h b/src/mod/sort/SortPlanner.h new file mode 100644 index 0000000..8237175 --- /dev/null +++ b/src/mod/sort/SortPlanner.h @@ -0,0 +1,80 @@ +#pragma once + +#include "mod/sort/SortKey.h" + +#include +#include +#include + +// Pure inventory planning. This header has no Minecraft or LeviLamina +// dependency so the planner can be exercised with synthetic slot states +// (see tests/SortPlannerTest.cpp). +// +// The planner never decides whether two stacks may be merged: that is the +// game's decision (ItemStackBase::isStackable) and arrives here as an opaque +// `group` id. Equal non-negative group ids mean "vanilla lets these stack". +namespace lamina_sort::sort { + +/// One slot of the sortable region. +struct SlotStack { + /// Item count; 0 means the slot is empty and the other fields are ignored. + int count{0}; + /// Vanilla max stack size of the item (1 for unstackable items). + int maxStackSize{1}; + /// Mergeability class assigned by the caller: two stacks can be merged + /// only if they share a non-negative group. Empty slots use -1. + int group{-1}; + /// Ordering key; all stacks in a group must carry an equal key. + SortKey key; + /// The slot may not be touched at all (vanilla "lock in slot"): its + /// contents stay where they are, nothing is merged into or out of it, + /// and the rest of the region is sorted around it. Ignored when empty. + bool locked{false}; + + [[nodiscard]] bool empty() const { return count <= 0; } + [[nodiscard]] bool fixed() const { return locked && !empty(); } + + static SlotStack emptySlot() { return SlotStack{}; } +}; + +enum class OpKind { + /// Move `count` items from slot `from` into slot `to`. `to` is either + /// empty or holds a stack of the same group with room for `count`. + Move, + /// Exchange the contents of two occupied slots of different groups. + Swap, +}; + +struct Operation { + OpKind kind{OpKind::Move}; + int from{-1}; + int to{-1}; + int count{0}; ///< Items moved (Move only). +}; + +struct Plan { + std::vector ops; + /// Slot layout after every operation has been applied. + std::vector expected; +}; + +/// Returns whether the two stacks are the same kind and count (an empty slot +/// only matches an empty slot). Used for verifying simulated state. +[[nodiscard]] bool sameStack(SlotStack const& a, SlotStack const& b); + +/// Applies one operation to `slots`. Returns false (leaving `slots` untouched) +/// if the operation is not valid for the current state. +[[nodiscard]] bool applyOperation(std::vector& slots, Operation const& op); + +/// Computes the operations that consolidate compatible partial stacks and +/// arrange the region in sorted order (key ascending, then group, then count +/// descending, empty slots last). Fixed (locked, non-empty) slots are left +/// exactly as they are and never addressed by any operation; the movable +/// slots are sorted around them. The result is deterministic for a given +/// input and the total item count of every group is preserved. +[[nodiscard]] Plan planSort(std::vector const& slots); + +/// Formats an operation for logs, e.g. "move 22 #7 -> #3" or "swap #1 <-> #9". +[[nodiscard]] std::string describe(Operation const& op); + +} // namespace lamina_sort::sort diff --git a/tests/SortKeyTest.cpp b/tests/SortKeyTest.cpp new file mode 100644 index 0000000..bbecbe8 --- /dev/null +++ b/tests/SortKeyTest.cpp @@ -0,0 +1,633 @@ +// Checks for the semantic ordering: sections, Shulker Boxes, custom names, +// enchantments and durability. Keys are built the way the game-side +// classifier builds them, but from synthetic data; what these tests prove is +// the comparator, not how the game reports item state. + +#include "mod/sort/SortKey.h" +#include "mod/sort/SortPlanner.h" + +#include +#include +#include +#include + +using namespace lamina_sort::sort; + +namespace { + +int gFailures = 0; + +void check(bool ok, char const* what, int line) { + if (!ok) { + ++gFailures; + std::printf("FAIL (line %d): %s\n", line, what); + } +} + +#define CHECK(expr) check((expr), #expr, __LINE__) + +// Enchantment ids as the game numbers them (see Enchant::Type). +constexpr int kSharpness = 9; +constexpr int kEfficiency = 15; +constexpr int kSilkTouch = 16; +constexpr int kUnbreaking = 17; +constexpr int kFortune = 18; +constexpr int kMending = 26; + +SortKey item(Section section, int creativeIndex, std::string const& typeName) { + SortKey k; + k.section = section; + k.creativeIndex = creativeIndex; + k.typeName = typeName; + return k; +} + +SortKey pickaxe(int damage = 0, std::vector ench = {}, std::string const& name = "") { + auto k = item(Section::Equipment, 590060, "minecraft:diamond_pickaxe"); + k.enchantments = canonicalEnchantments(std::move(ench)); + k.variantRank = k.enchantments.empty() ? 1 : 0; + k.name = normalizeName(name); + k.nameRank = k.name.empty() ? 1 : 0; + k.damage = damage; + return k; +} + +SortKey book(std::vector ench) { + auto k = item(Section::Items, 440861, "minecraft:enchanted_book"); + k.enchantments = canonicalEnchantments(std::move(ench)); + k.variantRank = 0; + return k; +} + +// A Shulker Box key the way the classifier builds it: contents become a +// signature, the colour moves to `tail`, filled boxes get index 0. +SortKey shulker(std::string const& colourId, std::vector contents, std::string const& name = "") { + SortKey k; + k.section = Section::ShulkerBox; + k.typeName = "shulker_box"; + k.tail = colourId; + k.variantRank = 0; + k.contents = contentSignature(std::move(contents)); + k.creativeIndex = k.contents.empty() ? 1 : 0; + k.name = normalizeName(name); + k.nameRank = k.name.empty() ? 1 : 0; + return k; +} + +ContentEntry cobble(int count) { + return ContentEntry{item(Section::Construction, 130383, "minecraft:cobblestone"), count}; +} +ContentEntry stone(int count) { return ContentEntry{item(Section::Nature, 270603, "minecraft:stone"), count}; } +ContentEntry egg(int count) { return ContentEntry{item(Section::Nature, 440861, "minecraft:egg"), count}; } +// An inner tool with the same variant state an inventory item would carry. +ContentEntry innerPickaxe(int damage = 0, std::vector ench = {}, std::string const& name = "") { + return ContentEntry{pickaxe(damage, std::move(ench), name), 1}; +} + +bool before(SortKey const& a, SortKey const& b) { return compareKeys(a, b) < 0; } +bool same(SortKey const& a, SortKey const& b) { return compareKeys(a, b) == 0; } + +std::vector sortedKeys(std::vector keys) { + std::stable_sort(keys.begin(), keys.end(), [](SortKey const& a, SortKey const& b) { return before(a, b); }); + return keys; +} + +// Runs a region of unstackable items through the planner and returns the +// resulting type names, to prove the planner honours the key order. +std::vector plannedOrder(std::vector const& keys) { + std::vector slots; + for (size_t i = 0; i < keys.size(); ++i) { + SlotStack s; + s.count = 1; + s.maxStackSize = 1; + s.group = static_cast(i); + s.key = keys[i]; + slots.push_back(s); + } + slots.resize(27); + auto const plan = planSort(slots); + std::vector out; + for (auto const& s : plan.expected) { + if (!s.empty()) out.push_back(s.key.tail.empty() ? s.key.typeName : s.key.tail); + } + return out; +} + +// --- top-level sections ---------------------------------------------------- + +void testTopLevelSections() { + // Deliberately mixed and shuffled: a Nature block, a sword, a Shulker + // Box, a misc item, an unknown add-on item, a Construction block, a + // pickaxe. + auto const order = plannedOrder({ + item(Section::Nature, 250578, "minecraft:dirt"), + item(Section::Equipment, 561039, "minecraft:diamond_sword"), + shulker("minecraft:red_shulker_box", {cobble(3)}), + item(Section::Items, 400001, "minecraft:string"), + item(Section::Unknown, INT_MAX, "addon:widget"), + item(Section::Construction, 130383, "minecraft:cobblestone"), + item(Section::Equipment, 590060, "minecraft:diamond_pickaxe"), + }); + std::vector const expected{ + "minecraft:red_shulker_box", + "minecraft:diamond_sword", + "minecraft:diamond_pickaxe", + "minecraft:string", + "minecraft:cobblestone", + "minecraft:dirt", + "addon:widget", + }; + CHECK(order == expected); +} + +void testEquipmentDoesNotSplitBlocks() { + auto const order = plannedOrder({ + item(Section::Construction, 130383, "minecraft:cobblestone"), + item(Section::Equipment, 590060, "minecraft:diamond_pickaxe"), + item(Section::Nature, 250578, "minecraft:dirt"), + item(Section::Construction, 100010, "minecraft:oak_planks"), + item(Section::Nature, 270603, "minecraft:stone"), + }); + // All blocks are contiguous at the tail, Construction before Nature. + std::vector const expected{ + "minecraft:diamond_pickaxe", + "minecraft:oak_planks", + "minecraft:cobblestone", + "minecraft:dirt", + "minecraft:stone", + }; + CHECK(order == expected); +} + +void testUnknownItemsAreStableAndLast() { + auto const a = item(Section::Unknown, INT_MAX, "addon:zeta"); + auto const b = item(Section::Unknown, INT_MAX, "addon:alpha"); + CHECK(before(b, a)); // identifier order among unknowns + CHECK(before(item(Section::Nature, INT_MAX - 1, "minecraft:x"), b)); + CHECK(same(a, a)); +} + +// --- Shulker Boxes --------------------------------------------------------- + +void testEveryShulkerBeforeNonShulkers() { + auto const emptyBox = shulker("minecraft:undyed_shulker_box", {}); + for (auto const& other : { + item(Section::Equipment, 1, "minecraft:diamond_sword"), + item(Section::Items, 1, "minecraft:string"), + item(Section::Construction, 1, "minecraft:cobblestone"), + item(Section::Nature, 1, "minecraft:dirt"), + item(Section::Unknown, INT_MAX, "addon:widget"), + }) { + CHECK(before(emptyBox, other)); + } +} + +void testFilledBeforeEmptyNoInterleaving() { + auto const filledA = shulker("minecraft:red_shulker_box", {cobble(32)}); + auto const emptyB = shulker("minecraft:blue_shulker_box", {}); + auto const filledC = shulker("minecraft:yellow_shulker_box", {stone(64)}); + // input: filled -> empty -> filled + auto const order = plannedOrder({filledA, emptyB, filledC}); + std::vector const expected{ + "minecraft:red_shulker_box", + "minecraft:yellow_shulker_box", + "minecraft:blue_shulker_box", + }; + CHECK(order == expected); +} + +void testAllColoursStayInShulkerSection() { + std::vector keys; + for (auto const* colour : + {"white", + "orange", + "magenta", + "light_blue", + "yellow", + "lime", + "pink", + "gray", + "light_gray", + "cyan", + "purple", + "blue", + "brown", + "green", + "red", + "black", + "undyed"}) { + keys.push_back(shulker(std::string("minecraft:") + colour + "_shulker_box", {})); + } + keys.push_back(item(Section::Equipment, 1, "minecraft:diamond_sword")); + keys.push_back(item(Section::Construction, 1, "minecraft:cobblestone")); + auto const order = plannedOrder(keys); + for (size_t i = 0; i < 17; ++i) CHECK(order[i].ends_with("shulker_box")); + CHECK(order[17] == "minecraft:diamond_sword"); +} + +void testContentSignatureIgnoresSlotLayout() { + // A: slot 0 cobble x32, slot 15 stone x64. B: slot 2 stone x64, slot 20 + // cobble x32. Slot positions are not part of the entries at all, and the + // entry order must not matter either. + auto const sigA = contentSignature({cobble(32), stone(64)}); + auto const sigB = contentSignature({stone(64), cobble(32)}); + CHECK(sigA == sigB); + CHECK(!sigA.empty()); + // Split stacks of one kind are the same contents as one merged stack. + CHECK(contentSignature({cobble(20), stone(64), cobble(12)}) == sigA); + CHECK(same( + shulker("minecraft:red_shulker_box", {cobble(32), stone(64)}), + shulker("minecraft:red_shulker_box", {stone(64), cobble(32)}) + )); +} + +void testDifferentContentsOrderDeterministically() { + auto const cobbleBox = shulker("minecraft:red_shulker_box", {cobble(64)}); + auto const stoneBox = shulker("minecraft:red_shulker_box", {stone(64)}); + auto const eggBox = shulker("minecraft:red_shulker_box", {egg(16)}); + // Contents are ordered like an inventory: Construction before Nature, + // then Creative order inside Nature. + CHECK(before(cobbleBox, stoneBox)); + CHECK(before(stoneBox, eggBox)); + // More of the leading kind first. + CHECK(before(shulker("minecraft:red_shulker_box", {cobble(64)}), shulker("minecraft:red_shulker_box", {cobble(3)})) + ); + // Deterministic regardless of the order the comparison is made in. + CHECK(!before(stoneBox, cobbleBox)); + CHECK(compareKeys(cobbleBox, stoneBox) == -compareKeys(stoneBox, cobbleBox)); +} + +void testShulkerInnerEnchantedVsPlainDiffer() { + // Same base item inside, different variant state: the boxes must not + // collapse to the same content key. + auto const effBox = shulker( + "minecraft:red_shulker_box", + { + innerPickaxe(0, {{kEfficiency, 5}} + ) + } + ); + auto const plainBox = shulker("minecraft:red_shulker_box", {innerPickaxe(0)}); + CHECK(!same(effBox, plainBox)); + CHECK(effBox.contents != plainBox.contents); + // And they follow the inventory rule: the enchanted one leads. + CHECK(before(effBox, plainBox)); +} + +void testShulkerInnerNamedVariants() { + auto const namedBox = shulker("minecraft:red_shulker_box", {innerPickaxe(0, {}, "Old Faithful")}); + auto const plainBox = shulker("minecraft:red_shulker_box", {innerPickaxe(0)}); + CHECK(!same(namedBox, plainBox)); + CHECK(before(namedBox, plainBox)); // named inner item leads, as in an inventory + // Formatting codes in the inner name do not matter. + CHECK(same(namedBox, shulker("minecraft:red_shulker_box", {innerPickaxe(0, {}, "§bOld Faithful")}))); +} + +void testShulkerInnerDurabilityVariants() { + auto const freshBox = shulker("minecraft:red_shulker_box", {innerPickaxe(10)}); + auto const wornBox = shulker("minecraft:red_shulker_box", {innerPickaxe(1400)}); + CHECK(!same(freshBox, wornBox)); + CHECK(before(freshBox, wornBox)); + // The review example: Efficiency V + nearly full vs plain + nearly broken. + auto const good = shulker( + "minecraft:red_shulker_box", + { + innerPickaxe(10, {{kEfficiency, 5}} + ) + } + ); + auto const bad = shulker("minecraft:red_shulker_box", {innerPickaxe(1500)}); + CHECK(!same(good, bad)); + CHECK(before(good, bad)); +} + +void testShulkerInnerVariantsIgnoreSlotArrangement() { + // Same three inner stacks, listed in different orders (i.e. different + // internal slots) and with one kind split across two stacks. + auto const a = shulker( + "minecraft:red_shulker_box", + { + innerPickaxe(0, {{kEfficiency, 5}} + ), + cobble(20), + stone(64), + cobble(12) + } + ); + auto const b = shulker( + "minecraft:red_shulker_box", + { + stone(64), + cobble(32), + innerPickaxe(0, {{kEfficiency, 5}} + ) + } + ); + CHECK(same(a, b)); + CHECK(a.contents == b.contents); +} + +void testNamedShulkersOrderByReadableName() { + auto const tools = shulker("minecraft:red_shulker_box", {cobble(1)}, "§bTools"); + auto const blocks = shulker("minecraft:blue_shulker_box", {cobble(64)}, "Blocks"); + auto const plain = shulker("minecraft:white_shulker_box", {cobble(64)}); + // Named boxes lead, in name order, ahead of the unnamed one. + auto const order = plannedOrder({plain, tools, blocks}); + std::vector const expected{ + "minecraft:blue_shulker_box", + "minecraft:red_shulker_box", + "minecraft:white_shulker_box", + }; + CHECK(order == expected); + CHECK(normalizeName("§bTools") == "tools"); + CHECK(normalizeName(" Blocks ") == "blocks"); + // An empty named box is still after every filled box. + CHECK(before(plain, shulker("minecraft:red_shulker_box", {}, "AAA"))); +} + +void testHashDifferencesDoNotScrambleShulkers() { + auto a = shulker("minecraft:red_shulker_box", {cobble(32)}); + auto b = shulker("minecraft:red_shulker_box", {cobble(32)}); + a.detail = "999999"; + b.detail = "111111"; + auto c = shulker("minecraft:red_shulker_box", {stone(64)}); + c.detail = "000000"; + // The hash only breaks the tie between the two equal boxes; the box + // with different contents keeps its semantic position. + CHECK(before(b, a)); + CHECK(before(a, c) && before(b, c)); + CHECK(before(shulker("minecraft:blue_shulker_box", {cobble(32)}), a)); // colour before hash +} + +void testEmptyShulkersOrderByNameThenColour() { + auto const named = shulker("minecraft:white_shulker_box", {}, "Spare"); + auto const blue = shulker("minecraft:blue_shulker_box", {}); + auto const red = shulker("minecraft:red_shulker_box", {}); + auto const order = plannedOrder({red, blue, named}); + std::vector const expected{ + "minecraft:white_shulker_box", + "minecraft:blue_shulker_box", + "minecraft:red_shulker_box", + }; + CHECK(order == expected); +} + +// --- same-item variants ---------------------------------------------------- + +void testEnchantedBeforeUnenchanted() { + auto const plain = pickaxe(); + auto const enchanted = pickaxe( + 0, + { + {kEfficiency, 3} + } + ); + CHECK(before(enchanted, plain)); + // Both still sit inside the pickaxe run, i.e. before the next item kind. + auto next = item(Section::Equipment, 590061, "minecraft:diamond_shovel"); + CHECK(before(plain, next) && before(enchanted, next)); +} + +void testSameEnchantmentHigherLevelFirst() { + CHECK(before( + pickaxe( + 0, + { + {kEfficiency, 5} + } + ), + pickaxe(0, {{kEfficiency, 3}}) + )); + CHECK(before( + pickaxe( + 0, + { + {kEfficiency, 3} + } + ), + pickaxe(0, {{kEfficiency, 1}}) + )); +} + +void testDifferentEnchantmentsGroupByIdentity() { + auto const eff5 = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + auto const eff1 = pickaxe( + 0, + { + {kEfficiency, 1} + } + ); + auto const silk = pickaxe( + 0, + { + {kSilkTouch, 1} + } + ); + auto const fort3 = pickaxe( + 0, + { + {kFortune, 3} + } + ); + auto const order = plannedOrder({fort3, eff1, silk, eff5}); + // Efficiency (15) < Silk Touch (16) < Fortune (18); Efficiency V before I. + std::vector const names{ + "minecraft:diamond_pickaxe", + "minecraft:diamond_pickaxe", + "minecraft:diamond_pickaxe", + "minecraft:diamond_pickaxe" + }; + CHECK(order == names); + auto const sorted = sortedKeys({fort3, eff1, silk, eff5}); + CHECK(sorted[0].enchantments == eff5.enchantments); + CHECK(sorted[1].enchantments == eff1.enchantments); + CHECK(sorted[2].enchantments == silk.enchantments); + CHECK(sorted[3].enchantments == fort3.enchantments); + // A summed "power" score must not decide: Fortune III (3) does not beat + // Efficiency I (1) just because 3 > 1. + CHECK(before(eff1, fort3)); +} + +void testMultipleEnchantmentsExtendTheirPrefix() { + auto const eff = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + auto const effUnb = pickaxe( + 0, + { + {kUnbreaking, 3}, + {kEfficiency, 5} + } + ); + auto const effMend = pickaxe( + 0, + { + {kMending, 1}, + {kEfficiency, 5} + } + ); + auto const fortUnb = pickaxe( + 0, + { + {kFortune, 3}, + {kUnbreaking, 3} + } + ); + // Canonical order is by id, whatever order the NBT listed them in. + CHECK(effUnb.enchantments[0].id == kEfficiency && effUnb.enchantments[1].id == kUnbreaking); + // All Efficiency pickaxes stay together; the one with more enchantments + // leads; Fortune pickaxes follow. + auto const sorted = sortedKeys({fortUnb, eff, effMend, effUnb}); + CHECK(sorted[0].enchantments == effUnb.enchantments); + CHECK(sorted[1].enchantments == effMend.enchantments); + CHECK(sorted[2].enchantments == eff.enchantments); + CHECK(sorted[3].enchantments == fortUnb.enchantments); +} + +void testEnchantedBooks() { + auto const sharp5 = book({ + {kSharpness, 5} + }); + auto const sharp1 = book({ + {kSharpness, 1} + }); + auto const mend = book({ + {kMending, 1} + }); + auto const effUnb = book({ + {kEfficiency, 4}, + {kUnbreaking, 3} + }); + auto const sorted = sortedKeys({mend, sharp1, effUnb, sharp5}); + CHECK(sorted[0].enchantments == sharp5.enchantments); // Sharpness (9) + CHECK(sorted[1].enchantments == sharp1.enchantments); + CHECK(sorted[2].enchantments == effUnb.enchantments); // Efficiency (15) + CHECK(sorted[3].enchantments == mend.enchantments); // Mending (26) + // Books stay with the enchanted_book kind, not mixed into pickaxes. + CHECK(before(pickaxe(), sharp5)); +} + +void testCustomNamedVariantsLead() { + auto const named = pickaxe(500, {}, "Old Faithful"); + auto const namedB = pickaxe( + 0, + { + {kEfficiency, 5} + }, + "Beacon Breaker" + ); + auto const enchant = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + auto const plain = pickaxe(); + auto const sorted = sortedKeys({plain, enchant, named, namedB}); + CHECK(sorted[0].name == "beacon breaker"); // named, by name + CHECK(sorted[1].name == "old faithful"); + CHECK(sorted[2].enchantments == enchant.enchantments); // then enchanted + CHECK(sorted[3].name.empty() && sorted[3].enchantments.empty()); // then plain + // Name comparison is textual and formatting-insensitive. + CHECK(same(pickaxe(0, {}, "§lAlpha"), pickaxe(0, {}, "alpha"))); +} + +void testDurabilityBetterConditionFirst() { + auto const fresh = pickaxe(0); + auto const used = pickaxe(600); + auto const nearly = pickaxe(1500); + auto const sorted = sortedKeys({nearly, fresh, used}); + CHECK(sorted[0].damage == 0 && sorted[1].damage == 600 && sorted[2].damage == 1500); + // Same enchantment state, different durability: still better first, but + // the enchantment decides before durability. + auto const effWorn = pickaxe( + 1500, + { + {kEfficiency, 5} + } + ); + auto const effFresh = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + CHECK(before(effFresh, effWorn)); + CHECK(before(effWorn, fresh)); // enchanted (worn) still leads plain (fresh) +} + +void testDetailIsLastResortOnly() { + auto a = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + auto b = pickaxe( + 0, + { + {kEfficiency, 5} + } + ); + a.detail = "9"; + b.detail = "1"; + CHECK(before(b, a)); + // Any semantic field outranks it. + a.damage = 1; + CHECK(before(b, a)); + b.damage = 2; + CHECK(before(a, b)); +} + +void testCanonicalEnchantmentsDedupes() { + auto const list = canonicalEnchantments({ + {kUnbreaking, 3}, + {kEfficiency, 5}, + {kUnbreaking, 3} + }); + CHECK(list.size() == 2); + CHECK(list[0].id == kEfficiency && list[1].id == kUnbreaking); +} + +} // namespace + +int runSortKeyTests() { + testTopLevelSections(); + testEquipmentDoesNotSplitBlocks(); + testUnknownItemsAreStableAndLast(); + testEveryShulkerBeforeNonShulkers(); + testFilledBeforeEmptyNoInterleaving(); + testAllColoursStayInShulkerSection(); + testContentSignatureIgnoresSlotLayout(); + testDifferentContentsOrderDeterministically(); + testShulkerInnerEnchantedVsPlainDiffer(); + testShulkerInnerNamedVariants(); + testShulkerInnerDurabilityVariants(); + testShulkerInnerVariantsIgnoreSlotArrangement(); + testNamedShulkersOrderByReadableName(); + testHashDifferencesDoNotScrambleShulkers(); + testEmptyShulkersOrderByNameThenColour(); + testEnchantedBeforeUnenchanted(); + testSameEnchantmentHigherLevelFirst(); + testDifferentEnchantmentsGroupByIdentity(); + testMultipleEnchantmentsExtendTheirPrefix(); + testEnchantedBooks(); + testCustomNamedVariantsLead(); + testDurabilityBetterConditionFirst(); + testDetailIsLastResortOnly(); + testCanonicalEnchantmentsDedupes(); + if (gFailures == 0) { + std::printf("SortKeyTest: all checks passed\n"); + } else { + std::printf("SortKeyTest: %d check(s) failed\n", gFailures); + } + return gFailures; +} diff --git a/tests/SortPlannerTest.cpp b/tests/SortPlannerTest.cpp new file mode 100644 index 0000000..0a600e4 --- /dev/null +++ b/tests/SortPlannerTest.cpp @@ -0,0 +1,497 @@ +// Focused checks for the pure sort planner. These tests use synthetic slot +// states: the `group` id stands in for vanilla's stackability verdict, so they +// prove the planning arithmetic, NOT that any two real items may stack. That +// half is decided by Minecraft at runtime (ItemStackBase::isStackable) and is +// covered by in-game validation. +// +// Built with `xmake build LaminaSortTests`, run with `xmake run LaminaSortTests`. + +#include "mod/sort/SortPlanner.h" + +#include +#include +#include +#include + +using namespace lamina_sort::sort; + +// Semantic ordering checks live in SortKeyTest.cpp. +int runSortKeyTests(); + +namespace { + +int gFailures = 0; + +void check(bool ok, char const* what, int line) { + if (!ok) { + ++gFailures; + std::printf("FAIL (line %d): %s\n", line, what); + } +} + +#define CHECK(expr) check((expr), #expr, __LINE__) + +// Test vocabulary: a handful of item kinds with distinct keys. Two stacks are +// mergeable only when they share a group; `variant` produces stacks that look +// like the same item type (same name) but that vanilla keeps apart (different +// group and a distinguishing detail), e.g. enchanted vs plain, damaged, named. +SlotStack stack(std::string const& name, int count, int max = 64, int group = -1, std::string detail = "") { + SlotStack s; + s.count = count; + s.maxStackSize = max; + s.group = group; + s.key.typeName = name; + s.key.detail = std::move(detail); + return s; +} + +SlotStack cobble(int count, int group = 1) { return stack("minecraft:cobblestone", count, 64, group); } +SlotStack egg(int count, int group = 2) { return stack("minecraft:egg", count, 16, group); } +SlotStack dirt(int count, int group = 3) { return stack("minecraft:dirt", count, 64, group); } +SlotStack sword(int group, std::string detail = "") { return stack("minecraft:diamond_sword", 1, 1, group, detail); } +SlotStack empty() { return SlotStack::emptySlot(); } + +std::vector region(std::vector stacks, size_t size = 27) { + stacks.resize(size); + return stacks; +} + +// Runs the plan through applyOperation from the original state and checks it +// lands exactly on plan.expected (the planner's own simulation). +bool replays(std::vector slots, Plan const& plan) { + for (auto const& op : plan.ops) { + if (!applyOperation(slots, op)) return false; + } + if (slots.size() != plan.expected.size()) return false; + for (size_t i = 0; i < slots.size(); ++i) { + if (!sameStack(slots[i], plan.expected[i])) return false; + } + return true; +} + +std::map totalsByGroup(std::vector const& slots) { + std::map totals; + for (auto const& s : slots) { + if (!s.empty()) totals[s.group] += s.count; + } + return totals; +} + +// Fixed slots are not part of the sorted sequence: they are skipped here. +bool emptiesLast(std::vector const& slots) { + bool seenEmpty = false; + for (auto const& s : slots) { + if (s.fixed()) continue; + if (s.empty()) seenEmpty = true; + else if (seenEmpty) return false; + } + return true; +} + +bool sorted(std::vector const& slots) { + SlotStack const* prev = nullptr; + for (auto const& s : slots) { + if (s.fixed() || s.empty()) continue; + if (prev) { + auto const& a = *prev; + auto const& b = s; + if (b.key < a.key) return false; + if (a.key == b.key && b.group < a.group) return false; + if (a.key == b.key && a.group == b.group && b.count > a.count) return false; + } + prev = &s; + } + return true; +} + +std::vector counts(std::vector const& slots, size_t n) { + std::vector out; + for (size_t i = 0; i < n && i < slots.size(); ++i) out.push_back(slots[i].count); + return out; +} + +// Common invariants every plan must satisfy. +void checkInvariants(std::vector const& input, Plan const& plan, int line) { + check(replays(input, plan), "plan replays onto expected state", line); + for (size_t i = 0; i < input.size(); ++i) { + if (!input[i].fixed()) continue; + check(sameStack(plan.expected[i], input[i]), "fixed slot keeps its contents", line); + for (auto const& op : plan.ops) { + check( + op.from != static_cast(i) && op.to != static_cast(i), + "no operation touches a fixed slot", + line + ); + } + } + check(totalsByGroup(input) == totalsByGroup(plan.expected), "item totals preserved per group", line); + check(emptiesLast(plan.expected), "empty slots are last", line); + check(sorted(plan.expected), "expected layout is sorted", line); + for (auto const& s : plan.expected) { + check(s.empty() || s.count <= s.maxStackSize, "no stack exceeds its max size", line); + } + for (auto const& op : plan.ops) { + check(op.kind == OpKind::Move || op.count == 0, "swap carries no count", line); + check(op.from != op.to, "operation touches two distinct slots", line); + } +} + +void testEmptyInventory() { + auto const input = region({}); + auto const plan = planSort(input); + CHECK(plan.ops.empty()); + checkInvariants(input, plan, __LINE__); +} + +void testAlreadySorted() { + auto const input = region({cobble(64), cobble(10), dirt(64), egg(16)}); + auto const plan = planSort(input); + CHECK(plan.ops.empty()); + checkInvariants(input, plan, __LINE__); +} + +void testOneItem() { + auto const input = region({empty(), empty(), dirt(5)}); + auto const plan = planSort(input); + CHECK(plan.ops.size() == 1); + CHECK(plan.ops[0].kind == OpKind::Move && plan.ops[0].from == 2 && plan.ops[0].to == 0 && plan.ops[0].count == 5); + CHECK(counts(plan.expected, 3) == std::vector({5, 0, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testMultipleDifferentItems() { + auto const input = region({dirt(3), empty(), egg(2), empty(), cobble(7)}); + auto const plan = planSort(input); + CHECK(plan.expected[0].key.typeName == "minecraft:cobblestone"); + CHECK(plan.expected[1].key.typeName == "minecraft:dirt"); + CHECK(plan.expected[2].key.typeName == "minecraft:egg"); + CHECK(plan.expected[3].empty()); + checkInvariants(input, plan, __LINE__); +} + +void testMerge42Plus10() { + auto const input = region({cobble(42), cobble(10)}); + auto const plan = planSort(input); + CHECK(plan.ops.size() == 1); + CHECK(plan.ops[0].kind == OpKind::Move && plan.ops[0].from == 1 && plan.ops[0].to == 0 && plan.ops[0].count == 10); + CHECK(counts(plan.expected, 2) == std::vector({52, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testMerge42Plus30() { + auto const input = region({cobble(42), cobble(30)}); + auto const plan = planSort(input); + CHECK(plan.ops.size() == 1); + CHECK(plan.ops[0].kind == OpKind::Move && plan.ops[0].count == 22); + CHECK(counts(plan.expected, 2) == std::vector({64, 8})); + checkInvariants(input, plan, __LINE__); +} + +void testMergeSeparatedStacksPartialFirst() { + // 10 sits before 42: the smaller stack is topped up first (it is the + // lowest-index one), yielding 52 in slot 0 and nothing in slot 5. + auto const input = region({cobble(10), dirt(1), empty(), empty(), empty(), cobble(42)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 3) == std::vector({52, 1, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testMoreThanTwoPartialStacks() { + auto const input = region({cobble(42), cobble(10), cobble(25)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 3) == std::vector({64, 13, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testManyPartialStacksMinimalCount() { + // 7 x 20 = 140 = 64 + 64 + 12 -> exactly three stacks remain. + std::vector stacks; + for (int i = 0; i < 7; ++i) stacks.push_back(cobble(20)); + auto const input = region(stacks); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 4) == std::vector({64, 64, 12, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testAlreadyFullStacks() { + auto const input = region({cobble(64), cobble(64), cobble(64)}); + auto const plan = planSort(input); + CHECK(plan.ops.empty()); + checkInvariants(input, plan, __LINE__); +} + +void testFullPlusPartial() { + auto const input = region({cobble(64), cobble(20), cobble(64)}); + auto const plan = planSort(input); + // 148 = 64 + 64 + 20: fulls first, partial last. + CHECK(counts(plan.expected, 4) == std::vector({64, 64, 20, 0})); + checkInvariants(input, plan, __LINE__); +} + +void testMaxStackSize16() { + auto const input = region({egg(10), egg(9), egg(3)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 3) == std::vector({16, 6, 0})); + checkInvariants(input, plan, __LINE__); + for (auto const& op : plan.ops) CHECK(op.kind == OpKind::Move); +} + +void testMaxStackSize16NoOvershoot() { + auto const input = region({egg(15), egg(15)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 2) == std::vector({16, 14})); + checkInvariants(input, plan, __LINE__); +} + +void testNonStackableItemsNeverMerge() { + // Distinct groups (vanilla says isStackable() is false for every pair). + auto const input = region({sword(10), empty(), sword(11), sword(12)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 4) == std::vector({1, 1, 1, 0})); + for (auto const& op : plan.ops) CHECK(op.kind == OpKind::Move || op.kind == OpKind::Swap); + checkInvariants(input, plan, __LINE__); +} + +void testNonStackableEvenWhenGivenSameGroupByMistake() { + // Defensive: max stack 1 must never be merged even if a caller hands the + // stacks a shared group. + auto const input = region({stack("minecraft:shears", 1, 1, 5), stack("minecraft:shears", 1, 1, 5)}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 2) == std::vector({1, 1})); + checkInvariants(input, plan, __LINE__); +} + +void testLookAlikeButIncompatibleStacksStaySeparate() { + // Same item name, but vanilla keeps them apart (different groups: e.g. one + // is renamed / enchanted). They must not be merged, and the detail key + // keeps the order deterministic. + auto const plainA = stack("minecraft:cobblestone", 30, 64, 1); + auto const plainB = stack("minecraft:cobblestone", 30, 64, 1); + auto const named = stack("minecraft:cobblestone", 30, 64, 2, "name=Special"); + auto const input = region({named, plainA, plainB}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 3) == std::vector({60, 30, 0})); + CHECK(plan.expected[0].group == 1 && plan.expected[1].group == 2); + checkInvariants(input, plan, __LINE__); +} + +void testLookAlikesWithEqualKeysStayContiguous() { + // Two kinds that vanilla keeps apart but whose keys are identical (the + // classifier could not tell them apart): each kind must still end up in + // one contiguous run, ordered by first appearance, biggest stack first. + auto const plainA = stack("minecraft:cobblestone", 8, 64, 1); + auto const specialA = stack("minecraft:cobblestone", 40, 64, 2); + auto const plainB = stack("minecraft:cobblestone", 64, 64, 1); + auto const input = region({plainA, specialA, plainB}); + auto const plan = planSort(input); + CHECK(counts(plan.expected, 4) == std::vector({64, 8, 40, 0})); + CHECK(plan.expected[0].group == 1 && plan.expected[1].group == 1 && plan.expected[2].group == 2); + checkInvariants(input, plan, __LINE__); + // Sorting the result again (groups re-numbered by first appearance) + // changes nothing. + auto again = plan.expected; + for (auto& s : again) { + if (!s.empty()) s.group = s.group == 1 ? 0 : 1; + } + CHECK(planSort(again).ops.empty()); +} + +void testEmptySlotPlacement() { + auto const input = region({empty(), cobble(1), empty(), dirt(1), empty(), egg(1)}); + auto const plan = planSort(input); + CHECK(!plan.expected[0].empty() && !plan.expected[1].empty() && !plan.expected[2].empty()); + for (size_t i = 3; i < plan.expected.size(); ++i) CHECK(plan.expected[i].empty()); + checkInvariants(input, plan, __LINE__); +} + +void testDeterministic() { + auto const input = region({dirt(3), cobble(42), egg(4), sword(7), cobble(30), empty(), egg(13), sword(8)}); + auto const a = planSort(input); + auto const b = planSort(input); + CHECK(a.ops.size() == b.ops.size()); + for (size_t i = 0; i < a.ops.size() && i < b.ops.size(); ++i) { + CHECK( + a.ops[i].kind == b.ops[i].kind && a.ops[i].from == b.ops[i].from && a.ops[i].to == b.ops[i].to + && a.ops[i].count == b.ops[i].count + ); + } + for (size_t i = 0; i < a.expected.size(); ++i) CHECK(sameStack(a.expected[i], b.expected[i])); + checkInvariants(input, a, __LINE__); +} + +void testRepeatSortIsIdempotent() { + auto const input = region({dirt(3), cobble(42), egg(4), sword(7), cobble(30), empty(), egg(13), sword(8)}); + auto const first = planSort(input); + checkInvariants(input, first, __LINE__); + auto const second = planSort(first.expected); + CHECK(second.ops.empty()); + for (size_t i = 0; i < first.expected.size(); ++i) CHECK(sameStack(first.expected[i], second.expected[i])); +} + +void testOperationsStayInsideRegion() { + // The planner only ever sees the sortable region; excluded slots (hotbar, + // armor, offhand...) are simply not part of its input, so no operation + // can address them. Check every op index is within the region. + auto const input = region({dirt(3), cobble(42), egg(4), sword(7), cobble(30), egg(13)}, 9); + auto const plan = planSort(input); + for (auto const& op : plan.ops) { + CHECK(op.from >= 0 && op.from < 9); + CHECK(op.to >= 0 && op.to < 9); + } + checkInvariants(input, plan, __LINE__); +} + +void testFullRegionNoEmptySlots() { + // 27 occupied slots: arranging must still work with swaps only. + std::vector stacks; + for (int i = 0; i < 27; ++i) { + switch (i % 3) { + case 0: + stacks.push_back(sword(100 + i)); + break; + case 1: + stacks.push_back(dirt(64)); + break; + default: + stacks.push_back(egg(16)); + break; + } + } + auto const input = region(stacks); + auto const plan = planSort(input); + checkInvariants(input, plan, __LINE__); + CHECK(plan.expected[0].key.typeName == "minecraft:diamond_sword"); + CHECK(plan.expected[9].key.typeName == "minecraft:dirt"); + CHECK(plan.expected[18].key.typeName == "minecraft:egg"); +} + +void testCreativeIndexOrdersBeforeName() { + auto a = dirt(1); + a.key.creativeIndex = 50; + auto b = cobble(1); + b.key.creativeIndex = 10; + auto c = egg(1); // unknown index: last + auto const input = region({a, b, c}); + auto const plan = planSort(input); + CHECK(plan.expected[0].key.typeName == "minecraft:cobblestone"); + CHECK(plan.expected[1].key.typeName == "minecraft:dirt"); + CHECK(plan.expected[2].key.typeName == "minecraft:egg"); + checkInvariants(input, plan, __LINE__); +} + +SlotStack lockedStack(SlotStack s) { + s.locked = true; + return s; +} + +void testLockedSlotStaysAndOthersSortAround() { + // Slot 1 holds a locked oak-log-like stack; everything else must sort + // into the remaining slots without ever touching slot 1. + auto const input = region({egg(3), lockedStack(dirt(5)), cobble(42), empty(), cobble(10), sword(7)}); + auto const plan = planSort(input); + checkInvariants(input, plan, __LINE__); + CHECK(plan.expected[1].count == 5 && plan.expected[1].key.typeName == "minecraft:dirt" && plan.expected[1].fixed()); + // Movable slots in order: 0, 2, 3, 4, 5 -> cobble 52, egg 3, sword, empty... + CHECK(plan.expected[0].key.typeName == "minecraft:cobblestone" && plan.expected[0].count == 52); + CHECK(plan.expected[2].key.typeName == "minecraft:diamond_sword"); + CHECK(plan.expected[3].key.typeName == "minecraft:egg"); + CHECK(plan.expected[4].empty()); +} + +void testLockedSlotIsNeverMergedIntoOrOutOf() { + // A locked partial stack of the same kind as movable partials must keep + // its exact count: no consolidation may use it as donor or receiver. + auto const input = region({cobble(42), lockedStack(cobble(10)), cobble(30)}); + auto const plan = planSort(input); + checkInvariants(input, plan, __LINE__); + CHECK(plan.expected[1].count == 10 && plan.expected[1].fixed()); + // The two movable stacks still consolidate: 42 + 30 -> 64 + 8. + CHECK(plan.expected[0].count == 64 && plan.expected[2].count == 8); +} + +void testLockedSlotsIdempotent() { + auto const input = region({dirt(3), lockedStack(sword(9)), cobble(42), egg(4), lockedStack(cobble(7)), cobble(30)}); + auto const first = planSort(input); + checkInvariants(input, first, __LINE__); + auto const second = planSort(first.expected); + CHECK(second.ops.empty()); + for (size_t i = 0; i < first.expected.size(); ++i) CHECK(sameStack(first.expected[i], second.expected[i])); +} + +void testAllSlotsLockedIsNoOp() { + auto const input = region({lockedStack(dirt(3)), lockedStack(cobble(1))}, 2); + auto const plan = planSort(input); + CHECK(plan.ops.empty()); + checkInvariants(input, plan, __LINE__); +} + +void testLockedEmptySlotIsJustEmpty() { + // A lock is an item property; an empty slot cannot be fixed. + auto slot = SlotStack::emptySlot(); + slot.locked = true; + auto const input = region({slot, dirt(3)}, 2); + auto const plan = planSort(input); + CHECK(plan.expected[0].key.typeName == "minecraft:dirt"); + checkInvariants(input, plan, __LINE__); +} + +void testApplyOperationRejectsFixedSlots() { + auto slots = region({lockedStack(cobble(42)), cobble(3), empty()}, 3); + CHECK(!applyOperation(slots, Operation{OpKind::Move, 1, 0, 3})); + CHECK(!applyOperation(slots, Operation{OpKind::Move, 0, 2, 3})); + CHECK(!applyOperation(slots, Operation{OpKind::Swap, 0, 1, 0})); + CHECK(slots[0].count == 42 && slots[1].count == 3 && slots[2].empty()); +} + +void testApplyOperationRejectsInvalid() { + auto slots = region({cobble(42), dirt(3), empty()}, 3); + CHECK(!applyOperation(slots, Operation{OpKind::Move, 0, 1, 5})); // different groups + CHECK(!applyOperation(slots, Operation{OpKind::Move, 0, 2, 50})); // more than available + CHECK(!applyOperation(slots, Operation{OpKind::Move, 2, 0, 1})); // from empty + CHECK(!applyOperation(slots, Operation{OpKind::Swap, 0, 2, 0})); // swap with empty + CHECK(!applyOperation(slots, Operation{OpKind::Move, 0, 0, 1})); // same slot + CHECK(!applyOperation(slots, Operation{OpKind::Move, 0, 3, 1})); // out of range + CHECK(slots[0].count == 42 && slots[1].count == 3 && slots[2].empty()); +} + +} // namespace + +int main() { + testEmptyInventory(); + testAlreadySorted(); + testOneItem(); + testMultipleDifferentItems(); + testMerge42Plus10(); + testMerge42Plus30(); + testMergeSeparatedStacksPartialFirst(); + testMoreThanTwoPartialStacks(); + testManyPartialStacksMinimalCount(); + testAlreadyFullStacks(); + testFullPlusPartial(); + testMaxStackSize16(); + testMaxStackSize16NoOvershoot(); + testNonStackableItemsNeverMerge(); + testNonStackableEvenWhenGivenSameGroupByMistake(); + testLookAlikeButIncompatibleStacksStaySeparate(); + testLookAlikesWithEqualKeysStayContiguous(); + testEmptySlotPlacement(); + testDeterministic(); + testRepeatSortIsIdempotent(); + testOperationsStayInsideRegion(); + testFullRegionNoEmptySlots(); + testCreativeIndexOrdersBeforeName(); + testLockedSlotStaysAndOthersSortAround(); + testLockedSlotIsNeverMergedIntoOrOutOf(); + testLockedSlotsIdempotent(); + testAllSlotsLockedIsNoOp(); + testLockedEmptySlotIsJustEmpty(); + testApplyOperationRejectsFixedSlots(); + testApplyOperationRejectsInvalid(); + + if (gFailures == 0) { + std::printf("SortPlannerTest: all checks passed\n"); + } else { + std::printf("SortPlannerTest: %d check(s) failed\n", gFailures); + } + return (gFailures + runSortKeyTests()) == 0 ? 0 : 1; +} diff --git a/xmake.lua b/xmake.lua index 5a99bf8..bc610ee 100644 --- a/xmake.lua +++ b/xmake.lua @@ -10,7 +10,18 @@ option("target_type") set_values("client") option_end() -add_requires("levilamina 26.51.1", {configs = {target_type = get_config("target_type")}}) +-- Diagnostic build: raises the mod's logger to Debug and mirrors it, flushed +-- immediately, into mods/LaminaSort/trace.log. Off by default. +option("trace") + set_default(false) + set_showmenu(true) + set_description("Enable verbose runtime diagnostics") +option_end() + +-- The "v" form checks out the upstream git tag directly; the LeviMC xmake-repo +-- had not published a 26.51.3 version entry when this was written. Switch to +-- "levilamina 26.51.3" once it has one. +add_requires("levilamina v26.51.3", {configs = {target_type = get_config("target_type")}}) add_requires("levibuildscript") @@ -41,6 +52,9 @@ target("LaminaSort") set_toolchains("clang-cl") end add_packages("levilamina") + if has_config("trace") then + add_defines("LAMINASORT_TRACE") + end set_kind("shared") set_languages("c++20") set_symbols("debug") @@ -48,3 +62,15 @@ target("LaminaSort") add_files("src/**.cpp") add_includedirs("src") +-- Unit tests for the pure (game-independent) sort planner. Not built by +-- default: `xmake build LaminaSortTests && xmake run LaminaSortTests`. +target("LaminaSortTests") + set_kind("binary") + set_default(false) + set_languages("c++20") + add_includedirs("src") + add_files("src/mod/sort/**.cpp", "tests/**.cpp") + if is_plat("windows") then + add_cxflags("/utf-8", "/W4") + set_toolchains("clang-cl") + end